diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..1ca443da4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,24 @@ +* text=auto +* text eol=lf + +# Windows forced line-endings +/.idea/* text eol=crlf + +# Gradle wrapper +*.jar binary + +# Images +*.webp binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.gz binary +*.zip binary +*.7z binary +*.ttf binary +*.eot binary +*.woff binary +*.pyc binary +*.swp binary diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 2d08379f9..7d3c0e614 100755 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,10 +1,10 @@ -1. **Before reporting a new issue, take a look at the [FAQ](https://github.com/inorichi/tachiyomi/wiki/FAQ), the [changelog](https://github.com/inorichi/tachiyomi/releases) and the already opened [issues](https://github.com/inorichi/tachiyomi/issues).** +1. **Before reporting a new issue, take a look at the [FAQ](https://tachiyomi.org/help/faq/), the [changelog](https://github.com/inorichi/tachiyomi/releases) and the already opened [issues](https://github.com/inorichi/tachiyomi/issues).** 2. If you are unsure, ask here: [![Discord](https://img.shields.io/discord/349436576037732353.svg)](https://discord.gg/tachiyomi) 3. What is your type of issue? * [Catalogue request](#catalogue-requests) * [Bugs](#bugs) * [Feature requests](#feature-requests) - * [Translations](https://github.com/inorichi/tachiyomi/wiki/Translation) + * [Translations](https://tachiyomi.org/help/contribution/#translation) 4. After following 1. and 3. you can [open your issue](https://github.com/inorichi/tachiyomi/issues/new) *** @@ -29,5 +29,5 @@ DON'T: https://github.com/inorichi/tachiyomi/issues/75 # Feature requests -* Write a detailed issue, explaning what it should do or how. Avoid writing just "like X app does" +* Write a detailed issue, explaining what it should do or how. Avoid writing just "like X app does" * Include screenshot (if needed) diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index f6ea76ef5..1f887f589 100755 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,17 +1,26 @@ -**DO NOT OPEN ISSUES/REQUESTS RELATING TO EXTENSIONS/CATALOGUES IN THIS REPOSITORY. Open them at the following repository https://github.com/inorichi/tachiyomi-extensions/** +**PLEASE READ THIS** -**For all other requests Please fill out the form below and remove the first 3 lines of this template** +I acknowledge that: -**App version:** +- I have updated to the latest version of the app (stable is v0.9.2) +- I have updated all extensions +- If this is an issue with an extension, that I should be opening an issue in https://github.com/inorichi/tachiyomi-extensions -**Android version:** +**DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT** -**Issue/Request:** +--- -**Steps to reproduce (if applicable)** +### Device information +* Tachiyomi version: ? +* Android version: ? +* Device: ? - 1. - 2. - 3. +## Steps to reproduce +1. First step +2. Second step -**Other details:** +## Issue/Request +? + +## Other details +Additional details and attachments. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index ac0f44ea8..ee88bb2fa 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -3,12 +3,24 @@ name: "🐞 Bug report" about: Report a bug title: "[Bug] Write short description here" labels: "bug" +--- + +**PLEASE READ THIS** + +I acknowledge that: + +- I have updated to the latest version of the app (stable is v0.9.2) +- I have updated all extensions +- If this is an issue with an extension, that I should be opening an issue in https://github.com/inorichi/tachiyomi-extensions + +**DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT** --- ### Device information * Tachiyomi version: ? * Android version: ? +* Device: ? ## Steps to reproduce 1. First step diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index d3c307acf..823c3901f 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -3,8 +3,20 @@ name: "🌟 Feature request" about: Suggest a feature to improve Tachiyomi title: "[Feature Request] Write short description here" labels: "feature" +--- + +**PLEASE READ THIS** + +I acknowledge that: + +- I have updated to the latest version of the app (stable is v0.9.2) +- I have updated all extensions +- If this is an issue with an extension, that I should be opening an issue in https://github.com/inorichi/tachiyomi-extensions + +**DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT** --- + ### Why/User Benefit/User Problem (explain why this feature should be added) diff --git a/.github/ISSUE_TEMPLATE/source_issue.md b/.github/ISSUE_TEMPLATE/source_issue.md new file mode 100644 index 000000000..fc7078da4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/source_issue.md @@ -0,0 +1,8 @@ +--- +name: "Extension/source/catalogue issue" +about: "Do not open an issue here. See https://github.com/inorichi/tachiyomi-extensions" +title: "THIS ISSUE IS IN THE WRONG REPO; SEE https://github.com/inorichi/tachiyomi-extensions" +labels: "catalog" +--- + +DO NOT OPEN AN ISSUE IN THIS REPO. SEE https://github.com/inorichi/tachiyomi-extensions \ No newline at end of file diff --git a/.github/readme-images/screens.png b/.github/readme-images/screens.png index dfe857913..16d43a649 100644 Binary files a/.github/readme-images/screens.png and b/.github/readme-images/screens.png differ diff --git a/.github/workflows/issue_closer.yml b/.github/workflows/issue_closer.yml new file mode 100644 index 000000000..3978f672e --- /dev/null +++ b/.github/workflows/issue_closer.yml @@ -0,0 +1,13 @@ +name: Issue closer +on: [issues] +jobs: + autoclose: + runs-on: ubuntu-latest + steps: + - name: Autoclose issue + uses: arkon/issue-closer-action@v1.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + issue-close-message: "@${issue.user.login} this issue was automatically closed because it was not filled in correctly or the acknowledgment section was not removed." + issue-title-pattern: ".*THIS ISSUE IS IN THE WRONG REPO.*" + issue-body-pattern: ".*DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT.*" diff --git a/.gitignore b/.gitignore index 5dca3c5bc..e305aeb86 100755 --- a/.gitignore +++ b/.gitignore @@ -2,14 +2,21 @@ /local.properties /.idea/workspace.xml .DS_Store -/build .idea/ *iml *.iml -*/build /mainframer /.mainframer + +# Built files +*/build +/build *.apk +app/**/output.json + +# Hebrew assets are copied on build +app/src/main/res/values-iw/ + TODO.md CHANGELOG.md /captures diff --git a/.travis.yml b/.travis.yml index edfc5a7c0..21b55ea28 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,59 +1,74 @@ dist: trusty language: android + android: components: - - build-tools-29.0.2 - - android-28 - - extra-android-m2repository - - extra-google-m2repository - - extra-android-support - - extra-google-google_play_services + - tools + - platform-tools + - build-tools-29.0.3 + - android-29 + - extra-android-m2repository + - extra-google-m2repository + - extra-android-support + - extra-google-google_play_services + licenses: - - android-sdk-license-.+ + - 'android-sdk-license-.+' + - 'android-sdk-preview-license-.+' + before_install: -- yes | sdkmanager "platforms;android-28" # workaround for accepting the license -- if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then + - yes | sdkmanager "platforms;android-29" # workaround for accepting the license + - if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then openssl aes-256-cbc -K $encrypted_e56be693d4fd_key -iv $encrypted_e56be693d4fd_iv -in "$PWD/.travis/secrets.tar.enc" -out secrets.tar -d; tar xf secrets.tar; mv debug.keystore "$HOME/.android"; - fi -- git clone https://github.com/urho3d/android-ndk.git $HOME/android-ndk-root -- export ANDROID_NDK_HOME=$HOME/android-ndk-root -- mkdir "$ANDROID_HOME/licenses" || true -- echo -e "\n8933bad161af4178b1185d1a37fbf41ea5269c55" > "$ANDROID_HOME/licenses/android-sdk-license" -- echo -e "\n84831b9409646a918e30573bab4c9c91346d8abd" > "$ANDROID_HOME/licenses/android-sdk-preview-license" + fi + - mkdir "$ANDROID_HOME/licenses" || true + - echo -e "\n24333f8a63b6825ea9c5514f83c2829b004d1fee" > "$ANDROID_HOME/licenses/android-sdk-license" + - echo -e "\n84831b9409646a918e30573bab4c9c91346d8abd" > "$ANDROID_HOME/licenses/android-sdk-preview-license" + +install: + - echo y | sdkmanager "ndk-bundle" + +before_script: + - export ANDROID_NDK_HOME=$ANDROID_HOME/ndk-bundle + script: ".travis/build.sh" + before_cache: -- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock -- rm -fr $HOME/.gradle/caches/*/plugin-resolution/ + - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock + - rm -fr $HOME/.gradle/caches/*/plugin-resolution/ + cache: directories: - - "$HOME/.gradle/caches/" - - "$HOME/.gradle/wrapper/" - - "$HOME/.android/build-cache" + - "$HOME/.gradle/caches/" + - "$HOME/.gradle/wrapper/" + - "$HOME/.android/build-cache" + deploy: -- provider: releases - api_key: - secure: qmS9SyMq8xPDqaY83rvAFyZcvic24lGBj3MFt22RhVJzIXAAN/vqL1R70PnNiCF7CE+R7PaDlBpwjxDMBiuh0QQNc1oX6cgepUbro4/Nt7NFFfCvKXaFdR1cSgYouhuHmy0SS0/alrcfhQ2bPwcm1/vAOiSa8Wu7hsXhCcxbFyEbXZVD11QZmiffEM0py+OeuqOFo2JxZaGRu2z04E/u5TWep1ZEuhFRCC87PGgFqABgg6jYYebQOUZADG/0G8581HTGU0mdwueYsiA35ncRzpV2V8DajEEBd5wOe5d8SyMtE+6Qs5PD9KcXAqGGe4QRmrJMX5EcLQaLZf/Qd5s9SFZVHb1aJIw/y05w4L5dlVpsjx5WuUAYAVg7Ol5UawofFo/hYkYCNmfub67wJQdHSIxPif7V6YeON6RQQMpc5GBYY9eA6ZxhrdA2m7eyoOT3jcbdaVJwC0jMGhn26hkgJfTo1LfAUs85Cs3BrK8w6Poqc/Jb+4Y0NhdGIKgO5tS3vY54cTJVVrQTq4/XmME4ZnzOX3HaOqzfyt/6M4gEQMvaeFksxwoFhocV7wfaCq9ps/Kdq2dl4KwoqRV2WqVaauqzCP4XPSlVLaJqztsw0wboupcaZepWJ2a/6j9IrKo1pEnyeHF5y+k0SUAxL0X8iKZ0uPxsgoVrlNtqXJWNGvA= - file: tachiyomi-v*.apk - file_glob: true - skip_cleanup: true - on: - tags: true - repo: inorichi/tachiyomi -- provider: script - script: ".travis/deploy.sh" - skip_cleanup: true - on: - branch: master - condition: "-z $TRAVIS_TAG" - repo: inorichi/tachiyomi + - provider: releases + api_key: + secure: qmS9SyMq8xPDqaY83rvAFyZcvic24lGBj3MFt22RhVJzIXAAN/vqL1R70PnNiCF7CE+R7PaDlBpwjxDMBiuh0QQNc1oX6cgepUbro4/Nt7NFFfCvKXaFdR1cSgYouhuHmy0SS0/alrcfhQ2bPwcm1/vAOiSa8Wu7hsXhCcxbFyEbXZVD11QZmiffEM0py+OeuqOFo2JxZaGRu2z04E/u5TWep1ZEuhFRCC87PGgFqABgg6jYYebQOUZADG/0G8581HTGU0mdwueYsiA35ncRzpV2V8DajEEBd5wOe5d8SyMtE+6Qs5PD9KcXAqGGe4QRmrJMX5EcLQaLZf/Qd5s9SFZVHb1aJIw/y05w4L5dlVpsjx5WuUAYAVg7Ol5UawofFo/hYkYCNmfub67wJQdHSIxPif7V6YeON6RQQMpc5GBYY9eA6ZxhrdA2m7eyoOT3jcbdaVJwC0jMGhn26hkgJfTo1LfAUs85Cs3BrK8w6Poqc/Jb+4Y0NhdGIKgO5tS3vY54cTJVVrQTq4/XmME4ZnzOX3HaOqzfyt/6M4gEQMvaeFksxwoFhocV7wfaCq9ps/Kdq2dl4KwoqRV2WqVaauqzCP4XPSlVLaJqztsw0wboupcaZepWJ2a/6j9IrKo1pEnyeHF5y+k0SUAxL0X8iKZ0uPxsgoVrlNtqXJWNGvA= + file: tachiyomi-v*.apk + file_glob: true + skip_cleanup: true + on: + tags: true + repo: inorichi/tachiyomi + - provider: script + script: ".travis/deploy.sh" + skip_cleanup: true + on: + branch: master + condition: "-z $TRAVIS_TAG" + repo: inorichi/tachiyomi + env: global: - - secure: Ita1+tzo7P5IC2yqU3KgRcXt+5DTpP0103Hx/ECYi42/7rLt+TC7PMjl2yH3Z189+tGwLq0Ol1KJ2Z5Rn3q7EaQgD0+WRkH/ijtrjKoVh7dyItIBp7yowZpA0TJHQ4EZpGSxZakKbIP4di8XMxJ2+5VzEivYUt04LCUpzugemL6b6XOfUmOZykVxV2UDAlPPggklITYBXkHUa0mwJhjS1aPPeeR3PhVXomkqfuOJOKejPXXXJope9fhAnmopHA7ISfjIrTuwDVQJqNSuco+O9kQShmlu0C8pob1vFGPEDvafaDS8SZ9A6gKT1ZfgSUqVmvDbx0WLX8XugBLrQedtZv63esOa1WUyGhgFVpeJjexlszXlhyfP1gH5QbzRr+EiSaagCyjf9II2veLAtU5cFY+nj6KBdKQsazIMRHf8SAQlWASyJYMED/N9RnUFxSf1rnLGqiY2ezjycx4ieFj7vhlbTgyao1GHjjR9cwNuntdMYWhY8+Vc7Fctmzm46xOyyz9oJGdyim76Y4w4MZvQNKeZOBAjdEgX6cXBk15scoM2Vj9ENox+MKZLaKRawXg2U1ujK+bWAQkXiVvPriv05/JtYsNUft8qAsm+69vtohDsUW7Wu0bBIKDL+v0W30ty1PpyNehBB2OquUE7fp53gitOmYl7TyuxktkMY8PXKKU= - - secure: NABCfigMUVM/9TLALYBpQLp/p3rG6MbH5y34/oqCSej/oh2u0nyhFSi8veS0lFpDIcv0TZvxHJXoSA0zeZijb1fUU8jYVNT2azuPWE6Gu7sf0TfBeCvulqbgLMoaA6JuWbEbZwHcxpKHg4vLSMjNk+ZP4v2dffI6A620fxLltxxhTpsYkYYsfKG857CpQtdgN/HqcOaxyvzXFmWWyVWHala1uMcMeXZCwgnlVAqau9o0bsU092txSmHqoesHoAinidSCTCmTlEqp/1AFaYQTbxmnfNC1yLgzxWAlJcS3NWzNo3ellMvKjsiIGn3JJpAjTGcyf3yPsvhs1cY3MZbmJYVyKH5HbnkA5ms6mx0DDJ2UOs5H2dmED82m14+hu62Xb8XN8zAdq+bySNSwgsGzvr1PT74pT4BW1T+D7L1xvUe6k1enZ38GIMJbJPhBybRQazhjKPmXRB30Thxoqe5VqU8UeiXHAEps7JYAWUR1PLZvEYQb6MWurmTxs9be/OTwrUT1LDiqeJZg6XkDGgQwuR2YBaQJHJD17Piq6q1BUX8abhK6wzAAYVqyGvpmUCmQCtHZgenE6ulwcKChzBv4n97OjE21LGWnbNF5ViUhfAbGcKOVufd1chZsfbkJ7a3tHYCfLnxHUIhKvHk26f5Em8h68D0wQkPnkcVVkfh7XpI= - - secure: C93UADV5aR0zhLCLwR6tCyz+fwUYslZbhjBl3PHQp+0boIGS/Be2UQTFHp/NB9mQmhWqbwqHoAVFENZFytV04ePgOuNtMFcjAIfnzm19Am7iRAMFixD45pF/CuYYfLupElkAcSequtAzN0g4G0sQ5KR1hibaDIoz9kfA2YcUAMaZ4T5bhCr8os/xA2nOlmvPDWsRWCFBYkSpnmbsSsgYAhulA/V5bSNAWnp9LPw3CBLibW3WsVP4wuhZAkXznKwn/mHT31kfQlpMH3qNhXpsN9huUkZ/k8QWeakcHJKugung0Z2T1StK8rlI8OrJstVcwueHTa2ses4f5VbhWog/Z8HDkdll9W9RM/QqXjNDtOVBt/SPuhCp4k2rvJixFUxzvSqgSWQvQnbHwjWxIUVVyHtnb0/zc/S9ONZG14TOwB/+Lkgacb85PNszurZ2f3mH0O6slIh1mH+5d9J4+L976ot4nTPlK1OtothagVyKGOrn9HycrPk/MjftIJuElHzo7NEJd/wRPqIb5y12iZN1RSPriU+itg1uSAVP891/o3peJyuqY9WSB7dYwgDJos6dDvbr19emtdyxkQR+eAb5duyH6s4R58wh1kJ1d4zu0X6uSnF4AIc+6teKkN24rSXcqB/hrcojS49jgLy5P0/CVsUbYZPI/tx8E/IJfr8m36E= - - secure: mawwBvllvESc/mp+JHvncq1iUhiC7nyokPgXmOehffc0K3byMLs2L25HjNsU6EnXG9Lcae1cfP8S9bWLquU2C3kpAkLBUpjEbdx7K0654uvs7Rrvb5hcTRHwjzaEVmVaBFX4ROcjUhBYny/Wjj/YENCkSWpkfcMd1esFbVsO+fOLyaAPvrb6auKY7H+pUSqlEwaEnrkYeBBZIHa7KqwL4g5DHbq6K368tjmval/wBzwMB0V8V3dt/ik8RMVDtKPrik4Bu0V9UmXZUIo/a06ii/CM82ekFRh3eUb0DKkdkmYbdH6MBMoLTfQtMa6A4luXaA0oycAnTX3OGB5MWIjK39KhWRavh6ybSIt4aHKoolxzH8Zgmk7xMhFSot/laX5q5IzjZu5KU6F2SmdV0kcQugM8oAjANFySetPvY1q7nZ8pM+NO1xKS/mH0w4vChhdJFD1mw7aCoh8FdeUf0Eym2+pp5Q9uAisWMmNn5XN8/fL5q6PzAxkXmkedfrr1N61FmIL6EKx8qiWpOUNlRRTIMJ4GMhCyckCF6cNxDkBItp52c+Hmkbn+ZEInEyX6gpjYVm3xyEi0Z5kLCi/fMX2nBNczc5BuGLzzmJnITv4ovpeYn2/vPvHbaPgPC4LqDK3AjlpVadMZk/M5Egn+hWY7Mni57CmpZD+SpxUbbsItI0c= - - secure: PJPDkUg1zc57brxUvNpSh+Q3ZEaGpBqZzwDavqslkn0WmjBTLrE6/OG7TFHKNmO+P56qFl+pMEKqThxqR3+4bWEeEx8ykkixDVzxNJMmws+7A7ImJ75iQyB6giMW/4tykVMMHgIPNAdcnI8VOWn0LGHnpFWUd70yoyAGX8s6cspHCKgcuWMA3GS410KJfHpyd0B9/QS7ZyWzSETW7zSPyLPa81SBO95EhOF3TOGZYLt/mBhdtU3YGFs4k9fZ8jDDcm9XmBfqVlUhb8HiZcxJiZDdRvxODERfNnwc47uaJk6+kxGDzIW2uAxrMXXVKkG04GeMOokXoR9kW1Hl2JmoyySLKLZmB7I/XEtVWdzZw16mWi+4zmhjLhfB0phSW+/5I+0VtZZ6jO031J5FL/JqVrcq1ws/aw4QlaOdPUco/x2u4LNHyYYgOi5arD9xSyu6IRy0jCC4Xa1zuqM5adGJX+rZyVfKZ0TxOW661HTxlo8COtkB2i0WR2deZGVN75ooCAEO8DauQoUcFH1OelahmPtzVs1/6ZczuxGdp9ED7ZQq9NHEOsOdUGCj/D79Dm1hWFQsIsslnnGYWitAycNCgEwmlt2Q6fbrv2CJrmLqZ9a9r3AhzxoHn9Qx1GyuyfhZJzm/6Ff2kcOjma2kcz13KUwTxdW+2G5dDCotK3f7aiI= - - secure: FIIZfEEYfjNMKODs33Czh603CYVn6LRrzpFNIiPHYTb8iQWv9qAYhsg4FpHfOjDikokTwb5X/h8G7AX93Z0xKyyDi75ACT11oPeTNTArDdcmdDVlOYBvYHc2Ci7pMW5r8LGejB7Y3mWM8uKyA3oKvneEFutB65vO3JVZvFWrm03Lmqqe7+mA4qNqNqTbN7R7fmk5b7zt7A3DHvDu0JPTbSSUwpso/p2I5WJYjrf71I7YMQwIFLoMfplC1onVA3EFS3lZsF65zE+xVRy34AKa41iZAMbhVDyqUHEnx6L0dwEdn2Z5XLlK0ov1+qLTLlQsBE4Knre6TNkWMfktk7MKA+ch8RYxvEYLODhQkIrOkLSNWhZPhdaT+xD4fr0RCKSHo6uWRC4aofsJx8wSqb8ZL4j2zopUp9VisMOI202UEnvFDBtOkVGJSxxYbFjifIB7NCJBn788w+3k+k4IbOg537VdyoK2PMBR8/TDdjImWhWHY1i7+345ejwmzHL7ZPfb6GTNnQTWkajT77/n6Yk41twR5vvegOSTKuuO++WN/pUks4PGqtcQe9fnSfx2OcOq1ofLiG+JDorJ7z8kHSG13wHLq+QYMDayQbyJEYpDzmn/w3Ou1s2o0a7A41+cIkRzAgH9y3v4lgjp9GcMP2S74ZPA7OecWbFSexM7tL/dYxY= - - secure: DKCGc4E9PKeTX68r9pbbNg5qITsN0bApQ1m0x8xdEoi8GLRKVMYNn6ahoAxvy1YsBXC9Zlt5++gLmUV1I1JyDMyJXMr/lZrp4oarW0xWpTBmn3HzOph/K2W4i/fTGgMFieumPEbQIFOnU3JSjK6UJB8qVGEXD2OqS7A//EdrGDbAYVDL3ZTKE6JUlTNHgaKaNHhn+Dq4aBLTSYPwlLyqo+WNBVUUCKCHOq62ULF8MpX5YGaPFNxKYzircV7HpF1hCbV31dmpkeYT9xztra5V0SIBM27jAcQqGmtHH2mhx1sLu+gjhFJbbtY6cggA9EedzYYLDx/NPmgfyuOJfyVbSwTF3vhDUYfskqc1THWpwOSKO0Ry+8/xYb9crxg+FSwuI5hnfkIFk9woBvRGBhjto3/1buMNY9dSFiWtEbN6Let8e747l0wIGJCpJxSeh7vn7F1mWjixhf9GX1+V9BrUvGTd3XJDNb9cVnafYa1RTj8BLteA4HBza7Z9R3dvG4YWp16L/94UuaTzgAQfERLTZGopQth/hsaVTlYesJmJLF70lGM+W83y3YuNkSaX1zQ5FAIvp7oH0O16t7ISm6GprUFwN2Uox7AAbPZdWHxJbly+D+yCFNcqS3Bz9mV3YCLo690Sy1ePNHr+nCseVfBMo7OYyavSS/EjPWfEy65Wq04= + - secure: Ita1+tzo7P5IC2yqU3KgRcXt+5DTpP0103Hx/ECYi42/7rLt+TC7PMjl2yH3Z189+tGwLq0Ol1KJ2Z5Rn3q7EaQgD0+WRkH/ijtrjKoVh7dyItIBp7yowZpA0TJHQ4EZpGSxZakKbIP4di8XMxJ2+5VzEivYUt04LCUpzugemL6b6XOfUmOZykVxV2UDAlPPggklITYBXkHUa0mwJhjS1aPPeeR3PhVXomkqfuOJOKejPXXXJope9fhAnmopHA7ISfjIrTuwDVQJqNSuco+O9kQShmlu0C8pob1vFGPEDvafaDS8SZ9A6gKT1ZfgSUqVmvDbx0WLX8XugBLrQedtZv63esOa1WUyGhgFVpeJjexlszXlhyfP1gH5QbzRr+EiSaagCyjf9II2veLAtU5cFY+nj6KBdKQsazIMRHf8SAQlWASyJYMED/N9RnUFxSf1rnLGqiY2ezjycx4ieFj7vhlbTgyao1GHjjR9cwNuntdMYWhY8+Vc7Fctmzm46xOyyz9oJGdyim76Y4w4MZvQNKeZOBAjdEgX6cXBk15scoM2Vj9ENox+MKZLaKRawXg2U1ujK+bWAQkXiVvPriv05/JtYsNUft8qAsm+69vtohDsUW7Wu0bBIKDL+v0W30ty1PpyNehBB2OquUE7fp53gitOmYl7TyuxktkMY8PXKKU= + - secure: NABCfigMUVM/9TLALYBpQLp/p3rG6MbH5y34/oqCSej/oh2u0nyhFSi8veS0lFpDIcv0TZvxHJXoSA0zeZijb1fUU8jYVNT2azuPWE6Gu7sf0TfBeCvulqbgLMoaA6JuWbEbZwHcxpKHg4vLSMjNk+ZP4v2dffI6A620fxLltxxhTpsYkYYsfKG857CpQtdgN/HqcOaxyvzXFmWWyVWHala1uMcMeXZCwgnlVAqau9o0bsU092txSmHqoesHoAinidSCTCmTlEqp/1AFaYQTbxmnfNC1yLgzxWAlJcS3NWzNo3ellMvKjsiIGn3JJpAjTGcyf3yPsvhs1cY3MZbmJYVyKH5HbnkA5ms6mx0DDJ2UOs5H2dmED82m14+hu62Xb8XN8zAdq+bySNSwgsGzvr1PT74pT4BW1T+D7L1xvUe6k1enZ38GIMJbJPhBybRQazhjKPmXRB30Thxoqe5VqU8UeiXHAEps7JYAWUR1PLZvEYQb6MWurmTxs9be/OTwrUT1LDiqeJZg6XkDGgQwuR2YBaQJHJD17Piq6q1BUX8abhK6wzAAYVqyGvpmUCmQCtHZgenE6ulwcKChzBv4n97OjE21LGWnbNF5ViUhfAbGcKOVufd1chZsfbkJ7a3tHYCfLnxHUIhKvHk26f5Em8h68D0wQkPnkcVVkfh7XpI= + - secure: C93UADV5aR0zhLCLwR6tCyz+fwUYslZbhjBl3PHQp+0boIGS/Be2UQTFHp/NB9mQmhWqbwqHoAVFENZFytV04ePgOuNtMFcjAIfnzm19Am7iRAMFixD45pF/CuYYfLupElkAcSequtAzN0g4G0sQ5KR1hibaDIoz9kfA2YcUAMaZ4T5bhCr8os/xA2nOlmvPDWsRWCFBYkSpnmbsSsgYAhulA/V5bSNAWnp9LPw3CBLibW3WsVP4wuhZAkXznKwn/mHT31kfQlpMH3qNhXpsN9huUkZ/k8QWeakcHJKugung0Z2T1StK8rlI8OrJstVcwueHTa2ses4f5VbhWog/Z8HDkdll9W9RM/QqXjNDtOVBt/SPuhCp4k2rvJixFUxzvSqgSWQvQnbHwjWxIUVVyHtnb0/zc/S9ONZG14TOwB/+Lkgacb85PNszurZ2f3mH0O6slIh1mH+5d9J4+L976ot4nTPlK1OtothagVyKGOrn9HycrPk/MjftIJuElHzo7NEJd/wRPqIb5y12iZN1RSPriU+itg1uSAVP891/o3peJyuqY9WSB7dYwgDJos6dDvbr19emtdyxkQR+eAb5duyH6s4R58wh1kJ1d4zu0X6uSnF4AIc+6teKkN24rSXcqB/hrcojS49jgLy5P0/CVsUbYZPI/tx8E/IJfr8m36E= + - secure: mawwBvllvESc/mp+JHvncq1iUhiC7nyokPgXmOehffc0K3byMLs2L25HjNsU6EnXG9Lcae1cfP8S9bWLquU2C3kpAkLBUpjEbdx7K0654uvs7Rrvb5hcTRHwjzaEVmVaBFX4ROcjUhBYny/Wjj/YENCkSWpkfcMd1esFbVsO+fOLyaAPvrb6auKY7H+pUSqlEwaEnrkYeBBZIHa7KqwL4g5DHbq6K368tjmval/wBzwMB0V8V3dt/ik8RMVDtKPrik4Bu0V9UmXZUIo/a06ii/CM82ekFRh3eUb0DKkdkmYbdH6MBMoLTfQtMa6A4luXaA0oycAnTX3OGB5MWIjK39KhWRavh6ybSIt4aHKoolxzH8Zgmk7xMhFSot/laX5q5IzjZu5KU6F2SmdV0kcQugM8oAjANFySetPvY1q7nZ8pM+NO1xKS/mH0w4vChhdJFD1mw7aCoh8FdeUf0Eym2+pp5Q9uAisWMmNn5XN8/fL5q6PzAxkXmkedfrr1N61FmIL6EKx8qiWpOUNlRRTIMJ4GMhCyckCF6cNxDkBItp52c+Hmkbn+ZEInEyX6gpjYVm3xyEi0Z5kLCi/fMX2nBNczc5BuGLzzmJnITv4ovpeYn2/vPvHbaPgPC4LqDK3AjlpVadMZk/M5Egn+hWY7Mni57CmpZD+SpxUbbsItI0c= + - secure: PJPDkUg1zc57brxUvNpSh+Q3ZEaGpBqZzwDavqslkn0WmjBTLrE6/OG7TFHKNmO+P56qFl+pMEKqThxqR3+4bWEeEx8ykkixDVzxNJMmws+7A7ImJ75iQyB6giMW/4tykVMMHgIPNAdcnI8VOWn0LGHnpFWUd70yoyAGX8s6cspHCKgcuWMA3GS410KJfHpyd0B9/QS7ZyWzSETW7zSPyLPa81SBO95EhOF3TOGZYLt/mBhdtU3YGFs4k9fZ8jDDcm9XmBfqVlUhb8HiZcxJiZDdRvxODERfNnwc47uaJk6+kxGDzIW2uAxrMXXVKkG04GeMOokXoR9kW1Hl2JmoyySLKLZmB7I/XEtVWdzZw16mWi+4zmhjLhfB0phSW+/5I+0VtZZ6jO031J5FL/JqVrcq1ws/aw4QlaOdPUco/x2u4LNHyYYgOi5arD9xSyu6IRy0jCC4Xa1zuqM5adGJX+rZyVfKZ0TxOW661HTxlo8COtkB2i0WR2deZGVN75ooCAEO8DauQoUcFH1OelahmPtzVs1/6ZczuxGdp9ED7ZQq9NHEOsOdUGCj/D79Dm1hWFQsIsslnnGYWitAycNCgEwmlt2Q6fbrv2CJrmLqZ9a9r3AhzxoHn9Qx1GyuyfhZJzm/6Ff2kcOjma2kcz13KUwTxdW+2G5dDCotK3f7aiI= + - secure: FIIZfEEYfjNMKODs33Czh603CYVn6LRrzpFNIiPHYTb8iQWv9qAYhsg4FpHfOjDikokTwb5X/h8G7AX93Z0xKyyDi75ACT11oPeTNTArDdcmdDVlOYBvYHc2Ci7pMW5r8LGejB7Y3mWM8uKyA3oKvneEFutB65vO3JVZvFWrm03Lmqqe7+mA4qNqNqTbN7R7fmk5b7zt7A3DHvDu0JPTbSSUwpso/p2I5WJYjrf71I7YMQwIFLoMfplC1onVA3EFS3lZsF65zE+xVRy34AKa41iZAMbhVDyqUHEnx6L0dwEdn2Z5XLlK0ov1+qLTLlQsBE4Knre6TNkWMfktk7MKA+ch8RYxvEYLODhQkIrOkLSNWhZPhdaT+xD4fr0RCKSHo6uWRC4aofsJx8wSqb8ZL4j2zopUp9VisMOI202UEnvFDBtOkVGJSxxYbFjifIB7NCJBn788w+3k+k4IbOg537VdyoK2PMBR8/TDdjImWhWHY1i7+345ejwmzHL7ZPfb6GTNnQTWkajT77/n6Yk41twR5vvegOSTKuuO++WN/pUks4PGqtcQe9fnSfx2OcOq1ofLiG+JDorJ7z8kHSG13wHLq+QYMDayQbyJEYpDzmn/w3Ou1s2o0a7A41+cIkRzAgH9y3v4lgjp9GcMP2S74ZPA7OecWbFSexM7tL/dYxY= + - secure: DKCGc4E9PKeTX68r9pbbNg5qITsN0bApQ1m0x8xdEoi8GLRKVMYNn6ahoAxvy1YsBXC9Zlt5++gLmUV1I1JyDMyJXMr/lZrp4oarW0xWpTBmn3HzOph/K2W4i/fTGgMFieumPEbQIFOnU3JSjK6UJB8qVGEXD2OqS7A//EdrGDbAYVDL3ZTKE6JUlTNHgaKaNHhn+Dq4aBLTSYPwlLyqo+WNBVUUCKCHOq62ULF8MpX5YGaPFNxKYzircV7HpF1hCbV31dmpkeYT9xztra5V0SIBM27jAcQqGmtHH2mhx1sLu+gjhFJbbtY6cggA9EedzYYLDx/NPmgfyuOJfyVbSwTF3vhDUYfskqc1THWpwOSKO0Ry+8/xYb9crxg+FSwuI5hnfkIFk9woBvRGBhjto3/1buMNY9dSFiWtEbN6Let8e747l0wIGJCpJxSeh7vn7F1mWjixhf9GX1+V9BrUvGTd3XJDNb9cVnafYa1RTj8BLteA4HBza7Z9R3dvG4YWp16L/94UuaTzgAQfERLTZGopQth/hsaVTlYesJmJLF70lGM+W83y3YuNkSaX1zQ5FAIvp7oH0O16t7ISm6GprUFwN2Uox7AAbPZdWHxJbly+D+yCFNcqS3Bz9mV3YCLo690Sy1ePNHr+nCseVfBMo7OYyavSS/EjPWfEy65Wq04= diff --git a/README.md b/README.md index 4737fba4d..4d8772901 100644 --- a/README.md +++ b/README.md @@ -1,3 +1 @@ -I dont want to maintain a readme - -#YEET +I havent started a readme \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 0a3ed5e33..bed216542 100755 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,6 +2,7 @@ import java.text.SimpleDateFormat apply plugin: 'com.android.application' +apply plugin: 'com.google.android.gms.oss-licenses-plugin' apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' apply plugin: 'kotlin-kapt' @@ -32,25 +33,23 @@ ext { } android { - compileSdkVersion 28 - buildToolsVersion '29.0.2' + compileSdkVersion 29 + buildToolsVersion '29.0.3' publishNonDefault true defaultConfig { - applicationId "eu.kanade.tachiyomi.az" - minSdkVersion 16 - targetSdkVersion 28 + applicationId "eu.kanade.tachiyomi.sy" + minSdkVersion 21 + targetSdkVersion 29 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - versionCode 8405 - versionName "v8.4.5-AZ" + versionCode 1 + versionName "v0.9.2.0" buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\"" buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\"" buildConfigField "String", "BUILD_TIME", "\"${getBuildTime()}\"" buildConfigField "boolean", "INCLUDE_UPDATER", "true" - vectorDrawables.useSupportLibrary = true - multiDexEnabled true ndk { @@ -58,6 +57,10 @@ android { } } + viewBinding { + enabled = true + } + buildTypes { debug { versionNameSuffix "-${getCommitCount()}" @@ -72,8 +75,8 @@ android { proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } release { -// minifyEnabled true -// shrinkResources true + minifyEnabled true + shrinkResources true zipAlignEnabled true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } @@ -127,6 +130,10 @@ android { } } +androidExtensions { + experimental = true +} + dependencies { // Modified dependencies @@ -134,41 +141,46 @@ dependencies { implementation 'com.github.inorichi:junrar-android:634c1f5' // Android support library - implementation 'androidx.legacy:legacy-support-v4:1.0.0' implementation 'androidx.appcompat:appcompat:1.1.0' implementation 'androidx.cardview:cardview:1.0.0' - implementation 'com.google.android.material:material:1.0.0' + implementation 'androidx.constraintlayout:constraintlayout:1.1.3' implementation 'androidx.recyclerview:recyclerview:1.1.0' - implementation 'androidx.preference:preference:1.1.0' + implementation 'androidx.preference:preference:1.1.1' implementation 'androidx.annotation:annotation:1.1.0' implementation 'androidx.browser:browser:1.2.0' - - implementation 'androidx.constraintlayout:constraintlayout:1.1.3' - implementation 'androidx.multidex:multidex:2.0.1' + implementation 'androidx.biometric:biometric:1.0.1' - // DO NOT UPGRADE TO 17.0, IT REQUIRES ANDROIDX - standardImplementation 'com.google.firebase:firebase-core:17.2.1' + final lifecycle_version = '2.2.0' + implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version" + implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version" + implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" + + // UI library + implementation 'com.google.android.material:material:1.1.0' + + standardImplementation 'com.google.firebase:firebase-core:17.4.0' // ReactiveX implementation 'io.reactivex:rxandroid:1.2.1' implementation 'io.reactivex:rxjava:1.3.8' implementation 'com.jakewharton.rxrelay:rxrelay:1.2.0' - implementation 'com.f2prateek.rx.preferences:rx-preferences:1.0.2' implementation 'com.github.pwittchen:reactivenetwork:0.13.0' // Network client - implementation "com.squareup.okhttp3:okhttp:4.2.1" // DO NOT UPGRADE TO 3.13.X+, it requires minSdk 21 - implementation 'com.squareup.okio:okio:2.4.0' // I think we can do 2.x, okhttp is ok with it but is there any other deps that need 1.x? + final okhttp_version = '4.5.0' + implementation "com.squareup.okhttp3:okhttp:$okhttp_version" + implementation "com.squareup.okhttp3:logging-interceptor:$okhttp_version" + implementation 'com.squareup.okio:okio:2.6.0' // REST - final retrofit_version = '2.6.2' + final retrofit_version = '2.8.1' implementation "com.squareup.retrofit2:retrofit:$retrofit_version" implementation "com.squareup.retrofit2:converter-gson:$retrofit_version" implementation "com.squareup.retrofit2:adapter-rxjava:$retrofit_version" // JSON - implementation 'com.google.code.gson:gson:2.8.5' + implementation 'com.google.code.gson:gson:2.8.6' implementation 'com.github.salomonbrys.kotson:kotson:2.5.0' // JavaScript engine @@ -179,12 +191,12 @@ dependencies { implementation 'com.github.inorichi:unifile:e9ee588' // HTML parser - implementation 'org.jsoup:jsoup:1.12.1' + implementation 'org.jsoup:jsoup:1.13.1' // Job scheduling - implementation 'com.evernote:android-job:1.2.5' - // DO NOT UPGRADE TO 17.0, IT REQUIRES ANDROIDX - implementation 'com.google.android.gms:play-services-gcm:17.0.0' + final work_version = '2.3.4' + implementation "androidx.work:work-runtime:$work_version" + implementation "androidx.work:work-runtime-ktx:$work_version" // [EXH] Android 7 SSL Workaround implementation 'com.google.android.gms:play-services-safetynet:17.0.0' @@ -193,13 +205,17 @@ dependencies { implementation 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0' // Database - implementation 'androidx.sqlite:sqlite:2.0.1' + implementation 'androidx.sqlite:sqlite:2.1.0' implementation 'com.github.inorichi.storio:storio-common:8be19de@aar' implementation 'com.github.inorichi.storio:storio-sqlite:8be19de@aar' - implementation 'io.requery:sqlite-android:3.25.2' + implementation 'io.requery:sqlite-android:3.31.0' + + // Preferences + implementation 'com.f2prateek.rx.preferences:rx-preferences:1.0.2' + implementation 'com.github.tfcporciuncula:flow-preferences:1.1.1' // Model View Presenter - final nucleus_version = '3.0.1' + final nucleus_version = '6.0.0' implementation "info.android15.nucleus:nucleus:$nucleus_version" implementation "info.android15.nucleus:nucleus-support-v7:$nucleus_version" @@ -212,47 +228,51 @@ dependencies { implementation "com.github.bumptech.glide:okhttp3-integration:$glide_version" kapt "com.github.bumptech.glide:compiler:$glide_version" - // Transformations - implementation 'jp.wasabeef:glide-transformations:4.0.0' - // Logging implementation 'com.jakewharton.timber:timber:4.7.1' // Crash reports - implementation 'ch.acra:acra:4.9.2' + final acra_version = '5.5.0' + implementation "ch.acra:acra-http:$acra_version" + + // Sort + implementation 'com.github.gpanther:java-nat-sort:natural-comparator-1.1' // UI implementation 'com.dmitrymalkovich.android:material-design-dimens:1.4' implementation 'com.github.dmytrodanylyk.android-process-button:library:1.0.4' - implementation 'eu.davidea:flexible-adapter:5.1.0' // Cannot upgrade to 5.1.0 as it uses AndroidX + implementation 'eu.davidea:flexible-adapter:5.1.0' implementation 'eu.davidea:flexible-adapter-ui:1.0.0' implementation 'com.nononsenseapps:filepicker:2.5.2' - implementation 'com.github.amulyakhare:TextDrawable:558677e' - implementation 'com.afollestad.material-dialogs:core:0.9.6.0' // Cannot upgrade to 2.x, AndroidX and API changes - implementation 'me.zhanghai.android.systemuihelper:library:1.0.0' implementation 'com.nightlynexus.viewstatepageradapter:viewstatepageradapter:1.1.0' implementation 'com.github.mthli:Slice:v1.3' - implementation 'me.gujun.android.taggroup:library:1.4@aar' - implementation 'com.github.chrisbanes:PhotoView:2.3.0' // Cannot upgrade to 2.2.x+ as it uses AndroidX + implementation 'com.github.chrisbanes:PhotoView:2.3.0' implementation 'com.github.carlosesco:DirectionalViewPager:a844dbca0a' + // 3.2.0+ introduces weird UI blinking or cut off issues on some devices + final material_dialogs_version = '3.1.1' + implementation "com.afollestad.material-dialogs:core:$material_dialogs_version" + implementation "com.afollestad.material-dialogs:input:$material_dialogs_version" + implementation "com.afollestad.material-dialogs:datetime:$material_dialogs_version" + // Conductor implementation 'com.bluelinelabs:conductor:2.1.5' implementation("com.bluelinelabs:conductor-support:2.1.5") { exclude group: "com.android.support" } - implementation 'com.github.inorichi:conductor-support-preference:78e2344' + implementation 'com.github.inorichi:conductor-support-preference:a32c357' - // RxBindings - final rxbindings_version = '1.0.1' - implementation "com.jakewharton.rxbinding:rxbinding-kotlin:$rxbindings_version" - implementation "com.jakewharton.rxbinding:rxbinding-appcompat-v7-kotlin:$rxbindings_version" - implementation "com.jakewharton.rxbinding:rxbinding-support-v4-kotlin:$rxbindings_version" - implementation "com.jakewharton.rxbinding:rxbinding-recyclerview-v7-kotlin:$rxbindings_version" + // FlowBinding + final flowbinding_version = '0.11.1' + implementation "io.github.reactivecircus.flowbinding:flowbinding-android:$flowbinding_version" + implementation "io.github.reactivecircus.flowbinding:flowbinding-appcompat:$flowbinding_version" + implementation "io.github.reactivecircus.flowbinding:flowbinding-recyclerview:$flowbinding_version" + implementation "io.github.reactivecircus.flowbinding:flowbinding-swiperefreshlayout:$flowbinding_version" + implementation "io.github.reactivecircus.flowbinding:flowbinding-viewpager:$flowbinding_version" // Tests - testImplementation 'junit:junit:4.12' - testImplementation 'org.assertj:assertj-core:1.7.1' + testImplementation 'junit:junit:4.13' + testImplementation 'org.assertj:assertj-core:3.12.2' testImplementation 'org.mockito:mockito-core:1.10.19' final robolectric_version = '3.1.4' @@ -260,20 +280,22 @@ dependencies { testImplementation "org.robolectric:shadows-multidex:$robolectric_version" testImplementation "org.robolectric:shadows-play-services:$robolectric_version" - implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" - final coroutines_version = '1.3.3' + final coroutines_version = '1.3.5' implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-reactive:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx2:$coroutines_version" + implementation 'com.google.android.gms:play-services-oss-licenses:17.0.0' + // Text distance (EH) implementation 'info.debatty:java-string-similarity:1.2.1' // Pin lock view (EH) - implementation 'com.andrognito.pinlockview:pinlockview:2.1.0' + implementation 'com.github.jawnnypoo:pinlockview:2.2.0' // Reprint (EH) implementation 'com.github.ajalt.reprint:core:3.2.1@aar' @@ -283,10 +305,7 @@ dependencies { implementation 'com.mattprecious.swirl:swirl:1.2.0' // RxJava 2 interop for Realm (EH) - implementation 'com.lvla.android:rxjava2-interop-kt:0.2.1' - - // Debug network interceptor (EH) - implementation "com.squareup.okhttp3:logging-interceptor:4.2.1" + implementation 'com.github.akarnokd:rxjava2-interop:0.13.7' // Firebase (EH) implementation 'com.crashlytics.sdk.android:crashlytics:2.10.1' @@ -323,7 +342,7 @@ dependencies { } buildscript { - ext.kotlin_version = '1.3.61' + ext.kotlin_version = '1.3.72' repositories { mavenCentral() } @@ -341,10 +360,15 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.AbstractKotlinCompile).all { kotlinOptions.freeCompilerArgs += ["-Xuse-experimental=kotlin.Experimental"] } -androidExtensions { - experimental = true +// Duplicating Hebrew string assets due to some locale code issues on different devices +task copyResources(type: Copy) { + from './src/main/res/values-he' + into './src/main/res/values-iw' + include '**/*' } +preBuild.dependsOn(ktlintFormat, copyResources) + if (getGradle().getStartParameter().getTaskRequests().toString().contains("Standard")) { apply plugin: 'com.google.gms.google-services' // Firebase (EH) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 1d7106f03..9cf2feb84 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,22 +1,15 @@ -#-repackageclasses '' +-dontobfuscate -# == Make debugging easier --renamesourcefileattribute SourceFile --keepattributes SourceFile,LineNumberTable - -# === Keep app classes +# Extensions may require methods unused in the core app -dontwarn eu.kanade.tachiyomi.** --keep class eu.kanade.tachiyomi.** { *; } +-keep class eu.kanade.tachiyomi.** { public protected private *; } -# === Keep extension classes -keep class org.jsoup.** { *; } -keep class kotlin.** { *; } -keep class okhttp3.** { *; } -keep class com.google.gson.** { *; } -keep class com.github.salomonbrys.kotson.** { *; } -keep class com.squareup.duktape.** { *; } --keep class android.support.v7.preference.** { *; } --keep class uy.kohesive.injekt.** { *; } # === Keep EH classes -keep class exh.** { *; } @@ -25,7 +18,20 @@ # === Keep RxAndroid, https://github.com/ReactiveX/RxAndroid/issues/350 -keep class rx.android.** { *; } -# === RxJava 1.3.8 +# Design library +-dontwarn com.google.android.material.** +-keep class com.google.android.material.** { *; } +-keep interface com.google.android.material.** { *; } +-keep public class com.google.android.material.R$* { *; } + +-keep class com.hippo.image.** { *; } +-keep interface com.hippo.image.** { *; } +-keepclassmembers class * extends nucleus.presenter.Presenter { + (); +} + + +# RxJava 1.1.0 -dontwarn sun.misc.** -keepclassmembers class rx.internal.util.unsafe.*ArrayQueue*Field* { @@ -130,6 +136,7 @@ # === Humanize + Guava: https://github.com/google/guava/wiki/UsingProGuardWithGuava -dontwarn javax.lang.model.element.Modifier +-keep class org.ocpsoft.prettytime.i18n.** # Note: We intentionally don't add the flags we'd need to make Enums work. # That's because the Proguard configuration required to make it work on @@ -223,10 +230,3 @@ -keep class com.google.apphosting.api.ApiProxy { static *** getCurrentEnvironment (...); } - -# === Support library -# From original config: http://stackoverflow.com/questions/29679177/cardview-shadow-not-appearing-in-lollipop-after-obfuscate-with-proguard/29698051 --keep class android.support.v7.widget.RoundRectDrawable { *; } - -# Fix missing back button: https://stackoverflow.com/a/46207775/5054192 --keep class android.support.v7.graphics.** { *; } diff --git a/app/shortcuts.xml b/app/shortcuts.xml index f0d74789e..9ba1137fc 100644 --- a/app/shortcuts.xml +++ b/app/shortcuts.xml @@ -2,7 +2,7 @@ + android:shortcutLongLabel="@string/browse" + android:shortcutShortLabel="@string/browse"> diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 51afaa1f3..05b7205b8 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ + @@ -27,44 +28,62 @@ android:allowBackup="true" android:fullBackupContent="@xml/backup_rules" android:hardwareAccelerated="true" + android:hasFragileUserData="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:largeHeap="true" + android:requestLegacyExternalStorage="true" android:roundIcon="@mipmap/ic_launcher_round" - android:usesCleartextTraffic="true" android:networkSecurityConfig="@xml/network_security_config" - android:theme="@style/Theme.Tachiyomi"> + android:theme="@style/Theme.Tachiyomi.Light" + android:usesCleartextTraffic="true"> + android:launchMode="singleTop" + android:theme="@style/Theme.Splash"> - - - - - - - - - - - - + + android:name=".ui.main.DeepLinkActivity" + android:launchMode="singleTask" + android:theme="@android:style/Theme.NoDisplay"> + + + + + + + + + + + + + + + + @@ -78,7 +97,7 @@ @@ -92,7 +111,7 @@ @@ -110,6 +129,13 @@ android:name=".extension.util.ExtensionInstallActivity" android:theme="@android:style/Theme.Translucent.NoTitleBar" /> + + + + + + + @@ -273,7 +303,6 @@ - diff --git a/app/src/main/assets/fonts/PTSans-Narrow.ttf b/app/src/main/assets/fonts/PTSans-Narrow.ttf deleted file mode 100755 index df8ba7185..000000000 Binary files a/app/src/main/assets/fonts/PTSans-Narrow.ttf and /dev/null differ diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index 4a2607d5d..03f4df9db 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/App.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi import android.app.Application import android.content.Context import android.content.res.Configuration +<<<<<<< HEAD import android.graphics.Color import android.os.Build import android.os.Environment @@ -37,16 +38,28 @@ import io.realm.Realm import io.realm.RealmConfiguration import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.OnLifecycleEvent +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.multidex.MultiDex +import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate +import eu.kanade.tachiyomi.util.system.LocaleHelper import timber.log.Timber import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.InjektScope +import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.registry.default.DefaultRegistrar import java.io.File import java.security.NoSuchAlgorithmException import javax.net.ssl.SSLContext import kotlin.concurrent.thread -open class App : Application() { + +open class App : Application(), LifecycleObserver { + override fun onCreate() { super.onCreate() if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) @@ -57,7 +70,6 @@ open class App : Application() { Injekt = InjektScope(DefaultRegistrar()) Injekt.importModule(AppModule(this)) - setupJobManager() setupNotificationChannels() GlobalScope.launch { deleteOldMetadataRealm() } // Delete old metadata DB (EH) Reprint.initialize(this) //Setup fingerprint (EH) @@ -66,6 +78,8 @@ open class App : Application() { } LocaleHelper.updateConfiguration(this, resources.configuration) + + ProcessLifecycleOwner.get().lifecycle.addObserver(this) } override fun attachBaseContext(base: Context) { @@ -97,18 +111,12 @@ open class App : Application() { } } - protected open fun setupJobManager() { - try { - JobManager.create(this).addJobCreator { tag -> - when (tag) { - LibraryUpdateJob.TAG -> LibraryUpdateJob() - UpdaterJob.TAG -> UpdaterJob() - BackupCreatorJob.TAG -> BackupCreatorJob() - else -> null - } - } - } catch (e: Exception) { - Timber.w("Can't initialize job manager") + @OnLifecycleEvent(Lifecycle.Event.ON_STOP) + @Suppress("unused") + fun onAppBackgrounded() { + val preferences: PreferencesHelper by injectLazy() + if (preferences.lockAppAfter().get() >= 0) { + SecureActivityDelegate.locked = true } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt b/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt index 00fc3654f..44e412f96 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt @@ -11,16 +11,17 @@ import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.source.SourceManager -import exh.eh.EHentaiUpdateHelper -import io.noties.markwon.Markwon -import rx.Observable -import rx.schedulers.Schedulers -import uy.kohesive.injekt.api.* +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import uy.kohesive.injekt.api.InjektModule +import uy.kohesive.injekt.api.InjektRegistrar +import uy.kohesive.injekt.api.addSingleton +import uy.kohesive.injekt.api.addSingletonFactory +import uy.kohesive.injekt.api.get class AppModule(val app: Application) : InjektModule { override fun InjektRegistrar.registerInjectables() { - addSingleton(app) addSingletonFactory { PreferencesHelper(app) } @@ -49,20 +50,14 @@ class AppModule(val app: Application) : InjektModule { // Asynchronously init expensive components for a faster cold start - rxAsync { get() } + GlobalScope.launch { get() } - rxAsync { get() } + GlobalScope.launch { get() } - rxAsync { get() } + GlobalScope.launch { get() } - rxAsync { get() } - - rxAsync { get() } + GlobalScope.launch { get() } + GlobalScope.launch { get() } } - - private fun rxAsync(block: () -> Unit) { - Observable.fromCallable { block() }.subscribeOn(Schedulers.computation()).subscribe() - } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt index a6733ab4e..96305be4a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt @@ -1,9 +1,11 @@ package eu.kanade.tachiyomi +import eu.kanade.tachiyomi.data.backup.BackupCreatorJob import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.updater.UpdaterJob +import eu.kanade.tachiyomi.extension.ExtensionUpdateJob +import eu.kanade.tachiyomi.ui.library.LibrarySort import java.io.File object Migrations { @@ -18,18 +20,33 @@ object Migrations { */ fun upgrade(preferences: PreferencesHelper): Boolean { val context = preferences.context - val oldVersion = preferences.lastVersionCode().getOrDefault() + val oldVersion = preferences.lastVersionCode().get() + + // Cancel app updater job for debug builds that don't include it + if (BuildConfig.DEBUG && !BuildConfig.INCLUDE_UPDATER) { + UpdaterJob.cancelTask(context) + } + if (oldVersion < BuildConfig.VERSION_CODE) { preferences.lastVersionCode().set(BuildConfig.VERSION_CODE) - if (oldVersion == 0) return false + // Fresh install + if (oldVersion == 0) { + // Set up default background tasks + if (BuildConfig.INCLUDE_UPDATER) { + UpdaterJob.setupTask(context) + } + ExtensionUpdateJob.setupTask(context) + LibraryUpdateJob.setupTask(context) + return false + } if (oldVersion < 14) { - // Restore jobs after upgrading to evernote's job scheduler. - if (BuildConfig.INCLUDE_UPDATER && preferences.automaticUpdates()) { - UpdaterJob.setupTask() + // Restore jobs after upgrading to Evernote's job scheduler. + if (BuildConfig.INCLUDE_UPDATER) { + UpdaterJob.setupTask(context) } - LibraryUpdateJob.setupTask() + LibraryUpdateJob.setupTask(context) } if (oldVersion < 15) { // Delete internal chapter cache dir. @@ -41,7 +58,7 @@ object Migrations { if (oldDir.exists()) { val destDir = context.getExternalFilesDir("covers") if (destDir != null) { - oldDir.listFiles().forEach { + oldDir.listFiles()?.forEach { it.renameTo(File(destDir, it.name)) } } @@ -57,12 +74,25 @@ object Migrations { } } } + if (oldVersion < 43) { + // Restore jobs after migrating from Evernote's job scheduler to WorkManager. + if (BuildConfig.INCLUDE_UPDATER) { + UpdaterJob.setupTask(context) + } + LibraryUpdateJob.setupTask(context) + BackupCreatorJob.setupTask(context) - // ===========[ ALL MIGRATIONS ABOVE HERE HAVE BEEN ALREADY REWRITTEN ]=========== - + // New extension update check job + ExtensionUpdateJob.setupTask(context) + } + if (oldVersion < 44) { + // Reset sorting preference if using removed sort by source + if (preferences.librarySortingMode().get() == LibrarySort.SOURCE) { + preferences.librarySortingMode().set(LibrarySort.ALPHA) + } + } return true } return false } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupConst.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupConst.kt index f2427c3fe..df76e47d1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupConst.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupConst.kt @@ -1,23 +1,10 @@ package eu.kanade.tachiyomi.data.backup -import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID +import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID object BackupConst { - const val INTENT_FILTER = "SettingsBackupFragment" - const val ACTION_BACKUP_COMPLETED_DIALOG = "$ID.$INTENT_FILTER.ACTION_BACKUP_COMPLETED_DIALOG" - const val ACTION_SET_PROGRESS_DIALOG = "$ID.$INTENT_FILTER.ACTION_SET_PROGRESS_DIALOG" - const val ACTION_ERROR_BACKUP_DIALOG = "$ID.$INTENT_FILTER.ACTION_ERROR_BACKUP_DIALOG" - const val ACTION_ERROR_RESTORE_DIALOG = "$ID.$INTENT_FILTER.ACTION_ERROR_RESTORE_DIALOG" - const val ACTION_RESTORE_COMPLETED_DIALOG = "$ID.$INTENT_FILTER.ACTION_RESTORE_COMPLETED_DIALOG" - const val ACTION = "$ID.$INTENT_FILTER.ACTION" - const val EXTRA_PROGRESS = "$ID.$INTENT_FILTER.EXTRA_PROGRESS" - const val EXTRA_AMOUNT = "$ID.$INTENT_FILTER.EXTRA_AMOUNT" - const val EXTRA_ERRORS = "$ID.$INTENT_FILTER.EXTRA_ERRORS" - const val EXTRA_CONTENT = "$ID.$INTENT_FILTER.EXTRA_CONTENT" - const val EXTRA_ERROR_MESSAGE = "$ID.$INTENT_FILTER.EXTRA_ERROR_MESSAGE" - const val EXTRA_URI = "$ID.$INTENT_FILTER.EXTRA_URI" - const val EXTRA_TIME = "$ID.$INTENT_FILTER.EXTRA_TIME" - const val EXTRA_ERROR_FILE_PATH = "$ID.$INTENT_FILTER.EXTRA_ERROR_FILE_PATH" - const val EXTRA_ERROR_FILE = "$ID.$INTENT_FILTER.EXTRA_ERROR_FILE" -} \ No newline at end of file + private const val NAME = "BackupRestoreServices" + const val EXTRA_URI = "$ID.$NAME.EXTRA_URI" + const val EXTRA_FLAGS = "$ID.$NAME.EXTRA_FLAGS" +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt index 0d993a701..a212db9de 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt @@ -1,25 +1,22 @@ package eu.kanade.tachiyomi.data.backup -import android.app.IntentService +import android.app.Service import android.content.Context import android.content.Intent import android.net.Uri -import com.google.gson.JsonArray -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID +import android.os.Build +import android.os.IBinder +import android.os.PowerManager +import com.hippo.unifile.UniFile +import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.util.system.isServiceRunning /** - * [IntentService] used to backup [Manga] information to [JsonArray] + * Service for backing up library information to a JSON file. */ -class BackupCreateService : IntentService(NAME) { +class BackupCreateService : Service() { companion object { - // Name of class - private const val NAME = "BackupCreateService" - - // Options for backup - private const val EXTRA_FLAGS = "$ID.$NAME.EXTRA_FLAGS" - // Filter options internal const val BACKUP_CATEGORY = 0x1 internal const val BACKUP_CATEGORY_MASK = 0x1 @@ -31,6 +28,15 @@ class BackupCreateService : IntentService(NAME) { internal const val BACKUP_TRACK_MASK = 0x8 internal const val BACKUP_ALL = 0xF + /** + * Returns the status of the service. + * + * @param context the application context. + * @return true if the service is running, false otherwise. + */ + fun isRunning(context: Context): Boolean = + context.isServiceRunning(BackupCreateService::class.java) + /** * Make a backup from library * @@ -38,26 +44,78 @@ class BackupCreateService : IntentService(NAME) { * @param uri path of Uri * @param flags determines what to backup */ - fun makeBackup(context: Context, uri: Uri, flags: Int) { - val intent = Intent(context, BackupCreateService::class.java).apply { - putExtra(BackupConst.EXTRA_URI, uri) - putExtra(EXTRA_FLAGS, flags) + fun start(context: Context, uri: Uri, flags: Int) { + if (!isRunning(context)) { + val intent = Intent(context, BackupCreateService::class.java).apply { + putExtra(BackupConst.EXTRA_URI, uri) + putExtra(BackupConst.EXTRA_FLAGS, flags) + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + context.startService(intent) + } else { + context.startForegroundService(intent) + } } - context.startService(intent) + } + } + + /** + * Wake lock that will be held until the service is destroyed. + */ + private lateinit var wakeLock: PowerManager.WakeLock + + private lateinit var backupManager: BackupManager + private lateinit var notifier: BackupNotifier + + override fun onCreate() { + super.onCreate() + notifier = BackupNotifier(this) + + startForeground(Notifications.ID_BACKUP_PROGRESS, notifier.showBackupProgress().build()) + + wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, "${javaClass.name}:WakeLock" + ) + wakeLock.acquire() + } + + override fun stopService(name: Intent?): Boolean { + destroyJob() + return super.stopService(name) + } + + override fun onDestroy() { + destroyJob() + super.onDestroy() + } + + private fun destroyJob() { + if (wakeLock.isHeld) { + wakeLock.release() + } + } + + /** + * This method needs to be implemented, but it's not used/needed. + */ + override fun onBind(intent: Intent): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent == null) return START_NOT_STICKY + + try { + val uri = intent.getParcelableExtra(BackupConst.EXTRA_URI) + val backupFlags = intent.getIntExtra(BackupConst.EXTRA_FLAGS, 0) + backupManager = BackupManager(this) + + val backupFileUri = Uri.parse(backupManager.createBackup(uri, backupFlags, false)) + val unifile = UniFile.fromUri(this, backupFileUri) + notifier.showBackupComplete(unifile) + } catch (e: Exception) { + notifier.showBackupError(e.message) } + stopSelf(startId) + return START_NOT_STICKY } - - private val backupManager by lazy { BackupManager(this) } - - override fun onHandleIntent(intent: Intent?) { - if (intent == null) return - - // Get values - val uri = intent.getParcelableExtra(BackupConst.EXTRA_URI) - val flags = intent.getIntExtra(EXTRA_FLAGS, 0) - // Create backup - backupManager.createBackup(uri, flags, false) - } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt index a1f5aca92..1e3f4026d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt @@ -1,42 +1,51 @@ package eu.kanade.tachiyomi.data.backup +import android.content.Context import android.net.Uri -import com.evernote.android.job.Job -import com.evernote.android.job.JobManager -import com.evernote.android.job.JobRequest +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.Worker +import androidx.work.WorkerParameters import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault +import java.util.concurrent.TimeUnit import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -class BackupCreatorJob : Job() { +class BackupCreatorJob(private val context: Context, workerParams: WorkerParameters) : + Worker(context, workerParams) { - override fun onRunJob(params: Params): Result { + override fun doWork(): Result { val preferences = Injekt.get() val backupManager = BackupManager(context) - val uri = Uri.parse(preferences.backupsDirectory().getOrDefault()) + val uri = Uri.parse(preferences.backupsDirectory().get()) val flags = BackupCreateService.BACKUP_ALL - backupManager.createBackup(uri, flags, true) - return Result.SUCCESS + return try { + backupManager.createBackup(uri, flags, true) + Result.success() + } catch (e: Exception) { + Result.failure() + } } companion object { - const val TAG = "BackupCreator" + private const val TAG = "BackupCreator" - fun setupTask(prefInterval: Int? = null) { + fun setupTask(context: Context, prefInterval: Int? = null) { val preferences = Injekt.get() - val interval = prefInterval ?: preferences.backupInterval().getOrDefault() + val interval = prefInterval ?: preferences.backupInterval().get() if (interval > 0) { - JobRequest.Builder(TAG) - .setPeriodic(interval * 60 * 60 * 1000L, 10 * 60 * 1000) - .setUpdateCurrent(true) - .build() - .schedule() - } - } + val request = PeriodicWorkRequestBuilder( + interval.toLong(), TimeUnit.HOURS, + 10, TimeUnit.MINUTES + ) + .addTag(TAG) + .build() - fun cancelTask() { - JobManager.instance().cancelAllForTag(TAG) + WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.REPLACE, request) + } else { + WorkManager.getInstance(context).cancelAllWorkByTag(TAG) + } } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt index 38af057ad..6506df184 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt @@ -1,10 +1,16 @@ package eu.kanade.tachiyomi.data.backup import android.content.Context -import android.content.Intent import android.net.Uri -import com.github.salomonbrys.kotson.* -import com.google.gson.* +import com.github.salomonbrys.kotson.fromJson +import com.github.salomonbrys.kotson.registerTypeAdapter +import com.github.salomonbrys.kotson.registerTypeHierarchyAdapter +import com.github.salomonbrys.kotson.set +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY_MASK @@ -18,42 +24,42 @@ import eu.kanade.tachiyomi.data.backup.models.Backup import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS import eu.kanade.tachiyomi.data.backup.models.Backup.CURRENT_VERSION +import eu.kanade.tachiyomi.data.backup.models.Backup.EXTENSIONS import eu.kanade.tachiyomi.data.backup.models.Backup.HISTORY import eu.kanade.tachiyomi.data.backup.models.Backup.MANGA import eu.kanade.tachiyomi.data.backup.models.Backup.TRACK import eu.kanade.tachiyomi.data.backup.models.DHistory -import eu.kanade.tachiyomi.data.backup.serializer.* +import eu.kanade.tachiyomi.data.backup.serializer.CategoryTypeAdapter +import eu.kanade.tachiyomi.data.backup.serializer.ChapterTypeAdapter +import eu.kanade.tachiyomi.data.backup.serializer.HistoryTypeAdapter +import eu.kanade.tachiyomi.data.backup.serializer.MangaTypeAdapter +import eu.kanade.tachiyomi.data.backup.serializer.TrackTypeAdapter import eu.kanade.tachiyomi.data.database.DatabaseHelper -import eu.kanade.tachiyomi.data.database.models.* +import eu.kanade.tachiyomi.data.database.models.CategoryImpl +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.database.models.ChapterImpl +import eu.kanade.tachiyomi.data.database.models.History +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.MangaCategory +import eu.kanade.tachiyomi.data.database.models.MangaImpl +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.database.models.TrackImpl import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceManager -import eu.kanade.tachiyomi.source.online.all.EHentai -import eu.kanade.tachiyomi.util.sendLocalBroadcast -import eu.kanade.tachiyomi.util.syncChaptersWithSource -import exh.eh.EHentaiThrottleManager +import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource +import kotlin.math.max import rx.Observable import timber.log.Timber import uy.kohesive.injekt.injectLazy class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { - /** - * Database. - */ internal val databaseHelper: DatabaseHelper by injectLazy() - - /** - * Source manager. - */ internal val sourceManager: SourceManager by injectLazy() - - /** - * Tracking manager - */ internal val trackManager: TrackManager by injectLazy() + private val preferences: PreferencesHelper by injectLazy() /** * Version of parser @@ -66,11 +72,6 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { */ var parser: Gson = initParser() - /** - * Preferences - */ - private val preferences: PreferencesHelper by injectLazy() - /** * Set version of parser * @@ -83,7 +84,8 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { private fun initParser(): Gson = when (version) { 1 -> GsonBuilder().create() - 2 -> GsonBuilder() + 2 -> + GsonBuilder() .registerTypeAdapter(MangaTypeAdapter.build()) .registerTypeHierarchyAdapter(ChapterTypeAdapter.build()) .registerTypeAdapter(CategoryTypeAdapter.build()) @@ -99,7 +101,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { * @param uri path of Uri * @param isJob backup called from job */ - fun createBackup(uri: Uri, flags: Int, isJob: Boolean) { + fun createBackup(uri: Uri, flags: Int, isJob: Boolean): String? { // Create root object val root = JsonObject() @@ -109,24 +111,38 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { // Create category array val categoryEntries = JsonArray() + // Create extension ID/name mapping + val extensionEntries = JsonArray() + // Add value's to root - root[Backup.VERSION] = Backup.CURRENT_VERSION + root[Backup.VERSION] = CURRENT_VERSION root[Backup.MANGAS] = mangaEntries root[CATEGORIES] = categoryEntries + root[EXTENSIONS] = extensionEntries databaseHelper.inTransaction { // Get manga from database val mangas = getFavoriteManga() + val extensions: MutableSet = mutableSetOf() + // Backup library manga and its dependencies mangas.forEach { manga -> mangaEntries.add(backupMangaObject(manga, flags)) + + // Maintain set of extensions/sources used (excludes local source) + if (manga.source != 0L && sourceManager.get(manga.source) != null) { + extensions.add("${manga.source}:${sourceManager.get(manga.source)!!.name}") + } } // Backup categories if ((flags and BACKUP_CATEGORY_MASK) == BACKUP_CATEGORY) { backupCategories(categoryEntries) } + + // Backup extension ID/name mapping + backupExtensionInfo(extensionEntries, extensions) } try { @@ -140,42 +156,38 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { val numberOfBackups = numberOfBackups() val backupRegex = Regex("""tachiyomi_\d+-\d+-\d+_\d+-\d+.json""") dir.listFiles { _, filename -> backupRegex.matches(filename) } - .orEmpty() - .sortedByDescending { it.name } - .drop(numberOfBackups - 1) - .forEach { it.delete() } + .orEmpty() + .sortedByDescending { it.name } + .drop(numberOfBackups - 1) + .forEach { it.delete() } // Create new file to place backup val newFile = dir.createFile(Backup.getDefaultFilename()) - ?: throw Exception("Couldn't create backup file") + ?: throw Exception("Couldn't create backup file") newFile.openOutputStream().bufferedWriter().use { parser.toJson(root, it) } + + return newFile.uri.toString() } else { val file = UniFile.fromUri(context, uri) - ?: throw Exception("Couldn't create backup file") + ?: throw Exception("Couldn't create backup file") file.openOutputStream().bufferedWriter().use { parser.toJson(root, it) } - // Show completed dialog - val intent = Intent(BackupConst.INTENT_FILTER).apply { - putExtra(BackupConst.ACTION, BackupConst.ACTION_BACKUP_COMPLETED_DIALOG) - putExtra(BackupConst.EXTRA_URI, file.uri.toString()) - } - context.sendLocalBroadcast(intent) + return file.uri.toString() } } catch (e: Exception) { Timber.e(e) - if (!isJob) { - // Show error dialog - val intent = Intent(BackupConst.INTENT_FILTER).apply { - putExtra(BackupConst.ACTION, BackupConst.ACTION_ERROR_BACKUP_DIALOG) - putExtra(BackupConst.EXTRA_ERROR_MESSAGE, e.message) - } - context.sendLocalBroadcast(intent) - } + throw e + } + } + + private fun backupExtensionInfo(root: JsonArray, extensions: Set) { + extensions.sorted().forEach { + root.add(it) } } @@ -206,7 +218,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { if (options and BACKUP_CHAPTER_MASK == BACKUP_CHAPTER) { // Backup all the chapters val chapters = databaseHelper.getChapters(manga).executeAsBlocking() - if (!chapters.isEmpty()) { + if (chapters.isNotEmpty()) { val chaptersJson = parser.toJsonTree(chapters) if (chaptersJson.asJsonArray.size() > 0) { entry[CHAPTERS] = chaptersJson @@ -218,7 +230,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) { // Backup categories for this manga val categoriesForManga = databaseHelper.getCategoriesForManga(manga).executeAsBlocking() - if (!categoriesForManga.isEmpty()) { + if (categoriesForManga.isNotEmpty()) { val categoriesNames = categoriesForManga.map { it.name } entry[CATEGORIES] = parser.toJsonTree(categoriesNames) } @@ -227,7 +239,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { // Check if user wants track information in backup if (options and BACKUP_TRACK_MASK == BACKUP_TRACK) { val tracks = databaseHelper.getTracks(manga).executeAsBlocking() - if (!tracks.isEmpty()) { + if (tracks.isNotEmpty()) { entry[TRACK] = parser.toJsonTree(tracks) } } @@ -235,7 +247,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { // Check if user wants history information in backup if (options and BACKUP_HISTORY_MASK == BACKUP_HISTORY) { val historyForManga = databaseHelper.getHistoryByMangaId(manga.id!!).executeAsBlocking() - if (!historyForManga.isEmpty()) { + if (historyForManga.isNotEmpty()) { val historyData = historyForManga.mapNotNull { history -> val url = databaseHelper.getChapter(history.chapter_id).executeAsBlocking()?.url url?.let { DHistory(url, history.last_read) } @@ -266,13 +278,13 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { */ fun restoreMangaFetchObservable(source: Source, manga: Manga): Observable { return source.fetchMangaDetails(manga) - .map { networkManga -> - manga.copyFrom(networkManga) - manga.favorite = true - manga.initialized = true - manga.id = insertManga(manga) - manga - } + .map { networkManga -> + manga.copyFrom(networkManga) + manga.favorite = true + manga.initialized = true + manga.id = insertManga(manga) + manga + } } /** @@ -282,18 +294,18 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { * @param manga manga that needs updating * @return [Observable] that contains manga */ - fun restoreChapterFetchObservable(source: Source, manga: Manga, chapters: List, throttleManager: EHentaiThrottleManager): Observable, List>> { + fun restoreChapterFetchObservable(source: Source, manga: Manga, chapters: List): Observable, List>> { return (if(source is EHentai) { source.fetchChapterList(manga, throttleManager::throttle) } else { source.fetchChapterList(manga) - }).map { syncChaptersWithSource(databaseHelper, it, manga, source) } - .doOnNext { - if (it.first.isNotEmpty()) { - chapters.forEach { it.manga_id = manga.id } - insertChapters(chapters) - } + .map { syncChaptersWithSource(databaseHelper, it, manga, source) } + .doOnNext { pair -> + if (pair.first.isNotEmpty()) { + chapters.forEach { it.manga_id = manga.id } + insertChapters(chapters) } + } } /** @@ -349,7 +361,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { } // Update database - if (!mangaCategoriesToUpdate.isEmpty()) { + if (mangaCategoriesToUpdate.isNotEmpty()) { val mangaAsList = ArrayList() mangaAsList.add(manga) databaseHelper.deleteOldMangasCategories(mangaAsList).executeAsBlocking() @@ -370,7 +382,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { // Check if history already in database and update if (dbHistory != null) { dbHistory.apply { - last_read = Math.max(lastRead, dbHistory.last_read) + last_read = max(lastRead, dbHistory.last_read) } historyToBeUpdated.add(dbHistory) } else { @@ -413,7 +425,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { if (track.library_id != dbTrack.library_id) { dbTrack.library_id = track.library_id } - dbTrack.last_chapter_read = Math.max(dbTrack.last_chapter_read, track.last_chapter_read) + dbTrack.last_chapter_read = max(dbTrack.last_chapter_read, track.last_chapter_read) isInDatabase = true trackToUpdate.add(dbTrack) break @@ -427,7 +439,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { } } // Update database - if (!trackToUpdate.isEmpty()) { + if (trackToUpdate.isNotEmpty()) { databaseHelper.insertTracks(trackToUpdate).executeAsBlocking() } } @@ -443,8 +455,9 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { val dbChapters = databaseHelper.getChapters(manga).executeAsBlocking() // Return if fetch is needed - if (dbChapters.isEmpty() || dbChapters.size < chapters.size) + if (dbChapters.isEmpty() || dbChapters.size < chapters.size) { return false + } for (chapter in chapters) { val pos = dbChapters.indexOf(chapter) @@ -469,7 +482,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { * @return [Manga], null if not found */ internal fun getMangaFromDatabase(manga: Manga): Manga? = - databaseHelper.getManga(manga.url, manga.source).executeAsBlocking() + databaseHelper.getManga(manga.url, manga.source).executeAsBlocking() /** * Returns list containing manga from library @@ -477,7 +490,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { * @return [Manga] from library */ internal fun getFavoriteManga(): List = - databaseHelper.getFavoriteMangas().executeAsBlocking() + databaseHelper.getFavoriteMangas().executeAsBlocking() /** * Inserts manga and returns id @@ -485,7 +498,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { * @return id of [Manga], null if not found */ internal fun insertManga(manga: Manga): Long? = - databaseHelper.insertManga(manga).executeAsBlocking().insertedId() + databaseHelper.insertManga(manga).executeAsBlocking().insertedId() /** * Inserts list of chapters @@ -499,5 +512,5 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { * * @return number of backups selected by user */ - fun numberOfBackups(): Int = preferences.numberOfBackups().getOrDefault() + fun numberOfBackups(): Int = preferences.numberOfBackups().get() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupNotifier.kt new file mode 100644 index 000000000..a731f5126 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupNotifier.kt @@ -0,0 +1,159 @@ +package eu.kanade.tachiyomi.data.backup + +import android.content.Context +import android.graphics.BitmapFactory +import androidx.core.app.NotificationCompat +import com.hippo.unifile.UniFile +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.notification.NotificationReceiver +import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.util.storage.getUriCompat +import eu.kanade.tachiyomi.util.system.notificationBuilder +import eu.kanade.tachiyomi.util.system.notificationManager +import java.io.File +import java.util.concurrent.TimeUnit +import uy.kohesive.injekt.injectLazy + +internal class BackupNotifier(private val context: Context) { + + private val preferences: PreferencesHelper by injectLazy() + + private val progressNotificationBuilder = context.notificationBuilder(Notifications.CHANNEL_BACKUP_RESTORE_PROGRESS) { + setLargeIcon(BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher)) + setSmallIcon(R.drawable.ic_tachi) + setAutoCancel(false) + setOngoing(true) + } + + private val completeNotificationBuilder = context.notificationBuilder(Notifications.CHANNEL_BACKUP_RESTORE_COMPLETE) { + setLargeIcon(BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher)) + setSmallIcon(R.drawable.ic_tachi) + setAutoCancel(false) + } + + private fun NotificationCompat.Builder.show(id: Int) { + context.notificationManager.notify(id, build()) + } + + fun showBackupProgress(): NotificationCompat.Builder { + val builder = with(progressNotificationBuilder) { + setContentTitle(context.getString(R.string.creating_backup)) + + setProgress(0, 0, true) + } + + builder.show(Notifications.ID_BACKUP_PROGRESS) + + return builder + } + + fun showBackupError(error: String?) { + context.notificationManager.cancel(Notifications.ID_BACKUP_PROGRESS) + + with(completeNotificationBuilder) { + setContentTitle(context.getString(R.string.creating_backup_error)) + setContentText(error) + + show(Notifications.ID_BACKUP_COMPLETE) + } + } + + fun showBackupComplete(unifile: UniFile) { + context.notificationManager.cancel(Notifications.ID_BACKUP_PROGRESS) + + with(completeNotificationBuilder) { + setContentTitle(context.getString(R.string.backup_created)) + + if (unifile.filePath != null) { + setContentText(unifile.filePath) + } + + // Clear old actions if they exist + if (mActions.isNotEmpty()) { + mActions.clear() + } + + addAction( + R.drawable.ic_share_24dp, + context.getString(R.string.action_share), + NotificationReceiver.shareBackupPendingBroadcast(context, unifile.uri, Notifications.ID_BACKUP_COMPLETE) + ) + + show(Notifications.ID_BACKUP_COMPLETE) + } + } + + fun showRestoreProgress(content: String = "", progress: Int = 0, maxAmount: Int = 100): NotificationCompat.Builder { + val builder = with(progressNotificationBuilder) { + setContentTitle(context.getString(R.string.restoring_backup)) + + if (!preferences.hideNotificationContent()) { + setContentText(content) + } + + setProgress(maxAmount, progress, false) + + // Clear old actions if they exist + if (mActions.isNotEmpty()) { + mActions.clear() + } + + addAction( + R.drawable.ic_close_24dp, + context.getString(R.string.action_stop), + NotificationReceiver.cancelRestorePendingBroadcast(context, Notifications.ID_RESTORE_PROGRESS) + ) + } + + builder.show(Notifications.ID_RESTORE_PROGRESS) + + return builder + } + + fun showRestoreError(error: String?) { + context.notificationManager.cancel(Notifications.ID_RESTORE_PROGRESS) + + with(completeNotificationBuilder) { + setContentTitle(context.getString(R.string.restoring_backup_error)) + setContentText(error) + + show(Notifications.ID_RESTORE_COMPLETE) + } + } + + fun showRestoreComplete(time: Long, errorCount: Int, path: String?, file: String?) { + context.notificationManager.cancel(Notifications.ID_RESTORE_PROGRESS) + + val timeString = context.getString( + R.string.restore_duration, + TimeUnit.MILLISECONDS.toMinutes(time), + TimeUnit.MILLISECONDS.toSeconds(time) - TimeUnit.MINUTES.toSeconds( + TimeUnit.MILLISECONDS.toMinutes(time) + ) + ) + + with(completeNotificationBuilder) { + setContentTitle(context.getString(R.string.restore_completed)) + setContentText(context.getString(R.string.restore_completed_content, timeString, errorCount)) + + // Clear old actions if they exist + if (mActions.isNotEmpty()) { + mActions.clear() + } + + if (errorCount > 0 && !path.isNullOrEmpty() && !file.isNullOrEmpty()) { + val destFile = File(path, file) + val uri = destFile.getUriCompat(context) + + addAction( + R.drawable.nnf_ic_file_folder, + context.getString(R.string.action_open_log), + NotificationReceiver.openErrorLogPendingActivity(context, uri) + ) + } + + show(Notifications.ID_RESTORE_COMPLETE) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt index 9f202c906..e7e71daa2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt @@ -10,6 +10,8 @@ import android.os.PowerManager import com.elvishew.xlog.XLog import com.github.salomonbrys.kotson.fromJson import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject import com.google.gson.JsonParser import com.google.gson.stream.JsonReader import eu.kanade.tachiyomi.R @@ -22,12 +24,16 @@ import eu.kanade.tachiyomi.data.backup.models.Backup.TRACK import eu.kanade.tachiyomi.data.backup.models.Backup.VERSION import eu.kanade.tachiyomi.data.backup.models.DHistory import eu.kanade.tachiyomi.data.database.DatabaseHelper -import eu.kanade.tachiyomi.data.database.models.* +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.database.models.ChapterImpl +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.MangaImpl +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.database.models.TrackImpl +import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.source.Source -import eu.kanade.tachiyomi.util.chop -import eu.kanade.tachiyomi.util.isServiceRunning -import eu.kanade.tachiyomi.util.sendLocalBroadcast +import eu.kanade.tachiyomi.util.system.isServiceRunning import exh.BackupEntry import exh.EH_SOURCE_ID import exh.EXHMigrations @@ -42,11 +48,16 @@ import java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import rx.Observable +import timber.log.Timber +import uy.kohesive.injekt.injectLazy /** - * Restores backup from json file + * Restores backup from a JSON file. */ class BackupRestoreService : Service() { @@ -58,8 +69,8 @@ class BackupRestoreService : Service() { * @param context the application context. * @return true if the service is running, false otherwise. */ - private fun isRunning(context: Context): Boolean = - context.isServiceRunning(BackupRestoreService::class.java) + fun isRunning(context: Context): Boolean = + context.isServiceRunning(BackupRestoreService::class.java) /** * Starts a service to restore a backup from Json @@ -72,7 +83,11 @@ class BackupRestoreService : Service() { val intent = Intent(context, BackupRestoreService::class.java).apply { putExtra(BackupConst.EXTRA_URI, uri) } - context.startService(intent) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + context.startService(intent) + } else { + context.startForegroundService(intent) + } } } @@ -83,6 +98,8 @@ class BackupRestoreService : Service() { */ fun stop(context: Context) { context.stopService(Intent(context, BackupRestoreService::class.java)) + + BackupNotifier(context).showRestoreError(context.getString(R.string.restoring_backup_canceled)) } } @@ -91,10 +108,7 @@ class BackupRestoreService : Service() { */ private lateinit var wakeLock: PowerManager.WakeLock - /** - * Subscription where the update is done. - */ - private var subscription: Subscription? = null + private var job: Job? = null /** * The progress of a backup restore @@ -111,20 +125,12 @@ class BackupRestoreService : Service() { */ private val errors = mutableListOf>() - /** - * Backup manager - */ private lateinit var backupManager: BackupManager + private lateinit var notifier: BackupNotifier - /** - * Database - */ private val db: DatabaseHelper by injectLazy() - /** - * Tracking manager - */ - internal val trackManager: TrackManager by injectLazy() + private val trackManager: TrackManager by injectLazy() private lateinit var executor: ExecutorService @@ -136,23 +142,31 @@ class BackupRestoreService : Service() { */ override fun onCreate() { super.onCreate() + notifier = BackupNotifier(this) + + startForeground(Notifications.ID_RESTORE_PROGRESS, notifier.showRestoreProgress().build()) + wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, "BackupRestoreService:WakeLock") + PowerManager.PARTIAL_WAKE_LOCK, "${javaClass.name}:WakeLock" + ) wakeLock.acquire() - executor = Executors.newSingleThreadExecutor() } - /** - * Method called when the service is destroyed. It destroys the running subscription and - * releases the wake lock. - */ + override fun stopService(name: Intent?): Boolean { + destroyJob() + return super.stopService(name) + } + override fun onDestroy() { - subscription?.unsubscribe() - executor.shutdown() // must be called after unsubscribe + destroyJob() + super.onDestroy() + } + + private fun destroyJob() { + job?.cancel() if (wakeLock.isHeld) { wakeLock.release() } - super.onDestroy() } /** @@ -169,157 +183,109 @@ class BackupRestoreService : Service() { * @return the start value of the command. */ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (intent == null) return Service.START_NOT_STICKY + val uri = intent?.getParcelableExtra(BackupConst.EXTRA_URI) ?: return START_NOT_STICKY - val uri = intent.getParcelableExtra(BackupConst.EXTRA_URI) + // Cancel any previous job if needed. + job?.cancel() + val handler = CoroutineExceptionHandler { _, exception -> + Timber.e(exception) + writeErrorLog() - throttleManager.resetThrottle() + notifier.showRestoreError(exception.message) - // Unsubscribe from any previous subscription if needed. - subscription?.unsubscribe() + stopSelf(startId) + } + job = GlobalScope.launch(handler) { + restoreBackup(uri) + } + job?.invokeOnCompletion { + stopSelf(startId) + } - subscription = Observable.using( - { - // Pause auto-gallery-update during restore - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - EHentaiUpdateWorker.cancelBackground(this) - } - db.lowLevel().beginTransaction() - }, - { getRestoreObservable(uri).doOnNext { db.lowLevel().setTransactionSuccessful() } }, - { - // Resume auto-gallery-update - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - EHentaiUpdateWorker.scheduleBackground(this) - } - executor.execute { db.lowLevel().endTransaction() } - }) - .doAfterTerminate { stopSelf(startId) } - .subscribeOn(Schedulers.from(executor)) - .subscribe() - - return Service.START_NOT_STICKY + return START_NOT_STICKY } /** - * Returns an [Observable] containing restore process. + * Restores data from backup file. * - * @param uri restore file - * @return [Observable] + * @param uri backup file to restore */ - private fun getRestoreObservable(uri: Uri): Observable> { + private fun restoreBackup(uri: Uri) { val startTime = System.currentTimeMillis() - return Observable.just(Unit) - .map { - val reader = JsonReader(contentResolver.openInputStream(uri).bufferedReader()) - val json = JsonParser().parse(reader).asJsonObject + val reader = JsonReader(contentResolver.openInputStream(uri)!!.bufferedReader()) + val json = JsonParser.parseReader(reader).asJsonObject - // Get parser version - val version = json.get(VERSION)?.asInt ?: 1 + // Get parser version + val version = json.get(VERSION)?.asInt ?: 1 - // Initialize manager - backupManager = BackupManager(this, version) + // Initialize manager + backupManager = BackupManager(this, version) - val mangasJson = json.get(MANGAS).asJsonArray + val mangasJson = json.get(MANGAS).asJsonArray - restoreAmount = mangasJson.size() + 1 // +1 for categories - restoreProgress = 0 - errors.clear() + restoreAmount = mangasJson.size() + 1 // +1 for categories + restoreProgress = 0 + errors.clear() - // Restore categories - json.get(CATEGORIES)?.let { - backupManager.restoreCategories(it.asJsonArray) - restoreProgress += 1 - showRestoreProgress(restoreProgress, restoreAmount, "Categories added", errors.size) - } + // Restore categories + restoreCategories(json.get(CATEGORIES)) - mangasJson - } - .flatMap { Observable.from(it) } - .concatMap { - val obj = it.asJsonObject - val manga = backupManager.parser.fromJson(obj.get(MANGA)) - val chapters = backupManager.parser.fromJson>(obj.get(CHAPTERS) ?: JsonArray()) - val categories = backupManager.parser.fromJson>(obj.get(CATEGORIES) ?: JsonArray()) - val history = backupManager.parser.fromJson>(obj.get(HISTORY) ?: JsonArray()) - val tracks = backupManager.parser.fromJson>(obj.get(TRACK) ?: JsonArray()) + // Restore individual manga + mangasJson.forEach { + restoreManga(it.asJsonObject) + } - // EXH --> - val migrated = EXHMigrations.migrateBackupEntry( - BackupEntry( - manga, - chapters, - categories, - history, - tracks - ) - ) + val endTime = System.currentTimeMillis() + val time = endTime - startTime - val observable = migrated.flatMap { (manga, chapters, categories, history, tracks) -> - getMangaRestoreObservable(manga, chapters, categories, history, tracks) - } - // EXH <-- - if (observable != null) { - observable - } else { - errors.add(Date() to "${manga.title} - ${getString(R.string.source_not_found)}") - restoreProgress += 1 - val content = getString(R.string.dialog_restoring_source_not_found, manga.title.chop(15)) - showRestoreProgress(restoreProgress, restoreAmount, manga.title, errors.size, content) - Observable.just(manga) - } - } - .toList() - .doOnNext { - val endTime = System.currentTimeMillis() - val time = endTime - startTime - val logFile = writeErrorLog() - val completeIntent = Intent(BackupConst.INTENT_FILTER).apply { - putExtra(BackupConst.EXTRA_TIME, time) - putExtra(BackupConst.EXTRA_ERRORS, errors.size) - putExtra(BackupConst.EXTRA_ERROR_FILE_PATH, logFile.parent) - putExtra(BackupConst.EXTRA_ERROR_FILE, logFile.name) - putExtra(BackupConst.ACTION, BackupConst.ACTION_RESTORE_COMPLETED_DIALOG) - } - sendLocalBroadcast(completeIntent) + val logFile = writeErrorLog() - } - .doOnError { error -> - // [EXH] - XLog.w("> Failed to perform restore!", error) - XLog.w("> (uri: %s)", uri) - - writeErrorLog() - val errorIntent = Intent(BackupConst.INTENT_FILTER).apply { - putExtra(BackupConst.ACTION, BackupConst.ACTION_ERROR_RESTORE_DIALOG) - putExtra(BackupConst.EXTRA_ERROR_MESSAGE, error.message) - } - sendLocalBroadcast(errorIntent) - } - .onErrorReturn { emptyList() } + notifier.showRestoreComplete(time, errors.size, logFile.parent, logFile.name) } - /** - * Write errors to error log - */ - private fun writeErrorLog(): File { - try { - if (errors.isNotEmpty()) { - val destFile = File(externalCacheDir, "tachiyomi_restore.log") - val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()) + private fun restoreCategories(categoriesJson: JsonElement) { + db.inTransaction { + backupManager.restoreCategories(categoriesJson.asJsonArray) - destFile.bufferedWriter().use { out -> - errors.forEach { (date, message) -> - out.write("[${sdf.format(date)}] $message\n") - } - } - return destFile - } - } catch (e: Exception) { - // Empty + restoreProgress += 1 + showRestoreProgress(restoreProgress, restoreAmount, getString(R.string.categories)) + } + } + + private fun restoreManga(mangaJson: JsonObject) { + db.inTransaction { + val manga = backupManager.parser.fromJson(mangaJson.get(MANGA)) + val chapters = backupManager.parser.fromJson>( + mangaJson.get(CHAPTERS) + ?: JsonArray() + ) + val categories = backupManager.parser.fromJson>( + mangaJson.get(CATEGORIES) + ?: JsonArray() + ) + val history = backupManager.parser.fromJson>( + mangaJson.get(HISTORY) + ?: JsonArray() + ) + val tracks = backupManager.parser.fromJson>( + mangaJson.get(TRACK) + ?: JsonArray() + ) + + if (job?.isActive != true) { + throw Exception(getString(R.string.restoring_backup_canceled)) + } + + try { + restoreMangaData(manga, chapters, categories, history, tracks) + } catch (e: Exception) { + errors.add(Date() to "${manga.title} - ${getString(R.string.source_not_found)}") + } + + restoreProgress += 1 + showRestoreProgress(restoreProgress, restoreAmount, manga.title) } - return File("") } /** @@ -330,23 +296,26 @@ class BackupRestoreService : Service() { * @param categories categories data from json * @param history history data from json * @param tracks tracking data from json - * @return [Observable] containing manga restore information */ - private fun getMangaRestoreObservable(manga: Manga, chapters: List, - categories: List, history: List, - tracks: List): Observable? { + private fun restoreMangaData( + manga: Manga, + chapters: List, + categories: List, + history: List, + tracks: List + ) { // Get source val source = backupManager.sourceManager.getOrStub(manga.source) val dbManga = backupManager.getMangaFromDatabase(manga) - return if (dbManga == null) { + if (dbManga == null) { // Manga not in database - mangaFetchObservable(source, manga, chapters, categories, history, tracks) + restoreMangaFetch(source, manga, chapters, categories, history, tracks) } else { // Manga in database // Copy information from manga already in database backupManager.restoreMangaNoFetch(manga, dbManga) // Fetch rest of manga information - mangaNoFetchObservable(source, manga, chapters, categories, history, tracks) + restoreMangaNoFetch(source, manga, chapters, categories, history, tracks) } } @@ -357,70 +326,58 @@ class BackupRestoreService : Service() { * @param chapters chapters of manga that needs updating * @param categories categories that need updating */ - private fun mangaFetchObservable(source: Source, manga: Manga, chapters: List, - categories: List, history: List, - tracks: List): Observable { - if(source.id == EH_SOURCE_ID || source.id == EXH_SOURCE_ID) - throttleManager.throttle() - - return backupManager.restoreMangaFetchObservable(source, manga) - .onErrorReturn { - // [EXH] - XLog.w("> Failed to restore manga!", it) - XLog.w("> (source.id: %s, source.name: %s, manga.id: %s, manga.url: %s)", - source.id, - source.name, - manga.id, - manga.url) - - errors.add(Date() to "${manga.title} - ${it.message}") - manga - } - .filter { it.id != null } - .flatMap { - chapterFetchObservable(source, it, chapters) - // Convert to the manga that contains new chapters. - .map { manga } - } - .doOnNext { - restoreExtraForManga(it, categories, history, tracks) - } - .flatMap { - trackingFetchObservable(it, tracks) - // Convert to the manga that contains new chapters. - .map { manga } - } - .doOnCompleted { - restoreProgress += 1 - showRestoreProgress(restoreProgress, restoreAmount, manga.title, errors.size) - } + private fun restoreMangaFetch( + source: Source, + manga: Manga, + chapters: List, + categories: List, + history: List, + tracks: List + ) { + backupManager.restoreMangaFetchObservable(source, manga) + .onErrorReturn { + errors.add(Date() to "${manga.title} - ${it.message}") + manga + } + .filter { it.id != null } + .flatMap { + chapterFetchObservable(source, it, chapters) + // Convert to the manga that contains new chapters. + .map { manga } + } + .doOnNext { + restoreExtraForManga(it, categories, history, tracks) + } + .flatMap { + trackingFetchObservable(it, tracks) + } + .subscribe() } - private fun mangaNoFetchObservable(source: Source, backupManga: Manga, chapters: List, - categories: List, history: List, - tracks: List): Observable { - - return Observable.just(backupManga) - .flatMap { manga -> - if (!backupManager.restoreChaptersForManga(manga, chapters)) { - chapterFetchObservable(source, manga, chapters) - .map { manga } - } else { - Observable.just(manga) - } - } - .doOnNext { - restoreExtraForManga(it, categories, history, tracks) - } - .flatMap { manga -> - trackingFetchObservable(manga, tracks) - // Convert to the manga that contains new chapters. - .map { manga } - } - .doOnCompleted { - restoreProgress += 1 - showRestoreProgress(restoreProgress, restoreAmount, backupManga.title, errors.size) + private fun restoreMangaNoFetch( + source: Source, + backupManga: Manga, + chapters: List, + categories: List, + history: List, + tracks: List + ) { + Observable.just(backupManga) + .flatMap { manga -> + if (!backupManager.restoreChaptersForManga(manga, chapters)) { + chapterFetchObservable(source, manga, chapters) + .map { manga } + } else { + Observable.just(manga) } + } + .doOnNext { + restoreExtraForManga(it, categories, history, tracks) + } + .flatMap { manga -> + trackingFetchObservable(manga, tracks) + } + .subscribe() } private fun restoreExtraForManga(manga: Manga, categories: List, history: List, tracks: List) { @@ -442,21 +399,12 @@ class BackupRestoreService : Service() { * @return [Observable] that contains manga */ private fun chapterFetchObservable(source: Source, manga: Manga, chapters: List): Observable, List>> { - return backupManager.restoreChapterFetchObservable(source, manga, chapters, throttleManager) - // If there's any error, return empty update and continue. - .onErrorReturn { - // [EXH] - XLog.w("> Failed to restore chapter!", it) - XLog.w("> (source.id: %s, source.name: %s, manga.id: %s, manga.url: %s, chapters.size: %s)", - source.id, - source.name, - manga.id, - manga.url, - chapters.size) - - errors.add(Date() to "${manga.title} - ${it.message}") - Pair(emptyList(), emptyList()) - } + return backupManager.restoreChapterFetchObservable(source, manga, chapters) + // If there's any error, return empty update and continue. + .onErrorReturn { + errors.add(Date() to "${manga.title} - ${it.message}") + Pair(emptyList(), emptyList()) + } } /** @@ -467,20 +415,20 @@ class BackupRestoreService : Service() { */ private fun trackingFetchObservable(manga: Manga, tracks: List): Observable { return Observable.from(tracks) - .concatMap { track -> - val service = trackManager.getService(track.sync_id) - if (service != null && service.isLogged) { - service.refresh(track) - .doOnNext { db.insertTrack(it).executeAsBlocking() } - .onErrorReturn { - errors.add(Date() to "${manga.title} - ${it.message}") - track - } - } else { - errors.add(Date() to "${manga.title} - ${service?.name} not logged in") - Observable.empty() - } + .concatMap { track -> + val service = trackManager.getService(track.sync_id) + if (service != null && service.isLogged) { + service.refresh(track) + .doOnNext { db.insertTrack(it).executeAsBlocking() } + .onErrorReturn { + errors.add(Date() to "${manga.title} - ${it.message}") + track + } + } else { + errors.add(Date() to "${manga.title} - ${service?.name} not logged in") + Observable.empty() } + } } /** @@ -490,16 +438,33 @@ class BackupRestoreService : Service() { * @param amount total restoreAmount of manga * @param title title of restored manga */ - private fun showRestoreProgress(progress: Int, amount: Int, title: String, errors: Int, - content: String = getString(R.string.dialog_restoring_backup, title.chop(15))) { - val intent = Intent(BackupConst.INTENT_FILTER).apply { - putExtra(BackupConst.EXTRA_PROGRESS, progress) - putExtra(BackupConst.EXTRA_AMOUNT, amount) - putExtra(BackupConst.EXTRA_CONTENT, content) - putExtra(BackupConst.EXTRA_ERRORS, errors) - putExtra(BackupConst.ACTION, BackupConst.ACTION_SET_PROGRESS_DIALOG) - } - sendLocalBroadcast(intent) + private fun showRestoreProgress( + progress: Int, + amount: Int, + title: String + ) { + notifier.showRestoreProgress(title, progress, amount) } + /** + * Write errors to error log + */ + private fun writeErrorLog(): File { + try { + if (errors.isNotEmpty()) { + val destFile = File(externalCacheDir, "tachiyomi_restore.txt") + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()) + + destFile.bufferedWriter().use { out -> + errors.forEach { (date, message) -> + out.write("[${sdf.format(date)}] $message\n") + } + } + return destFile + } + } catch (e: Exception) { + // Empty + } + return File("") + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/Backup.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/Backup.kt index 3a5e2d343..917f27754 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/Backup.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/Backup.kt @@ -1,23 +1,25 @@ -package eu.kanade.tachiyomi.data.backup.models - -import java.text.SimpleDateFormat -import java.util.* - -/** - * Json values - */ -object Backup { - const val CURRENT_VERSION = 2 - const val MANGA = "manga" - const val MANGAS = "mangas" - const val TRACK = "track" - const val CHAPTERS = "chapters" - const val CATEGORIES = "categories" - const val HISTORY = "history" - const val VERSION = "version" - - fun getDefaultFilename(): String { - val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date()) - return "tachiyomi_$date.json" - } -} \ No newline at end of file +package eu.kanade.tachiyomi.data.backup.models + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Json values + */ +object Backup { + const val CURRENT_VERSION = 2 + const val MANGA = "manga" + const val MANGAS = "mangas" + const val TRACK = "track" + const val CHAPTERS = "chapters" + const val CATEGORIES = "categories" + const val EXTENSIONS = "extensions" + const val HISTORY = "history" + const val VERSION = "version" + + fun getDefaultFilename(): String { + val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date()) + return "tachiyomi_$date.json" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/DHistory.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/DHistory.kt index 3623dd0d3..a5e1c1a0f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/DHistory.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/DHistory.kt @@ -1,3 +1,3 @@ package eu.kanade.tachiyomi.data.backup.models -data class DHistory(val url: String,val lastRead: Long) \ No newline at end of file +data class DHistory(val url: String, val lastRead: Long) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/CategoryTypeAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/CategoryTypeAdapter.kt index b31279268..1beb5d979 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/CategoryTypeAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/CategoryTypeAdapter.kt @@ -28,4 +28,4 @@ object CategoryTypeAdapter { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/ChapterTypeAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/ChapterTypeAdapter.kt index 74002582d..9bd6e8e1e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/ChapterTypeAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/ChapterTypeAdapter.kt @@ -43,9 +43,7 @@ object ChapterTypeAdapter { beginObject() while (hasNext()) { if (peek() == JsonToken.NAME) { - val name = nextName() - - when (name) { + when (nextName()) { URL -> chapter.url = nextString() READ -> chapter.read = nextInt() == 1 BOOKMARK -> chapter.bookmark = nextInt() == 1 @@ -58,4 +56,4 @@ object ChapterTypeAdapter { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/HistoryTypeAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/HistoryTypeAdapter.kt index e313c3b90..863a1a1f3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/HistoryTypeAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/HistoryTypeAdapter.kt @@ -29,4 +29,4 @@ object HistoryTypeAdapter { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/MangaTypeAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/MangaTypeAdapter.kt index 1aaaeda10..a18b98f1d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/MangaTypeAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/MangaTypeAdapter.kt @@ -34,4 +34,4 @@ object MangaTypeAdapter { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/TrackTypeAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/TrackTypeAdapter.kt index 5ed5b23d5..de78b8c11 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/TrackTypeAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/TrackTypeAdapter.kt @@ -41,9 +41,7 @@ object TrackTypeAdapter { beginObject() while (hasNext()) { if (peek() == JsonToken.NAME) { - val name = nextName() - - when (name) { + when (nextName()) { TITLE -> track.title = nextString() SYNC -> track.sync_id = nextInt() MEDIA -> track.media_id = nextInt() @@ -58,4 +56,4 @@ object TrackTypeAdapter { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt index 47c25636b..c7f884b41 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt @@ -9,15 +9,15 @@ import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.source.model.Page -import eu.kanade.tachiyomi.util.DiskUtil -import eu.kanade.tachiyomi.util.saveTo +import eu.kanade.tachiyomi.util.storage.DiskUtil +import eu.kanade.tachiyomi.util.storage.saveTo +import java.io.File +import java.io.IOException import okhttp3.Response import okio.buffer import okio.sink import rx.Observable import uy.kohesive.injekt.injectLazy -import java.io.File -import java.io.IOException /** * Class used to create chapter cache @@ -29,6 +29,7 @@ import java.io.IOException * @constructor creates an instance of the chapter cache. */ class ChapterCache(private val context: Context) { + companion object { /** Name of cache directory. */ const val PARAMETER_CACHE_DIRECTORY = "chapter_disk_cache" @@ -96,16 +97,17 @@ class ChapterCache(private val context: Context) { */ fun removeFileFromCache(file: String): Boolean { // Make sure we don't delete the journal file (keeps track of cache). - if (file == "journal" || file.startsWith("journal.")) + if (file == "journal" || file.startsWith("journal.")) { return false + } - try { + return try { // Remove the extension from the file to get the key of the cache val key = file.substringBeforeLast(".") // Remove file from cache. - return diskCache.remove(key) + diskCache.remove(key) } catch (e: Exception) { - return false + false } } @@ -154,7 +156,6 @@ class ChapterCache(private val context: Context) { diskCache.flush() editor.commit() editor.abortUnlessCommitted() - } catch (e: Exception) { // Ignore. } finally { @@ -169,10 +170,10 @@ class ChapterCache(private val context: Context) { * @return true if in cache otherwise false. */ fun isImageInCache(imageUrl: String): Boolean { - try { - return diskCache.get(DiskUtil.hashKeyForDisk(imageUrl)) != null + return try { + diskCache.get(DiskUtil.hashKeyForDisk(imageUrl)) != null } catch (e: IOException) { - return false + false } } @@ -190,7 +191,7 @@ class ChapterCache(private val context: Context) { /** * Add image to cache. - * + * * @param imageUrl url of image. * @param response http response from page. * @throws IOException image error. @@ -220,4 +221,3 @@ class ChapterCache(private val context: Context) { return "${chapter.manga_id}${chapter.url}" } } - diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt index c13492d9d..a2bfab8a2 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt @@ -1,7 +1,7 @@ package eu.kanade.tachiyomi.data.cache import android.content.Context -import eu.kanade.tachiyomi.util.DiskUtil +import eu.kanade.tachiyomi.util.storage.DiskUtil import java.io.File import java.io.IOException import java.io.InputStream @@ -20,8 +20,8 @@ class CoverCache(private val context: Context) { /** * Cache directory used for cache management. */ - private val cacheDir = context.getExternalFilesDir("covers") ?: - File(context.filesDir, "covers").also { it.mkdirs() } + private val cacheDir = context.getExternalFilesDir("covers") + ?: File(context.filesDir, "covers").also { it.mkdirs() } /** * Returns the cover from cache. @@ -37,7 +37,7 @@ class CoverCache(private val context: Context) { * Copy the given stream to this cache. * * @param thumbnailUrl url of the thumbnail. - * @param inputStream the stream to copy. + * @param inputStream the stream to copy. * @throws IOException if there's any error. */ @Throws(IOException::class) @@ -56,12 +56,12 @@ class CoverCache(private val context: Context) { */ fun deleteFromCache(thumbnailUrl: String?): Boolean { // Check if url is empty. - if (thumbnailUrl.isNullOrEmpty()) + if (thumbnailUrl.isNullOrEmpty()) { return false + } // Remove file. val file = getCoverFile(thumbnailUrl) return file.exists() && file.delete() } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/DatabaseHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/DatabaseHelper.kt index 812667411..220c88f19 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/DatabaseHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/DatabaseHelper.kt @@ -3,46 +3,51 @@ package eu.kanade.tachiyomi.data.database import android.content.Context import androidx.sqlite.db.SupportSQLiteOpenHelper import com.pushtorefresh.storio.sqlite.impl.DefaultStorIOSQLite -import eu.kanade.tachiyomi.data.database.mappers.* -import eu.kanade.tachiyomi.data.database.models.* -import eu.kanade.tachiyomi.data.database.queries.* -import exh.metadata.sql.mappers.SearchMetadataTypeMapping -import exh.metadata.sql.mappers.SearchTagTypeMapping -import exh.metadata.sql.mappers.SearchTitleTypeMapping -import exh.metadata.sql.models.SearchMetadata -import exh.metadata.sql.models.SearchTag -import exh.metadata.sql.models.SearchTitle -import exh.metadata.sql.queries.SearchMetadataQueries -import exh.metadata.sql.queries.SearchTagQueries -import exh.metadata.sql.queries.SearchTitleQueries +import eu.kanade.tachiyomi.data.database.mappers.CategoryTypeMapping +import eu.kanade.tachiyomi.data.database.mappers.ChapterTypeMapping +import eu.kanade.tachiyomi.data.database.mappers.HistoryTypeMapping +import eu.kanade.tachiyomi.data.database.mappers.MangaCategoryTypeMapping +import eu.kanade.tachiyomi.data.database.mappers.MangaTypeMapping +import eu.kanade.tachiyomi.data.database.mappers.TrackTypeMapping +import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.database.models.History +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.MangaCategory +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.database.queries.CategoryQueries +import eu.kanade.tachiyomi.data.database.queries.ChapterQueries +import eu.kanade.tachiyomi.data.database.queries.HistoryQueries +import eu.kanade.tachiyomi.data.database.queries.MangaCategoryQueries +import eu.kanade.tachiyomi.data.database.queries.MangaQueries +import eu.kanade.tachiyomi.data.database.queries.TrackQueries import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory /** * This class provides operations to manage the database through its interfaces. */ -open class DatabaseHelper(context: Context) - : MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries, - /* EXH --> */ SearchMetadataQueries, SearchTagQueries, SearchTitleQueries /* EXH <-- */ -{ +open class DatabaseHelper(context: Context) : + MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries, /* EXH --> */ SearchMetadataQueries, SearchTagQueries, SearchTitleQueries /* EXH <-- */ { + private val configuration = SupportSQLiteOpenHelper.Configuration.builder(context) .name(DbOpenCallback.DATABASE_NAME) .callback(DbOpenCallback()) .build() override val db = DefaultStorIOSQLite.builder() - .sqliteOpenHelper(RequerySQLiteOpenHelperFactory().create(configuration)) - .addTypeMapping(Manga::class.java, MangaTypeMapping()) - .addTypeMapping(Chapter::class.java, ChapterTypeMapping()) - .addTypeMapping(Track::class.java, TrackTypeMapping()) - .addTypeMapping(Category::class.java, CategoryTypeMapping()) - .addTypeMapping(MangaCategory::class.java, MangaCategoryTypeMapping()) - .addTypeMapping(History::class.java, HistoryTypeMapping()) - // EXH --> - .addTypeMapping(SearchMetadata::class.java, SearchMetadataTypeMapping()) - .addTypeMapping(SearchTag::class.java, SearchTagTypeMapping()) - .addTypeMapping(SearchTitle::class.java, SearchTitleTypeMapping()) - // EXH <-- - .build() + .sqliteOpenHelper(RequerySQLiteOpenHelperFactory().create(configuration)) + .addTypeMapping(Manga::class.java, MangaTypeMapping()) + .addTypeMapping(Chapter::class.java, ChapterTypeMapping()) + .addTypeMapping(Track::class.java, TrackTypeMapping()) + .addTypeMapping(Category::class.java, CategoryTypeMapping()) + .addTypeMapping(MangaCategory::class.java, MangaCategoryTypeMapping()) + .addTypeMapping(History::class.java, HistoryTypeMapping()) + // EXH --> + .addTypeMapping(SearchMetadata::class.java, SearchMetadataTypeMapping()) + .addTypeMapping(SearchTag::class.java, SearchTagTypeMapping()) + .addTypeMapping(SearchTitle::class.java, SearchTitleTypeMapping()) + // EXH <-- + .build() inline fun inTransaction(block: () -> Unit) = db.inTransaction(block) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/DbExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/DbExtensions.kt index 252ac0829..caaba0e10 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/DbExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/DbExtensions.kt @@ -22,4 +22,3 @@ inline fun StorIOSQLite.inTransactionReturn(block: () -> T): T { lowLevel().endTransaction() } } - diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenCallback.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenCallback.kt index 3eb00f694..f7dd90dfc 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenCallback.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenCallback.kt @@ -2,7 +2,12 @@ package eu.kanade.tachiyomi.data.database import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteOpenHelper -import eu.kanade.tachiyomi.data.database.tables.* +import eu.kanade.tachiyomi.data.database.tables.CategoryTable +import eu.kanade.tachiyomi.data.database.tables.ChapterTable +import eu.kanade.tachiyomi.data.database.tables.HistoryTable +import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable +import eu.kanade.tachiyomi.data.database.tables.MangaTable +import eu.kanade.tachiyomi.data.database.tables.TrackTable import exh.metadata.sql.tables.SearchMetadataTable import exh.metadata.sql.tables.SearchTagTable import exh.metadata.sql.tables.SearchTitleTable @@ -18,7 +23,7 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) { /** * Version of the database. */ - const val DATABASE_VERSION = 9 // [EXH] + const val DATABASE_VERSION = 0 // [SY] } override fun onCreate(db: SupportSQLiteDatabase) = with(db) { @@ -51,54 +56,18 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) { } override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) { - if (oldVersion < 2) { + if (oldVersion < 0) { db.execSQL(ChapterTable.sourceOrderUpdateQuery) // Fix kissmanga covers after supporting cloudflare - db.execSQL("""UPDATE mangas SET thumbnail_url = - REPLACE(thumbnail_url, '93.174.95.110', 'kissmanga.com') WHERE source = 4""") + db.execSQL( + """UPDATE mangas SET thumbnail_url = + REPLACE(thumbnail_url, '93.174.95.110', 'kissmanga.com') WHERE source = 4""" + ) } - if (oldVersion < 3) { - // Initialize history tables - db.execSQL(HistoryTable.createTableQuery) - db.execSQL(HistoryTable.createChapterIdIndexQuery) - } - if (oldVersion < 4) { - db.execSQL(ChapterTable.bookmarkUpdateQuery) - } - if (oldVersion < 5) { - db.execSQL(ChapterTable.addScanlator) - } - if (oldVersion < 6) { - db.execSQL(TrackTable.addTrackingUrl) - } - if (oldVersion < 7) { - db.execSQL(TrackTable.addLibraryId) - } - if (oldVersion < 8) { - db.execSQL("DROP INDEX IF EXISTS mangas_favorite_index") - db.execSQL(MangaTable.createLibraryIndexQuery) - db.execSQL(ChapterTable.createUnreadChaptersIndexQuery) - } - // EXH --> - if (oldVersion < 9) { - db.execSQL(SearchMetadataTable.createTableQuery) - db.execSQL(SearchTagTable.createTableQuery) - db.execSQL(SearchTitleTable.createTableQuery) - - db.execSQL(SearchMetadataTable.createUploaderIndexQuery) - db.execSQL(SearchMetadataTable.createIndexedExtraIndexQuery) - db.execSQL(SearchTagTable.createMangaIdIndexQuery) - db.execSQL(SearchTagTable.createNamespaceNameIndexQuery) - db.execSQL(SearchTitleTable.createMangaIdIndexQuery) - db.execSQL(SearchTitleTable.createTitleIndexQuery) - } - // Remember to increment any Tachiyomi database upgrades after this - // EXH <-- } override fun onConfigure(db: SupportSQLiteDatabase) { db.setForeignKeyConstraintsEnabled(true) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/DbProvider.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/DbProvider.kt index 7af8dff0c..4609852b9 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/DbProvider.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/DbProvider.kt @@ -5,5 +5,4 @@ import com.pushtorefresh.storio.sqlite.impl.DefaultStorIOSQLite interface DbProvider { val db: DefaultStorIOSQLite - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/CategoryTypeMapping.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/CategoryTypeMapping.kt index 0069934be..4cf2d1eea 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/CategoryTypeMapping.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/CategoryTypeMapping.kt @@ -18,22 +18,22 @@ import eu.kanade.tachiyomi.data.database.tables.CategoryTable.COL_ORDER import eu.kanade.tachiyomi.data.database.tables.CategoryTable.TABLE class CategoryTypeMapping : SQLiteTypeMapping( - CategoryPutResolver(), - CategoryGetResolver(), - CategoryDeleteResolver() + CategoryPutResolver(), + CategoryGetResolver(), + CategoryDeleteResolver() ) class CategoryPutResolver : DefaultPutResolver() { override fun mapToInsertQuery(obj: Category) = InsertQuery.builder() - .table(TABLE) - .build() + .table(TABLE) + .build() override fun mapToUpdateQuery(obj: Category) = UpdateQuery.builder() - .table(TABLE) - .where("$COL_ID = ?") - .whereArgs(obj.id) - .build() + .table(TABLE) + .where("$COL_ID = ?") + .whereArgs(obj.id) + .build() override fun mapToContentValues(obj: Category) = ContentValues(4).apply { put(COL_ID, obj.id) @@ -56,8 +56,8 @@ class CategoryGetResolver : DefaultGetResolver() { class CategoryDeleteResolver : DefaultDeleteResolver() { override fun mapToDeleteQuery(obj: Category) = DeleteQuery.builder() - .table(TABLE) - .where("$COL_ID = ?") - .whereArgs(obj.id) - .build() + .table(TABLE) + .where("$COL_ID = ?") + .whereArgs(obj.id) + .build() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/ChapterTypeMapping.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/ChapterTypeMapping.kt index 2e903a64b..9d5810e01 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/ChapterTypeMapping.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/ChapterTypeMapping.kt @@ -26,22 +26,22 @@ import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_URL import eu.kanade.tachiyomi.data.database.tables.ChapterTable.TABLE class ChapterTypeMapping : SQLiteTypeMapping( - ChapterPutResolver(), - ChapterGetResolver(), - ChapterDeleteResolver() + ChapterPutResolver(), + ChapterGetResolver(), + ChapterDeleteResolver() ) class ChapterPutResolver : DefaultPutResolver() { override fun mapToInsertQuery(obj: Chapter) = InsertQuery.builder() - .table(TABLE) - .build() + .table(TABLE) + .build() override fun mapToUpdateQuery(obj: Chapter) = UpdateQuery.builder() - .table(TABLE) - .where("$COL_ID = ?") - .whereArgs(obj.id) - .build() + .table(TABLE) + .where("$COL_ID = ?") + .whereArgs(obj.id) + .build() override fun mapToContentValues(obj: Chapter) = ContentValues(11).apply { put(COL_ID, obj.id) @@ -80,9 +80,8 @@ class ChapterGetResolver : DefaultGetResolver() { class ChapterDeleteResolver : DefaultDeleteResolver() { override fun mapToDeleteQuery(obj: Chapter) = DeleteQuery.builder() - .table(TABLE) - .where("$COL_ID = ?") - .whereArgs(obj.id) - .build() + .table(TABLE) + .where("$COL_ID = ?") + .whereArgs(obj.id) + .build() } - diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/HistoryTypeMapping.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/HistoryTypeMapping.kt index fdd96f69a..e3da10169 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/HistoryTypeMapping.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/HistoryTypeMapping.kt @@ -18,22 +18,22 @@ import eu.kanade.tachiyomi.data.database.tables.HistoryTable.COL_TIME_READ import eu.kanade.tachiyomi.data.database.tables.HistoryTable.TABLE class HistoryTypeMapping : SQLiteTypeMapping( - HistoryPutResolver(), - HistoryGetResolver(), - HistoryDeleteResolver() + HistoryPutResolver(), + HistoryGetResolver(), + HistoryDeleteResolver() ) open class HistoryPutResolver : DefaultPutResolver() { override fun mapToInsertQuery(obj: History) = InsertQuery.builder() - .table(TABLE) - .build() + .table(TABLE) + .build() override fun mapToUpdateQuery(obj: History) = UpdateQuery.builder() - .table(TABLE) - .where("$COL_ID = ?") - .whereArgs(obj.id) - .build() + .table(TABLE) + .where("$COL_ID = ?") + .whereArgs(obj.id) + .build() override fun mapToContentValues(obj: History) = ContentValues(4).apply { put(COL_ID, obj.id) @@ -56,8 +56,8 @@ class HistoryGetResolver : DefaultGetResolver() { class HistoryDeleteResolver : DefaultDeleteResolver() { override fun mapToDeleteQuery(obj: History) = DeleteQuery.builder() - .table(TABLE) - .where("$COL_ID = ?") - .whereArgs(obj.id) - .build() + .table(TABLE) + .where("$COL_ID = ?") + .whereArgs(obj.id) + .build() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/MangaCategoryTypeMapping.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/MangaCategoryTypeMapping.kt index 99cfdd47c..24fff124c 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/MangaCategoryTypeMapping.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/MangaCategoryTypeMapping.kt @@ -16,22 +16,22 @@ import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable.COL_MANGA_ID import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable.TABLE class MangaCategoryTypeMapping : SQLiteTypeMapping( - MangaCategoryPutResolver(), - MangaCategoryGetResolver(), - MangaCategoryDeleteResolver() + MangaCategoryPutResolver(), + MangaCategoryGetResolver(), + MangaCategoryDeleteResolver() ) class MangaCategoryPutResolver : DefaultPutResolver() { override fun mapToInsertQuery(obj: MangaCategory) = InsertQuery.builder() - .table(TABLE) - .build() + .table(TABLE) + .build() override fun mapToUpdateQuery(obj: MangaCategory) = UpdateQuery.builder() - .table(TABLE) - .where("$COL_ID = ?") - .whereArgs(obj.id) - .build() + .table(TABLE) + .where("$COL_ID = ?") + .whereArgs(obj.id) + .build() override fun mapToContentValues(obj: MangaCategory) = ContentValues(3).apply { put(COL_ID, obj.id) @@ -52,8 +52,8 @@ class MangaCategoryGetResolver : DefaultGetResolver() { class MangaCategoryDeleteResolver : DefaultDeleteResolver() { override fun mapToDeleteQuery(obj: MangaCategory) = DeleteQuery.builder() - .table(TABLE) - .where("$COL_ID = ?") - .whereArgs(obj.id) - .build() + .table(TABLE) + .where("$COL_ID = ?") + .whereArgs(obj.id) + .build() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/MangaTypeMapping.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/MangaTypeMapping.kt index 9ad72908f..001a83c00 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/MangaTypeMapping.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/MangaTypeMapping.kt @@ -29,22 +29,22 @@ import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_VIEWER import eu.kanade.tachiyomi.data.database.tables.MangaTable.TABLE class MangaTypeMapping : SQLiteTypeMapping( - MangaPutResolver(), - MangaGetResolver(), - MangaDeleteResolver() + MangaPutResolver(), + MangaGetResolver(), + MangaDeleteResolver() ) class MangaPutResolver : DefaultPutResolver() { override fun mapToInsertQuery(obj: Manga) = InsertQuery.builder() - .table(TABLE) - .build() + .table(TABLE) + .build() override fun mapToUpdateQuery(obj: Manga) = UpdateQuery.builder() - .table(TABLE) - .where("$COL_ID = ?") - .whereArgs(obj.id) - .build() + .table(TABLE) + .where("$COL_ID = ?") + .whereArgs(obj.id) + .build() override fun mapToContentValues(obj: Manga) = ContentValues(15).apply { put(COL_ID, obj.id) @@ -95,8 +95,8 @@ open class MangaGetResolver : DefaultGetResolver(), BaseMangaGetResolver class MangaDeleteResolver : DefaultDeleteResolver() { override fun mapToDeleteQuery(obj: Manga) = DeleteQuery.builder() - .table(TABLE) - .where("$COL_ID = ?") - .whereArgs(obj.id) - .build() + .table(TABLE) + .where("$COL_ID = ?") + .whereArgs(obj.id) + .build() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/TrackTypeMapping.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/TrackTypeMapping.kt index 6759316de..94de567ad 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/TrackTypeMapping.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/TrackTypeMapping.kt @@ -11,12 +11,14 @@ import com.pushtorefresh.storio.sqlite.queries.InsertQuery import com.pushtorefresh.storio.sqlite.queries.UpdateQuery import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.TrackImpl +import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_FINISH_DATE import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_ID import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_LAST_CHAPTER_READ import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_LIBRARY_ID import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_MANGA_ID import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_MEDIA_ID import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SCORE +import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_START_DATE import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_STATUS import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SYNC_ID import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TITLE @@ -25,22 +27,22 @@ import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TRACKING_URL import eu.kanade.tachiyomi.data.database.tables.TrackTable.TABLE class TrackTypeMapping : SQLiteTypeMapping( - TrackPutResolver(), - TrackGetResolver(), - TrackDeleteResolver() + TrackPutResolver(), + TrackGetResolver(), + TrackDeleteResolver() ) class TrackPutResolver : DefaultPutResolver() { override fun mapToInsertQuery(obj: Track) = InsertQuery.builder() - .table(TABLE) - .build() + .table(TABLE) + .build() override fun mapToUpdateQuery(obj: Track) = UpdateQuery.builder() - .table(TABLE) - .where("$COL_ID = ?") - .whereArgs(obj.id) - .build() + .table(TABLE) + .where("$COL_ID = ?") + .whereArgs(obj.id) + .build() override fun mapToContentValues(obj: Track) = ContentValues(10).apply { put(COL_ID, obj.id) @@ -54,7 +56,8 @@ class TrackPutResolver : DefaultPutResolver() { put(COL_STATUS, obj.status) put(COL_TRACKING_URL, obj.tracking_url) put(COL_SCORE, obj.score) - + put(COL_START_DATE, obj.started_reading_date) + put(COL_FINISH_DATE, obj.finished_reading_date) } } @@ -72,14 +75,16 @@ class TrackGetResolver : DefaultGetResolver() { status = cursor.getInt(cursor.getColumnIndex(COL_STATUS)) score = cursor.getFloat(cursor.getColumnIndex(COL_SCORE)) tracking_url = cursor.getString(cursor.getColumnIndex(COL_TRACKING_URL)) + started_reading_date = cursor.getLong(cursor.getColumnIndex(COL_START_DATE)) + finished_reading_date = cursor.getLong(cursor.getColumnIndex(COL_FINISH_DATE)) } } class TrackDeleteResolver : DefaultDeleteResolver() { override fun mapToDeleteQuery(obj: Track) = DeleteQuery.builder() - .table(TABLE) - .where("$COL_ID = ?") - .whereArgs(obj.id) - .build() + .table(TABLE) + .where("$COL_ID = ?") + .whereArgs(obj.id) + .build() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Category.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Category.kt index c88d93261..3657caa72 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Category.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Category.kt @@ -23,5 +23,4 @@ interface Category : Serializable { fun createDefault(): Category = create("Default").apply { id = 0 } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/CategoryImpl.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/CategoryImpl.kt index 65a004477..3e59a674e 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/CategoryImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/CategoryImpl.kt @@ -22,5 +22,4 @@ class CategoryImpl : Category { override fun hashCode(): Int { return name.hashCode() } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/ChapterImpl.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/ChapterImpl.kt index d2067abfd..167df2b05 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/ChapterImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/ChapterImpl.kt @@ -37,5 +37,4 @@ class ChapterImpl : Chapter { override fun hashCode(): Int { return url.hashCode() } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/History.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/History.kt index 30f50972c..dff3bcb15 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/History.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/History.kt @@ -35,7 +35,7 @@ interface History : Serializable { * @param chapter chapter object * @return history object */ - fun create(chapter: Chapter): History = HistoryImpl().apply { + fun create(chapter: Chapter): History = HistoryImpl().apply { this.chapter_id = chapter.id!! } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/LibraryManga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/LibraryManga.kt index b9a7d9428..35cf30c67 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/LibraryManga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/LibraryManga.kt @@ -5,5 +5,4 @@ class LibraryManga : MangaImpl() { var unread: Int = 0 var category: Int = 0 - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.kt index 1782662dc..ac91a41da 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.kt @@ -28,6 +28,10 @@ interface Manga : SManga { return chapter_flags and SORT_MASK == SORT_DESC } + fun getGenres(): List? { + return genre?.split(", ")?.map { it.trim() } + } + // Used to display the chapter's title one way or another var displayMode: Int get() = chapter_flags and DISPLAY_MASK @@ -88,5 +92,4 @@ interface Manga : SManga { this.source = source } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaCategory.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaCategory.kt index 305d5ef9c..220337088 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaCategory.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaCategory.kt @@ -17,5 +17,4 @@ class MangaCategory { return mc } } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaChapterHistory.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaChapterHistory.kt index e11fe8f83..5ed7ed455 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaChapterHistory.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaChapterHistory.kt @@ -5,6 +5,6 @@ package eu.kanade.tachiyomi.data.database.models * * @param manga object containing manga * @param chapter object containing chater - * @param history object containing history + * @param history object containing history */ data class MangaChapterHistory(val manga: Manga, val chapter: Chapter, val history: History) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaImpl.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaImpl.kt index 9932d3869..b2fb78525 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaImpl.kt @@ -39,11 +39,9 @@ open class MangaImpl : Manga { val manga = other as Manga return url == manga.url - } override fun hashCode(): Int { return url.hashCode() } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Track.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Track.kt index 19133e037..0f3815c54 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Track.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Track.kt @@ -24,12 +24,18 @@ interface Track : Serializable { var status: Int + var started_reading_date: Long + + var finished_reading_date: Long + var tracking_url: String fun copyPersonalFrom(other: Track) { last_chapter_read = other.last_chapter_read score = other.score status = other.status + started_reading_date = other.started_reading_date + finished_reading_date = other.finished_reading_date } companion object { @@ -37,5 +43,4 @@ interface Track : Serializable { sync_id = serviceId } } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt index 65f6ec7ab..6f5991133 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt @@ -22,6 +22,10 @@ class TrackImpl : Track { override var status: Int = 0 + override var started_reading_date: Long = 0 + + override var finished_reading_date: Long = 0 + override var tracking_url: String = "" override fun equals(other: Any?): Boolean { @@ -41,5 +45,4 @@ class TrackImpl : Track { result = 31 * result + media_id return result } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/CategoryQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/CategoryQueries.kt index edf839d8f..bf769b154 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/CategoryQueries.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/CategoryQueries.kt @@ -10,20 +10,24 @@ import eu.kanade.tachiyomi.data.database.tables.CategoryTable interface CategoryQueries : DbProvider { fun getCategories() = db.get() - .listOfObjects(Category::class.java) - .withQuery(Query.builder() - .table(CategoryTable.TABLE) - .orderBy(CategoryTable.COL_ORDER) - .build()) - .prepare() + .listOfObjects(Category::class.java) + .withQuery( + Query.builder() + .table(CategoryTable.TABLE) + .orderBy(CategoryTable.COL_ORDER) + .build() + ) + .prepare() fun getCategoriesForManga(manga: Manga) = db.get() - .listOfObjects(Category::class.java) - .withQuery(RawQuery.builder() - .query(getCategoriesForMangaQuery()) - .args(manga.id) - .build()) - .prepare() + .listOfObjects(Category::class.java) + .withQuery( + RawQuery.builder() + .query(getCategoriesForMangaQuery()) + .args(manga.id) + .build() + ) + .prepare() fun insertCategory(category: Category) = db.put().`object`(category).prepare() @@ -32,5 +36,4 @@ interface CategoryQueries : DbProvider { fun deleteCategory(category: Category) = db.delete().`object`(category).prepare() fun deleteCategories(categories: List) = db.delete().objects(categories).prepare() - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/ChapterQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/ChapterQueries.kt index 90ac910d8..b6a47ea04 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/ChapterQueries.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/ChapterQueries.kt @@ -11,7 +11,7 @@ import eu.kanade.tachiyomi.data.database.resolvers.ChapterProgressPutResolver import eu.kanade.tachiyomi.data.database.resolvers.ChapterSourceOrderPutResolver import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterGetResolver import eu.kanade.tachiyomi.data.database.tables.ChapterTable -import java.util.* +import java.util.Date interface ChapterQueries : DbProvider { @@ -27,32 +27,49 @@ interface ChapterQueries : DbProvider { .prepare() fun getRecentChapters(date: Date) = db.get() - .listOfObjects(MangaChapter::class.java) - .withQuery(RawQuery.builder() - .query(getRecentsQuery()) - .args(date.time) - .observesTables(ChapterTable.TABLE) - .build()) - .withGetResolver(MangaChapterGetResolver.INSTANCE) - .prepare() + .listOfObjects(MangaChapter::class.java) + .withQuery( + RawQuery.builder() + .query(getRecentsQuery()) + .args(date.time) + .observesTables(ChapterTable.TABLE) + .build() + ) + .withGetResolver(MangaChapterGetResolver.INSTANCE) + .prepare() fun getChapter(id: Long) = db.get() - .`object`(Chapter::class.java) - .withQuery(Query.builder() - .table(ChapterTable.TABLE) - .where("${ChapterTable.COL_ID} = ?") - .whereArgs(id) - .build()) - .prepare() + .`object`(Chapter::class.java) + .withQuery( + Query.builder() + .table(ChapterTable.TABLE) + .where("${ChapterTable.COL_ID} = ?") + .whereArgs(id) + .build() + ) + .prepare() fun getChapter(url: String) = db.get() - .`object`(Chapter::class.java) - .withQuery(Query.builder() - .table(ChapterTable.TABLE) - .where("${ChapterTable.COL_URL} = ?") - .whereArgs(url) - .build()) - .prepare() + .`object`(Chapter::class.java) + .withQuery( + Query.builder() + .table(ChapterTable.TABLE) + .where("${ChapterTable.COL_URL} = ?") + .whereArgs(url) + .build() + ) + .prepare() + + fun getChapter(url: String, mangaId: Long) = db.get() + .`object`(Chapter::class.java) + .withQuery( + Query.builder() + .table(ChapterTable.TABLE) + .where("${ChapterTable.COL_URL} = ? AND ${ChapterTable.COL_MANGA_ID} = ?") + .whereArgs(url, mangaId) + .build() + ) + .prepare() fun getChapters(url: String) = db.get() .listOfObjects(Chapter::class.java) @@ -73,23 +90,22 @@ interface ChapterQueries : DbProvider { fun deleteChapters(chapters: List) = db.delete().objects(chapters).prepare() fun updateChaptersBackup(chapters: List) = db.put() - .objects(chapters) - .withPutResolver(ChapterBackupPutResolver()) - .prepare() + .objects(chapters) + .withPutResolver(ChapterBackupPutResolver()) + .prepare() fun updateChapterProgress(chapter: Chapter) = db.put() - .`object`(chapter) - .withPutResolver(ChapterProgressPutResolver()) - .prepare() + .`object`(chapter) + .withPutResolver(ChapterProgressPutResolver()) + .prepare() fun updateChaptersProgress(chapters: List) = db.put() - .objects(chapters) - .withPutResolver(ChapterProgressPutResolver()) - .prepare() + .objects(chapters) + .withPutResolver(ChapterProgressPutResolver()) + .prepare() fun fixChaptersSourceOrder(chapters: List) = db.put() - .objects(chapters) - .withPutResolver(ChapterSourceOrderPutResolver()) - .prepare() - -} \ No newline at end of file + .objects(chapters) + .withPutResolver(ChapterSourceOrderPutResolver()) + .prepare() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/HistoryQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/HistoryQueries.kt index fc17e36ec..fa4072dd9 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/HistoryQueries.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/HistoryQueries.kt @@ -8,7 +8,7 @@ import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory import eu.kanade.tachiyomi.data.database.resolvers.HistoryLastReadPutResolver import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterHistoryGetResolver import eu.kanade.tachiyomi.data.database.tables.HistoryTable -import java.util.* +import java.util.Date interface HistoryQueries : DbProvider { @@ -23,32 +23,38 @@ interface HistoryQueries : DbProvider { * @param date recent date range */ fun getRecentManga(date: Date) = db.get() - .listOfObjects(MangaChapterHistory::class.java) - .withQuery(RawQuery.builder() - .query(getRecentMangasQuery()) - .args(date.time) - .observesTables(HistoryTable.TABLE) - .build()) - .withGetResolver(MangaChapterHistoryGetResolver.INSTANCE) - .prepare() + .listOfObjects(MangaChapterHistory::class.java) + .withQuery( + RawQuery.builder() + .query(getRecentMangasQuery()) + .args(date.time) + .observesTables(HistoryTable.TABLE) + .build() + ) + .withGetResolver(MangaChapterHistoryGetResolver.INSTANCE) + .prepare() fun getHistoryByMangaId(mangaId: Long) = db.get() - .listOfObjects(History::class.java) - .withQuery(RawQuery.builder() - .query(getHistoryByMangaId()) - .args(mangaId) - .observesTables(HistoryTable.TABLE) - .build()) - .prepare() + .listOfObjects(History::class.java) + .withQuery( + RawQuery.builder() + .query(getHistoryByMangaId()) + .args(mangaId) + .observesTables(HistoryTable.TABLE) + .build() + ) + .prepare() fun getHistoryByChapterUrl(chapterUrl: String) = db.get() - .`object`(History::class.java) - .withQuery(RawQuery.builder() - .query(getHistoryByChapterUrl()) - .args(chapterUrl) - .observesTables(HistoryTable.TABLE) - .build()) - .prepare() + .`object`(History::class.java) + .withQuery( + RawQuery.builder() + .query(getHistoryByChapterUrl()) + .args(chapterUrl) + .observesTables(HistoryTable.TABLE) + .build() + ) + .prepare() /** * Updates the history last read. @@ -56,9 +62,9 @@ interface HistoryQueries : DbProvider { * @param history history object */ fun updateHistoryLastRead(history: History) = db.put() - .`object`(history) - .withPutResolver(HistoryLastReadPutResolver()) - .prepare() + .`object`(history) + .withPutResolver(HistoryLastReadPutResolver()) + .prepare() /** * Updates the history last read. @@ -66,21 +72,25 @@ interface HistoryQueries : DbProvider { * @param historyList history object list */ fun updateHistoryLastRead(historyList: List) = db.put() - .objects(historyList) - .withPutResolver(HistoryLastReadPutResolver()) - .prepare() + .objects(historyList) + .withPutResolver(HistoryLastReadPutResolver()) + .prepare() fun deleteHistory() = db.delete() - .byQuery(DeleteQuery.builder() - .table(HistoryTable.TABLE) - .build()) - .prepare() + .byQuery( + DeleteQuery.builder() + .table(HistoryTable.TABLE) + .build() + ) + .prepare() fun deleteHistoryNoLastRead() = db.delete() - .byQuery(DeleteQuery.builder() - .table(HistoryTable.TABLE) - .where("${HistoryTable.COL_LAST_READ} = ?") - .whereArgs(0) - .build()) - .prepare() + .byQuery( + DeleteQuery.builder() + .table(HistoryTable.TABLE) + .where("${HistoryTable.COL_LAST_READ} = ?") + .whereArgs(0) + .build() + ) + .prepare() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/MangaCategoryQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/MangaCategoryQueries.kt index a9cb40c4e..49b127403 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/MangaCategoryQueries.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/MangaCategoryQueries.kt @@ -15,12 +15,14 @@ interface MangaCategoryQueries : DbProvider { fun insertMangasCategories(mangasCategories: List) = db.put().objects(mangasCategories).prepare() fun deleteOldMangasCategories(mangas: List) = db.delete() - .byQuery(DeleteQuery.builder() - .table(MangaCategoryTable.TABLE) - .where("${MangaCategoryTable.COL_MANGA_ID} IN (${Queries.placeholders(mangas.size)})") - .whereArgs(*mangas.map { it.id }.toTypedArray()) - .build()) - .prepare() + .byQuery( + DeleteQuery.builder() + .table(MangaCategoryTable.TABLE) + .where("${MangaCategoryTable.COL_MANGA_ID} IN (${Queries.placeholders(mangas.size)})") + .whereArgs(*mangas.map { it.id }.toTypedArray()) + .build() + ) + .prepare() fun setMangaCategories(mangasCategories: List, mangas: List) { db.inTransaction { @@ -32,5 +34,4 @@ interface MangaCategoryQueries : DbProvider { } } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/MangaQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/MangaQueries.kt index 6bda7b1a2..245caa4c0 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/MangaQueries.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/MangaQueries.kt @@ -6,7 +6,12 @@ import com.pushtorefresh.storio.sqlite.queries.RawQuery import eu.kanade.tachiyomi.data.database.DbProvider import eu.kanade.tachiyomi.data.database.models.LibraryManga import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.database.resolvers.* +import eu.kanade.tachiyomi.data.database.resolvers.LibraryMangaGetResolver +import eu.kanade.tachiyomi.data.database.resolvers.MangaFavoritePutResolver +import eu.kanade.tachiyomi.data.database.resolvers.MangaFlagsPutResolver +import eu.kanade.tachiyomi.data.database.resolvers.MangaLastUpdatedPutResolver +import eu.kanade.tachiyomi.data.database.resolvers.MangaTitlePutResolver +import eu.kanade.tachiyomi.data.database.resolvers.MangaViewerPutResolver import eu.kanade.tachiyomi.data.database.tables.CategoryTable import eu.kanade.tachiyomi.data.database.tables.ChapterTable import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable @@ -16,107 +21,140 @@ import exh.metadata.sql.tables.SearchMetadataTable interface MangaQueries : DbProvider { fun getMangas() = db.get() - .listOfObjects(Manga::class.java) - .withQuery(Query.builder() - .table(MangaTable.TABLE) - .build()) - .prepare() + .listOfObjects(Manga::class.java) + .withQuery( + Query.builder() + .table(MangaTable.TABLE) + .build() + ) + .prepare() fun getLibraryMangas() = db.get() - .listOfObjects(LibraryManga::class.java) - .withQuery(RawQuery.builder() - .query(libraryQuery) - .observesTables(MangaTable.TABLE, ChapterTable.TABLE, MangaCategoryTable.TABLE, CategoryTable.TABLE) - .build()) - .withGetResolver(LibraryMangaGetResolver.INSTANCE) - .prepare() + .listOfObjects(LibraryManga::class.java) + .withQuery( + RawQuery.builder() + .query(libraryQuery) + .observesTables(MangaTable.TABLE, ChapterTable.TABLE, MangaCategoryTable.TABLE, CategoryTable.TABLE) + .build() + ) + .withGetResolver(LibraryMangaGetResolver.INSTANCE) + .prepare() fun getFavoriteMangas() = db.get() - .listOfObjects(Manga::class.java) - .withQuery(Query.builder() - .table(MangaTable.TABLE) - .where("${MangaTable.COL_FAVORITE} = ?") - .whereArgs(1) - .orderBy(MangaTable.COL_TITLE) - .build()) - .prepare() + .listOfObjects(Manga::class.java) + .withQuery( + Query.builder() + .table(MangaTable.TABLE) + .where("${MangaTable.COL_FAVORITE} = ?") + .whereArgs(1) + .orderBy(MangaTable.COL_TITLE) + .build() + ) + .prepare() fun getManga(url: String, sourceId: Long) = db.get() - .`object`(Manga::class.java) - .withQuery(Query.builder() - .table(MangaTable.TABLE) - .where("${MangaTable.COL_URL} = ? AND ${MangaTable.COL_SOURCE} = ?") - .whereArgs(url, sourceId) - .build()) - .prepare() + .`object`(Manga::class.java) + .withQuery( + Query.builder() + .table(MangaTable.TABLE) + .where("${MangaTable.COL_URL} = ? AND ${MangaTable.COL_SOURCE} = ?") + .whereArgs(url, sourceId) + .build() + ) + .prepare() fun getManga(id: Long) = db.get() - .`object`(Manga::class.java) - .withQuery(Query.builder() - .table(MangaTable.TABLE) - .where("${MangaTable.COL_ID} = ?") - .whereArgs(id) - .build()) - .prepare() + .`object`(Manga::class.java) + .withQuery( + Query.builder() + .table(MangaTable.TABLE) + .where("${MangaTable.COL_ID} = ?") + .whereArgs(id) + .build() + ) + .prepare() fun insertManga(manga: Manga) = db.put().`object`(manga).prepare() fun insertMangas(mangas: List) = db.put().objects(mangas).prepare() fun updateFlags(manga: Manga) = db.put() - .`object`(manga) - .withPutResolver(MangaFlagsPutResolver()) - .prepare() + .`object`(manga) + .withPutResolver(MangaFlagsPutResolver()) + .prepare() fun updateLastUpdated(manga: Manga) = db.put() - .`object`(manga) - .withPutResolver(MangaLastUpdatedPutResolver()) - .prepare() + .`object`(manga) + .withPutResolver(MangaLastUpdatedPutResolver()) + .prepare() fun updateMangaFavorite(manga: Manga) = db.put() - .`object`(manga) - .withPutResolver(MangaFavoritePutResolver()) - .prepare() + .`object`(manga) + .withPutResolver(MangaFavoritePutResolver()) + .prepare() fun updateMangaViewer(manga: Manga) = db.put() - .`object`(manga) - .withPutResolver(MangaViewerPutResolver()) - .prepare() + .`object`(manga) + .withPutResolver(MangaViewerPutResolver()) + .prepare() fun updateMangaTitle(manga: Manga) = db.put() - .`object`(manga) - .withPutResolver(MangaTitlePutResolver()) - .prepare() + .`object`(manga) + .withPutResolver(MangaTitlePutResolver()) + .prepare() fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare() fun deleteMangas(mangas: List) = db.delete().objects(mangas).prepare() fun deleteMangasNotInLibrary() = db.delete() - .byQuery(DeleteQuery.builder() - .table(MangaTable.TABLE) - .where("${MangaTable.COL_FAVORITE} = ?") - .whereArgs(0) - .build()) - .prepare() + .byQuery( + DeleteQuery.builder() + .table(MangaTable.TABLE) + .where("${MangaTable.COL_FAVORITE} = ?") + .whereArgs(0) + .build() + ) + .prepare() fun deleteMangas() = db.delete() - .byQuery(DeleteQuery.builder() - .table(MangaTable.TABLE) - .build()) - .prepare() + .byQuery( + DeleteQuery.builder() + .table(MangaTable.TABLE) + .build() + ) + .prepare() fun getLastReadManga() = db.get() - .listOfObjects(Manga::class.java) - .withQuery(RawQuery.builder() - .query(getLastReadMangaQuery()) - .observesTables(MangaTable.TABLE) - .build()) - .prepare() + .listOfObjects(Manga::class.java) + .withQuery( + RawQuery.builder() + .query(getLastReadMangaQuery()) + .observesTables(MangaTable.TABLE) + .build() + ) + .prepare() - fun getTotalChapterManga() = db.get().listOfObjects(Manga::class.java) - .withQuery(RawQuery.builder().query(getTotalChapterMangaQuery()).observesTables(MangaTable.TABLE).build()).prepare(); + fun getTotalChapterManga() = db.get() + .listOfObjects(Manga::class.java) + .withQuery( + RawQuery.builder() + .query(getTotalChapterMangaQuery()) + .observesTables(MangaTable.TABLE) + .build() + ) + .prepare() + fun getLatestChapterManga() = db.get() + .listOfObjects(Manga::class.java) + .withQuery( + RawQuery.builder() + .query(getLatestChapterMangaQuery()) + .observesTables(MangaTable.TABLE) + .build() + ) + .prepare() + fun getMangaWithMetadata() = db.get() .listOfObjects(Manga::class.java) .withQuery(RawQuery.builder() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/RawQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/RawQueries.kt index daa5c48fd..b24c9beb0 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/RawQueries.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/RawQueries.kt @@ -9,7 +9,8 @@ import eu.kanade.tachiyomi.data.database.tables.MangaTable as Manga /** * Query to get the manga from the library, with their categories and unread count. */ -val libraryQuery = """ +val libraryQuery = + """ SELECT M.*, COALESCE(MC.${MangaCategory.COL_CATEGORY_ID}, 0) AS ${Manga.COL_CATEGORY} FROM ( SELECT ${Manga.TABLE}.*, COALESCE(C.unread, 0) AS ${Manga.COL_UNREAD} @@ -33,7 +34,8 @@ val libraryQuery = """ /** * Query to get the recent chapters of manga from the library up to a date. */ -fun getRecentsQuery() = """ +fun getRecentsQuery() = + """ SELECT ${Manga.TABLE}.${Manga.COL_URL} as mangaUrl, * FROM ${Manga.TABLE} JOIN ${Chapter.TABLE} ON ${Manga.TABLE}.${Manga.COL_ID} = ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} WHERE ${Manga.COL_FAVORITE} = 1 AND ${Chapter.COL_DATE_UPLOAD} > ? @@ -47,7 +49,8 @@ fun getRecentsQuery() = """ * and are read after the given time period * @return return limit is 25 */ -fun getRecentMangasQuery() = """ +fun getRecentMangasQuery() = + """ SELECT ${Manga.TABLE}.${Manga.COL_URL} as mangaUrl, ${Manga.TABLE}.*, ${Chapter.TABLE}.*, ${History.TABLE}.* FROM ${Manga.TABLE} JOIN ${Chapter.TABLE} @@ -65,7 +68,8 @@ fun getRecentMangasQuery() = """ LIMIT 25 """ -fun getHistoryByMangaId() = """ +fun getHistoryByMangaId() = + """ SELECT ${History.TABLE}.* FROM ${History.TABLE} JOIN ${Chapter.TABLE} @@ -73,7 +77,8 @@ fun getHistoryByMangaId() = """ WHERE ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} = ? AND ${History.TABLE}.${History.COL_CHAPTER_ID} = ${Chapter.TABLE}.${Chapter.COL_ID} """ -fun getHistoryByChapterUrl() = """ +fun getHistoryByChapterUrl() = + """ SELECT ${History.TABLE}.* FROM ${History.TABLE} JOIN ${Chapter.TABLE} @@ -81,7 +86,8 @@ fun getHistoryByChapterUrl() = """ WHERE ${Chapter.TABLE}.${Chapter.COL_URL} = ? AND ${History.TABLE}.${History.COL_CHAPTER_ID} = ${Chapter.TABLE}.${Chapter.COL_ID} """ -fun getLastReadMangaQuery() = """ +fun getLastReadMangaQuery() = + """ SELECT ${Manga.TABLE}.*, MAX(${History.TABLE}.${History.COL_LAST_READ}) AS max FROM ${Manga.TABLE} JOIN ${Chapter.TABLE} @@ -93,7 +99,8 @@ fun getLastReadMangaQuery() = """ ORDER BY max DESC """ -fun getTotalChapterMangaQuery()= """ +fun getTotalChapterMangaQuery() = + """ SELECT ${Manga.TABLE}.* FROM ${Manga.TABLE} JOIN ${Chapter.TABLE} @@ -102,12 +109,23 @@ fun getTotalChapterMangaQuery()= """ ORDER by COUNT(*) """ +fun getLatestChapterMangaQuery() = + """ + SELECT ${Manga.TABLE}.*, MAX(${Chapter.TABLE}.${Chapter.COL_DATE_UPLOAD}) AS max + FROM ${Manga.TABLE} + JOIN ${Chapter.TABLE} + ON ${Manga.TABLE}.${Manga.COL_ID} = ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} + GROUP BY ${Manga.TABLE}.${Manga.COL_ID} + ORDER by max DESC +""" + /** * Query to get the categories for a manga. */ -fun getCategoriesForMangaQuery() = """ +fun getCategoriesForMangaQuery() = + """ SELECT ${Category.TABLE}.* FROM ${Category.TABLE} JOIN ${MangaCategory.TABLE} ON ${Category.TABLE}.${Category.COL_ID} = ${MangaCategory.TABLE}.${MangaCategory.COL_CATEGORY_ID} WHERE ${MangaCategory.COL_MANGA_ID} = ? -""" \ No newline at end of file +""" diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/TrackQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/TrackQueries.kt index e215e72ea..7311fe40d 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/TrackQueries.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/TrackQueries.kt @@ -1,34 +1,37 @@ -package eu.kanade.tachiyomi.data.database.queries - -import com.pushtorefresh.storio.sqlite.queries.DeleteQuery -import com.pushtorefresh.storio.sqlite.queries.Query -import eu.kanade.tachiyomi.data.database.DbProvider -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.database.tables.TrackTable -import eu.kanade.tachiyomi.data.track.TrackService - -interface TrackQueries : DbProvider { - - fun getTracks(manga: Manga) = db.get() - .listOfObjects(Track::class.java) - .withQuery(Query.builder() - .table(TrackTable.TABLE) - .where("${TrackTable.COL_MANGA_ID} = ?") - .whereArgs(manga.id) - .build()) - .prepare() - - fun insertTrack(track: Track) = db.put().`object`(track).prepare() - - fun insertTracks(tracks: List) = db.put().objects(tracks).prepare() - - fun deleteTrackForManga(manga: Manga, sync: TrackService) = db.delete() - .byQuery(DeleteQuery.builder() - .table(TrackTable.TABLE) - .where("${TrackTable.COL_MANGA_ID} = ? AND ${TrackTable.COL_SYNC_ID} = ?") - .whereArgs(manga.id, sync.id) - .build()) - .prepare() - -} \ No newline at end of file +package eu.kanade.tachiyomi.data.database.queries + +import com.pushtorefresh.storio.sqlite.queries.DeleteQuery +import com.pushtorefresh.storio.sqlite.queries.Query +import eu.kanade.tachiyomi.data.database.DbProvider +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.database.tables.TrackTable +import eu.kanade.tachiyomi.data.track.TrackService + +interface TrackQueries : DbProvider { + + fun getTracks(manga: Manga) = db.get() + .listOfObjects(Track::class.java) + .withQuery( + Query.builder() + .table(TrackTable.TABLE) + .where("${TrackTable.COL_MANGA_ID} = ?") + .whereArgs(manga.id) + .build() + ) + .prepare() + + fun insertTrack(track: Track) = db.put().`object`(track).prepare() + + fun insertTracks(tracks: List) = db.put().objects(tracks).prepare() + + fun deleteTrackForManga(manga: Manga, sync: TrackService) = db.delete() + .byQuery( + DeleteQuery.builder() + .table(TrackTable.TABLE) + .where("${TrackTable.COL_MANGA_ID} = ? AND ${TrackTable.COL_SYNC_ID} = ?") + .whereArgs(manga.id, sync.id) + .build() + ) + .prepare() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/ChapterBackupPutResolver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/ChapterBackupPutResolver.kt index 1c3e6fb74..20008e074 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/ChapterBackupPutResolver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/ChapterBackupPutResolver.kt @@ -20,16 +20,14 @@ class ChapterBackupPutResolver : PutResolver() { } fun mapToUpdateQuery(chapter: Chapter) = UpdateQuery.builder() - .table(ChapterTable.TABLE) - .where("${ChapterTable.COL_URL} = ?") - .whereArgs(chapter.url) - .build() + .table(ChapterTable.TABLE) + .where("${ChapterTable.COL_URL} = ?") + .whereArgs(chapter.url) + .build() fun mapToContentValues(chapter: Chapter) = ContentValues(3).apply { put(ChapterTable.COL_READ, chapter.read) put(ChapterTable.COL_BOOKMARK, chapter.bookmark) put(ChapterTable.COL_LAST_PAGE_READ, chapter.last_page_read) } - } - diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/ChapterProgressPutResolver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/ChapterProgressPutResolver.kt index 18009d711..b2800551f 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/ChapterProgressPutResolver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/ChapterProgressPutResolver.kt @@ -20,16 +20,14 @@ class ChapterProgressPutResolver : PutResolver() { } fun mapToUpdateQuery(chapter: Chapter) = UpdateQuery.builder() - .table(ChapterTable.TABLE) - .where("${ChapterTable.COL_ID} = ?") - .whereArgs(chapter.id) - .build() + .table(ChapterTable.TABLE) + .where("${ChapterTable.COL_ID} = ?") + .whereArgs(chapter.id) + .build() fun mapToContentValues(chapter: Chapter) = ContentValues(3).apply { put(ChapterTable.COL_READ, chapter.read) put(ChapterTable.COL_BOOKMARK, chapter.bookmark) put(ChapterTable.COL_LAST_PAGE_READ, chapter.last_page_read) } - } - diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/ChapterSourceOrderPutResolver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/ChapterSourceOrderPutResolver.kt index 77bc0afad..fa0f3514c 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/ChapterSourceOrderPutResolver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/ChapterSourceOrderPutResolver.kt @@ -20,13 +20,12 @@ class ChapterSourceOrderPutResolver : PutResolver() { } fun mapToUpdateQuery(chapter: Chapter) = UpdateQuery.builder() - .table(ChapterTable.TABLE) - .where("${ChapterTable.COL_URL} = ? AND ${ChapterTable.COL_MANGA_ID} = ?") - .whereArgs(chapter.url, chapter.manga_id) - .build() + .table(ChapterTable.TABLE) + .where("${ChapterTable.COL_URL} = ? AND ${ChapterTable.COL_MANGA_ID} = ?") + .whereArgs(chapter.url, chapter.manga_id) + .build() fun mapToContentValues(chapter: Chapter) = ContentValues(1).apply { put(ChapterTable.COL_SOURCE_ORDER, chapter.source_order) } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/HistoryLastReadPutResolver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/HistoryLastReadPutResolver.kt index f1d68c22a..32f73c009 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/HistoryLastReadPutResolver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/HistoryLastReadPutResolver.kt @@ -19,25 +19,25 @@ class HistoryLastReadPutResolver : HistoryPutResolver() { override fun performPut(@NonNull db: StorIOSQLite, @NonNull history: History): PutResult = db.inTransactionReturn { val updateQuery = mapToUpdateQuery(history) - val cursor = db.lowLevel().query(Query.builder() + val cursor = db.lowLevel().query( + Query.builder() .table(updateQuery.table()) .where(updateQuery.where()) .whereArgs(updateQuery.whereArgs()) - .build()) + .build() + ) val putResult: PutResult - try { - if (cursor.count == 0) { + putResult = cursor.use { putCursor -> + if (putCursor.count == 0) { val insertQuery = mapToInsertQuery(history) val insertedId = db.lowLevel().insert(insertQuery, mapToContentValues(history)) - putResult = PutResult.newInsertResult(insertedId, insertQuery.table()) + PutResult.newInsertResult(insertedId, insertQuery.table()) } else { val numberOfRowsUpdated = db.lowLevel().update(updateQuery, mapToUpdateContentValues(history)) - putResult = PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table()) + PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table()) } - } finally { - cursor.close() } putResult @@ -48,10 +48,10 @@ class HistoryLastReadPutResolver : HistoryPutResolver() { * @param obj history object */ override fun mapToUpdateQuery(obj: History) = UpdateQuery.builder() - .table(HistoryTable.TABLE) - .where("${HistoryTable.COL_CHAPTER_ID} = ?") - .whereArgs(obj.chapter_id) - .build() + .table(HistoryTable.TABLE) + .where("${HistoryTable.COL_CHAPTER_ID} = ?") + .whereArgs(obj.chapter_id) + .build() /** * Create content query @@ -60,5 +60,4 @@ class HistoryLastReadPutResolver : HistoryPutResolver() { fun mapToUpdateContentValues(history: History) = ContentValues(1).apply { put(HistoryTable.COL_LAST_READ, history.last_read) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/LibraryMangaGetResolver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/LibraryMangaGetResolver.kt index 77369827a..aac8ead3e 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/LibraryMangaGetResolver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/LibraryMangaGetResolver.kt @@ -21,5 +21,4 @@ class LibraryMangaGetResolver : DefaultGetResolver(), BaseMangaGet return manga } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaChapterGetResolver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaChapterGetResolver.kt index 4f9ce536f..edd6a8983 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaChapterGetResolver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaChapterGetResolver.kt @@ -24,5 +24,4 @@ class MangaChapterGetResolver : DefaultGetResolver() { return MangaChapter(manga, chapter) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaFavoritePutResolver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaFavoritePutResolver.kt index c0057d213..5c948a2c8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaFavoritePutResolver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaFavoritePutResolver.kt @@ -20,14 +20,12 @@ class MangaFavoritePutResolver : PutResolver() { } fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder() - .table(MangaTable.TABLE) - .where("${MangaTable.COL_ID} = ?") - .whereArgs(manga.id) - .build() + .table(MangaTable.TABLE) + .where("${MangaTable.COL_ID} = ?") + .whereArgs(manga.id) + .build() fun mapToContentValues(manga: Manga) = ContentValues(1).apply { put(MangaTable.COL_FAVORITE, manga.favorite) } - } - diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaFlagsPutResolver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaFlagsPutResolver.kt index 0c9b28c52..0a30da7da 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaFlagsPutResolver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaFlagsPutResolver.kt @@ -20,14 +20,12 @@ class MangaFlagsPutResolver : PutResolver() { } fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder() - .table(MangaTable.TABLE) - .where("${MangaTable.COL_ID} = ?") - .whereArgs(manga.id) - .build() + .table(MangaTable.TABLE) + .where("${MangaTable.COL_ID} = ?") + .whereArgs(manga.id) + .build() fun mapToContentValues(manga: Manga) = ContentValues(1).apply { put(MangaTable.COL_CHAPTER_FLAGS, manga.chapter_flags) } - } - diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaLastUpdatedPutResolver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaLastUpdatedPutResolver.kt index 8b2672ea9..6b33ed255 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaLastUpdatedPutResolver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaLastUpdatedPutResolver.kt @@ -20,14 +20,12 @@ class MangaLastUpdatedPutResolver : PutResolver() { } fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder() - .table(MangaTable.TABLE) - .where("${MangaTable.COL_ID} = ?") - .whereArgs(manga.id) - .build() + .table(MangaTable.TABLE) + .where("${MangaTable.COL_ID} = ?") + .whereArgs(manga.id) + .build() fun mapToContentValues(manga: Manga) = ContentValues(1).apply { put(MangaTable.COL_LAST_UPDATE, manga.last_update) } - } - diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaTitlePutResolver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaTitlePutResolver.kt index 702173afb..cb29c26d9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaTitlePutResolver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaTitlePutResolver.kt @@ -20,13 +20,12 @@ class MangaTitlePutResolver : PutResolver() { } fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder() - .table(MangaTable.TABLE) - .where("${MangaTable.COL_ID} = ?") - .whereArgs(manga.id) - .build() + .table(MangaTable.TABLE) + .where("${MangaTable.COL_ID} = ?") + .whereArgs(manga.id) + .build() fun mapToContentValues(manga: Manga) = ContentValues(1).apply { put(MangaTable.COL_TITLE, manga.title) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaViewerPutResolver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaViewerPutResolver.kt index e40f397a8..13005d24c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaViewerPutResolver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaViewerPutResolver.kt @@ -20,13 +20,12 @@ class MangaViewerPutResolver : PutResolver() { } fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder() - .table(MangaTable.TABLE) - .where("${MangaTable.COL_ID} = ?") - .whereArgs(manga.id) - .build() + .table(MangaTable.TABLE) + .where("${MangaTable.COL_ID} = ?") + .whereArgs(manga.id) + .build() fun mapToContentValues(manga: Manga) = ContentValues(1).apply { put(MangaTable.COL_VIEWER, manga.viewer) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/CategoryTable.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/CategoryTable.kt index 65af41013..76ffd7187 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/CategoryTable.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/CategoryTable.kt @@ -13,11 +13,11 @@ object CategoryTable { const val COL_FLAGS = "flags" val createTableQuery: String - get() = """CREATE TABLE $TABLE( + get() = + """CREATE TABLE $TABLE( $COL_ID INTEGER NOT NULL PRIMARY KEY, $COL_NAME TEXT NOT NULL, $COL_ORDER INTEGER NOT NULL, $COL_FLAGS INTEGER NOT NULL )""" - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/ChapterTable.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/ChapterTable.kt index a2350caa0..67047cc00 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/ChapterTable.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/ChapterTable.kt @@ -29,7 +29,8 @@ object ChapterTable { const val COL_SOURCE_ORDER = "source_order" val createTableQuery: String - get() = """CREATE TABLE $TABLE( + get() = + """CREATE TABLE $TABLE( $COL_ID INTEGER NOT NULL PRIMARY KEY, $COL_MANGA_ID INTEGER NOT NULL, $COL_URL TEXT NOT NULL, @@ -51,7 +52,7 @@ object ChapterTable { val createUnreadChaptersIndexQuery: String get() = "CREATE INDEX ${TABLE}_unread_by_manga_index ON $TABLE($COL_MANGA_ID, $COL_READ) " + - "WHERE $COL_READ = 0" + "WHERE $COL_READ = 0" val sourceOrderUpdateQuery: String get() = "ALTER TABLE $TABLE ADD COLUMN $COL_SOURCE_ORDER INTEGER DEFAULT 0" @@ -61,5 +62,4 @@ object ChapterTable { val addScanlator: String get() = "ALTER TABLE $TABLE ADD COLUMN $COL_SCANLATOR TEXT DEFAULT NULL" - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/HistoryTable.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/HistoryTable.kt index d552b8fbd..9d19544a4 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/HistoryTable.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/HistoryTable.kt @@ -31,7 +31,8 @@ object HistoryTable { * query to create history table */ val createTableQuery: String - get() = """CREATE TABLE $TABLE( + get() = + """CREATE TABLE $TABLE( $COL_ID INTEGER NOT NULL PRIMARY KEY, $COL_CHAPTER_ID INTEGER NOT NULL UNIQUE, $COL_LAST_READ LONG, diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaCategoryTable.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaCategoryTable.kt index a42e91e92..578a85bbc 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaCategoryTable.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaCategoryTable.kt @@ -11,7 +11,8 @@ object MangaCategoryTable { const val COL_CATEGORY_ID = "category_id" val createTableQuery: String - get() = """CREATE TABLE $TABLE( + get() = + """CREATE TABLE $TABLE( $COL_ID INTEGER NOT NULL PRIMARY KEY, $COL_MANGA_ID INTEGER NOT NULL, $COL_CATEGORY_ID INTEGER NOT NULL, @@ -20,5 +21,4 @@ object MangaCategoryTable { FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID}) ON DELETE CASCADE )""" - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaTable.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaTable.kt index 2b1ff7458..cbea44d65 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaTable.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaTable.kt @@ -39,7 +39,8 @@ object MangaTable { const val COL_CATEGORY = "category" val createTableQuery: String - get() = """CREATE TABLE $TABLE( + get() = + """CREATE TABLE $TABLE( $COL_ID INTEGER NOT NULL PRIMARY KEY, $COL_SOURCE INTEGER NOT NULL, $COL_URL TEXT NOT NULL, @@ -62,5 +63,5 @@ object MangaTable { val createLibraryIndexQuery: String get() = "CREATE INDEX library_${COL_FAVORITE}_index ON $TABLE($COL_FAVORITE) " + - "WHERE $COL_FAVORITE = 1" + "WHERE $COL_FAVORITE = 1" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/TrackTable.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/TrackTable.kt index 82c863fb9..c8dff441a 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/TrackTable.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/TrackTable.kt @@ -26,8 +26,13 @@ object TrackTable { const val COL_TRACKING_URL = "remote_url" + const val COL_START_DATE = "start_date" + + const val COL_FINISH_DATE = "finish_date" + val createTableQuery: String - get() = """CREATE TABLE $TABLE( + get() = + """CREATE TABLE $TABLE( $COL_ID INTEGER NOT NULL PRIMARY KEY, $COL_MANGA_ID INTEGER NOT NULL, $COL_SYNC_ID INTEGER NOT NULL, @@ -39,6 +44,8 @@ object TrackTable { $COL_STATUS INTEGER NOT NULL, $COL_SCORE FLOAT NOT NULL, $COL_TRACKING_URL TEXT NOT NULL, + $COL_START_DATE LONG NOT NULL, + $COL_FINISH_DATE LONG NOT NULL, UNIQUE ($COL_MANGA_ID, $COL_SYNC_ID) ON CONFLICT REPLACE, FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID}) ON DELETE CASCADE @@ -49,4 +56,10 @@ object TrackTable { val addLibraryId: String get() = "ALTER TABLE $TABLE ADD COLUMN $COL_LIBRARY_ID INTEGER NULL" + + val addStartDate: String + get() = "ALTER TABLE $TABLE ADD COLUMN $COL_START_DATE LONG NOT NULL DEFAULT 0" + + val addFinishDate: String + get() = "ALTER TABLE $TABLE ADD COLUMN $COL_FINISH_DATE LONG NOT NULL DEFAULT 0" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt index bc6d6b42e..85ded01f8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt @@ -6,11 +6,11 @@ import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.source.SourceManager +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.flow.onEach import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.util.concurrent.TimeUnit /** * Cache where we dump the downloads directory from the filesystem. This class is needed because @@ -24,10 +24,10 @@ import java.util.concurrent.TimeUnit * @param preferences the preferences of the app. */ class DownloadCache( - private val context: Context, - private val provider: DownloadProvider, - private val sourceManager: SourceManager, - private val preferences: PreferencesHelper = Injekt.get() + private val context: Context, + private val provider: DownloadProvider, + private val sourceManager: SourceManager, + private val preferences: PreferencesHelper = Injekt.get() ) { /** @@ -47,19 +47,18 @@ class DownloadCache( private var rootDir = RootDirectory(getDirectoryFromPreference()) init { - preferences.downloadsDirectory().asObservable() - .skip(1) - .subscribe { - lastRenew = 0L // invalidate cache - rootDir = RootDirectory(getDirectoryFromPreference()) - } + preferences.downloadsDirectory().asFlow() + .onEach { + lastRenew = 0L // invalidate cache + rootDir = RootDirectory(getDirectoryFromPreference()) + } } /** * Returns the downloads directory from the user's preferences. */ private fun getDirectoryFromPreference(): UniFile { - val dir = preferences.downloadsDirectory().getOrDefault() + val dir = preferences.downloadsDirectory().get() return UniFile.fromUri(context, Uri.parse(dir)) } @@ -100,7 +99,9 @@ class DownloadCache( if (sourceDir != null) { val mangaDir = sourceDir.files[provider.getMangaDirName(manga)] if (mangaDir != null) { - return mangaDir.files.size + return mangaDir.files + .filter { !it.endsWith(Downloader.TMP_DIR_SUFFIX) } + .size } } return 0 @@ -124,26 +125,26 @@ class DownloadCache( val onlineSources = sourceManager.getOnlineSources() val sourceDirs = rootDir.dir.listFiles() - .orEmpty() - .associate { it.name to SourceDirectory(it) } - .mapNotNullKeys { entry -> - onlineSources.find { provider.getSourceDirName(it) == entry.key }?.id - } + .orEmpty() + .associate { it.name to SourceDirectory(it) } + .mapNotNullKeys { entry -> + onlineSources.find { provider.getSourceDirName(it) == entry.key }?.id + } rootDir.files = sourceDirs sourceDirs.values.forEach { sourceDir -> val mangaDirs = sourceDir.dir.listFiles() - .orEmpty() - .associateNotNullKeys { it.name to MangaDirectory(it) } + .orEmpty() + .associateNotNullKeys { it.name to MangaDirectory(it) } sourceDir.files = mangaDirs mangaDirs.values.forEach { mangaDir -> val chapterDirs = mangaDir.dir.listFiles() - .orEmpty() - .mapNotNull { it.name } - .toHashSet() + .orEmpty() + .mapNotNull { it.name } + .toHashSet() mangaDir.files = chapterDirs } @@ -231,27 +232,33 @@ class DownloadCache( /** * Class to store the files under the root downloads directory. */ - private class RootDirectory(val dir: UniFile, - var files: Map = hashMapOf()) + private class RootDirectory( + val dir: UniFile, + var files: Map = hashMapOf() + ) /** * Class to store the files under a source directory. */ - private class SourceDirectory(val dir: UniFile, - var files: Map = hashMapOf()) + private class SourceDirectory( + val dir: UniFile, + var files: Map = hashMapOf() + ) /** * Class to store the files under a manga directory. */ - private class MangaDirectory(val dir: UniFile, - var files: Set = hashSetOf()) + private class MangaDirectory( + val dir: UniFile, + var files: Set = hashSetOf() + ) /** * Returns a new map containing only the key entries of [transform] that are not null. */ private inline fun Map.mapNotNullKeys(transform: (Map.Entry) -> R?): Map { val destination = LinkedHashMap() - forEach { element -> transform(element)?.let { destination.put(it, element.value) } } + forEach { element -> transform(element)?.let { destination[it] = element.value } } return destination } @@ -263,10 +270,9 @@ class DownloadCache( for (element in this) { val (key, value) = transform(element) if (key != null) { - destination.put(key, value) + destination[key] = value } } return destination } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt index 0a5af76a7..4593671ef 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt @@ -20,7 +20,7 @@ import uy.kohesive.injekt.injectLazy * * @param context the application context. */ -class DownloadManager(context: Context) { +class DownloadManager(private val context: Context) { /** * The sources manager. @@ -99,10 +99,21 @@ class DownloadManager(context: Context) { * @param downloads value to set the download queue to */ fun reorderQueue(downloads: List) { + val wasRunning = downloader.isRunning + + if (downloads.isEmpty()) { + DownloadService.stop(context) + downloader.queue.clear() + return + } + downloader.pause() downloader.queue.clear() downloader.queue.addAll(downloads) - downloader.start() + + if (wasRunning) { + downloader.start() + } } /** @@ -137,16 +148,16 @@ class DownloadManager(context: Context) { private fun buildPageList(chapterDir: UniFile?): Observable> { return Observable.fromCallable { val files = chapterDir?.listFiles().orEmpty() - .filter { "image" in it.type.orEmpty() } + .filter { "image" in it.type.orEmpty() } if (files.isEmpty()) { throw Exception("Page list is empty") } files.sortedBy { it.name } - .mapIndexed { i, file -> - Page(i, uri = file.uri).apply { status = Page.READY } - } + .mapIndexed { i, file -> + Page(i, uri = file.uri).apply { status = Page.READY } + } } } @@ -170,6 +181,15 @@ class DownloadManager(context: Context) { return cache.getDownloadCount(manga) } + /** + * Calls delete chapter, which deletes a temp download. + * + * @param download the download to cancel. + */ + fun deletePendingDownload(download: Download) { + deleteChapters(listOf(download.chapter), download.manga, download.source) + } + /** * Deletes the directories of a list of downloaded chapters. * @@ -219,5 +239,4 @@ class DownloadManager(context: Context) { deleteChapters(chapters, manga, source) } } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt index 016c93a85..9a5714422 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt @@ -5,13 +5,16 @@ import android.graphics.BitmapFactory import androidx.core.app.NotificationCompat import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.download.model.Download -import eu.kanade.tachiyomi.data.download.model.DownloadQueue import eu.kanade.tachiyomi.data.notification.NotificationHandler import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.Notifications -import eu.kanade.tachiyomi.util.chop -import eu.kanade.tachiyomi.util.notificationManager +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.util.lang.chop +import eu.kanade.tachiyomi.util.system.notificationBuilder +import eu.kanade.tachiyomi.util.system.notificationManager import java.util.regex.Pattern +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get /** * DownloadNotifier is used to show notifications when downloading one or multiple chapters. @@ -19,40 +22,23 @@ import java.util.regex.Pattern * @param context context of application */ internal class DownloadNotifier(private val context: Context) { - /** - * Notification builder. - */ - private val notification by lazy { - NotificationCompat.Builder(context, Notifications.CHANNEL_DOWNLOADER) - .setLargeIcon(BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher)) + + private val notificationBuilder = context.notificationBuilder(Notifications.CHANNEL_DOWNLOADER) { + setLargeIcon(BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher)) } + private val preferences by lazy { Injekt.get() } + /** * Status of download. Used for correct notification icon. */ private var isDownloading = false - /** - * The size of queue on start download. - */ - var initialQueueSize = 0 - set(value) { - if (value != 0) { - isSingleChapter = (value == 1) - } - field = value - } - /** * Updated when error is thrown */ var errorThrown = false - /** - * Updated when only single page is downloaded - */ - var isSingleChapter = false - /** * Updated when paused */ @@ -70,9 +56,10 @@ internal class DownloadNotifier(private val context: Context) { /** * Clear old actions if they exist. */ - private fun clearActions() = with(notification) { - if (!mActions.isEmpty()) + private fun clearActions() = with(notificationBuilder) { + if (mActions.isNotEmpty()) { mActions.clear() + } } /** @@ -90,7 +77,7 @@ internal class DownloadNotifier(private val context: Context) { */ fun onProgressChange(download: Download) { // Create notification - with(notification) { + with(notificationBuilder) { // Check if first call. if (!isDownloading) { setSmallIcon(android.R.drawable.stat_sys_download) @@ -100,84 +87,65 @@ internal class DownloadNotifier(private val context: Context) { setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context)) isDownloading = true // Pause action - addAction(R.drawable.ic_av_pause_grey_24dp_img, - context.getString(R.string.action_pause), - NotificationReceiver.pauseDownloadsPendingBroadcast(context)) + addAction( + R.drawable.ic_pause_24dp, + context.getString(R.string.action_pause), + NotificationReceiver.pauseDownloadsPendingBroadcast(context) + ) + } + + val downloadingProgressText = context.getString(R.string.chapter_downloading_progress) + .format(download.downloadedImages, download.pages!!.size) + + if (preferences.hideNotificationContent()) { + setContentTitle(downloadingProgressText) + } else { + val title = download.manga.title.chop(15) + val quotedTitle = Pattern.quote(title) + val chapter = download.chapter.name.replaceFirst("$quotedTitle[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), "") + setContentTitle("$title - $chapter".chop(30)) + setContentText(downloadingProgressText) } - val title = download.manga.title.chop(15) - val quotedTitle = Pattern.quote(title) - val chapter = download.chapter.name.replaceFirst("$quotedTitle[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), "") - setContentTitle("$title - $chapter".chop(30)) - setContentText(context.getString(R.string.chapter_downloading_progress) - .format(download.downloadedImages, download.pages!!.size)) setProgress(download.pages!!.size, download.downloadedImages, false) } + // Displays the progress bar on notification - notification.show() + notificationBuilder.show() } /** * Show notification when download is paused. */ fun onDownloadPaused() { - with(notification) { + with(notificationBuilder) { setContentTitle(context.getString(R.string.chapter_paused)) setContentText(context.getString(R.string.download_notifier_download_paused)) - setSmallIcon(R.drawable.ic_av_pause_grey_24dp_img) + setSmallIcon(R.drawable.ic_pause_24dp) setAutoCancel(false) setProgress(0, 0, false) clearActions() // Open download manager when clicked setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context)) // Resume action - addAction(R.drawable.ic_av_play_arrow_grey_img, - context.getString(R.string.action_resume), - NotificationReceiver.resumeDownloadsPendingBroadcast(context)) - //Clear action - addAction(R.drawable.ic_clear_grey_24dp_img, - context.getString(R.string.action_clear), - NotificationReceiver.clearDownloadsPendingBroadcast(context)) + addAction( + R.drawable.ic_play_arrow_24dp, + context.getString(R.string.action_resume), + NotificationReceiver.resumeDownloadsPendingBroadcast(context) + ) + // Clear action + addAction( + R.drawable.ic_close_24dp, + context.getString(R.string.action_cancel_all), + NotificationReceiver.clearDownloadsPendingBroadcast(context) + ) } // Show notification. - notification.show() + notificationBuilder.show() // Reset initial values isDownloading = false - initialQueueSize = 0 - } - - /** - * Called when chapter is downloaded. - * - * @param download download object containing download information. - */ - fun onDownloadCompleted(download: Download, queue: DownloadQueue) { - // Check if last download - if (!queue.isEmpty()) { - return - } - // Create notification. - with(notification) { - val title = download.manga.title.chop(15) - val quotedTitle = Pattern.quote(title) - val chapter = download.chapter.name.replaceFirst("$quotedTitle[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), "") - setContentTitle("$title - $chapter".chop(30)) - setContentText(context.getString(R.string.update_check_notification_download_complete)) - setSmallIcon(android.R.drawable.stat_sys_download_done) - setAutoCancel(true) - clearActions() - setContentIntent(NotificationReceiver.openChapterPendingBroadcast(context, download.manga, download.chapter)) - setProgress(0, 0, false) - } - - // Show notification. - notification.show() - - // Reset initial values - isDownloading = false - initialQueueSize = 0 } /** @@ -186,7 +154,7 @@ internal class DownloadNotifier(private val context: Context) { * @param reason the text to show. */ fun onWarning(reason: String) { - with(notification) { + with(notificationBuilder) { setContentTitle(context.getString(R.string.download_notifier_downloader_title)) setContentText(reason) setSmallIcon(android.R.drawable.stat_sys_warning) @@ -195,7 +163,7 @@ internal class DownloadNotifier(private val context: Context) { setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context)) setProgress(0, 0, false) } - notification.show() + notificationBuilder.show() // Reset download information isDownloading = false @@ -210,16 +178,19 @@ internal class DownloadNotifier(private val context: Context) { */ fun onError(error: String? = null, chapter: String? = null) { // Create notification - with(notification) { - setContentTitle(chapter ?: context.getString(R.string.download_notifier_downloader_title)) - setContentText(error ?: context.getString(R.string.download_notifier_unkown_error)) + with(notificationBuilder) { + setContentTitle( + chapter + ?: context.getString(R.string.download_notifier_downloader_title) + ) + setContentText(error ?: context.getString(R.string.download_notifier_unknown_error)) setSmallIcon(android.R.drawable.stat_sys_warning) clearActions() setAutoCancel(false) setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context)) setProgress(0, 0, false) } - notification.show(Notifications.ID_DOWNLOAD_CHAPTER_ERROR) + notificationBuilder.show(Notifications.ID_DOWNLOAD_CHAPTER_ERROR) // Reset download information errorThrown = true diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadPendingDeleter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadPendingDeleter.kt index 894b9e493..a4e9fd67a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadPendingDeleter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadPendingDeleter.kt @@ -120,27 +120,27 @@ class DownloadPendingDeleter(context: Context) { * Class used to save an entry of chapters with their manga into preferences. */ private data class Entry( - val chapters: List, - val manga: MangaEntry + val chapters: List, + val manga: MangaEntry ) /** * Class used to save an entry for a chapter into preferences. */ private data class ChapterEntry( - val id: Long, - val url: String, - val name: String + val id: Long, + val url: String, + val name: String ) /** * Class used to save an entry for a manga into preferences. */ private data class MangaEntry( - val id: Long, - val url: String, - val title: String, - val source: Long + val id: Long, + val url: String, + val title: String, + val source: Long ) /** @@ -176,5 +176,4 @@ class DownloadPendingDeleter(context: Context) { it.name = name } } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt index bed8c5e36..3bf7a7300 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt @@ -3,12 +3,17 @@ package eu.kanade.tachiyomi.data.download import android.content.Context import android.net.Uri import com.hippo.unifile.UniFile +import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.source.Source -import eu.kanade.tachiyomi.util.DiskUtil +import eu.kanade.tachiyomi.util.storage.DiskUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import uy.kohesive.injekt.injectLazy /** @@ -19,24 +24,23 @@ import uy.kohesive.injekt.injectLazy */ class DownloadProvider(private val context: Context) { - /** - * Preferences helper. - */ private val preferences: PreferencesHelper by injectLazy() + private val scope = CoroutineScope(Job() + Dispatchers.Main) + /** * The root directory for downloads. */ - private var downloadsDir = preferences.downloadsDirectory().getOrDefault().let { + private var downloadsDir = preferences.downloadsDirectory().get().let { val dir = UniFile.fromUri(context, Uri.parse(it)) DiskUtil.createNoMediaFile(dir, context) dir } init { - preferences.downloadsDirectory().asObservable() - .skip(1) - .subscribe { downloadsDir = UniFile.fromUri(context, Uri.parse(it)) } + preferences.downloadsDirectory().asFlow() + .onEach { downloadsDir = UniFile.fromUri(context, Uri.parse(it)) } + .launchIn(scope) } /** @@ -46,9 +50,13 @@ class DownloadProvider(private val context: Context) { * @param source the source of the manga. */ internal fun getMangaDir(manga: Manga, source: Source): UniFile { - return downloadsDir + try { + return downloadsDir .createDirectory(getSourceDirName(source)) .createDirectory(getMangaDirName(manga)) + } catch (e: NullPointerException) { + throw Exception(context.getString(R.string.invalid_download_dir)) + } } /** @@ -121,5 +129,4 @@ class DownloadProvider(private val context: Context) { fun getChapterDirName(chapter: Chapter): String { return DiskUtil.buildValidFilename(chapter.name) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadService.kt index 75676fcb8..332ac1ea8 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadService.kt @@ -9,17 +9,17 @@ import android.net.NetworkInfo.State.DISCONNECTED import android.os.Build import android.os.IBinder import android.os.PowerManager -import androidx.core.app.NotificationCompat import com.github.pwittchen.reactivenetwork.library.Connectivity import com.github.pwittchen.reactivenetwork.library.ReactiveNetwork import com.jakewharton.rxrelay.BehaviorRelay import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.util.connectivityManager -import eu.kanade.tachiyomi.util.plusAssign -import eu.kanade.tachiyomi.util.powerManager -import eu.kanade.tachiyomi.util.toast +import eu.kanade.tachiyomi.util.lang.plusAssign +import eu.kanade.tachiyomi.util.system.connectivityManager +import eu.kanade.tachiyomi.util.system.notification +import eu.kanade.tachiyomi.util.system.powerManager +import eu.kanade.tachiyomi.util.system.toast import rx.android.schedulers.AndroidSchedulers import rx.schedulers.Schedulers import rx.subscriptions.CompositeSubscription @@ -63,14 +63,8 @@ class DownloadService : Service() { } } - /** - * Download manager. - */ private val downloadManager: DownloadManager by injectLazy() - /** - * Preferences helper. - */ private val preferences: PreferencesHelper by injectLazy() /** @@ -112,7 +106,7 @@ class DownloadService : Service() { * Not used. */ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - return Service.START_NOT_STICKY + return START_NOT_STICKY } /** @@ -129,13 +123,17 @@ class DownloadService : Service() { */ private fun listenNetworkChanges() { subscriptions += ReactiveNetwork.observeNetworkConnectivity(applicationContext) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ state -> onNetworkStateChanged(state) - }, { + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { state -> + onNetworkStateChanged(state) + }, + { toast(R.string.download_queue_error) stopSelf() - }) + } + ) } /** @@ -156,7 +154,9 @@ class DownloadService : Service() { DISCONNECTED -> { downloadManager.stopDownloads(getString(R.string.download_notifier_no_network)) } - else -> { /* Do nothing */ } + else -> { + /* Do nothing */ + } } } @@ -165,10 +165,11 @@ class DownloadService : Service() { */ private fun listenDownloaderState() { subscriptions += downloadManager.runningRelay.subscribe { running -> - if (running) + if (running) { wakeLock.acquireIfNeeded() - else + } else { wakeLock.releaseIfNeeded() + } } } @@ -187,9 +188,8 @@ class DownloadService : Service() { } private fun getPlaceholderNotification(): Notification { - return NotificationCompat.Builder(this, Notifications.CHANNEL_DOWNLOADER) - .setContentTitle(getString(R.string.download_notifier_downloader_title)) - .build() + return notification(Notifications.CHANNEL_DOWNLOADER) { + setContentTitle(getString(R.string.download_notifier_downloader_title)) + } } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadStore.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadStore.kt index e9754053c..de9b48630 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadStore.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadStore.kt @@ -15,8 +15,8 @@ import uy.kohesive.injekt.injectLazy * @param context the application context. */ class DownloadStore( - context: Context, - private val sourceManager: SourceManager + context: Context, + private val sourceManager: SourceManager ) { /** @@ -29,9 +29,6 @@ class DownloadStore( */ private val gson: Gson by injectLazy() - /** - * Database helper. - */ private val db: DatabaseHelper by injectLazy() /** @@ -80,9 +77,9 @@ class DownloadStore( */ fun restore(): List { val objs = preferences.all - .mapNotNull { it.value as? String } - .mapNotNull { deserialize(it) } - .sortedBy { it.order } + .mapNotNull { it.value as? String } + .mapNotNull { deserialize(it) } + .sortedBy { it.order } val downloads = mutableListOf() if (objs.isNotEmpty()) { @@ -133,5 +130,4 @@ class DownloadStore( * @param order the order of the download in the queue. */ data class DownloadObject(val mangaId: Long, val chapterId: Long, val order: Int) - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt index e8aa906de..73f667cd2 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt @@ -6,6 +6,7 @@ import com.elvishew.xlog.XLog import com.hippo.unifile.UniFile import com.jakewharton.rxrelay.BehaviorRelay import com.jakewharton.rxrelay.PublishRelay +import eu.kanade.tachiyomi.data.cache.ChapterCache import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.download.model.Download @@ -14,7 +15,14 @@ import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.fetchAllImageUrlsFromPageList -import eu.kanade.tachiyomi.util.* +import eu.kanade.tachiyomi.util.lang.RetryWithDelay +import eu.kanade.tachiyomi.util.lang.launchNow +import eu.kanade.tachiyomi.util.lang.launchUI +import eu.kanade.tachiyomi.util.lang.plusAssign +import eu.kanade.tachiyomi.util.storage.DiskUtil +import eu.kanade.tachiyomi.util.storage.saveTo +import eu.kanade.tachiyomi.util.system.ImageUtil +import java.io.File import kotlinx.coroutines.async import okhttp3.Response import rx.Observable @@ -22,6 +30,7 @@ import rx.android.schedulers.AndroidSchedulers import rx.schedulers.Schedulers import rx.subscriptions.CompositeSubscription import timber.log.Timber +import uy.kohesive.injekt.injectLazy /** * This class is the one in charge of downloading chapters. @@ -38,12 +47,14 @@ import timber.log.Timber * @param sourceManager the source manager. */ class Downloader( - private val context: Context, - private val provider: DownloadProvider, - private val cache: DownloadCache, - private val sourceManager: SourceManager + private val context: Context, + private val provider: DownloadProvider, + private val cache: DownloadCache, + private val sourceManager: SourceManager ) { + private val chapterCache: ChapterCache by injectLazy() + /** * Store for persisting downloads across restarts. */ @@ -77,7 +88,9 @@ class Downloader( /** * Whether the downloader is running. */ - @Volatile private var isRunning: Boolean = false + @Volatile + var isRunning: Boolean = false + private set init { launchNow { @@ -93,17 +106,19 @@ class Downloader( * @return true if the downloader is started, false otherwise. */ fun start(): Boolean { - if (isRunning || queue.isEmpty()) + if (isRunning || queue.isEmpty()) { return false - notifier.paused = false - if (!subscriptions.hasSubscriptions()) + } + + if (!subscriptions.hasSubscriptions()) { initializeSubscriptions() + } val pending = queue.filter { it.status != Download.DOWNLOADED } pending.forEach { if (it.status != Download.QUEUE) it.status = Download.QUEUE } downloadsRelay.call(pending) - return !pending.isEmpty() + return pending.isNotEmpty() } /** @@ -112,8 +127,8 @@ class Downloader( fun stop(reason: String? = null) { destroySubscriptions() queue - .filter { it.status == Download.DOWNLOADING } - .forEach { it.status = Download.ERROR } + .filter { it.status == Download.DOWNLOADING } + .forEach { it.status = Download.ERROR } if (reason != null) { notifier.onWarning(reason) @@ -121,8 +136,6 @@ class Downloader( if (notifier.paused) { notifier.paused = false notifier.onDownloadPaused() - } else if (notifier.isSingleChapter && !notifier.errorThrown) { - notifier.isSingleChapter = false } else { notifier.dismiss() } @@ -135,8 +148,8 @@ class Downloader( fun pause() { destroySubscriptions() queue - .filter { it.status == Download.DOWNLOADING } - .forEach { it.status = Download.QUEUE } + .filter { it.status == Download.DOWNLOADING } + .forEach { it.status = Download.QUEUE } notifier.paused = true } @@ -148,11 +161,11 @@ class Downloader( fun clearQueue(isNotification: Boolean = false) { destroySubscriptions() - //Needed to update the chapter view + // Needed to update the chapter view if (isNotification) { queue - .filter { it.status == Download.QUEUE } - .forEach { it.status = Download.NOT_DOWNLOADED } + .filter { it.status == Download.QUEUE } + .forEach { it.status = Download.NOT_DOWNLOADED } } queue.clear() notifier.dismiss() @@ -169,15 +182,19 @@ class Downloader( subscriptions.clear() subscriptions += downloadsRelay.concatMapIterable { it } - .concatMap { downloadChapter(it).subscribeOn(Schedulers.io()) } - .onBackpressureBuffer() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ completeDownload(it) - }, { error -> + .concatMap { downloadChapter(it).subscribeOn(Schedulers.io()) } + .onBackpressureBuffer() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + completeDownload(it) + }, + { error -> DownloadService.stop(context) Timber.e(error) notifier.onError(error.message) - }) + } + ) } /** @@ -200,40 +217,37 @@ class Downloader( */ fun queueChapters(manga: Manga, chapters: List, autoStart: Boolean) = launchUI { val source = sourceManager.get(manga.source) as? HttpSource ?: return@launchUI - + val wasEmpty = queue.isEmpty() // Called in background thread, the operation can be slow with SAF. val chaptersWithoutDir = async { val mangaDir = provider.findMangaDir(manga, source) chapters - // Avoid downloading chapters with the same name. - .distinctBy { it.name } - // Filter out those already downloaded. - .filter { mangaDir?.findFile(provider.getChapterDirName(it)) == null } - // Add chapters to queue from the start. - .sortedByDescending { it.source_order } + // Avoid downloading chapters with the same name. + .distinctBy { it.name } + // Filter out those already downloaded. + .filter { mangaDir?.findFile(provider.getChapterDirName(it)) == null } + // Add chapters to queue from the start. + .sortedByDescending { it.source_order } } // Runs in main thread (synchronization needed). val chaptersToQueue = chaptersWithoutDir.await() - // Filter out those already enqueued. - .filter { chapter -> queue.none { it.chapter.id == chapter.id } } - // Create a download for each one. - .map { Download(source, manga, it) } + // Filter out those already enqueued. + .filter { chapter -> queue.none { it.chapter.id == chapter.id } } + // Create a download for each one. + .map { Download(source, manga, it) } if (chaptersToQueue.isNotEmpty()) { queue.addAll(chaptersToQueue) - // Initialize queue size. - notifier.initialQueueSize = queue.size - if (isRunning) { // Send the list of downloads to the downloader. downloadsRelay.call(chaptersToQueue) } // Start downloader if needed - if (autoStart) { + if (autoStart && wasEmpty) { DownloadService.start(this@Downloader.context) } } @@ -247,59 +261,48 @@ class Downloader( private fun downloadChapter(download: Download): Observable = Observable.defer { val chapterDirname = provider.getChapterDirName(download.chapter) val mangaDir = provider.getMangaDir(download.manga, download.source) - val tmpDir = mangaDir.createDirectory("${chapterDirname}_tmp") + val tmpDir = mangaDir.createDirectory(chapterDirname + TMP_DIR_SUFFIX) val pageListObservable = if (download.pages == null) { // Pull page list from network and add them to download object download.source.fetchPageList(download.chapter) - .doOnNext { pages -> - if (pages.isEmpty()) { - throw Exception("Page list is empty") - } - download.pages = pages + .doOnNext { pages -> + if (pages.isEmpty()) { + throw Exception("Page list is empty") } + download.pages = pages + } } else { // Or if the page list already exists, start from the file Observable.just(download.pages!!) } pageListObservable - .doOnNext { _ -> - // Delete all temporary (unfinished) files - tmpDir.listFiles() - ?.filter { it.name!!.endsWith(".tmp") } - ?.forEach { it.delete() } - - download.downloadedImages = 0 - download.status = Download.DOWNLOADING - } - // Get all the URLs to the source images, fetch pages if necessary - .flatMap { download.source.fetchAllImageUrlsFromPageList(it) } - // Start downloading images, consider we can have downloaded images already - .concatMap { page -> getOrDownloadImage(page, download, tmpDir) } - // Do when page is downloaded. - .doOnNext { notifier.onProgressChange(download) } - .toList() - .map { _ -> download } - // Do after download completes - .doOnNext { ensureSuccessfulDownload(download, mangaDir, tmpDir, chapterDirname) } - // If the page list threw, it will resume here - .onErrorReturn { error -> - // [EXH] - XLog.w("> Download error!", error) - XLog.w("> (source.id: %s, source.name: %s, manga.id: %s, manga.url: %s, chapter.id: %s, chapter.url: %s)", - download.source.id, - download.source.name, - download.manga.id, - download.manga.url, - download.chapter.id, - download.chapter.url) - - download.status = Download.ERROR - notifier.onError(error.message, download.chapter.name) - download - } + .doOnNext { _ -> + // Delete all temporary (unfinished) files + tmpDir.listFiles() + ?.filter { it.name!!.endsWith(".tmp") } + ?.forEach { it.delete() } + download.downloadedImages = 0 + download.status = Download.DOWNLOADING + } + // Get all the URLs to the source images, fetch pages if necessary + .flatMap { download.source.fetchAllImageUrlsFromPageList(it) } + // Start downloading images, consider we can have downloaded images already + .concatMap { page -> getOrDownloadImage(page, download, tmpDir) } + // Do when page is downloaded. + .doOnNext { notifier.onProgressChange(download) } + .toList() + .map { download } + // Do after download completes + .doOnNext { ensureSuccessfulDownload(download, mangaDir, tmpDir, chapterDirname) } + // If the page list threw, it will resume here + .onErrorReturn { error -> + download.status = Download.ERROR + notifier.onError(error.message, download.chapter.name) + download + } } /** @@ -312,8 +315,9 @@ class Downloader( */ private fun getOrDownloadImage(page: Page, download: Download, tmpDir: UniFile): Observable { // If the image URL is empty, do nothing - if (page.imageUrl == null) + if (page.imageUrl == null) { return Observable.just(page) + } val filename = String.format("%03d", page.number) val tmpFile = tmpDir.findFile("$filename.tmp") @@ -325,26 +329,27 @@ class Downloader( val imageFile = tmpDir.listFiles()!!.find { it.name!!.startsWith("$filename.") } // If the image is already downloaded, do nothing. Otherwise download from network - val pageObservable = if (imageFile != null) - Observable.just(imageFile) - else - downloadImage(page, download.source, tmpDir, filename) + val pageObservable = when { + imageFile != null -> Observable.just(imageFile) + chapterCache.isImageInCache(page.imageUrl!!) -> copyImageFromCache(chapterCache.getImageFile(page.imageUrl!!), tmpDir, filename) + else -> downloadImage(page, download.source, tmpDir, filename) + } return pageObservable - // When the image is ready, set image path, progress (just in case) and status - .doOnNext { file -> - page.uri = file.uri - page.progress = 100 - download.downloadedImages++ - page.status = Page.READY - } - .map { page } - // Mark this page as error and allow to download the remaining - .onErrorReturn { - page.progress = 0 - page.status = Page.ERROR - page - } + // When the image is ready, set image path, progress (just in case) and status + .doOnNext { file -> + page.uri = file.uri + page.progress = 100 + download.downloadedImages++ + page.status = Page.READY + } + .map { page } + // Mark this page as error and allow to download the remaining + .onErrorReturn { + page.progress = 0 + page.status = Page.ERROR + page + } } /** @@ -359,30 +364,43 @@ class Downloader( page.status = Page.DOWNLOAD_IMAGE page.progress = 0 return source.fetchImage(page) - .map { response -> - val file = tmpDir.createFile("$filename.tmp") - try { - response.body!!.source().saveTo(file.openOutputStream()) - val extension = getImageExtension(response, file) - file.renameTo("$filename.$extension") - } catch (e: Exception) { - // [EXH] - XLog.w("> Failed to fetch image!", e) - XLog.w("> (source.id: %s, source.name: %s, page.index: %s, page.url: %s, page.imageUrl: %s)", - source.id, - source.name, - page.index, - page.url, - page.imageUrl) - - response.close() - file.delete() - throw e - } - file + .map { response -> + val file = tmpDir.createFile("$filename.tmp") + try { + response.body!!.source().saveTo(file.openOutputStream()) + val extension = getImageExtension(response, file) + file.renameTo("$filename.$extension") + } catch (e: Exception) { + response.close() + file.delete() + throw e } - // Retry 3 times, waiting 2, 4 and 8 seconds between attempts. - .retryWhen(RetryWithDelay(3, { (2 shl it - 1) * 1000 }, Schedulers.trampoline())) + file + } + // Retry 3 times, waiting 2, 4 and 8 seconds between attempts. + .retryWhen(RetryWithDelay(3, { (2 shl it - 1) * 1000 }, Schedulers.trampoline())) + } + + /** + * Return the observable which copies the image from cache. + * + * @param cacheFile the file from cache. + * @param tmpDir the temporary directory of the download. + * @param filename the filename of the image. + */ + private fun copyImageFromCache(cacheFile: File, tmpDir: UniFile, filename: String): Observable { + return Observable.just(cacheFile).map { + val tmpFile = tmpDir.createFile("$filename.tmp") + cacheFile.inputStream().use { input -> + tmpFile.openOutputStream().use { output -> + input.copyTo(output) + } + } + val extension = ImageUtil.findImageType(cacheFile.inputStream()) ?: return@map tmpFile + tmpFile.renameTo("$filename.${extension.extension}") + cacheFile.delete() + tmpFile + } } /** @@ -411,9 +429,12 @@ class Downloader( * @param tmpDir the directory where the download is currently stored. * @param dirname the real (non temporary) directory name of the download. */ - private fun ensureSuccessfulDownload(download: Download, mangaDir: UniFile, - tmpDir: UniFile, dirname: String) { - + private fun ensureSuccessfulDownload( + download: Download, + mangaDir: UniFile, + tmpDir: UniFile, + dirname: String + ) { // Ensure that the chapter folder has all the images. val downloadedImages = tmpDir.listFiles().orEmpty().filterNot { it.name!!.endsWith(".tmp") } @@ -442,9 +463,6 @@ class Downloader( queue.remove(download) } if (areAllDownloadsFinished()) { - if (notifier.isSingleChapter && !notifier.errorThrown) { - notifier.onDownloadCompleted(download, queue) - } DownloadService.stop(context) } } @@ -456,4 +474,7 @@ class Downloader( return queue.none { it.status <= Download.DOWNLOADING } } + companion object { + const val TMP_DIR_SUFFIX = "_tmp" + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/model/Download.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/model/Download.kt index 9c2b503e9..5bd9b15be 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/model/Download.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/model/Download.kt @@ -10,28 +10,48 @@ class Download(val source: HttpSource, val manga: Manga, val chapter: Chapter) { var pages: List? = null - @Volatile @Transient var totalProgress: Int = 0 + @Volatile + @Transient + var totalProgress: Int = 0 - @Volatile @Transient var downloadedImages: Int = 0 + @Volatile + @Transient + var downloadedImages: Int = 0 - @Volatile @Transient var status: Int = 0 + @Volatile + @Transient + var status: Int = 0 set(status) { field = status statusSubject?.onNext(this) + statusCallback?.invoke(this) } - @Transient private var statusSubject: PublishSubject? = null + @Transient + private var statusSubject: PublishSubject? = null + + @Transient + private var statusCallback: ((Download) -> Unit)? = null + + val progress: Int + get() { + val pages = pages ?: return 0 + return pages.map(Page::progress).average().toInt() + } fun setStatusSubject(subject: PublishSubject?) { statusSubject = subject } - companion object { + fun setStatusCallback(f: ((Download) -> Unit)?) { + statusCallback = f + } + companion object { const val NOT_DOWNLOADED = 0 const val QUEUE = 1 const val DOWNLOADING = 2 const val DOWNLOADED = 3 const val ERROR = 4 } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt index 197140d0f..79dde6a6e 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt @@ -5,14 +5,14 @@ import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.download.DownloadStore import eu.kanade.tachiyomi.source.model.Page +import java.util.concurrent.CopyOnWriteArrayList import rx.Observable import rx.subjects.PublishSubject -import java.util.concurrent.CopyOnWriteArrayList class DownloadQueue( - private val store: DownloadStore, - private val queue: MutableList = CopyOnWriteArrayList()) -: List by queue { + private val store: DownloadStore, + private val queue: MutableList = CopyOnWriteArrayList() +) : List by queue { private val statusSubject = PublishSubject.create() @@ -21,6 +21,7 @@ class DownloadQueue( fun addAll(downloads: List) { downloads.forEach { download -> download.setStatusSubject(statusSubject) + download.setStatusCallback(::setPagesFor) download.status = Download.QUEUE } queue.addAll(downloads) @@ -32,6 +33,10 @@ class DownloadQueue( val removed = queue.remove(download) store.remove(download) download.setStatusSubject(null) + download.setStatusCallback(null) + if (download.status == Download.DOWNLOADING || download.status == Download.QUEUE) { + download.status = Download.NOT_DOWNLOADED + } if (removed) { updatedRelay.call(Unit) } @@ -42,7 +47,9 @@ class DownloadQueue( } fun remove(chapters: List) { - for (chapter in chapters) { remove(chapter) } + for (chapter in chapters) { + remove(chapter) + } } fun remove(manga: Manga) { @@ -52,6 +59,10 @@ class DownloadQueue( fun clear() { queue.forEach { download -> download.setStatusSubject(null) + download.setStatusCallback(null) + if (download.status == Download.DOWNLOADING || download.status == Download.QUEUE) { + download.status = Download.NOT_DOWNLOADED + } } queue.clear() store.clear() @@ -64,35 +75,35 @@ class DownloadQueue( fun getStatusObservable(): Observable = statusSubject.onBackpressureBuffer() fun getUpdatedObservable(): Observable> = updatedRelay.onBackpressureBuffer() - .startWith(Unit) - .map { this } + .startWith(Unit) + .map { this } - fun getProgressObservable(): Observable { - return statusSubject.onBackpressureBuffer() - .startWith(getActiveDownloads()) - .flatMap { download -> - if (download.status == Download.DOWNLOADING) { - val pageStatusSubject = PublishSubject.create() - setPagesSubject(download.pages, pageStatusSubject) - return@flatMap pageStatusSubject - .onBackpressureBuffer() - .filter { it == Page.READY } - .map { download } - - } else if (download.status == Download.DOWNLOADED || download.status == Download.ERROR) { - setPagesSubject(download.pages, null) - } - Observable.just(download) - } - .filter { it.status == Download.DOWNLOADING } - } - - private fun setPagesSubject(pages: List?, subject: PublishSubject?) { - if (pages != null) { - for (page in pages) { - page.setStatusSubject(subject) - } + private fun setPagesFor(download: Download) { + if (download.status == Download.DOWNLOADED || download.status == Download.ERROR) { + setPagesSubject(download.pages, null) } } + fun getProgressObservable(): Observable { + return statusSubject.onBackpressureBuffer() + .startWith(getActiveDownloads()) + .flatMap { download -> + if (download.status == Download.DOWNLOADING) { + val pageStatusSubject = PublishSubject.create() + setPagesSubject(download.pages, pageStatusSubject) + return@flatMap pageStatusSubject + .onBackpressureBuffer() + .filter { it == Page.READY } + .map { download } + } else if (download.status == Download.DOWNLOADED || download.status == Download.ERROR) { + setPagesSubject(download.pages, null) + } + Observable.just(download) + } + .filter { it.status == Download.DOWNLOADING } + } + + private fun setPagesSubject(pages: List?, subject: PublishSubject?) { + pages?.forEach { it.setStatusSubject(subject) } + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/glide/FileFetcher.kt b/app/src/main/java/eu/kanade/tachiyomi/data/glide/FileFetcher.kt index a795e2e05..555314675 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/glide/FileFetcher.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/glide/FileFetcher.kt @@ -5,7 +5,12 @@ import android.util.Log import com.bumptech.glide.Priority import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.data.DataFetcher -import java.io.* +import java.io.File +import java.io.FileInputStream +import java.io.FileNotFoundException +import java.io.IOException +import java.io.InputStream +import timber.log.Timber open class FileFetcher(private val file: File) : DataFetcher { @@ -20,7 +25,7 @@ open class FileFetcher(private val file: File) : DataFetcher { data = FileInputStream(file) } catch (e: FileNotFoundException) { if (Log.isLoggable(TAG, Log.DEBUG)) { - Log.d(TAG, "Failed to open file", e) + Timber.d(e, "Failed to open file") } callback.onLoadFailed(e) return @@ -48,4 +53,4 @@ open class FileFetcher(private val file: File) : DataFetcher { override fun getDataSource(): DataSource { return DataSource.LOCAL } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/glide/LibraryMangaUrlFetcher.kt b/app/src/main/java/eu/kanade/tachiyomi/data/glide/LibraryMangaUrlFetcher.kt index 5fec42af7..79c81af4f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/glide/LibraryMangaUrlFetcher.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/glide/LibraryMangaUrlFetcher.kt @@ -16,44 +16,48 @@ import java.io.InputStream * @param manga the manga of the cover to load. * @param file the file where this cover should be. It may exists or not. */ -class LibraryMangaUrlFetcher(private val networkFetcher: DataFetcher, - private val manga: Manga, - private val file: File) -: FileFetcher(file) { +class LibraryMangaUrlFetcher( + private val networkFetcher: DataFetcher, + private val manga: Manga, + private val file: File +) : + FileFetcher(file) { override fun loadData(priority: Priority, callback: DataFetcher.DataCallback) { if (!file.exists()) { - networkFetcher.loadData(priority, object : DataFetcher.DataCallback { - override fun onDataReady(data: InputStream?) { - if (data != null) { - val tmpFile = File(file.path + ".tmp") - try { - // Retrieve destination stream, create parent folders if needed. - val output = try { - tmpFile.outputStream() - } catch (e: FileNotFoundException) { - tmpFile.parentFile.mkdirs() - tmpFile.outputStream() - } + networkFetcher.loadData( + priority, + object : DataFetcher.DataCallback { + override fun onDataReady(data: InputStream?) { + if (data != null) { + val tmpFile = File(file.path + ".tmp") + try { + // Retrieve destination stream, create parent folders if needed. + val output = try { + tmpFile.outputStream() + } catch (e: FileNotFoundException) { + tmpFile.parentFile.mkdirs() + tmpFile.outputStream() + } - // Copy the file and rename to the original. - data.use { output.use { data.copyTo(output) } } - tmpFile.renameTo(file) - loadFromFile(callback) - } catch (e: Exception) { - tmpFile.delete() - callback.onLoadFailed(e) + // Copy the file and rename to the original. + data.use { output.use { data.copyTo(output) } } + tmpFile.renameTo(file) + loadFromFile(callback) + } catch (e: Exception) { + tmpFile.delete() + callback.onLoadFailed(e) + } + } else { + callback.onLoadFailed(Exception("Null data")) } - } else { - callback.onLoadFailed(Exception("Null data")) + } + + override fun onLoadFailed(e: Exception) { + callback.onLoadFailed(e) } } - - override fun onLoadFailed(e: Exception) { - callback.onLoadFailed(e) - } - - }) + ) } else { loadFromFile(callback) } @@ -68,5 +72,4 @@ class LibraryMangaUrlFetcher(private val networkFetcher: DataFetcher { +class MangaThumbnailModelLoader : ModelLoader { /** * Cover cache where persistent covers are stored. @@ -56,18 +60,18 @@ class MangaModelLoader : ModelLoader { private val cachedHeaders = hashMapOf() /** - * Factory class for creating [MangaModelLoader] instances. + * Factory class for creating [MangaThumbnailModelLoader] instances. */ - class Factory : ModelLoaderFactory { + class Factory : ModelLoaderFactory { - override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader { - return MangaModelLoader() + override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader { + return MangaThumbnailModelLoader() } override fun teardown() {} } - override fun handles(model: Manga): Boolean { + override fun handles(model: MangaThumbnail): Boolean { return true } @@ -78,15 +82,21 @@ class MangaModelLoader : ModelLoader { * @param width the width of the view where the resource will be loaded. * @param height the height of the view where the resource will be loaded. */ - override fun buildLoadData(manga: Manga, width: Int, height: Int, - options: Options): ModelLoader.LoadData? { + override fun buildLoadData( + mangaThumbnail: MangaThumbnail, + width: Int, + height: Int, + options: Options + ): ModelLoader.LoadData? { // Check thumbnail is not null or empty - val url = manga.thumbnail_url + val url = mangaThumbnail.url if (url == null || url.isEmpty()) { return null } - if (url.startsWith("http")) { + val manga = mangaThumbnail.manga + + if (url.startsWith("http", true)) { val source = sourceManager.get(manga.source) as? HttpSource val glideUrl = GlideUrl(url, getHeaders(manga, source)) @@ -118,7 +128,7 @@ class MangaModelLoader : ModelLoader { * * @param manga the model. */ - fun getHeaders(manga: Manga, source: HttpSource?): Headers { + private fun getHeaders(manga: Manga, source: HttpSource?): Headers { if (source == null) return LazyHeaders.DEFAULT return cachedHeaders.getOrPut(manga.source) { @@ -142,5 +152,4 @@ class MangaModelLoader : ModelLoader { value } } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/glide/PassthroughModelLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/glide/PassthroughModelLoader.kt index bb117086e..dd6d546f8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/glide/PassthroughModelLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/glide/PassthroughModelLoader.kt @@ -14,10 +14,10 @@ import java.io.InputStream class PassthroughModelLoader : ModelLoader { override fun buildLoadData( - model: InputStream, - width: Int, - height: Int, - options: Options + model: InputStream, + width: Int, + height: Int, + options: Options ): ModelLoader.LoadData? { return ModelLoader.LoadData(ObjectKey(model), Fetcher(model)) } @@ -49,12 +49,11 @@ class PassthroughModelLoader : ModelLoader { } override fun loadData( - priority: Priority, - callback: DataFetcher.DataCallback + priority: Priority, + callback: DataFetcher.DataCallback ) { callback.onDataReady(stream) } - } /** @@ -63,12 +62,11 @@ class PassthroughModelLoader : ModelLoader { class Factory : ModelLoaderFactory { override fun build( - multiFactory: MultiModelLoaderFactory + multiFactory: MultiModelLoaderFactory ): ModelLoader { return PassthroughModelLoader() } override fun teardown() {} } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/glide/TachiGlideModule.kt b/app/src/main/java/eu/kanade/tachiyomi/data/glide/TachiGlideModule.kt index 1eecf3eed..9e83370f5 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/glide/TachiGlideModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/glide/TachiGlideModule.kt @@ -13,11 +13,10 @@ import com.bumptech.glide.load.model.GlideUrl import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions import com.bumptech.glide.module.AppGlideModule import com.bumptech.glide.request.RequestOptions -import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.network.NetworkHelper +import java.io.InputStream import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.io.InputStream /** * Class used to update Glide module settings @@ -28,16 +27,21 @@ class TachiGlideModule : AppGlideModule() { override fun applyOptions(context: Context, builder: GlideBuilder) { builder.setDiskCache(InternalCacheDiskCacheFactory(context, 50 * 1024 * 1024)) builder.setDefaultRequestOptions(RequestOptions().format(DecodeFormat.PREFER_RGB_565)) - builder.setDefaultTransitionOptions(Drawable::class.java, - DrawableTransitionOptions.withCrossFade()) + builder.setDefaultTransitionOptions( + Drawable::class.java, + DrawableTransitionOptions.withCrossFade() + ) } override fun registerComponents(context: Context, glide: Glide, registry: Registry) { val networkFactory = OkHttpUrlLoader.Factory(Injekt.get().client) registry.replace(GlideUrl::class.java, InputStream::class.java, networkFactory) - registry.append(Manga::class.java, InputStream::class.java, MangaModelLoader.Factory()) - registry.append(InputStream::class.java, InputStream::class.java, PassthroughModelLoader - .Factory()) + registry.append(MangaThumbnail::class.java, InputStream::class.java, MangaThumbnailModelLoader.Factory()) + registry.append( + InputStream::class.java, InputStream::class.java, + PassthroughModelLoader + .Factory() + ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt index ab386b133..2f4230edf 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt @@ -1,47 +1,58 @@ package eu.kanade.tachiyomi.data.library -import com.evernote.android.job.Job -import com.evernote.android.job.JobManager -import com.evernote.android.job.JobRequest +import android.content.Context +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.Worker +import androidx.work.WorkerParameters import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault +import java.util.concurrent.TimeUnit import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -class LibraryUpdateJob : Job() { +class LibraryUpdateJob(private val context: Context, workerParams: WorkerParameters) : + Worker(context, workerParams) { - override fun onRunJob(params: Params): Result { + override fun doWork(): Result { LibraryUpdateService.start(context) - return Job.Result.SUCCESS + return Result.success() } companion object { - const val TAG = "LibraryUpdate" + private const val TAG = "LibraryUpdate" - fun setupTask(prefInterval: Int? = null) { + fun setupTask(context: Context, prefInterval: Int? = null) { val preferences = Injekt.get() - val interval = prefInterval ?: preferences.libraryUpdateInterval().getOrDefault() + val interval = prefInterval ?: preferences.libraryUpdateInterval().get() if (interval > 0) { - val restrictions = preferences.libraryUpdateRestriction() + val restrictions = preferences.libraryUpdateRestriction()!! val acRestriction = "ac" in restrictions - val wifiRestriction = if ("wifi" in restrictions) - JobRequest.NetworkType.UNMETERED - else - JobRequest.NetworkType.CONNECTED + val wifiRestriction = if ("wifi" in restrictions) { + NetworkType.UNMETERED + } else { + NetworkType.CONNECTED + } - JobRequest.Builder(TAG) - .setPeriodic(interval * 60 * 60 * 1000L, 10 * 60 * 1000) - .setRequiredNetworkType(wifiRestriction) - .setRequiresCharging(acRestriction) - .setRequirementsEnforced(true) - .setUpdateCurrent(true) - .build() - .schedule() + val constraints = Constraints.Builder() + .setRequiredNetworkType(wifiRestriction) + .setRequiresCharging(acRestriction) + .build() + + val request = PeriodicWorkRequestBuilder( + interval.toLong(), TimeUnit.HOURS, + 10, TimeUnit.MINUTES + ) + .addTag(TAG) + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.REPLACE, request) + } else { + WorkManager.getInstance(context).cancelAllWorkByTag(TAG) } } - - fun cancelTask() { - JobManager.instance().cancelAllForTag(TAG) - } } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateRanker.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateRanker.kt index 735afa833..651a391ee 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateRanker.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateRanker.kt @@ -8,8 +8,9 @@ import eu.kanade.tachiyomi.data.database.models.Manga object LibraryUpdateRanker { val rankingScheme = listOf( - (this::lexicographicRanking)(), - (this::latestFirstRanking)()) + (this::lexicographicRanking)(), + (this::latestFirstRanking)() + ) /** * Provides a total ordering over all the Mangas. @@ -22,7 +23,7 @@ object LibraryUpdateRanker { */ fun latestFirstRanking(): Comparator { return Comparator { mangaFirst: Manga, - mangaSecond: Manga -> + mangaSecond: Manga -> compareValues(mangaSecond.last_update, mangaFirst.last_update) } } @@ -35,9 +36,8 @@ object LibraryUpdateRanker { */ fun lexicographicRanking(): Comparator { return Comparator { mangaFirst: Manga, - mangaSecond: Manga -> + mangaSecond: Manga -> compareValues(mangaFirst.title, mangaSecond.title) } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt index 6b5b8fe66..1b7ceac03 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt @@ -1,12 +1,19 @@ package eu.kanade.tachiyomi.data.library +import android.app.Notification +import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.os.Build import android.os.IBinder import android.os.PowerManager import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.GROUP_ALERT_SUMMARY +import androidx.core.app.NotificationManagerCompat +import com.bumptech.glide.Glide import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Category @@ -15,28 +22,34 @@ import eu.kanade.tachiyomi.data.database.models.LibraryManga import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadService +import eu.kanade.tachiyomi.data.glide.toMangaThumbnail import eu.kanade.tachiyomi.data.library.LibraryUpdateRanker.rankingScheme import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Companion.start import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.online.HttpSource -import eu.kanade.tachiyomi.util.isServiceRunning -import eu.kanade.tachiyomi.util.notificationManager -import eu.kanade.tachiyomi.util.syncChaptersWithSource +import eu.kanade.tachiyomi.ui.main.MainActivity +import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource +import eu.kanade.tachiyomi.util.lang.chop +import eu.kanade.tachiyomi.util.system.isServiceRunning +import eu.kanade.tachiyomi.util.system.notification +import eu.kanade.tachiyomi.util.system.notificationBuilder +import eu.kanade.tachiyomi.util.system.notificationManager import exh.LIBRARY_UPDATE_EXCLUDED_SOURCES +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.util.ArrayList +import java.util.concurrent.atomic.AtomicInteger import rx.Observable import rx.Subscription import rx.schedulers.Schedulers import timber.log.Timber import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.util.* -import java.util.concurrent.atomic.AtomicInteger /** * This class will take care of updating the chapters of the manga from the library. It can be @@ -47,11 +60,11 @@ import java.util.concurrent.atomic.AtomicInteger * destroyed. */ class LibraryUpdateService( - val db: DatabaseHelper = Injekt.get(), - val sourceManager: SourceManager = Injekt.get(), - val preferences: PreferencesHelper = Injekt.get(), - val downloadManager: DownloadManager = Injekt.get(), - val trackManager: TrackManager = Injekt.get() + val db: DatabaseHelper = Injekt.get(), + val sourceManager: SourceManager = Injekt.get(), + val preferences: PreferencesHelper = Injekt.get(), + val downloadManager: DownloadManager = Injekt.get(), + val trackManager: TrackManager = Injekt.get() ) : Service() { /** @@ -76,13 +89,15 @@ class LibraryUpdateService( /** * Cached progress notification to avoid creating a lot. */ - private val progressNotification by lazy { NotificationCompat.Builder(this, Notifications.CHANNEL_LIBRARY) - .setContentTitle(getString(R.string.app_name)) - .setSmallIcon(R.drawable.ic_refresh_white_24dp_img) - .setLargeIcon(updateNotifier.notificationBitmap) - .setOngoing(true) - .setOnlyAlertOnce(true) - .addAction(R.drawable.ic_clear_grey_24dp_img, getString(android.R.string.cancel), cancelIntent) + private val progressNotificationBuilder by lazy { + notificationBuilder(Notifications.CHANNEL_LIBRARY) { + setContentTitle(getString(R.string.app_name)) + setSmallIcon(R.drawable.ic_refresh_24dp) + setLargeIcon(notificationBitmap) + setOngoing(true) + setOnlyAlertOnce(true) + addAction(R.drawable.ic_close_24dp, getString(android.R.string.cancel), cancelIntent) + } } /** @@ -90,8 +105,8 @@ class LibraryUpdateService( */ enum class Target { CHAPTERS, // Manga chapters - DETAILS, // Manga metadata - TRACKING // Tracking metadata + DETAILS, // Manga metadata + TRACKING // Tracking metadata } companion object { @@ -106,6 +121,10 @@ class LibraryUpdateService( */ const val KEY_TARGET = "target" + private const val NOTIF_MAX_CHAPTERS = 5 + private const val NOTIF_TITLE_MAX_LEN = 45 + private const val NOTIF_ICON_SIZE = 192 + /** * Returns the status of the service. * @@ -123,8 +142,9 @@ class LibraryUpdateService( * @param context the application context. * @param category a specific category to update, or null for global update. * @param target defines what should be updated. + * @return true if service newly started, false otherwise */ - fun start(context: Context, category: Category? = null, target: Target = Target.CHAPTERS) { + fun start(context: Context, category: Category? = null, target: Target = Target.CHAPTERS): Boolean { if (!isRunning(context)) { val intent = Intent(context, LibraryUpdateService::class.java).apply { putExtra(KEY_TARGET, target) @@ -135,7 +155,11 @@ class LibraryUpdateService( } else { context.startForegroundService(intent) } + + return true } + + return false } /** @@ -146,7 +170,6 @@ class LibraryUpdateService( fun stop(context: Context) { context.stopService(Intent(context, LibraryUpdateService::class.java)) } - } /** @@ -155,9 +178,10 @@ class LibraryUpdateService( */ override fun onCreate() { super.onCreate() - startForeground(Notifications.ID_LIBRARY_PROGRESS, progressNotification.build()) + startForeground(Notifications.ID_LIBRARY_PROGRESS, progressNotificationBuilder.build()) wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, "LibraryUpdateService:WakeLock") + PowerManager.PARTIAL_WAKE_LOCK, "LibraryUpdateService:WakeLock" + ) wakeLock.acquire() } @@ -189,37 +213,41 @@ class LibraryUpdateService( * @return the start value of the command. */ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (intent == null) return Service.START_NOT_STICKY + if (intent == null) return START_NOT_STICKY val target = intent.getSerializableExtra(KEY_TARGET) as? Target - ?: return Service.START_NOT_STICKY + ?: return START_NOT_STICKY // Unsubscribe from any previous subscription if needed. subscription?.unsubscribe() // Update favorite manga. Destroy service when completed or in case of an error. subscription = Observable - .defer { - val selectedScheme = preferences.libraryUpdatePrioritization().getOrDefault() - val mangaList = getMangaToUpdate(intent, target) - .sortedWith(rankingScheme[selectedScheme]) + .defer { + val selectedScheme = preferences.libraryUpdatePrioritization().get() + val mangaList = getMangaToUpdate(intent, target) + .sortedWith(rankingScheme[selectedScheme]) - // Update either chapter list or manga details. - when (target) { - Target.CHAPTERS -> updateChapterList(mangaList) - Target.DETAILS -> updateDetails(mangaList) - Target.TRACKING -> updateTrackings(mangaList) - } + // Update either chapter list or manga details. + when (target) { + Target.CHAPTERS -> updateChapterList(mangaList) + Target.DETAILS -> updateDetails(mangaList) + Target.TRACKING -> updateTrackings(mangaList) } - .subscribeOn(Schedulers.io()) - .subscribe({ - }, { + } + .subscribeOn(Schedulers.io()) + .subscribe( + { + }, + { Timber.e(it) stopSelf(startId) - }, { + }, + { stopSelf(startId) - }) + } + ) - return Service.START_REDELIVER_INTENT + return START_REDELIVER_INTENT } /** @@ -232,18 +260,18 @@ class LibraryUpdateService( fun getMangaToUpdate(intent: Intent, target: Target): List { val categoryId = intent.getIntExtra(KEY_CATEGORY, -1) - var listToUpdate = if (categoryId != -1) + var listToUpdate = if (categoryId != -1) { db.getLibraryMangas().executeAsBlocking().filter { it.category == categoryId } - else { - val categoriesToUpdate = preferences.libraryUpdateCategories().getOrDefault().map(String::toInt) - if (categoriesToUpdate.isNotEmpty()) + } else { + val categoriesToUpdate = preferences.libraryUpdateCategories().get().map(String::toInt) + if (categoriesToUpdate.isNotEmpty()) { db.getLibraryMangas().executeAsBlocking() - .filter { it.category in categoriesToUpdate } - .distinctBy { it.id } - else + .filter { it.category in categoriesToUpdate } + .distinctBy { it.id } + } else { db.getLibraryMangas().executeAsBlocking().distinctBy { it.id } + } } - if (target == Target.CHAPTERS && preferences.updateOnlyNonCompleted()) { listToUpdate = listToUpdate.filter { it.status != SManga.COMPLETED } } @@ -264,66 +292,73 @@ class LibraryUpdateService( // Initialize the variables holding the progress of the updates. val count = AtomicInteger(0) // List containing new updates - val newUpdates = ArrayList() - // list containing failed updates + val newUpdates = ArrayList>>() + // List containing failed updates val failedUpdates = ArrayList() // List containing categories that get included in downloads. - val categoriesToDownload = preferences.downloadNewCategories().getOrDefault().map(String::toInt) + val categoriesToDownload = preferences.downloadNewCategories().get().map(String::toInt) // Boolean to determine if user wants to automatically download new chapters. - val downloadNew = preferences.downloadNew().getOrDefault() + val downloadNew = preferences.downloadNew().get() // Boolean to determine if DownloadManager has downloads var hasDownloads = false // Emit each manga and update it sequentially. return Observable.from(mangaToUpdate) - // Notify manga that will update. - .doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size) } - // Update the chapters of the manga. - .concatMap { manga -> - if(manga.source in LIBRARY_UPDATE_EXCLUDED_SOURCES) { + // Notify manga that will update. + .doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size) } + // Update the chapters of the manga. + .concatMap { manga -> + if(manga.source in LIBRARY_UPDATE_EXCLUDED_SOURCES) { // Ignore EXH manga, updating chapters for every manga will get you banned Observable.empty() } else { updateManga(manga) - // If there's any error, return empty update and continue. - .onErrorReturn { - failedUpdates.add(manga) - Pair(emptyList(), emptyList()) + // If there's any error, return empty update and continue. + .onErrorReturn { + failedUpdates.add(manga) + Pair(emptyList(), emptyList()) + } + // Filter out mangas without new chapters (or failed). + .filter { pair -> pair.first.isNotEmpty() } + .doOnNext { + if (downloadNew && ( + categoriesToDownload.isEmpty() || + manga.category in categoriesToDownload + ) + ) { + downloadChapters(manga, it.first) + hasDownloads = true } - // Filter out mangas without new chapters (or failed). - .filter { pair -> pair.first.isNotEmpty() } - .doOnNext { - if (downloadNew && (categoriesToDownload.isEmpty() || - manga.category in categoriesToDownload)) { - - downloadChapters(manga, it.first) - hasDownloads = true - } - } - // Convert to the manga that contains new chapters. - .map { manga } + } + // Convert to the manga that contains new chapters. + .map { + Pair( + manga, + (it.first.sortedByDescending { ch -> ch.source_order }.toTypedArray()) + ) + } + } + // Add manga with new chapters to the list. + .doOnNext { manga -> + // Add to the list + newUpdates.add(manga) + } + // Notify result of the overall update. + .doOnCompleted { + if (newUpdates.isNotEmpty()) { + showUpdateNotifications(newUpdates) + if (downloadNew && hasDownloads) { + DownloadService.start(this) } } - // Add manga with new chapters to the list. - .doOnNext { manga -> - // Add to the list - newUpdates.add(manga) - } - // Notify result of the overall update. - .doOnCompleted { - if (newUpdates.isNotEmpty()) { - updateNotifier.showResultNotification(newUpdates) - if (downloadNew && hasDownloads) { - DownloadService.start(this) - } - } - if (failedUpdates.isNotEmpty()) { - Timber.e("Failed updating: ${failedUpdates.map { it.title }}") - } - - cancelProgressNotification() + if (failedUpdates.isNotEmpty()) { + Timber.e("Failed updating: ${failedUpdates.map { it.title }}") } + + cancelProgressNotification() + } + .map { manga -> manga.first } } fun downloadChapters(manga: Manga, chapters: List) { @@ -346,7 +381,7 @@ class LibraryUpdateService( fun updateManga(manga: Manga): Observable, List>> { val source = sourceManager.get(manga.source) as? HttpSource ?: return Observable.empty() return source.fetchChapterList(manga) - .map { syncChaptersWithSource(db, it, manga, source) } + .map { syncChaptersWithSource(db, it, manga, source) } } /** @@ -362,24 +397,24 @@ class LibraryUpdateService( // Emit each manga and update it sequentially. return Observable.from(mangaToUpdate) - // Notify manga that will update. - .doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size) } - // Update the details of the manga. - .concatMap { manga -> - val source = sourceManager.get(manga.source) as? HttpSource - ?: return@concatMap Observable.empty() + // Notify manga that will update. + .doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size) } + // Update the details of the manga. + .concatMap { manga -> + val source = sourceManager.get(manga.source) as? HttpSource + ?: return@concatMap Observable.empty() - source.fetchMangaDetails(manga) - .map { networkManga -> - manga.copyFrom(networkManga) - db.insertManga(manga).executeAsBlocking() - manga - } - .onErrorReturn { manga } - } - .doOnCompleted { - cancelProgressNotification() - } + source.fetchMangaDetails(manga) + .map { networkManga -> + manga.copyFrom(networkManga) + db.insertManga(manga).executeAsBlocking() + manga + } + .onErrorReturn { manga } + } + .doOnCompleted { + cancelProgressNotification() + } } /** @@ -394,28 +429,28 @@ class LibraryUpdateService( // Emit each manga and update it sequentially. return Observable.from(mangaToUpdate) - // Notify manga that will update. - .doOnNext { showProgressNotification(it, count++, mangaToUpdate.size) } - // Update the tracking details. - .concatMap { manga -> - val tracks = db.getTracks(manga).executeAsBlocking() + // Notify manga that will update. + .doOnNext { showProgressNotification(it, count++, mangaToUpdate.size) } + // Update the tracking details. + .concatMap { manga -> + val tracks = db.getTracks(manga).executeAsBlocking() - Observable.from(tracks) - .concatMap { track -> - val service = trackManager.getService(track.sync_id) - if (service != null && service in loggedServices) { - service.refresh(track) - .doOnNext { db.insertTrack(it).executeAsBlocking() } - .onErrorReturn { track } - } else { - Observable.empty() - } - } - .map { manga } - } - .doOnCompleted { - cancelProgressNotification() - } + Observable.from(tracks) + .concatMap { track -> + val service = trackManager.getService(track.sync_id) + if (service != null && service in loggedServices) { + service.refresh(track) + .doOnNext { db.insertTrack(it).executeAsBlocking() } + .onErrorReturn { track } + } else { + Observable.empty() + } + } + .map { manga } + } + .doOnCompleted { + cancelProgressNotification() + } } /** @@ -426,10 +461,116 @@ class LibraryUpdateService( * @param total the total progress. */ private fun showProgressNotification(manga: Manga, current: Int, total: Int) { - notificationManager.notify(Notifications.ID_LIBRARY_PROGRESS, progressNotification - .setContentTitle(manga.title) + val title = if (preferences.hideNotificationContent()) { + getString(R.string.notification_check_updates) + } else { + manga.title + } + + notificationManager.notify( + Notifications.ID_LIBRARY_PROGRESS, + progressNotificationBuilder + .setContentTitle(title) .setProgress(total, current, false) - .build()) + .build() + ) + } + + /** + * Shows the notification containing the result of the update done by the service. + * + * @param updates a list of manga with new updates. + */ + private fun showUpdateNotifications(updates: List>>) { + if (updates.isEmpty()) { + return + } + + NotificationManagerCompat.from(this).apply { + // Parent group notification + notify( + Notifications.ID_NEW_CHAPTERS, + notification(Notifications.CHANNEL_NEW_CHAPTERS) { + setContentTitle(getString(R.string.notification_new_chapters)) + if (updates.size == 1 && !preferences.hideNotificationContent()) { + setContentText(updates.first().first.title.chop(NOTIF_TITLE_MAX_LEN)) + } else { + setContentText(resources.getQuantityString(R.plurals.notification_new_chapters_summary, updates.size, updates.size)) + + if (!preferences.hideNotificationContent()) { + setStyle( + NotificationCompat.BigTextStyle().bigText( + updates.joinToString("\n") { + it.first.title.chop(NOTIF_TITLE_MAX_LEN) + } + ) + ) + } + } + + setSmallIcon(R.drawable.ic_tachi) + setLargeIcon(notificationBitmap) + + setGroup(Notifications.GROUP_NEW_CHAPTERS) + setGroupAlertBehavior(GROUP_ALERT_SUMMARY) + setGroupSummary(true) + priority = NotificationCompat.PRIORITY_HIGH + + setContentIntent(getNotificationIntent()) + setAutoCancel(true) + } + ) + + // Per-manga notification + if (!preferences.hideNotificationContent()) { + updates.forEach { + val (manga, chapters) = it + notify(manga.id.hashCode(), createNewChaptersNotification(manga, chapters)) + } + } + } + } + + private fun createNewChaptersNotification(manga: Manga, chapters: Array): Notification { + return notification(Notifications.CHANNEL_NEW_CHAPTERS) { + setContentTitle(manga.title) + + val description = getNewChaptersDescription(chapters) + setContentText(description) + setStyle(NotificationCompat.BigTextStyle().bigText(description)) + + setSmallIcon(R.drawable.ic_tachi) + + val icon = getMangaIcon(manga) + if (icon != null) { + setLargeIcon(icon) + } + + setGroup(Notifications.GROUP_NEW_CHAPTERS) + setGroupAlertBehavior(GROUP_ALERT_SUMMARY) + priority = NotificationCompat.PRIORITY_HIGH + + // Open first chapter on tap + setContentIntent(NotificationReceiver.openChapterPendingActivity(this@LibraryUpdateService, manga, chapters.first())) + setAutoCancel(true) + + // Mark chapters as read action + addAction( + R.drawable.ic_glasses_black_24dp, getString(R.string.action_mark_as_read), + NotificationReceiver.markAsReadPendingBroadcast( + this@LibraryUpdateService, + manga, chapters, Notifications.ID_NEW_CHAPTERS + ) + ) + // View chapters action + addAction( + R.drawable.ic_book_24dp, getString(R.string.action_view_chapters), + NotificationReceiver.openChapterPendingActivity( + this@LibraryUpdateService, + manga, Notifications.ID_NEW_CHAPTERS + ) + ) + } } /** @@ -438,4 +579,77 @@ class LibraryUpdateService( private fun cancelProgressNotification() { notificationManager.cancel(Notifications.ID_LIBRARY_PROGRESS) } + + private fun getMangaIcon(manga: Manga): Bitmap? { + return try { + Glide.with(this) + .asBitmap() + .load(manga.toMangaThumbnail()) + .dontTransform() + .centerCrop() + .circleCrop() + .override(NOTIF_ICON_SIZE, NOTIF_ICON_SIZE) + .submit() + .get() + } catch (e: Exception) { + null + } + } + + private fun getNewChaptersDescription(chapters: Array): String { + val formatter = DecimalFormat( + "#.###", + DecimalFormatSymbols() + .apply { decimalSeparator = '.' } + ) + + val displayableChapterNumbers = chapters + .filter { it.isRecognizedNumber } + .sortedBy { it.chapter_number } + .map { formatter.format(it.chapter_number) } + .toSet() + + return when (displayableChapterNumbers.size) { + // No sensible chapter numbers to show (i.e. no chapters have parsed chapter number) + 0 -> { + // "1 new chapter" or "5 new chapters" + resources.getQuantityString(R.plurals.notification_chapters_generic, chapters.size, chapters.size) + } + // Only 1 chapter has a parsed chapter number + 1 -> { + val remaining = chapters.size - displayableChapterNumbers.size + if (remaining == 0) { + // "Chapter 2.5" + resources.getString(R.string.notification_chapters_single, displayableChapterNumbers.first()) + } else { + // "Chapter 2.5 and 10 more" + resources.getString(R.string.notification_chapters_single_and_more, displayableChapterNumbers.first(), remaining) + } + } + // Everything else (i.e. multiple parsed chapter numbers) + else -> { + val shouldTruncate = displayableChapterNumbers.size > NOTIF_MAX_CHAPTERS + if (shouldTruncate) { + // "Chapters 1, 2.5, 3, 4, 5 and 10 more" + val remaining = displayableChapterNumbers.size - NOTIF_MAX_CHAPTERS + val joinedChapterNumbers = displayableChapterNumbers.take(NOTIF_MAX_CHAPTERS).joinToString(", ") + resources.getQuantityString(R.plurals.notification_chapters_multiple_and_more, remaining, joinedChapterNumbers, remaining) + } else { + // "Chapters 1, 2.5, 3" + resources.getString(R.string.notification_chapters_multiple, displayableChapterNumbers.joinToString(", ")) + } + } + } + } + + /** + * Returns an intent to open the main activity. + */ + private fun getNotificationIntent(): PendingIntent { + val intent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + action = MainActivity.SHORTCUT_RECENTLY_UPDATED + } + return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationHandler.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationHandler.kt index 4c61ca4ee..3f74c375d 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationHandler.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationHandler.kt @@ -4,8 +4,9 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.net.Uri +import eu.kanade.tachiyomi.extension.util.ExtensionInstaller import eu.kanade.tachiyomi.ui.main.MainActivity -import eu.kanade.tachiyomi.util.getUriCompat +import eu.kanade.tachiyomi.util.storage.getUriCompat import java.io.File /** @@ -48,7 +49,7 @@ object NotificationHandler { */ fun installApkPendingActivity(context: Context, uri: Uri): PendingIntent { val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, "application/vnd.android.package-archive") + setDataAndType(uri, ExtensionInstaller.APK_MIME) flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION } return PendingIntent.getActivity(context, 0, intent, 0) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt index 11622c505..40333ee2c 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt @@ -4,22 +4,32 @@ import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.net.Uri +import android.os.Build import android.os.Handler +import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.backup.BackupRestoreService import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadService import eu.kanade.tachiyomi.data.library.LibraryUpdateService +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.ui.main.MainActivity +import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.reader.ReaderActivity -import eu.kanade.tachiyomi.util.DiskUtil -import eu.kanade.tachiyomi.util.getUriCompat -import eu.kanade.tachiyomi.util.notificationManager -import eu.kanade.tachiyomi.util.toast -import uy.kohesive.injekt.injectLazy +import eu.kanade.tachiyomi.util.lang.launchIO +import eu.kanade.tachiyomi.util.storage.DiskUtil +import eu.kanade.tachiyomi.util.storage.getUriCompat +import eu.kanade.tachiyomi.util.system.notificationManager +import eu.kanade.tachiyomi.util.system.toast import java.io.File -import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy /** * Global [BroadcastReceiver] that runs on UI thread @@ -27,9 +37,7 @@ import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID * NOTE: Use local broadcasts if possible. */ class NotificationReceiver : BroadcastReceiver() { - /** - * Download manager. - */ + private val downloadManager: DownloadManager by injectLazy() override fun onReceive(context: Context, intent: Intent) { @@ -45,20 +53,48 @@ class NotificationReceiver : BroadcastReceiver() { } // Clear the download queue ACTION_CLEAR_DOWNLOADS -> downloadManager.clearQueue(true) - // Show message notification created - ACTION_SHORTCUT_CREATED -> context.toast(R.string.shortcut_created) // Launch share activity and dismiss notification - ACTION_SHARE_IMAGE -> shareImage(context, intent.getStringExtra(EXTRA_FILE_LOCATION), - intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)) + ACTION_SHARE_IMAGE -> + shareImage( + context, intent.getStringExtra(EXTRA_FILE_LOCATION), + intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1) + ) // Delete image from path and dismiss notification - ACTION_DELETE_IMAGE -> deleteImage(context, intent.getStringExtra(EXTRA_FILE_LOCATION), - intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)) + ACTION_DELETE_IMAGE -> + deleteImage( + context, intent.getStringExtra(EXTRA_FILE_LOCATION), + intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1) + ) + // Share backup file + ACTION_SHARE_BACKUP -> + shareBackup( + context, intent.getParcelableExtra(EXTRA_URI), + intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1) + ) + ACTION_CANCEL_RESTORE -> cancelRestore( + context, + intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1) + ) // Cancel library update and dismiss notification ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context, Notifications.ID_LIBRARY_PROGRESS) // Open reader activity ACTION_OPEN_CHAPTER -> { - openChapter(context, intent.getLongExtra(EXTRA_MANGA_ID, -1), - intent.getLongExtra(EXTRA_CHAPTER_ID, -1)) + openChapter( + context, intent.getLongExtra(EXTRA_MANGA_ID, -1), + intent.getLongExtra(EXTRA_CHAPTER_ID, -1) + ) + } + // Mark updated manga chapters as read + ACTION_MARK_AS_READ -> { + val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1) + if (notificationId > -1) { + dismissNotification(context, notificationId, intent.getIntExtra(EXTRA_GROUP_ID, 0)) + } + val urls = intent.getStringArrayExtra(EXTRA_CHAPTER_URL) ?: return + val mangaId = intent.getLongExtra(EXTRA_MANGA_ID, -1) + if (mangaId > -1) { + markAsRead(urls, mangaId) + } } } } @@ -84,8 +120,8 @@ class NotificationReceiver : BroadcastReceiver() { val intent = Intent(Intent.ACTION_SEND).apply { val uri = File(path).getUriCompat(context) putExtra(Intent.EXTRA_STREAM, uri) - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION type = "image/*" + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION } // Dismiss notification dismissNotification(context, notificationId) @@ -93,6 +129,25 @@ class NotificationReceiver : BroadcastReceiver() { context.startActivity(intent) } + /** + * Called to start share intent to share backup file + * + * @param context context of application + * @param path path of file + * @param notificationId id of notification + */ + private fun shareBackup(context: Context, uri: Uri, notificationId: Int) { + val sendIntent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_STREAM, uri) + type = "application/json" + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION + } + // Dismiss notification + dismissNotification(context, notificationId) + // Launch share activity + context.startActivity(sendIntent) + } + /** * Starts reader activity * @@ -104,7 +159,6 @@ class NotificationReceiver : BroadcastReceiver() { val db = DatabaseHelper(context) val manga = db.getManga(mangaId).executeAsBlocking() val chapter = db.getChapter(chapterId).executeAsBlocking() - if (manga != null && chapter != null) { val intent = ReaderActivity.newIntent(context, manga, chapter).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP @@ -132,6 +186,17 @@ class NotificationReceiver : BroadcastReceiver() { DiskUtil.scanMedia(context, file) } + /** + * Method called when user wants to stop a backup restore job. + * + * @param context context of application + * @param notificationId id of notification + */ + private fun cancelRestore(context: Context, notificationId: Int) { + BackupRestoreService.stop(context) + Handler().post { dismissNotification(context, notificationId) } + } + /** * Method called when user wants to stop a library update * @@ -143,6 +208,35 @@ class NotificationReceiver : BroadcastReceiver() { Handler().post { dismissNotification(context, notificationId) } } + /** + * Method called when user wants to mark manga chapters as read + * + * @param chapterUrls URLs of chapter to mark as read + * @param mangaId id of manga + */ + private fun markAsRead(chapterUrls: Array, mangaId: Long) { + val db: DatabaseHelper = Injekt.get() + val preferences: PreferencesHelper = Injekt.get() + val sourceManager: SourceManager = Injekt.get() + + launchIO { + chapterUrls.mapNotNull { db.getChapter(it, mangaId).executeAsBlocking() } + .forEach { + it.read = true + db.updateChapterProgress(it).executeAsBlocking() + if (preferences.removeAfterMarkedAsRead()) { + val manga = db.getManga(mangaId).executeAsBlocking() + if (manga != null) { + val source = sourceManager.get(manga.source) + if (source != null) { + downloadManager.deleteChapters(listOf(it), manga, source) + } + } + } + } + } + } + companion object { private const val NAME = "NotificationReceiver" @@ -152,10 +246,19 @@ class NotificationReceiver : BroadcastReceiver() { // Called to delete image. private const val ACTION_DELETE_IMAGE = "$ID.$NAME.DELETE_IMAGE" + // Called to launch send intent. + private const val ACTION_SHARE_BACKUP = "$ID.$NAME.SEND_BACKUP" + + // Called to cancel backup restore job. + private const val ACTION_CANCEL_RESTORE = "$ID.$NAME.CANCEL_RESTORE" + // Called to cancel library update. private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE" - // Called to open chapter + // Called to mark manga chapters as read. + private const val ACTION_MARK_AS_READ = "$ID.$NAME.MARK_AS_READ" + + // Called to open chapter. private const val ACTION_OPEN_CHAPTER = "$ID.$NAME.ACTION_OPEN_CHAPTER" // Value containing file location. @@ -170,21 +273,27 @@ class NotificationReceiver : BroadcastReceiver() { // Called to clear downloads. private const val ACTION_CLEAR_DOWNLOADS = "$ID.$NAME.ACTION_CLEAR_DOWNLOADS" - // Called to notify user shortcut is created. - private const val ACTION_SHORTCUT_CREATED = "$ID.$NAME.ACTION_SHORTCUT_CREATED" - // Called to dismiss notification. private const val ACTION_DISMISS_NOTIFICATION = "$ID.$NAME.ACTION_DISMISS_NOTIFICATION" + // Value containing uri. + private const val EXTRA_URI = "$ID.$NAME.URI" + // Value containing notification id. private const val EXTRA_NOTIFICATION_ID = "$ID.$NAME.NOTIFICATION_ID" + // Value containing group id. + private const val EXTRA_GROUP_ID = "$ID.$NAME.EXTRA_GROUP_ID" + // Value containing manga id. private const val EXTRA_MANGA_ID = "$ID.$NAME.EXTRA_MANGA_ID" // Value containing chapter id. private const val EXTRA_CHAPTER_ID = "$ID.$NAME.EXTRA_CHAPTER_ID" + // Value containing chapter url. + private const val EXTRA_CHAPTER_URL = "$ID.$NAME.EXTRA_CHAPTER_URL" + /** * Returns a [PendingIntent] that resumes the download of a chapter * @@ -224,13 +333,6 @@ class NotificationReceiver : BroadcastReceiver() { return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) } - internal fun shortcutCreatedBroadcast(context: Context): PendingIntent { - val intent = Intent(context, NotificationReceiver::class.java).apply { - action = ACTION_SHORTCUT_CREATED - } - return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) - } - /** * Returns [PendingIntent] that starts a service which dismissed the notification * @@ -246,6 +348,44 @@ class NotificationReceiver : BroadcastReceiver() { return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) } + /** + * Returns [PendingIntent] that starts a service which dismissed the notification + * + * @param context context of application + * @param notificationId id of notification + * @return [PendingIntent] + */ + internal fun dismissNotification(context: Context, notificationId: Int, groupId: Int? = null) { + /* + Group notifications always have at least 2 notifications: + - Group summary notification + - Single manga notification + + If the single notification is dismissed by the system, ie by a user swipe or tapping on the notification, + it will auto dismiss the group notification if there's no other single updates. + + When programmatically dismissing this notification, the group notification is not automatically dismissed. + */ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val groupKey = context.notificationManager.activeNotifications.find { + it.id == notificationId + }?.groupKey + + if (groupId != null && groupId != 0 && groupKey != null && groupKey.isNotEmpty()) { + val notifications = context.notificationManager.activeNotifications.filter { + it.groupKey == groupKey + } + + if (notifications.size == 2) { + context.notificationManager.cancel(groupId) + return + } + } + } + + context.notificationManager.cancel(notificationId) + } + /** * Returns [PendingIntent] that starts a service which cancels the notification and starts a share activity * @@ -281,19 +421,53 @@ class NotificationReceiver : BroadcastReceiver() { } /** - * Returns [PendingIntent] that start a reader activity containing chapter. + * Returns [PendingIntent] that starts a reader activity containing chapter. * * @param context context of application * @param manga manga of chapter * @param chapter chapter that needs to be opened */ - internal fun openChapterPendingBroadcast(context: Context, manga: Manga, chapter: Chapter): PendingIntent { - val intent = Intent(context, NotificationReceiver::class.java).apply { - action = ACTION_OPEN_CHAPTER + internal fun openChapterPendingActivity(context: Context, manga: Manga, chapter: Chapter): PendingIntent { + val newIntent = ReaderActivity.newIntent(context, manga, chapter) + return PendingIntent.getActivity(context, manga.id.hashCode(), newIntent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + /** + * Returns [PendingIntent] that opens the manga info controller. + * + * @param context context of application + * @param manga manga of chapter + */ + internal fun openChapterPendingActivity(context: Context, manga: Manga, groupId: Int): PendingIntent { + val newIntent = + Intent(context, MainActivity::class.java).setAction(MainActivity.SHORTCUT_MANGA) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + .putExtra(MangaController.MANGA_EXTRA, manga.id) + .putExtra("notificationId", manga.id.hashCode()) + .putExtra("groupId", groupId) + return PendingIntent.getActivity(context, manga.id.hashCode(), newIntent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + /** + * Returns [PendingIntent] that marks a chapter as read and deletes it if preferred + * + * @param context context of application + * @param manga manga of chapter + */ + internal fun markAsReadPendingBroadcast( + context: Context, + manga: Manga, + chapters: Array, + groupId: Int + ): PendingIntent { + val newIntent = Intent(context, NotificationReceiver::class.java).apply { + action = ACTION_MARK_AS_READ + putExtra(EXTRA_CHAPTER_URL, chapters.map { it.url }.toTypedArray()) putExtra(EXTRA_MANGA_ID, manga.id) - putExtra(EXTRA_CHAPTER_ID, chapter.id) + putExtra(EXTRA_NOTIFICATION_ID, manga.id.hashCode()) + putExtra(EXTRA_GROUP_ID, groupId) } - return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + return PendingIntent.getBroadcast(context, manga.id.hashCode(), newIntent, PendingIntent.FLAG_UPDATE_CURRENT) } /** @@ -308,5 +482,67 @@ class NotificationReceiver : BroadcastReceiver() { } return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) } + + /** + * Returns [PendingIntent] that opens the extensions controller. + * + * @param context context of application + * @return [PendingIntent] + */ + internal fun openExtensionsPendingActivity(context: Context): PendingIntent { + val intent = Intent(context, MainActivity::class.java).apply { + action = MainActivity.SHORTCUT_EXTENSIONS + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + /** + * Returns [PendingIntent] that starts a share activity for a backup file. + * + * @param context context of application + * @param uri uri of backup file + * @param notificationId id of notification + * @return [PendingIntent] + */ + internal fun shareBackupPendingBroadcast(context: Context, uri: Uri, notificationId: Int): PendingIntent { + val intent = Intent(context, NotificationReceiver::class.java).apply { + action = ACTION_SHARE_BACKUP + putExtra(EXTRA_URI, uri) + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + } + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + /** + * Returns [PendingIntent] that opens the error log file in an external viewer + * + * @param context context of application + * @param uri uri of error log file + * @return [PendingIntent] + */ + internal fun openErrorLogPendingActivity(context: Context, uri: Uri): PendingIntent { + val intent = Intent().apply { + action = Intent.ACTION_VIEW + setDataAndType(uri, "text/plain") + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION + } + return PendingIntent.getActivity(context, 0, intent, 0) + } + + /** + * Returns [PendingIntent] that cancels a backup restore job. + * + * @param context context of application + * @param notificationId id of notification + * @return [PendingIntent] + */ + internal fun cancelRestorePendingBroadcast(context: Context, notificationId: Int): PendingIntent { + val intent = Intent(context, NotificationReceiver::class.java).apply { + action = ACTION_CANCEL_RESTORE + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + } + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt index be537dd14..6fd4f3c98 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt @@ -1,11 +1,12 @@ package eu.kanade.tachiyomi.data.notification import android.app.NotificationChannel +import android.app.NotificationChannelGroup import android.app.NotificationManager import android.content.Context import android.os.Build import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.util.notificationManager +import eu.kanade.tachiyomi.util.system.notificationManager /** * Class to manage the basic information of all the notifications used in the app. @@ -23,15 +24,42 @@ object Notifications { * Notification channel and ids used by the library updater. */ const val CHANNEL_LIBRARY = "library_channel" - const val ID_LIBRARY_PROGRESS = 101 - const val ID_LIBRARY_RESULT = 102 + const val ID_LIBRARY_PROGRESS = -101 /** * Notification channel and ids used by the downloader. */ const val CHANNEL_DOWNLOADER = "downloader_channel" - const val ID_DOWNLOAD_CHAPTER = 201 - const val ID_DOWNLOAD_CHAPTER_ERROR = 202 + const val ID_DOWNLOAD_CHAPTER = -201 + const val ID_DOWNLOAD_CHAPTER_ERROR = -202 + + /** + * Notification channel and ids used by the library updater. + */ + const val CHANNEL_NEW_CHAPTERS = "new_chapters_channel" + const val ID_NEW_CHAPTERS = -301 + const val GROUP_NEW_CHAPTERS = "eu.kanade.tachiyomi.NEW_CHAPTERS" + + /** + * Notification channel and ids used by the library updater. + */ + const val CHANNEL_UPDATES_TO_EXTS = "updates_ext_channel" + const val ID_UPDATES_TO_EXTS = -401 + + /** + * Notification channel and ids used by the backup/restore system. + */ + private const val GROUP_BACK_RESTORE = "group_backup_restore" + const val CHANNEL_BACKUP_RESTORE_PROGRESS = "backup_restore_progress_channel" + const val ID_BACKUP_PROGRESS = -501 + const val ID_RESTORE_PROGRESS = -503 + const val CHANNEL_BACKUP_RESTORE_COMPLETE = "backup_restore_complete_channel_v2" + const val ID_BACKUP_COMPLETE = -502 + const val ID_RESTORE_COMPLETE = -504 + + private val deprecatedChannels = listOf( + "backup_restore_complete_channel" + ) /** * Creates the notification channels introduced in Android Oreo. @@ -41,14 +69,55 @@ object Notifications { fun createChannels(context: Context) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val backupRestoreGroup = NotificationChannelGroup(GROUP_BACK_RESTORE, context.getString(R.string.channel_backup_restore)) + context.notificationManager.createNotificationChannelGroup(backupRestoreGroup) + val channels = listOf( - NotificationChannel(CHANNEL_COMMON, context.getString(R.string.channel_common), - NotificationManager.IMPORTANCE_LOW), - NotificationChannel(CHANNEL_LIBRARY, context.getString(R.string.channel_library), - NotificationManager.IMPORTANCE_LOW), - NotificationChannel(CHANNEL_DOWNLOADER, context.getString(R.string.channel_downloader), - NotificationManager.IMPORTANCE_LOW) + NotificationChannel( + CHANNEL_COMMON, context.getString(R.string.channel_common), + NotificationManager.IMPORTANCE_LOW + ), + NotificationChannel( + CHANNEL_LIBRARY, context.getString(R.string.channel_library), + NotificationManager.IMPORTANCE_LOW + ).apply { + setShowBadge(false) + }, + NotificationChannel( + CHANNEL_DOWNLOADER, context.getString(R.string.channel_downloader), + NotificationManager.IMPORTANCE_LOW + ).apply { + setShowBadge(false) + }, + NotificationChannel( + CHANNEL_NEW_CHAPTERS, context.getString(R.string.channel_new_chapters), + NotificationManager.IMPORTANCE_DEFAULT + ), + NotificationChannel( + CHANNEL_UPDATES_TO_EXTS, context.getString(R.string.channel_ext_updates), + NotificationManager.IMPORTANCE_DEFAULT + ), + NotificationChannel( + CHANNEL_BACKUP_RESTORE_PROGRESS, context.getString(R.string.channel_backup_restore_progress), + NotificationManager.IMPORTANCE_LOW + ).apply { + group = GROUP_BACK_RESTORE + setShowBadge(false) + }, + NotificationChannel( + CHANNEL_BACKUP_RESTORE_COMPLETE, context.getString(R.string.channel_backup_restore_complete), + NotificationManager.IMPORTANCE_HIGH + ).apply { + group = GROUP_BACK_RESTORE + setShowBadge(false) + setSound(null, null) + } ) context.notificationManager.createNotificationChannels(channels) + + // Delete old notification channels + deprecatedChannels.forEach { + context.notificationManager.deleteNotificationChannel(it) + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt index 65e4ce4ce..20a3c0063 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt @@ -1,201 +1,227 @@ -package eu.kanade.tachiyomi.data.preference - -/** - * This class stores the keys for the preferences in the application. - */ -object PreferenceKeys { - - const val theme = "pref_theme_key" - - const val rotation = "pref_rotation_type_key" - - const val enableTransitions = "pref_enable_transitions_key" - - const val doubleTapAnimationSpeed = "pref_double_tap_anim_speed" - - const val showPageNumber = "pref_show_page_number_key" - - const val trueColor = "pref_true_color_key" - - const val fullscreen = "fullscreen" - - const val keepScreenOn = "pref_keep_screen_on_key" - - const val customBrightness = "pref_custom_brightness_key" - - const val customBrightnessValue = "custom_brightness_value" - - const val colorFilter = "pref_color_filter_key" - - const val colorFilterValue = "color_filter_value" - - const val colorFilterMode = "color_filter_mode" - - const val defaultViewer = "pref_default_viewer_key" - - const val imageScaleType = "pref_image_scale_type_key" - - const val zoomStart = "pref_zoom_start_key" - - const val readerTheme = "pref_reader_theme_key" - - const val cropBorders = "crop_borders" - - const val cropBordersWebtoon = "crop_borders_webtoon" - - const val readWithTapping = "reader_tap" - - const val readWithLongTap = "reader_long_tap" - - const val readWithVolumeKeys = "reader_volume_keys" - - const val readWithVolumeKeysInverted = "reader_volume_keys_inverted" - - const val portraitColumns = "pref_library_columns_portrait_key" - - const val landscapeColumns = "pref_library_columns_landscape_key" - - const val updateOnlyNonCompleted = "pref_update_only_non_completed_key" - - const val autoUpdateTrack = "pref_auto_update_manga_sync_key" - - const val lastUsedCatalogueSource = "last_catalogue_source" - - const val lastUsedCategory = "last_used_category" - - const val catalogueAsList = "pref_display_catalogue_as_list" - - const val enabledLanguages = "source_languages" - - const val backupDirectory = "backup_directory" - - const val downloadsDirectory = "download_directory" - - const val downloadOnlyOverWifi = "pref_download_only_over_wifi_key" - - const val numberOfBackups = "backup_slots" - - const val backupInterval = "backup_interval" - - const val removeAfterReadSlots = "remove_after_read_slots" - - const val removeAfterMarkedAsRead = "pref_remove_after_marked_as_read_key" - - const val libraryUpdateInterval = "pref_library_update_interval_key" - - const val libraryUpdateRestriction = "library_update_restriction" - - const val libraryUpdateCategories = "library_update_categories" - - const val libraryUpdatePrioritization = "library_update_prioritization" - - const val filterDownloaded = "pref_filter_downloaded_key" - - const val filterUnread = "pref_filter_unread_key" - - const val filterCompleted = "pref_filter_completed_key" - - const val librarySortingMode = "library_sorting_mode" - - const val automaticUpdates = "automatic_updates" - - const val startScreen = "start_screen" - - const val downloadNew = "download_new" - - const val downloadNewCategories = "download_new_categories" - - const val libraryAsList = "pref_display_library_as_list" - - const val lang = "app_language" - - const val defaultCategory = "default_category" - - const val skipRead = "skip_read" - - const val downloadBadge = "display_download_badge" - - @Deprecated("Use the preferences of the source") - fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId" - - @Deprecated("Use the preferences of the source") - fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId" - - fun sourceSharedPref(sourceId: Long) = "source_$sourceId" - - fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId" - - fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId" - - fun trackToken(syncId: Int) = "track_token_$syncId" - - const val eh_lock_hash = "lock_hash" - - const val eh_lock_salt = "lock_salt" - - const val eh_lock_length = "lock_length" - - const val eh_lock_finger = "lock_finger" - - const val eh_lock_manually = "eh_lock_manually" - - const val eh_nh_useHighQualityThumbs = "eh_nh_hq_thumbs" - - const val eh_showSyncIntro = "eh_show_sync_intro" - - const val eh_readOnlySync = "eh_sync_read_only" - - const val eh_lenientSync = "eh_lenient_sync" - - const val eh_useOrigImages = "eh_useOrigImages" - - const val eh_ehSettingsProfile = "eh_ehSettingsProfile" - - const val eh_exhSettingsProfile = "eh_exhSettingsProfile" - - const val eh_settingsKey = "eh_settingsKey" - - const val eh_sessionCookie = "eh_sessionCookie" - - const val eh_hathPerksCookie = "eh_hathPerksCookie" - - const val eh_enableExHentai = "enable_exhentai" - - const val eh_ts_aspNetCookie = "eh_ts_aspNetCookie" - - const val eh_showSettingsUploadWarning = "eh_showSettingsUploadWarning2" - - const val eh_expandFilters = "eh_expand_filters" - - const val eh_readerThreads = "eh_reader_threads" - - const val eh_readerInstantRetry = "eh_reader_instant_retry" - - const val eh_utilAutoscrollInterval = "eh_util_autoscroll_interval" - - const val eh_cacheSize = "eh_cache_size" - - const val eh_preserveReadingPosition = "eh_preserve_reading_position" - - const val eh_incogWebview = "eh_incognito_webview" - - const val eh_autoSolveCaptchas = "eh_autosolve_captchas" - - const val eh_delegateSources = "eh_delegate_sources" - - const val eh_showTransitionPages = "eh_show_transition_pages" - - const val eh_logLevel = "eh_log_level" - - const val eh_enableSourceBlacklist = "eh_enable_source_blacklist" - - const val eh_autoUpdateFrequency = "eh_auto_update_frequency" - - const val eh_autoUpdateRestrictions = "eh_auto_update_restrictions" - - const val eh_autoUpdateStats = "eh_auto_update_stats" - - const val eh_aggressivePageLoading = "eh_aggressive_page_loading" - - const val eh_hl_useHighQualityThumbs = "eh_hl_hq_thumbs" -} +package eu.kanade.tachiyomi.data.preference + +/** + * This class stores the keys for the preferences in the application. + */ +object PreferenceKeys { + + const val themeMode = "pref_theme_mode_key" + + const val themeLight = "pref_theme_light_key" + + const val themeDark = "pref_theme_dark_key" + + const val confirmExit = "pref_confirm_exit" + + const val rotation = "pref_rotation_type_key" + + const val enableTransitions = "pref_enable_transitions_key" + + const val doubleTapAnimationSpeed = "pref_double_tap_anim_speed" + + const val showPageNumber = "pref_show_page_number_key" + + const val trueColor = "pref_true_color_key" + + const val fullscreen = "fullscreen" + + const val cutoutShort = "cutout_short" + + const val keepScreenOn = "pref_keep_screen_on_key" + + const val customBrightness = "pref_custom_brightness_key" + + const val customBrightnessValue = "custom_brightness_value" + + const val colorFilter = "pref_color_filter_key" + + const val colorFilterValue = "color_filter_value" + + const val colorFilterMode = "color_filter_mode" + + const val defaultViewer = "pref_default_viewer_key" + + const val imageScaleType = "pref_image_scale_type_key" + + const val zoomStart = "pref_zoom_start_key" + + const val readerTheme = "pref_reader_theme_key" + + const val cropBorders = "crop_borders" + + const val cropBordersWebtoon = "crop_borders_webtoon" + + const val readWithTapping = "reader_tap" + + const val readWithLongTap = "reader_long_tap" + + const val readWithVolumeKeys = "reader_volume_keys" + + const val readWithVolumeKeysInverted = "reader_volume_keys_inverted" + + const val webtoonSidePadding = "webtoon_side_padding" + + const val portraitColumns = "pref_library_columns_portrait_key" + + const val landscapeColumns = "pref_library_columns_landscape_key" + + const val updateOnlyNonCompleted = "pref_update_only_non_completed_key" + + const val autoUpdateTrack = "pref_auto_update_manga_sync_key" + + const val lastUsedCatalogueSource = "last_catalogue_source" + + const val lastUsedCategory = "last_used_category" + + const val catalogueAsList = "pref_display_catalogue_as_list" + + const val enabledLanguages = "source_languages" + + const val sourcesSort = "sources_sort" + + const val backupDirectory = "backup_directory" + + const val downloadsDirectory = "download_directory" + + const val downloadOnlyOverWifi = "pref_download_only_over_wifi_key" + + const val numberOfBackups = "backup_slots" + + const val backupInterval = "backup_interval" + + const val removeAfterReadSlots = "remove_after_read_slots" + + const val removeAfterMarkedAsRead = "pref_remove_after_marked_as_read_key" + + const val libraryUpdateInterval = "pref_library_update_interval_key" + + const val libraryUpdateRestriction = "library_update_restriction" + + const val libraryUpdateCategories = "library_update_categories" + + const val libraryUpdatePrioritization = "library_update_prioritization" + + const val downloadedOnly = "pref_downloaded_only" + + const val filterDownloaded = "pref_filter_downloaded_key" + + const val filterUnread = "pref_filter_unread_key" + + const val filterCompleted = "pref_filter_completed_key" + + const val librarySortingMode = "library_sorting_mode" + + const val automaticExtUpdates = "automatic_ext_updates" + + const val startScreen = "start_screen" + + const val useBiometricLock = "use_biometric_lock" + + const val lockAppAfter = "lock_app_after" + + const val lastAppUnlock = "last_app_unlock" + + const val secureScreen = "secure_screen" + + const val hideNotificationContent = "hide_notification_content" + + const val downloadNew = "download_new" + + const val downloadNewCategories = "download_new_categories" + + const val libraryAsList = "pref_display_library_as_list" + + const val lang = "app_language" + + const val dateFormat = "app_date_format" + + const val defaultCategory = "default_category" + + const val skipRead = "skip_read" + + const val skipFiltered = "skip_filtered" + + const val downloadBadge = "display_download_badge" + + const val skipPreMigration = "skip_pre_migration" + + const val alwaysShowChapterTransition = "always_show_chapter_transition" + + const val searchPinnedSourcesOnly = "search_pinned_sources_only" + + fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId" + + fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId" + + fun trackToken(syncId: Int) = "track_token_$syncId" + + const val eh_lock_hash = "lock_hash" + + const val eh_lock_salt = "lock_salt" + + const val eh_lock_length = "lock_length" + + const val eh_lock_finger = "lock_finger" + + const val eh_lock_manually = "eh_lock_manually" + + const val eh_nh_useHighQualityThumbs = "eh_nh_hq_thumbs" + + const val eh_showSyncIntro = "eh_show_sync_intro" + + const val eh_readOnlySync = "eh_sync_read_only" + + const val eh_lenientSync = "eh_lenient_sync" + + const val eh_useOrigImages = "eh_useOrigImages" + + const val eh_ehSettingsProfile = "eh_ehSettingsProfile" + + const val eh_exhSettingsProfile = "eh_exhSettingsProfile" + + const val eh_settingsKey = "eh_settingsKey" + + const val eh_sessionCookie = "eh_sessionCookie" + + const val eh_hathPerksCookie = "eh_hathPerksCookie" + + const val eh_enableExHentai = "enable_exhentai" + + const val eh_ts_aspNetCookie = "eh_ts_aspNetCookie" + + const val eh_showSettingsUploadWarning = "eh_showSettingsUploadWarning2" + + const val eh_expandFilters = "eh_expand_filters" + + const val eh_readerThreads = "eh_reader_threads" + + const val eh_readerInstantRetry = "eh_reader_instant_retry" + + const val eh_utilAutoscrollInterval = "eh_util_autoscroll_interval" + + const val eh_cacheSize = "eh_cache_size" + + const val eh_preserveReadingPosition = "eh_preserve_reading_position" + + const val eh_autoSolveCaptchas = "eh_autosolve_captchas" + + const val eh_delegateSources = "eh_delegate_sources" + + const val eh_logLevel = "eh_log_level" + + const val eh_enableSourceBlacklist = "eh_enable_source_blacklist" + + const val eh_autoUpdateFrequency = "eh_auto_update_frequency" + + const val eh_autoUpdateRestrictions = "eh_auto_update_restrictions" + + const val eh_autoUpdateStats = "eh_auto_update_stats" + + const val eh_aggressivePageLoading = "eh_aggressive_page_loading" + + const val eh_hl_useHighQualityThumbs = "eh_hl_hq_thumbs" + + const val eh_library_rounded_corners = "eh_library_corners" + + const val eh_preload_size = "eh_preload_size" +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceValues.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceValues.kt new file mode 100644 index 000000000..3e1a5a323 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceValues.kt @@ -0,0 +1,18 @@ +package eu.kanade.tachiyomi.data.preference + +/** + * This class stores the values for the preferences in the application. + */ +object PreferenceValues { + + const val THEME_MODE_LIGHT = "light" + const val THEME_MODE_DARK = "dark" + const val THEME_MODE_SYSTEM = "system" + + const val THEME_LIGHT_DEFAULT = "default" + const val THEME_LIGHT_BLUE = "blue" + + const val THEME_DARK_DEFAULT = "default" + const val THEME_DARK_BLUE = "blue" + const val THEME_DARK_AMOLED = "amoled" +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index 946848c68..8a5696f5a 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt @@ -1,84 +1,146 @@ package eu.kanade.tachiyomi.data.preference import android.content.Context +import android.content.SharedPreferences import android.net.Uri import android.os.Environment -import android.preference.PreferenceManager -import com.f2prateek.rx.preferences.Preference +import androidx.preference.PreferenceManager +import com.f2prateek.rx.preferences.Preference as RxPreference import com.f2prateek.rx.preferences.RxSharedPreferences +import com.tfcporciuncula.flow.FlowSharedPreferences +import com.tfcporciuncula.flow.Preference import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.track.TrackService -import eu.kanade.tachiyomi.source.Source -import exh.ui.migration.MigrationStatus -import java.io.File import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys +import eu.kanade.tachiyomi.data.preference.PreferenceValues as Values +import eu.kanade.tachiyomi.data.track.TrackService +import eu.kanade.tachiyomi.data.track.anilist.Anilist +import java.io.File +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Locale +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onEach -fun Preference.getOrDefault(): T = get() ?: defaultValue()!! +fun RxPreference.getOrDefault(): T = get() ?: defaultValue()!! -fun Preference.invert(): Boolean = getOrDefault().let { set(!it); !it } +@OptIn(ExperimentalCoroutinesApi::class) +fun Preference.asImmediateFlow(block: (value: T) -> Unit): Flow { + block(get()) + return asFlow() + .onEach { block(it) } +} +private class DateFormatConverter : RxPreference.Adapter { + override fun get(key: String, preferences: SharedPreferences): DateFormat { + val dateFormat = preferences.getString(Keys.dateFormat, "")!! + + if (dateFormat != "") { + return SimpleDateFormat(dateFormat, Locale.getDefault()) + } + + return DateFormat.getDateInstance(DateFormat.SHORT) + } + + override fun set(key: String, value: DateFormat, editor: SharedPreferences.Editor) { + // No-op + } +} + +@OptIn(ExperimentalCoroutinesApi::class) class PreferencesHelper(val context: Context) { val prefs = PreferenceManager.getDefaultSharedPreferences(context) val rxPrefs = RxSharedPreferences.create(prefs) + val flowPrefs = FlowSharedPreferences(prefs) private val defaultDownloadsDir = Uri.fromFile( - File(Environment.getExternalStorageDirectory().absolutePath + File.separator + - context.getString(R.string.app_name), "downloads")) + File( + Environment.getExternalStorageDirectory().absolutePath + File.separator + + context.getString(R.string.app_name), + "downloads" + ) + ) private val defaultBackupDir = Uri.fromFile( - File(Environment.getExternalStorageDirectory().absolutePath + File.separator + - context.getString(R.string.app_name), "backup")) + File( + Environment.getExternalStorageDirectory().absolutePath + File.separator + + context.getString(R.string.app_name), + "backup" + ) + ) fun startScreen() = prefs.getInt(Keys.startScreen, 1) + fun confirmExit() = prefs.getBoolean(Keys.confirmExit, false) + + fun useBiometricLock() = flowPrefs.getBoolean(Keys.useBiometricLock, false) + + fun lockAppAfter() = flowPrefs.getInt(Keys.lockAppAfter, 0) + + fun lastAppUnlock() = flowPrefs.getLong(Keys.lastAppUnlock, 0) + + fun secureScreen() = flowPrefs.getBoolean(Keys.secureScreen, false) + + fun hideNotificationContent() = prefs.getBoolean(Keys.hideNotificationContent, false) + fun clear() = prefs.edit().clear().apply() - fun theme() = prefs.getInt(Keys.theme, 1) + fun themeMode() = flowPrefs.getString(Keys.themeMode, Values.THEME_MODE_SYSTEM) + + fun themeLight() = flowPrefs.getString(Keys.themeLight, Values.THEME_LIGHT_DEFAULT) + + fun themeDark() = flowPrefs.getString(Keys.themeDark, Values.THEME_DARK_DEFAULT) fun rotation() = rxPrefs.getInteger(Keys.rotation, 1) - fun pageTransitions() = rxPrefs.getBoolean(Keys.enableTransitions, true) + fun pageTransitions() = flowPrefs.getBoolean(Keys.enableTransitions, true) - fun doubleTapAnimSpeed() = rxPrefs.getInteger(Keys.doubleTapAnimationSpeed, 500) + fun doubleTapAnimSpeed() = flowPrefs.getInt(Keys.doubleTapAnimationSpeed, 500) - fun showPageNumber() = rxPrefs.getBoolean(Keys.showPageNumber, true) + fun showPageNumber() = flowPrefs.getBoolean(Keys.showPageNumber, true) - fun trueColor() = rxPrefs.getBoolean(Keys.trueColor, false) + fun trueColor() = flowPrefs.getBoolean(Keys.trueColor, false) - fun fullscreen() = rxPrefs.getBoolean(Keys.fullscreen, true) + fun fullscreen() = flowPrefs.getBoolean(Keys.fullscreen, true) - fun keepScreenOn() = rxPrefs.getBoolean(Keys.keepScreenOn, true) + fun cutoutShort() = flowPrefs.getBoolean(Keys.cutoutShort, true) - fun customBrightness() = rxPrefs.getBoolean(Keys.customBrightness, false) + fun keepScreenOn() = flowPrefs.getBoolean(Keys.keepScreenOn, true) - fun customBrightnessValue() = rxPrefs.getInteger(Keys.customBrightnessValue, 0) + fun customBrightness() = flowPrefs.getBoolean(Keys.customBrightness, false) - fun colorFilter() = rxPrefs.getBoolean(Keys.colorFilter, false) + fun customBrightnessValue() = flowPrefs.getInt(Keys.customBrightnessValue, 0) - fun colorFilterValue() = rxPrefs.getInteger(Keys.colorFilterValue, 0) + fun colorFilter() = flowPrefs.getBoolean(Keys.colorFilter, false) - fun colorFilterMode() = rxPrefs.getInteger(Keys.colorFilterMode, 0) + fun colorFilterValue() = flowPrefs.getInt(Keys.colorFilterValue, 0) + + fun colorFilterMode() = flowPrefs.getInt(Keys.colorFilterMode, 0) fun defaultViewer() = prefs.getInt(Keys.defaultViewer, 1) - fun imageScaleType() = rxPrefs.getInteger(Keys.imageScaleType, 1) + fun imageScaleType() = flowPrefs.getInt(Keys.imageScaleType, 1) - fun zoomStart() = rxPrefs.getInteger(Keys.zoomStart, 1) + fun zoomStart() = flowPrefs.getInt(Keys.zoomStart, 1) - fun readerTheme() = rxPrefs.getInteger(Keys.readerTheme, 0) + fun readerTheme() = flowPrefs.getInt(Keys.readerTheme, 1) - fun cropBorders() = rxPrefs.getBoolean(Keys.cropBorders, false) + fun alwaysShowChapterTransition() = flowPrefs.getBoolean(Keys.alwaysShowChapterTransition, true) - fun cropBordersWebtoon() = rxPrefs.getBoolean(Keys.cropBordersWebtoon, false) + fun cropBorders() = flowPrefs.getBoolean(Keys.cropBorders, false) - fun readWithTapping() = rxPrefs.getBoolean(Keys.readWithTapping, true) + fun cropBordersWebtoon() = flowPrefs.getBoolean(Keys.cropBordersWebtoon, false) - fun readWithLongTap() = rxPrefs.getBoolean(Keys.readWithLongTap, true) + fun webtoonSidePadding() = flowPrefs.getInt(Keys.webtoonSidePadding, 0) - fun readWithVolumeKeys() = rxPrefs.getBoolean(Keys.readWithVolumeKeys, false) + fun readWithTapping() = flowPrefs.getBoolean(Keys.readWithTapping, true) - fun readWithVolumeKeysInverted() = rxPrefs.getBoolean(Keys.readWithVolumeKeysInverted, false) + fun readWithLongTap() = flowPrefs.getBoolean(Keys.readWithLongTap, true) + + fun readWithVolumeKeys() = flowPrefs.getBoolean(Keys.readWithVolumeKeys, false) + + fun readWithVolumeKeysInverted() = flowPrefs.getBoolean(Keys.readWithVolumeKeysInverted, false) fun portraitColumns() = rxPrefs.getInteger(Keys.portraitColumns, 0) @@ -90,24 +152,15 @@ class PreferencesHelper(val context: Context) { fun lastUsedCatalogueSource() = rxPrefs.getLong(Keys.lastUsedCatalogueSource, -1) - fun lastUsedCategory() = rxPrefs.getInteger(Keys.lastUsedCategory, 0) + fun lastUsedCategory() = flowPrefs.getInt(Keys.lastUsedCategory, 0) - fun lastVersionCode() = rxPrefs.getInteger("last_version_code", 0) + fun lastVersionCode() = flowPrefs.getInt("last_version_code", 0) fun catalogueAsList() = rxPrefs.getBoolean(Keys.catalogueAsList, false) - fun enabledLanguages() = rxPrefs.getStringSet(Keys.enabledLanguages, setOf("all")) + fun enabledLanguages() = flowPrefs.getStringSet(Keys.enabledLanguages, setOf("all", "en", Locale.getDefault().language)) - fun sourceUsername(source: Source) = prefs.getString(Keys.sourceUsername(source.id), "") - - fun sourcePassword(source: Source) = prefs.getString(Keys.sourcePassword(source.id), "") - - fun setSourceCredentials(source: Source, username: String, password: String) { - prefs.edit() - .putString(Keys.sourceUsername(source.id), username) - .putString(Keys.sourcePassword(source.id), password) - .apply() - } + fun sourceSorting() = flowPrefs.getInt(Keys.sourcesSort, 0) fun trackUsername(sync: TrackService) = prefs.getString(Keys.trackUsername(sync.id), "") @@ -115,59 +168,71 @@ class PreferencesHelper(val context: Context) { fun setTrackCredentials(sync: TrackService, username: String, password: String) { prefs.edit() - .putString(Keys.trackUsername(sync.id), username) - .putString(Keys.trackPassword(sync.id), password) - .apply() + .putString(Keys.trackUsername(sync.id), username) + .putString(Keys.trackPassword(sync.id), password) + .apply() } - fun trackToken(sync: TrackService) = rxPrefs.getString(Keys.trackToken(sync.id), "") + fun trackToken(sync: TrackService) = flowPrefs.getString(Keys.trackToken(sync.id), "") - fun anilistScoreType() = rxPrefs.getString("anilist_score_type", "POINT_10") + fun anilistScoreType() = flowPrefs.getString("anilist_score_type", Anilist.POINT_10) - fun backupsDirectory() = rxPrefs.getString(Keys.backupDirectory, defaultBackupDir.toString()) + fun backupsDirectory() = flowPrefs.getString(Keys.backupDirectory, defaultBackupDir.toString()) - fun downloadsDirectory() = rxPrefs.getString(Keys.downloadsDirectory, defaultDownloadsDir.toString()) + fun dateFormat() = rxPrefs.getObject(Keys.dateFormat, DateFormat.getDateInstance(DateFormat.SHORT), DateFormatConverter()) + + fun downloadsDirectory() = flowPrefs.getString(Keys.downloadsDirectory, defaultDownloadsDir.toString()) fun downloadOnlyOverWifi() = prefs.getBoolean(Keys.downloadOnlyOverWifi, true) - fun numberOfBackups() = rxPrefs.getInteger(Keys.numberOfBackups, 1) + fun numberOfBackups() = flowPrefs.getInt(Keys.numberOfBackups, 1) - fun backupInterval() = rxPrefs.getInteger(Keys.backupInterval, 0) + fun backupInterval() = flowPrefs.getInt(Keys.backupInterval, 0) fun removeAfterReadSlots() = prefs.getInt(Keys.removeAfterReadSlots, -1) fun removeAfterMarkedAsRead() = prefs.getBoolean(Keys.removeAfterMarkedAsRead, false) - fun libraryUpdateInterval() = rxPrefs.getInteger(Keys.libraryUpdateInterval, 0) + fun libraryUpdateInterval() = flowPrefs.getInt(Keys.libraryUpdateInterval, 24) - fun libraryUpdateRestriction() = prefs.getStringSet(Keys.libraryUpdateRestriction, emptySet()) + fun libraryUpdateRestriction() = prefs.getStringSet(Keys.libraryUpdateRestriction, setOf("wifi")) - fun libraryUpdateCategories() = rxPrefs.getStringSet(Keys.libraryUpdateCategories, emptySet()) + fun libraryUpdateCategories() = flowPrefs.getStringSet(Keys.libraryUpdateCategories, emptySet()) - fun libraryUpdatePrioritization() = rxPrefs.getInteger(Keys.libraryUpdatePrioritization, 0) + fun libraryUpdatePrioritization() = flowPrefs.getInt(Keys.libraryUpdatePrioritization, 0) - fun libraryAsList() = rxPrefs.getBoolean(Keys.libraryAsList, false) + fun libraryAsList() = flowPrefs.getBoolean(Keys.libraryAsList, false) - fun downloadBadge() = rxPrefs.getBoolean(Keys.downloadBadge, false) + fun downloadBadge() = flowPrefs.getBoolean(Keys.downloadBadge, false) + + fun downloadedOnly() = flowPrefs.getBoolean(Keys.downloadedOnly, false) // J2K converted from boolean to integer - fun filterDownloaded() = rxPrefs.getInteger(Keys.filterDownloaded, 0) + fun filterDownloaded() = flowPrefs.getInt(Keys.filterDownloaded, 0) - fun filterUnread() = rxPrefs.getInteger(Keys.filterUnread, 0) + fun filterUnread() = flowPrefs.getInt(Keys.filterUnread, 0) - fun filterCompleted() = rxPrefs.getInteger(Keys.filterCompleted, 0) + fun filterCompleted() = flowPrefs.getInt(Keys.filterCompleted, 0) - fun librarySortingMode() = rxPrefs.getInteger(Keys.librarySortingMode, 0) + fun librarySortingMode() = flowPrefs.getInt(Keys.librarySortingMode, 0) - fun librarySortingAscending() = rxPrefs.getBoolean("library_sorting_ascending", true) + fun librarySortingAscending() = flowPrefs.getBoolean("library_sorting_ascending", true) - fun automaticUpdates() = prefs.getBoolean(Keys.automaticUpdates, false) + fun automaticExtUpdates() = flowPrefs.getBoolean(Keys.automaticExtUpdates, true) - fun hiddenCatalogues() = rxPrefs.getStringSet("hidden_catalogues", emptySet()) + fun extensionUpdatesCount() = flowPrefs.getInt("ext_updates_count", 0) - fun downloadNew() = rxPrefs.getBoolean(Keys.downloadNew, false) + fun lastExtCheck() = flowPrefs.getLong("last_ext_check", 0) - fun downloadNewCategories() = rxPrefs.getStringSet(Keys.downloadNewCategories, emptySet()) + fun searchPinnedSourcesOnly() = prefs.getBoolean(Keys.searchPinnedSourcesOnly, false) + + fun hiddenCatalogues() = flowPrefs.getStringSet("hidden_catalogues", mutableSetOf()) + + fun pinnedCatalogues() = flowPrefs.getStringSet("pinned_catalogues", emptySet()) + + fun downloadNew() = flowPrefs.getBoolean(Keys.downloadNew, false) + + fun downloadNewCategories() = flowPrefs.getStringSet(Keys.downloadNewCategories, emptySet()) fun lang() = prefs.getString(Keys.lang, "") @@ -175,12 +240,24 @@ class PreferencesHelper(val context: Context) { fun skipRead() = prefs.getBoolean(Keys.skipRead, false) - fun migrateFlags() = rxPrefs.getInteger("migrate_flags", Int.MAX_VALUE) + fun skipFiltered() = prefs.getBoolean(Keys.skipFiltered, true) - fun trustedSignatures() = rxPrefs.getStringSet("trusted_signatures", emptySet()) + fun migrateFlags() = flowPrefs.getInt("migrate_flags", Int.MAX_VALUE) + + fun trustedSignatures() = flowPrefs.getStringSet("trusted_signatures", emptySet()) // --> AZ J2K CHERRYPICKING + fun defaultMangaOrder() = flowPrefs.getString("default_manga_order", "") + + fun migrationSources() = flowPrefs.getString("migrate_sources", "") + + fun smartMigration() = rxPrefs.getBoolean("smart_migrate", false) + + fun useSourceWithMost() = rxPrefs.getBoolean("use_source_with_most", false) + + fun skipPreMigration() = flowPrefs.getBoolean(Keys.skipPreMigration, false) + fun upgradeFilters() { val filterDl = rxPrefs.getBoolean(Keys.filterDownloaded, false).getOrDefault() val filterUn = rxPrefs.getBoolean(Keys.filterUnread, false).getOrDefault() @@ -192,7 +269,6 @@ class PreferencesHelper(val context: Context) { // <-- - // --> EH fun enableExhentai() = rxPrefs.getBoolean(Keys.eh_enableExHentai, false) @@ -210,14 +286,11 @@ class PreferencesHelper(val context: Context) { fun thumbnailRows() = rxPrefs.getString("ex_thumb_rows", "tr_2") - fun migrateLibraryAsked() = rxPrefs.getBoolean("ex_migrate_library3", false) - - fun migrationStatus() = rxPrefs.getInteger("migration_status", MigrationStatus.NOT_INITIALIZED) - fun hasPerformedURLMigration() = rxPrefs.getBoolean("performed_url_migration", false) - //EH Cookies + // EH Cookies fun memberIdVal() = rxPrefs.getString("eh_ipb_member_id", "") + fun passHashVal() = rxPrefs.getString("eh_ipb_pass_hash", "") fun igneousVal() = rxPrefs.getString("eh_igneous", "") fun eh_ehSettingsProfile() = rxPrefs.getInteger(Keys.eh_ehSettingsProfile, -1) @@ -226,7 +299,7 @@ class PreferencesHelper(val context: Context) { fun eh_sessionCookie() = rxPrefs.getString(Keys.eh_sessionCookie, "") fun eh_hathPerksCookies() = rxPrefs.getString(Keys.eh_hathPerksCookie, "") - //Lock + // Lock fun eh_lockHash() = rxPrefs.getString(Keys.eh_lock_hash, null) fun eh_lockSalt() = rxPrefs.getString(Keys.eh_lock_salt, null) @@ -239,7 +312,7 @@ class PreferencesHelper(val context: Context) { fun eh_nh_useHighQualityThumbs() = rxPrefs.getBoolean(Keys.eh_nh_useHighQualityThumbs, false) - fun eh_showSyncIntro() = rxPrefs.getBoolean(Keys.eh_showSyncIntro, true) + fun eh_showSyncIntro() = flowPrefs.getBoolean(Keys.eh_showSyncIntro, true) fun eh_readOnlySync() = rxPrefs.getBoolean(Keys.eh_readOnlySync, false) @@ -247,7 +320,7 @@ class PreferencesHelper(val context: Context) { fun eh_ts_aspNetCookie() = rxPrefs.getString(Keys.eh_ts_aspNetCookie, "") - fun eh_showSettingsUploadWarning() = rxPrefs.getBoolean(Keys.eh_showSettingsUploadWarning, true) + fun eh_showSettingsUploadWarning() = flowPrefs.getBoolean(Keys.eh_showSettingsUploadWarning, true) fun eh_expandFilters() = rxPrefs.getBoolean(Keys.eh_expandFilters, false) @@ -255,14 +328,12 @@ class PreferencesHelper(val context: Context) { fun eh_readerInstantRetry() = rxPrefs.getBoolean(Keys.eh_readerInstantRetry, true) - fun eh_utilAutoscrollInterval() = rxPrefs.getFloat(Keys.eh_utilAutoscrollInterval, 3f) + fun eh_utilAutoscrollInterval() = flowPrefs.getFloat(Keys.eh_utilAutoscrollInterval, 3f) fun eh_cacheSize() = rxPrefs.getString(Keys.eh_cacheSize, "75") fun eh_preserveReadingPosition() = rxPrefs.getBoolean(Keys.eh_preserveReadingPosition, false) - fun eh_incogWebview() = rxPrefs.getBoolean(Keys.eh_incogWebview, false) - fun eh_autoSolveCaptchas() = rxPrefs.getBoolean(Keys.eh_autoSolveCaptchas, false) fun eh_delegateSources() = rxPrefs.getBoolean(Keys.eh_delegateSources, true) @@ -271,11 +342,9 @@ class PreferencesHelper(val context: Context) { fun eh_savedSearches() = rxPrefs.getStringSet("eh_saved_searches", emptySet()) - fun eh_showTransitionPages() = rxPrefs.getBoolean(Keys.eh_showTransitionPages, true) - fun eh_logLevel() = rxPrefs.getInteger(Keys.eh_logLevel, 0) - fun eh_enableSourceBlacklist() = rxPrefs.getBoolean(Keys.eh_enableSourceBlacklist, true) + fun eh_enableSourceBlacklist() = flowPrefs.getBoolean(Keys.eh_enableSourceBlacklist, true) fun eh_autoUpdateFrequency() = rxPrefs.getInteger(Keys.eh_autoUpdateFrequency, 1) @@ -286,4 +355,8 @@ class PreferencesHelper(val context: Context) { fun eh_aggressivePageLoading() = rxPrefs.getBoolean(Keys.eh_aggressivePageLoading, false) fun eh_hl_useHighQualityThumbs() = rxPrefs.getBoolean(Keys.eh_hl_useHighQualityThumbs, false) + + fun eh_library_corner_radius() = rxPrefs.getInteger(Keys.eh_library_rounded_corners, 4) + + fun eh_preload_size() = rxPrefs.getInteger(Keys.eh_preload_size, 4) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt index 854afa03d..173a6ffd6 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt @@ -1,36 +1,35 @@ -package eu.kanade.tachiyomi.data.track - -import android.content.Context -import eu.kanade.tachiyomi.data.track.anilist.Anilist -import eu.kanade.tachiyomi.data.track.kitsu.Kitsu -import eu.kanade.tachiyomi.data.track.myanimelist.Myanimelist -import eu.kanade.tachiyomi.data.track.shikimori.Shikimori -import eu.kanade.tachiyomi.data.track.bangumi.Bangumi - -class TrackManager(private val context: Context) { - - companion object { - const val MYANIMELIST = 1 - const val ANILIST = 2 - const val KITSU = 3 - const val SHIKIMORI = 4 - const val BANGUMI = 5 - } - - val myAnimeList = Myanimelist(context, MYANIMELIST) - - val aniList = Anilist(context, ANILIST) - - val kitsu = Kitsu(context, KITSU) - - val shikimori = Shikimori(context, SHIKIMORI) - - val bangumi = Bangumi(context, BANGUMI) - - val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi) - - fun getService(id: Int) = services.find { it.id == id } - - fun hasLoggedServices() = services.any { it.isLogged } - -} +package eu.kanade.tachiyomi.data.track + +import android.content.Context +import eu.kanade.tachiyomi.data.track.anilist.Anilist +import eu.kanade.tachiyomi.data.track.bangumi.Bangumi +import eu.kanade.tachiyomi.data.track.kitsu.Kitsu +import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeList +import eu.kanade.tachiyomi.data.track.shikimori.Shikimori + +class TrackManager(context: Context) { + + companion object { + const val MYANIMELIST = 1 + const val ANILIST = 2 + const val KITSU = 3 + const val SHIKIMORI = 4 + const val BANGUMI = 5 + } + + val myAnimeList = MyAnimeList(context, MYANIMELIST) + + val aniList = Anilist(context, ANILIST) + + val kitsu = Kitsu(context, KITSU) + + val shikimori = Shikimori(context, SHIKIMORI) + + val bangumi = Bangumi(context, BANGUMI) + + val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi) + + fun getService(id: Int) = services.find { it.id == id } + + fun hasLoggedServices() = services.any { it.isLogged } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt index a3dc22d2d..8c2c54da8 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt @@ -22,6 +22,9 @@ abstract class TrackService(val id: Int) { // Name of the manga sync service to display abstract val name: String + // Application and remote support for reading dates + open val supportsReadingDates: Boolean = false + @DrawableRes abstract fun getLogo(): Int @@ -31,6 +34,8 @@ abstract class TrackService(val id: Int) { abstract fun getStatus(status: Int): String + abstract fun getCompletionStatus(): Int + abstract fun getScoreList(): List open fun indexToScore(index: Int): Float { @@ -57,8 +62,8 @@ abstract class TrackService(val id: Int) { } open val isLogged: Boolean - get() = !getUsername().isEmpty() && - !getPassword().isEmpty() + get() = getUsername().isNotEmpty() && + getPassword().isNotEmpty() fun getUsername() = preferences.trackUsername(this)!! diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt index 95c4f6461..0ad37fa03 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt @@ -1,214 +1,210 @@ -package eu.kanade.tachiyomi.data.track.anilist - -import android.content.Context -import android.graphics.Color -import com.google.gson.Gson -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.data.track.TrackService -import eu.kanade.tachiyomi.data.track.model.TrackSearch -import rx.Completable -import rx.Observable -import uy.kohesive.injekt.injectLazy - -class Anilist(private val context: Context, id: Int) : TrackService(id) { - - companion object { - const val READING = 1 - const val COMPLETED = 2 - const val ON_HOLD = 3 - const val DROPPED = 4 - const val PLANNING = 5 - const val REPEATING = 6 - - const val DEFAULT_STATUS = READING - const val DEFAULT_SCORE = 0 - - const val POINT_100 = "POINT_100" - const val POINT_10 = "POINT_10" - const val POINT_10_DECIMAL = "POINT_10_DECIMAL" - const val POINT_5 = "POINT_5" - const val POINT_3 = "POINT_3" - } - - override val name = "AniList" - - private val gson: Gson by injectLazy() - - private val interceptor by lazy { AnilistInterceptor(this, getPassword()) } - - private val api by lazy { AnilistApi(client, interceptor) } - - private val scorePreference = preferences.anilistScoreType() - - init { - // If the preference is an int from APIv1, logout user to force using APIv2 - try { - scorePreference.get() - } catch (e: ClassCastException) { - logout() - scorePreference.delete() - } - } - - override fun getLogo() = R.drawable.al - - override fun getLogoColor() = Color.rgb(18, 25, 35) - - override fun getStatusList(): List { - return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING, REPEATING) - } - - override fun getStatus(status: Int): String = with(context) { - when (status) { - READING -> getString(R.string.reading) - COMPLETED -> getString(R.string.completed) - ON_HOLD -> getString(R.string.on_hold) - DROPPED -> getString(R.string.dropped) - PLANNING -> getString(R.string.plan_to_read) - REPEATING -> getString(R.string.repeating) - else -> "" - } - } - - override fun getScoreList(): List { - return when (scorePreference.getOrDefault()) { - // 10 point - POINT_10 -> IntRange(0, 10).map(Int::toString) - // 100 point - POINT_100 -> IntRange(0, 100).map(Int::toString) - // 5 stars - POINT_5 -> IntRange(0, 5).map { "$it ★" } - // Smiley - POINT_3 -> listOf("-", "😦", "😐", "😊") - // 10 point decimal - POINT_10_DECIMAL -> IntRange(0, 100).map { (it / 10f).toString() } - else -> throw Exception("Unknown score type") - } - } - - override fun indexToScore(index: Int): Float { - return when (scorePreference.getOrDefault()) { - // 10 point - POINT_10 -> index * 10f - // 100 point - POINT_100 -> index.toFloat() - // 5 stars - POINT_5 -> when { - index == 0 -> 0f - else -> index * 20f - 10f - } - // Smiley - POINT_3 -> when { - index == 0 -> 0f - else -> index * 25f + 10f - } - // 10 point decimal - POINT_10_DECIMAL -> index.toFloat() - else -> throw Exception("Unknown score type") - } - } - - override fun displayScore(track: Track): String { - val score = track.score - - return when (scorePreference.getOrDefault()) { - POINT_5 -> when { - score == 0f -> "0 ★" - else -> "${((score + 10) / 20).toInt()} ★" - } - POINT_3 -> when { - score == 0f -> "0" - score <= 35 -> "😦" - score <= 60 -> "😐" - else -> "😊" - } - else -> track.toAnilistScore() - } - } - - override fun add(track: Track): Observable { - return api.addLibManga(track) - } - - override fun update(track: Track): Observable { - if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { - track.status = COMPLETED - } - // If user was using API v1 fetch library_id - if (track.library_id == null || track.library_id!! == 0L){ - return api.findLibManga(track, getUsername().toInt()).flatMap { - if (it == null) { - throw Exception("$track not found on user library") - } - track.library_id = it.library_id - api.updateLibManga(track) - } - } - - return api.updateLibManga(track) - } - - override fun bind(track: Track): Observable { - return api.findLibManga(track, getUsername().toInt()) - .flatMap { remoteTrack -> - if (remoteTrack != null) { - track.copyPersonalFrom(remoteTrack) - track.library_id = remoteTrack.library_id - update(track) - } else { - // Set default fields if it's not found in the list - track.score = DEFAULT_SCORE.toFloat() - track.status = DEFAULT_STATUS - add(track) - } - } - } - - override fun search(query: String): Observable> { - return api.search(query) - } - - override fun refresh(track: Track): Observable { - return api.getLibManga(track, getUsername().toInt()) - .map { remoteTrack -> - track.copyPersonalFrom(remoteTrack) - track.total_chapters = remoteTrack.total_chapters - track - } - } - - override fun login(username: String, password: String) = login(password) - - fun login(token: String): Completable { - val oauth = api.createOAuth(token) - interceptor.setAuth(oauth) - return api.getCurrentUser().map { (username, scoreType) -> - scorePreference.set(scoreType) - saveCredentials(username.toString(), oauth.access_token) - }.doOnError{ - logout() - }.toCompletable() - } - - override fun logout() { - super.logout() - preferences.trackToken(this).set(null) - interceptor.setAuth(null) - } - - fun saveOAuth(oAuth: OAuth?) { - preferences.trackToken(this).set(gson.toJson(oAuth)) - } - - fun loadOAuth(): OAuth? { - return try { - gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) - } catch (e: Exception) { - null - } - } - -} - +package eu.kanade.tachiyomi.data.track.anilist + +import android.content.Context +import android.graphics.Color +import com.google.gson.Gson +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.TrackService +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import rx.Completable +import rx.Observable +import uy.kohesive.injekt.injectLazy + +class Anilist(private val context: Context, id: Int) : TrackService(id) { + + companion object { + const val READING = 1 + const val COMPLETED = 2 + const val PAUSED = 3 + const val DROPPED = 4 + const val PLANNING = 5 + const val REPEATING = 6 + + const val DEFAULT_STATUS = READING + const val DEFAULT_SCORE = 0 + + const val POINT_100 = "POINT_100" + const val POINT_10 = "POINT_10" + const val POINT_10_DECIMAL = "POINT_10_DECIMAL" + const val POINT_5 = "POINT_5" + const val POINT_3 = "POINT_3" + } + + override val name = "AniList" + + private val gson: Gson by injectLazy() + + private val interceptor by lazy { AnilistInterceptor(this, getPassword()) } + + private val api by lazy { AnilistApi(client, interceptor) } + + private val scorePreference = preferences.anilistScoreType() + + init { + // If the preference is an int from APIv1, logout user to force using APIv2 + try { + scorePreference.get() + } catch (e: ClassCastException) { + logout() + scorePreference.delete() + } + } + + override fun getLogo() = R.drawable.ic_tracker_anilist + + override fun getLogoColor() = Color.rgb(18, 25, 35) + + override fun getStatusList(): List { + return listOf(READING, PLANNING, COMPLETED, REPEATING, PAUSED, DROPPED) + } + + override fun getStatus(status: Int): String = with(context) { + when (status) { + READING -> getString(R.string.reading) + PLANNING -> getString(R.string.plan_to_read) + COMPLETED -> getString(R.string.completed) + REPEATING -> getString(R.string.repeating) + PAUSED -> getString(R.string.paused) + DROPPED -> getString(R.string.dropped) + else -> "" + } + } + + override fun getCompletionStatus(): Int = COMPLETED + + override fun getScoreList(): List { + return when (scorePreference.get()) { + // 10 point + POINT_10 -> IntRange(0, 10).map(Int::toString) + // 100 point + POINT_100 -> IntRange(0, 100).map(Int::toString) + // 5 stars + POINT_5 -> IntRange(0, 5).map { "$it ★" } + // Smiley + POINT_3 -> listOf("-", "😦", "😐", "😊") + // 10 point decimal + POINT_10_DECIMAL -> IntRange(0, 100).map { (it / 10f).toString() } + else -> throw Exception("Unknown score type") + } + } + + override fun indexToScore(index: Int): Float { + return when (scorePreference.get()) { + // 10 point + POINT_10 -> index * 10f + // 100 point + POINT_100 -> index.toFloat() + // 5 stars + POINT_5 -> when (index) { + 0 -> 0f + else -> index * 20f - 10f + } + // Smiley + POINT_3 -> when (index) { + 0 -> 0f + else -> index * 25f + 10f + } + // 10 point decimal + POINT_10_DECIMAL -> index.toFloat() + else -> throw Exception("Unknown score type") + } + } + + override fun displayScore(track: Track): String { + val score = track.score + + return when (scorePreference.get()) { + POINT_5 -> when (score) { + 0f -> "0 ★" + else -> "${((score + 10) / 20).toInt()} ★" + } + POINT_3 -> when { + score == 0f -> "0" + score <= 35 -> "😦" + score <= 60 -> "😐" + else -> "😊" + } + else -> track.toAnilistScore() + } + } + + override fun add(track: Track): Observable { + return api.addLibManga(track) + } + + override fun update(track: Track): Observable { + // If user was using API v1 fetch library_id + if (track.library_id == null || track.library_id!! == 0L) { + return api.findLibManga(track, getUsername().toInt()).flatMap { + if (it == null) { + throw Exception("$track not found on user library") + } + track.library_id = it.library_id + api.updateLibManga(track) + } + } + + return api.updateLibManga(track) + } + + override fun bind(track: Track): Observable { + return api.findLibManga(track, getUsername().toInt()) + .flatMap { remoteTrack -> + if (remoteTrack != null) { + track.copyPersonalFrom(remoteTrack) + track.library_id = remoteTrack.library_id + update(track) + } else { + // Set default fields if it's not found in the list + track.score = DEFAULT_SCORE.toFloat() + track.status = DEFAULT_STATUS + add(track) + } + } + } + + override fun search(query: String): Observable> { + return api.search(query) + } + + override fun refresh(track: Track): Observable { + return api.getLibManga(track, getUsername().toInt()) + .map { remoteTrack -> + track.copyPersonalFrom(remoteTrack) + track.total_chapters = remoteTrack.total_chapters + track + } + } + + override fun login(username: String, password: String) = login(password) + + fun login(token: String): Completable { + val oauth = api.createOAuth(token) + interceptor.setAuth(oauth) + return api.getCurrentUser().map { (username, scoreType) -> + scorePreference.set(scoreType) + saveCredentials(username.toString(), oauth.access_token) + }.doOnError { + logout() + }.toCompletable() + } + + override fun logout() { + super.logout() + preferences.trackToken(this).delete() + interceptor.setAuth(null) + } + + fun saveOAuth(oAuth: OAuth?) { + preferences.trackToken(this).set(gson.toJson(oAuth)) + } + + fun loadOAuth(): OAuth? { + return try { + gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) + } catch (e: Exception) { + null + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt index ae21e2d4c..201d84be6 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt @@ -1,28 +1,32 @@ package eu.kanade.tachiyomi.data.track.anilist import android.net.Uri -import com.github.salomonbrys.kotson.* +import com.github.salomonbrys.kotson.array +import com.github.salomonbrys.kotson.get +import com.github.salomonbrys.kotson.jsonObject +import com.github.salomonbrys.kotson.nullInt +import com.github.salomonbrys.kotson.nullString +import com.github.salomonbrys.kotson.obj import com.google.gson.JsonObject import com.google.gson.JsonParser import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.network.asObservableSuccess +import java.util.Calendar import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody import rx.Observable -import java.util.* - class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { - private val parser = JsonParser() private val jsonMime = "application/json; charset=utf-8".toMediaTypeOrNull() private val authClient = client.newBuilder().addInterceptor(interceptor).build() fun addLibManga(track: Track): Observable { - val query = """ + val query = + """ |mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) { |SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status) { | id @@ -31,35 +35,36 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { |} |""".trimMargin() val variables = jsonObject( - "mangaId" to track.media_id, - "progress" to track.last_chapter_read, - "status" to track.toAnilistStatus() + "mangaId" to track.media_id, + "progress" to track.last_chapter_read, + "status" to track.toAnilistStatus() ) val payload = jsonObject( - "query" to query, - "variables" to variables + "query" to query, + "variables" to variables ) - val body = RequestBody.create(jsonMime, payload.toString()) + val body = payload.toString().toRequestBody(jsonMime) val request = Request.Builder() - .url(apiUrl) - .post(body) - .build() + .url(apiUrl) + .post(body) + .build() return authClient.newCall(request) - .asObservableSuccess() - .map { netResponse -> - val responseBody = netResponse.body?.string().orEmpty() - netResponse.close() - if (responseBody.isEmpty()) { - throw Exception("Null Response") - } - val response = parser.parse(responseBody).obj - track.library_id = response["data"]["SaveMediaListEntry"]["id"].asLong - track + .asObservableSuccess() + .map { netResponse -> + val responseBody = netResponse.body?.string().orEmpty() + netResponse.close() + if (responseBody.isEmpty()) { + throw Exception("Null Response") } + val response = JsonParser.parseString(responseBody).obj + track.library_id = response["data"]["SaveMediaListEntry"]["id"].asLong + track + } } fun updateLibManga(track: Track): Observable { - val query = """ + val query = + """ |mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) { |SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) { |id @@ -69,29 +74,30 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { |} |""".trimMargin() val variables = jsonObject( - "listId" to track.library_id, - "progress" to track.last_chapter_read, - "status" to track.toAnilistStatus(), - "score" to track.score.toInt() + "listId" to track.library_id, + "progress" to track.last_chapter_read, + "status" to track.toAnilistStatus(), + "score" to track.score.toInt() ) val payload = jsonObject( - "query" to query, - "variables" to variables + "query" to query, + "variables" to variables ) - val body = RequestBody.create(jsonMime, payload.toString()) + val body = payload.toString().toRequestBody(jsonMime) val request = Request.Builder() - .url(apiUrl) - .post(body) - .build() + .url(apiUrl) + .post(body) + .build() return authClient.newCall(request) - .asObservableSuccess() - .map { - track - } + .asObservableSuccess() + .map { + track + } } fun search(search: String): Observable> { - val query = """ + val query = + """ |query Search(${'$'}query: String) { |Page (perPage: 50) { |media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) { @@ -116,36 +122,36 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { |} |""".trimMargin() val variables = jsonObject( - "query" to search + "query" to search ) val payload = jsonObject( - "query" to query, - "variables" to variables + "query" to query, + "variables" to variables ) - val body = RequestBody.create(jsonMime, payload.toString()) + val body = payload.toString().toRequestBody(jsonMime) val request = Request.Builder() - .url(apiUrl) - .post(body) - .build() + .url(apiUrl) + .post(body) + .build() return authClient.newCall(request) - .asObservableSuccess() - .map { netResponse -> - val responseBody = netResponse.body?.string().orEmpty() - if (responseBody.isEmpty()) { - throw Exception("Null Response") - } - val response = parser.parse(responseBody).obj - val data = response["data"]!!.obj - val page = data["Page"].obj - val media = page["media"].array - val entries = media.map { jsonToALManga(it.obj) } - entries.map { it.toTrack() } + .asObservableSuccess() + .map { netResponse -> + val responseBody = netResponse.body?.string().orEmpty() + if (responseBody.isEmpty()) { + throw Exception("Null Response") } + val response = JsonParser.parseString(responseBody).obj + val data = response["data"]!!.obj + val page = data["Page"].obj + val media = page["media"].array + val entries = media.map { jsonToALManga(it.obj) } + entries.map { it.toTrack() } + } } - fun findLibManga(track: Track, userid: Int): Observable { - val query = """ + val query = + """ |query (${'$'}id: Int!, ${'$'}manga_id: Int!) { |Page { |mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) { @@ -176,38 +182,37 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { |} |""".trimMargin() val variables = jsonObject( - "id" to userid, - "manga_id" to track.media_id + "id" to userid, + "manga_id" to track.media_id ) val payload = jsonObject( - "query" to query, - "variables" to variables + "query" to query, + "variables" to variables ) - val body = RequestBody.create(jsonMime, payload.toString()) + val body = payload.toString().toRequestBody(jsonMime) val request = Request.Builder() - .url(apiUrl) - .post(body) - .build() + .url(apiUrl) + .post(body) + .build() return authClient.newCall(request) - .asObservableSuccess() - .map { netResponse -> - val responseBody = netResponse.body?.string().orEmpty() - if (responseBody.isEmpty()) { - throw Exception("Null Response") - } - val response = parser.parse(responseBody).obj - val data = response["data"]!!.obj - val page = data["Page"].obj - val media = page["mediaList"].array - val entries = media.map { jsonToALUserManga(it.obj) } - entries.firstOrNull()?.toTrack() - + .asObservableSuccess() + .map { netResponse -> + val responseBody = netResponse.body?.string().orEmpty() + if (responseBody.isEmpty()) { + throw Exception("Null Response") } + val response = JsonParser.parseString(responseBody).obj + val data = response["data"]!!.obj + val page = data["Page"].obj + val media = page["mediaList"].array + val entries = media.map { jsonToALUserManga(it.obj) } + entries.firstOrNull()?.toTrack() + } } fun getLibManga(track: Track, userid: Int): Observable { return findLibManga(track, userid) - .map { it ?: throw Exception("Could not find manga") } + .map { it ?: throw Exception("Could not find manga") } } fun createOAuth(token: String): OAuth { @@ -215,7 +220,8 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { } fun getCurrentUser(): Observable> { - val query = """ + val query = + """ |query User { |Viewer { |id @@ -226,40 +232,48 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { |} |""".trimMargin() val payload = jsonObject( - "query" to query + "query" to query ) - val body = RequestBody.create(jsonMime, payload.toString()) + val body = payload.toString().toRequestBody(jsonMime) val request = Request.Builder() - .url(apiUrl) - .post(body) - .build() + .url(apiUrl) + .post(body) + .build() return authClient.newCall(request) - .asObservableSuccess() - .map { netResponse -> - val responseBody = netResponse.body?.string().orEmpty() - if (responseBody.isEmpty()) { - throw Exception("Null Response") - } - val response = parser.parse(responseBody).obj - val data = response["data"]!!.obj - val viewer = data["Viewer"].obj - Pair(viewer["id"].asInt, viewer["mediaListOptions"]["scoreFormat"].asString) + .asObservableSuccess() + .map { netResponse -> + val responseBody = netResponse.body?.string().orEmpty() + if (responseBody.isEmpty()) { + throw Exception("Null Response") } + val response = JsonParser.parseString(responseBody).obj + val data = response["data"]!!.obj + val viewer = data["Viewer"].obj + Pair(viewer["id"].asInt, viewer["mediaListOptions"]["scoreFormat"].asString) + } } private fun jsonToALManga(struct: JsonObject): ALManga { val date = try { val date = Calendar.getInstance() - date.set(struct["startDate"]["year"].nullInt ?: 0, (struct["startDate"]["month"].nullInt ?: 0) - 1, - struct["startDate"]["day"].nullInt ?: 0) + date.set( + struct["startDate"]["year"].nullInt ?: 0, + ( + struct["startDate"]["month"].nullInt + ?: 0 + ) - 1, + struct["startDate"]["day"].nullInt ?: 0 + ) date.timeInMillis } catch (_: Exception) { 0L } - return ALManga(struct["id"].asInt, struct["title"]["romaji"].asString, struct["coverImage"]["large"].asString, - struct["description"].nullString.orEmpty(), struct["type"].asString, struct["status"].asString, - date, struct["chapters"].nullInt ?: 0) + return ALManga( + struct["id"].asInt, struct["title"]["romaji"].asString, struct["coverImage"]["large"].asString, + struct["description"].nullString.orEmpty(), struct["type"].asString, struct["status"].asString, + date, struct["chapters"].nullInt ?: 0 + ) } private fun jsonToALUserManga(struct: JsonObject): ALUserManga { @@ -278,9 +292,8 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { } fun authUrl() = Uri.parse("${baseUrl}oauth/authorize").buildUpon() - .appendQueryParameter("client_id", clientId) - .appendQueryParameter("response_type", "token") - .build() + .appendQueryParameter("client_id", clientId) + .appendQueryParameter("response_type", "token") + .build() } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistInterceptor.kt index 427b0acfe..07ac60111 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistInterceptor.kt @@ -1,58 +1,56 @@ -package eu.kanade.tachiyomi.data.track.anilist - -import okhttp3.Interceptor -import okhttp3.Response - - -class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Interceptor { - - /** - * OAuth object used for authenticated requests. - * - * Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute - * before its original expiration date. - */ - private var oauth: OAuth? = null - set(value) { - field = value?.copy(expires = value.expires * 1000 - 60 * 1000) - } - - override fun intercept(chain: Interceptor.Chain): Response { - val originalRequest = chain.request() - - if (token.isNullOrEmpty()) { - throw Exception("Not authenticated with Anilist") - } - if (oauth == null){ - oauth = anilist.loadOAuth() - } - // Refresh access token if null or expired. - if (oauth!!.isExpired()) { - anilist.logout() - throw Exception("Token expired") - } - - // Throw on null auth. - if (oauth == null) { - throw Exception("No authentication token") - } - - // Add the authorization header to the original request. - val authRequest = originalRequest.newBuilder() - .addHeader("Authorization", "Bearer ${oauth!!.access_token}") - .build() - - return chain.proceed(authRequest) - } - - /** - * Called when the user authenticates with Anilist for the first time. Sets the refresh token - * and the oauth object. - */ - fun setAuth(oauth: OAuth?) { - token = oauth?.access_token - this.oauth = oauth - anilist.saveOAuth(oauth) - } - -} \ No newline at end of file +package eu.kanade.tachiyomi.data.track.anilist + +import okhttp3.Interceptor +import okhttp3.Response + +class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Interceptor { + + /** + * OAuth object used for authenticated requests. + * + * Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute + * before its original expiration date. + */ + private var oauth: OAuth? = null + set(value) { + field = value?.copy(expires = value.expires * 1000 - 60 * 1000) + } + + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + if (token.isNullOrEmpty()) { + throw Exception("Not authenticated with Anilist") + } + if (oauth == null) { + oauth = anilist.loadOAuth() + } + // Refresh access token if null or expired. + if (oauth!!.isExpired()) { + anilist.logout() + throw Exception("Token expired") + } + + // Throw on null auth. + if (oauth == null) { + throw Exception("No authentication token") + } + + // Add the authorization header to the original request. + val authRequest = originalRequest.newBuilder() + .addHeader("Authorization", "Bearer ${oauth!!.access_token}") + .build() + + return chain.proceed(authRequest) + } + + /** + * Called when the user authenticates with Anilist for the first time. Sets the refresh token + * and the oauth object. + */ + fun setAuth(oauth: OAuth?) { + token = oauth?.access_token + this.oauth = oauth + anilist.saveOAuth(oauth) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt index 8f96723d0..8c8cfd576 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt @@ -2,22 +2,22 @@ package eu.kanade.tachiyomi.data.track.anilist import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.model.TrackSearch -import uy.kohesive.injekt.injectLazy import java.text.SimpleDateFormat -import java.util.* +import java.util.Locale +import uy.kohesive.injekt.injectLazy data class ALManga( - val media_id: Int, - val title_romaji: String, - val image_url_lge: String, - val description: String?, - val type: String, - val publishing_status: String, - val start_date_fuzzy: Long, - val total_chapters: Int) { + val media_id: Int, + val title_romaji: String, + val image_url_lge: String, + val description: String?, + val type: String, + val publishing_status: String, + val start_date_fuzzy: Long, + val total_chapters: Int +) { fun toTrack() = TrackSearch.create(TrackManager.ANILIST).apply { media_id = this@ALManga.media_id @@ -40,11 +40,12 @@ data class ALManga( } data class ALUserManga( - val library_id: Long, - val list_status: String, - val score_raw: Int, - val chapters_read: Int, - val manga: ALManga) { + val library_id: Long, + val list_status: String, + val score_raw: Int, + val chapters_read: Int, + val manga: ALManga +) { fun toTrack() = Track.create(TrackManager.ANILIST).apply { media_id = manga.media_id @@ -58,7 +59,7 @@ data class ALUserManga( fun toTrackStatus() = when (list_status) { "CURRENT" -> Anilist.READING "COMPLETED" -> Anilist.COMPLETED - "PAUSED" -> Anilist.ON_HOLD + "PAUSED" -> Anilist.PAUSED "DROPPED" -> Anilist.DROPPED "PLANNING" -> Anilist.PLANNING "REPEATING" -> Anilist.REPEATING @@ -69,7 +70,7 @@ data class ALUserManga( fun Track.toAnilistStatus() = when (status) { Anilist.READING -> "CURRENT" Anilist.COMPLETED -> "COMPLETED" - Anilist.ON_HOLD -> "PAUSED" + Anilist.PAUSED -> "PAUSED" Anilist.DROPPED -> "DROPPED" Anilist.PLANNING -> "PLANNING" Anilist.REPEATING -> "REPEATING" @@ -78,7 +79,7 @@ fun Track.toAnilistStatus() = when (status) { private val preferences: PreferencesHelper by injectLazy() -fun Track.toAnilistScore(): String = when (preferences.anilistScoreType().getOrDefault()) { +fun Track.toAnilistScore(): String = when (preferences.anilistScoreType().get()) { // 10 point "POINT_10" -> (score.toInt() / 10).toString() // 100 point diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/OAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/OAuth.kt index 1d7a31ac5..5df10fd51 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/OAuth.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/OAuth.kt @@ -1,10 +1,11 @@ -package eu.kanade.tachiyomi.data.track.anilist - -data class OAuth( - val access_token: String, - val token_type: String, - val expires: Long, - val expires_in: Long) { - - fun isExpired() = System.currentTimeMillis() > expires -} \ No newline at end of file +package eu.kanade.tachiyomi.data.track.anilist + +data class OAuth( + val access_token: String, + val token_type: String, + val expires: Long, + val expires_in: Long +) { + + fun isExpired() = System.currentTimeMillis() > expires +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Avatar.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Avatar.kt index 02e0b2efb..b54f4ec02 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Avatar.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Avatar.kt @@ -1,7 +1,7 @@ package eu.kanade.tachiyomi.data.track.bangumi data class Avatar( - val large: String? = "", - val medium: String? = "", - val small: String? = "" -) \ No newline at end of file + val large: String? = "", + val medium: String? = "", + val small: String? = "" +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt index 1eb6fff59..99f1db581 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt @@ -1,144 +1,143 @@ -package eu.kanade.tachiyomi.data.track.bangumi - -import android.content.Context -import android.graphics.Color -import com.google.gson.Gson -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.track.TrackService -import eu.kanade.tachiyomi.data.track.model.TrackSearch -import rx.Completable -import rx.Observable -import uy.kohesive.injekt.injectLazy - -class Bangumi(private val context: Context, id: Int) : TrackService(id) { - - override fun getScoreList(): List { - return IntRange(0, 10).map(Int::toString) - } - - override fun displayScore(track: Track): String { - return track.score.toInt().toString() - } - - override fun add(track: Track): Observable { - return api.addLibManga(track) - } - - override fun update(track: Track): Observable { - if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { - track.status = COMPLETED - } - return api.updateLibManga(track) - } - - override fun bind(track: Track): Observable { - return api.statusLibManga(track) - .flatMap { - api.findLibManga(track).flatMap { remoteTrack -> - if (remoteTrack != null && it != null) { - track.copyPersonalFrom(remoteTrack) - track.library_id = remoteTrack.library_id - track.status = remoteTrack.status - track.last_chapter_read = remoteTrack.last_chapter_read - update(track) - } else { - // Set default fields if it's not found in the list - track.score = DEFAULT_SCORE.toFloat() - track.status = DEFAULT_STATUS - add(track) - update(track) - } - } - } - } - - override fun search(query: String): Observable> { - return api.search(query) - } - - override fun refresh(track: Track): Observable { - return api.statusLibManga(track) - .flatMap { - track.copyPersonalFrom(it!!) - api.findLibManga(track) - .map { remoteTrack -> - if (remoteTrack != null) { - track.total_chapters = remoteTrack.total_chapters - track.status = remoteTrack.status - } - track - } - } - } - - companion object { - const val READING = 3 - const val COMPLETED = 2 - const val ON_HOLD = 4 - const val DROPPED = 5 - const val PLANNING = 1 - - const val DEFAULT_STATUS = READING - const val DEFAULT_SCORE = 0 - } - - override val name = "Bangumi" - - private val gson: Gson by injectLazy() - - private val interceptor by lazy { BangumiInterceptor(this, gson) } - - private val api by lazy { BangumiApi(client, interceptor) } - - override fun getLogo() = R.drawable.bangumi - - override fun getLogoColor() = Color.rgb(0xF0, 0x91, 0x99) - - override fun getStatusList(): List { - return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING) - } - - override fun getStatus(status: Int): String = with(context) { - when (status) { - READING -> getString(R.string.reading) - COMPLETED -> getString(R.string.completed) - ON_HOLD -> getString(R.string.on_hold) - DROPPED -> getString(R.string.dropped) - PLANNING -> getString(R.string.plan_to_read) - else -> "" - } - } - - override fun login(username: String, password: String) = login(password) - - fun login(code: String): Completable { - return api.accessToken(code).map { oauth: OAuth? -> - interceptor.newAuth(oauth) - if (oauth != null) { - saveCredentials(oauth.user_id.toString(), oauth.access_token) - } - }.doOnError { - logout() - }.toCompletable() - } - - fun saveToken(oauth: OAuth?) { - val json = gson.toJson(oauth) - preferences.trackToken(this).set(json) - } - - fun restoreToken(): OAuth? { - return try { - gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) - } catch (e: Exception) { - null - } - } - - override fun logout() { - super.logout() - preferences.trackToken(this).set(null) - interceptor.newAuth(null) - } -} +package eu.kanade.tachiyomi.data.track.bangumi + +import android.content.Context +import android.graphics.Color +import com.google.gson.Gson +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.TrackService +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import rx.Completable +import rx.Observable +import uy.kohesive.injekt.injectLazy + +class Bangumi(private val context: Context, id: Int) : TrackService(id) { + + override val name = "Bangumi" + + private val gson: Gson by injectLazy() + + private val interceptor by lazy { BangumiInterceptor(this, gson) } + + private val api by lazy { BangumiApi(client, interceptor) } + + override fun getScoreList(): List { + return IntRange(0, 10).map(Int::toString) + } + + override fun displayScore(track: Track): String { + return track.score.toInt().toString() + } + + override fun add(track: Track): Observable { + return api.addLibManga(track) + } + + override fun update(track: Track): Observable { + return api.updateLibManga(track) + } + + override fun bind(track: Track): Observable { + return api.statusLibManga(track) + .flatMap { + api.findLibManga(track).flatMap { remoteTrack -> + if (remoteTrack != null && it != null) { + track.copyPersonalFrom(remoteTrack) + track.library_id = remoteTrack.library_id + track.status = remoteTrack.status + track.last_chapter_read = remoteTrack.last_chapter_read + refresh(track) + } else { + // Set default fields if it's not found in the list + track.score = DEFAULT_SCORE.toFloat() + track.status = DEFAULT_STATUS + add(track) + update(track) + } + } + } + } + + override fun search(query: String): Observable> { + return api.search(query) + } + + override fun refresh(track: Track): Observable { + return api.statusLibManga(track) + .flatMap { + track.copyPersonalFrom(it!!) + api.findLibManga(track) + .map { remoteTrack -> + if (remoteTrack != null) { + track.total_chapters = remoteTrack.total_chapters + track.status = remoteTrack.status + } + track + } + } + } + + override fun getLogo() = R.drawable.ic_tracker_bangumi + + override fun getLogoColor() = Color.rgb(240, 145, 153) + + override fun getStatusList(): List { + return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING) + } + + override fun getStatus(status: Int): String = with(context) { + when (status) { + READING -> getString(R.string.reading) + COMPLETED -> getString(R.string.completed) + ON_HOLD -> getString(R.string.on_hold) + DROPPED -> getString(R.string.dropped) + PLANNING -> getString(R.string.plan_to_read) + else -> "" + } + } + + override fun getCompletionStatus(): Int = COMPLETED + + override fun login(username: String, password: String) = login(password) + + fun login(code: String): Completable { + return api.accessToken(code).map { oauth: OAuth? -> + interceptor.newAuth(oauth) + if (oauth != null) { + saveCredentials(oauth.user_id.toString(), oauth.access_token) + } + }.doOnError { + logout() + }.toCompletable() + } + + fun saveToken(oauth: OAuth?) { + val json = gson.toJson(oauth) + preferences.trackToken(this).set(json) + } + + fun restoreToken(): OAuth? { + return try { + gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) + } catch (e: Exception) { + null + } + } + + override fun logout() { + super.logout() + preferences.trackToken(this).delete() + interceptor.newAuth(null) + } + + companion object { + const val READING = 3 + const val COMPLETED = 2 + const val ON_HOLD = 4 + const val DROPPED = 5 + const val PLANNING = 1 + + const val DEFAULT_STATUS = READING + const val DEFAULT_SCORE = 0 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt index 8e302e110..b8c1be008 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt @@ -11,198 +11,207 @@ import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.asObservableSuccess +import java.net.URLEncoder import okhttp3.CacheControl import okhttp3.FormBody import okhttp3.OkHttpClient import okhttp3.Request import rx.Observable import uy.kohesive.injekt.injectLazy -import java.net.URLEncoder class BangumiApi(private val client: OkHttpClient, interceptor: BangumiInterceptor) { - private val gson: Gson by injectLazy() - private val parser = JsonParser() - private val authClient = client.newBuilder().addInterceptor(interceptor).build() + private val gson: Gson by injectLazy() + private val authClient = client.newBuilder().addInterceptor(interceptor).build() - fun addLibManga(track: Track): Observable { - val body = FormBody.Builder() - .add("rating", track.score.toInt().toString()) - .add("status", track.toBangumiStatus()) - .build() - val request = Request.Builder() - .url("$apiUrl/collection/${track.media_id}/update") - .post(body) - .build() - return authClient.newCall(request) - .asObservableSuccess() - .map { - track - } - } + fun addLibManga(track: Track): Observable { + val body = FormBody.Builder() + .add("rating", track.score.toInt().toString()) + .add("status", track.toBangumiStatus()) + .build() + val request = Request.Builder() + .url("$apiUrl/collection/${track.media_id}/update") + .post(body) + .build() + return authClient.newCall(request) + .asObservableSuccess() + .map { + track + } + } - fun updateLibManga(track: Track): Observable { - // chapter update - val body = FormBody.Builder() - .add("watched_eps", track.last_chapter_read.toString()) - .build() - val request = Request.Builder() - .url("$apiUrl/subject/${track.media_id}/update/watched_eps") - .post(body) - .build() + fun updateLibManga(track: Track): Observable { + // chapter update + val body = FormBody.Builder() + .add("watched_eps", track.last_chapter_read.toString()) + .build() + val request = Request.Builder() + .url("$apiUrl/subject/${track.media_id}/update/watched_eps") + .post(body) + .build() - // read status update - val sbody = FormBody.Builder() - .add("status", track.toBangumiStatus()) - .build() - val srequest = Request.Builder() - .url("$apiUrl/collection/${track.media_id}/update") - .post(sbody) - .build() - return authClient.newCall(request) - .asObservableSuccess() - .map { - track - }.flatMap { - authClient.newCall(srequest) - .asObservableSuccess() - .map { - track - } - } - } + // read status update + val sbody = FormBody.Builder() + .add("status", track.toBangumiStatus()) + .build() + val srequest = Request.Builder() + .url("$apiUrl/collection/${track.media_id}/update") + .post(sbody) + .build() + return authClient.newCall(srequest) + .asObservableSuccess() + .map { + track + }.flatMap { + authClient.newCall(request) + .asObservableSuccess() + .map { + track + } + } + } - fun search(search: String): Observable> { - val url = Uri.parse( - "$apiUrl/search/subject/${URLEncoder.encode(search, Charsets.UTF_8.name())}").buildUpon() - .appendQueryParameter("max_results", "20") - .build() - val request = Request.Builder() - .url(url.toString()) - .get() - .build() - return authClient.newCall(request) - .asObservableSuccess() - .map { netResponse -> - val responseBody = netResponse.body?.string().orEmpty() - if (responseBody.isEmpty()) { - throw Exception("Null Response") + fun search(search: String): Observable> { + val url = Uri.parse( + "$apiUrl/search/subject/${URLEncoder.encode(search, Charsets.UTF_8.name())}" + ).buildUpon() + .appendQueryParameter("max_results", "20") + .build() + val request = Request.Builder() + .url(url.toString()) + .get() + .build() + return authClient.newCall(request) + .asObservableSuccess() + .map { netResponse -> + var responseBody = netResponse.body?.string().orEmpty() + if (responseBody.isEmpty()) { + throw Exception("Null Response") + } + if (responseBody.contains("\"code\":404")) { + responseBody = "{\"results\":0,\"list\":[]}" + } + val response = JsonParser.parseString(responseBody).obj["list"]?.array + response?.filter { it.obj["type"].asInt == 1 }?.map { jsonToSearch(it.obj) } + } + } + + private fun jsonToSearch(obj: JsonObject): TrackSearch { + return TrackSearch.create(TrackManager.BANGUMI).apply { + media_id = obj["id"].asInt + title = obj["name_cn"].asString + cover_url = obj["images"].obj["common"].asString + summary = obj["name"].asString + tracking_url = obj["url"].asString } - val response = parser.parse(responseBody).obj["list"]?.array - response?.filter { it.obj["type"].asInt == 1 }?.map { jsonToSearch(it.obj) } - } - - } - - private fun jsonToSearch(obj: JsonObject): TrackSearch { - return TrackSearch.create(TrackManager.BANGUMI).apply { - media_id = obj["id"].asInt - title = obj["name_cn"].asString - cover_url = obj["images"].obj["common"].asString - summary = obj["name"].asString - tracking_url = obj["url"].asString - } - } - - private fun jsonToTrack(mangas: JsonObject): Track { - return Track.create(TrackManager.BANGUMI).apply { - title = mangas["name"].asString - media_id = mangas["id"].asInt - score = if (mangas["rating"] != null) - (if (mangas["rating"].isJsonObject) mangas["rating"].obj["score"].asFloat else 0f) - else 0f - status = Bangumi.DEFAULT_STATUS - tracking_url = mangas["url"].asString - } - } - - fun findLibManga(track: Track): Observable { - val urlMangas = "$apiUrl/subject/${track.media_id}" - val requestMangas = Request.Builder() - .url(urlMangas) - .get() - .build() - - return authClient.newCall(requestMangas) - .asObservableSuccess() - .map { netResponse -> - // get comic info - val responseBody = netResponse.body?.string().orEmpty() - jsonToTrack(parser.parse(responseBody).obj) - } - } - - fun statusLibManga(track: Track): Observable { - val urlUserRead = "$apiUrl/collection/${track.media_id}" - val requestUserRead = Request.Builder() - .url(urlUserRead) - .cacheControl(CacheControl.FORCE_NETWORK) - .get() - .build() - - // todo get user readed chapter here - return authClient.newCall(requestUserRead) - .asObservableSuccess() - .map { netResponse -> - val resp = netResponse.body?.string() - val coll = gson.fromJson(resp, Collection::class.java) - track.status = coll.status?.id!! - track.last_chapter_read = coll.ep_status!! - track - } - } - - fun accessToken(code: String): Observable { - return client.newCall(accessTokenRequest(code)).asObservableSuccess().map { netResponse -> - val responseBody = netResponse.body?.string().orEmpty() - if (responseBody.isEmpty()) { - throw Exception("Null Response") - } - gson.fromJson(responseBody, OAuth::class.java) - } - } - - private fun accessTokenRequest(code: String) = POST(oauthUrl, - body = FormBody.Builder() - .add("grant_type", "authorization_code") - .add("client_id", clientId) - .add("client_secret", clientSecret) - .add("code", code) - .add("redirect_uri", redirectUrl) - .build() - ) - - companion object { - private const val clientId = "bgm10555cda0762e80ca" - private const val clientSecret = "8fff394a8627b4c388cbf349ec865775" - - private const val baseUrl = "https://bangumi.org" - private const val apiUrl = "https://api.bgm.tv" - private const val oauthUrl = "https://bgm.tv/oauth/access_token" - private const val loginUrl = "https://bgm.tv/oauth/authorize" - - private const val redirectUrl = "tachiyomi://bangumi-auth" - private const val baseMangaUrl = "$apiUrl/mangas" - - fun mangaUrl(remoteId: Int): String { - return "$baseMangaUrl/$remoteId" } - fun authUrl() = - Uri.parse(loginUrl).buildUpon() - .appendQueryParameter("client_id", clientId) - .appendQueryParameter("response_type", "code") - .appendQueryParameter("redirect_uri", redirectUrl) - .build() + private fun jsonToTrack(mangas: JsonObject): Track { + return Track.create(TrackManager.BANGUMI).apply { + title = mangas["name"].asString + media_id = mangas["id"].asInt + score = if (mangas["rating"] != null) { + if (mangas["rating"].isJsonObject) { + mangas["rating"].obj["score"].asFloat + } else { + 0f + } + } else { + 0f + } + status = Bangumi.DEFAULT_STATUS + tracking_url = mangas["url"].asString + } + } - fun refreshTokenRequest(token: String) = POST(oauthUrl, - body = FormBody.Builder() - .add("grant_type", "refresh_token") - .add("client_id", clientId) - .add("client_secret", clientSecret) - .add("refresh_token", token) - .add("redirect_uri", redirectUrl) - .build()) - } + fun findLibManga(track: Track): Observable { + val urlMangas = "$apiUrl/subject/${track.media_id}" + val requestMangas = Request.Builder() + .url(urlMangas) + .get() + .build() + return authClient.newCall(requestMangas) + .asObservableSuccess() + .map { netResponse -> + // get comic info + val responseBody = netResponse.body?.string().orEmpty() + jsonToTrack(JsonParser.parseString(responseBody).obj) + } + } + + fun statusLibManga(track: Track): Observable { + val urlUserRead = "$apiUrl/collection/${track.media_id}" + val requestUserRead = Request.Builder() + .url(urlUserRead) + .cacheControl(CacheControl.FORCE_NETWORK) + .get() + .build() + + // todo get user readed chapter here + return authClient.newCall(requestUserRead) + .asObservableSuccess() + .map { netResponse -> + val resp = netResponse.body?.string() + val coll = gson.fromJson(resp, Collection::class.java) + track.status = coll.status?.id!! + track.last_chapter_read = coll.ep_status!! + track + } + } + + fun accessToken(code: String): Observable { + return client.newCall(accessTokenRequest(code)).asObservableSuccess().map { netResponse -> + val responseBody = netResponse.body?.string().orEmpty() + if (responseBody.isEmpty()) { + throw Exception("Null Response") + } + gson.fromJson(responseBody, OAuth::class.java) + } + } + + private fun accessTokenRequest(code: String) = POST( + oauthUrl, + body = FormBody.Builder() + .add("grant_type", "authorization_code") + .add("client_id", clientId) + .add("client_secret", clientSecret) + .add("code", code) + .add("redirect_uri", redirectUrl) + .build() + ) + + companion object { + private const val clientId = "bgm10555cda0762e80ca" + private const val clientSecret = "8fff394a8627b4c388cbf349ec865775" + + private const val apiUrl = "https://api.bgm.tv" + private const val oauthUrl = "https://bgm.tv/oauth/access_token" + private const val loginUrl = "https://bgm.tv/oauth/authorize" + + private const val redirectUrl = "tachiyomi://bangumi-auth" + private const val baseMangaUrl = "$apiUrl/mangas" + + fun mangaUrl(remoteId: Int): String { + return "$baseMangaUrl/$remoteId" + } + + fun authUrl() = + Uri.parse(loginUrl).buildUpon() + .appendQueryParameter("client_id", clientId) + .appendQueryParameter("response_type", "code") + .appendQueryParameter("redirect_uri", redirectUrl) + .build() + + fun refreshTokenRequest(token: String) = POST( + oauthUrl, + body = FormBody.Builder() + .add("grant_type", "refresh_token") + .add("client_id", clientId) + .add("client_secret", clientSecret) + .add("refresh_token", token) + .add("redirect_uri", redirectUrl) + .build() + ) + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt index a3a546c88..2ffa9ad0e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt @@ -7,55 +7,58 @@ import okhttp3.Response class BangumiInterceptor(val bangumi: Bangumi, val gson: Gson) : Interceptor { - /** - * OAuth object used for authenticated requests. - */ - private var oauth: OAuth? = bangumi.restoreToken() + /** + * OAuth object used for authenticated requests. + */ + private var oauth: OAuth? = bangumi.restoreToken() - fun addTocken(tocken: String, oidFormBody: FormBody): FormBody { - val newFormBody = FormBody.Builder() - for (i in 0 until oidFormBody.size) { - newFormBody.add(oidFormBody.name(i), oidFormBody.value(i)) - } - newFormBody.add("access_token", tocken) - return newFormBody.build() - } - - override fun intercept(chain: Interceptor.Chain): Response { - val originalRequest = chain.request() - - val currAuth = oauth ?: throw Exception("Not authenticated with Bangumi") - - if (currAuth.isExpired()) { - val response = chain.proceed(BangumiApi.refreshTokenRequest(currAuth.refresh_token!!)) - if (response.isSuccessful) { - newAuth(gson.fromJson(response.body!!.string(), OAuth::class.java)) - } else { - response.close() - } + fun addTocken(tocken: String, oidFormBody: FormBody): FormBody { + val newFormBody = FormBody.Builder() + for (i in 0 until oidFormBody.size) { + newFormBody.add(oidFormBody.name(i), oidFormBody.value(i)) + } + newFormBody.add("access_token", tocken) + return newFormBody.build() } - var authRequest = if (originalRequest.method == "GET") originalRequest.newBuilder() - .header("User-Agent", "Tachiyomi") - .url(originalRequest.url.newBuilder() - .addQueryParameter("access_token", currAuth.access_token).build()) - .build() else originalRequest.newBuilder() - .post(addTocken(currAuth.access_token, originalRequest.body as FormBody)) - .header("User-Agent", "Tachiyomi") - .build() + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() - return chain.proceed(authRequest) - } + val currAuth = oauth ?: throw Exception("Not authenticated with Bangumi") - fun newAuth(oauth: OAuth?) { - this.oauth = if (oauth == null) null else OAuth( - oauth.access_token, - oauth.token_type, - System.currentTimeMillis() / 1000, - oauth.expires_in, - oauth.refresh_token, - this.oauth?.user_id) + if (currAuth.isExpired()) { + val response = chain.proceed(BangumiApi.refreshTokenRequest(currAuth.refresh_token!!)) + if (response.isSuccessful) { + newAuth(gson.fromJson(response.body!!.string(), OAuth::class.java)) + } else { + response.close() + } + } - bangumi.saveToken(oauth) - } + val authRequest = if (originalRequest.method == "GET") originalRequest.newBuilder() + .header("User-Agent", "Tachiyomi") + .url( + originalRequest.url.newBuilder() + .addQueryParameter("access_token", currAuth.access_token).build() + ) + .build() else originalRequest.newBuilder() + .post(addTocken(currAuth.access_token, originalRequest.body as FormBody)) + .header("User-Agent", "Tachiyomi") + .build() + + return chain.proceed(authRequest) + } + + fun newAuth(oauth: OAuth?) { + this.oauth = if (oauth == null) null else OAuth( + oauth.access_token, + oauth.token_type, + System.currentTimeMillis() / 1000, + oauth.expires_in, + oauth.refresh_token, + this.oauth?.user_id + ) + + bangumi.saveToken(oauth) + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiModels.kt index 83b9ce305..0b02f2b2f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiModels.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiModels.kt @@ -3,20 +3,20 @@ package eu.kanade.tachiyomi.data.track.bangumi import eu.kanade.tachiyomi.data.database.models.Track fun Track.toBangumiStatus() = when (status) { - Bangumi.READING -> "do" - Bangumi.COMPLETED -> "collect" - Bangumi.ON_HOLD -> "on_hold" - Bangumi.DROPPED -> "dropped" - Bangumi.PLANNING -> "wish" - else -> throw NotImplementedError("Unknown status") + Bangumi.READING -> "do" + Bangumi.COMPLETED -> "collect" + Bangumi.ON_HOLD -> "on_hold" + Bangumi.DROPPED -> "dropped" + Bangumi.PLANNING -> "wish" + else -> throw NotImplementedError("Unknown status") } fun toTrackStatus(status: String) = when (status) { - "do" -> Bangumi.READING - "collect" -> Bangumi.COMPLETED - "on_hold" -> Bangumi.ON_HOLD - "dropped" -> Bangumi.DROPPED - "wish" -> Bangumi.PLANNING + "do" -> Bangumi.READING + "collect" -> Bangumi.COMPLETED + "on_hold" -> Bangumi.ON_HOLD + "dropped" -> Bangumi.DROPPED + "wish" -> Bangumi.PLANNING - else -> throw Exception("Unknown status") + else -> throw Exception("Unknown status") } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Collection.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Collection.kt index 732676bf3..d9e3f7642 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Collection.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Collection.kt @@ -1,13 +1,13 @@ package eu.kanade.tachiyomi.data.track.bangumi data class Collection( - val `private`: Int? = 0, - val comment: String? = "", - val ep_status: Int? = 0, - val lasttouch: Int? = 0, - val rating: Int? = 0, - val status: Status? = Status(), - val tag: List? = listOf(), - val user: User? = User(), - val vol_status: Int? = 0 -) \ No newline at end of file + val `private`: Int? = 0, + val comment: String? = "", + val ep_status: Int? = 0, + val lasttouch: Int? = 0, + val rating: Int? = 0, + val status: Status? = Status(), + val tag: List? = listOf(), + val user: User? = User(), + val vol_status: Int? = 0 +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/OAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/OAuth.kt index 68dc7e5c4..d3dfda20f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/OAuth.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/OAuth.kt @@ -1,16 +1,14 @@ -package eu.kanade.tachiyomi.data.track.bangumi - -data class OAuth( - val access_token: String, - val token_type: String, - val created_at: Long, - val expires_in: Long, - val refresh_token: String?, - val user_id: Long? -) { - - // Access token refersh before expired - fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) - -} - +package eu.kanade.tachiyomi.data.track.bangumi + +data class OAuth( + val access_token: String, + val token_type: String, + val created_at: Long, + val expires_in: Long, + val refresh_token: String?, + val user_id: Long? +) { + + // Access token refresh before expired + fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Status.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Status.kt index 78e22e882..fbae6a3dd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Status.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Status.kt @@ -1,7 +1,7 @@ package eu.kanade.tachiyomi.data.track.bangumi data class Status( - val id: Int? = 0, - val name: String? = "", - val type: String? = "" -) \ No newline at end of file + val id: Int? = 0, + val name: String? = "", + val type: String? = "" +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/User.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/User.kt index 808e4860a..274bc3bcb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/User.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/User.kt @@ -1,11 +1,11 @@ package eu.kanade.tachiyomi.data.track.bangumi data class User( - val avatar: Avatar? = Avatar(), - val id: Int? = 0, - val nickname: String? = "", - val sign: String? = "", - val url: String? = "", - val usergroup: Int? = 0, - val username: String? = "" -) \ No newline at end of file + val avatar: Avatar? = Avatar(), + val id: Int? = 0, + val nickname: String? = "", + val sign: String? = "", + val url: String? = "", + val usergroup: Int? = 0, + val username: String? = "" +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt index 14be0ddb7..8b5ef74f1 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt @@ -1,144 +1,137 @@ -package eu.kanade.tachiyomi.data.track.kitsu - -import android.content.Context -import android.graphics.Color -import com.google.gson.Gson -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.track.TrackService -import eu.kanade.tachiyomi.data.track.model.TrackSearch -import rx.Completable -import rx.Observable -import uy.kohesive.injekt.injectLazy -import java.text.DecimalFormat - -class Kitsu(private val context: Context, id: Int) : TrackService(id) { - - companion object { - const val READING = 1 - const val COMPLETED = 2 - const val ON_HOLD = 3 - const val DROPPED = 4 - const val PLAN_TO_READ = 5 - - const val DEFAULT_STATUS = READING - const val DEFAULT_SCORE = 0f - } - - override val name = "Kitsu" - - private val gson: Gson by injectLazy() - - private val interceptor by lazy { KitsuInterceptor(this, gson) } - - private val api by lazy { KitsuApi(client, interceptor) } - - override fun getLogo(): Int { - return R.drawable.kitsu - } - - override fun getLogoColor(): Int { - return Color.rgb(51, 37, 50) - } - - override fun getStatusList(): List { - return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ) - } - - override fun getStatus(status: Int): String = with(context) { - when (status) { - READING -> getString(R.string.reading) - COMPLETED -> getString(R.string.completed) - ON_HOLD -> getString(R.string.on_hold) - DROPPED -> getString(R.string.dropped) - PLAN_TO_READ -> getString(R.string.plan_to_read) - else -> "" - } - } - - override fun getScoreList(): List { - val df = DecimalFormat("0.#") - return listOf("0") + IntRange(2, 20).map { df.format(it / 2f) } - } - - override fun indexToScore(index: Int): Float { - return if (index > 0) (index + 1) / 2f else 0f - } - - override fun displayScore(track: Track): String { - val df = DecimalFormat("0.#") - return df.format(track.score) - } - - override fun add(track: Track): Observable { - return api.addLibManga(track, getUserId()) - } - - override fun update(track: Track): Observable { - if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { - track.status = COMPLETED - } - - return api.updateLibManga(track) - } - - override fun bind(track: Track): Observable { - return api.findLibManga(track, getUserId()) - .flatMap { remoteTrack -> - if (remoteTrack != null) { - track.copyPersonalFrom(remoteTrack) - track.media_id = remoteTrack.media_id - update(track) - } else { - track.score = DEFAULT_SCORE - track.status = DEFAULT_STATUS - add(track) - } - } - } - - override fun search(query: String): Observable> { - return api.search(query) - } - - override fun refresh(track: Track): Observable { - return api.getLibManga(track) - .map { remoteTrack -> - track.copyPersonalFrom(remoteTrack) - track.total_chapters = remoteTrack.total_chapters - track - } - } - - override fun login(username: String, password: String): Completable { - return api.login(username, password) - .doOnNext { interceptor.newAuth(it) } - .flatMap { api.getCurrentUser() } - .doOnNext { userId -> saveCredentials(username, userId) } - .doOnError { logout() } - .toCompletable() - } - - override fun logout() { - super.logout() - interceptor.newAuth(null) - } - - private fun getUserId(): String { - return getPassword() - } - - fun saveToken(oauth: OAuth?) { - val json = gson.toJson(oauth) - preferences.trackToken(this).set(json) - } - - fun restoreToken(): OAuth? { - return try { - gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) - } catch (e: Exception) { - null - } - } - -} +package eu.kanade.tachiyomi.data.track.kitsu + +import android.content.Context +import android.graphics.Color +import com.google.gson.Gson +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.TrackService +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import java.text.DecimalFormat +import rx.Completable +import rx.Observable +import uy.kohesive.injekt.injectLazy + +class Kitsu(private val context: Context, id: Int) : TrackService(id) { + + companion object { + const val READING = 1 + const val COMPLETED = 2 + const val ON_HOLD = 3 + const val DROPPED = 4 + const val PLAN_TO_READ = 5 + + const val DEFAULT_STATUS = READING + const val DEFAULT_SCORE = 0f + } + + override val name = "Kitsu" + + private val gson: Gson by injectLazy() + + private val interceptor by lazy { KitsuInterceptor(this, gson) } + + private val api by lazy { KitsuApi(client, interceptor) } + + override fun getLogo() = R.drawable.ic_tracker_kitsu + + override fun getLogoColor() = Color.rgb(51, 37, 50) + + override fun getStatusList(): List { + return listOf(READING, PLAN_TO_READ, COMPLETED, ON_HOLD, DROPPED) + } + + override fun getStatus(status: Int): String = with(context) { + when (status) { + READING -> getString(R.string.currently_reading) + PLAN_TO_READ -> getString(R.string.want_to_read) + COMPLETED -> getString(R.string.completed) + ON_HOLD -> getString(R.string.on_hold) + DROPPED -> getString(R.string.dropped) + else -> "" + } + } + + override fun getCompletionStatus(): Int = COMPLETED + + override fun getScoreList(): List { + val df = DecimalFormat("0.#") + return listOf("0") + IntRange(2, 20).map { df.format(it / 2f) } + } + + override fun indexToScore(index: Int): Float { + return if (index > 0) (index + 1) / 2f else 0f + } + + override fun displayScore(track: Track): String { + val df = DecimalFormat("0.#") + return df.format(track.score) + } + + override fun add(track: Track): Observable { + return api.addLibManga(track, getUserId()) + } + + override fun update(track: Track): Observable { + return api.updateLibManga(track) + } + + override fun bind(track: Track): Observable { + return api.findLibManga(track, getUserId()) + .flatMap { remoteTrack -> + if (remoteTrack != null) { + track.copyPersonalFrom(remoteTrack) + track.media_id = remoteTrack.media_id + update(track) + } else { + track.score = DEFAULT_SCORE + track.status = DEFAULT_STATUS + add(track) + } + } + } + + override fun search(query: String): Observable> { + return api.search(query) + } + + override fun refresh(track: Track): Observable { + return api.getLibManga(track) + .map { remoteTrack -> + track.copyPersonalFrom(remoteTrack) + track.total_chapters = remoteTrack.total_chapters + track + } + } + + override fun login(username: String, password: String): Completable { + return api.login(username, password) + .doOnNext { interceptor.newAuth(it) } + .flatMap { api.getCurrentUser() } + .doOnNext { userId -> saveCredentials(username, userId) } + .doOnError { logout() } + .toCompletable() + } + + override fun logout() { + super.logout() + interceptor.newAuth(null) + } + + private fun getUserId(): String { + return getPassword() + } + + fun saveToken(oauth: OAuth?) { + val json = gson.toJson(oauth) + preferences.trackToken(this).set(json) + } + + fun restoreToken(): OAuth? { + return try { + gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) + } catch (e: Exception) { + null + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt index fa72b6d54..63e55c65d 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt @@ -1,6 +1,11 @@ package eu.kanade.tachiyomi.data.track.kitsu -import com.github.salomonbrys.kotson.* +import com.github.salomonbrys.kotson.array +import com.github.salomonbrys.kotson.get +import com.github.salomonbrys.kotson.int +import com.github.salomonbrys.kotson.jsonObject +import com.github.salomonbrys.kotson.obj +import com.github.salomonbrys.kotson.string import com.google.gson.GsonBuilder import com.google.gson.JsonObject import eu.kanade.tachiyomi.data.database.models.Track @@ -11,7 +16,16 @@ import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory import retrofit2.converter.gson.GsonConverterFactory -import retrofit2.http.* +import retrofit2.http.Body +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Headers +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query import rx.Observable class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) { @@ -19,59 +33,59 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) private val authClient = client.newBuilder().addInterceptor(interceptor).build() private val rest = Retrofit.Builder() - .baseUrl(baseUrl) - .client(authClient) - .addConverterFactory(GsonConverterFactory.create(GsonBuilder().serializeNulls().create())) - .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) - .build() - .create(KitsuApi.Rest::class.java) + .baseUrl(baseUrl) + .client(authClient) + .addConverterFactory(GsonConverterFactory.create(GsonBuilder().serializeNulls().create())) + .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) + .build() + .create(Rest::class.java) private val searchRest = Retrofit.Builder() - .baseUrl(algoliaKeyUrl) - .client(authClient) - .addConverterFactory(GsonConverterFactory.create()) - .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) - .build() - .create(KitsuApi.SearchKeyRest::class.java) + .baseUrl(algoliaKeyUrl) + .client(authClient) + .addConverterFactory(GsonConverterFactory.create()) + .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) + .build() + .create(SearchKeyRest::class.java) private val algoliaRest = Retrofit.Builder() - .baseUrl(algoliaUrl) - .client(client) - .addConverterFactory(GsonConverterFactory.create()) - .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) - .build() - .create(KitsuApi.AgoliaSearchRest::class.java) + .baseUrl(algoliaUrl) + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) + .build() + .create(AgoliaSearchRest::class.java) fun addLibManga(track: Track, userId: String): Observable { return Observable.defer { // @formatter:off val data = jsonObject( - "type" to "libraryEntries", - "attributes" to jsonObject( - "status" to track.toKitsuStatus(), - "progress" to track.last_chapter_read + "type" to "libraryEntries", + "attributes" to jsonObject( + "status" to track.toKitsuStatus(), + "progress" to track.last_chapter_read + ), + "relationships" to jsonObject( + "user" to jsonObject( + "data" to jsonObject( + "id" to userId, + "type" to "users" + ) ), - "relationships" to jsonObject( - "user" to jsonObject( - "data" to jsonObject( - "id" to userId, - "type" to "users" - ) - ), - "media" to jsonObject( - "data" to jsonObject( - "id" to track.media_id, - "type" to "manga" - ) - ) + "media" to jsonObject( + "data" to jsonObject( + "id" to track.media_id, + "type" to "manga" + ) ) + ) ) rest.addLibManga(jsonObject("data" to data)) - .map { json -> - track.media_id = json["data"]["id"].int - track - } + .map { json -> + track.media_id = json["data"]["id"].int + track + } } } @@ -79,79 +93,77 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) return Observable.defer { // @formatter:off val data = jsonObject( - "type" to "libraryEntries", - "id" to track.media_id, - "attributes" to jsonObject( - "status" to track.toKitsuStatus(), - "progress" to track.last_chapter_read, - "ratingTwenty" to track.toKitsuScore() - ) + "type" to "libraryEntries", + "id" to track.media_id, + "attributes" to jsonObject( + "status" to track.toKitsuStatus(), + "progress" to track.last_chapter_read, + "ratingTwenty" to track.toKitsuScore() + ) ) // @formatter:on rest.updateLibManga(track.media_id, jsonObject("data" to data)) - .map { track } + .map { track } } } - fun search(query: String): Observable> { return searchRest - .getKey().map { json -> - json["media"].asJsonObject["key"].string - }.flatMap { key -> - algoliaSearch(key, query) - } + .getKey().map { json -> + json["media"].asJsonObject["key"].string + }.flatMap { key -> + algoliaSearch(key, query) + } } - private fun algoliaSearch(key: String, query: String): Observable> { val jsonObject = jsonObject("params" to "query=$query$algoliaFilter") return algoliaRest - .getSearchQuery(algoliaAppId, key, jsonObject) - .map { json -> - val data = json["hits"].array - data.map { KitsuSearchManga(it.obj) } - .filter { it.subType != "novel" } - .map { it.toTrack() } - } + .getSearchQuery(algoliaAppId, key, jsonObject) + .map { json -> + val data = json["hits"].array + data.map { KitsuSearchManga(it.obj) } + .filter { it.subType != "novel" } + .map { it.toTrack() } + } } fun findLibManga(track: Track, userId: String): Observable { return rest.findLibManga(track.media_id, userId) - .map { json -> - val data = json["data"].array - if (data.size() > 0) { - val manga = json["included"].array[0].obj - KitsuLibManga(data[0].obj, manga).toTrack() - } else { - null - } + .map { json -> + val data = json["data"].array + if (data.size() > 0) { + val manga = json["included"].array[0].obj + KitsuLibManga(data[0].obj, manga).toTrack() + } else { + null } + } } fun getLibManga(track: Track): Observable { return rest.getLibManga(track.media_id) - .map { json -> - val data = json["data"].array - if (data.size() > 0) { - val manga = json["included"].array[0].obj - KitsuLibManga(data[0].obj, manga).toTrack() - } else { - throw Exception("Could not find manga") - } + .map { json -> + val data = json["data"].array + if (data.size() > 0) { + val manga = json["included"].array[0].obj + KitsuLibManga(data[0].obj, manga).toTrack() + } else { + throw Exception("Could not find manga") } + } } fun login(username: String, password: String): Observable { return Retrofit.Builder() - .baseUrl(loginUrl) - .client(client) - .addConverterFactory(GsonConverterFactory.create()) - .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) - .build() - .create(KitsuApi.LoginRest::class.java) - .requestAccessToken(username, password) + .baseUrl(loginUrl) + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) + .build() + .create(LoginRest::class.java) + .requestAccessToken(username, password) } fun getCurrentUser(): Observable { @@ -163,35 +175,33 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) @Headers("Content-Type: application/vnd.api+json") @POST("library-entries") fun addLibManga( - @Body data: JsonObject + @Body data: JsonObject ): Observable @Headers("Content-Type: application/vnd.api+json") @PATCH("library-entries/{id}") fun updateLibManga( - @Path("id") remoteId: Int, - @Body data: JsonObject + @Path("id") remoteId: Int, + @Body data: JsonObject ): Observable - @GET("library-entries") fun findLibManga( - @Query("filter[manga_id]", encoded = true) remoteId: Int, - @Query("filter[user_id]", encoded = true) userId: String, - @Query("include") includes: String = "manga" + @Query("filter[manga_id]", encoded = true) remoteId: Int, + @Query("filter[user_id]", encoded = true) userId: String, + @Query("include") includes: String = "manga" ): Observable @GET("library-entries") fun getLibManga( - @Query("filter[id]", encoded = true) remoteId: Int, - @Query("include") includes: String = "manga" + @Query("filter[id]", encoded = true) remoteId: Int, + @Query("include") includes: String = "manga" ): Observable @GET("users") fun getCurrentUser( - @Query("filter[self]", encoded = true) self: Boolean = true + @Query("filter[self]", encoded = true) self: Boolean = true ): Observable - } private interface SearchKeyRest { @@ -209,13 +219,12 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) @FormUrlEncoded @POST("oauth/token") fun requestAccessToken( - @Field("username") username: String, - @Field("password") password: String, - @Field("grant_type") grantType: String = "password", - @Field("client_id") client_id: String = clientId, - @Field("client_secret") client_secret: String = clientSecret + @Field("username") username: String, + @Field("password") password: String, + @Field("grant_type") grantType: String = "password", + @Field("client_id") client_id: String = clientId, + @Field("client_secret") client_secret: String = clientSecret ): Observable - } companion object { @@ -229,20 +238,18 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) private const val algoliaAppId = "AWQO5J657S" private const val algoliaFilter = "&facetFilters=%5B%22kind%3Amanga%22%5D&attributesToRetrieve=%5B%22synopsis%22%2C%22canonicalTitle%22%2C%22chapterCount%22%2C%22posterImage%22%2C%22startDate%22%2C%22subtype%22%2C%22endDate%22%2C%20%22id%22%5D" - fun mangaUrl(remoteId: Int): String { return baseMangaUrl + remoteId } - - fun refreshTokenRequest(token: String) = POST("${loginUrl}oauth/token", - body = FormBody.Builder() - .add("grant_type", "refresh_token") - .add("client_id", clientId) - .add("client_secret", clientSecret) - .add("refresh_token", token) - .build()) - + fun refreshTokenRequest(token: String) = POST( + "${loginUrl}oauth/token", + body = FormBody.Builder() + .add("grant_type", "refresh_token") + .add("client_id", clientId) + .add("client_secret", clientSecret) + .add("refresh_token", token) + .build() + ) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuInterceptor.kt index 1a74b8d9e..0e4dc070c 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuInterceptor.kt @@ -30,10 +30,10 @@ class KitsuInterceptor(val kitsu: Kitsu, val gson: Gson) : Interceptor { // Add the authorization header to the original request. val authRequest = originalRequest.newBuilder() - .addHeader("Authorization", "Bearer ${oauth!!.access_token}") - .header("Accept", "application/vnd.api+json") - .header("Content-Type", "application/vnd.api+json") - .build() + .addHeader("Authorization", "Bearer ${oauth!!.access_token}") + .header("Accept", "application/vnd.api+json") + .header("Content-Type", "application/vnd.api+json") + .build() return chain.proceed(authRequest) } @@ -42,5 +42,4 @@ class KitsuInterceptor(val kitsu: Kitsu, val gson: Gson) : Interceptor { this.oauth = oauth kitsu.saveToken(oauth) } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt index 5e709e810..48e5d3871 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt @@ -1,13 +1,19 @@ package eu.kanade.tachiyomi.data.track.kitsu import androidx.annotation.CallSuper -import com.github.salomonbrys.kotson.* +import com.github.salomonbrys.kotson.byInt +import com.github.salomonbrys.kotson.byString +import com.github.salomonbrys.kotson.nullInt +import com.github.salomonbrys.kotson.nullObj +import com.github.salomonbrys.kotson.nullString +import com.github.salomonbrys.kotson.obj import com.google.gson.JsonObject import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.model.TrackSearch import java.text.SimpleDateFormat -import java.util.* +import java.util.Date +import java.util.Locale class KitsuSearchManga(obj: JsonObject) { val id by obj.byInt @@ -30,17 +36,16 @@ class KitsuSearchManga(obj: JsonObject) { cover_url = original ?: "" summary = synopsis tracking_url = KitsuApi.mangaUrl(media_id) - if (endDate == null) { - publishing_status = "Publishing" + publishing_status = if (endDate == null) { + "Publishing" } else { - publishing_status = "Finished" + "Finished" } publishing_type = subType ?: "" start_date = startDate ?: "" } } - class KitsuLibManga(obj: JsonObject, manga: JsonObject) { val id by manga.byInt private val canonicalTitle by manga["attributes"].byString @@ -77,7 +82,6 @@ class KitsuLibManga(obj: JsonObject, manga: JsonObject) { "planned" -> Kitsu.PLAN_TO_READ else -> throw Exception("Unknown status") } - } fun Track.toKitsuStatus() = when (status) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/OAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/OAuth.kt index e9f2ae401..a10981c51 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/OAuth.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/OAuth.kt @@ -1,11 +1,12 @@ -package eu.kanade.tachiyomi.data.track.kitsu - -data class OAuth( - val access_token: String, - val token_type: String, - val created_at: Long, - val expires_in: Long, - val refresh_token: String?) { - - fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) -} \ No newline at end of file +package eu.kanade.tachiyomi.data.track.kitsu + +data class OAuth( + val access_token: String, + val token_type: String, + val created_at: Long, + val expires_in: Long, + val refresh_token: String? +) { + + fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/model/TrackSearch.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/model/TrackSearch.kt index a7fb8b80d..9035f5550 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/model/TrackSearch.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/model/TrackSearch.kt @@ -24,6 +24,10 @@ class TrackSearch : Track { override var status: Int = 0 + override var started_reading_date: Long = 0 + + override var finished_reading_date: Long = 0 + override lateinit var tracking_url: String var cover_url: String = "" @@ -53,12 +57,10 @@ class TrackSearch : Track { result = 31 * result + media_id return result } - companion object { + companion object { fun create(serviceId: Int): TrackSearch = TrackSearch().apply { sync_id = serviceId } - } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt index 083060016..a87bb61ce 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt @@ -4,14 +4,13 @@ import android.content.Context import android.graphics.Color import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.model.TrackSearch import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import rx.Completable import rx.Observable -class Myanimelist(private val context: Context, id: Int) : TrackService(id) { +class MyAnimeList(private val context: Context, id: Int) : TrackService(id) { companion object { const val READING = 1 @@ -29,15 +28,21 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) { } private val interceptor by lazy { MyAnimeListInterceptor(this) } - private val api by lazy { MyanimelistApi(client, interceptor) } + private val api by lazy { MyAnimeListApi(client, interceptor) } override val name: String get() = "MyAnimeList" - override fun getLogo() = R.drawable.mal + override val supportsReadingDates: Boolean = true + + override fun getLogo() = R.drawable.ic_tracker_mal override fun getLogoColor() = Color.rgb(46, 81, 162) + override fun getStatusList(): List { + return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ) + } + override fun getStatus(status: Int): String = with(context) { when (status) { READING -> getString(R.string.reading) @@ -49,9 +54,7 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) { } } - override fun getStatusList(): List { - return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ) - } + override fun getCompletionStatus(): Int = COMPLETED override fun getScoreList(): List { return IntRange(0, 10).map(Int::toString) @@ -66,26 +69,22 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) { } override fun update(track: Track): Observable { - if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { - track.status = COMPLETED - } - return api.updateLibManga(track) } override fun bind(track: Track): Observable { return api.findLibManga(track) - .flatMap { remoteTrack -> - if (remoteTrack != null) { - track.copyPersonalFrom(remoteTrack) - update(track) - } else { - // Set default fields if it's not found in the list - track.score = DEFAULT_SCORE.toFloat() - track.status = DEFAULT_STATUS - add(track) - } + .flatMap { remoteTrack -> + if (remoteTrack != null) { + track.copyPersonalFrom(remoteTrack) + update(track) + } else { + // Set default fields if it's not found in the list + track.score = DEFAULT_SCORE.toFloat() + track.status = DEFAULT_STATUS + add(track) } + } } override fun search(query: String): Observable> { @@ -94,21 +93,21 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) { override fun refresh(track: Track): Observable { return api.getLibManga(track) - .map { remoteTrack -> - track.copyPersonalFrom(remoteTrack) - track.total_chapters = remoteTrack.total_chapters - track - } + .map { remoteTrack -> + track.copyPersonalFrom(remoteTrack) + track.total_chapters = remoteTrack.total_chapters + track + } } override fun login(username: String, password: String): Completable { logout() return Observable.fromCallable { api.login(username, password) } - .doOnNext { csrf -> saveCSRF(csrf) } - .doOnNext { saveCredentials(username, password) } - .doOnError { logout() } - .toCompletable() + .doOnNext { csrf -> saveCSRF(csrf) } + .doOnNext { saveCredentials(username, password) } + .doOnError { logout() } + .toCompletable() } fun refreshLogin() { @@ -142,10 +141,10 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) { val isAuthorized: Boolean get() = super.isLogged && - getCSRF().isNotEmpty() && - checkCookies() + getCSRF().isNotEmpty() && + checkCookies() - fun getCSRF(): String = preferences.trackToken(this).getOrDefault() + fun getCSRF(): String = preferences.trackToken(this).get() private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf) @@ -153,11 +152,11 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) { var ckCount = 0 val url = BASE_URL.toHttpUrlOrNull()!! for (ck in networkService.cookieManager.get(url)) { - if (ck.name == USER_SESSION_COOKIE || ck.name == LOGGED_IN_COOKIE) + if (ck.name == USER_SESSION_COOKIE || ck.name == LOGGED_IN_COOKIE) { ckCount++ + } } return ckCount == 2 } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListInterceptor.kt index 89770c6a0..09873f84b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListInterceptor.kt @@ -3,11 +3,12 @@ package eu.kanade.tachiyomi.data.track.myanimelist import okhttp3.Interceptor import okhttp3.Request import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okio.Buffer import org.json.JSONObject -class MyAnimeListInterceptor(private val myanimelist: Myanimelist): Interceptor { +class MyAnimeListInterceptor(private val myanimelist: MyAnimeList) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { myanimelist.ensureLoggedIn() @@ -17,6 +18,7 @@ class MyAnimeListInterceptor(private val myanimelist: Myanimelist): Interceptor if (response.code == 400) { myanimelist.refreshLogin() + response.close() response = chain.proceed(updateRequest(request)) } @@ -45,15 +47,14 @@ class MyAnimeListInterceptor(private val myanimelist: Myanimelist): Interceptor private fun updateFormBody(requestBody: RequestBody): RequestBody { val formString = bodyToString(requestBody) - return RequestBody.create(requestBody.contentType(), - "$formString${if (formString.isNotEmpty()) "&" else ""}${MyanimelistApi.CSRF}=${myanimelist.getCSRF()}") + return "$formString${if (formString.isNotEmpty()) "&" else ""}${MyAnimeListApi.CSRF}=${myanimelist.getCSRF()}".toRequestBody(requestBody.contentType()) } private fun updateJsonBody(requestBody: RequestBody): RequestBody { val jsonString = bodyToString(requestBody) val newBody = JSONObject(jsonString) - .put(MyanimelistApi.CSRF, myanimelist.getCSRF()) + .put(MyAnimeListApi.CSRF, myanimelist.getCSRF()) - return RequestBody.create(requestBody.contentType(), newBody.toString()) + return newBody.toString().toRequestBody(requestBody.contentType()) } } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyanimelistApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyanimelistApi.kt index ae3c93ef8..6d1839047 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyanimelistApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyanimelistApi.kt @@ -8,12 +8,21 @@ import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.asObservable import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.util.lang.toCalendar import eu.kanade.tachiyomi.util.selectInt import eu.kanade.tachiyomi.util.selectText +import java.io.BufferedReader +import java.io.InputStreamReader +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.GregorianCalendar +import java.util.Locale +import java.util.zip.GZIPInputStream import okhttp3.FormBody import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import org.json.JSONObject import org.jsoup.Jsoup @@ -21,12 +30,8 @@ import org.jsoup.nodes.Document import org.jsoup.nodes.Element import org.jsoup.parser.Parser import rx.Observable -import java.io.BufferedReader -import java.io.InputStreamReader -import java.util.zip.GZIPInputStream - -class MyanimelistApi(private val client: OkHttpClient, interceptor: MyAnimeListInterceptor) { +class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListInterceptor) { private val authClient = client.newBuilder().addInterceptor(interceptor).build() @@ -34,79 +39,98 @@ class MyanimelistApi(private val client: OkHttpClient, interceptor: MyAnimeListI return if (query.startsWith(PREFIX_MY)) { val realQuery = query.removePrefix(PREFIX_MY) getList() - .flatMap { Observable.from(it) } - .filter { it.title.contains(realQuery, true) } - .toList() - } - else { + .flatMap { Observable.from(it) } + .filter { it.title.contains(realQuery, true) } + .toList() + } else { client.newCall(GET(searchUrl(query))) - .asObservable() - .flatMap { response -> - Observable.from(Jsoup.parse(response.consumeBody()) - .select("div.js-categories-seasonal.js-block-list.list") - .select("table").select("tbody") - .select("tr").drop(1)) + .asObservable() + .flatMap { response -> + Observable.from( + Jsoup.parse(response.consumeBody()) + .select("div.js-categories-seasonal.js-block-list.list") + .select("table").select("tbody") + .select("tr").drop(1) + ) + } + .filter { row -> + row.select(TD)[2].text() != "Novel" + } + .map { row -> + TrackSearch.create(TrackManager.MYANIMELIST).apply { + title = row.searchTitle() + media_id = row.searchMediaId() + total_chapters = row.searchTotalChapters() + summary = row.searchSummary() + cover_url = row.searchCoverUrl() + tracking_url = mangaUrl(media_id) + publishing_status = row.searchPublishingStatus() + publishing_type = row.searchPublishingType() + start_date = row.searchStartDate() } - .filter { row -> - row.select(TD)[2].text() != "Novel" - } - .map { row -> - TrackSearch.create(TrackManager.MYANIMELIST).apply { - title = row.searchTitle() - media_id = row.searchMediaId() - total_chapters = row.searchTotalChapters() - summary = row.searchSummary() - cover_url = row.searchCoverUrl() - tracking_url = mangaUrl(media_id) - publishing_status = row.searchPublishingStatus() - publishing_type = row.searchPublishingType() - start_date = row.searchStartDate() - } - } - .toList() + } + .toList() } } fun addLibManga(track: Track): Observable { return Observable.defer { authClient.newCall(POST(url = addUrl(), body = mangaPostPayload(track))) - .asObservableSuccess() - .map { track } + .asObservableSuccess() + .map { track } } } fun updateLibManga(track: Track): Observable { return Observable.defer { - authClient.newCall(POST(url = updateUrl(), body = mangaPostPayload(track))) - .asObservableSuccess() - .map { track } + // Get track data + val response = authClient.newCall(GET(url = editPageUrl(track.media_id))).execute() + val editData = response.use { + val page = Jsoup.parse(it.consumeBody()) + + // Extract track data from MAL page + extractDataFromEditPage(page).apply { + // Apply changes to the just fetched data + copyPersonalFrom(track) + } + } + + // Update remote + authClient.newCall(POST(url = editPageUrl(track.media_id), body = mangaEditPostBody(editData))) + .asObservableSuccess() + .map { + track + } } } fun findLibManga(track: Track): Observable { - return authClient.newCall(GET(url = listEntryUrl(track.media_id))) - .asObservable() - .map {response -> - var libTrack: Track? = null - response.use { - if (it.priorResponse?.isRedirect != true) { - val trackForm = Jsoup.parse(it.consumeBody()) + return authClient.newCall(GET(url = editPageUrl(track.media_id))) + .asObservable() + .map { response -> + var libTrack: Track? = null + response.use { + if (it.priorResponse?.isRedirect != true) { + val trackForm = Jsoup.parse(it.consumeBody()) - libTrack = Track.create(TrackManager.MYANIMELIST).apply { - last_chapter_read = trackForm.select("#add_manga_num_read_chapters").`val`().toInt() - total_chapters = trackForm.select("#totalChap").text().toInt() - status = trackForm.select("#add_manga_status > option[selected]").`val`().toInt() - score = trackForm.select("#add_manga_score > option[selected]").`val`().toFloatOrNull() ?: 0f - } + libTrack = Track.create(TrackManager.MYANIMELIST).apply { + last_chapter_read = trackForm.select("#add_manga_num_read_chapters").`val`().toInt() + total_chapters = trackForm.select("#totalChap").text().toInt() + status = trackForm.select("#add_manga_status > option[selected]").`val`().toInt() + score = trackForm.select("#add_manga_score > option[selected]").`val`().toFloatOrNull() + ?: 0f + started_reading_date = trackForm.searchDatePicker("#add_manga_start_date") + finished_reading_date = trackForm.searchDatePicker("#add_manga_finish_date") } } - libTrack } + libTrack + } } fun getLibManga(track: Track): Observable { return findLibManga(track) - .map { it ?: throw Exception("Could not find manga") } + .map { it ?: throw Exception("Could not find manga") } } fun login(username: String, password: String): String { @@ -121,8 +145,8 @@ class MyanimelistApi(private val client: OkHttpClient, interceptor: MyAnimeListI val response = client.newCall(GET(loginUrl())).execute() return Jsoup.parse(response.consumeBody()) - .select("meta[name=csrf_token]") - .attr("content") + .select("meta[name=csrf_token]") + .attr("content") } private fun login(username: String, password: String, csrf: String) { @@ -135,43 +159,45 @@ class MyanimelistApi(private val client: OkHttpClient, interceptor: MyAnimeListI private fun getList(): Observable> { return getListUrl() - .flatMap { url -> - getListXml(url) + .flatMap { url -> + getListXml(url) + } + .flatMap { doc -> + Observable.from(doc.select("manga")) + } + .map { + TrackSearch.create(TrackManager.MYANIMELIST).apply { + title = it.selectText("manga_title")!! + media_id = it.selectInt("manga_mangadb_id") + last_chapter_read = it.selectInt("my_read_chapters") + status = getStatus(it.selectText("my_status")!!) + score = it.selectInt("my_score").toFloat() + total_chapters = it.selectInt("manga_chapters") + tracking_url = mangaUrl(media_id) + started_reading_date = it.searchDateXml("my_start_date") + finished_reading_date = it.searchDateXml("my_finish_date") } - .flatMap { doc -> - Observable.from(doc.select("manga")) - } - .map { - TrackSearch.create(TrackManager.MYANIMELIST).apply { - title = it.selectText("manga_title")!! - media_id = it.selectInt("manga_mangadb_id") - last_chapter_read = it.selectInt("my_read_chapters") - status = getStatus(it.selectText("my_status")!!) - score = it.selectInt("my_score").toFloat() - total_chapters = it.selectInt("manga_chapters") - tracking_url = mangaUrl(media_id) - } - } - .toList() + } + .toList() } private fun getListUrl(): Observable { return authClient.newCall(POST(url = exportListUrl(), body = exportPostBody())) - .asObservable() - .map {response -> - baseUrl + Jsoup.parse(response.consumeBody()) - .select("div.goodresult") - .select("a") - .attr("href") - } + .asObservable() + .map { response -> + baseUrl + Jsoup.parse(response.consumeBody()) + .select("div.goodresult") + .select("a") + .attr("href") + } } private fun getListXml(url: String): Observable { return authClient.newCall(GET(url)) - .asObservable() - .map { response -> - Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser()) - } + .asObservable() + .map { response -> + Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser()) + } } private fun Response.consumeBody(): String? { @@ -194,6 +220,35 @@ class MyanimelistApi(private val client: OkHttpClient, interceptor: MyAnimeListI } } + private fun extractDataFromEditPage(page: Document): MyAnimeListEditData { + val tables = page.select("form#main-form table") + + return MyAnimeListEditData( + entry_id = tables[0].select("input[name=entry_id]").`val`(), // Always 0 + manga_id = tables[0].select("#manga_id").`val`(), + status = tables[0].select("#add_manga_status > option[selected]").`val`(), + num_read_volumes = tables[0].select("#add_manga_num_read_volumes").`val`(), + last_completed_vol = tables[0].select("input[name=last_completed_vol]").`val`(), // Always empty + num_read_chapters = tables[0].select("#add_manga_num_read_chapters").`val`(), + score = tables[0].select("#add_manga_score > option[selected]").`val`(), + start_date_month = tables[0].select("#add_manga_start_date_month > option[selected]").`val`(), + start_date_day = tables[0].select("#add_manga_start_date_day > option[selected]").`val`(), + start_date_year = tables[0].select("#add_manga_start_date_year > option[selected]").`val`(), + finish_date_month = tables[0].select("#add_manga_finish_date_month > option[selected]").`val`(), + finish_date_day = tables[0].select("#add_manga_finish_date_day > option[selected]").`val`(), + finish_date_year = tables[0].select("#add_manga_finish_date_year > option[selected]").`val`(), + tags = tables[1].select("#add_manga_tags").`val`(), + priority = tables[1].select("#add_manga_priority > option[selected]").`val`(), + storage_type = tables[1].select("#add_manga_storage_type > option[selected]").`val`(), + num_retail_volumes = tables[1].select("#add_manga_num_retail_volumes").`val`(), + num_read_times = tables[1].select("#add_manga_num_read_times").`val`(), + reread_value = tables[1].select("#add_manga_reread_value > option[selected]").`val`(), + comments = tables[1].select("#add_manga_comments").`val`(), + is_asked_to_discuss = tables[1].select("#add_manga_is_asked_to_discuss > option[selected]").`val`(), + sns_post_type = tables[1].select("#add_manga_sns_post_type > option[selected]").`val`() + ) + } + companion object { const val CSRF = "csrf_token" @@ -206,67 +261,112 @@ class MyanimelistApi(private val client: OkHttpClient, interceptor: MyAnimeListI private fun mangaUrl(remoteId: Int) = baseMangaUrl + remoteId private fun loginUrl() = Uri.parse(baseUrl).buildUpon() - .appendPath("login.php") - .toString() + .appendPath("login.php") + .toString() private fun searchUrl(query: String): String { val col = "c[]" return Uri.parse(baseUrl).buildUpon() - .appendPath("manga.php") - .appendQueryParameter("q", query) - .appendQueryParameter(col, "a") - .appendQueryParameter(col, "b") - .appendQueryParameter(col, "c") - .appendQueryParameter(col, "d") - .appendQueryParameter(col, "e") - .appendQueryParameter(col, "g") - .toString() + .appendPath("manga.php") + .appendQueryParameter("q", query) + .appendQueryParameter(col, "a") + .appendQueryParameter(col, "b") + .appendQueryParameter(col, "c") + .appendQueryParameter(col, "d") + .appendQueryParameter(col, "e") + .appendQueryParameter(col, "g") + .toString() } private fun exportListUrl() = Uri.parse(baseUrl).buildUpon() - .appendPath("panel.php") - .appendQueryParameter("go", "export") - .toString() + .appendPath("panel.php") + .appendQueryParameter("go", "export") + .toString() - private fun updateUrl() = Uri.parse(baseModifyListUrl).buildUpon() - .appendPath("edit.json") - .toString() + private fun editPageUrl(mediaId: Int) = Uri.parse(baseModifyListUrl).buildUpon() + .appendPath(mediaId.toString()) + .appendPath("edit") + .toString() private fun addUrl() = Uri.parse(baseModifyListUrl).buildUpon() - .appendPath( "add.json") - .toString() - - private fun listEntryUrl(mediaId: Int) = Uri.parse(baseModifyListUrl).buildUpon() - .appendPath(mediaId.toString()) - .appendPath("edit") - .toString() + .appendPath("add.json") + .toString() private fun loginPostBody(username: String, password: String, csrf: String): RequestBody { return FormBody.Builder() - .add("user_name", username) - .add("password", password) - .add("cookie", "1") - .add("sublogin", "Login") - .add("submit", "1") - .add(CSRF, csrf) - .build() + .add("user_name", username) + .add("password", password) + .add("cookie", "1") + .add("sublogin", "Login") + .add("submit", "1") + .add(CSRF, csrf) + .build() } private fun exportPostBody(): RequestBody { return FormBody.Builder() - .add("type", "2") - .add("subexport", "Export My List") - .build() + .add("type", "2") + .add("subexport", "Export My List") + .build() } private fun mangaPostPayload(track: Track): RequestBody { val body = JSONObject() - .put("manga_id", track.media_id) - .put("status", track.status) - .put("score", track.score) - .put("num_read_chapters", track.last_chapter_read) + .put("manga_id", track.media_id) + .put("status", track.status) + .put("score", track.score) + .put("num_read_chapters", track.last_chapter_read) - return RequestBody.create("application/json; charset=utf-8".toMediaTypeOrNull(), body.toString()) + return body.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()) + } + + private fun mangaEditPostBody(track: MyAnimeListEditData): RequestBody { + return FormBody.Builder() + .add("entry_id", track.entry_id) + .add("manga_id", track.manga_id) + .add("add_manga[status]", track.status) + .add("add_manga[num_read_volumes]", track.num_read_volumes) + .add("last_completed_vol", track.last_completed_vol) + .add("add_manga[num_read_chapters]", track.num_read_chapters) + .add("add_manga[score]", track.score) + .add("add_manga[start_date][month]", track.start_date_month) + .add("add_manga[start_date][day]", track.start_date_day) + .add("add_manga[start_date][year]", track.start_date_year) + .add("add_manga[finish_date][month]", track.finish_date_month) + .add("add_manga[finish_date][day]", track.finish_date_day) + .add("add_manga[finish_date][year]", track.finish_date_year) + .add("add_manga[tags]", track.tags) + .add("add_manga[priority]", track.priority) + .add("add_manga[storage_type]", track.storage_type) + .add("add_manga[num_retail_volumes]", track.num_retail_volumes) + .add("add_manga[num_read_times]", track.num_read_times) + .add("add_manga[reread_value]", track.reread_value) + .add("add_manga[comments]", track.comments) + .add("add_manga[is_asked_to_discuss]", track.is_asked_to_discuss) + .add("add_manga[sns_post_type]", track.sns_post_type) + .add("submitIt", track.submitIt) + .build() + } + + private fun Element.searchDateXml(field: String): Long { + val text = selectText(field, "0000-00-00")!! + // MAL sets the data to 0000-00-00 when date is invalid or missing + if (text == "0000-00-00") { + return 0L + } + + return SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(text)?.time ?: 0L + } + + private fun Element.searchDatePicker(id: String): Long { + val month = select(id + "_month > option[selected]").`val`().toIntOrNull() + val day = select(id + "_day > option[selected]").`val`().toIntOrNull() + val year = select(id + "_year > option[selected]").`val`().toIntOrNull() + if (year == null || month == null || day == null) { + return 0L + } + + return GregorianCalendar(year, month - 1, day).timeInMillis } private fun Element.searchTitle() = select("strong").text()!! @@ -274,18 +374,18 @@ class MyanimelistApi(private val client: OkHttpClient, interceptor: MyAnimeListI private fun Element.searchTotalChapters() = if (select(TD)[4].text() == "-") 0 else select(TD)[4].text().toInt() private fun Element.searchCoverUrl() = select("img") - .attr("data-src") - .split("\\?")[0] - .replace("/r/50x70/", "/") + .attr("data-src") + .split("\\?")[0] + .replace("/r/50x70/", "/") private fun Element.searchMediaId() = select("div.picSurround") - .select("a").attr("id") - .replace("sarea", "") - .toInt() + .select("a").attr("id") + .replace("sarea", "") + .toInt() private fun Element.searchSummary() = select("div.pt4") - .first() - .ownText()!! + .first() + .ownText()!! private fun Element.searchPublishingStatus() = if (select(TD).last().text() == "-") "Publishing" else "Finished" @@ -300,6 +400,106 @@ class MyanimelistApi(private val client: OkHttpClient, interceptor: MyAnimeListI "Dropped" -> 4 "Plan to Read" -> 6 else -> 1 - } + } } -} \ No newline at end of file + + private class MyAnimeListEditData( + // entry_id + var entry_id: String, + + // manga_id + var manga_id: String, + + // add_manga[status] + var status: String, + + // add_manga[num_read_volumes] + var num_read_volumes: String, + + // last_completed_vol + var last_completed_vol: String, + + // add_manga[num_read_chapters] + var num_read_chapters: String, + + // add_manga[score] + var score: String, + + // add_manga[start_date][month] + var start_date_month: String, // [1-12] + + // add_manga[start_date][day] + var start_date_day: String, + + // add_manga[start_date][year] + var start_date_year: String, + + // add_manga[finish_date][month] + var finish_date_month: String, // [1-12] + + // add_manga[finish_date][day] + var finish_date_day: String, + + // add_manga[finish_date][year] + var finish_date_year: String, + + // add_manga[tags] + var tags: String, + + // add_manga[priority] + var priority: String, + + // add_manga[storage_type] + var storage_type: String, + + // add_manga[num_retail_volumes] + var num_retail_volumes: String, + + // add_manga[num_read_times] + var num_read_times: String, + + // add_manga[reread_value] + var reread_value: String, + + // add_manga[comments] + var comments: String, + + // add_manga[is_asked_to_discuss] + var is_asked_to_discuss: String, + + // add_manga[sns_post_type] + var sns_post_type: String, + + // submitIt + val submitIt: String = "0" + ) { + fun copyPersonalFrom(track: Track) { + num_read_chapters = track.last_chapter_read.toString() + val numScore = track.score.toInt() + if (numScore in 1..9) { + score = numScore.toString() + } + status = track.status.toString() + if (track.started_reading_date == 0L) { + start_date_month = "" + start_date_day = "" + start_date_year = "" + } + if (track.finished_reading_date == 0L) { + finish_date_month = "" + finish_date_day = "" + finish_date_year = "" + } + track.started_reading_date.toCalendar()?.let { cal -> + start_date_month = (cal[Calendar.MONTH] + 1).toString() + start_date_day = cal[Calendar.DAY_OF_MONTH].toString() + start_date_year = cal[Calendar.YEAR].toString() + } + track.finished_reading_date.toCalendar()?.let { cal -> + finish_date_month = (cal[Calendar.MONTH] + 1).toString() + finish_date_day = cal[Calendar.DAY_OF_MONTH].toString() + finish_date_year = cal[Calendar.YEAR].toString() + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyanimelistApi.kt~9dbb59f33... Upstream merge b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyanimelistApi.kt~9dbb59f33... Upstream merge new file mode 100644 index 000000000..973b2f26e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyanimelistApi.kt~9dbb59f33... Upstream merge @@ -0,0 +1,187 @@ +package eu.kanade.tachiyomi.data.track.myanimelist + +import android.net.Uri +import android.util.Xml +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.asObservable +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.util.selectInt +import eu.kanade.tachiyomi.util.selectText +import okhttp3.* +import org.jsoup.Jsoup +import org.xmlpull.v1.XmlSerializer +import rx.Observable +import java.io.StringWriter + +class MyanimelistApi(private val client: OkHttpClient, username: String, password: String) { + + private var headers = createHeaders(username, password) + + fun addLibManga(track: Track): Observable { + return Observable.defer { + client.newCall(POST(getAddUrl(track), headers, getMangaPostPayload(track))) + .asObservableSuccess() + .map { track } + } + } + + fun updateLibManga(track: Track): Observable { + return Observable.defer { + client.newCall(POST(getUpdateUrl(track), headers, getMangaPostPayload(track))) + .asObservableSuccess() + .map { track } + } + } + + fun search(query: String, username: String): Observable> { + return if (query.startsWith(PREFIX_MY)) { + val realQuery = query.substring(PREFIX_MY.length).toLowerCase().trim() + getList(username) + .flatMap { Observable.from(it) } + .filter { realQuery in it.title.toLowerCase() } + .toList() + } else { + client.newCall(GET(getSearchUrl(query), headers)) + .asObservable() + .map { Jsoup.parse(it.body().string()) } + .flatMap { Observable.from(it.select("entry")) } + .filter { it.select("type").text() != "Novel" } + .map { + Track.create(TrackManager.MYANIMELIST).apply { + title = it.selectText("title")!! + remote_id = it.selectInt("id") + total_chapters = it.selectInt("chapters") + } + } + .toList() + } + } + + fun getList(username: String): Observable> { + return client + .newCall(GET(getListUrl(username), headers)) + .asObservable() + .map { Jsoup.parse(it.body().string()) } + .flatMap { Observable.from(it.select("manga")) } + .map { + Track.create(TrackManager.MYANIMELIST).apply { + title = it.selectText("series_title")!! + remote_id = it.selectInt("series_mangadb_id") + last_chapter_read = it.selectInt("my_read_chapters") + status = it.selectInt("my_status") + score = it.selectInt("my_score").toFloat() + total_chapters = it.selectInt("series_chapters") + } + } + .toList() + } + + fun findLibManga(track: Track, username: String): Observable { + return getList(username) + .map { list -> list.find { it.remote_id == track.remote_id } } + } + + fun getLibManga(track: Track, username: String): Observable { + return findLibManga(track, username) + .map { it ?: throw Exception("Could not find manga") } + } + + fun login(username: String, password: String): Observable { + headers = createHeaders(username, password) + return client.newCall(GET(getLoginUrl(), headers)) + .asObservable() + .doOnNext { response -> + response.close() + if (response.code() != 200) throw Exception("Login error") + } + } + + private fun getMangaPostPayload(track: Track): RequestBody { + val data = xml { + element(ENTRY_TAG) { + if (track.last_chapter_read != 0) { + text(CHAPTER_TAG, track.last_chapter_read.toString()) + } + text(STATUS_TAG, track.status.toString()) + text(SCORE_TAG, track.score.toString()) + } + } + + return FormBody.Builder() + .add("data", data) + .build() + } + + private inline fun xml(block: XmlSerializer.() -> Unit): String { + val x = Xml.newSerializer() + val writer = StringWriter() + + with(x) { + setOutput(writer) + startDocument("UTF-8", false) + block() + endDocument() + } + + return writer.toString() + } + + private inline fun XmlSerializer.element(tag: String, block: XmlSerializer.() -> Unit) { + startTag("", tag) + block() + endTag("", tag) + } + + private fun XmlSerializer.text(tag: String, body: String) { + startTag("", tag) + text(body) + endTag("", tag) + } + + fun getLoginUrl() = Uri.parse(baseUrl).buildUpon() + .appendEncodedPath("api/account/verify_credentials.xml") + .toString() + + fun getSearchUrl(query: String) = Uri.parse(baseUrl).buildUpon() + .appendEncodedPath("api/manga/search.xml") + .appendQueryParameter("q", query) + .toString() + + fun getListUrl(username: String) = Uri.parse(baseUrl).buildUpon() + .appendPath("malappinfo.php") + .appendQueryParameter("u", username) + .appendQueryParameter("status", "all") + .appendQueryParameter("type", "manga") + .toString() + + fun getUpdateUrl(track: Track) = Uri.parse(baseUrl).buildUpon() + .appendEncodedPath("api/mangalist/update") + .appendPath("${track.remote_id}.xml") + .toString() + + fun getAddUrl(track: Track) = Uri.parse(baseUrl).buildUpon() + .appendEncodedPath("api/mangalist/add") + .appendPath("${track.remote_id}.xml") + .toString() + + fun createHeaders(username: String, password: String): Headers { + return Headers.Builder() + .add("Authorization", Credentials.basic(username, password)) + .add("User-Agent", "api-indiv-9F93C52A963974CF674325391990191C") + .build() + } + + companion object { + const val baseUrl = "https://myanimelist.net" + + private val ENTRY_TAG = "entry" + private val CHAPTER_TAG = "chapter" + private val SCORE_TAG = "score" + private val STATUS_TAG = "status" + + const val PREFIX_MY = "my:" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/OAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/OAuth.kt index 118e584e7..8c6f2a982 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/OAuth.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/OAuth.kt @@ -1,13 +1,13 @@ -package eu.kanade.tachiyomi.data.track.shikimori - -data class OAuth( - val access_token: String, - val token_type: String, - val created_at: Long, - val expires_in: Long, - val refresh_token: String?) { - - // Access token lives 1 day - fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) -} - +package eu.kanade.tachiyomi.data.track.shikimori + +data class OAuth( + val access_token: String, + val token_type: String, + val created_at: Long, + val expires_in: Long, + val refresh_token: String? +) { + + // Access token lives 1 day + fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt index 8068e6d55..397559b11 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt @@ -1,139 +1,137 @@ -package eu.kanade.tachiyomi.data.track.shikimori - -import android.content.Context -import android.graphics.Color -import android.util.Log -import com.google.gson.Gson -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.track.TrackService -import eu.kanade.tachiyomi.data.track.model.TrackSearch -import rx.Completable -import rx.Observable -import uy.kohesive.injekt.injectLazy - -class Shikimori(private val context: Context, id: Int) : TrackService(id) { - - override fun getScoreList(): List { - return IntRange(0, 10).map(Int::toString) - } - - override fun displayScore(track: Track): String { - return track.score.toInt().toString() - } - - override fun add(track: Track): Observable { - return api.addLibManga(track, getUsername()) - } - - override fun update(track: Track): Observable { - if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { - track.status = COMPLETED - } - return api.updateLibManga(track, getUsername()) - } - - override fun bind(track: Track): Observable { - return api.findLibManga(track, getUsername()) - .flatMap { remoteTrack -> - if (remoteTrack != null) { - track.copyPersonalFrom(remoteTrack) - track.library_id = remoteTrack.library_id - update(track) - } else { - // Set default fields if it's not found in the list - track.score = DEFAULT_SCORE.toFloat() - track.status = DEFAULT_STATUS - add(track) - } - } - } - - override fun search(query: String): Observable> { - return api.search(query) - } - - override fun refresh(track: Track): Observable { - return api.findLibManga(track, getUsername()) - .map { remoteTrack -> - if (remoteTrack != null) { - track.copyPersonalFrom(remoteTrack) - track.total_chapters = remoteTrack.total_chapters - } - track - } - } - - companion object { - const val READING = 1 - const val COMPLETED = 2 - const val ON_HOLD = 3 - const val DROPPED = 4 - const val PLANNING = 5 - const val REPEATING = 6 - - const val DEFAULT_STATUS = READING - const val DEFAULT_SCORE = 0 - } - - override val name = "Shikimori" - - private val gson: Gson by injectLazy() - - private val interceptor by lazy { ShikimoriInterceptor(this, gson) } - - private val api by lazy { ShikimoriApi(client, interceptor) } - - override fun getLogo() = R.drawable.shikimori - - override fun getLogoColor() = Color.rgb(40, 40, 40) - - override fun getStatusList(): List { - return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING, REPEATING) - } - - override fun getStatus(status: Int): String = with(context) { - when (status) { - READING -> getString(R.string.reading) - COMPLETED -> getString(R.string.completed) - ON_HOLD -> getString(R.string.on_hold) - DROPPED -> getString(R.string.dropped) - PLANNING -> getString(R.string.plan_to_read) - REPEATING -> getString(R.string.repeating) - else -> "" - } - } - - override fun login(username: String, password: String) = login(password) - - fun login(code: String): Completable { - return api.accessToken(code).map { oauth: OAuth? -> - interceptor.newAuth(oauth) - if (oauth != null) { - val user = api.getCurrentUser() - saveCredentials(user.toString(), oauth.access_token) - } - }.doOnError { - logout() - }.toCompletable() - } - - fun saveToken(oauth: OAuth?) { - val json = gson.toJson(oauth) - preferences.trackToken(this).set(json) - } - - fun restoreToken(): OAuth? { - return try { - gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) - } catch (e: Exception) { - null - } - } - - override fun logout() { - super.logout() - preferences.trackToken(this).set(null) - interceptor.newAuth(null) - } -} +package eu.kanade.tachiyomi.data.track.shikimori + +import android.content.Context +import android.graphics.Color +import com.google.gson.Gson +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.TrackService +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import rx.Completable +import rx.Observable +import uy.kohesive.injekt.injectLazy + +class Shikimori(private val context: Context, id: Int) : TrackService(id) { + + companion object { + const val READING = 1 + const val COMPLETED = 2 + const val ON_HOLD = 3 + const val DROPPED = 4 + const val PLANNING = 5 + const val REPEATING = 6 + + const val DEFAULT_STATUS = READING + const val DEFAULT_SCORE = 0 + } + + override val name = "Shikimori" + + private val gson: Gson by injectLazy() + + private val interceptor by lazy { ShikimoriInterceptor(this, gson) } + + private val api by lazy { ShikimoriApi(client, interceptor) } + + override fun getScoreList(): List { + return IntRange(0, 10).map(Int::toString) + } + + override fun displayScore(track: Track): String { + return track.score.toInt().toString() + } + + override fun add(track: Track): Observable { + return api.addLibManga(track, getUsername()) + } + + override fun update(track: Track): Observable { + return api.updateLibManga(track, getUsername()) + } + + override fun bind(track: Track): Observable { + return api.findLibManga(track, getUsername()) + .flatMap { remoteTrack -> + if (remoteTrack != null) { + track.copyPersonalFrom(remoteTrack) + track.library_id = remoteTrack.library_id + update(track) + } else { + // Set default fields if it's not found in the list + track.score = DEFAULT_SCORE.toFloat() + track.status = DEFAULT_STATUS + add(track) + } + } + } + + override fun search(query: String): Observable> { + return api.search(query) + } + + override fun refresh(track: Track): Observable { + return api.findLibManga(track, getUsername()) + .map { remoteTrack -> + if (remoteTrack != null) { + track.copyPersonalFrom(remoteTrack) + track.total_chapters = remoteTrack.total_chapters + } + track + } + } + + override fun getLogo() = R.drawable.ic_tracker_shikimori + + override fun getLogoColor() = Color.rgb(40, 40, 40) + + override fun getStatusList(): List { + return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING, REPEATING) + } + + override fun getStatus(status: Int): String = with(context) { + when (status) { + READING -> getString(R.string.reading) + COMPLETED -> getString(R.string.completed) + ON_HOLD -> getString(R.string.on_hold) + DROPPED -> getString(R.string.dropped) + PLANNING -> getString(R.string.plan_to_read) + REPEATING -> getString(R.string.repeating) + else -> "" + } + } + + override fun getCompletionStatus(): Int = COMPLETED + + override fun login(username: String, password: String) = login(password) + + fun login(code: String): Completable { + return api.accessToken(code).map { oauth: OAuth? -> + interceptor.newAuth(oauth) + if (oauth != null) { + val user = api.getCurrentUser() + saveCredentials(user.toString(), oauth.access_token) + } + }.doOnError { + logout() + }.toCompletable() + } + + fun saveToken(oauth: OAuth?) { + val json = gson.toJson(oauth) + preferences.trackToken(this).set(json) + } + + fun restoreToken(): OAuth? { + return try { + gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) + } catch (e: Exception) { + null + } + } + + override fun logout() { + super.logout() + preferences.trackToken(this).delete() + interceptor.newAuth(null) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt index 7441cf66d..069786a0a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt @@ -18,63 +18,61 @@ import okhttp3.FormBody import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody import rx.Observable import uy.kohesive.injekt.injectLazy class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInterceptor) { private val gson: Gson by injectLazy() - private val parser = JsonParser() private val jsonime = "application/json; charset=utf-8".toMediaTypeOrNull() private val authClient = client.newBuilder().addInterceptor(interceptor).build() fun addLibManga(track: Track, user_id: String): Observable { val payload = jsonObject( - "user_rate" to jsonObject( - "user_id" to user_id, - "target_id" to track.media_id, - "target_type" to "Manga", - "chapters" to track.last_chapter_read, - "score" to track.score.toInt(), - "status" to track.toShikimoriStatus() - ) + "user_rate" to jsonObject( + "user_id" to user_id, + "target_id" to track.media_id, + "target_type" to "Manga", + "chapters" to track.last_chapter_read, + "score" to track.score.toInt(), + "status" to track.toShikimoriStatus() + ) ) - val body = RequestBody.create(jsonime, payload.toString()) + val body = payload.toString().toRequestBody(jsonime) val request = Request.Builder() - .url("$apiUrl/v2/user_rates") - .post(body) - .build() + .url("$apiUrl/v2/user_rates") + .post(body) + .build() return authClient.newCall(request) - .asObservableSuccess() - .map { - track - } + .asObservableSuccess() + .map { + track + } } fun updateLibManga(track: Track, user_id: String): Observable = addLibManga(track, user_id) fun search(search: String): Observable> { val url = Uri.parse("$apiUrl/mangas").buildUpon() - .appendQueryParameter("order", "popularity") - .appendQueryParameter("search", search) - .appendQueryParameter("limit", "20") - .build() + .appendQueryParameter("order", "popularity") + .appendQueryParameter("search", search) + .appendQueryParameter("limit", "20") + .build() val request = Request.Builder() - .url(url.toString()) - .get() - .build() + .url(url.toString()) + .get() + .build() return authClient.newCall(request) - .asObservableSuccess() - .map { netResponse -> - val responseBody = netResponse.body?.string().orEmpty() - if (responseBody.isEmpty()) { - throw Exception("Null Response") - } - val response = parser.parse(responseBody).array - response.map { jsonToSearch(it.obj) } + .asObservableSuccess() + .map { netResponse -> + val responseBody = netResponse.body?.string().orEmpty() + if (responseBody.isEmpty()) { + throw Exception("Null Response") } - + val response = JsonParser.parseString(responseBody).array + response.map { jsonToSearch(it.obj) } + } } private fun jsonToSearch(obj: JsonObject): TrackSearch { @@ -105,50 +103,50 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter fun findLibManga(track: Track, user_id: String): Observable { val url = Uri.parse("$apiUrl/v2/user_rates").buildUpon() - .appendQueryParameter("user_id", user_id) - .appendQueryParameter("target_id", track.media_id.toString()) - .appendQueryParameter("target_type", "Manga") - .build() + .appendQueryParameter("user_id", user_id) + .appendQueryParameter("target_id", track.media_id.toString()) + .appendQueryParameter("target_type", "Manga") + .build() val request = Request.Builder() - .url(url.toString()) - .get() - .build() + .url(url.toString()) + .get() + .build() val urlMangas = Uri.parse("$apiUrl/mangas").buildUpon() - .appendPath(track.media_id.toString()) - .build() + .appendPath(track.media_id.toString()) + .build() val requestMangas = Request.Builder() - .url(urlMangas.toString()) - .get() - .build() + .url(urlMangas.toString()) + .get() + .build() return authClient.newCall(requestMangas) - .asObservableSuccess() - .map { netResponse -> - val responseBody = netResponse.body?.string().orEmpty() - parser.parse(responseBody).obj - }.flatMap { mangas -> - authClient.newCall(request) - .asObservableSuccess() - .map { netResponse -> - val responseBody = netResponse.body?.string().orEmpty() - if (responseBody.isEmpty()) { - throw Exception("Null Response") - } - val response = parser.parse(responseBody).array - if (response.size() > 1) { - throw Exception("Too much mangas in response") - } - val entry = response.map { - jsonToTrack(it.obj, mangas) - } - entry.firstOrNull() - } - } + .asObservableSuccess() + .map { netResponse -> + val responseBody = netResponse.body?.string().orEmpty() + JsonParser.parseString(responseBody).obj + }.flatMap { mangas -> + authClient.newCall(request) + .asObservableSuccess() + .map { netResponse -> + val responseBody = netResponse.body?.string().orEmpty() + if (responseBody.isEmpty()) { + throw Exception("Null Response") + } + val response = JsonParser.parseString(responseBody).array + if (response.size() > 1) { + throw Exception("Too much mangas in response") + } + val entry = response.map { + jsonToTrack(it.obj, mangas) + } + entry.firstOrNull() + } + } } fun getCurrentUser(): Int { val user = authClient.newCall(GET("$apiUrl/users/whoami")).execute().body?.string() - return parser.parse(user).obj["id"].asInt + return JsonParser.parseString(user).obj["id"].asInt } fun accessToken(code: String): Observable { @@ -161,17 +159,17 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter } } - private fun accessTokenRequest(code: String) = POST(oauthUrl, - body = FormBody.Builder() - .add("grant_type", "authorization_code") - .add("client_id", clientId) - .add("client_secret", clientSecret) - .add("code", code) - .add("redirect_uri", redirectUrl) - .build() + private fun accessTokenRequest(code: String) = POST( + oauthUrl, + body = FormBody.Builder() + .add("grant_type", "authorization_code") + .add("client_id", clientId) + .add("client_secret", clientSecret) + .add("code", code) + .add("redirect_uri", redirectUrl) + .build() ) - companion object { private const val clientId = "1aaf4cf232372708e98b5abc813d795b539c5a916dbbfe9ac61bf02a360832cc" private const val clientSecret = "229942c742dd4cde803125d17d64501d91c0b12e14cb1e5120184d77d67024c0" @@ -189,21 +187,20 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter } fun authUrl() = - Uri.parse(loginUrl).buildUpon() - .appendQueryParameter("client_id", clientId) - .appendQueryParameter("redirect_uri", redirectUrl) - .appendQueryParameter("response_type", "code") - .build() - - - fun refreshTokenRequest(token: String) = POST(oauthUrl, - body = FormBody.Builder() - .add("grant_type", "refresh_token") - .add("client_id", clientId) - .add("client_secret", clientSecret) - .add("refresh_token", token) - .build()) + Uri.parse(loginUrl).buildUpon() + .appendQueryParameter("client_id", clientId) + .appendQueryParameter("redirect_uri", redirectUrl) + .appendQueryParameter("response_type", "code") + .build() + fun refreshTokenRequest(token: String) = POST( + oauthUrl, + body = FormBody.Builder() + .add("grant_type", "refresh_token") + .add("client_id", clientId) + .add("client_secret", clientSecret) + .add("refresh_token", token) + .build() + ) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriInterceptor.kt index 6e10b4de3..0a2688d70 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriInterceptor.kt @@ -29,9 +29,9 @@ class ShikimoriInterceptor(val shikimori: Shikimori, val gson: Gson) : Intercept } // Add the authorization header to the original request. val authRequest = originalRequest.newBuilder() - .addHeader("Authorization", "Bearer ${oauth!!.access_token}") - .header("User-Agent", "Tachiyomi") - .build() + .addHeader("Authorization", "Bearer ${oauth!!.access_token}") + .header("User-Agent", "Tachiyomi") + .build() return chain.proceed(authRequest) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/Release.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/Release.kt index 9ac138e98..61f2bd787 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/Release.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/Release.kt @@ -9,5 +9,4 @@ interface Release { * @return download link of latest release. */ val downloadLink: String - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateChecker.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateChecker.kt index 4d2a1de66..9f1e8eac1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateChecker.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateChecker.kt @@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.data.updater import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.data.updater.devrepo.DevRepoUpdateChecker import eu.kanade.tachiyomi.data.updater.github.GithubUpdateChecker -import rx.Observable abstract class UpdateChecker { @@ -20,6 +19,5 @@ abstract class UpdateChecker { /** * Returns observable containing release information */ - abstract fun checkForUpdate(): Observable - + abstract suspend fun checkForUpdate(): UpdateResult } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateResult.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateResult.kt index a59864f55..a147c01df 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateResult.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateResult.kt @@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.data.updater abstract class UpdateResult { - open class NewUpdate(val release: T): UpdateResult() - open class NoNewUpdate: UpdateResult() - + open class NewUpdate(val release: T) : UpdateResult() + open class NoNewUpdate : UpdateResult() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterJob.kt index 3bcfe9fcf..f786afc10 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterJob.kt @@ -1,44 +1,54 @@ package eu.kanade.tachiyomi.data.updater import android.app.PendingIntent +import android.content.Context import android.content.Intent import androidx.core.app.NotificationCompat -import com.evernote.android.job.Job -import com.evernote.android.job.JobManager -import com.evernote.android.job.JobRequest +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.Worker +import androidx.work.WorkerParameters import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.notification.Notifications -import eu.kanade.tachiyomi.util.notificationManager +import eu.kanade.tachiyomi.util.system.notificationManager +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.runBlocking -class UpdaterJob : Job() { +class UpdaterJob(private val context: Context, workerParams: WorkerParameters) : + Worker(context, workerParams) { - override fun onRunJob(params: Params): Result { - return UpdateChecker.getUpdateChecker() - .checkForUpdate() - .map { result -> - if (result is UpdateResult.NewUpdate<*>) { - val url = result.release.downloadLink + override fun doWork(): Result { + return runBlocking { + try { + val result = UpdateChecker.getUpdateChecker().checkForUpdate() - val intent = Intent(context, UpdaterService::class.java).apply { - putExtra(UpdaterService.EXTRA_DOWNLOAD_URL, url) - } + if (result is UpdateResult.NewUpdate<*>) { + val url = result.release.downloadLink - NotificationCompat.Builder(context, Notifications.CHANNEL_COMMON).update { - setContentTitle(context.getString(R.string.app_name)) - setContentText(context.getString(R.string.update_check_notification_update_available)) - setSmallIcon(android.R.drawable.stat_sys_download_done) - // Download action - addAction(android.R.drawable.stat_sys_download_done, - context.getString(R.string.action_download), - PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)) - } + val intent = Intent(context, UpdaterService::class.java).apply { + putExtra(UpdaterService.EXTRA_DOWNLOAD_URL, url) + } + + NotificationCompat.Builder(context, Notifications.CHANNEL_COMMON).update { + setContentTitle(context.getString(R.string.app_name)) + setContentText(context.getString(R.string.update_check_notification_update_available)) + setSmallIcon(android.R.drawable.stat_sys_download_done) + // Download action + addAction( + android.R.drawable.stat_sys_download_done, + context.getString(R.string.action_download), + PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + ) } - Result.SUCCESS } - .onErrorReturn { Result.FAILURE } - // Sadly, the task needs to be synchronous. - .toBlocking() - .single() + Result.success() + } catch (e: Exception) { + Result.failure() + } + } } fun NotificationCompat.Builder.update(block: NotificationCompat.Builder.() -> Unit) { @@ -47,21 +57,26 @@ class UpdaterJob : Job() { } companion object { - const val TAG = "UpdateChecker" + private const val TAG = "UpdateChecker" - fun setupTask() { - JobRequest.Builder(TAG) - .setPeriodic(24 * 60 * 60 * 1000, 60 * 60 * 1000) - .setRequiredNetworkType(JobRequest.NetworkType.CONNECTED) - .setRequirementsEnforced(true) - .setUpdateCurrent(true) - .build() - .schedule() + fun setupTask(context: Context) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = PeriodicWorkRequestBuilder( + 3, TimeUnit.DAYS, + 3, TimeUnit.HOURS + ) + .addTag(TAG) + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.REPLACE, request) } - fun cancelTask() { - JobManager.instance().cancelAllForTag(TAG) + fun cancelTask(context: Context) { + WorkManager.getInstance(context).cancelAllWorkByTag(TAG) } } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterNotifier.kt index 9879ec5ed..cbab3f73b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterNotifier.kt @@ -7,7 +7,8 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.notification.NotificationHandler import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.Notifications -import eu.kanade.tachiyomi.util.notificationManager +import eu.kanade.tachiyomi.util.system.notificationBuilder +import eu.kanade.tachiyomi.util.system.notificationManager /** * DownloadNotifier is used to show notifications when downloading and update. @@ -16,12 +17,7 @@ import eu.kanade.tachiyomi.util.notificationManager */ internal class UpdaterNotifier(private val context: Context) { - /** - * Builder to manage notifications. - */ - private val notification by lazy { - NotificationCompat.Builder(context, Notifications.CHANNEL_COMMON) - } + private val notificationBuilder = context.notificationBuilder(Notifications.CHANNEL_COMMON) /** * Call to show notification. @@ -38,13 +34,13 @@ internal class UpdaterNotifier(private val context: Context) { * @param title tile of notification. */ fun onDownloadStarted(title: String) { - with(notification) { + with(notificationBuilder) { setContentTitle(title) setContentText(context.getString(R.string.update_check_notification_download_in_progress)) setSmallIcon(android.R.drawable.stat_sys_download) setOngoing(true) } - notification.show() + notificationBuilder.show() } /** @@ -53,11 +49,11 @@ internal class UpdaterNotifier(private val context: Context) { * @param progress progress of download (xx%/100). */ fun onProgressChange(progress: Int) { - with(notification) { + with(notificationBuilder) { setProgress(100, progress, false) setOnlyAlertOnce(true) } - notification.show() + notificationBuilder.show() } /** @@ -66,22 +62,26 @@ internal class UpdaterNotifier(private val context: Context) { * @param uri path location of apk. */ fun onDownloadFinished(uri: Uri) { - with(notification) { + with(notificationBuilder) { setContentText(context.getString(R.string.update_check_notification_download_complete)) setSmallIcon(android.R.drawable.stat_sys_download_done) setOnlyAlertOnce(false) setProgress(0, 0, false) // Install action setContentIntent(NotificationHandler.installApkPendingActivity(context, uri)) - addAction(R.drawable.ic_system_update_grey_24dp_img, - context.getString(R.string.action_install), - NotificationHandler.installApkPendingActivity(context, uri)) + addAction( + R.drawable.ic_system_update_alt_white_24dp, + context.getString(R.string.action_install), + NotificationHandler.installApkPendingActivity(context, uri) + ) // Cancel action - addAction(R.drawable.ic_clear_grey_24dp_img, - context.getString(R.string.action_cancel), - NotificationReceiver.dismissNotificationPendingBroadcast(context, Notifications.ID_UPDATER)) + addAction( + R.drawable.ic_close_24dp, + context.getString(R.string.action_cancel), + NotificationReceiver.dismissNotificationPendingBroadcast(context, Notifications.ID_UPDATER) + ) } - notification.show() + notificationBuilder.show() } /** @@ -90,20 +90,24 @@ internal class UpdaterNotifier(private val context: Context) { * @param url web location of apk to download. */ fun onDownloadError(url: String) { - with(notification) { + with(notificationBuilder) { setContentText(context.getString(R.string.update_check_notification_download_error)) setSmallIcon(android.R.drawable.stat_sys_warning) setOnlyAlertOnce(false) setProgress(0, 0, false) // Retry action - addAction(R.drawable.ic_refresh_grey_24dp_img, - context.getString(R.string.action_retry), - UpdaterService.downloadApkPendingService(context, url)) + addAction( + R.drawable.ic_refresh_24dp, + context.getString(R.string.action_retry), + UpdaterService.downloadApkPendingService(context, url) + ) // Cancel action - addAction(R.drawable.ic_clear_grey_24dp_img, - context.getString(R.string.action_cancel), - NotificationReceiver.dismissNotificationPendingBroadcast(context, Notifications.ID_UPDATER)) + addAction( + R.drawable.ic_close_24dp, + context.getString(R.string.action_cancel), + NotificationReceiver.dismissNotificationPendingBroadcast(context, Notifications.ID_UPDATER) + ) } - notification.show(Notifications.ID_UPDATER) + notificationBuilder.show(Notifications.ID_UPDATER) } } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterService.kt index 5dd8dee2e..624d5cfdb 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterService.kt @@ -10,16 +10,14 @@ import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.ProgressListener import eu.kanade.tachiyomi.network.newCallWithProgress -import eu.kanade.tachiyomi.util.getUriCompat -import eu.kanade.tachiyomi.util.saveTo +import eu.kanade.tachiyomi.util.storage.getUriCompat +import eu.kanade.tachiyomi.util.storage.saveTo +import java.io.File import timber.log.Timber import uy.kohesive.injekt.injectLazy -import java.io.File class UpdaterService : IntentService(UpdaterService::class.java.name) { - /** - * Network helper - */ + private val network: NetworkHelper by injectLazy() /** @@ -121,5 +119,3 @@ class UpdaterService : IntentService(UpdaterService::class.java.name) { } } } - - diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/devrepo/DevRepoRelease.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/devrepo/DevRepoRelease.kt index ea8a79a18..0f2b1ac75 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/devrepo/DevRepoRelease.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/devrepo/DevRepoRelease.kt @@ -10,5 +10,4 @@ class DevRepoRelease(override val info: String) : Release { companion object { const val LATEST_URL = "https://tachiyomi.kanade.eu/latest" } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/devrepo/DevRepoUpdateChecker.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/devrepo/DevRepoUpdateChecker.kt index a24036830..88084a234 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/devrepo/DevRepoUpdateChecker.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/devrepo/DevRepoUpdateChecker.kt @@ -5,9 +5,10 @@ import eu.kanade.tachiyomi.data.updater.UpdateChecker import eu.kanade.tachiyomi.data.updater.UpdateResult import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.NetworkHelper -import eu.kanade.tachiyomi.network.asObservable +import eu.kanade.tachiyomi.network.await +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient -import rx.Observable import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -15,26 +16,26 @@ class DevRepoUpdateChecker : UpdateChecker() { private val client: OkHttpClient by lazy { Injekt.get().client.newBuilder() - .followRedirects(false) - .build() + .followRedirects(false) + .build() } private val versionRegex: Regex by lazy { Regex("tachiyomi-r(\\d+).apk") } - override fun checkForUpdate(): Observable { - return client.newCall(GET(DevRepoRelease.LATEST_URL)).asObservable() - .map { response -> - // Get latest repo version number from header in format "Location: tachiyomi-r1512.apk" - val latestVersionNumber: String = versionRegex.find(response.header("Location")!!)!!.groupValues[1] + override suspend fun checkForUpdate(): UpdateResult { + val response = withContext(Dispatchers.IO) { + client.newCall(GET(DevRepoRelease.LATEST_URL)).await(assertSuccess = false) + } - if (latestVersionNumber.toInt() > BuildConfig.COMMIT_COUNT.toInt()) { - DevRepoUpdateResult.NewUpdate(DevRepoRelease("v$latestVersionNumber")) - } else { - DevRepoUpdateResult.NoNewUpdate() - } - } + // Get latest repo version number from header in format "Location: tachiyomi-r1512.apk" + val latestVersionNumber: String = versionRegex.find(response.header("Location")!!)!!.groupValues[1] + + return if (latestVersionNumber.toInt() > BuildConfig.COMMIT_COUNT.toInt()) { + DevRepoUpdateResult.NewUpdate(DevRepoRelease("v$latestVersionNumber")) + } else { + DevRepoUpdateResult.NoNewUpdate() + } } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/devrepo/DevRepoUpdateResult.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/devrepo/DevRepoUpdateResult.kt index 1bda48b9c..1b62201a8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/devrepo/DevRepoUpdateResult.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/devrepo/DevRepoUpdateResult.kt @@ -4,7 +4,6 @@ import eu.kanade.tachiyomi.data.updater.UpdateResult sealed class DevRepoUpdateResult : UpdateResult() { - class NewUpdate(release: DevRepoRelease): UpdateResult.NewUpdate(release) - class NoNewUpdate: UpdateResult.NoNewUpdate() - + class NewUpdate(release: DevRepoRelease) : UpdateResult.NewUpdate(release) + class NoNewUpdate : UpdateResult.NoNewUpdate() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/github/GithubRelease.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/github/GithubRelease.kt index f65bf39ba..09f1b37d0 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/github/GithubRelease.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/github/GithubRelease.kt @@ -5,15 +5,17 @@ import eu.kanade.tachiyomi.data.updater.Release /** * Release object. - * Contains information about the latest release from Github. + * Contains information about the latest release from GitHub. * * @param version version of latest release. * @param info log of latest release. * @param assets assets of latest release. */ -class GithubRelease(@SerializedName("tag_name") val version: String, - @SerializedName("body") override val info: String, - @SerializedName("assets") private val assets: List): Release { +class GithubRelease( + @SerializedName("tag_name") val version: String, + @SerializedName("body") override val info: String, + @SerializedName("assets") private val assets: List +) : Release { /** * Get download link of latest release from the assets. @@ -28,4 +30,3 @@ class GithubRelease(@SerializedName("tag_name") val version: String, */ inner class Assets(@SerializedName("browser_download_url") val downloadLink: String) } - diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/github/GithubService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/github/GithubService.kt index d5d69664f..3fb00076b 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/github/GithubService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/github/GithubService.kt @@ -2,32 +2,28 @@ package eu.kanade.tachiyomi.data.updater.github import eu.kanade.tachiyomi.network.NetworkHelper import retrofit2.Retrofit -import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory import retrofit2.converter.gson.GsonConverterFactory import retrofit2.http.GET -import rx.Observable import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get /** - * Used to connect with the Github API. + * Used to connect with the GitHub API. */ interface GithubService { companion object { fun create(): GithubService { val restAdapter = Retrofit.Builder() - .baseUrl("https://api.github.com") - .addConverterFactory(GsonConverterFactory.create()) - .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) - .client(Injekt.get().client) - .build() + .baseUrl("https://api.github.com") + .addConverterFactory(GsonConverterFactory.create()) + .client(Injekt.get().client) + .build() return restAdapter.create(GithubService::class.java) } } - @GET("/repos/az4521/tachiyomiAZ/releases/latest") - fun getLatestVersion(): Observable - + @GET("/repos/jobobby04/tachiyomiSY/releases/latest") + suspend fun getLatestVersion(): GithubRelease } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/github/GithubUpdateChecker.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/github/GithubUpdateChecker.kt index db745f582..20ab5de5c 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/github/GithubUpdateChecker.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/github/GithubUpdateChecker.kt @@ -3,23 +3,21 @@ package eu.kanade.tachiyomi.data.updater.github import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.data.updater.UpdateChecker import eu.kanade.tachiyomi.data.updater.UpdateResult -import rx.Observable class GithubUpdateChecker : UpdateChecker() { private val service: GithubService = GithubService.create() - override fun checkForUpdate(): Observable { - return service.getLatestVersion().map { release -> - val newVersion = release.version + override suspend fun checkForUpdate(): UpdateResult { + val release = service.getLatestVersion() - // Check if latest version is different from current version - if (newVersion != BuildConfig.VERSION_NAME) { - GithubUpdateResult.NewUpdate(release) - } else { - GithubUpdateResult.NoNewUpdate() - } + val newVersion = release.version.replace("[^\\d.]".toRegex(), "") + + // Check if latest version is different from current version + return if (newVersion != BuildConfig.VERSION_NAME) { + GithubUpdateResult.NewUpdate(release) + } else { + GithubUpdateResult.NoNewUpdate() } } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/github/GithubUpdateResult.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/github/GithubUpdateResult.kt index fcb304604..8462f937e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/github/GithubUpdateResult.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/github/GithubUpdateResult.kt @@ -4,7 +4,6 @@ import eu.kanade.tachiyomi.data.updater.UpdateResult sealed class GithubUpdateResult : UpdateResult() { - class NewUpdate(release: GithubRelease): UpdateResult.NewUpdate(release) + class NewUpdate(release: GithubRelease) : UpdateResult.NewUpdate(release) class NoNewUpdate : UpdateResult.NoNewUpdate() - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt index 127749a85..1e2426bb8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt @@ -1,10 +1,9 @@ package eu.kanade.tachiyomi.extension import android.content.Context -import com.elvishew.xlog.XLog +import android.graphics.drawable.Drawable import com.jakewharton.rxrelay.BehaviorRelay import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.InstallStep @@ -12,13 +11,13 @@ import eu.kanade.tachiyomi.extension.model.LoadResult import eu.kanade.tachiyomi.extension.util.ExtensionInstallReceiver import eu.kanade.tachiyomi.extension.util.ExtensionInstaller import eu.kanade.tachiyomi.extension.util.ExtensionLoader +import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceManager -import eu.kanade.tachiyomi.util.launchNow +import eu.kanade.tachiyomi.util.lang.launchNow +import eu.kanade.tachiyomi.util.system.toast import exh.source.BlacklistedSources import kotlinx.coroutines.async import rx.Observable -import rx.android.schedulers.AndroidSchedulers -import rx.schedulers.Schedulers import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -33,8 +32,8 @@ import uy.kohesive.injekt.api.get * @param preferences The application preferences. */ class ExtensionManager( - private val context: Context, - private val preferences: PreferencesHelper = Injekt.get() + private val context: Context, + private val preferences: PreferencesHelper = Injekt.get() ) { /** @@ -52,6 +51,8 @@ class ExtensionManager( */ private val installedExtensionsRelay = BehaviorRelay.create>() + private val iconMap = mutableMapOf() + /** * List of the currently installed extensions. */ @@ -61,6 +62,14 @@ class ExtensionManager( installedExtensionsRelay.call(value) } + fun getAppIconForSource(source: Source): Drawable? { + val pkgName = installedExtensions.find { ext -> ext.sources.any { it.id == source.id } }?.pkgName + if (pkgName != null) { + return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) { context.packageManager.getApplicationIcon(pkgName) } + } + return null + } + /** * Relay used to notify the available extensions. */ @@ -73,7 +82,7 @@ class ExtensionManager( private set(value) { field = value availableExtensionsRelay.call(value) - setUpdateFieldOfInstalledExtensions(value) + updatedInstalledExtensionsStatuses(value) } /** @@ -111,19 +120,20 @@ class ExtensionManager( val extensions = ExtensionLoader.loadExtensions(context) installedExtensions = extensions - .filterIsInstance() - .map { it.extension } - .filterNotBlacklisted() + .filterIsInstance() + .map { it.extension } + .filterNotBlacklisted() installedExtensions - .flatMap { it.sources } - // overwrite is needed until the bundled sources are removed - .forEach { sourceManager.registerSource(it, true) } + .flatMap { it.sources } + // overwrite is needed until the bundled sources are removed + .forEach { sourceManager.registerSource(it, true) } untrustedExtensions = extensions - .filterIsInstance() - .map { it.extension } - .filterNotBlacklisted() + .filterIsInstance() + .map { it.extension } + .filterNotBlacklisted() } + // EXH --> fun Iterable.filterNotBlacklisted(): List { val blacklistEnabled = preferences.eh_enableSourceBlacklist().getOrDefault() @@ -166,11 +176,16 @@ class ExtensionManager( * Finds the available extensions in the [api] and updates [availableExtensions]. */ fun findAvailableExtensions() { - api.findExtensions() - .onErrorReturn { emptyList() } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { availableExtensions = it.filterNotBlacklisted() } + launchNow { + val extensions: List = try { + api.findExtensions() + } catch (e: Exception) { + context.toast(e.message) + emptyList() + } + + availableExtensions = extensions.filterNotBlacklisted() + } } /** @@ -178,23 +193,34 @@ class ExtensionManager( * * @param availableExtensions The list of extensions given by the [api]. */ - private fun setUpdateFieldOfInstalledExtensions(availableExtensions: List) { + private fun updatedInstalledExtensionsStatuses(availableExtensions: List) { + if (availableExtensions.isEmpty()) { + preferences.extensionUpdatesCount().set(0) + return + } + val mutInstalledExtensions = installedExtensions.toMutableList() var changed = false for ((index, installedExt) in mutInstalledExtensions.withIndex()) { val pkgName = installedExt.pkgName - val availableExt = availableExtensions.find { it.pkgName == pkgName } ?: continue + val availableExt = availableExtensions.find { it.pkgName == pkgName } - val hasUpdate = availableExt.versionCode > installedExt.versionCode - if (installedExt.hasUpdate != hasUpdate) { - mutInstalledExtensions[index] = installedExt.copy(hasUpdate = hasUpdate) + if (availableExt == null && !installedExt.isObsolete) { + mutInstalledExtensions[index] = installedExt.copy(isObsolete = true) changed = true + } else if (availableExt != null) { + val hasUpdate = availableExt.versionCode > installedExt.versionCode + if (installedExt.hasUpdate != hasUpdate) { + mutInstalledExtensions[index] = installedExt.copy(hasUpdate = hasUpdate) + changed = true + } } } if (changed) { installedExtensions = mutInstalledExtensions.filterNotBlacklisted() } + updatePendingUpdatesCount() } /** @@ -215,9 +241,9 @@ class ExtensionManager( * * @param extension The extension to be updated. */ - fun updateExtension(extension: Extension.Installed): Observable { + fun updateExtension(extension: Extension.Installed): Observable { val availableExt = availableExtensions.find { it.pkgName == extension.pkgName } - ?: return Observable.empty() + ?: return Observable.empty() return installExtension(availableExt) } @@ -252,7 +278,7 @@ class ExtensionManager( ExtensionLoader.trustedSignatures += signature val preference = preferences.trustedSignatures() - preference.set(preference.getOrDefault() + signature) + preference.set(preference.get() + signature) val nowTrustedExtensions = untrustedExtensions.filter { it.signatureHash == signature } untrustedExtensions -= nowTrustedExtensions @@ -260,15 +286,15 @@ class ExtensionManager( val ctx = context launchNow { nowTrustedExtensions - .map { extension -> - async { ExtensionLoader.loadExtensionFromPkgName(ctx, extension.pkgName) } - } - .map { it.await() } - .forEach { result -> - if (result is LoadResult.Success) { - registerNewExtension(result.extension) - } + .map { extension -> + async { ExtensionLoader.loadExtensionFromPkgName(ctx, extension.pkgName) } + } + .map { it.await() } + .forEach { result -> + if (result is LoadResult.Success) { + registerNewExtension(result.extension) } + } } } @@ -335,10 +361,12 @@ class ExtensionManager( override fun onExtensionInstalled(extension: Extension.Installed) { registerNewExtension(extension.withUpdateCheck()) + updatePendingUpdatesCount() } override fun onExtensionUpdated(extension: Extension.Installed) { registerUpdatedExtension(extension.withUpdateCheck()) + updatePendingUpdatesCount() } override fun onExtensionUntrusted(extension: Extension.Untrusted) { @@ -347,6 +375,7 @@ class ExtensionManager( override fun onPackageUninstalled(pkgName: String) { unregisterExtension(pkgName) + updatePendingUpdatesCount() } } @@ -361,4 +390,7 @@ class ExtensionManager( return this } + private fun updatePendingUpdatesCount() { + preferences.extensionUpdatesCount().set(installedExtensions.count { it.hasUpdate }) + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateJob.kt new file mode 100644 index 000000000..d9d2ed398 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateJob.kt @@ -0,0 +1,89 @@ +package eu.kanade.tachiyomi.extension + +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.notification.NotificationReceiver +import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi +import eu.kanade.tachiyomi.util.system.notification +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.coroutineScope +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class ExtensionUpdateJob(private val context: Context, workerParams: WorkerParameters) : + CoroutineWorker(context, workerParams) { + + override suspend fun doWork(): Result = coroutineScope { + val pendingUpdates = try { + ExtensionGithubApi().checkForUpdates(context) + } catch (e: Exception) { + return@coroutineScope Result.failure() + } + + if (pendingUpdates.isNotEmpty()) { + createUpdateNotification(pendingUpdates.map { it.name }) + } + + Result.success() + } + + private fun createUpdateNotification(names: List) { + NotificationManagerCompat.from(context).apply { + notify( + Notifications.ID_UPDATES_TO_EXTS, + context.notification(Notifications.CHANNEL_UPDATES_TO_EXTS) { + setContentTitle( + context.resources.getQuantityString( + R.plurals.update_check_notification_ext_updates, + names.size, + names.size + ) + ) + val extNames = names.joinToString(", ") + setContentText(extNames) + setStyle(NotificationCompat.BigTextStyle().bigText(extNames)) + setSmallIcon(R.drawable.ic_extension_24dp) + setContentIntent(NotificationReceiver.openExtensionsPendingActivity(context)) + setAutoCancel(true) + } + ) + } + } + + companion object { + private const val TAG = "ExtensionUpdate" + + fun setupTask(context: Context, forceAutoUpdateJob: Boolean? = null) { + val preferences = Injekt.get() + val autoUpdateJob = forceAutoUpdateJob ?: preferences.automaticExtUpdates().get() + if (autoUpdateJob) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = PeriodicWorkRequestBuilder( + 12, TimeUnit.HOURS, + 1, TimeUnit.HOURS + ) + .addTag(TAG) + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.REPLACE, request) + } else { + WorkManager.getInstance(context).cancelAllWorkByTag(TAG) + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt index d8eb98212..98b0928b5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt @@ -1,34 +1,67 @@ package eu.kanade.tachiyomi.extension.api +import android.content.Context import com.github.salomonbrys.kotson.fromJson import com.github.salomonbrys.kotson.get import com.github.salomonbrys.kotson.int import com.github.salomonbrys.kotson.string import com.google.gson.Gson import com.google.gson.JsonArray +import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.extension.model.Extension +import eu.kanade.tachiyomi.extension.model.LoadResult +import eu.kanade.tachiyomi.extension.util.ExtensionLoader import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.NetworkHelper -import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.network.await +import java.util.Date +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.Response -import rx.Observable import uy.kohesive.injekt.injectLazy internal class ExtensionGithubApi { private val network: NetworkHelper by injectLazy() - - private val client get() = network.client + private val preferences: PreferencesHelper by injectLazy() private val gson: Gson by injectLazy() - private val repoUrl = "https://raw.githubusercontent.com/inorichi/tachiyomi-extensions/repo" + suspend fun findExtensions(): List { + val call = GET(EXT_URL) - fun findExtensions(): Observable> { - val call = GET("$repoUrl/index.json") + return withContext(Dispatchers.IO) { + val response = network.client.newCall(call).await() + if (response.isSuccessful) { + parseResponse(response) + } else { + response.close() + throw Exception("Failed to get extensions") + } + } + } - return client.newCall(call).asObservableSuccess() - .map(::parseResponse) + suspend fun checkForUpdates(context: Context): List { + val extensions = findExtensions() + + preferences.lastExtCheck().set(Date().time) + + val installedExtensions = ExtensionLoader.loadExtensions(context) + .filterIsInstance() + .map { it.extension } + + val extensionsWithUpdate = mutableListOf() + for (installedExt in installedExtensions) { + val pkgName = installedExt.pkgName + val availableExt = extensions.find { it.pkgName == pkgName } ?: continue + + val hasUpdate = availableExt.versionCode > installedExt.versionCode + if (hasUpdate) { + extensionsWithUpdate.add(installedExt) + } + } + + return extensionsWithUpdate } private fun parseResponse(response: Response): List { @@ -36,20 +69,31 @@ internal class ExtensionGithubApi { val json = gson.fromJson(text) - return json.map { element -> - val name = element["name"].string.substringAfter("Tachiyomi: ") - val pkgName = element["pkg"].string - val apkName = element["apk"].string - val versionName = element["version"].string - val versionCode = element["code"].int - val lang = element["lang"].string - val icon = "$repoUrl/icon/${apkName.replace(".apk", ".png")}" + return json + .filter { element -> + val versionName = element["version"].string + val libVersion = versionName.substringBeforeLast('.').toDouble() + libVersion >= ExtensionLoader.LIB_VERSION_MIN && libVersion <= ExtensionLoader.LIB_VERSION_MAX + } + .map { element -> + val name = element["name"].string.substringAfter("Tachiyomi: ") + val pkgName = element["pkg"].string + val apkName = element["apk"].string + val versionName = element["version"].string + val versionCode = element["code"].int + val lang = element["lang"].string + val icon = "$REPO_URL/icon/${apkName.replace(".apk", ".png")}" - Extension.Available(name, pkgName, versionName, versionCode, lang, apkName, icon) - } + Extension.Available(name, pkgName, versionName, versionCode, lang, apkName, icon) + } } fun getApkUrl(extension: Extension.Available): String { - return "$repoUrl/apk/${extension.apkName}" + return "$REPO_URL/apk/${extension.apkName}" + } + + companion object { + private const val REPO_URL = "https://raw.githubusercontent.com/inorichi/tachiyomi-extensions/repo" + private const val EXT_URL = "$REPO_URL/index.json" } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt index ef4c24568..7fb384cc0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt @@ -10,27 +10,33 @@ sealed class Extension { abstract val versionCode: Int abstract val lang: String? - data class Installed(override val name: String, - override val pkgName: String, - override val versionName: String, - override val versionCode: Int, - val sources: List, - override val lang: String, - val hasUpdate: Boolean = false) : Extension() + data class Installed( + override val name: String, + override val pkgName: String, + override val versionName: String, + override val versionCode: Int, + val sources: List, + override val lang: String, + val hasUpdate: Boolean = false, + val isObsolete: Boolean = false + ) : Extension() - data class Available(override val name: String, - override val pkgName: String, - override val versionName: String, - override val versionCode: Int, - override val lang: String, - val apkName: String, - val iconUrl: String) : Extension() - - data class Untrusted(override val name: String, - override val pkgName: String, - override val versionName: String, - override val versionCode: Int, - val signatureHash: String, - override val lang: String? = null) : Extension() + data class Available( + override val name: String, + override val pkgName: String, + override val versionName: String, + override val versionCode: Int, + override val lang: String, + val apkName: String, + val iconUrl: String + ) : Extension() + data class Untrusted( + override val name: String, + override val pkgName: String, + override val versionName: String, + override val versionCode: Int, + val signatureHash: String, + override val lang: String? = null + ) : Extension() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallActivity.kt index 5dcd6bb12..1623a5d7d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallActivity.kt @@ -4,7 +4,7 @@ import android.app.Activity import android.content.Intent import android.os.Bundle import eu.kanade.tachiyomi.extension.ExtensionManager -import eu.kanade.tachiyomi.util.toast +import eu.kanade.tachiyomi.util.system.toast import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -18,9 +18,9 @@ class ExtensionInstallActivity : Activity() { super.onCreate(savedInstanceState) val installIntent = Intent(Intent.ACTION_INSTALL_PACKAGE) - .setDataAndType(intent.data, intent.type) - .putExtra(Intent.EXTRA_RETURN_RESULT, true) - .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .setDataAndType(intent.data, intent.type) + .putExtra(Intent.EXTRA_RETURN_RESULT, true) + .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) try { startActivityForResult(installIntent, INSTALL_REQUEST_CODE) diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallReceiver.kt index 6d21d84ac..cdef6e436 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallReceiver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallReceiver.kt @@ -6,8 +6,7 @@ import android.content.Intent import android.content.IntentFilter import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.LoadResult -import eu.kanade.tachiyomi.util.launchNow -import kotlinx.coroutines.withContext +import eu.kanade.tachiyomi.util.lang.launchNow import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -20,7 +19,7 @@ import kotlinx.coroutines.async * @param listener The listener that should be notified of extension installation events. */ internal class ExtensionInstallReceiver(private val listener: Listener) : - BroadcastReceiver() { + BroadcastReceiver() { /** * Registers this broadcast receiver @@ -32,12 +31,13 @@ internal class ExtensionInstallReceiver(private val listener: Listener) : /** * Returns the intent filter this receiver should subscribe to. */ - private val filter get() = IntentFilter().apply { - addAction(Intent.ACTION_PACKAGE_ADDED) - addAction(Intent.ACTION_PACKAGE_REPLACED) - addAction(Intent.ACTION_PACKAGE_REMOVED) - addDataScheme("package") - } + private val filter + get() = IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_ADDED) + addAction(Intent.ACTION_PACKAGE_REPLACED) + addAction(Intent.ACTION_PACKAGE_REMOVED) + addDataScheme("package") + } /** * Called when one of the events of the [filter] is received. When the package is an extension, @@ -49,8 +49,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) : when (intent.action) { Intent.ACTION_PACKAGE_ADDED -> { if (!isReplacing(intent)) launchNow { - val result = getExtensionFromIntent(context, intent) - when (result) { + when (val result = getExtensionFromIntent(context, intent)) { is LoadResult.Success -> listener.onExtensionInstalled(result.extension) is LoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension) } @@ -58,11 +57,11 @@ internal class ExtensionInstallReceiver(private val listener: Listener) : } Intent.ACTION_PACKAGE_REPLACED -> { launchNow { - val result = getExtensionFromIntent(context, intent) - when (result) { + when (val result = getExtensionFromIntent(context, intent)) { is LoadResult.Success -> listener.onExtensionUpdated(result.extension) // Not needed as a package can't be upgraded if the signature is different - is LoadResult.Untrusted -> {} + is LoadResult.Untrusted -> { + } } } } @@ -93,9 +92,9 @@ internal class ExtensionInstallReceiver(private val listener: Listener) : * @param intent The intent containing the package name of the extension. */ private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): LoadResult { - val pkgName = getPackageNameFromIntent(intent) ?: - return LoadResult.Error("Package name not found") - return withContext(Dispatchers.Default) { ExtensionLoader.loadExtensionFromPkgName(context, pkgName) } + val pkgName = getPackageNameFromIntent(intent) + ?: return LoadResult.Error("Package name not found") + return GlobalScope.async(Dispatchers.Default, CoroutineStart.DEFAULT) { ExtensionLoader.loadExtensionFromPkgName(context, pkgName) }.await() } /** @@ -114,5 +113,4 @@ internal class ExtensionInstallReceiver(private val listener: Listener) : fun onExtensionUntrusted(extension: Extension.Untrusted) fun onPackageUninstalled(pkgName: String) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt index f6feabcb8..a0fca5091 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt @@ -6,16 +6,16 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.net.Uri -import android.os.Build +import android.os.Environment import com.jakewharton.rxrelay.PublishRelay import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.InstallStep -import eu.kanade.tachiyomi.util.getUriCompat +import eu.kanade.tachiyomi.util.storage.getUriCompat +import java.io.File +import java.util.concurrent.TimeUnit import rx.Observable import rx.android.schedulers.AndroidSchedulers import timber.log.Timber -import java.io.File -import java.util.concurrent.TimeUnit /** * The installer which installs, updates and uninstalls the extensions. @@ -63,26 +63,28 @@ internal class ExtensionInstaller(private val context: Context) { // Register the receiver after removing (and unregistering) the previous download downloadReceiver.register() - val request = DownloadManager.Request(Uri.parse(url)) - .setTitle(extension.name) - .setMimeType(APK_MIME) - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + val downloadUri = Uri.parse(url) + val request = DownloadManager.Request(downloadUri) + .setTitle(extension.name) + .setMimeType(APK_MIME) + .setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, downloadUri.lastPathSegment) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) val id = downloadManager.enqueue(request) activeDownloads[pkgName] = id downloadsRelay.filter { it.first == id } - .map { it.second } - // Poll download status - .mergeWith(pollStatus(id)) - // Force an error if the download takes more than 3 minutes - .mergeWith(Observable.timer(3, TimeUnit.MINUTES).map { InstallStep.Error }) - // Stop when the application is installed or errors - .takeUntil { it.isCompleted() } - // Always notify on main thread - .observeOn(AndroidSchedulers.mainThread()) - // Always remove the download when unsubscribed - .doOnUnsubscribe { deleteDownload(pkgName) } + .map { it.second } + // Poll download status + .mergeWith(pollStatus(id)) + // Force an error if the download takes more than 3 minutes + .mergeWith(Observable.timer(3, TimeUnit.MINUTES).map { InstallStep.Error }) + // Stop when the application is installed or errors + .takeUntil { it.isCompleted() } + // Always notify on main thread + .observeOn(AndroidSchedulers.mainThread()) + // Always remove the download when unsubscribed + .doOnUnsubscribe { deleteDownload(pkgName) } } /** @@ -95,25 +97,25 @@ internal class ExtensionInstaller(private val context: Context) { val query = DownloadManager.Query().setFilterById(id) return Observable.interval(0, 1, TimeUnit.SECONDS) - // Get the current download status - .map { - downloadManager.query(query).use { cursor -> - cursor.moveToFirst() - cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)) - } + // Get the current download status + .map { + downloadManager.query(query).use { cursor -> + cursor.moveToFirst() + cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)) } - // Ignore duplicate results - .distinctUntilChanged() - // Stop polling when the download fails or finishes - .takeUntil { it == DownloadManager.STATUS_SUCCESSFUL || it == DownloadManager.STATUS_FAILED } - // Map to our model - .flatMap { status -> - when (status) { - DownloadManager.STATUS_PENDING -> Observable.just(InstallStep.Pending) - DownloadManager.STATUS_RUNNING -> Observable.just(InstallStep.Downloading) - else -> Observable.empty() - } + } + // Ignore duplicate results + .distinctUntilChanged() + // Stop polling when the download fails or finishes + .takeUntil { it == DownloadManager.STATUS_SUCCESSFUL || it == DownloadManager.STATUS_FAILED } + // Map to our model + .flatMap { status -> + when (status) { + DownloadManager.STATUS_PENDING -> Observable.just(InstallStep.Pending) + DownloadManager.STATUS_RUNNING -> Observable.just(InstallStep.Downloading) + else -> Observable.empty() } + } } /** @@ -123,9 +125,9 @@ internal class ExtensionInstaller(private val context: Context) { */ fun installApk(downloadId: Long, uri: Uri) { val intent = Intent(context, ExtensionInstallActivity::class.java) - .setDataAndType(uri, APK_MIME) - .putExtra(EXTRA_DOWNLOAD_ID, downloadId) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) + .setDataAndType(uri, APK_MIME) + .putExtra(EXTRA_DOWNLOAD_ID, downloadId) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) context.startActivity(intent) } @@ -138,7 +140,7 @@ internal class ExtensionInstaller(private val context: Context) { fun uninstallApk(pkgName: String) { val packageUri = Uri.parse("package:$pkgName") val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, packageUri) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(intent) } @@ -159,7 +161,7 @@ internal class ExtensionInstaller(private val context: Context) { * * @param pkgName The package name of the download to delete. */ - fun deleteDownload(pkgName: String) { + private fun deleteDownload(pkgName: String) { val downloadId = activeDownloads.remove(pkgName) if (downloadId != null) { downloadManager.remove(downloadId) @@ -221,20 +223,15 @@ internal class ExtensionInstaller(private val context: Context) { return } - // Due to a bug in Android versions prior to N, the installer can't open files that do - // not contain the extension in the path, even if you specify the correct MIME. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { - val query = DownloadManager.Query().setFilterById(id) - downloadManager.query(query).use { cursor -> - if (cursor.moveToFirst()) { - @Suppress("DEPRECATION") - val uriCompat = File(cursor.getString(cursor.getColumnIndex( - DownloadManager.COLUMN_LOCAL_FILENAME))).getUriCompat(context) - installApk(id, uriCompat) - } + val query = DownloadManager.Query().setFilterById(id) + downloadManager.query(query).use { cursor -> + if (cursor.moveToFirst()) { + val localUri = cursor.getString( + cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI) + ).removePrefix(FILE_SCHEME) + + installApk(id, File(localUri).getUriCompat(context)) } - } else { - installApk(id, uri) } } } @@ -242,6 +239,6 @@ internal class ExtensionInstaller(private val context: Context) { companion object { const val APK_MIME = "application/vnd.android.package-archive" const val EXTRA_DOWNLOAD_ID = "ExtensionInstaller.extra.DOWNLOAD_ID" + const val FILE_SCHEME = "file://" } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt index c4018f046..0ff9b6e61 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt @@ -6,13 +6,12 @@ import android.content.pm.PackageInfo import android.content.pm.PackageManager import dalvik.system.PathClassLoader import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.LoadResult import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceFactory -import eu.kanade.tachiyomi.util.Hash +import eu.kanade.tachiyomi.util.lang.Hash import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking import timber.log.Timber @@ -27,8 +26,8 @@ internal object ExtensionLoader { private const val EXTENSION_FEATURE = "tachiyomi.extension" private const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class" - private const val LIB_VERSION_MIN = 1 - private const val LIB_VERSION_MAX = 1 + const val LIB_VERSION_MIN = 1.2 + const val LIB_VERSION_MAX = 1.2 private const val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES @@ -36,9 +35,9 @@ internal object ExtensionLoader { * List of the trusted signatures. */ var trustedSignatures = mutableSetOf() + - Injekt.get().trustedSignatures().getOrDefault() + - // inorichi's key - "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23" + Injekt.get().trustedSignatures().get() + + // inorichi's key + "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23" /** * Return a list of all the installed extensions initialized concurrently. @@ -95,16 +94,23 @@ internal object ExtensionLoader { return LoadResult.Error(error) } - val extName = pkgManager.getApplicationLabel(appInfo)?.toString() - .orEmpty().substringAfter("Tachiyomi: ") + val extName = pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Tachiyomi: ") val versionName = pkgInfo.versionName val versionCode = pkgInfo.versionCode + if (versionName.isNullOrEmpty()) { + val exception = Exception("Missing versionName for extension $extName") + Timber.w(exception) + return LoadResult.Error(exception) + } + // Validate lib version - val majorLibVersion = versionName.substringBefore('.').toInt() - if (majorLibVersion < LIB_VERSION_MIN || majorLibVersion > LIB_VERSION_MAX) { - val exception = Exception("Lib version is $majorLibVersion, while only versions " + - "$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed") + val libVersion = versionName.substringBeforeLast('.').toDouble() + if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) { + val exception = Exception( + "Lib version is $libVersion, while only versions " + + "$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed" + ) Timber.w(exception) return LoadResult.Error(exception) } @@ -122,30 +128,30 @@ internal object ExtensionLoader { val classLoader = PathClassLoader(appInfo.sourceDir, null, context.classLoader) val sources = appInfo.metaData.getString(METADATA_SOURCE_CLASS)!! - .split(";") - .map { - val sourceClass = it.trim() - if (sourceClass.startsWith(".")) - pkgInfo.packageName + sourceClass - else - sourceClass + .split(";") + .map { + val sourceClass = it.trim() + if (sourceClass.startsWith(".")) { + pkgInfo.packageName + sourceClass + } else { + sourceClass } - .flatMap { - try { - val obj = Class.forName(it, false, classLoader).newInstance() - when (obj) { - is Source -> listOf(obj) - is SourceFactory -> obj.createSources() - else -> throw Exception("Unknown source class type! ${obj.javaClass}") - } - } catch (e: Throwable) { - Timber.e(e, "Extension load error: $extName.") - return LoadResult.Error(e) + } + .flatMap { + try { + when (val obj = Class.forName(it, false, classLoader).newInstance()) { + is Source -> listOf(obj) + is SourceFactory -> obj.createSources() + else -> throw Exception("Unknown source class type! ${obj.javaClass}") } + } catch (e: Throwable) { + Timber.e(e, "Extension load error: $extName.") + return LoadResult.Error(e) } + } val langs = sources.filterIsInstance() - .map { it.lang } - .toSet() + .map { it.lang } + .toSet() val lang = when (langs.size) { 0 -> "" @@ -173,11 +179,10 @@ internal object ExtensionLoader { */ private fun getSignatureHash(pkgInfo: PackageInfo): String? { val signatures = pkgInfo.signatures - return if (signatures != null && !signatures.isEmpty()) { + return if (signatures != null && signatures.isNotEmpty()) { Hash.sha256(signatures.first().toByteArray()) } else { null } } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/AndroidCookieJar.kt b/app/src/main/java/eu/kanade/tachiyomi/network/AndroidCookieJar.kt index ff231ed42..c93b217ee 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/AndroidCookieJar.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/AndroidCookieJar.kt @@ -1,35 +1,20 @@ package eu.kanade.tachiyomi.network -import android.content.Context -import android.os.Build import android.webkit.CookieManager -import android.webkit.CookieSyncManager import okhttp3.Cookie import okhttp3.CookieJar import okhttp3.HttpUrl -class AndroidCookieJar(context: Context) : CookieJar { +class AndroidCookieJar : CookieJar { private val manager = CookieManager.getInstance() - private val syncManager by lazy { CookieSyncManager.createInstance(context) } - - init { - // Init sync manager when using anything below L - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - syncManager - } - } - override fun saveFromResponse(url: HttpUrl, cookies: List) { val urlString = url.toString() for (cookie in cookies) { manager.setCookie(urlString, cookie.toString()) } - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - syncManager.sync() - } } override fun loadForRequest(url: HttpUrl): List { @@ -39,33 +24,32 @@ class AndroidCookieJar(context: Context) : CookieJar { fun get(url: HttpUrl): List { val cookies = manager.getCookie(url.toString()) - return if (cookies != null && !cookies.isEmpty()) { + return if (cookies != null && cookies.isNotEmpty()) { cookies.split(";").mapNotNull { Cookie.parse(url, it) } } else { emptyList() } } - fun remove(url: HttpUrl) { + fun remove(url: HttpUrl, cookieNames: List? = null, maxAge: Int = -1) { val urlString = url.toString() val cookies = manager.getCookie(urlString) ?: return + fun List.filterNames(): List { + return if (cookieNames != null) { + this.filter { it in cookieNames } + } else { + this + } + } + cookies.split(";") .map { it.substringBefore("=") } - .onEach { manager.setCookie(urlString, "$it=;Max-Age=-1") } - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - syncManager.sync() - } + .filterNames() + .onEach { manager.setCookie(urlString, "$it=;Max-Age=$maxAge") } } fun removeAll() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - manager.removeAllCookies {} - } else { - manager.removeAllCookie() - syncManager.sync() - } + manager.removeAllCookies {} } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/CloudflareInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/network/CloudflareInterceptor.kt index 18051f680..fcfaacd71 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/network/CloudflareInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/CloudflareInterceptor.kt @@ -5,103 +5,104 @@ import android.content.Context import android.os.Build import android.os.Handler import android.os.Looper -import android.webkit.WebResourceResponse import android.webkit.WebSettings import android.webkit.WebView -import eu.kanade.tachiyomi.util.WebViewClientCompat -import okhttp3.Interceptor -import okhttp3.Request -import okhttp3.Response +import android.widget.Toast +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.util.system.WebViewClientCompat +import eu.kanade.tachiyomi.util.system.isOutdated +import eu.kanade.tachiyomi.util.system.toast import java.io.IOException import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import okhttp3.Cookie +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import uy.kohesive.injekt.injectLazy class CloudflareInterceptor(private val context: Context) : Interceptor { - private val serverCheck = arrayOf("cloudflare-nginx", "cloudflare") - private val handler = Handler(Looper.getMainLooper()) + private val networkHelper: NetworkHelper by injectLazy() + /** * When this is called, it initializes the WebView if it wasn't already. We use this to avoid * blocking the main thread too much. If used too often we could consider moving it to the * Application class. */ private val initWebView by lazy { - if (Build.VERSION.SDK_INT >= 17) { - WebSettings.getDefaultUserAgent(context) - } else { - null - } + WebSettings.getDefaultUserAgent(context) } @Synchronized override fun intercept(chain: Interceptor.Chain): Response { initWebView - val response = chain.proceed(chain.request()) + val originalRequest = chain.request() + val response = chain.proceed(originalRequest) // Check if Cloudflare anti-bot is on - if (response.code == 503 && response.header("Server") in serverCheck) { - try { - response.close() - val solutionRequest = resolveWithWebView(chain.request()) - return chain.proceed(solutionRequest) - } catch (e: Exception) { - // Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that - // we don't crash the entire app - throw IOException(e) - } + if (response.code != 503 || response.header("Server") !in SERVER_CHECK) { + return response } - return response - } + try { + response.close() + networkHelper.cookieManager.remove(originalRequest.url, COOKIE_NAMES, 0) + val oldCookie = networkHelper.cookieManager.get(originalRequest.url) + .firstOrNull { it.name == "cf_clearance" } + resolveWithWebView(originalRequest, oldCookie) - private fun isChallengeSolutionUrl(url: String): Boolean { - return "chk_jschl" in url + return chain.proceed(originalRequest) + } catch (e: Exception) { + // Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that + // we don't crash the entire app + throw IOException(e) + } } @SuppressLint("SetJavaScriptEnabled") - private fun resolveWithWebView(request: Request): Request { + private fun resolveWithWebView(request: Request, oldCookie: Cookie?) { // We need to lock this thread until the WebView finds the challenge solution url, because // OkHttp doesn't support asynchronous interceptors. val latch = CountDownLatch(1) var webView: WebView? = null - var solutionUrl: String? = null + var challengeFound = false + var cloudflareBypassed = false + var isWebViewOutdated = false val origRequestUrl = request.url.toString() val headers = request.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" } handler.post { - val view = WebView(context) - webView = view - view.settings.javaScriptEnabled = true - view.settings.userAgentString = request.header("User-Agent") - view.webViewClient = object : WebViewClientCompat() { + val webview = WebView(context) + webView = webview + webview.settings.javaScriptEnabled = true - override fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean { - if (isChallengeSolutionUrl(url)) { - solutionUrl = url + // Avoid set empty User-Agent, Chromium WebView will reset to default if empty + webview.settings.userAgentString = request.header("User-Agent") + ?: HttpSource.DEFAULT_USERAGENT + + webview.webViewClient = object : WebViewClientCompat() { + override fun onPageFinished(view: WebView, url: String) { + fun isCloudFlareBypassed(): Boolean { + return networkHelper.cookieManager.get(origRequestUrl.toHttpUrl()) + .firstOrNull { it.name == "cf_clearance" } + .let { it != null && it != oldCookie } + } + + if (isCloudFlareBypassed()) { + cloudflareBypassed = true latch.countDown() } - return solutionUrl != null - } - override fun shouldInterceptRequestCompat( - view: WebView, - url: String - ): WebResourceResponse? { - if (solutionUrl != null) { - // Intercept any request when we have the solution. - return WebResourceResponse("text/plain", "UTF-8", null) - } - return null - } - - override fun onPageFinished(view: WebView, url: String) { - // Http error codes are only received since M + // HTTP error codes are only received since M if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && url == origRequestUrl && !challengeFound ) { @@ -111,11 +112,11 @@ class CloudflareInterceptor(private val context: Context) : Interceptor { } override fun onReceivedErrorCompat( - view: WebView, - errorCode: Int, - description: String?, - failingUrl: String, - isMainFrame: Boolean + view: WebView, + errorCode: Int, + description: String?, + failingUrl: String, + isMainFrame: Boolean ) { if (isMainFrame) { if (errorCode == 503) { @@ -128,6 +129,7 @@ class CloudflareInterceptor(private val context: Context) : Interceptor { } } } + webView?.loadUrl(origRequestUrl, headers) } @@ -136,19 +138,27 @@ class CloudflareInterceptor(private val context: Context) : Interceptor { latch.await(12, TimeUnit.SECONDS) handler.post { + if (!cloudflareBypassed) { + isWebViewOutdated = webView?.isOutdated() == true + } + webView?.stopLoading() webView?.destroy() } - val solution = solutionUrl ?: throw Exception("Challenge not found") + // Throw exception if we failed to bypass Cloudflare + if (!cloudflareBypassed) { + // Prompt user to update WebView if it seems too outdated + if (isWebViewOutdated) { + context.toast(R.string.information_webview_outdated, Toast.LENGTH_LONG) + } - return Request.Builder().get() - .url(solution) - .headers(request.headers) - .addHeader("Referer", origRequestUrl) - .addHeader("Accept", "text/html,application/xhtml+xml,application/xml") - .addHeader("Accept-Language", "en") - .build() + throw Exception(context.getString(R.string.information_cloudflare_bypass_failure)) + } } + companion object { + private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare") + private val COOKIE_NAMES = listOf("__cfduid", "cf_clearance") + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt index f67c7e840..49455ef78 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt @@ -1,120 +1,28 @@ package eu.kanade.tachiyomi.network import android.content.Context -import android.os.Build -import exh.log.maybeInjectEHLogger -import okhttp3.* import java.io.File -import java.io.IOException -import java.net.InetAddress -import java.net.Socket -import java.net.UnknownHostException -import java.security.KeyManagementException -import java.security.KeyStore -import java.security.NoSuchAlgorithmException -import javax.net.ssl.* +import java.util.concurrent.TimeUnit +import okhttp3.Cache +import okhttp3.OkHttpClient -open class NetworkHelper(context: Context) { +class NetworkHelper(context: Context) { private val cacheDir = File(context.cacheDir, "network_cache") private val cacheSize = 5L * 1024 * 1024 // 5 MiB - open val cookieManager = AndroidCookieJar(context) + val cookieManager = AndroidCookieJar() - open val client = OkHttpClient.Builder() - .cookieJar(cookieManager) - .cache(Cache(cacheDir, cacheSize)) - .enableTLS12() - .maybeInjectEHLogger() - .build() + val client = OkHttpClient.Builder() + .cookieJar(cookieManager) + .cache(Cache(cacheDir, cacheSize)) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() - open val cloudflareClient = client.newBuilder() - .addInterceptor(CloudflareInterceptor(context)) - .maybeInjectEHLogger() - .build() - - private fun OkHttpClient.Builder.enableTLS12(): OkHttpClient.Builder { - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) { - return this - } - - val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) - trustManagerFactory.init(null as KeyStore?) - val trustManagers = trustManagerFactory.trustManagers - if (trustManagers.size == 1 && trustManagers[0] is X509TrustManager) { - class TLSSocketFactory @Throws(KeyManagementException::class, NoSuchAlgorithmException::class) - constructor() : SSLSocketFactory() { - - private val internalSSLSocketFactory: SSLSocketFactory - - init { - val context = SSLContext.getInstance("TLS") - context.init(null, null, null) - internalSSLSocketFactory = context.socketFactory - } - - override fun getDefaultCipherSuites(): Array { - return internalSSLSocketFactory.defaultCipherSuites - } - - override fun getSupportedCipherSuites(): Array { - return internalSSLSocketFactory.supportedCipherSuites - } - - @Throws(IOException::class) - override fun createSocket(): Socket? { - return enableTLSOnSocket(internalSSLSocketFactory.createSocket()) - } - - @Throws(IOException::class) - override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket? { - return enableTLSOnSocket(internalSSLSocketFactory.createSocket(s, host, port, autoClose)) - } - - @Throws(IOException::class, UnknownHostException::class) - override fun createSocket(host: String, port: Int): Socket? { - return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port)) - } - - @Throws(IOException::class, UnknownHostException::class) - override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket? { - return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port, localHost, localPort)) - } - - @Throws(IOException::class) - override fun createSocket(host: InetAddress, port: Int): Socket? { - return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port)) - } - - @Throws(IOException::class) - override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket? { - return enableTLSOnSocket(internalSSLSocketFactory.createSocket(address, port, localAddress, localPort)) - } - - private fun enableTLSOnSocket(socket: Socket?): Socket? { - if (socket != null && socket is SSLSocket) { - socket.enabledProtocols = socket.supportedProtocols - } - return socket - } - } - - sslSocketFactory(TLSSocketFactory(), trustManagers[0] as X509TrustManager) - } - - val specCompat = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) - .tlsVersions(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0) - .cipherSuites( - *ConnectionSpec.MODERN_TLS.cipherSuites.orEmpty().toTypedArray(), - CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, - CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA - ) - .build() - - val specs = listOf(specCompat, ConnectionSpec.CLEARTEXT) - connectionSpecs(specs) - - return this - } + val cloudflareClient = client.newBuilder() + .addInterceptor(UserAgentInterceptor()) + .addInterceptor(CloudflareInterceptor(context)) + .build() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt index 2d103a8f8..8d86629cf 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt @@ -1,48 +1,44 @@ package eu.kanade.tachiyomi.network -import exh.util.withRootCause +import java.io.IOException +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine import okhttp3.Call +import okhttp3.Callback import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import rx.Observable import rx.Producer import rx.Subscription -import java.util.concurrent.atomic.AtomicBoolean - -fun Call.asObservableWithAsyncStacktrace(): Observable> { - // Record stacktrace at creation time for easier debugging - // asObservable is involved in a lot of crashes so this is worth the performance hit - val asyncStackTrace = Exception("Async stacktrace") +fun Call.asObservable(): Observable { return Observable.unsafeCreate { subscriber -> // Since Call is a one-shot type, clone it for each new subscriber. val call = clone() // Wrap the call in a helper which handles both unsubscription and backpressure. val requestArbiter = object : AtomicBoolean(), Producer, Subscription { - val executed = AtomicBoolean(false) - override fun request(n: Long) { if (n == 0L || !compareAndSet(false, true)) return try { val response = call.execute() - executed.set(true) if (!subscriber.isUnsubscribed) { - subscriber.onNext(asyncStackTrace to response) + subscriber.onNext(response) subscriber.onCompleted() } - } catch (error: Throwable) { + } catch (error: Exception) { if (!subscriber.isUnsubscribed) { - subscriber.onError(error.withRootCause(asyncStackTrace)) + subscriber.onError(error) } } } override fun unsubscribe() { - if(!executed.get()) - call.cancel() + call.cancel() } override fun isUnsubscribed(): Boolean { @@ -55,27 +51,55 @@ fun Call.asObservableWithAsyncStacktrace(): Observable } } -fun Call.asObservable() = asObservableWithAsyncStacktrace().map { it.second } +// Based on https://github.com/gildor/kotlin-coroutines-okhttp +suspend fun Call.await(assertSuccess: Boolean = false): Response { + return suspendCancellableCoroutine { continuation -> + enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + if (assertSuccess && !response.isSuccessful) { + continuation.resumeWithException(Exception("HTTP error ${response.code}")) + return + } + + continuation.resume(response) + } + + override fun onFailure(call: Call, e: IOException) { + // Don't bother with resuming the continuation if it is already cancelled. + if (continuation.isCancelled) return + continuation.resumeWithException(e) + } + }) + + continuation.invokeOnCancellation { + try { + cancel() + } catch (ex: Throwable) { + // Ignore cancel exception + } + } + } +} fun Call.asObservableSuccess(): Observable { - return asObservableWithAsyncStacktrace().map { (asyncStacktrace, response) -> + return asObservable().doOnNext { response -> if (!response.isSuccessful) { response.close() - throw Exception("HTTP error ${response.code}", asyncStacktrace) - } else response + throw Exception("HTTP error ${response.code}") + } } } fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call { val progressClient = newBuilder() - .cache(null) - .addNetworkInterceptor { chain -> - val originalResponse = chain.proceed(chain.request()) - originalResponse.newBuilder() - .body(ProgressResponseBody(originalResponse.body!!, listener)) - .build() - } - .build() + .cache(null) + .addNetworkInterceptor { chain -> + val originalResponse = chain.proceed(chain.request()) + originalResponse.newBuilder() + .body(ProgressResponseBody(originalResponse.body!!, listener)) + .build() + } + .build() return progressClient.newCall(request) -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/ProgressListener.kt b/app/src/main/java/eu/kanade/tachiyomi/network/ProgressListener.kt index 113f99763..2e219895f 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/network/ProgressListener.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/ProgressListener.kt @@ -1,5 +1,5 @@ -package eu.kanade.tachiyomi.network - -interface ProgressListener { - fun update(bytesRead: Long, contentLength: Long, done: Boolean) -} \ No newline at end of file +package eu.kanade.tachiyomi.network + +interface ProgressListener { + fun update(bytesRead: Long, contentLength: Long, done: Boolean) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/ProgressResponseBody.kt b/app/src/main/java/eu/kanade/tachiyomi/network/ProgressResponseBody.kt index a566a5d90..53799e9ca 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/network/ProgressResponseBody.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/ProgressResponseBody.kt @@ -1,9 +1,13 @@ package eu.kanade.tachiyomi.network +import java.io.IOException import okhttp3.MediaType import okhttp3.ResponseBody -import okio.* -import java.io.IOException +import okio.Buffer +import okio.BufferedSource +import okio.ForwardingSource +import okio.Source +import okio.buffer class ProgressResponseBody(private val responseBody: ResponseBody, private val progressListener: ProgressListener) : ResponseBody() { @@ -25,7 +29,7 @@ class ProgressResponseBody(private val responseBody: ResponseBody, private val p private fun source(source: Source): Source { return object : ForwardingSource(source) { - internal var totalBytesRead = 0L + var totalBytesRead = 0L @Throws(IOException::class) override fun read(sink: Buffer, byteCount: Long): Long { @@ -37,4 +41,4 @@ class ProgressResponseBody(private val responseBody: ResponseBody, private val p } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt b/app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt index 3b89d0d88..8fc2b9a6f 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt @@ -1,32 +1,38 @@ -package eu.kanade.tachiyomi.network - -import okhttp3.* -import java.util.concurrent.TimeUnit.MINUTES - -private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, MINUTES).build() -private val DEFAULT_HEADERS = Headers.Builder().build() -private val DEFAULT_BODY: RequestBody = FormBody.Builder().build() - -fun GET(url: String, - headers: Headers = DEFAULT_HEADERS, - cache: CacheControl = DEFAULT_CACHE_CONTROL): Request { - - return Request.Builder() - .url(url) - .headers(headers) - .cacheControl(cache) - .build() -} - -fun POST(url: String, - headers: Headers = DEFAULT_HEADERS, - body: RequestBody = DEFAULT_BODY, - cache: CacheControl = DEFAULT_CACHE_CONTROL): Request { - - return Request.Builder() - .url(url) - .post(body) - .headers(headers) - .cacheControl(cache) - .build() -} +package eu.kanade.tachiyomi.network + +import java.util.concurrent.TimeUnit.MINUTES +import okhttp3.CacheControl +import okhttp3.FormBody +import okhttp3.Headers +import okhttp3.Request +import okhttp3.RequestBody + +private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, MINUTES).build() +private val DEFAULT_HEADERS = Headers.Builder().build() +private val DEFAULT_BODY: RequestBody = FormBody.Builder().build() + +fun GET( + url: String, + headers: Headers = DEFAULT_HEADERS, + cache: CacheControl = DEFAULT_CACHE_CONTROL +): Request { + return Request.Builder() + .url(url) + .headers(headers) + .cacheControl(cache) + .build() +} + +fun POST( + url: String, + headers: Headers = DEFAULT_HEADERS, + body: RequestBody = DEFAULT_BODY, + cache: CacheControl = DEFAULT_CACHE_CONTROL +): Request { + return Request.Builder() + .url(url) + .post(body) + .headers(headers) + .cacheControl(cache) + .build() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/UserAgentInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/network/UserAgentInterceptor.kt new file mode 100644 index 000000000..a5aa3e4f5 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/network/UserAgentInterceptor.kt @@ -0,0 +1,22 @@ +package eu.kanade.tachiyomi.network + +import eu.kanade.tachiyomi.source.online.HttpSource +import okhttp3.Interceptor +import okhttp3.Response + +class UserAgentInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + return if (originalRequest.header("User-Agent").isNullOrEmpty()) { + val newRequest = originalRequest + .newBuilder() + .removeHeader("User-Agent") + .addHeader("User-Agent", HttpSource.DEFAULT_USERAGENT) + .build() + chain.proceed(newRequest) + } else { + chain.proceed(originalRequest) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/CatalogueSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/CatalogueSource.kt index f8d0ea464..c78033ea6 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/CatalogueSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/CatalogueSource.kt @@ -1,46 +1,46 @@ -package eu.kanade.tachiyomi.source - -import eu.kanade.tachiyomi.source.model.FilterList -import eu.kanade.tachiyomi.source.model.MangasPage -import rx.Observable - -interface CatalogueSource : Source { - - /** - * An ISO 639-1 compliant language code (two letters in lower case). - */ - val lang: String - - /** - * Whether the source has support for latest updates. - */ - val supportsLatest: Boolean - - /** - * Returns an observable containing a page with a list of manga. - * - * @param page the page number to retrieve. - */ - fun fetchPopularManga(page: Int): Observable - - /** - * Returns an observable containing a page with a list of manga. - * - * @param page the page number to retrieve. - * @param query the search query. - * @param filters the list of filters to apply. - */ - fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable - - /** - * Returns an observable containing a page with a list of latest manga updates. - * - * @param page the page number to retrieve. - */ - fun fetchLatestUpdates(page: Int): Observable - - /** - * Returns the list of filters for the source. - */ - fun getFilterList(): FilterList -} \ No newline at end of file +package eu.kanade.tachiyomi.source + +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.MangasPage +import rx.Observable + +interface CatalogueSource : Source { + + /** + * An ISO 639-1 compliant language code (two letters in lower case). + */ + val lang: String + + /** + * Whether the source has support for latest updates. + */ + val supportsLatest: Boolean + + /** + * Returns an observable containing a page with a list of manga. + * + * @param page the page number to retrieve. + */ + fun fetchPopularManga(page: Int): Observable + + /** + * Returns an observable containing a page with a list of manga. + * + * @param page the page number to retrieve. + * @param query the search query. + * @param filters the list of filters to apply. + */ + fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable + + /** + * Returns an observable containing a page with a list of latest manga updates. + * + * @param page the page number to retrieve. + */ + fun fetchLatestUpdates(page: Int): Observable + + /** + * Returns the list of filters for the source. + */ + fun getFilterList(): FilterList +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt index 1630e723d..daa1d3ac0 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt @@ -1,6 +1,8 @@ package eu.kanade.tachiyomi.source import android.content.Context +import com.google.gson.Gson +import com.google.gson.JsonObject import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.FilterList @@ -8,30 +10,33 @@ import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga -import eu.kanade.tachiyomi.util.ChapterRecognition -import eu.kanade.tachiyomi.util.ComparatorUtil.CaseInsensitiveNaturalComparator -import eu.kanade.tachiyomi.util.DiskUtil -import eu.kanade.tachiyomi.util.EpubFile -import eu.kanade.tachiyomi.util.ImageUtil -import junrar.Archive -import junrar.rarfile.FileHeader -import rx.Observable -import timber.log.Timber +import eu.kanade.tachiyomi.util.chapter.ChapterRecognition +import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder +import eu.kanade.tachiyomi.util.storage.DiskUtil +import eu.kanade.tachiyomi.util.storage.EpubFile +import eu.kanade.tachiyomi.util.system.ImageUtil import java.io.File import java.io.FileInputStream import java.io.InputStream import java.util.Locale +import java.util.Scanner import java.util.concurrent.TimeUnit import java.util.zip.ZipEntry import java.util.zip.ZipFile +import junrar.Archive +import junrar.rarfile.FileHeader +import rx.Observable +import timber.log.Timber class LocalSource(private val context: Context) : CatalogueSource { companion object { - private val COVER_NAME = "cover.jpg" + const val HELP_URL = "https://tachiyomi.org/help/guides/reading-local-manga/" + + private const val COVER_NAME = "cover.jpg" private val POPULAR_FILTERS = FilterList(OrderBy()) private val LATEST_FILTERS = FilterList(OrderBy().apply { state = Filter.Sort.Selection(1, false) }) private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS) - val ID = 0L + const val ID = 0L fun updateCover(context: Context, manga: SManga, input: InputStream): File? { val dir = getBaseDirectories(context).firstOrNull() @@ -71,23 +76,25 @@ class LocalSource(private val context: Context) : CatalogueSource { val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L var mangaDirs = baseDirs.mapNotNull { it.listFiles()?.toList() } - .flatten() - .filter { it.isDirectory && if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time } - .distinctBy { it.name } + .flatten() + .filter { it.isDirectory && if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time } + .distinctBy { it.name } val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state when (state?.index) { 0 -> { - if (state.ascending) - mangaDirs = mangaDirs.sortedBy { it.name.toLowerCase(Locale.ENGLISH) } - else - mangaDirs = mangaDirs.sortedByDescending { it.name.toLowerCase(Locale.ENGLISH) } + mangaDirs = if (state.ascending) { + mangaDirs.sortedBy { it.name.toLowerCase(Locale.ENGLISH) } + } else { + mangaDirs.sortedByDescending { it.name.toLowerCase(Locale.ENGLISH) } + } } 1 -> { - if (state.ascending) - mangaDirs = mangaDirs.sortedBy(File::lastModified) - else - mangaDirs = mangaDirs.sortedByDescending(File::lastModified) + mangaDirs = if (state.ascending) { + mangaDirs.sortedBy(File::lastModified) + } else { + mangaDirs.sortedByDescending(File::lastModified) + } } } @@ -105,20 +112,26 @@ class LocalSource(private val context: Context) : CatalogueSource { } } - // Copy the cover from the first chapter found. - if (thumbnail_url == null) { - val chapters = fetchChapterList(this).toBlocking().first() - if (chapters.isNotEmpty()) { + val chapters = fetchChapterList(this).toBlocking().first() + if (chapters.isNotEmpty()) { + val chapter = chapters.last() + val format = getFormat(chapter) + if (format is Format.Epub) { + EpubFile(format.file).use { epub -> + epub.fillMangaMetadata(this) + } + } + + // Copy the cover from the first chapter found. + if (thumbnail_url == null) { try { - val dest = updateCover(chapters.last(), this) + val dest = updateCover(chapter, this) thumbnail_url = dest?.absolutePath } catch (e: Exception) { Timber.e(e) } } } - - initialized = true } } return Observable.just(MangasPage(mangas, false)) @@ -126,35 +139,97 @@ class LocalSource(private val context: Context) : CatalogueSource { override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS) - override fun fetchMangaDetails(manga: SManga) = Observable.just(manga) + override fun fetchMangaDetails(manga: SManga): Observable { + getBaseDirectories(context) + .mapNotNull { File(it, manga.url).listFiles()?.toList() } + .flatten() + .firstOrNull { it.extension == "json" } + ?.apply { + val json = Gson().fromJson(Scanner(this).useDelimiter("\\Z").next(), JsonObject::class.java) + manga.title = json["title"]?.asString ?: manga.title + manga.author = json["author"]?.asString ?: manga.author + manga.artist = json["artist"]?.asString ?: manga.artist + manga.description = json["description"]?.asString ?: manga.description + manga.genre = json["genre"]?.asJsonArray?.joinToString(", ") { it.asString } + ?: manga.genre + manga.status = json["status"]?.asInt ?: manga.status + } + return Observable.just(manga) + } override fun fetchChapterList(manga: SManga): Observable> { val chapters = getBaseDirectories(context) - .mapNotNull { File(it, manga.url).listFiles()?.toList() } - .flatten() - .filter { it.isDirectory || isSupportedFile(it.extension) } - .map { chapterFile -> - SChapter.create().apply { - url = "${manga.url}/${chapterFile.name}" - val chapName = if (chapterFile.isDirectory) { - chapterFile.name - } else { - chapterFile.nameWithoutExtension - } - val chapNameCut = chapName.replace(manga.title, "", true).trim(' ', '-', '_') - name = if (chapNameCut.isEmpty()) chapName else chapNameCut - date_upload = chapterFile.lastModified() - ChapterRecognition.parseChapterNumber(this, manga) + .asSequence() + .mapNotNull { File(it, manga.url).listFiles()?.toList() } + .flatten() + .filter { it.isDirectory || isSupportedFile(it.extension) } + .map { chapterFile -> + SChapter.create().apply { + url = "${manga.url}/${chapterFile.name}" + name = if (chapterFile.isDirectory) { + chapterFile.name + } else { + chapterFile.nameWithoutExtension } + date_upload = chapterFile.lastModified() + + val format = getFormat(this) + if (format is Format.Epub) { + EpubFile(format.file).use { epub -> + epub.fillChapterMetadata(this) + } + } + + val chapNameCut = stripMangaTitle(name, manga.title) + if (chapNameCut.isNotEmpty()) name = chapNameCut + ChapterRecognition.parseChapterNumber(this, manga) } - .sortedWith(Comparator { c1, c2 -> + } + .sortedWith( + Comparator { c1, c2 -> val c = c2.chapter_number.compareTo(c1.chapter_number) - if (c == 0) CaseInsensitiveNaturalComparator.compare(c2.name, c1.name) else c - }) + if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c + } + ) + .toList() return Observable.just(chapters) } + /** + * Strips the manga title from a chapter name, matching only based on alphanumeric and whitespace + * characters. + */ + private fun stripMangaTitle(chapterName: String, mangaTitle: String): String { + var chapterNameIndex = 0 + var mangaTitleIndex = 0 + while (chapterNameIndex < chapterName.length && mangaTitleIndex < mangaTitle.length) { + val chapterChar = chapterName.get(chapterNameIndex) + val mangaChar = mangaTitle.get(mangaTitleIndex) + if (!chapterChar.equals(mangaChar, true)) { + val invalidChapterChar = !chapterChar.isLetterOrDigit() && !chapterChar.isWhitespace() + val invalidMangaChar = !mangaChar.isLetterOrDigit() && !mangaChar.isWhitespace() + + if (!invalidChapterChar && !invalidMangaChar) { + return chapterName + } + + if (invalidChapterChar) { + chapterNameIndex++ + } + + if (invalidMangaChar) { + mangaTitleIndex++ + } + } else { + chapterNameIndex++ + mangaTitleIndex++ + } + } + + return chapterName.substring(chapterNameIndex).trimStart(' ', '-', '_', ',', ':') + } + override fun fetchPageList(chapter: SChapter): Observable> { return Observable.error(Exception("Unused")) } @@ -191,31 +266,30 @@ class LocalSource(private val context: Context) : CatalogueSource { } private fun updateCover(chapter: SChapter, manga: SManga): File? { - val format = getFormat(chapter) - return when (format) { + return when (val format = getFormat(chapter)) { is Format.Directory -> { val entry = format.file.listFiles() - .sortedWith(Comparator { f1, f2 -> CaseInsensitiveNaturalComparator.compare(f1.name, f2.name) }) + .sortedWith(Comparator { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }) .find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } } - entry?.let { updateCover(context, manga, it.inputStream())} + entry?.let { updateCover(context, manga, it.inputStream()) } } is Format.Zip -> { ZipFile(format.file).use { zip -> val entry = zip.entries().toList() - .sortedWith(Comparator { f1, f2 -> CaseInsensitiveNaturalComparator.compare(f1.name, f2.name) }) + .sortedWith(Comparator { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }) .find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } } - entry?.let { updateCover(context, manga, zip.getInputStream(it) )} + entry?.let { updateCover(context, manga, zip.getInputStream(it)) } } } is Format.Rar -> { Archive(format.file).use { archive -> val entry = archive.fileHeaders - .sortedWith(Comparator { f1, f2 -> CaseInsensitiveNaturalComparator.compare(f1.fileNameString, f2.fileNameString) }) + .sortedWith(Comparator { f1, f2 -> f1.fileNameString.compareToCaseInsensitiveNaturalOrder(f2.fileNameString) }) .find { !it.isDirectory && ImageUtil.isImage(it.fileNameString) { archive.getInputStream(it) } } - entry?.let { updateCover(context, manga, archive.getInputStream(it) )} + entry?.let { updateCover(context, manga, archive.getInputStream(it)) } } } is Format.Epub -> { @@ -230,15 +304,14 @@ class LocalSource(private val context: Context) : CatalogueSource { } } - private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Filter.Sort.Selection(0, true)) + private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Selection(0, true)) override fun getFilterList() = FilterList(OrderBy()) sealed class Format { data class Directory(val file: File) : Format() data class Zip(val file: File) : Format() - data class Rar(val file: File): Format() + data class Rar(val file: File) : Format() data class Epub(val file: File) : Format() } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt b/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt index 666621bb4..ca63ae041 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt @@ -1,44 +1,49 @@ -package eu.kanade.tachiyomi.source - -import eu.kanade.tachiyomi.source.model.Page -import eu.kanade.tachiyomi.source.model.SChapter -import eu.kanade.tachiyomi.source.model.SManga -import rx.Observable - -/** - * A basic interface for creating a source. It could be an online source, a local source, etc... - */ -interface Source { - - /** - * Id for the source. Must be unique. - */ - val id: Long - - /** - * Name of the source. - */ - val name: String - - /** - * Returns an observable with the updated details for a manga. - * - * @param manga the manga to update. - */ - fun fetchMangaDetails(manga: SManga): Observable - - /** - * Returns an observable with all the available chapters for a manga. - * - * @param manga the manga to update. - */ - fun fetchChapterList(manga: SManga): Observable> - - /** - * Returns an observable with the list of pages a chapter has. - * - * @param chapter the chapter. - */ - fun fetchPageList(chapter: SChapter): Observable> - -} \ No newline at end of file +package eu.kanade.tachiyomi.source + +import android.graphics.drawable.Drawable +import eu.kanade.tachiyomi.extension.ExtensionManager +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import rx.Observable +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +/** + * A basic interface for creating a source. It could be an online source, a local source, etc... + */ +interface Source { + + /** + * Id for the source. Must be unique. + */ + val id: Long + + /** + * Name of the source. + */ + val name: String + + /** + * Returns an observable with the updated details for a manga. + * + * @param manga the manga to update. + */ + fun fetchMangaDetails(manga: SManga): Observable + + /** + * Returns an observable with all the available chapters for a manga. + * + * @param manga the manga to update. + */ + fun fetchChapterList(manga: SManga): Observable> + + /** + * Returns an observable with the list of pages a chapter has. + * + * @param chapter the chapter. + */ + fun fetchPageList(chapter: SChapter): Observable> +} + +fun Source.icon(): Drawable? = Injekt.get().getAppIconForSource(this) diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt index f0364e127..ce71ea811 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt @@ -3,15 +3,22 @@ package eu.kanade.tachiyomi.source import android.content.Context import com.elvishew.xlog.XLog import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.source.online.HttpSource -import eu.kanade.tachiyomi.source.online.all.* -import eu.kanade.tachiyomi.source.online.english.* -import rx.Observable +import eu.kanade.tachiyomi.source.online.all.EHentai +import eu.kanade.tachiyomi.source.online.all.Hitomi +import eu.kanade.tachiyomi.source.online.all.MergedSource +import eu.kanade.tachiyomi.source.online.all.NHentai +import eu.kanade.tachiyomi.source.online.all.PervEden +import eu.kanade.tachiyomi.source.online.english.EightMuses +import eu.kanade.tachiyomi.source.online.english.HBrowse +import eu.kanade.tachiyomi.source.online.english.HentaiCafe +import eu.kanade.tachiyomi.source.online.english.Pururin +import eu.kanade.tachiyomi.source.online.english.Tsumino import exh.EH_SOURCE_ID import exh.EXH_SOURCE_ID import exh.PERV_EDEN_EN_SOURCE_ID @@ -20,8 +27,9 @@ import exh.metadata.metadata.PervEdenLang import exh.source.BlacklistedSources import exh.source.DelegatedHttpSource import exh.source.EnhancedHttpSource -import uy.kohesive.injekt.injectLazy import kotlin.reflect.KClass +import rx.Observable +import uy.kohesive.injekt.injectLazy open class SourceManager(private val context: Context) { @@ -34,14 +42,14 @@ open class SourceManager(private val context: Context) { init { createInternalSources().forEach { registerSource(it) } - //Recreate sources when they change + // Recreate sources when they change val prefEntries = arrayOf( - prefs.enableExhentai(), - prefs.imageQuality(), - prefs.useHentaiAtHome(), - prefs.useJapaneseTitle(), - prefs.ehSearchSize(), - prefs.thumbnailRows() + prefs.enableExhentai(), + prefs.imageQuality(), + prefs.useHentaiAtHome(), + prefs.useJapaneseTitle(), + prefs.ehSearchSize(), + prefs.thumbnailRows() ).map { it.asObservable() } Observable.merge(prefEntries).skip(prefEntries.size - 1).subscribe { @@ -73,15 +81,15 @@ open class SourceManager(private val context: Context) { // EXH --> val sourceQName = source::class.qualifiedName val delegate = DELEGATED_SOURCES[sourceQName] - val newSource = if(source is HttpSource && delegate != null) { + val newSource = if (source is HttpSource && delegate != null) { XLog.d("[EXH] Delegating source: %s -> %s!", sourceQName, delegate.newSourceClass.qualifiedName) EnhancedHttpSource( - source, - delegate.newSourceClass.constructors.find { it.parameters.size == 1 }!!.call(source) + source, + delegate.newSourceClass.constructors.find { it.parameters.size == 1 }!!.call(source) ) } else source - if(source.id in BlacklistedSources.BLACKLISTED_EXT_SOURCES) { + if (source.id in BlacklistedSources.BLACKLISTED_EXT_SOURCES) { XLog.d("[EXH] Removing blacklisted source: (id: %s, name: %s, lang: %s)!", source.id, source.name, (source as? CatalogueSource)?.lang) return } @@ -97,20 +105,19 @@ open class SourceManager(private val context: Context) { } private fun createInternalSources(): List = listOf( - LocalSource(context) + LocalSource(context) ) private fun createEHSources(): List { val exSrcs = mutableListOf( - EHentai(EH_SOURCE_ID, false, context) + EHentai(EH_SOURCE_ID, false, context) ) - if(prefs.enableExhentai().getOrDefault()) { + if (prefs.enableExhentai().getOrDefault()) { exSrcs += EHentai(EXH_SOURCE_ID, true, context) } exSrcs += PervEden(PERV_EDEN_EN_SOURCE_ID, PervEdenLang.en) exSrcs += PervEden(PERV_EDEN_IT_SOURCE_ID, PervEdenLang.it) exSrcs += NHentai(context) - exSrcs += Tsumino(context) exSrcs += Hitomi() exSrcs += EightMuses() exSrcs += HBrowse() @@ -145,23 +152,33 @@ open class SourceManager(private val context: Context) { companion object { val DELEGATED_SOURCES = listOf( - DelegatedSource( - "Hentai Cafe", - 260868874183818481, - "eu.kanade.tachiyomi.extension.all.foolslide.HentaiCafe", - HentaiCafe::class - ), - DelegatedSource( - "Pururin", - 2221515250486218861, - "eu.kanade.tachiyomi.extension.en.pururin.Pururin", - Pururin::class - ) + DelegatedSource( + "Hentai Cafe", + 260868874183818481, + "eu.kanade.tachiyomi.extension.all.foolslide.HentaiCafe", + HentaiCafe::class + ), + DelegatedSource( + "Pururin", + 2221515250486218861, + "eu.kanade.tachiyomi.extension.en.pururin.Pururin", + Pururin::class + ), + DelegatedSource( + "Tsumino", + 6707338697138388238, + "eu.kanade.tachiyomi.extension.en.tsumino.Tsumino", + Tsumino::class + ) ).associateBy { it.originalSourceQualifiedClassName } - data class DelegatedSource(val sourceName: String, - val sourceId: Long, - val originalSourceQualifiedClassName: String, - val newSourceClass: KClass) + data class DelegatedSource( + val sourceName: String, + val sourceId: Long, + val originalSourceQualifiedClassName: String, + val newSourceClass: KClass + ) } } + +class SourceNotFoundException(message: String, val id: Long) : Exception(message) diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/Filter.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/Filter.kt index e3ca0ce39..381c893a7 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/Filter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/Filter.kt @@ -1,44 +1,44 @@ -package eu.kanade.tachiyomi.source.model - -sealed class Filter(val name: String, var state: T) { - open class Header(name: String) : Filter(name, 0) - // --> EXH - // name = button text - open class HelpDialog(name: String, val dialogTitle: String = name, val markdown: String) : Filter(name, 0) - // <-- EXH - open class Separator(name: String = "") : Filter(name, 0) - abstract class Select(name: String, val values: Array, state: Int = 0) : Filter(name, state) - abstract class Text(name: String, state: String = "") : Filter(name, state) - abstract class CheckBox(name: String, state: Boolean = false) : Filter(name, state) - abstract class TriState(name: String, state: Int = STATE_IGNORE) : Filter(name, state) { - fun isIgnored() = state == STATE_IGNORE - fun isIncluded() = state == STATE_INCLUDE - fun isExcluded() = state == STATE_EXCLUDE - - companion object { - const val STATE_IGNORE = 0 - const val STATE_INCLUDE = 1 - const val STATE_EXCLUDE = 2 - } - } - abstract class Group(name: String, state: List): Filter>(name, state) - - abstract class Sort(name: String, val values: Array, state: Selection? = null) - : Filter(name, state) { - data class Selection(val index: Int, val ascending: Boolean) - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is Filter<*>) return false - - return name == other.name && state == other.state - } - - override fun hashCode(): Int { - var result = name.hashCode() - result = 31 * result + (state?.hashCode() ?: 0) - return result - } - -} \ No newline at end of file +package eu.kanade.tachiyomi.source.model + +sealed class Filter(val name: String, var state: T) { + open class Header(name: String) : Filter(name, 0) + // --> EXH + // name = button text + open class HelpDialog(name: String, val dialogTitle: String = name, val markdown: String) : Filter(name, 0) + // <-- EXH + open class Separator(name: String = "") : Filter(name, 0) + abstract class Select(name: String, val values: Array, state: Int = 0) : Filter(name, state) + abstract class Text(name: String, state: String = "") : Filter(name, state) + abstract class CheckBox(name: String, state: Boolean = false) : Filter(name, state) + abstract class TriState(name: String, state: Int = STATE_IGNORE) : Filter(name, state) { + fun isIgnored() = state == STATE_IGNORE + fun isIncluded() = state == STATE_INCLUDE + fun isExcluded() = state == STATE_EXCLUDE + + companion object { + const val STATE_IGNORE = 0 + const val STATE_INCLUDE = 1 + const val STATE_EXCLUDE = 2 + } + } + + abstract class Group(name: String, state: List) : Filter>(name, state) + + abstract class Sort(name: String, val values: Array, state: Selection? = null) : + Filter(name, state) { + data class Selection(val index: Int, val ascending: Boolean) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Filter<*>) return false + + return name == other.name && state == other.state + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + (state?.hashCode() ?: 0) + return result + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/FilterList.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/FilterList.kt index 36d8e144a..42b6bc74b 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/FilterList.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/FilterList.kt @@ -1,7 +1,6 @@ -package eu.kanade.tachiyomi.source.model - -data class FilterList(val list: List>) : List> by list { - - constructor(vararg fs: Filter<*>) : this(if (fs.isNotEmpty()) fs.asList() else emptyList()) - -} \ No newline at end of file +package eu.kanade.tachiyomi.source.model + +data class FilterList(val list: List>) : List> by list { + + constructor(vararg fs: Filter<*>) : this(if (fs.isNotEmpty()) fs.asList() else emptyList()) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/MangasPage.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/MangasPage.kt index e359619fb..a377c36ea 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/MangasPage.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/MangasPage.kt @@ -1,3 +1,3 @@ -package eu.kanade.tachiyomi.source.model - -data class MangasPage(val mangas: List, val hasNextPage: Boolean) \ No newline at end of file +package eu.kanade.tachiyomi.source.model + +data class MangasPage(val mangas: List, val hasNextPage: Boolean) diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt index 5d61ef4f4..22436bfe8 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt @@ -5,24 +5,37 @@ import eu.kanade.tachiyomi.network.ProgressListener import rx.subjects.Subject open class Page( - val index: Int, - var url: String = "", - var imageUrl: String? = null, - @Transient var uri: Uri? = null // Deprecated but can't be deleted due to extensions + val index: Int, + val url: String = "", + var imageUrl: String? = null, + @Transient var uri: Uri? = null // Deprecated but can't be deleted due to extensions ) : ProgressListener { val number: Int get() = index + 1 - @Transient @Volatile var status: Int = 0 + @Transient + @Volatile + var status: Int = 0 set(value) { field = value statusSubject?.onNext(value) + statusCallback?.invoke(this) } - @Transient @Volatile var progress: Int = 0 + @Transient + @Volatile + var progress: Int = 0 + set(value) { + field = value + statusCallback?.invoke(this) + } - @Transient private var statusSubject: Subject? = null + @Transient + private var statusSubject: Subject? = null + + @Transient + private var statusCallback: ((Page) -> Unit)? = null override fun update(bytesRead: Long, contentLength: Long, done: Boolean) { progress = if (contentLength > 0) { @@ -36,13 +49,15 @@ open class Page( this.statusSubject = subject } - companion object { + fun setStatusCallback(f: ((Page) -> Unit)?) { + statusCallback = f + } + companion object { const val QUEUE = 0 const val LOAD_PAGE = 1 const val DOWNLOAD_IMAGE = 2 const val READY = 3 const val ERROR = 4 } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapter.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapter.kt index 991d24d41..f53bbe8f0 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapter.kt @@ -1,31 +1,30 @@ -package eu.kanade.tachiyomi.source.model - -import java.io.Serializable - -interface SChapter : Serializable { - - var url: String - - var name: String - - var date_upload: Long - - var chapter_number: Float - - var scanlator: String? - - fun copyFrom(other: SChapter) { - name = other.name - url = other.url - date_upload = other.date_upload - chapter_number = other.chapter_number - scanlator = other.scanlator - } - - companion object { - fun create(): SChapter { - return SChapterImpl() - } - } - -} \ No newline at end of file +package eu.kanade.tachiyomi.source.model + +import java.io.Serializable + +interface SChapter : Serializable { + + var url: String + + var name: String + + var date_upload: Long + + var chapter_number: Float + + var scanlator: String? + + fun copyFrom(other: SChapter) { + name = other.name + url = other.url + date_upload = other.date_upload + chapter_number = other.chapter_number + scanlator = other.scanlator + } + + companion object { + fun create(): SChapter { + return SChapterImpl() + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapterImpl.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapterImpl.kt index cfc4c3999..4d5e43f1e 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapterImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapterImpl.kt @@ -1,15 +1,14 @@ -package eu.kanade.tachiyomi.source.model - -class SChapterImpl : SChapter { - - override lateinit var url: String - - override lateinit var name: String - - override var date_upload: Long = 0 - - override var chapter_number: Float = -1f - - override var scanlator: String? = null - -} \ No newline at end of file +package eu.kanade.tachiyomi.source.model + +class SChapterImpl : SChapter { + + override lateinit var url: String + + override lateinit var name: String + + override var date_upload: Long = 0 + + override var chapter_number: Float = -1f + + override var scanlator: String? = null +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt index a7f81264f..eed3ad1bf 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt @@ -1,63 +1,68 @@ -package eu.kanade.tachiyomi.source.model - -import java.io.Serializable - -interface SManga : Serializable { - - var url: String - - var title: String - - var artist: String? - - var author: String? - - var description: String? - - var genre: String? - - var status: Int - - var thumbnail_url: String? - - var initialized: Boolean - - fun copyFrom(other: SManga) { - // EXH --> - if(other.title.isNotBlank()) - title = other.title - // EXH <-- - - if (other.author != null) - author = other.author - - if (other.artist != null) - artist = other.artist - - if (other.description != null) - description = other.description - - if (other.genre != null) - genre = other.genre - - if (other.thumbnail_url != null) - thumbnail_url = other.thumbnail_url - - status = other.status - - if (!initialized) - initialized = other.initialized - } - - companion object { - const val UNKNOWN = 0 - const val ONGOING = 1 - const val COMPLETED = 2 - const val LICENSED = 3 - - fun create(): SManga { - return SMangaImpl() - } - } - -} \ No newline at end of file +package eu.kanade.tachiyomi.source.model + +import java.io.Serializable + +interface SManga : Serializable { + + var url: String + + var title: String + + var artist: String? + + var author: String? + + var description: String? + + var genre: String? + + var status: Int + + var thumbnail_url: String? + + var initialized: Boolean + + fun copyFrom(other: SManga) { + // EXH --> + if(other.title.isNotBlank()) + title = other.title + // EXH <-- + + if (other.author != null) { + author = other.author + } + + if (other.artist != null) { + artist = other.artist + } + + if (other.description != null) { + description = other.description + } + + if (other.genre != null) { + genre = other.genre + } + + if (other.thumbnail_url != null) { + thumbnail_url = other.thumbnail_url + } + + status = other.status + + if (!initialized) { + initialized = other.initialized + } + } + + companion object { + const val UNKNOWN = 0 + const val ONGOING = 1 + const val COMPLETED = 2 + const val LICENSED = 3 + + fun create(): SManga { + return SMangaImpl() + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/SMangaImpl.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/SMangaImpl.kt index 657fd6daa..c944474a1 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/SMangaImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/SMangaImpl.kt @@ -1,23 +1,22 @@ -package eu.kanade.tachiyomi.source.model - -class SMangaImpl : SManga { - - override lateinit var url: String - - override var title: String = "" - - override var artist: String? = null - - override var author: String? = null - - override var description: String? = null - - override var genre: String? = null - - override var status: Int = 0 - - override var thumbnail_url: String? = null - - override var initialized: Boolean = false - -} \ No newline at end of file +package eu.kanade.tachiyomi.source.model + +class SMangaImpl : SManga { + + override lateinit var url: String + + override lateinit var title: String + + override var artist: String? = null + + override var author: String? = null + + override var description: String? = null + + override var genre: String? = null + + override var status: Int = 0 + + override var thumbnail_url: String? = null + + override var initialized: Boolean = false +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt index 29d226788..1c1e01f05 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt @@ -1,23 +1,24 @@ package eu.kanade.tachiyomi.source.online -import android.app.Application -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.network.* +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.network.newCallWithProgress import eu.kanade.tachiyomi.source.CatalogueSource -import eu.kanade.tachiyomi.source.model.* -import exh.patch.injectPatches -import exh.source.DelegatedHttpSource +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import java.net.URI +import java.net.URISyntaxException +import java.security.MessageDigest import okhttp3.Headers import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import rx.Observable -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import java.net.URI -import java.net.URISyntaxException -import java.security.MessageDigest +import uy.kohesive.injekt.injectLazy /** * A simple implementation for sources from a website. @@ -27,25 +28,7 @@ abstract class HttpSource : CatalogueSource { /** * Network service. */ - protected val network: NetworkHelper by lazy { - val original = Injekt.get() - object : NetworkHelper(Injekt.get()) { - override val client: OkHttpClient - get() = delegate?.networkHttpClient ?: original.client - .newBuilder() - .injectPatches { id } - .build() - - override val cloudflareClient: OkHttpClient - get() = delegate?.networkCloudflareClient ?: original.cloudflareClient - .newBuilder() - .injectPatches { id } - .build() - - override val cookieManager: AndroidCookieJar - get() = original.cookieManager - } - } + protected val network: NetworkHelper by injectLazy() // /** // * Preferences that a source may need. @@ -85,13 +68,13 @@ abstract class HttpSource : CatalogueSource { * Default network client for doing requests. */ open val client: OkHttpClient - get() = delegate?.baseHttpClient ?: network.client + get() = network.client /** * Headers builder for requests. Implementations can override this method for custom headers. */ - open protected fun headersBuilder() = Headers.Builder().apply { - add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)") + protected open fun headersBuilder() = Headers.Builder().apply { + add("User-Agent", DEFAULT_USERAGENT) } /** @@ -107,10 +90,10 @@ abstract class HttpSource : CatalogueSource { */ override fun fetchPopularManga(page: Int): Observable { return client.newCall(popularMangaRequest(page)) - .asObservableSuccess() - .map { response -> - popularMangaParse(response) - } + .asObservableSuccess() + .map { response -> + popularMangaParse(response) + } } /** @@ -118,14 +101,14 @@ abstract class HttpSource : CatalogueSource { * * @param page the page number to retrieve. */ - abstract protected fun popularMangaRequest(page: Int): Request + protected abstract fun popularMangaRequest(page: Int): Request /** * Parses the response from the site and returns a [MangasPage] object. * * @param response the response from the site. */ - abstract protected fun popularMangaParse(response: Response): MangasPage + protected abstract fun popularMangaParse(response: Response): MangasPage /** * Returns an observable containing a page with a list of manga. Normally it's not needed to @@ -137,10 +120,10 @@ abstract class HttpSource : CatalogueSource { */ override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { return client.newCall(searchMangaRequest(page, query, filters)) - .asObservableSuccess() - .map { response -> - searchMangaParse(response) - } + .asObservableSuccess() + .map { response -> + searchMangaParse(response) + } } /** @@ -150,14 +133,14 @@ abstract class HttpSource : CatalogueSource { * @param query the search query. * @param filters the list of filters to apply. */ - abstract protected fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request + protected abstract fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request /** * Parses the response from the site and returns a [MangasPage] object. * * @param response the response from the site. */ - abstract protected fun searchMangaParse(response: Response): MangasPage + protected abstract fun searchMangaParse(response: Response): MangasPage /** * Returns an observable containing a page with a list of latest manga updates. @@ -166,10 +149,10 @@ abstract class HttpSource : CatalogueSource { */ override fun fetchLatestUpdates(page: Int): Observable { return client.newCall(latestUpdatesRequest(page)) - .asObservableSuccess() - .map { response -> - latestUpdatesParse(response) - } + .asObservableSuccess() + .map { response -> + latestUpdatesParse(response) + } } /** @@ -177,14 +160,14 @@ abstract class HttpSource : CatalogueSource { * * @param page the page number to retrieve. */ - abstract protected fun latestUpdatesRequest(page: Int): Request + protected abstract fun latestUpdatesRequest(page: Int): Request /** * Parses the response from the site and returns a [MangasPage] object. * * @param response the response from the site. */ - abstract protected fun latestUpdatesParse(response: Response): MangasPage + protected abstract fun latestUpdatesParse(response: Response): MangasPage /** * Returns an observable with the updated details for a manga. Normally it's not needed to @@ -194,10 +177,10 @@ abstract class HttpSource : CatalogueSource { */ override fun fetchMangaDetails(manga: SManga): Observable { return client.newCall(mangaDetailsRequest(manga)) - .asObservableSuccess() - .map { response -> - mangaDetailsParse(response).apply { initialized = true } - } + .asObservableSuccess() + .map { response -> + mangaDetailsParse(response).apply { initialized = true } + } } /** @@ -215,7 +198,7 @@ abstract class HttpSource : CatalogueSource { * * @param response the response from the site. */ - abstract protected fun mangaDetailsParse(response: Response): SManga + protected abstract fun mangaDetailsParse(response: Response): SManga /** * Returns an observable with the updated chapter list for a manga. Normally it's not needed to @@ -224,14 +207,14 @@ abstract class HttpSource : CatalogueSource { * @param manga the manga to look for chapters. */ override fun fetchChapterList(manga: SManga): Observable> { - if (manga.status != SManga.LICENSED) { - return client.newCall(chapterListRequest(manga)) - .asObservableSuccess() - .map { response -> - chapterListParse(response) - } + return if (manga.status != SManga.LICENSED) { + client.newCall(chapterListRequest(manga)) + .asObservableSuccess() + .map { response -> + chapterListParse(response) + } } else { - return Observable.error(Exception("Licensed - No chapters to show")) + Observable.error(Exception("Licensed - No chapters to show")) } } @@ -241,7 +224,7 @@ abstract class HttpSource : CatalogueSource { * * @param manga the manga to look for chapters. */ - open protected fun chapterListRequest(manga: SManga): Request { + protected open fun chapterListRequest(manga: SManga): Request { return GET(baseUrl + manga.url, headers) } @@ -250,7 +233,7 @@ abstract class HttpSource : CatalogueSource { * * @param response the response from the site. */ - abstract protected fun chapterListParse(response: Response): List + protected abstract fun chapterListParse(response: Response): List /** * Returns an observable with the page list for a chapter. @@ -259,10 +242,10 @@ abstract class HttpSource : CatalogueSource { */ override fun fetchPageList(chapter: SChapter): Observable> { return client.newCall(pageListRequest(chapter)) - .asObservableSuccess() - .map { response -> - pageListParse(response) - } + .asObservableSuccess() + .map { response -> + pageListParse(response) + } } /** @@ -271,7 +254,7 @@ abstract class HttpSource : CatalogueSource { * * @param chapter the chapter whose page list has to be fetched. */ - open protected fun pageListRequest(chapter: SChapter): Request { + protected open fun pageListRequest(chapter: SChapter): Request { return GET(baseUrl + chapter.url, headers) } @@ -280,7 +263,7 @@ abstract class HttpSource : CatalogueSource { * * @param response the response from the site. */ - abstract protected fun pageListParse(response: Response): List + protected abstract fun pageListParse(response: Response): List /** * Returns an observable with the page containing the source url of the image. If there's any @@ -290,8 +273,8 @@ abstract class HttpSource : CatalogueSource { */ open fun fetchImageUrl(page: Page): Observable { return client.newCall(imageUrlRequest(page)) - .asObservableSuccess() - .map { imageUrlParse(it) } + .asObservableSuccess() + .map { imageUrlParse(it) } } /** @@ -300,7 +283,7 @@ abstract class HttpSource : CatalogueSource { * * @param page the chapter whose page list has to be fetched */ - open protected fun imageUrlRequest(page: Page): Request { + protected open fun imageUrlRequest(page: Page): Request { return GET(page.url, headers) } @@ -309,16 +292,16 @@ abstract class HttpSource : CatalogueSource { * * @param response the response from the site. */ - abstract protected fun imageUrlParse(response: Response): String + protected abstract fun imageUrlParse(response: Response): String /** * Returns an observable with the response of the source image. * * @param page the page whose source image has to be downloaded. */ - open fun fetchImage(page: Page): Observable { + fun fetchImage(page: Page): Observable { return client.newCallWithProgress(imageRequest(page), page) - .asObservableSuccess() + .asObservableSuccess() } /** @@ -327,7 +310,7 @@ abstract class HttpSource : CatalogueSource { * * @param page the chapter whose page list has to be fetched */ - open protected fun imageRequest(page: Page): Request { + protected open fun imageRequest(page: Page): Request { return GET(page.imageUrl!!, headers) } @@ -357,16 +340,18 @@ abstract class HttpSource : CatalogueSource { * @param orig the full url. */ private fun getUrlWithoutDomain(orig: String): String { - try { + return try { val uri = URI(orig) var out = uri.path - if (uri.query != null) + if (uri.query != null) { out += "?" + uri.query - if (uri.fragment != null) + } + if (uri.fragment != null) { out += "#" + uri.fragment - return out + } + out } catch (e: URISyntaxException) { - return orig + orig } } @@ -394,4 +379,8 @@ abstract class HttpSource : CatalogueSource { this.delegate = delegate } // EXH <-- + + companion object { + const val DEFAULT_USERAGENT = "Mozilla/5.0 (Windows NT 6.3; WOW64)" + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSourceFetcher.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSourceFetcher.kt index 07a3c4ca8..7b3ea4bde 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSourceFetcher.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSourceFetcher.kt @@ -1,37 +1,25 @@ -package eu.kanade.tachiyomi.source.online - -import com.elvishew.xlog.XLog -import eu.kanade.tachiyomi.source.model.Page -import rx.Observable - -fun HttpSource.getImageUrl(page: Page): Observable { - page.status = Page.LOAD_PAGE - return fetchImageUrl(page) - .doOnError { page.status = Page.ERROR } - .onErrorReturn { - // [EXH] - XLog.w("> Failed to fetch image URL!", it) - XLog.w("> (source.id: %s, source.name: %s, page.index: %s, page.url: %s, page.imageUrl: %s)", - id, - name, - page.index, - page.url, - page.imageUrl) - - null - } - .doOnNext { page.imageUrl = it } - .map { page } -} - -fun HttpSource.fetchAllImageUrlsFromPageList(pages: List): Observable { - return Observable.from(pages) - .filter { !it.imageUrl.isNullOrEmpty() } - .mergeWith(fetchRemainingImageUrlsFromPageList(pages)) -} - -fun HttpSource.fetchRemainingImageUrlsFromPageList(pages: List): Observable { - return Observable.from(pages) - .filter { it.imageUrl.isNullOrEmpty() } - .concatMap { getImageUrl(it) } -} +package eu.kanade.tachiyomi.source.online + +import eu.kanade.tachiyomi.source.model.Page +import rx.Observable + +fun HttpSource.getImageUrl(page: Page): Observable { + page.status = Page.LOAD_PAGE + return fetchImageUrl(page) + .doOnError { page.status = Page.ERROR } + .onErrorReturn { null } + .doOnNext { page.imageUrl = it } + .map { page } +} + +fun HttpSource.fetchAllImageUrlsFromPageList(pages: List): Observable { + return Observable.from(pages) + .filter { !it.imageUrl.isNullOrEmpty() } + .mergeWith(fetchRemainingImageUrlsFromPageList(pages)) +} + +fun HttpSource.fetchRemainingImageUrlsFromPageList(pages: List): Observable { + return Observable.from(pages) + .filter { it.imageUrl.isNullOrEmpty() } + .concatMap { getImageUrl(it) } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/LoginSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/LoginSource.kt deleted file mode 100755 index 61ec4fd35..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/LoginSource.kt +++ /dev/null @@ -1,15 +0,0 @@ -package eu.kanade.tachiyomi.source.online - -import eu.kanade.tachiyomi.source.Source -import okhttp3.Response -import rx.Observable - -interface LoginSource : Source { - - fun isLogged(): Boolean - - fun login(username: String, password: String): Observable - - fun isAuthenticationSuccessful(response: Response): Boolean - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/ParsedHttpSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/ParsedHttpSource.kt index 6053fc2b6..941a3167a 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/ParsedHttpSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/ParsedHttpSource.kt @@ -1,200 +1,200 @@ -package eu.kanade.tachiyomi.source.online - -import eu.kanade.tachiyomi.source.model.MangasPage -import eu.kanade.tachiyomi.source.model.Page -import eu.kanade.tachiyomi.source.model.SChapter -import eu.kanade.tachiyomi.source.model.SManga -import eu.kanade.tachiyomi.util.asJsoup -import okhttp3.Response -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element - -/** - * A simple implementation for sources from a website using Jsoup, an HTML parser. - */ -abstract class ParsedHttpSource : HttpSource() { - - /** - * Parses the response from the site and returns a [MangasPage] object. - * - * @param response the response from the site. - */ - override fun popularMangaParse(response: Response): MangasPage { - val document = response.asJsoup() - - val mangas = document.select(popularMangaSelector()).map { element -> - popularMangaFromElement(element) - } - - val hasNextPage = popularMangaNextPageSelector()?.let { selector -> - document.select(selector).first() - } != null - - return MangasPage(mangas, hasNextPage) - } - - /** - * Returns the Jsoup selector that returns a list of [Element] corresponding to each manga. - */ - abstract protected fun popularMangaSelector(): String - - /** - * Returns a manga from the given [element]. Most sites only show the title and the url, it's - * totally fine to fill only those two values. - * - * @param element an element obtained from [popularMangaSelector]. - */ - abstract protected fun popularMangaFromElement(element: Element): SManga - - /** - * Returns the Jsoup selector that returns the tag linking to the next page, or null if - * there's no next page. - */ - abstract protected fun popularMangaNextPageSelector(): String? - - /** - * Parses the response from the site and returns a [MangasPage] object. - * - * @param response the response from the site. - */ - override fun searchMangaParse(response: Response): MangasPage { - val document = response.asJsoup() - - val mangas = document.select(searchMangaSelector()).map { element -> - searchMangaFromElement(element) - } - - val hasNextPage = searchMangaNextPageSelector()?.let { selector -> - document.select(selector).first() - } != null - - return MangasPage(mangas, hasNextPage) - } - - /** - * Returns the Jsoup selector that returns a list of [Element] corresponding to each manga. - */ - abstract protected fun searchMangaSelector(): String - - /** - * Returns a manga from the given [element]. Most sites only show the title and the url, it's - * totally fine to fill only those two values. - * - * @param element an element obtained from [searchMangaSelector]. - */ - abstract protected fun searchMangaFromElement(element: Element): SManga - - /** - * Returns the Jsoup selector that returns the tag linking to the next page, or null if - * there's no next page. - */ - abstract protected fun searchMangaNextPageSelector(): String? - - /** - * Parses the response from the site and returns a [MangasPage] object. - * - * @param response the response from the site. - */ - override fun latestUpdatesParse(response: Response): MangasPage { - val document = response.asJsoup() - - val mangas = document.select(latestUpdatesSelector()).map { element -> - latestUpdatesFromElement(element) - } - - val hasNextPage = latestUpdatesNextPageSelector()?.let { selector -> - document.select(selector).first() - } != null - - return MangasPage(mangas, hasNextPage) - } - - /** - * Returns the Jsoup selector that returns a list of [Element] corresponding to each manga. - */ - abstract protected fun latestUpdatesSelector(): String - - /** - * Returns a manga from the given [element]. Most sites only show the title and the url, it's - * totally fine to fill only those two values. - * - * @param element an element obtained from [latestUpdatesSelector]. - */ - abstract protected fun latestUpdatesFromElement(element: Element): SManga - - /** - * Returns the Jsoup selector that returns the tag linking to the next page, or null if - * there's no next page. - */ - abstract protected fun latestUpdatesNextPageSelector(): String? - - /** - * Parses the response from the site and returns the details of a manga. - * - * @param response the response from the site. - */ - override fun mangaDetailsParse(response: Response): SManga { - return mangaDetailsParse(response.asJsoup()) - } - - /** - * Returns the details of the manga from the given [document]. - * - * @param document the parsed document. - */ - abstract protected fun mangaDetailsParse(document: Document): SManga - - /** - * Parses the response from the site and returns a list of chapters. - * - * @param response the response from the site. - */ - override fun chapterListParse(response: Response): List { - val document = response.asJsoup() - return document.select(chapterListSelector()).map { chapterFromElement(it) } - } - - /** - * Returns the Jsoup selector that returns a list of [Element] corresponding to each chapter. - */ - abstract protected fun chapterListSelector(): String - - /** - * Returns a chapter from the given element. - * - * @param element an element obtained from [chapterListSelector]. - */ - abstract protected fun chapterFromElement(element: Element): SChapter - - /** - * Parses the response from the site and returns the page list. - * - * @param response the response from the site. - */ - override fun pageListParse(response: Response): List { - return pageListParse(response.asJsoup()) - } - - /** - * Returns a page list from the given document. - * - * @param document the parsed document. - */ - abstract protected fun pageListParse(document: Document): List - - /** - * Parse the response from the site and returns the absolute url to the source image. - * - * @param response the response from the site. - */ - override fun imageUrlParse(response: Response): String { - return imageUrlParse(response.asJsoup()) - } - - /** - * Returns the absolute url to the source image from the document. - * - * @param document the parsed document. - */ - abstract protected fun imageUrlParse(document: Document): String -} +package eu.kanade.tachiyomi.source.online + +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.util.asJsoup +import okhttp3.Response +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element + +/** + * A simple implementation for sources from a website using Jsoup, an HTML parser. + */ +abstract class ParsedHttpSource : HttpSource() { + + /** + * Parses the response from the site and returns a [MangasPage] object. + * + * @param response the response from the site. + */ + override fun popularMangaParse(response: Response): MangasPage { + val document = response.asJsoup() + + val mangas = document.select(popularMangaSelector()).map { element -> + popularMangaFromElement(element) + } + + val hasNextPage = popularMangaNextPageSelector()?.let { selector -> + document.select(selector).first() + } != null + + return MangasPage(mangas, hasNextPage) + } + + /** + * Returns the Jsoup selector that returns a list of [Element] corresponding to each manga. + */ + protected abstract fun popularMangaSelector(): String + + /** + * Returns a manga from the given [element]. Most sites only show the title and the url, it's + * totally fine to fill only those two values. + * + * @param element an element obtained from [popularMangaSelector]. + */ + protected abstract fun popularMangaFromElement(element: Element): SManga + + /** + * Returns the Jsoup selector that returns the tag linking to the next page, or null if + * there's no next page. + */ + protected abstract fun popularMangaNextPageSelector(): String? + + /** + * Parses the response from the site and returns a [MangasPage] object. + * + * @param response the response from the site. + */ + override fun searchMangaParse(response: Response): MangasPage { + val document = response.asJsoup() + + val mangas = document.select(searchMangaSelector()).map { element -> + searchMangaFromElement(element) + } + + val hasNextPage = searchMangaNextPageSelector()?.let { selector -> + document.select(selector).first() + } != null + + return MangasPage(mangas, hasNextPage) + } + + /** + * Returns the Jsoup selector that returns a list of [Element] corresponding to each manga. + */ + protected abstract fun searchMangaSelector(): String + + /** + * Returns a manga from the given [element]. Most sites only show the title and the url, it's + * totally fine to fill only those two values. + * + * @param element an element obtained from [searchMangaSelector]. + */ + protected abstract fun searchMangaFromElement(element: Element): SManga + + /** + * Returns the Jsoup selector that returns the tag linking to the next page, or null if + * there's no next page. + */ + protected abstract fun searchMangaNextPageSelector(): String? + + /** + * Parses the response from the site and returns a [MangasPage] object. + * + * @param response the response from the site. + */ + override fun latestUpdatesParse(response: Response): MangasPage { + val document = response.asJsoup() + + val mangas = document.select(latestUpdatesSelector()).map { element -> + latestUpdatesFromElement(element) + } + + val hasNextPage = latestUpdatesNextPageSelector()?.let { selector -> + document.select(selector).first() + } != null + + return MangasPage(mangas, hasNextPage) + } + + /** + * Returns the Jsoup selector that returns a list of [Element] corresponding to each manga. + */ + protected abstract fun latestUpdatesSelector(): String + + /** + * Returns a manga from the given [element]. Most sites only show the title and the url, it's + * totally fine to fill only those two values. + * + * @param element an element obtained from [latestUpdatesSelector]. + */ + protected abstract fun latestUpdatesFromElement(element: Element): SManga + + /** + * Returns the Jsoup selector that returns the tag linking to the next page, or null if + * there's no next page. + */ + protected abstract fun latestUpdatesNextPageSelector(): String? + + /** + * Parses the response from the site and returns the details of a manga. + * + * @param response the response from the site. + */ + override fun mangaDetailsParse(response: Response): SManga { + return mangaDetailsParse(response.asJsoup()) + } + + /** + * Returns the details of the manga from the given [document]. + * + * @param document the parsed document. + */ + protected abstract fun mangaDetailsParse(document: Document): SManga + + /** + * Parses the response from the site and returns a list of chapters. + * + * @param response the response from the site. + */ + override fun chapterListParse(response: Response): List { + val document = response.asJsoup() + return document.select(chapterListSelector()).map { chapterFromElement(it) } + } + + /** + * Returns the Jsoup selector that returns a list of [Element] corresponding to each chapter. + */ + protected abstract fun chapterListSelector(): String + + /** + * Returns a chapter from the given element. + * + * @param element an element obtained from [chapterListSelector]. + */ + protected abstract fun chapterFromElement(element: Element): SChapter + + /** + * Parses the response from the site and returns the page list. + * + * @param response the response from the site. + */ + override fun pageListParse(response: Response): List { + return pageListParse(response.asJsoup()) + } + + /** + * Returns a page list from the given document. + * + * @param document the parsed document. + */ + protected abstract fun pageListParse(document: Document): List + + /** + * Parse the response from the site and returns the absolute url to the source image. + * + * @param response the response from the site. + */ + override fun imageUrlParse(response: Response): String { + return imageUrlParse(response.asJsoup()) + } + + /** + * Returns the absolute url to the source image from the document. + * + * @param document the parsed document. + */ + protected abstract fun imageUrlParse(document: Document): String +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseActivity.kt index 3740f3af0..11c86bf18 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseActivity.kt @@ -1,11 +1,86 @@ package eu.kanade.tachiyomi.ui.base.activity +import android.content.res.Configuration +import android.os.Build +import android.os.Bundle import androidx.appcompat.app.AppCompatActivity -import eu.kanade.tachiyomi.util.LocaleHelper +import androidx.lifecycle.lifecycleScope +import androidx.viewbinding.ViewBinding +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferenceValues as Values +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate +import eu.kanade.tachiyomi.util.system.LocaleHelper +import uy.kohesive.injekt.injectLazy + +abstract class BaseActivity : AppCompatActivity() { + + val preferences: PreferencesHelper by injectLazy() + + val scope = lifecycleScope + lateinit var binding: VB + + @Suppress("LeakingThis") + private val secureActivityDelegate = SecureActivityDelegate(this) + + private val lightTheme: Int by lazy { + when (preferences.themeLight().get()) { + Values.THEME_LIGHT_BLUE -> R.style.Theme_Tachiyomi_LightBlue + else -> { + when { + // Light status + navigation bar + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 -> { + R.style.Theme_Tachiyomi_Light_Api27 + } + // Light status bar + fallback gray navigation bar + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> { + R.style.Theme_Tachiyomi_Light_Api23 + } + // Fallback gray status + navigation bar + else -> { + R.style.Theme_Tachiyomi_Light + } + } + } + } + } + + private val darkTheme: Int by lazy { + when (preferences.themeDark().get()) { + Values.THEME_DARK_BLUE -> R.style.Theme_Tachiyomi_DarkBlue + Values.THEME_DARK_AMOLED -> R.style.Theme_Tachiyomi_Amoled + else -> R.style.Theme_Tachiyomi_Dark + } + } -abstract class BaseActivity : AppCompatActivity() { init { @Suppress("LeakingThis") LocaleHelper.updateConfiguration(this) } + + override fun onCreate(savedInstanceState: Bundle?) { + setTheme( + when (preferences.themeMode().get()) { + Values.THEME_MODE_SYSTEM -> { + if (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES) { + darkTheme + } else { + lightTheme + } + } + Values.THEME_MODE_DARK -> darkTheme + else -> lightTheme + } + ) + + super.onCreate(savedInstanceState) + + secureActivityDelegate.onCreate() + } + + override fun onResume() { + super.onResume() + + secureActivityDelegate.onResume() + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseRxActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseRxActivity.kt index 560fb01c8..9358da75b 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseRxActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseRxActivity.kt @@ -1,14 +1,35 @@ package eu.kanade.tachiyomi.ui.base.activity +import android.os.Bundle +import androidx.lifecycle.lifecycleScope +import androidx.viewbinding.ViewBinding import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter -import eu.kanade.tachiyomi.util.LocaleHelper +import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate +import eu.kanade.tachiyomi.util.system.LocaleHelper import nucleus.view.NucleusAppCompatActivity -abstract class BaseRxActivity

> : NucleusAppCompatActivity

() { +abstract class BaseRxActivity> : NucleusAppCompatActivity

() { + + @Suppress("LeakingThis") + private val secureActivityDelegate = SecureActivityDelegate(this) + + val scope = lifecycleScope + lateinit var binding: VB init { @Suppress("LeakingThis") LocaleHelper.updateConfiguration(this) } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + secureActivityDelegate.onCreate() + } + + override fun onResume() { + super.onResume() + + secureActivityDelegate.onResume() + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/BaseController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/BaseController.kt index bbe4f36d1..48e7aa8a3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/BaseController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/BaseController.kt @@ -6,16 +6,20 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity +import androidx.viewbinding.ViewBinding import com.bluelinelabs.conductor.Controller import com.bluelinelabs.conductor.ControllerChangeHandler import com.bluelinelabs.conductor.ControllerChangeType import com.bluelinelabs.conductor.RestoreViewOnCreateController import kotlinx.android.extensions.LayoutContainer -import kotlinx.android.synthetic.* +import kotlinx.android.synthetic.clearFindViewByIdCache import timber.log.Timber -abstract class BaseController(bundle: Bundle? = null) : RestoreViewOnCreateController(bundle), - LayoutContainer { +abstract class BaseController(bundle: Bundle? = null) : + RestoreViewOnCreateController(bundle), + LayoutContainer { + + lateinit var binding: VB init { addLifecycleListener(object : LifecycleListener() { @@ -55,12 +59,13 @@ abstract class BaseController(bundle: Bundle? = null) : RestoreViewOnCreateContr abstract fun inflateView(inflater: LayoutInflater, container: ViewGroup): View - open fun onViewCreated(view: View) { } + open fun onViewCreated(view: View) {} override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { if (type.isEnter) { setTitle() } + setHasOptionsMenu(type.isEnter) super.onChangeStarted(handler, type) } @@ -71,7 +76,7 @@ abstract class BaseController(bundle: Bundle? = null) : RestoreViewOnCreateContr fun setTitle() { var parentController = parentController while (parentController != null) { - if (parentController is BaseController && parentController.getTitle() != null) { + if (parentController is BaseController<*> && parentController.getTitle() != null) { return } parentController = parentController.parentController @@ -85,21 +90,42 @@ abstract class BaseController(bundle: Bundle? = null) : RestoreViewOnCreateContr } /** - * Workaround for disappearing menu items when collapsing an expandable item like a SearchView. + * Workaround for buggy menu item layout after expanding/collapsing an expandable item like a SearchView. * This method should be removed when fixed upstream. * Issue link: https://issuetracker.google.com/issues/37657375 */ - fun MenuItem.fixExpand() { + var expandActionViewFromInteraction = false + + fun MenuItem.fixExpand(onExpand: ((MenuItem) -> Boolean)? = null, onCollapse: ((MenuItem) -> Boolean)? = null) { setOnActionExpandListener(object : MenuItem.OnActionExpandListener { override fun onMenuItemActionExpand(item: MenuItem): Boolean { - return true + return onExpand?.invoke(item) ?: true } override fun onMenuItemActionCollapse(item: MenuItem): Boolean { activity?.invalidateOptionsMenu() - return true + + return onCollapse?.invoke(item) ?: true } }) + + if (expandActionViewFromInteraction) { + expandActionViewFromInteraction = false + expandActionView() + } } + /** + * Workaround for menu items not disappearing when expanding an expandable item like a SearchView. + * [expandActionViewFromInteraction] should be set to true in [onOptionsItemSelected] when the expandable item is selected + * This method should be called as part of [MenuItem.OnActionExpandListener.onMenuItemActionExpand] + */ + fun invalidateMenuOnExpand(): Boolean { + return if (expandActionViewFromInteraction) { + activity?.invalidateOptionsMenu() + false + } else { + true + } + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ConductorExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ConductorExtensions.kt index 79a6ce86a..ef4239912 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ConductorExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ConductorExtensions.kt @@ -30,6 +30,6 @@ fun Controller.requestPermissionsSafe(permissions: Array, requestCode: I fun Controller.withFadeTransaction(): RouterTransaction { return RouterTransaction.with(this) - .pushChangeHandler(FadeChangeHandler()) - .popChangeHandler(FadeChangeHandler()) + .pushChangeHandler(FadeChangeHandler()) + .popChangeHandler(FadeChangeHandler()) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/DialogController.java b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/DialogController.java deleted file mode 100644 index 95adc2038..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/DialogController.java +++ /dev/null @@ -1,140 +0,0 @@ -package eu.kanade.tachiyomi.ui.base.controller; - -import android.app.Dialog; -import android.content.DialogInterface; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import com.bluelinelabs.conductor.RestoreViewOnCreateController; -import com.bluelinelabs.conductor.Router; -import com.bluelinelabs.conductor.RouterTransaction; -import com.bluelinelabs.conductor.changehandler.SimpleSwapChangeHandler; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -/** - * A controller that displays a dialog window, floating on top of its activity's window. - * This is a wrapper over {@link Dialog} object like {@link android.app.DialogFragment}. - * - *

Implementations should override this class and implement {@link #onCreateDialog(Bundle)} to create a custom dialog, such as an {@link android.app.AlertDialog} - */ -public abstract class DialogController extends RestoreViewOnCreateController { - - private static final String SAVED_DIALOG_STATE_TAG = "android:savedDialogState"; - - private Dialog dialog; - private boolean dismissed; - - /** - * Convenience constructor for use when no arguments are needed. - */ - protected DialogController() { - super(null); - } - - /** - * Constructor that takes arguments that need to be retained across restarts. - * - * @param args Any arguments that need to be retained. - */ - protected DialogController(@Nullable Bundle args) { - super(args); - } - - @NonNull - @Override - final protected View onCreateView(@NonNull LayoutInflater inflater, @NonNull ViewGroup container, @Nullable Bundle savedViewState) { - dialog = onCreateDialog(savedViewState); - //noinspection ConstantConditions - dialog.setOwnerActivity(getActivity()); - dialog.setOnDismissListener(new DialogInterface.OnDismissListener() { - @Override - public void onDismiss(DialogInterface dialog) { - dismissDialog(); - } - }); - if (savedViewState != null) { - Bundle dialogState = savedViewState.getBundle(SAVED_DIALOG_STATE_TAG); - if (dialogState != null) { - dialog.onRestoreInstanceState(dialogState); - } - } - return new View(getActivity());//stub view - } - - @Override - protected void onSaveViewState(@NonNull View view, @NonNull Bundle outState) { - super.onSaveViewState(view, outState); - Bundle dialogState = dialog.onSaveInstanceState(); - outState.putBundle(SAVED_DIALOG_STATE_TAG, dialogState); - } - - @Override - protected void onAttach(@NonNull View view) { - super.onAttach(view); - dialog.show(); - } - - @Override - protected void onDetach(@NonNull View view) { - super.onDetach(view); - dialog.hide(); - } - - @Override - protected void onDestroyView(@NonNull View view) { - super.onDestroyView(view); - dialog.setOnDismissListener(null); - dialog.dismiss(); - dialog = null; - } - - /** - * Display the dialog, create a transaction and pushing the controller. - * @param router The router on which the transaction will be applied - */ - public void showDialog(@NonNull Router router) { - showDialog(router, null); - } - - /** - * Display the dialog, create a transaction and pushing the controller. - * @param router The router on which the transaction will be applied - * @param tag The tag for this controller - */ - public void showDialog(@NonNull Router router, @Nullable String tag) { - dismissed = false; - router.pushController(RouterTransaction.with(this) - .pushChangeHandler(new SimpleSwapChangeHandler(false)) - .popChangeHandler(new SimpleSwapChangeHandler(false)) - .tag(tag)); - } - - /** - * Dismiss the dialog and pop this controller - */ - public void dismissDialog() { - if (dismissed) { - return; - } - getRouter().popController(this); - dismissed = true; - } - - @Nullable - protected Dialog getDialog() { - return dialog; - } - - /** - * Build your own custom Dialog container such as an {@link android.app.AlertDialog} - * - * @param savedViewState A bundle for the view's state, which would have been created in {@link #onSaveViewState(View, Bundle)} or {@code null} if no saved state exists. - * @return Return a new Dialog instance to be displayed by the Controller - */ - @NonNull - protected abstract Dialog onCreateDialog(@Nullable Bundle savedViewState); -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/DialogController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/DialogController.kt new file mode 100644 index 000000000..9fecbecda --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/DialogController.kt @@ -0,0 +1,120 @@ +package eu.kanade.tachiyomi.ui.base.controller + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.bluelinelabs.conductor.RestoreViewOnCreateController +import com.bluelinelabs.conductor.Router +import com.bluelinelabs.conductor.RouterTransaction +import com.bluelinelabs.conductor.changehandler.SimpleSwapChangeHandler + +/** + * A controller that displays a dialog window, floating on top of its activity's window. + * This is a wrapper over [Dialog] object like [android.app.DialogFragment]. + * + * + * Implementations should override this class and implement [.onCreateDialog] to create a custom dialog, such as an [android.app.AlertDialog] + */ +abstract class DialogController : RestoreViewOnCreateController { + + protected var dialog: Dialog? = null + private set + + private var dismissed = false + + /** + * Convenience constructor for use when no arguments are needed. + */ + protected constructor() : super(null) + + /** + * Constructor that takes arguments that need to be retained across restarts. + * + * @param args Any arguments that need to be retained. + */ + protected constructor(args: Bundle?) : super(args) + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup, savedViewState: Bundle?): View { + dialog = onCreateDialog(savedViewState) + dialog!!.setOwnerActivity(activity!!) + dialog!!.setOnDismissListener { dismissDialog() } + if (savedViewState != null) { + val dialogState = savedViewState.getBundle(SAVED_DIALOG_STATE_TAG) + if (dialogState != null) { + dialog!!.onRestoreInstanceState(dialogState) + } + } + return View(activity) // stub view + } + + override fun onSaveViewState(view: View, outState: Bundle) { + super.onSaveViewState(view, outState) + val dialogState = dialog!!.onSaveInstanceState() + outState.putBundle(SAVED_DIALOG_STATE_TAG, dialogState) + } + + override fun onAttach(view: View) { + super.onAttach(view) + dialog!!.show() + } + + override fun onDetach(view: View) { + super.onDetach(view) + dialog!!.hide() + } + + override fun onDestroyView(view: View) { + super.onDestroyView(view) + dialog!!.setOnDismissListener(null) + dialog!!.dismiss() + dialog = null + } + + /** + * Display the dialog, create a transaction and pushing the controller. + * @param router The router on which the transaction will be applied + */ + open fun showDialog(router: Router) { + showDialog(router, null) + } + + /** + * Display the dialog, create a transaction and pushing the controller. + * @param router The router on which the transaction will be applied + * @param tag The tag for this controller + */ + fun showDialog(router: Router, tag: String?) { + dismissed = false + router.pushController( + RouterTransaction.with(this) + .pushChangeHandler(SimpleSwapChangeHandler(false)) + .popChangeHandler(SimpleSwapChangeHandler(false)) + .tag(tag) + ) + } + + /** + * Dismiss the dialog and pop this controller + */ + fun dismissDialog() { + if (dismissed) { + return + } + router.popController(this) + dismissed = true + } + + /** + * Build your own custom Dialog container such as an [android.app.AlertDialog] + * + * @param savedViewState A bundle for the view's state, which would have been created in [.onSaveViewState] or `null` if no saved state exists. + * @return Return a new Dialog instance to be displayed by the Controller + */ + protected abstract fun onCreateDialog(savedViewState: Bundle?): Dialog + + companion object { + private const val SAVED_DIALOG_STATE_TAG = "android:savedDialogState" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/NoToolbarElevationController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/NoToolbarElevationController.kt index c03612389..74c9b47bb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/NoToolbarElevationController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/NoToolbarElevationController.kt @@ -1,3 +1,3 @@ package eu.kanade.tachiyomi.ui.base.controller -interface NoToolbarElevationController \ No newline at end of file +interface NoToolbarElevationController diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/NucleusController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/NucleusController.kt index 3f252409c..247b6d8bd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/NucleusController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/NucleusController.kt @@ -1,21 +1,28 @@ -package eu.kanade.tachiyomi.ui.base.controller - -import android.os.Bundle -import eu.kanade.tachiyomi.ui.base.presenter.NucleusConductorDelegate -import eu.kanade.tachiyomi.ui.base.presenter.NucleusConductorLifecycleListener -import nucleus.factory.PresenterFactory -import nucleus.presenter.Presenter - -@Suppress("LeakingThis") -abstract class NucleusController

>(val bundle: Bundle? = null) : RxController(bundle), - PresenterFactory

{ - - private val delegate = NucleusConductorDelegate(this) - - val presenter: P - get() = delegate.presenter - - init { - addLifecycleListener(NucleusConductorLifecycleListener(delegate)) - } -} +package eu.kanade.tachiyomi.ui.base.controller + +import android.os.Bundle +import androidx.viewbinding.ViewBinding +import eu.kanade.tachiyomi.ui.base.presenter.NucleusConductorDelegate +import eu.kanade.tachiyomi.ui.base.presenter.NucleusConductorLifecycleListener +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import nucleus.factory.PresenterFactory +import nucleus.presenter.Presenter + +@Suppress("LeakingThis") +abstract class NucleusController>(val bundle: Bundle? = null) : + RxController(bundle), + PresenterFactory

{ + + private val delegate = NucleusConductorDelegate(this) + + val scope = CoroutineScope(Job() + Dispatchers.Main) + + val presenter: P + get() = delegate.presenter!! + + init { + addLifecycleListener(NucleusConductorLifecycleListener(delegate)) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/RootController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/RootController.kt new file mode 100644 index 000000000..17b27f911 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/RootController.kt @@ -0,0 +1,3 @@ +package eu.kanade.tachiyomi.ui.base.controller + +interface RootController diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/RxController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/RxController.kt index 109efb119..2c3d7e9b3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/RxController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/RxController.kt @@ -3,11 +3,12 @@ package eu.kanade.tachiyomi.ui.base.controller import android.os.Bundle import android.view.View import androidx.annotation.CallSuper +import androidx.viewbinding.ViewBinding import rx.Observable import rx.Subscription import rx.subscriptions.CompositeSubscription -abstract class RxController(bundle: Bundle? = null) : BaseController(bundle) { +abstract class RxController(bundle: Bundle? = null) : BaseController(bundle) { var untilDetachSubscriptions = CompositeSubscription() private set @@ -42,51 +43,49 @@ abstract class RxController(bundle: Bundle? = null) : BaseController(bundle) { untilDestroySubscriptions.unsubscribe() } - fun Observable.subscribeUntilDetach(): Subscription { - return subscribe().also { untilDetachSubscriptions.add(it) } } fun Observable.subscribeUntilDetach(onNext: (T) -> Unit): Subscription { - return subscribe(onNext).also { untilDetachSubscriptions.add(it) } } - fun Observable.subscribeUntilDetach(onNext: (T) -> Unit, - onError: (Throwable) -> Unit): Subscription { - + fun Observable.subscribeUntilDetach( + onNext: (T) -> Unit, + onError: (Throwable) -> Unit + ): Subscription { return subscribe(onNext, onError).also { untilDetachSubscriptions.add(it) } } - fun Observable.subscribeUntilDetach(onNext: (T) -> Unit, - onError: (Throwable) -> Unit, - onCompleted: () -> Unit): Subscription { - + fun Observable.subscribeUntilDetach( + onNext: (T) -> Unit, + onError: (Throwable) -> Unit, + onCompleted: () -> Unit + ): Subscription { return subscribe(onNext, onError, onCompleted).also { untilDetachSubscriptions.add(it) } } fun Observable.subscribeUntilDestroy(): Subscription { - return subscribe().also { untilDestroySubscriptions.add(it) } } fun Observable.subscribeUntilDestroy(onNext: (T) -> Unit): Subscription { - return subscribe(onNext).also { untilDestroySubscriptions.add(it) } } - fun Observable.subscribeUntilDestroy(onNext: (T) -> Unit, - onError: (Throwable) -> Unit): Subscription { - + fun Observable.subscribeUntilDestroy( + onNext: (T) -> Unit, + onError: (Throwable) -> Unit + ): Subscription { return subscribe(onNext, onError).also { untilDestroySubscriptions.add(it) } } - fun Observable.subscribeUntilDestroy(onNext: (T) -> Unit, - onError: (Throwable) -> Unit, - onCompleted: () -> Unit): Subscription { - + fun Observable.subscribeUntilDestroy( + onNext: (T) -> Unit, + onError: (Throwable) -> Unit, + onCompleted: () -> Unit + ): Subscription { return subscribe(onNext, onError, onCompleted).also { untilDestroySubscriptions.add(it) } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/SecondaryDrawerController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/SecondaryDrawerController.kt deleted file mode 100644 index ad8180848..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/SecondaryDrawerController.kt +++ /dev/null @@ -1,10 +0,0 @@ -package eu.kanade.tachiyomi.ui.base.controller - -import android.view.ViewGroup - -interface SecondaryDrawerController { - - fun createSecondaryDrawer(drawer: androidx.drawerlayout.widget.DrawerLayout): ViewGroup? - - fun cleanupSecondaryDrawer(drawer: androidx.drawerlayout.widget.DrawerLayout) -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/holder/BaseFlexibleViewHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/holder/BaseFlexibleViewHolder.kt index 3cc6b9c2a..e85cb7709 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/holder/BaseFlexibleViewHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/holder/BaseFlexibleViewHolder.kt @@ -5,11 +5,12 @@ import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.viewholders.FlexibleViewHolder import kotlinx.android.extensions.LayoutContainer -abstract class BaseFlexibleViewHolder(view: View, - adapter: FlexibleAdapter<*>, - stickyHeader: Boolean = false) : - FlexibleViewHolder(view, adapter, stickyHeader), LayoutContainer { +abstract class BaseFlexibleViewHolder( + view: View, + adapter: FlexibleAdapter<*>, + stickyHeader: Boolean = false +) : FlexibleViewHolder(view, adapter, stickyHeader), LayoutContainer { override val containerView: View? get() = itemView -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/holder/BaseViewHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/holder/BaseViewHolder.kt index 9a69e171b..525902040 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/holder/BaseViewHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/holder/BaseViewHolder.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.ui.base.holder import android.view.View +import androidx.recyclerview.widget.RecyclerView import kotlinx.android.extensions.LayoutContainer abstract class BaseViewHolder(view: View) : androidx.recyclerview.widget.RecyclerView.ViewHolder(view), LayoutContainer { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/holder/SlicedHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/holder/SlicedHolder.kt index b2fc8fd26..3acb98558 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/holder/SlicedHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/holder/SlicedHolder.kt @@ -1,12 +1,11 @@ package eu.kanade.tachiyomi.ui.base.holder -import android.os.Build import android.view.View import android.view.ViewGroup import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.IFlexible import eu.davidea.flexibleadapter.items.ISectionable -import eu.kanade.tachiyomi.util.dpToPx +import eu.kanade.tachiyomi.util.system.dpToPx import io.github.mthli.slice.Slice interface SlicedHolder { @@ -32,18 +31,23 @@ interface SlicedHolder { when { // Only one item in the card - count == 1 -> applySlice(2f, false, false, true, true) + count == 1 -> applySlice(2f, topRect = false, bottomRect = false, topShadow = true, bottomShadow = true) // First item of the card - position == 0 -> applySlice(2f, false, true, true, false) + position == 0 -> applySlice(2f, topRect = false, bottomRect = true, topShadow = true, bottomShadow = false) // Last item of the card - position == count - 1 -> applySlice(2f, true, false, false, true) + position == count - 1 -> applySlice(2f, topRect = true, bottomRect = false, topShadow = false, bottomShadow = true) // Middle item - else -> applySlice(0f, false, false, false, false) + else -> applySlice(0f, topRect = false, bottomRect = false, topShadow = false, bottomShadow = false) } } - private fun applySlice(radius: Float, topRect: Boolean, bottomRect: Boolean, - topShadow: Boolean, bottomShadow: Boolean) { + private fun applySlice( + radius: Float, + topRect: Boolean, + bottomRect: Boolean, + topShadow: Boolean, + bottomShadow: Boolean + ) { val margin = margin slice.setRadius(radius) @@ -51,10 +55,6 @@ interface SlicedHolder { slice.showRightTopRect(topRect) slice.showLeftBottomRect(bottomRect) slice.showRightBottomRect(bottomRect) - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - slice.showTopEdgeShadow(topShadow) - slice.showBottomEdgeShadow(bottomShadow) - } setMargins(margin, if (topShadow) margin else 0, margin, if (bottomShadow) margin else 0) } @@ -67,5 +67,4 @@ interface SlicedHolder { val margin get() = 8.dpToPx - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/BasePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/BasePresenter.kt index 130362f51..c8eb46e5b 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/BasePresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/BasePresenter.kt @@ -23,8 +23,7 @@ open class BasePresenter : RxPresenter() { * @param onNext function to execute when the observable emits an item. * @param onError function to execute when the observable throws an error. */ - fun Observable.subscribeFirst(onNext: (V, T) -> Unit, onError: ((V, Throwable) -> Unit)? = null) - = compose(deliverFirst()).subscribe(split(onNext, onError)).apply { add(this) } + fun Observable.subscribeFirst(onNext: (V, T) -> Unit, onError: ((V, Throwable) -> Unit)? = null) = compose(deliverFirst()).subscribe(split(onNext, onError)).apply { add(this) } /** * Subscribes an observable with [deliverLatestCache] and adds it to the presenter's lifecycle @@ -33,8 +32,7 @@ open class BasePresenter : RxPresenter() { * @param onNext function to execute when the observable emits an item. * @param onError function to execute when the observable throws an error. */ - fun Observable.subscribeLatestCache(onNext: (V, T) -> Unit, onError: ((V, Throwable) -> Unit)? = null) - = compose(deliverLatestCache()).subscribe(split(onNext, onError)).apply { add(this) } + fun Observable.subscribeLatestCache(onNext: (V, T) -> Unit, onError: ((V, Throwable) -> Unit)? = null) = compose(deliverLatestCache()).subscribe(split(onNext, onError)).apply { add(this) } /** * Subscribes an observable with [deliverReplay] and adds it to the presenter's lifecycle @@ -43,8 +41,7 @@ open class BasePresenter : RxPresenter() { * @param onNext function to execute when the observable emits an item. * @param onError function to execute when the observable throws an error. */ - fun Observable.subscribeReplay(onNext: (V, T) -> Unit, onError: ((V, Throwable) -> Unit)? = null) - = compose(deliverReplay()).subscribe(split(onNext, onError)).apply { add(this) } + fun Observable.subscribeReplay(onNext: (V, T) -> Unit, onError: ((V, Throwable) -> Unit)? = null) = compose(deliverReplay()).subscribe(split(onNext, onError)).apply { add(this) } /** * Subscribes an observable with [DeliverWithView] and adds it to the presenter's lifecycle @@ -53,8 +50,7 @@ open class BasePresenter : RxPresenter() { * @param onNext function to execute when the observable emits an item. * @param onError function to execute when the observable throws an error. */ - fun Observable.subscribeWithView(onNext: (V, T) -> Unit, onError: ((V, Throwable) -> Unit)? = null) - = compose(DeliverWithView(view())).subscribe(split(onNext, onError)).apply { add(this) } + fun Observable.subscribeWithView(onNext: (V, T) -> Unit, onError: ((V, Throwable) -> Unit)? = null) = compose(DeliverWithView(view())).subscribe(split(onNext, onError)).apply { add(this) } /** * A deliverable that only emits to the view if attached, otherwise the event is ignored. @@ -63,12 +59,11 @@ open class BasePresenter : RxPresenter() { override fun call(observable: Observable): Observable> { return observable - .materialize() - .filter { notification -> !notification.isOnCompleted } - .flatMap { notification -> - view.take(1).filter { it != null }.map { Delivery(it, notification) } - } + .materialize() + .filter { notification -> !notification.isOnCompleted } + .flatMap { notification -> + view.take(1).filter { it != null }.map { Delivery(it, notification) } + } } } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/NucleusConductorDelegate.java b/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/NucleusConductorDelegate.java deleted file mode 100644 index 328a01e67..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/NucleusConductorDelegate.java +++ /dev/null @@ -1,61 +0,0 @@ -package eu.kanade.tachiyomi.ui.base.presenter; - -import android.os.Bundle; - -import androidx.annotation.Nullable; -import nucleus.factory.PresenterFactory; -import nucleus.presenter.Presenter; - -public class NucleusConductorDelegate

{ - - @Nullable private P presenter; - @Nullable private Bundle bundle; - - private PresenterFactory

factory; - - public NucleusConductorDelegate(PresenterFactory

creator) { - this.factory = creator; - } - - public P getPresenter() { - if (presenter == null) { - presenter = factory.createPresenter(); - presenter.create(bundle); - bundle = null; - } - return presenter; - } - - Bundle onSaveInstanceState() { - Bundle bundle = new Bundle(); -// getPresenter(); // Workaround a crash related to saving instance state with child routers - if (presenter != null) { - presenter.save(bundle); - } - return bundle; - } - - void onRestoreInstanceState(Bundle presenterState) { - bundle = presenterState; - } - - void onTakeView(Object view) { - getPresenter(); - if (presenter != null) { - //noinspection unchecked - presenter.takeView(view); - } - } - - void onDropView() { - if (presenter != null) { - presenter.dropView(); - } - } - - void onDestroy() { - if (presenter != null) { - presenter.destroy(); - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/NucleusConductorDelegate.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/NucleusConductorDelegate.kt new file mode 100644 index 000000000..cd07ed478 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/NucleusConductorDelegate.kt @@ -0,0 +1,46 @@ +package eu.kanade.tachiyomi.ui.base.presenter + +import android.os.Bundle +import nucleus.factory.PresenterFactory +import nucleus.presenter.Presenter + +class NucleusConductorDelegate

>(private val factory: PresenterFactory

) { + + var presenter: P? = null + get() { + if (field == null) { + field = factory.createPresenter() + field!!.create(bundle) + bundle = null + } + return field + } + + private var bundle: Bundle? = null + + fun onSaveInstanceState(): Bundle { + val bundle = Bundle() + // getPresenter(); // Workaround a crash related to saving instance state with child routers + presenter?.save(bundle) + return bundle + } + + fun onRestoreInstanceState(presenterState: Bundle?) { + bundle = presenterState + } + + @Suppress("UNCHECKED_CAST") + private fun Presenter.takeView(view: Any) = takeView(view as View) + + fun onTakeView(view: Any) { + presenter?.takeView(view) + } + + fun onDropView() { + presenter?.dropView() + } + + fun onDestroy() { + presenter?.destroy() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/NucleusConductorLifecycleListener.java b/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/NucleusConductorLifecycleListener.java deleted file mode 100644 index 51b7cf571..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/NucleusConductorLifecycleListener.java +++ /dev/null @@ -1,45 +0,0 @@ -package eu.kanade.tachiyomi.ui.base.presenter; - -import android.os.Bundle; -import android.view.View; - -import com.bluelinelabs.conductor.Controller; - -import androidx.annotation.NonNull; - -public class NucleusConductorLifecycleListener extends Controller.LifecycleListener { - - private static final String PRESENTER_STATE_KEY = "presenter_state"; - - private NucleusConductorDelegate delegate; - - public NucleusConductorLifecycleListener(NucleusConductorDelegate delegate) { - this.delegate = delegate; - } - - @Override - public void postCreateView(@NonNull Controller controller, @NonNull View view) { - delegate.onTakeView(controller); - } - - @Override - public void preDestroyView(@NonNull Controller controller, @NonNull View view) { - delegate.onDropView(); - } - - @Override - public void preDestroy(@NonNull Controller controller) { - delegate.onDestroy(); - } - - @Override - public void onSaveInstanceState(@NonNull Controller controller, @NonNull Bundle outState) { - outState.putBundle(PRESENTER_STATE_KEY, delegate.onSaveInstanceState()); - } - - @Override - public void onRestoreInstanceState(@NonNull Controller controller, @NonNull Bundle savedInstanceState) { - delegate.onRestoreInstanceState(savedInstanceState.getBundle(PRESENTER_STATE_KEY)); - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/NucleusConductorLifecycleListener.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/NucleusConductorLifecycleListener.kt new file mode 100644 index 000000000..f59febccf --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/NucleusConductorLifecycleListener.kt @@ -0,0 +1,32 @@ +package eu.kanade.tachiyomi.ui.base.presenter + +import android.os.Bundle +import android.view.View +import com.bluelinelabs.conductor.Controller + +class NucleusConductorLifecycleListener(private val delegate: NucleusConductorDelegate<*>) : Controller.LifecycleListener() { + + override fun postCreateView(controller: Controller, view: View) { + delegate.onTakeView(controller) + } + + override fun preDestroyView(controller: Controller, view: View) { + delegate.onDropView() + } + + override fun preDestroy(controller: Controller) { + delegate.onDestroy() + } + + override fun onSaveInstanceState(controller: Controller, outState: Bundle) { + outState.putBundle(PRESENTER_STATE_KEY, delegate.onSaveInstanceState()) + } + + override fun onRestoreInstanceState(controller: Controller, savedInstanceState: Bundle) { + delegate.onRestoreInstanceState(savedInstanceState.getBundle(PRESENTER_STATE_KEY)) + } + + companion object { + private const val PRESENTER_STATE_KEY = "presenter_state" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseController.kt new file mode 100644 index 000000000..f3245cc83 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseController.kt @@ -0,0 +1,148 @@ +package eu.kanade.tachiyomi.ui.browse + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.bluelinelabs.conductor.Controller +import com.bluelinelabs.conductor.ControllerChangeHandler +import com.bluelinelabs.conductor.ControllerChangeType +import com.bluelinelabs.conductor.Router +import com.bluelinelabs.conductor.RouterTransaction +import com.bluelinelabs.conductor.support.RouterPagerAdapter +import com.google.android.material.badge.BadgeDrawable +import com.google.android.material.tabs.TabLayout +import com.jakewharton.rxrelay.PublishRelay +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.databinding.PagerControllerBinding +import eu.kanade.tachiyomi.ui.base.controller.RootController +import eu.kanade.tachiyomi.ui.base.controller.RxController +import eu.kanade.tachiyomi.ui.base.controller.TabbedController +import eu.kanade.tachiyomi.ui.browse.extension.ExtensionController +import eu.kanade.tachiyomi.ui.browse.source.SourceController +import kotlinx.android.synthetic.main.main_activity.tabs +import uy.kohesive.injekt.injectLazy + +class BrowseController : + RxController, + RootController, + TabbedController { + + constructor(toExtensions: Boolean = false) : super( + Bundle().apply { + putBoolean(TO_EXTENSIONS_EXTRA, toExtensions) + } + ) + + @Suppress("unused") + constructor(bundle: Bundle) : this(bundle.getBoolean(TO_EXTENSIONS_EXTRA)) + + private val preferences: PreferencesHelper by injectLazy() + + private val toExtensions = args.getBoolean(TO_EXTENSIONS_EXTRA, false) + + val extensionListUpdateRelay: PublishRelay = PublishRelay.create() + + private var adapter: BrowseAdapter? = null + + override fun getTitle(): String? { + return resources!!.getString(R.string.browse) + } + + override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { + binding = PagerControllerBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View) { + super.onViewCreated(view) + + adapter = BrowseAdapter() + binding.pager.adapter = adapter + + if (toExtensions) { + binding.pager.currentItem = EXTENSIONS_CONTROLLER + } + } + + override fun onDestroyView(view: View) { + super.onDestroyView(view) + adapter = null + } + + override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { + super.onChangeStarted(handler, type) + if (type.isEnter) { + activity?.tabs?.apply { + setupWithViewPager(binding.pager) + + // Show badge on tab for extension updates + setExtensionUpdateBadge() + } + } + } + + override fun configureTabs(tabs: TabLayout) { + with(tabs) { + tabGravity = TabLayout.GRAVITY_FILL + tabMode = TabLayout.MODE_FIXED + } + } + + override fun cleanupTabs(tabs: TabLayout) { + // Remove extension update badge + tabs.getTabAt(EXTENSIONS_CONTROLLER)?.removeBadge() + } + + fun pushController(transaction: RouterTransaction) { + router.pushController(transaction) + } + + fun setExtensionUpdateBadge() { + activity?.tabs?.apply { + val updates = preferences.extensionUpdatesCount().get() + if (updates > 0) { + val badge: BadgeDrawable? = getTabAt(1)?.orCreateBadge + badge?.isVisible = true + } else { + getTabAt(EXTENSIONS_CONTROLLER)?.removeBadge() + } + } + } + + private inner class BrowseAdapter : RouterPagerAdapter(this@BrowseController) { + + private val tabTitles = listOf( + R.string.label_sources, + R.string.label_extensions + ) + .map { resources!!.getString(it) } + + override fun getCount(): Int { + return tabTitles.size + } + + override fun configureRouter(router: Router, position: Int) { + if (!router.hasRootController()) { + val controller: Controller = when (position) { + SOURCES_CONTROLLER -> SourceController() + EXTENSIONS_CONTROLLER -> ExtensionController() + else -> error("Wrong position $position") + } + router.setRoot(RouterTransaction.with(controller)) + } + } + + override fun getPageTitle(position: Int): CharSequence { + return tabTitles[position] + } + } + + companion object { + const val TO_EXTENSIONS_EXTRA = "to_extensions" + + const val SOURCES_CONTROLLER = 0 + const val EXTENSIONS_CONTROLLER = 1 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionAdapter.kt similarity index 68% rename from app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionAdapter.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionAdapter.kt index e8addfbae..5a2b3c1d3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionAdapter.kt @@ -1,9 +1,9 @@ -package eu.kanade.tachiyomi.ui.extension +package eu.kanade.tachiyomi.ui.browse.extension import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.IFlexible import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.util.getResourceColor +import eu.kanade.tachiyomi.util.system.getResourceColor /** * Adapter that holds the catalogue cards. @@ -11,9 +11,9 @@ import eu.kanade.tachiyomi.util.getResourceColor * @param controller instance of [ExtensionController]. */ class ExtensionAdapter(val controller: ExtensionController) : - FlexibleAdapter>(null, controller, true) { + FlexibleAdapter>(null, controller, true) { - val cardBackground = controller.activity!!.getResourceColor(R.attr.background_card) + val cardBackground = controller.activity!!.getResourceColor(R.attr.colorSurface) init { setDisplayHeadersAtStartUp(true) @@ -22,7 +22,7 @@ class ExtensionAdapter(val controller: ExtensionController) : /** * Listener for browse item clicks. */ - val buttonClickListener: ExtensionAdapter.OnButtonClickListener = controller + val buttonClickListener: OnButtonClickListener = controller interface OnButtonClickListener { fun onButtonClick(position: Int) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionController.kt similarity index 59% rename from app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionController.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionController.kt index ab44c0608..c1ac6ef60 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionController.kt @@ -1,26 +1,39 @@ -package eu.kanade.tachiyomi.ui.extension +package eu.kanade.tachiyomi.ui.browse.extension -import android.view.* +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup import androidx.appcompat.widget.SearchView -import com.jakewharton.rxbinding.support.v4.widget.refreshes -import com.jakewharton.rxbinding.support.v7.widget.queryTextChanges +import androidx.recyclerview.widget.LinearLayoutManager +import com.bluelinelabs.conductor.ControllerChangeHandler +import com.bluelinelabs.conductor.ControllerChangeType import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.IFlexible import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.ExtensionControllerBinding import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.ui.base.controller.NucleusController import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction -import kotlinx.android.synthetic.main.extension_controller.* - +import eu.kanade.tachiyomi.ui.browse.BrowseController +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.appcompat.queryTextChanges +import reactivecircus.flowbinding.swiperefreshlayout.refreshes +import uy.kohesive.injekt.api.get /** * Controller to manage the catalogues available in the app. */ -open class ExtensionController : NucleusController(), - ExtensionAdapter.OnButtonClickListener, - FlexibleAdapter.OnItemClickListener, - FlexibleAdapter.OnItemLongClickListener, - ExtensionTrustDialog.Listener { +open class ExtensionController : + NucleusController(), + ExtensionAdapter.OnButtonClickListener, + FlexibleAdapter.OnItemClickListener, + FlexibleAdapter.OnItemLongClickListener, + ExtensionTrustDialog.Listener { /** * Adapter containing the list of manga from the catalogue. @@ -44,23 +57,25 @@ open class ExtensionController : NucleusController(), } override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.extension_controller, container, false) + binding = ExtensionControllerBinding.inflate(inflater) + return binding.root } override fun onViewCreated(view: View) { super.onViewCreated(view) - ext_swipe_refresh.isRefreshing = true - ext_swipe_refresh.refreshes().subscribeUntilDestroy { - presenter.findAvailableExtensions() - } + binding.extSwipeRefresh.isRefreshing = true + binding.extSwipeRefresh.refreshes() + .onEach { presenter.findAvailableExtensions() } + .launchIn(scope) // Initialize adapter, scroll listener and recycler views adapter = ExtensionAdapter(this) // Create recycler and set adapter. - ext_recycler.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(view.context) - ext_recycler.adapter = adapter - ext_recycler.addItemDecoration(ExtensionDividerItemDecoration(view.context)) + binding.extRecycler.layoutManager = LinearLayoutManager(view.context) + binding.extRecycler.adapter = adapter + binding.extRecycler.addItemDecoration(ExtensionDividerItemDecoration(view.context)) + adapter?.fastScroller = binding.fastScroller } override fun onDestroyView(view: View) { @@ -68,6 +83,26 @@ open class ExtensionController : NucleusController(), super.onDestroyView(view) } + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_search -> expandActionViewFromInteraction = true + R.id.action_settings -> { + (parentController as BrowseController).pushController( + ExtensionFilterController().withFadeTransaction() + ) + } + else -> return super.onOptionsItemSelected(item) + } + return super.onOptionsItemSelected(item) + } + + override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { + super.onChangeStarted(handler, type) + if (type.isPush) { + presenter.findAvailableExtensions() + } + } + override fun onButtonClick(position: Int) { val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return when (extension) { @@ -94,7 +129,7 @@ open class ExtensionController : NucleusController(), val searchView = searchItem.actionView as SearchView searchView.maxWidth = Int.MAX_VALUE - if (!query.isEmpty()) { + if (query.isNotEmpty()) { searchItem.expandActionView() searchView.setQuery(query, true) searchView.clearFocus() @@ -102,17 +137,17 @@ open class ExtensionController : NucleusController(), searchView.queryTextChanges() .filter { router.backstack.lastOrNull()?.controller() == this } - .subscribeUntilDestroy { + .onEach { query = it.toString() drawExtensions() } + .launchIn(scope) // Fixes problem with the overflow icon showing up in lieu of search - searchItem.fixExpand() + searchItem.fixExpand(onExpand = { invalidateMenuOnExpand() }) } override fun onItemClick(view: View, position: Int): Boolean { -// override fun onItemClick(position: Int): Boolean { val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return false if (extension is Extension.Installed) { openDetails(extension) @@ -132,26 +167,32 @@ open class ExtensionController : NucleusController(), private fun openDetails(extension: Extension.Installed) { val controller = ExtensionDetailsController(extension.pkgName) - router.pushController(controller.withFadeTransaction()) + (parentController as BrowseController).pushController(controller.withFadeTransaction()) } private fun openTrustDialog(extension: Extension.Untrusted) { ExtensionTrustDialog(this, extension.signatureHash, extension.pkgName) - .showDialog(router) + .showDialog(router) } fun setExtensions(extensions: List) { - ext_swipe_refresh?.isRefreshing = false + binding.extSwipeRefresh.isRefreshing = false this.extensions = extensions drawExtensions() + + // Update badge on parent controller tab + val ctrl = parentController as BrowseController + ctrl.setExtensionUpdateBadge() + ctrl.extensionListUpdateRelay.call(true) } - fun drawExtensions() { + private fun drawExtensions() { if (!query.isBlank()) { adapter?.updateDataSet( - extensions.filter { - it.extension.name.contains(query, ignoreCase = true) - }) + extensions.filter { + it.extension.name.contains(query, ignoreCase = true) + } + ) } else { adapter?.updateDataSet(extensions) } @@ -168,5 +209,4 @@ open class ExtensionController : NucleusController(), override fun uninstallExtension(pkgName: String) { presenter.uninstallExtension(pkgName) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDetailsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionDetailsController.kt similarity index 58% rename from app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDetailsController.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionDetailsController.kt index a65dd35e4..69fd1b51a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDetailsController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionDetailsController.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.ui.extension +package eu.kanade.tachiyomi.ui.browse.extension import android.annotation.SuppressLint import android.content.Context @@ -8,47 +8,54 @@ import android.view.ContextThemeWrapper import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.preference.* +import androidx.preference.DialogPreference +import androidx.preference.EditTextPreference +import androidx.preference.EditTextPreferenceDialogController +import androidx.preference.ListPreference +import androidx.preference.ListPreferenceDialogController +import androidx.preference.MultiSelectListPreference +import androidx.preference.MultiSelectListPreferenceDialogController +import androidx.preference.Preference +import androidx.preference.PreferenceGroupAdapter +import androidx.preference.PreferenceManager +import androidx.preference.PreferenceScreen import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration.VERTICAL import androidx.recyclerview.widget.LinearLayoutManager -import com.elvishew.xlog.XLog -import com.jakewharton.rxbinding.view.clicks import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.preference.EmptyPreferenceDataStore import eu.kanade.tachiyomi.data.preference.SharedPreferencesDataStore +import eu.kanade.tachiyomi.databinding.ExtensionDetailControllerBinding import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.Source -import eu.kanade.tachiyomi.source.online.LoginSource import eu.kanade.tachiyomi.ui.base.controller.NucleusController -import eu.kanade.tachiyomi.ui.setting.preferenceCategory -import eu.kanade.tachiyomi.util.LocaleHelper -import eu.kanade.tachiyomi.util.toast -import eu.kanade.tachiyomi.widget.preference.LoginPreference -import eu.kanade.tachiyomi.widget.preference.SourceLoginDialog -import kotlinx.android.synthetic.main.extension_detail_controller.* +import eu.kanade.tachiyomi.util.preference.preferenceCategory +import eu.kanade.tachiyomi.util.system.LocaleHelper +import eu.kanade.tachiyomi.util.view.visible +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.android.view.clicks @SuppressLint("RestrictedApi") class ExtensionDetailsController(bundle: Bundle? = null) : - NucleusController(bundle), - PreferenceManager.OnDisplayPreferenceDialogListener, - DialogPreference.TargetFragment, - SourceLoginDialog.Listener { - - // EXH --> - private val logger = XLog.tag("ExtensionDetailsController") - // EXH <-- + NucleusController(bundle), + PreferenceManager.OnDisplayPreferenceDialogListener, + DialogPreference.TargetFragment { private var lastOpenPreferencePosition: Int? = null private var preferenceScreen: PreferenceScreen? = null - constructor(pkgName: String) : this(Bundle().apply { - putString(PKGNAME_KEY, pkgName) - }) + constructor(pkgName: String) : this( + Bundle().apply { + putString(PKGNAME_KEY, pkgName) + } + ) override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.extension_detail_controller, container, false) + val themedInflater = inflater.cloneInContext(getPreferenceThemeContext()) + binding = ExtensionDetailControllerBinding.inflate(themedInflater) + return binding.root } override fun createPresenter(): ExtensionDetailsPresenter { @@ -66,48 +73,42 @@ class ExtensionDetailsController(bundle: Bundle? = null) : val extension = presenter.extension ?: return val context = view.context - extension_title.text = extension.name - extension_version.text = context.getString(R.string.ext_version_info, extension.versionName) - extension_lang.text = context.getString(R.string.ext_language_info, LocaleHelper.getDisplayName(extension.lang, context)) - extension_pkg.text = extension.pkgName - extension.getApplicationIcon(context)?.let { extension_icon.setImageDrawable(it) } - extension_uninstall_button.clicks().subscribeUntilDestroy { - presenter.uninstallExtension() + binding.extensionTitle.text = extension.name + binding.extensionVersion.text = context.getString(R.string.ext_version_info, extension.versionName) + binding.extensionLang.text = context.getString(R.string.ext_language_info, LocaleHelper.getSourceDisplayName(extension.lang, context)) + binding.extensionPkg.text = extension.pkgName + extension.getApplicationIcon(context)?.let { binding.extensionIcon.setImageDrawable(it) } + binding.extensionUninstallButton.clicks() + .onEach { presenter.uninstallExtension() } + .launchIn(scope) + + if (extension.isObsolete) { + binding.extensionObsolete.visible() } val themedContext by lazy { getPreferenceThemeContext() } val manager = PreferenceManager(themedContext) manager.preferenceDataStore = EmptyPreferenceDataStore() manager.onDisplayPreferenceDialogListener = this - val screen = manager.createPreferenceScreen(context) + val screen = manager.createPreferenceScreen(themedContext) preferenceScreen = screen val multiSource = extension.sources.size > 1 for (source in extension.sources) { if (source is ConfigurableSource) { - // EXH --> - try { - // EXH <-- - addPreferencesForSource(screen, source, multiSource) - // EXH --> - } catch(e: Exception) { - logger.e("Failed to load preferences for source: ${source.name}!", e) - context.toast("Failed to load preferences for this source!") - } - // EXH <-- + addPreferencesForSource(screen, source, multiSource) } } manager.setPreferences(screen) - extension_prefs_recycler.layoutManager = LinearLayoutManager(context) - extension_prefs_recycler.adapter = PreferenceGroupAdapter(screen) - extension_prefs_recycler.addItemDecoration(DividerItemDecoration(context, VERTICAL)) + binding.extensionPrefsRecycler.layoutManager = LinearLayoutManager(context) + binding.extensionPrefsRecycler.adapter = PreferenceGroupAdapter(screen) + binding.extensionPrefsRecycler.addItemDecoration(DividerItemDecoration(context, VERTICAL)) if (screen.preferenceCount == 0) { - extension_prefs_empty_view.show(R.drawable.ic_no_settings, - R.string.ext_empty_preferences) + binding.extensionPrefsEmptyView.show(R.string.ext_empty_preferences) } } @@ -137,8 +138,9 @@ class ExtensionDetailsController(bundle: Bundle? = null) : val dataStore = SharedPreferencesDataStore(/*if (source is HttpSource) { source.preferences } else {*/ - context.getSharedPreferences("source_${source.id}", Context.MODE_PRIVATE) - /*}*/) + context.getSharedPreferences("source_${source.id}", Context.MODE_PRIVATE) + /*}*/ + ) if (source is ConfigurableSource) { if (multiSource) { @@ -153,8 +155,10 @@ class ExtensionDetailsController(bundle: Bundle? = null) : // Reparent the preferences while (newScreen.preferenceCount != 0) { val pref = newScreen.getPreference(0) + pref.isIconSpaceReserved = false pref.preferenceDataStore = dataStore pref.order = Int.MAX_VALUE // reset to default order + newScreen.removePreference(pref) screen.addPreference(pref) } @@ -177,31 +181,33 @@ class ExtensionDetailsController(bundle: Bundle? = null) : } val f = when (preference) { - is EditTextPreference -> EditTextPreferenceDialogController + is EditTextPreference -> + EditTextPreferenceDialogController .newInstance(preference.getKey()) - is ListPreference -> ListPreferenceDialogController + is ListPreference -> + ListPreferenceDialogController .newInstance(preference.getKey()) - is MultiSelectListPreference -> MultiSelectListPreferenceDialogController + is MultiSelectListPreference -> + MultiSelectListPreferenceDialogController .newInstance(preference.getKey()) - else -> throw IllegalArgumentException("Tried to display dialog for unknown " + - "preference type. Did you forget to override onDisplayPreferenceDialog()?") + else -> throw IllegalArgumentException( + "Tried to display dialog for unknown " + + "preference type. Did you forget to override onDisplayPreferenceDialog()?" + ) } f.targetController = this f.showDialog(router) } + @Suppress("UNCHECKED_CAST") override fun findPreference(key: CharSequence): T? { - return preferenceScreen!!.findPreference(key) - } - - override fun loginDialogClosed(source: LoginSource) { - val lastOpen = lastOpenPreferencePosition ?: return - (preferenceScreen?.getPreference(lastOpen) as? LoginPreference)?.notifyChanged() + // We track [lastOpenPreferencePosition] when displaying the dialog + // [key] isn't useful since there may be duplicates + return preferenceScreen!!.getPreference(lastOpenPreferencePosition!!) as T } private companion object { const val PKGNAME_KEY = "pkg_name" const val LASTOPENPREFERENCE_KEY = "last_open_preference" } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDetailsPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionDetailsPresenter.kt similarity index 63% rename from app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDetailsPresenter.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionDetailsPresenter.kt index 1b9b958f0..a9760b758 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDetailsPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionDetailsPresenter.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.ui.extension +package eu.kanade.tachiyomi.ui.browse.extension import android.os.Bundle import eu.kanade.tachiyomi.extension.ExtensionManager @@ -8,8 +8,8 @@ import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get class ExtensionDetailsPresenter( - val pkgName: String, - private val extensionManager: ExtensionManager = Injekt.get() + val pkgName: String, + private val extensionManager: ExtensionManager = Injekt.get() ) : BasePresenter() { val extension = extensionManager.installedExtensions.find { it.pkgName == pkgName } @@ -22,14 +22,14 @@ class ExtensionDetailsPresenter( private fun bindToUninstalledExtension() { extensionManager.getInstalledExtensionsObservable() - .skip(1) - .filter { extensions -> extensions.none { it.pkgName == pkgName } } - .map { Unit } - .take(1) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst({ view, _ -> - view.onExtensionUninstalled() - }) + .skip(1) + .filter { extensions -> extensions.none { it.pkgName == pkgName } } + .map { Unit } + .take(1) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst({ view, _ -> + view.onExtensionUninstalled() + }) } fun uninstallExtension() { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDividerItemDecoration.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionDividerItemDecoration.kt similarity index 51% rename from app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDividerItemDecoration.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionDividerItemDecoration.kt index 84798947f..839d19956 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDividerItemDecoration.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionDividerItemDecoration.kt @@ -1,12 +1,13 @@ -package eu.kanade.tachiyomi.ui.extension +package eu.kanade.tachiyomi.ui.browse.extension import android.content.Context import android.graphics.Canvas import android.graphics.Rect import android.graphics.drawable.Drawable import android.view.View +import androidx.recyclerview.widget.RecyclerView -class ExtensionDividerItemDecoration(context: Context) : androidx.recyclerview.widget.RecyclerView.ItemDecoration() { +class ExtensionDividerItemDecoration(context: Context) : RecyclerView.ItemDecoration() { private val divider: Drawable @@ -16,18 +17,19 @@ class ExtensionDividerItemDecoration(context: Context) : androidx.recyclerview.w a.recycle() } - override fun onDraw(c: Canvas, parent: androidx.recyclerview.widget.RecyclerView, state: androidx.recyclerview.widget.RecyclerView.State) { + override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { val childCount = parent.childCount for (i in 0 until childCount - 1) { val child = parent.getChildAt(i) val holder = parent.getChildViewHolder(child) if (holder is ExtensionHolder && - parent.getChildViewHolder(parent.getChildAt(i + 1)) is ExtensionHolder) { - val params = child.layoutParams as androidx.recyclerview.widget.RecyclerView.LayoutParams + parent.getChildViewHolder(parent.getChildAt(i + 1)) is ExtensionHolder + ) { + val params = child.layoutParams as RecyclerView.LayoutParams val top = child.bottom + params.bottomMargin val bottom = top + divider.intrinsicHeight - val left = parent.paddingLeft + holder.margin - val right = parent.width - parent.paddingRight - holder.margin + val left = parent.paddingStart + holder.margin + val right = parent.width - parent.paddingEnd - holder.margin divider.setBounds(left, top, right, bottom) divider.draw(c) @@ -35,9 +37,12 @@ class ExtensionDividerItemDecoration(context: Context) : androidx.recyclerview.w } } - override fun getItemOffsets(outRect: Rect, view: View, parent: androidx.recyclerview.widget.RecyclerView, - state: androidx.recyclerview.widget.RecyclerView.State) { + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { outRect.set(0, 0, 0, divider.intrinsicHeight) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionFilterController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionFilterController.kt new file mode 100644 index 000000000..5675b6d40 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionFilterController.kt @@ -0,0 +1,51 @@ +package eu.kanade.tachiyomi.ui.browse.extension + +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.extension.ExtensionManager +import eu.kanade.tachiyomi.ui.setting.SettingsController +import eu.kanade.tachiyomi.util.preference.onChange +import eu.kanade.tachiyomi.util.preference.switchPreference +import eu.kanade.tachiyomi.util.preference.titleRes +import eu.kanade.tachiyomi.util.system.LocaleHelper +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class ExtensionFilterController : SettingsController() { + + override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { + titleRes = R.string.action_filter + + val activeLangs = preferences.enabledLanguages().get() + + val availableLangs = + Injekt.get().availableExtensions.groupBy { + it.lang + }.keys.minus("all").partition { + it in activeLangs + }.let { + it.first + it.second + } + + availableLangs.forEach { + switchPreference { + preferenceScreen.addPreference(this) + title = LocaleHelper.getSourceDisplayName(it, context) + isPersistent = false + isChecked = it in activeLangs + + onChange { newValue -> + val checked = newValue as Boolean + val currentActiveLangs = preferences.enabledLanguages().get() + + if (checked) { + preferences.enabledLanguages().set(currentActiveLangs + it) + } else { + preferences.enabledLanguages().set(currentActiveLangs - it) + } + true + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupHolder.kt similarity index 52% rename from app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupHolder.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupHolder.kt index 85c2a597c..2712a4ee1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupHolder.kt @@ -1,16 +1,21 @@ -package eu.kanade.tachiyomi.ui.extension +package eu.kanade.tachiyomi.ui.browse.extension import android.annotation.SuppressLint import android.view.View import eu.davidea.flexibleadapter.FlexibleAdapter import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder -import kotlinx.android.synthetic.main.extension_card_header.title +import kotlinx.android.synthetic.main.source_main_controller_card_header.title class ExtensionGroupHolder(view: View, adapter: FlexibleAdapter<*>) : - BaseFlexibleViewHolder(view, adapter) { + BaseFlexibleViewHolder(view, adapter) { @SuppressLint("SetTextI18n") fun bind(item: ExtensionGroupItem) { - title.text = item.name + var text = item.name + if (item.showSize) { + text += " (${item.size})" + } + + title.text = text } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupItem.kt similarity index 64% rename from app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupItem.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupItem.kt index b0667775a..6e235f91c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupItem.kt @@ -1,6 +1,7 @@ -package eu.kanade.tachiyomi.ui.extension +package eu.kanade.tachiyomi.ui.browse.extension import android.view.View +import androidx.recyclerview.widget.RecyclerView import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractHeaderItem import eu.davidea.flexibleadapter.items.IFlexible @@ -12,28 +13,31 @@ import eu.kanade.tachiyomi.R * @param name The header name. * @param size The number of items in the group. */ -data class ExtensionGroupItem(val name: String, val size: Int) : AbstractHeaderItem() { +data class ExtensionGroupItem(val name: String, val size: Int, val showSize: Boolean = false) : AbstractHeaderItem() { /** * Returns the layout resource of this item. */ override fun getLayoutRes(): Int { - return R.layout.extension_card_header + return R.layout.source_main_controller_card_header } /** * Creates a new view holder for this item. */ - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): ExtensionGroupHolder { + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): ExtensionGroupHolder { return ExtensionGroupHolder(view, adapter) } /** * Binds this item to the given view holder. */ - override fun bindViewHolder(adapter: FlexibleAdapter>, holder: ExtensionGroupHolder, - position: Int, payloads: List?) { - + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: ExtensionGroupHolder, + position: Int, + payloads: List? + ) { holder.bind(this) } @@ -48,5 +52,4 @@ data class ExtensionGroupItem(val name: String, val size: Int) : AbstractHeaderI override fun hashCode(): Int { return name.hashCode() } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionHolder.kt similarity index 52% rename from app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionHolder.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionHolder.kt index bcf6b3a13..456806b38 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionHolder.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.ui.extension +package eu.kanade.tachiyomi.ui.browse.extension import android.view.View import eu.kanade.tachiyomi.R @@ -7,13 +7,19 @@ import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder import eu.kanade.tachiyomi.ui.base.holder.SlicedHolder -import eu.kanade.tachiyomi.util.LocaleHelper +import eu.kanade.tachiyomi.util.system.LocaleHelper +import eu.kanade.tachiyomi.util.system.getResourceColor import io.github.mthli.slice.Slice -import kotlinx.android.synthetic.main.extension_card_item.* +import kotlinx.android.synthetic.main.extension_card_item.card +import kotlinx.android.synthetic.main.extension_card_item.ext_button +import kotlinx.android.synthetic.main.extension_card_item.ext_title +import kotlinx.android.synthetic.main.extension_card_item.image +import kotlinx.android.synthetic.main.extension_card_item.lang +import kotlinx.android.synthetic.main.extension_card_item.version class ExtensionHolder(view: View, override val adapter: ExtensionAdapter) : - BaseFlexibleViewHolder(view, adapter), - SlicedHolder { + BaseFlexibleViewHolder(view, adapter), + SlicedHolder { override val slice = Slice(card).apply { setColor(adapter.cardBackground) @@ -24,7 +30,7 @@ class ExtensionHolder(view: View, override val adapter: ExtensionAdapter) : init { ext_button.setOnClickListener { - adapter.buttonClickListener.onButtonClick(adapterPosition) + adapter.buttonClickListener.onButtonClick(bindingAdapterPosition) } } @@ -36,7 +42,7 @@ class ExtensionHolder(view: View, override val adapter: ExtensionAdapter) : ext_title.text = extension.name version.text = extension.versionName lang.text = if (extension !is Extension.Untrusted) { - LocaleHelper.getDisplayName(extension.lang, itemView.context) + LocaleHelper.getSourceDisplayName(extension.lang, itemView.context) } else { itemView.context.getString(R.string.ext_untrusted).toUpperCase() } @@ -44,40 +50,50 @@ class ExtensionHolder(view: View, override val adapter: ExtensionAdapter) : GlideApp.with(itemView.context).clear(image) if (extension is Extension.Available) { GlideApp.with(itemView.context) - .load(extension.iconUrl) - .into(image) + .load(extension.iconUrl) + .into(image) } else { extension.getApplicationIcon(itemView.context)?.let { image.setImageDrawable(it) } } bindButton(item) } + @Suppress("ResourceType") fun bindButton(item: ExtensionItem) = with(ext_button) { isEnabled = true isClickable = true - isActivated = false + + setTextColor(context.getResourceColor(R.attr.colorAccent)) val extension = item.extension val installStep = item.installStep if (installStep != null) { - setText(when (installStep) { - InstallStep.Pending -> R.string.ext_pending - InstallStep.Downloading -> R.string.ext_downloading - InstallStep.Installing -> R.string.ext_installing - InstallStep.Installed -> R.string.ext_installed - InstallStep.Error -> R.string.action_retry - }) + setText( + when (installStep) { + InstallStep.Pending -> R.string.ext_pending + InstallStep.Downloading -> R.string.ext_downloading + InstallStep.Installing -> R.string.ext_installing + InstallStep.Installed -> R.string.ext_installed + InstallStep.Error -> R.string.action_retry + } + ) if (installStep != InstallStep.Error) { isEnabled = false isClickable = false } } else if (extension is Extension.Installed) { - if (extension.hasUpdate) { - isActivated = true - setText(R.string.ext_update) - } else { - setText(R.string.ext_details) + when { + extension.hasUpdate -> { + setText(R.string.ext_update) + } + extension.isObsolete -> { + setTextColor(context.getResourceColor(R.attr.colorError)) + setText(R.string.ext_obsolete) + } + else -> { + setText(R.string.ext_details) + } } } else if (extension is Extension.Untrusted) { setText(R.string.ext_trust) @@ -85,5 +101,4 @@ class ExtensionHolder(view: View, override val adapter: ExtensionAdapter) : setText(R.string.ext_install) } } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionItem.kt similarity index 70% rename from app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionItem.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionItem.kt index 09d0dc023..ddea87cc7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionItem.kt @@ -1,6 +1,7 @@ -package eu.kanade.tachiyomi.ui.extension +package eu.kanade.tachiyomi.ui.browse.extension import android.view.View +import androidx.recyclerview.widget.RecyclerView import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractSectionableItem import eu.davidea.flexibleadapter.items.IFlexible @@ -15,10 +16,12 @@ import eu.kanade.tachiyomi.source.CatalogueSource * @param source Instance of [CatalogueSource] containing source information. * @param header The header for this item. */ -data class ExtensionItem(val extension: Extension, - val header: ExtensionGroupItem? = null, - val installStep: InstallStep? = null) : - AbstractSectionableItem(header) { +data class ExtensionItem( + val extension: Extension, + val header: ExtensionGroupItem? = null, + val installStep: InstallStep? = null +) : + AbstractSectionableItem(header) { /** * Returns the layout resource of this item. @@ -30,16 +33,19 @@ data class ExtensionItem(val extension: Extension, /** * Creates a new view holder for this item. */ - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): ExtensionHolder { + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): ExtensionHolder { return ExtensionHolder(view, adapter as ExtensionAdapter) } /** * Binds this item to the given view holder. */ - override fun bindViewHolder(adapter: FlexibleAdapter>, holder: ExtensionHolder, - position: Int, payloads: List?) { - + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: ExtensionHolder, + position: Int, + payloads: List? + ) { if (payloads == null || payloads.isEmpty()) { holder.bind(this) } else { @@ -56,5 +62,4 @@ data class ExtensionItem(val extension: Extension, override fun hashCode(): Int { return extension.pkgName.hashCode() } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionPresenter.kt similarity index 60% rename from app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionPresenter.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionPresenter.kt index 04ff5f3ce..cf1be5fe4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionPresenter.kt @@ -1,28 +1,30 @@ -package eu.kanade.tachiyomi.ui.extension +package eu.kanade.tachiyomi.ui.browse.extension import android.app.Application import android.os.Bundle import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter -import eu.kanade.tachiyomi.util.LocaleHelper +import eu.kanade.tachiyomi.util.system.LocaleHelper +import java.util.concurrent.TimeUnit import rx.Observable import rx.Subscription import rx.android.schedulers.AndroidSchedulers import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.util.concurrent.TimeUnit -private typealias ExtensionTuple - = Triple, List, List> +private typealias ExtensionTuple = + Triple, List, List> /** * Presenter of [ExtensionController]. */ open class ExtensionPresenter( - private val extensionManager: ExtensionManager = Injekt.get() + private val extensionManager: ExtensionManager = Injekt.get(), + private val preferences: PreferencesHelper = Injekt.get() ) : BasePresenter() { private var extensions = emptyList() @@ -40,32 +42,42 @@ open class ExtensionPresenter( val installedObservable = extensionManager.getInstalledExtensionsObservable() val untrustedObservable = extensionManager.getUntrustedExtensionsObservable() val availableObservable = extensionManager.getAvailableExtensionsObservable() - .startWith(emptyList()) + .startWith(emptyList()) - return Observable.combineLatest(installedObservable, untrustedObservable, availableObservable) - { installed, untrusted, available -> Triple(installed, untrusted, available) } - .debounce(100, TimeUnit.MILLISECONDS) - .map(::toItems) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeLatestCache({ view, _ -> view.setExtensions(extensions) }) + return Observable.combineLatest(installedObservable, untrustedObservable, availableObservable) { installed, untrusted, available -> Triple(installed, untrusted, available) } + .debounce(100, TimeUnit.MILLISECONDS) + .map(::toItems) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeLatestCache({ view, _ -> view.setExtensions(extensions) }) } @Synchronized private fun toItems(tuple: ExtensionTuple): List { val context = Injekt.get() + val activeLangs = preferences.enabledLanguages().get() val (installed, untrusted, available) = tuple val items = mutableListOf() - val installedSorted = installed.sortedWith(compareBy({ !it.hasUpdate }, { it.pkgName })) + val updatesSorted = installed.filter { it.hasUpdate }.sortedBy { it.pkgName } + val installedSorted = installed.filter { !it.hasUpdate }.sortedWith(compareBy({ !it.isObsolete }, { it.pkgName })) val untrustedSorted = untrusted.sortedBy { it.pkgName } val availableSorted = available - // Filter out already installed extensions - .filter { avail -> installed.none { it.pkgName == avail.pkgName } - && untrusted.none { it.pkgName == avail.pkgName } } - .sortedBy { it.pkgName } + // Filter out already installed extensions and disabled languages + .filter { avail -> + installed.none { it.pkgName == avail.pkgName } && + untrusted.none { it.pkgName == avail.pkgName } && + (avail.lang in activeLangs || avail.lang == "all") + } + .sortedBy { it.pkgName } + if (updatesSorted.isNotEmpty()) { + val header = ExtensionGroupItem(context.getString(R.string.ext_updates_pending), updatesSorted.size, true) + items += updatesSorted.map { extension -> + ExtensionItem(extension, header, currentDownloads[extension.pkgName]) + } + } if (installedSorted.isNotEmpty() || untrustedSorted.isNotEmpty()) { val header = ExtensionGroupItem(context.getString(R.string.ext_installed), installedSorted.size + untrustedSorted.size) items += installedSorted.map { extension -> @@ -77,16 +89,16 @@ open class ExtensionPresenter( } if (availableSorted.isNotEmpty()) { val availableGroupedByLang = availableSorted - .groupBy { LocaleHelper.getDisplayName(it.lang, context) } - .toSortedMap() + .groupBy { LocaleHelper.getSourceDisplayName(it.lang, context) } + .toSortedMap() availableGroupedByLang - .forEach { - val header = ExtensionGroupItem(it.key, it.value.size) - items += it.value.map { extension -> - ExtensionItem(extension, header, currentDownloads[extension.pkgName]) - } + .forEach { + val header = ExtensionGroupItem(it.key, it.value.size) + items += it.value.map { extension -> + ExtensionItem(extension, header, currentDownloads[extension.pkgName]) } + } } this.extensions = items @@ -119,13 +131,13 @@ open class ExtensionPresenter( private fun Observable.subscribeToInstallUpdate(extension: Extension) { this.doOnNext { currentDownloads[extension.pkgName] = it } - .doOnUnsubscribe { currentDownloads.remove(extension.pkgName) } - .map { state -> updateInstallStep(extension, state) } - .subscribeWithView({ view, item -> - if (item != null) { - view.downloadUpdate(item) - } - }) + .doOnUnsubscribe { currentDownloads.remove(extension.pkgName) } + .map { state -> updateInstallStep(extension, state) } + .subscribeWithView({ view, item -> + if (item != null) { + view.downloadUpdate(item) + } + }) } fun uninstallExtension(pkgName: String) { @@ -139,5 +151,4 @@ open class ExtensionPresenter( fun trustSignature(signatureHash: String) { extensionManager.trustSignature(signatureHash) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionTrustDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionTrustDialog.kt new file mode 100644 index 000000000..3a91724aa --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionTrustDialog.kt @@ -0,0 +1,43 @@ +package eu.kanade.tachiyomi.ui.browse.extension + +import android.app.Dialog +import android.os.Bundle +import com.afollestad.materialdialogs.MaterialDialog +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.base.controller.DialogController + +class ExtensionTrustDialog(bundle: Bundle? = null) : DialogController(bundle) + where T : Controller, T : ExtensionTrustDialog.Listener { + + constructor(target: T, signatureHash: String, pkgName: String) : this( + Bundle().apply { + putString(SIGNATURE_KEY, signatureHash) + putString(PKGNAME_KEY, pkgName) + } + ) { + targetController = target + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + return MaterialDialog(activity!!) + .title(R.string.untrusted_extension) + .message(R.string.untrusted_extension_message) + .positiveButton(R.string.ext_trust) { + (targetController as? Listener)?.trustSignature(args.getString(SIGNATURE_KEY)!!) + } + .negativeButton(R.string.ext_uninstall) { + (targetController as? Listener)?.uninstallExtension(args.getString(PKGNAME_KEY)!!) + } + } + + private companion object { + const val SIGNATURE_KEY = "signature_key" + const val PKGNAME_KEY = "pkgname_key" + } + + interface Listener { + fun trustSignature(signatureHash: String) + fun uninstallExtension(pkgName: String) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionViewUtils.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionViewUtils.kt similarity index 88% rename from app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionViewUtils.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionViewUtils.kt index b3a30e4d9..a4bae2484 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionViewUtils.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionViewUtils.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.ui.extension +package eu.kanade.tachiyomi.ui.browse.extension import android.content.Context import android.content.pm.PackageManager diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/LangHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/LangHolder.kt new file mode 100644 index 000000000..d62ccd601 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/LangHolder.kt @@ -0,0 +1,15 @@ +package eu.kanade.tachiyomi.ui.browse.source + +import android.view.View +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder +import eu.kanade.tachiyomi.util.system.LocaleHelper +import kotlinx.android.synthetic.main.source_main_controller_card_header.title + +class LangHolder(view: View, adapter: FlexibleAdapter<*>) : + BaseFlexibleViewHolder(view, adapter) { + + fun bind(item: LangItem) { + title.text = LocaleHelper.getSourceDisplayName(item.code, itemView.context) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/LangItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/LangItem.kt similarity index 65% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/LangItem.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/LangItem.kt index 83a8da6b7..189c1c41d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/LangItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/LangItem.kt @@ -1,6 +1,7 @@ -package eu.kanade.tachiyomi.ui.catalogue +package eu.kanade.tachiyomi.ui.browse.source import android.view.View +import androidx.recyclerview.widget.RecyclerView import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractHeaderItem import eu.davidea.flexibleadapter.items.IFlexible @@ -17,23 +18,25 @@ data class LangItem(val code: String) : AbstractHeaderItem() { * Returns the layout resource of this item. */ override fun getLayoutRes(): Int { - return R.layout.catalogue_main_controller_card + return R.layout.source_main_controller_card_header } /** * Creates a new view holder for this item. */ - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): LangHolder { + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): LangHolder { return LangHolder(view, adapter) } /** * Binds this item to the given view holder. */ - override fun bindViewHolder(adapter: FlexibleAdapter>, holder: LangHolder, - position: Int, payloads: List?) { - + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: LangHolder, + position: Int, + payloads: MutableList + ) { holder.bind(this) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceAdapter.kt similarity index 67% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueAdapter.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceAdapter.kt index 1b1f09d9a..8f9557d70 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceAdapter.kt @@ -1,19 +1,19 @@ -package eu.kanade.tachiyomi.ui.catalogue +package eu.kanade.tachiyomi.ui.browse.source import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.IFlexible import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.util.getResourceColor +import eu.kanade.tachiyomi.util.system.getResourceColor /** * Adapter that holds the catalogue cards. * - * @param controller instance of [CatalogueController]. + * @param controller instance of [SourceController]. */ -class CatalogueAdapter(val controller: CatalogueController) : - FlexibleAdapter>(null, controller, true) { +class SourceAdapter(val controller: SourceController) : + FlexibleAdapter>(null, controller, true) { - val cardBackground = controller.activity!!.getResourceColor(R.attr.background_card) + val cardBackground = controller.activity!!.getResourceColor(R.attr.colorSurface) init { setDisplayHeadersAtStartUp(true) @@ -31,7 +31,7 @@ class CatalogueAdapter(val controller: CatalogueController) : /** * Listener which should be called when user clicks browse. - * Note: Should only be handled by [CatalogueController] + * Note: Should only be handled by [SourceController] */ interface OnBrowseClickListener { fun onBrowseClick(position: Int) @@ -39,10 +39,9 @@ class CatalogueAdapter(val controller: CatalogueController) : /** * Listener which should be called when user clicks latest. - * Note: Should only be handled by [CatalogueController] + * Note: Should only be handled by [SourceController] */ interface OnLatestClickListener { fun onLatestClick(position: Int) } } - diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceController.kt new file mode 100644 index 000000000..a2632b469 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceController.kt @@ -0,0 +1,253 @@ +package eu.kanade.tachiyomi.ui.browse.source + +import android.Manifest.permission.WRITE_EXTERNAL_STORAGE +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.SearchView +import androidx.recyclerview.widget.LinearLayoutManager +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.list.listItems +import com.bluelinelabs.conductor.ControllerChangeHandler +import com.bluelinelabs.conductor.ControllerChangeType +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.IFlexible +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.databinding.SourceMainControllerBinding +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.ui.base.controller.NucleusController +import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe +import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction +import eu.kanade.tachiyomi.ui.browse.BrowseController +import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController +import eu.kanade.tachiyomi.ui.browse.source.latest.LatestUpdatesController +import eu.kanade.tachiyomi.ui.setting.SettingsSourcesController +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.appcompat.QueryTextEvent +import reactivecircus.flowbinding.appcompat.queryTextEvents +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +/** + * This controller shows and manages the different catalogues enabled by the user. + * This controller should only handle UI actions, IO actions should be done by [SourcePresenter] + * [SourceAdapter.OnBrowseClickListener] call function data on browse item click. + * [SourceAdapter.OnLatestClickListener] call function data on latest item click + */ +class SourceController : + NucleusController(), + FlexibleAdapter.OnItemClickListener, + FlexibleAdapter.OnItemLongClickListener, + SourceAdapter.OnBrowseClickListener, + SourceAdapter.OnLatestClickListener { + + private val preferences: PreferencesHelper = Injekt.get() + + /** + * Adapter containing sources. + */ + private var adapter: SourceAdapter? = null + + init { + setHasOptionsMenu(true) + } + + override fun getTitle(): String? { + return applicationContext?.getString(R.string.label_sources) + } + + override fun createPresenter(): SourcePresenter { + return SourcePresenter() + } + + /** + * Initiate the view with [R.layout.source_main_controller]. + * + * @param inflater used to load the layout xml. + * @param container containing parent views. + * @return inflated view. + */ + override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { + binding = SourceMainControllerBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View) { + super.onViewCreated(view) + + adapter = SourceAdapter(this) + + // Create recycler and set adapter. + binding.recycler.layoutManager = LinearLayoutManager(view.context) + binding.recycler.adapter = adapter + binding.recycler.addItemDecoration(SourceDividerItemDecoration(view.context)) + adapter?.fastScroller = binding.fastScroller + + requestPermissionsSafe(arrayOf(WRITE_EXTERNAL_STORAGE), 301) + + // Update list on extension changes (e.g. new installation) + (parentController as BrowseController).extensionListUpdateRelay + .subscribeUntilDestroy { + presenter.updateSources() + } + } + + override fun onDestroyView(view: View) { + adapter = null + super.onDestroyView(view) + } + + override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { + super.onChangeStarted(handler, type) + if (type.isPush) { + presenter.updateSources() + } + } + + override fun onItemClick(view: View, position: Int): Boolean { + val item = adapter?.getItem(position) as? SourceItem ?: return false + val source = item.source + openCatalogue(source, BrowseSourceController(source)) + return false + } + + override fun onItemLongClick(position: Int) { + val activity = activity ?: return + val item = adapter?.getItem(position) as? SourceItem ?: return + + val isPinned = item.header?.code?.equals(SourcePresenter.PINNED_KEY) ?: false + + MaterialDialog(activity) + .title(text = item.source.name) + .listItems( + items = listOf( + activity.getString(R.string.action_hide), + activity.getString(if (isPinned) R.string.action_unpin else R.string.action_pin) + ), + waitForPositiveButton = false + ) { dialog, which, _ -> + when (which) { + 0 -> hideCatalogue(item.source) + 1 -> pinCatalogue(item.source, isPinned) + } + dialog.dismiss() + } + .show() + } + + private fun hideCatalogue(source: Source) { + val current = preferences.hiddenCatalogues().get() + preferences.hiddenCatalogues().set(current + source.id.toString()) + + presenter.updateSources() + } + + private fun pinCatalogue(source: Source, isPinned: Boolean) { + val current = preferences.pinnedCatalogues().get() + if (isPinned) { + preferences.pinnedCatalogues().set(current - source.id.toString()) + } else { + preferences.pinnedCatalogues().set(current + source.id.toString()) + } + + presenter.updateSources() + } + + /** + * Called when browse is clicked in [SourceAdapter] + */ + override fun onBrowseClick(position: Int) { + onItemClick(view!!, position) + } + + /** + * Called when latest is clicked in [SourceAdapter] + */ + override fun onLatestClick(position: Int) { + val item = adapter?.getItem(position) as? SourceItem ?: return + openCatalogue(item.source, LatestUpdatesController(item.source)) + } + + /** + * Opens a catalogue with the given controller. + */ + private fun openCatalogue(source: CatalogueSource, controller: BrowseSourceController) { + preferences.lastUsedCatalogueSource().set(source.id) + (parentController as BrowseController).pushController(controller.withFadeTransaction()) + } + + /** + * Adds items to the options menu. + * + * @param menu menu containing options. + * @param inflater used to load the menu xml. + */ + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + // Inflate menu + inflater.inflate(R.menu.source_main, menu) + + // Initialize search option. + val searchItem = menu.findItem(R.id.action_search) + val searchView = searchItem.actionView as SearchView + searchView.maxWidth = Int.MAX_VALUE + + // Change hint to show global search. + searchView.queryHint = applicationContext?.getString(R.string.action_global_search_hint) + + // Create query listener which opens the global search view. + searchView.queryTextEvents() + .filter { it is QueryTextEvent.QuerySubmitted } + .onEach { performGlobalSearch(it.queryText.toString()) } + .launchIn(scope) + } + + private fun performGlobalSearch(query: String) { + (parentController as BrowseController).pushController( + GlobalSearchController(query).withFadeTransaction() + ) + } + + /** + * Called when an option menu item has been selected by the user. + * + * @param item The selected item. + * @return True if this event has been consumed, false if it has not. + */ + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + // Initialize option to open catalogue settings. + R.id.action_settings -> { + (parentController as BrowseController).pushController( + SettingsSourcesController().withFadeTransaction() + ) + } + } + return super.onOptionsItemSelected(item) + } + + /** + * Called to update adapter containing sources. + */ + fun setSources(sources: List>) { + adapter?.updateDataSet(sources) + } + + /** + * Called to set the last used catalogue at the top of the view. + */ + fun setLastUsedSource(item: SourceItem?) { + adapter?.removeAllScrollableHeaders() + if (item != null) { + adapter?.addScrollableHeader(item) + adapter?.addScrollableHeader(LangItem(SourcePresenter.LAST_USED_KEY)) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/SourceDividerItemDecoration.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceDividerItemDecoration.kt similarity index 51% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/SourceDividerItemDecoration.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceDividerItemDecoration.kt index 951d0238a..a64de8401 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/SourceDividerItemDecoration.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceDividerItemDecoration.kt @@ -1,12 +1,13 @@ -package eu.kanade.tachiyomi.ui.catalogue +package eu.kanade.tachiyomi.ui.browse.source import android.content.Context import android.graphics.Canvas import android.graphics.Rect import android.graphics.drawable.Drawable import android.view.View +import androidx.recyclerview.widget.RecyclerView -class SourceDividerItemDecoration(context: Context) : androidx.recyclerview.widget.RecyclerView.ItemDecoration() { +class SourceDividerItemDecoration(context: Context) : RecyclerView.ItemDecoration() { private val divider: Drawable @@ -16,18 +17,19 @@ class SourceDividerItemDecoration(context: Context) : androidx.recyclerview.widg a.recycle() } - override fun onDraw(c: Canvas, parent: androidx.recyclerview.widget.RecyclerView, state: androidx.recyclerview.widget.RecyclerView.State) { + override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { val childCount = parent.childCount for (i in 0 until childCount - 1) { val child = parent.getChildAt(i) val holder = parent.getChildViewHolder(child) if (holder is SourceHolder && - parent.getChildViewHolder(parent.getChildAt(i + 1)) is SourceHolder) { - val params = child.layoutParams as androidx.recyclerview.widget.RecyclerView.LayoutParams + parent.getChildViewHolder(parent.getChildAt(i + 1)) is SourceHolder + ) { + val params = child.layoutParams as RecyclerView.LayoutParams val top = child.bottom + params.bottomMargin val bottom = top + divider.intrinsicHeight - val left = parent.paddingLeft + holder.margin - val right = parent.width - parent.paddingRight - holder.margin + val left = parent.paddingStart + holder.margin + val right = parent.width - parent.paddingEnd - holder.margin divider.setBounds(left, top, right, bottom) divider.draw(c) @@ -35,9 +37,12 @@ class SourceDividerItemDecoration(context: Context) : androidx.recyclerview.widg } } - override fun getItemOffsets(outRect: Rect, view: View, parent: androidx.recyclerview.widget.RecyclerView, - state: androidx.recyclerview.widget.RecyclerView.State) { + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { outRect.set(0, 0, 0, divider.intrinsicHeight) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceHolder.kt new file mode 100644 index 000000000..d9c1df465 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceHolder.kt @@ -0,0 +1,62 @@ +package eu.kanade.tachiyomi.ui.browse.source + +import android.view.View +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.icon +import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder +import eu.kanade.tachiyomi.ui.base.holder.SlicedHolder +import eu.kanade.tachiyomi.util.view.gone +import eu.kanade.tachiyomi.util.view.visible +import io.github.mthli.slice.Slice +import kotlinx.android.synthetic.main.source_main_controller_card_item.card +import kotlinx.android.synthetic.main.source_main_controller_card_item.image +import kotlinx.android.synthetic.main.source_main_controller_card_item.source_browse +import kotlinx.android.synthetic.main.source_main_controller_card_item.source_latest +import kotlinx.android.synthetic.main.source_main_controller_card_item.title + +class SourceHolder(view: View, override val adapter: SourceAdapter) : + BaseFlexibleViewHolder(view, adapter), + SlicedHolder { + + override val slice = Slice(card).apply { + setColor(adapter.cardBackground) + } + + override val viewToSlice: View + get() = card + + init { + source_browse.setOnClickListener { + adapter.browseClickListener.onBrowseClick(bindingAdapterPosition) + } + + source_latest.setOnClickListener { + adapter.latestClickListener.onLatestClick(bindingAdapterPosition) + } + } + + fun bind(item: SourceItem) { + val source = item.source + setCardEdges(item) + + // Set source name + title.text = source.name + + // Set circle letter image. + itemView.post { + val icon = source.icon() + when { + icon != null -> image.setImageDrawable(icon) + item.source.id == LocalSource.ID -> image.setImageResource(R.mipmap.ic_local_source) + } + } + + source_browse.setText(R.string.browse) + if (source.supportsLatest) { + source_latest.visible() + } else { + source_latest.gone() + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/SourceItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceItem.kt similarity index 58% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/SourceItem.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceItem.kt index 365c06df4..56008a5a9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/SourceItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceItem.kt @@ -1,6 +1,7 @@ -package eu.kanade.tachiyomi.ui.catalogue +package eu.kanade.tachiyomi.ui.browse.source import android.view.View +import androidx.recyclerview.widget.RecyclerView import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractSectionableItem import eu.davidea.flexibleadapter.items.IFlexible @@ -13,30 +14,32 @@ import eu.kanade.tachiyomi.source.CatalogueSource * @param source Instance of [CatalogueSource] containing source information. * @param header The header for this item. */ -data class SourceItem(val source: CatalogueSource, val header: LangItem? = null, val showButtons: Boolean) : - AbstractSectionableItem(header) { +data class SourceItem(val source: CatalogueSource, val header: LangItem? = null) : + AbstractSectionableItem(header) { /** * Returns the layout resource of this item. */ override fun getLayoutRes(): Int { - return R.layout.catalogue_main_controller_card_item + return R.layout.source_main_controller_card_item } /** * Creates a new view holder for this item. */ - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): SourceHolder { - return SourceHolder(view, adapter as CatalogueAdapter, showButtons) + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): SourceHolder { + return SourceHolder(view, adapter as SourceAdapter) } /** * Binds this item to the given view holder. */ - override fun bindViewHolder(adapter: FlexibleAdapter>, holder: SourceHolder, - position: Int, payloads: List?) { - + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: SourceHolder, + position: Int, + payloads: MutableList + ) { holder.bind(this) } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CataloguePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcePresenter.kt old mode 100755 new mode 100644 similarity index 57% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CataloguePresenter.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcePresenter.kt index daac2580e..65aab36dd --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CataloguePresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcePresenter.kt @@ -1,36 +1,31 @@ -package eu.kanade.tachiyomi.ui.catalogue +package eu.kanade.tachiyomi.ui.browse.source import android.os.Bundle import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import java.util.TreeMap +import java.util.concurrent.TimeUnit import rx.Observable import rx.Subscription import rx.android.schedulers.AndroidSchedulers import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.util.* -import java.util.concurrent.TimeUnit /** - * Presenter of [CatalogueController] + * Presenter of [SourceController] * Function calls should be done from here. UI calls should be done from the controller. * * @param sourceManager manages the different sources. * @param preferences application preferences. */ -class CataloguePresenter( - val sourceManager: SourceManager = Injekt.get(), - private val preferences: PreferencesHelper = Injekt.get(), - private val controllerMode: CatalogueController.Mode -) : BasePresenter() { +class SourcePresenter( + val sourceManager: SourceManager = Injekt.get(), + private val preferences: PreferencesHelper = Injekt.get() +) : BasePresenter() { - /** - * Enabled sources. - */ var sources = getEnabledSources() /** @@ -49,9 +44,12 @@ class CataloguePresenter( /** * Unsubscribe and create a new subscription to fetch enabled sources. */ - fun loadSources() { + private fun loadSources() { sourceSubscription?.unsubscribe() + val pinnedSources = mutableListOf() + val pinnedCatalogues = preferences.pinnedCatalogues().get() + val map = TreeMap> { d1, d2 -> // Catalogues without a lang defined will be placed at the end when { @@ -61,13 +59,23 @@ class CataloguePresenter( } } val byLang = sources.groupByTo(map, { it.lang }) - val sourceItems = byLang.flatMap { + var sourceItems = byLang.flatMap { val langItem = LangItem(it.key) - it.value.map { source -> SourceItem(source, langItem, controllerMode == CatalogueController.Mode.CATALOGUE) } + it.value.map { source -> + if (source.id.toString() in pinnedCatalogues) { + pinnedSources.add(SourceItem(source, LangItem(PINNED_KEY))) + } + + SourceItem(source, langItem) + } + } + + if (pinnedSources.isNotEmpty()) { + sourceItems = pinnedSources + sourceItems } sourceSubscription = Observable.just(sourceItems) - .subscribeLatestCache(CatalogueController::setSources) + .subscribeLatestCache(SourceController::setSources) } private fun loadLastUsedSource() { @@ -75,11 +83,12 @@ class CataloguePresenter( // Emit the first item immediately but delay subsequent emissions by 500ms. Observable.merge( - sharedObs.take(1), - sharedObs.skip(1).delay(500, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())) - .distinctUntilChanged() - .map { (sourceManager.get(it) as? CatalogueSource)?.let { SourceItem(it, showButtons = controllerMode == CatalogueController.Mode.CATALOGUE) } } - .subscribeLatestCache(CatalogueController::setLastUsedSource) + sharedObs.take(1), + sharedObs.skip(1).delay(500, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) + ) + .distinctUntilChanged() + .map { item -> (sourceManager.get(item) as? CatalogueSource)?.let { SourceItem(it) } } + .subscribeLatestCache(SourceController::setLastUsedSource) } fun updateSources() { @@ -93,13 +102,18 @@ class CataloguePresenter( * @return list containing enabled sources. */ private fun getEnabledSources(): List { - val languages = preferences.enabledLanguages().getOrDefault() - val hiddenCatalogues = preferences.hiddenCatalogues().getOrDefault() + val languages = preferences.enabledLanguages().get() + val hiddenCatalogues = preferences.hiddenCatalogues().get() - return sourceManager.getVisibleCatalogueSources() - .filter { it.lang in languages } - .filterNot { it.id.toString() in hiddenCatalogues } - .sortedBy { "(${it.lang}) ${it.name}" } + - sourceManager.get(LocalSource.ID) as LocalSource + return sourceManager.getCatalogueSources() + .filter { it.lang in languages } + .filterNot { it.id.toString() in hiddenCatalogues } + .sortedBy { "(${it.lang}) ${it.name}" } + + sourceManager.get(LocalSource.ID) as LocalSource + } + + companion object { + const val PINNED_KEY = "pinned" + const val LAST_USED_KEY = "last_used" } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt new file mode 100644 index 000000000..4b77da0e7 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt @@ -0,0 +1,622 @@ +package eu.kanade.tachiyomi.ui.browse.source.browse + +import android.content.res.Configuration +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.SearchView +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.list.listItems +import com.f2prateek.rx.preferences.Preference +import com.google.android.material.snackbar.Snackbar +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.IFlexible +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.databinding.SourceControllerBinding +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.ui.base.controller.NucleusController +import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction +import eu.kanade.tachiyomi.ui.catalogue.CatalogueController +import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog +import eu.kanade.tachiyomi.ui.main.offsetFabAppbarHeight +import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.webview.WebViewActivity +import eu.kanade.tachiyomi.util.system.connectivityManager +import eu.kanade.tachiyomi.util.system.openInBrowser +import eu.kanade.tachiyomi.util.system.toast +import eu.kanade.tachiyomi.util.view.gone +import eu.kanade.tachiyomi.util.view.inflate +import eu.kanade.tachiyomi.util.view.shrinkOnScroll +import eu.kanade.tachiyomi.util.view.snack +import eu.kanade.tachiyomi.util.view.visible +import eu.kanade.tachiyomi.widget.AutofitRecyclerView +import eu.kanade.tachiyomi.widget.EmptyView +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.appcompat.QueryTextEvent +import reactivecircus.flowbinding.appcompat.queryTextEvents +import rx.Subscription +import timber.log.Timber +import uy.kohesive.injekt.injectLazy + +/** + * Controller to manage the catalogues available in the app. + */ +open class BrowseSourceController(bundle: Bundle) : + NucleusController(bundle), + FlexibleAdapter.OnItemClickListener, + FlexibleAdapter.OnItemLongClickListener, + FlexibleAdapter.EndlessScrollListener, + ChangeMangaCategoriesDialog.Listener { + + constructor(source: CatalogueSource, + searchQuery: String? = null, + smartSearchConfig: CatalogueController.SmartSearchConfig? = null) : this( + Bundle().apply { + putLong(SOURCE_ID_KEY, source.id) + + if(searchQuery != null) + putString(SEARCH_QUERY_KEY, searchQuery) + + if (smartSearchConfig != null) + putParcelable(SMART_SEARCH_CONFIG_KEY, smartSearchConfig) + } + ) + + private val preferences: PreferencesHelper by injectLazy() + + /** + * Adapter containing the list of manga from the catalogue. + */ + private var adapter: FlexibleAdapter>? = null + + /** + * Snackbar containing an error message when a request fails. + */ + private var snack: Snackbar? = null + + /** + * Sheet containing filter items. + */ + private var filterSheet: SourceFilterSheet? = null + + /** + * Recycler view with the list of results. + */ + private var recycler: RecyclerView? = null + + /** + * Subscription for the number of manga per row. + */ + private var numColumnsSubscription: Subscription? = null + + /** + * Endless loading item. + */ + private var progressItem: ProgressItem? = null + + init { + setHasOptionsMenu(true) + } + + override fun getTitle(): String? { + return presenter.source.name + } + + override fun createPresenter(): BrowseSourcePresenter { + return BrowseSourcePresenter(args.getLong(SOURCE_ID_KEY), + args.getString(SEARCH_QUERY_KEY)) + } + + override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { + binding = SourceControllerBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View) { + super.onViewCreated(view) + + // Prepare filter sheet + initFilterSheet() + + // Initialize adapter, scroll listener and recycler views + adapter = FlexibleAdapter(null, this) + setupRecycler(view) + + binding.progress.visible() + } + + open fun initFilterSheet() { + if (presenter.sourceFilters.isEmpty()) { + return + } + + filterSheet = SourceFilterSheet( + activity!!, + onFilterClicked = { + val allDefault = presenter.sourceFilters == presenter.source.getFilterList() + showProgressBar() + adapter?.clear() + presenter.setSourceFilter(if (allDefault) FilterList() else presenter.sourceFilters) + }, + onResetClicked = { + presenter.appliedFilters = FilterList() + val newFilters = presenter.source.getFilterList() + presenter.sourceFilters = newFilters + filterSheet?.setFilters(presenter.filterItems) + } + ) + filterSheet?.setFilters(presenter.filterItems) + + // TODO: [ExtendedFloatingActionButton] hide/show methods don't work properly + filterSheet?.setOnShowListener { binding.fabFilter.gone() } + filterSheet?.setOnDismissListener { binding.fabFilter.visible() } + + binding.fabFilter.setOnClickListener { filterSheet?.show() } + + binding.fabFilter.offsetFabAppbarHeight(activity!!) + binding.fabFilter.visible() + } + + override fun onDestroyView(view: View) { + numColumnsSubscription?.unsubscribe() + numColumnsSubscription = null + adapter = null + snack = null + recycler = null + super.onDestroyView(view) + } + + private fun setupRecycler(view: View) { + numColumnsSubscription?.unsubscribe() + + var oldPosition = RecyclerView.NO_POSITION + val oldRecycler = binding.catalogueView.getChildAt(1) + if (oldRecycler is RecyclerView) { + oldPosition = (oldRecycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() + oldRecycler.adapter = null + + binding.catalogueView.removeView(oldRecycler) + } + + val recycler = if (presenter.isListMode) { + RecyclerView(view.context).apply { + id = R.id.recycler + layoutManager = LinearLayoutManager(context) + layoutParams = RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) + } + } else { + (binding.catalogueView.inflate(R.layout.source_recycler_autofit) as AutofitRecyclerView).apply { + numColumnsSubscription = getColumnsPreferenceForCurrentOrientation().asObservable() + .doOnNext { spanCount = it } + .skip(1) + // Set again the adapter to recalculate the covers height + .subscribe { adapter = this@BrowseSourceController.adapter } + + (layoutManager as GridLayoutManager).spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { + override fun getSpanSize(position: Int): Int { + return when (adapter?.getItemViewType(position)) { + R.layout.source_grid_item, null -> 1 + else -> spanCount + } + } + } + } + } + + if (filterSheet != null) { + // Add bottom padding if filter FAB is visible + recycler.setPadding( + recycler.paddingLeft, + recycler.paddingTop, + recycler.paddingRight, + view.resources.getDimensionPixelOffset(R.dimen.fab_list_padding) + ) + recycler.clipToPadding = false + + binding.fabFilter.shrinkOnScroll(recycler) + } + + recycler.setHasFixedSize(true) + recycler.adapter = adapter + + binding.catalogueView.addView(recycler, 1) + + if (oldPosition != RecyclerView.NO_POSITION) { + recycler.layoutManager?.scrollToPosition(oldPosition) + } + this.recycler = recycler + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.source_browse, menu) + + // Initialize search menu + val searchItem = menu.findItem(R.id.action_search) + val searchView = searchItem.actionView as SearchView + searchView.maxWidth = Int.MAX_VALUE + + val query = presenter.query + if (!query.isBlank()) { + searchItem.expandActionView() + searchView.setQuery(query, true) + searchView.clearFocus() + } + + searchView.queryTextEvents() + .filter { router.backstack.lastOrNull()?.controller() == this@BrowseSourceController } + .filter { it is QueryTextEvent.QuerySubmitted } + .onEach { searchWithQuery(it.queryText.toString()) } + .launchIn(scope) + + searchItem.fixExpand( + onExpand = { invalidateMenuOnExpand() }, + onCollapse = { + searchWithQuery("") + true + } + ) + + // Show next display mode + menu.findItem(R.id.action_display_mode).apply { + val icon = if (presenter.isListMode) { + R.drawable.ic_view_module_24dp + } else { + R.drawable.ic_view_list_24dp + } + setIcon(icon) + } + } + + override fun onPrepareOptionsMenu(menu: Menu) { + super.onPrepareOptionsMenu(menu) + + val isHttpSource = presenter.source is HttpSource + menu.findItem(R.id.action_open_in_web_view).isVisible = isHttpSource + + val isLocalSource = presenter.source is LocalSource + menu.findItem(R.id.action_local_source_help).isVisible = isLocalSource + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_search -> expandActionViewFromInteraction = true + R.id.action_display_mode -> swapDisplayMode() + R.id.action_open_in_web_view -> openInWebView() + R.id.action_local_source_help -> openLocalSourceHelpGuide() + } + return super.onOptionsItemSelected(item) + } + + private fun openInWebView() { + val source = presenter.source as? HttpSource ?: return + + val activity = activity ?: return + val intent = WebViewActivity.newIntent(activity, source.baseUrl, source.id, presenter.source.name) + startActivity(intent) + } + + private fun openLocalSourceHelpGuide() { + activity?.openInBrowser(LocalSource.HELP_URL) + } + + /** + * Restarts the request with a new query. + * + * @param newQuery the new query. + */ + fun searchWithQuery(newQuery: String) { + // If text didn't change, do nothing + if (presenter.query == newQuery) { + return + } + + showProgressBar() + adapter?.clear() + + presenter.restartPager(newQuery) + } + + /** + * Called from the presenter when the network request is received. + * + * @param page the current page. + * @param mangas the list of manga of the page. + */ + fun onAddPage(page: Int, mangas: List) { + val adapter = adapter ?: return + hideProgressBar() + if (page == 1) { + adapter.clear() + resetProgressItem() + } + adapter.onLoadMoreComplete(mangas) + } + + /** + * Called from the presenter when the network request fails. + * + * @param error the error received. + */ + fun onAddPageError(error: Throwable) { + XLog.w("> Failed to load next catalogue page!", error) + XLog.w("> (source.id: %s, source.name: %s)", + presenter.source.id, + presenter.source.name) + + val adapter = adapter ?: return + adapter.onLoadMoreComplete(null) + hideProgressBar() + + snack?.dismiss() + + val message = getErrorMessage(error) + val retryAction = View.OnClickListener { + // If not the first page, show bottom progress bar. + if (adapter.mainItemCount > 0 && progressItem != null) { + adapter.addScrollableFooterWithDelay(progressItem!!, 0, true) + } else { + showProgressBar() + } + presenter.requestNext() + } + + if (adapter.isEmpty) { + val actions = emptyList().toMutableList() + + if (presenter.source is LocalSource) { + actions += EmptyView.Action(R.string.local_source_help_guide, View.OnClickListener { openLocalSourceHelpGuide() }) + } else { + actions += EmptyView.Action(R.string.action_retry, retryAction) + } + + if (presenter.source is HttpSource) { + actions += EmptyView.Action(R.string.action_open_in_web_view, View.OnClickListener { openInWebView() }) + } + + binding.emptyView.show(message, actions) + } else { + snack = binding.catalogueView.snack(message, Snackbar.LENGTH_INDEFINITE) { + setAction(R.string.action_retry, retryAction) + } + } + } + + private fun getErrorMessage(error: Throwable): String { + if (error is NoResultsException) { + return binding.catalogueView.context.getString(R.string.no_results_found) + } + + return when { + error.message == null -> "" + error.message!!.startsWith("HTTP error") -> "${error.message}: ${binding.catalogueView.context.getString(R.string.http_error_hint)}" + else -> error.message!! + } + } + + /** + * Sets a new progress item and reenables the scroll listener. + */ + private fun resetProgressItem() { + progressItem = ProgressItem() + adapter?.endlessTargetCount = 0 + adapter?.setEndlessScrollListener(this, progressItem!!) + } + + /** + * Called by the adapter when scrolled near the bottom. + */ + override fun onLoadMore(lastPosition: Int, currentPage: Int) { + if (presenter.hasNextPage()) { + presenter.requestNext() + } else { + adapter?.onLoadMoreComplete(null) + adapter?.endlessTargetCount = 1 + } + } + + override fun noMoreLoad(newItemsSize: Int) { + } + + /** + * Called from the presenter when a manga is initialized. + * + * @param manga the manga initialized + */ + fun onMangaInitialized(manga: Manga) { + getHolder(manga)?.setImage(manga) + } + + /** + * Swaps the current display mode. + */ + fun swapDisplayMode() { + val view = view ?: return + val adapter = adapter ?: return + + presenter.swapDisplayMode() + val isListMode = presenter.isListMode + activity?.invalidateOptionsMenu() + setupRecycler(view) + if (!isListMode || !view.context.connectivityManager.isActiveNetworkMetered) { + // Initialize mangas if going to grid view or if over wifi when going to list view + val mangas = (0 until adapter.itemCount).mapNotNull { + (adapter.getItem(it) as? SourceItem)?.manga + } + presenter.initializeMangas(mangas) + } + } + + /** + * Returns a preference for the number of manga per row based on the current orientation. + * + * @return the preference. + */ + private fun getColumnsPreferenceForCurrentOrientation(): Preference { + return if (resources?.configuration?.orientation == Configuration.ORIENTATION_PORTRAIT) { + preferences.portraitColumns() + } else { + preferences.landscapeColumns() + } + } + + /** + * Returns the view holder for the given manga. + * + * @param manga the manga to find. + * @return the holder of the manga or null if it's not bound. + */ + private fun getHolder(manga: Manga): SourceHolder? { + val adapter = adapter ?: return null + + adapter.allBoundViewHolders.forEach { holder -> + val item = adapter.getItem(holder.bindingAdapterPosition) as? SourceItem + if (item != null && item.manga.id!! == manga.id!!) { + return holder as SourceHolder + } + } + + return null + } + + /** + * Shows the progress bar. + */ + private fun showProgressBar() { + binding.emptyView.hide() + binding.progress.visible() + snack?.dismiss() + snack = null + } + + /** + * Hides active progress bars. + */ + private fun hideProgressBar() { + binding.emptyView.hide() + binding.progress.gone() + } + + /** + * Called when a manga is clicked. + * + * @param position the position of the element clicked. + * @return true if the item should be selected, false otherwise. + */ + override fun onItemClick(view: View, position: Int): Boolean { + val item = adapter?.getItem(position) as? SourceItem ?: return false + router.pushController(MangaController(item.manga, true, + args.getParcelable(SMART_SEARCH_CONFIG_KEY)).withFadeTransaction()) + + return false + } + + /** + * Called when a manga is long clicked. + * + * Adds the manga to the default category if none is set it shows a list of categories for the user to put the manga + * in, the list consists of the default category plus the user's categories. The default category is preselected on + * new manga, and on already favorited manga the manga's categories are preselected. + * + * @param position the position of the element clicked. + */ + override fun onItemLongClick(position: Int) { + val activity = activity ?: return + val manga = (adapter?.getItem(position) as? SourceItem?)?.manga ?: return + + if (manga.favorite) { + MaterialDialog(activity) + .listItems( + items = listOf(activity.getString(R.string.remove_from_library)), + waitForPositiveButton = false + ) { _, which, _ -> + when (which) { + 0 -> { + presenter.changeMangaFavorite(manga) + adapter?.notifyItemChanged(position) + activity.toast(activity.getString(R.string.manga_removed_library)) + } + } + } + .show() + } else { + val categories = presenter.getCategories() + val defaultCategoryId = preferences.defaultCategory() + val defaultCategory = categories.find { it.id == defaultCategoryId } + + when { + // Default category set + defaultCategory != null -> { + presenter.moveMangaToCategory(manga, defaultCategory) + + presenter.changeMangaFavorite(manga) + adapter?.notifyItemChanged(position) + activity.toast(activity.getString(R.string.manga_added_library)) + } + + // Automatic 'Default' or no categories + defaultCategoryId == 0 || categories.isEmpty() -> { + presenter.moveMangaToCategory(manga, null) + + presenter.changeMangaFavorite(manga) + adapter?.notifyItemChanged(position) + activity.toast(activity.getString(R.string.manga_added_library)) + } + + // Choose a category + else -> { + val ids = presenter.getMangaCategoryIds(manga) + val preselected = ids.mapNotNull { id -> + categories.indexOfFirst { it.id == id }.takeIf { it != -1 } + }.toTypedArray() + + ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected) + .showDialog(router) + } + } + } + } + + /** + * Update manga to use selected categories. + * + * @param mangas The list of manga to move to categories. + * @param categories The list of categories where manga will be placed. + */ + override fun updateCategoriesForMangas(mangas: List, categories: List) { + val manga = mangas.firstOrNull() ?: return + + presenter.changeMangaFavorite(manga) + presenter.updateMangaCategories(manga, categories) + + val position = adapter?.currentItems?.indexOfFirst { it -> (it as SourceItem).manga.id == manga.id } + if (position != null) { + adapter?.notifyItemChanged(position) + } + activity?.toast(activity?.getString(R.string.manga_added_library)) + } + + protected companion object { + const val SOURCE_ID_KEY = "sourceId" + const val SEARCH_QUERY_KEY = "searchQuery" + // EXH --> + const val SMART_SEARCH_CONFIG_KEY = "smartSearchConfig" + // EXH <-- + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/BrowseCataloguePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt similarity index 76% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/BrowseCataloguePresenter.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt index 2953063e8..47cd440f8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/BrowseCataloguePresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.ui.catalogue.browse +package eu.kanade.tachiyomi.ui.browse.source.browse import android.os.Bundle import com.github.salomonbrys.kotson.* @@ -19,7 +19,19 @@ import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter -import eu.kanade.tachiyomi.ui.catalogue.filter.* +import eu.kanade.tachiyomi.ui.browse.source.filter.CheckboxItem +import eu.kanade.tachiyomi.ui.browse.source.filter.CheckboxSectionItem +import eu.kanade.tachiyomi.ui.browse.source.filter.GroupItem +import eu.kanade.tachiyomi.ui.browse.source.filter.HeaderItem +import eu.kanade.tachiyomi.ui.browse.source.filter.SelectItem +import eu.kanade.tachiyomi.ui.browse.source.filter.SelectSectionItem +import eu.kanade.tachiyomi.ui.browse.source.filter.SeparatorItem +import eu.kanade.tachiyomi.ui.browse.source.filter.SortGroup +import eu.kanade.tachiyomi.ui.browse.source.filter.SortItem +import eu.kanade.tachiyomi.ui.browse.source.filter.TextItem +import eu.kanade.tachiyomi.ui.browse.source.filter.TextSectionItem +import eu.kanade.tachiyomi.ui.browse.source.filter.TriStateItem +import eu.kanade.tachiyomi.ui.browse.source.filter.TriStateSectionItem import exh.EXHSavedSearch import rx.Observable import rx.Subscription @@ -34,16 +46,16 @@ import xyz.nulldev.ts.api.http.serializer.FilterSerializer import java.lang.RuntimeException /** - * Presenter of [BrowseCatalogueController]. + * Presenter of [BrowseSourceController]. */ -open class BrowseCataloguePresenter( - sourceId: Long, - searchQuery: String? = null, - sourceManager: SourceManager = Injekt.get(), - private val db: DatabaseHelper = Injekt.get(), - private val prefs: PreferencesHelper = Injekt.get(), - private val coverCache: CoverCache = Injekt.get() -) : BasePresenter() { +open class BrowseSourcePresenter( + sourceId: Long, + searchQuery: String? = null, + sourceManager: SourceManager = Injekt.get(), + private val db: DatabaseHelper = Injekt.get(), + private val prefs: PreferencesHelper = Injekt.get(), + private val coverCache: CoverCache = Injekt.get() +) : BasePresenter() { /** * Selected source. @@ -112,8 +124,10 @@ open class BrowseCataloguePresenter( query = savedState.getString(::query.name, "") } - add(prefs.catalogueAsList().asObservable() - .subscribe { setDisplayMode(it) }) + add( + prefs.catalogueAsList().asObservable() + .subscribe { setDisplayMode(it) } + ) restartPager() } @@ -145,16 +159,19 @@ open class BrowseCataloguePresenter( // Prepare the pager. pagerSubscription?.let { remove(it) } pagerSubscription = pager.results() - .observeOn(Schedulers.io()) - .map { it.first to it.second.map { networkToLocalManga(it, sourceId) } } - .doOnNext { initializeMangas(it.second) } - .map { it.first to it.second.map { CatalogueItem(it, catalogueAsList) } } - .observeOn(AndroidSchedulers.mainThread()) - .subscribeReplay({ view, (page, mangas) -> + .observeOn(Schedulers.io()) + .map { pair -> pair.first to pair.second.map { networkToLocalManga(it, sourceId) } } + .doOnNext { initializeMangas(it.second) } + .map { pair -> pair.first to pair.second.map { SourceItem(it, catalogueAsList) } } + .observeOn(AndroidSchedulers.mainThread()) + .subscribeReplay( + { view, (page, mangas) -> view.onAddPage(page, mangas) - }, { _, error -> + }, + { _, error -> Timber.e(error) - }) + } + ) // Request first page. requestNext() @@ -168,9 +185,12 @@ open class BrowseCataloguePresenter( pageSubscription?.let { remove(it) } pageSubscription = Observable.defer { pager.requestNext() } - .subscribeFirst({ _, _ -> + .subscribeFirst( + { _, _ -> // Nothing to do when onNext is emitted. - }, BrowseCatalogueController::onAddPageError) + }, + BrowseSourceController::onAddPageError + ) } /** @@ -196,18 +216,21 @@ open class BrowseCataloguePresenter( private fun subscribeToMangaInitializer() { initializerSubscription?.let { remove(it) } initializerSubscription = mangaDetailSubject.observeOn(Schedulers.io()) - .flatMap { Observable.from(it) } - .filter { it.thumbnail_url == null && !it.initialized } - .concatMap { getMangaDetailsObservable(it) } - .onBackpressureBuffer() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ manga -> + .flatMap { Observable.from(it) } + .filter { it.thumbnail_url == null && !it.initialized } + .concatMap { getMangaDetailsObservable(it) } + .onBackpressureBuffer() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { manga -> @Suppress("DEPRECATION") view?.onMangaInitialized(manga) - }, { error -> + }, + { error -> Timber.e(error) - }) - .apply { add(this) } + } + ) + .apply { add(this) } } /** @@ -246,13 +269,13 @@ open class BrowseCataloguePresenter( */ private fun getMangaDetailsObservable(manga: Manga): Observable { return source.fetchMangaDetails(manga) - .flatMap { networkManga -> - manga.copyFrom(networkManga) - manga.initialized = true - db.insertManga(manga).executeAsBlocking() - Observable.just(manga) - } - .onErrorResumeNext { Observable.just(manga) } + .flatMap { networkManga -> + manga.copyFrom(networkManga) + manga.initialized = true + db.insertManga(manga).executeAsBlocking() + Observable.just(manga) + } + .onErrorResumeNext { Observable.just(manga) } } /** @@ -285,24 +308,24 @@ open class BrowseCataloguePresenter( } open fun createPager(query: String, filters: FilterList): Pager { - return CataloguePager(source, query, filters) + return SourcePager(source, query, filters) } private fun FilterList.toItems(): List> { - return mapNotNull { - when (it) { - is Filter.Header -> HeaderItem(it) + return mapNotNull { filter -> + when (filter) { + is Filter.Header -> HeaderItem(filter) // --> EXH - is Filter.HelpDialog -> HelpDialogItem(it) + is Filter.HelpDialog -> HelpDialogItem(filter) // <-- EXH - is Filter.Separator -> SeparatorItem(it) - is Filter.CheckBox -> CheckboxItem(it) - is Filter.TriState -> TriStateItem(it) - is Filter.Text -> TextItem(it) - is Filter.Select<*> -> SelectItem(it) + is Filter.Separator -> SeparatorItem(filter) + is Filter.CheckBox -> CheckboxItem(filter) + is Filter.TriState -> TriStateItem(filter) + is Filter.Text -> TextItem(filter) + is Filter.Select<*> -> SelectItem(filter) is Filter.Group<*> -> { - val group = GroupItem(it) - val subItems = it.state.mapNotNull { + val group = GroupItem(filter) + val subItems = filter.state.mapNotNull { when (it) { is Filter.CheckBox -> CheckboxSectionItem(it) is Filter.TriState -> TriStateSectionItem(it) @@ -316,8 +339,8 @@ open class BrowseCataloguePresenter( group } is Filter.Sort -> { - val group = SortGroup(it) - val subItems = it.values.map { + val group = SortGroup(filter) + val subItems = filter.values.map { SortItem(it, group) } group.subItems = subItems @@ -375,14 +398,11 @@ open class BrowseCataloguePresenter( * @param selectedCategories selected categories */ fun updateMangaCategories(manga: Manga, selectedCategories: List) { - if (!selectedCategories.isEmpty()) { - if (!manga.favorite) - changeMangaFavorite(manga) - - moveMangaToCategories(manga, selectedCategories.filter { it.id != 0 }) - } else { + if (!manga.favorite) { changeMangaFavorite(manga) } + + moveMangaToCategories(manga, selectedCategories) } // EXH --> diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/NoResultsException.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/NoResultsException.kt new file mode 100644 index 000000000..cf52294e7 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/NoResultsException.kt @@ -0,0 +1,3 @@ +package eu.kanade.tachiyomi.ui.browse.source.browse + +class NoResultsException : Exception() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/Pager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/Pager.kt old mode 100755 new mode 100644 similarity index 84% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/Pager.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/Pager.kt index 1383fdfcc..77284e5a8 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/Pager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/Pager.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.ui.catalogue.browse +package eu.kanade.tachiyomi.ui.browse.source.browse import com.jakewharton.rxrelay.PublishRelay import eu.kanade.tachiyomi.source.model.MangasPage @@ -24,8 +24,7 @@ abstract class Pager(var currentPage: Int = 1) { fun onPageReceived(mangasPage: MangasPage) { val page = currentPage currentPage++ - hasNextPage = mangasPage.hasNextPage && !mangasPage.mangas.isEmpty() + hasNextPage = mangasPage.hasNextPage && mangasPage.mangas.isNotEmpty() results.call(Pair(page, mangasPage.mangas)) } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/ProgressItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/ProgressItem.kt old mode 100755 new mode 100644 similarity index 65% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/ProgressItem.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/ProgressItem.kt index ed135a656..0edf8fefd --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/ProgressItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/ProgressItem.kt @@ -1,39 +1,41 @@ -package eu.kanade.tachiyomi.ui.catalogue.browse +package eu.kanade.tachiyomi.ui.browse.source.browse import android.view.View import android.widget.ProgressBar import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractFlexibleItem import eu.davidea.flexibleadapter.items.IFlexible import eu.davidea.viewholders.FlexibleViewHolder import eu.kanade.tachiyomi.R - +import eu.kanade.tachiyomi.util.view.gone +import eu.kanade.tachiyomi.util.view.visible class ProgressItem : AbstractFlexibleItem() { private var loadMore = true override fun getLayoutRes(): Int { - return R.layout.catalogue_progress_item + return R.layout.source_progress_item } - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): Holder { + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): Holder { return Holder(view, adapter) } - override fun bindViewHolder(adapter: FlexibleAdapter>, holder: Holder, position: Int, payloads: List) { - holder.progressBar.visibility = View.GONE - holder.progressMessage.visibility = View.GONE + override fun bindViewHolder(adapter: FlexibleAdapter>, holder: Holder, position: Int, payloads: List) { + holder.progressBar.gone() + holder.progressMessage.gone() if (!adapter.isEndlessScrollEnabled) { loadMore = false } if (loadMore) { - holder.progressBar.visibility = View.VISIBLE + holder.progressBar.visible() } else { - holder.progressMessage.visibility = View.VISIBLE + holder.progressMessage.visible() } } @@ -41,10 +43,13 @@ class ProgressItem : AbstractFlexibleItem() { return this === other } + override fun hashCode(): Int { + return loadMore.hashCode() + } + class Holder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) { val progressBar: ProgressBar = view.findViewById(R.id.progress_bar) val progressMessage: TextView = view.findViewById(R.id.progress_message) } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceFilterSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceFilterSheet.kt new file mode 100644 index 000000000..4853a058f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceFilterSheet.kt @@ -0,0 +1,59 @@ +package eu.kanade.tachiyomi.ui.browse.source.browse + +import android.app.Activity +import android.content.Context +import android.util.AttributeSet +import android.view.ViewGroup +import com.google.android.material.bottomsheet.BottomSheetDialog +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.IFlexible +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.view.inflate +import eu.kanade.tachiyomi.widget.SimpleNavigationView +import kotlinx.android.synthetic.main.source_filter_sheet.view.filter_btn +import kotlinx.android.synthetic.main.source_filter_sheet.view.reset_btn + +class SourceFilterSheet( + activity: Activity, + onFilterClicked: () -> Unit, + onResetClicked: () -> Unit +) : BottomSheetDialog(activity) { + + private var filterNavView: FilterNavigationView + + init { + filterNavView = FilterNavigationView(activity) + filterNavView.onFilterClicked = { + onFilterClicked() + this.dismiss() + } + filterNavView.onResetClicked = onResetClicked + + setContentView(filterNavView) + } + + fun setFilters(items: List>) { + filterNavView.adapter.updateDataSet(items) + } + + class FilterNavigationView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + SimpleNavigationView(context, attrs) { + + var onFilterClicked = {} + var onResetClicked = {} + + val adapter: FlexibleAdapter> = FlexibleAdapter>(null) + .setDisplayHeadersAtStartUp(true) + .setStickyHeaders(true) + + init { + recycler.adapter = adapter + recycler.setHasFixedSize(true) + val view = inflate(R.layout.source_filter_sheet) + ((view as ViewGroup).getChildAt(1) as ViewGroup).addView(recycler) + addView(view) + filter_btn.setOnClickListener { onFilterClicked() } + reset_btn.setOnClickListener { onResetClicked() } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/CatalogueGridHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceGridHolder.kt old mode 100755 new mode 100644 similarity index 60% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/CatalogueGridHolder.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceGridHolder.kt index 660704bc4..abe6d2a79 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/CatalogueGridHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceGridHolder.kt @@ -1,23 +1,26 @@ -package eu.kanade.tachiyomi.ui.catalogue.browse +package eu.kanade.tachiyomi.ui.browse.source.browse import android.view.View import com.bumptech.glide.load.engine.DiskCacheStrategy import eu.davidea.flexibleadapter.FlexibleAdapter import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.data.glide.toMangaThumbnail import eu.kanade.tachiyomi.widget.StateImageViewTarget -import kotlinx.android.synthetic.main.catalogue_grid_item.* +import kotlinx.android.synthetic.main.source_grid_item.progress +import kotlinx.android.synthetic.main.source_grid_item.thumbnail +import kotlinx.android.synthetic.main.source_grid_item.title /** * Class used to hold the displayed data of a manga in the catalogue, like the cover or the title. - * All the elements from the layout file "item_catalogue_grid" are available in this class. + * All the elements from the layout file "item_source_grid" are available in this class. * * @param view the inflated view for this holder. * @param adapter the adapter handling this holder. * @constructor creates a new catalogue holder. */ -class CatalogueGridHolder(private val view: View, private val adapter: FlexibleAdapter<*>) : - CatalogueHolder(view, adapter) { +class SourceGridHolder(private val view: View, private val adapter: FlexibleAdapter<*>) : + SourceHolder(view, adapter) { /** * Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this @@ -39,11 +42,11 @@ class CatalogueGridHolder(private val view: View, private val adapter: FlexibleA GlideApp.with(view.context).clear(thumbnail) if (!manga.thumbnail_url.isNullOrEmpty()) { GlideApp.with(view.context) - .load(manga) - .diskCacheStrategy(DiskCacheStrategy.DATA) - .centerCrop() - .placeholder(android.R.color.transparent) - .into(StateImageViewTarget(thumbnail, progress)) + .load(manga.toMangaThumbnail()) + .diskCacheStrategy(DiskCacheStrategy.DATA) + .centerCrop() + .placeholder(android.R.color.transparent) + .into(StateImageViewTarget(thumbnail, progress)) } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/CatalogueHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceHolder.kt old mode 100755 new mode 100644 similarity index 83% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/CatalogueHolder.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceHolder.kt index 9a0ec3916..90743afda --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/CatalogueHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceHolder.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.ui.catalogue.browse +package eu.kanade.tachiyomi.ui.browse.source.browse import android.view.View import eu.davidea.flexibleadapter.FlexibleAdapter @@ -11,8 +11,8 @@ import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder * @param view the inflated view for this holder. * @param adapter the adapter handling this holder. */ -abstract class CatalogueHolder(view: View, adapter: FlexibleAdapter<*>) : - BaseFlexibleViewHolder(view, adapter) { +abstract class SourceHolder(view: View, adapter: FlexibleAdapter<*>) : + BaseFlexibleViewHolder(view, adapter) { /** * Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this @@ -22,7 +22,6 @@ abstract class CatalogueHolder(view: View, adapter: FlexibleAdapter<*>) : */ abstract fun onSetValues(manga: Manga) - /** * Updates the image for this holder. Useful to update the image when the manga is initialized * and the url is now known. diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/CatalogueItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceItem.kt old mode 100755 new mode 100644 similarity index 50% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/CatalogueItem.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceItem.kt index 08e1dcb93..e3f89c896 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/CatalogueItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceItem.kt @@ -1,9 +1,10 @@ -package eu.kanade.tachiyomi.ui.catalogue.browse +package eu.kanade.tachiyomi.ui.browse.source.browse import android.view.Gravity import android.view.View import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout +import androidx.recyclerview.widget.RecyclerView import com.f2prateek.rx.preferences.Preference import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractFlexibleItem @@ -12,44 +13,52 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.widget.AutofitRecyclerView -import kotlinx.android.synthetic.main.catalogue_grid_item.view.* +import kotlinx.android.synthetic.main.source_grid_item.view.card +import kotlinx.android.synthetic.main.source_grid_item.view.gradient -class CatalogueItem(val manga: Manga, private val catalogueAsList: Preference) : - AbstractFlexibleItem() { +class SourceItem(val manga: Manga, private val catalogueAsList: Preference) : + AbstractFlexibleItem() { override fun getLayoutRes(): Int { - return if (catalogueAsList.getOrDefault()) - R.layout.catalogue_list_item - else - R.layout.catalogue_grid_item + return if (catalogueAsList.getOrDefault()) { + R.layout.source_list_item + } else { + R.layout.source_grid_item + } } - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): CatalogueHolder { + override fun createViewHolder( + view: View, + adapter: FlexibleAdapter> + ): SourceHolder { val parent = adapter.recyclerView return if (parent is AutofitRecyclerView) { view.apply { card.layoutParams = FrameLayout.LayoutParams( - MATCH_PARENT, parent.itemWidth / 3 * 4) + MATCH_PARENT, parent.itemWidth / 3 * 4 + ) gradient.layoutParams = FrameLayout.LayoutParams( - MATCH_PARENT, parent.itemWidth / 3 * 4 / 2, Gravity.BOTTOM) + MATCH_PARENT, parent.itemWidth / 3 * 4 / 2, Gravity.BOTTOM + ) } - CatalogueGridHolder(view, adapter) + SourceGridHolder(view, adapter) } else { - CatalogueListHolder(view, adapter) + SourceListHolder(view, adapter) } } - override fun bindViewHolder(adapter: FlexibleAdapter>, - holder: CatalogueHolder, - position: Int, - payloads: List?) { - + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: SourceHolder, + position: Int, + payloads: List? + ) { holder.onSetValues(manga) } override fun equals(other: Any?): Boolean { if (this === other) return true - if (other is CatalogueItem) { + if (other is SourceItem) { return manga.id!! == other.manga.id!! } return false @@ -58,7 +67,4 @@ class CatalogueItem(val manga: Manga, private val catalogueAsList: Preference) : - CatalogueHolder(view, adapter) { +class SourceListHolder(private val view: View, adapter: FlexibleAdapter<*>) : + SourceHolder(view, adapter) { - private val favoriteColor = view.context.getResourceColor(android.R.attr.textColorHint) - private val unfavoriteColor = view.context.getResourceColor(android.R.attr.textColorPrimary) + private val favoriteColor = view.context.getResourceColor(R.attr.colorOnSurface, 0.38f) + private val unfavoriteColor = view.context.getResourceColor(R.attr.colorOnSurface) /** * Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this @@ -40,14 +43,13 @@ class CatalogueListHolder(private val view: View, adapter: FlexibleAdapter<*>) : GlideApp.with(view.context).clear(thumbnail) if (!manga.thumbnail_url.isNullOrEmpty()) { GlideApp.with(view.context) - .load(manga) - .diskCacheStrategy(DiskCacheStrategy.DATA) - .centerCrop() - .circleCrop() - .dontAnimate() - .placeholder(android.R.color.transparent) - .into(thumbnail) + .load(manga.toMangaThumbnail()) + .diskCacheStrategy(DiskCacheStrategy.DATA) + .centerCrop() + .circleCrop() + .dontAnimate() + .placeholder(android.R.color.transparent) + .into(thumbnail) } } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/CataloguePager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourcePager.kt old mode 100755 new mode 100644 similarity index 52% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/CataloguePager.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourcePager.kt index a7b563074..d771098c2 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/CataloguePager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourcePager.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.ui.catalogue.browse +package eu.kanade.tachiyomi.ui.browse.source.browse import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.model.FilterList @@ -7,26 +7,26 @@ import rx.Observable import rx.android.schedulers.AndroidSchedulers import rx.schedulers.Schedulers -open class CataloguePager(val source: CatalogueSource, val query: String, val filters: FilterList) : Pager() { +open class SourcePager(val source: CatalogueSource, val query: String, val filters: FilterList) : Pager() { override fun requestNext(): Observable { val page = currentPage - val observable = if (query.isBlank() && filters.isEmpty()) + val observable = if (query.isBlank() && filters.isEmpty()) { source.fetchPopularManga(page) - else + } else { source.fetchSearchManga(page, query, filters) + } return observable - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext { - if (it.mangas.isNotEmpty()) { - onPageReceived(it) - } else { - throw NoResultsException() - } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { + if (it.mangas.isNotEmpty()) { + onPageReceived(it) + } else { + throw NoResultsException() } + } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/CheckboxItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/CheckboxItem.kt old mode 100755 new mode 100644 similarity index 96% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/CheckboxItem.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/CheckboxItem.kt index ba59105ef..bae15e426 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/CheckboxItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/CheckboxItem.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.ui.catalogue.filter +package eu.kanade.tachiyomi.ui.browse.source.filter import android.view.View import android.widget.CheckBox @@ -40,7 +40,6 @@ open class CheckboxItem(val filter: Filter.CheckBox) : AbstractFlexibleItem) : FlexibleViewHolder(view, adapter) { - val check: CheckBox = itemView.findViewById(R.id.nav_view_item) } } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/GroupItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/GroupItem.kt old mode 100755 new mode 100644 similarity index 78% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/GroupItem.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/GroupItem.kt index 09047002e..8b68dd1a5 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/GroupItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/GroupItem.kt @@ -1,8 +1,9 @@ -package eu.kanade.tachiyomi.ui.catalogue.filter +package eu.kanade.tachiyomi.ui.browse.source.filter import android.view.View import android.widget.ImageView import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractExpandableHeaderItem import eu.davidea.flexibleadapter.items.IFlexible @@ -12,7 +13,7 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.source.model.Filter -import eu.kanade.tachiyomi.util.setVectorCompat +import eu.kanade.tachiyomi.util.view.setVectorCompat import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -32,20 +33,22 @@ class GroupItem(val filter: Filter.Group<*>) : AbstractExpandableHeaderItem>): Holder { + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): Holder { return Holder(view, adapter) } - override fun bindViewHolder(adapter: FlexibleAdapter>, holder: Holder, position: Int, payloads: List?) { + override fun bindViewHolder(adapter: FlexibleAdapter>, holder: Holder, position: Int, payloads: List?) { holder.title.text = filter.name - holder.icon.setVectorCompat(if (isExpanded) - R.drawable.ic_expand_more_white_24dp - else - R.drawable.ic_chevron_right_white_24dp) + holder.icon.setVectorCompat( + if (isExpanded) { + R.drawable.ic_expand_more_white_24dp + } else { + R.drawable.ic_chevron_right_white_24dp + } + ) holder.itemView.setOnClickListener(holder) - } override fun equals(other: Any?): Boolean { @@ -58,15 +61,12 @@ class GroupItem(val filter: Filter.Group<*>) : AbstractExpandableHeaderItem) : ExpandableViewHolder(view, adapter, true) { - val title: TextView = itemView.findViewById(R.id.title) val icon: ImageView = itemView.findViewById(R.id.expand_icon) override fun shouldNotifyParentOnClick(): Boolean { return true } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/HeaderItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/HeaderItem.kt old mode 100755 new mode 100644 similarity index 83% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/HeaderItem.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/HeaderItem.kt index 8a903c6d7..7c0692036 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/HeaderItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/HeaderItem.kt @@ -1,8 +1,9 @@ -package eu.kanade.tachiyomi.ui.catalogue.filter +package eu.kanade.tachiyomi.ui.browse.source.filter import android.annotation.SuppressLint import android.view.View import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView import com.google.android.material.R import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractHeaderItem @@ -17,11 +18,11 @@ class HeaderItem(val filter: Filter.Header) : AbstractHeaderItem>): Holder { + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): Holder { return Holder(view, adapter) } - override fun bindViewHolder(adapter: FlexibleAdapter>, holder: Holder, position: Int, payloads: List?) { + override fun bindViewHolder(adapter: FlexibleAdapter>, holder: Holder, position: Int, payloads: List?) { val view = holder.itemView as TextView view.text = filter.name } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SectionItems.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/SectionItems.kt old mode 100755 new mode 100644 similarity index 94% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SectionItems.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/SectionItems.kt index c55113c16..12e1eb4af --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SectionItems.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/SectionItems.kt @@ -1,88 +1,88 @@ -package eu.kanade.tachiyomi.ui.catalogue.filter - -import eu.davidea.flexibleadapter.items.ISectionable -import eu.kanade.tachiyomi.source.model.Filter - -class TriStateSectionItem(filter: Filter.TriState) : TriStateItem(filter), ISectionable { - - private var head: GroupItem? = null - - override fun getHeader(): GroupItem? = head - - override fun setHeader(header: GroupItem?) { - head = header - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - return filter == (other as TriStateSectionItem).filter - } - - override fun hashCode(): Int { - return filter.hashCode() - } -} - -class TextSectionItem(filter: Filter.Text) : TextItem(filter), ISectionable { - - private var head: GroupItem? = null - - override fun getHeader(): GroupItem? = head - - override fun setHeader(header: GroupItem?) { - head = header - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - return filter == (other as TextSectionItem).filter - } - - override fun hashCode(): Int { - return filter.hashCode() - } -} - -class CheckboxSectionItem(filter: Filter.CheckBox) : CheckboxItem(filter), ISectionable { - - private var head: GroupItem? = null - - override fun getHeader(): GroupItem? = head - - override fun setHeader(header: GroupItem?) { - head = header - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - return filter == (other as CheckboxSectionItem).filter - } - - override fun hashCode(): Int { - return filter.hashCode() - } -} - -class SelectSectionItem(filter: Filter.Select<*>) : SelectItem(filter), ISectionable { - - private var head: GroupItem? = null - - override fun getHeader(): GroupItem? = head - - override fun setHeader(header: GroupItem?) { - head = header - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - return filter == (other as SelectSectionItem).filter - } - - override fun hashCode(): Int { - return filter.hashCode() - } -} +package eu.kanade.tachiyomi.ui.browse.source.filter + +import eu.davidea.flexibleadapter.items.ISectionable +import eu.kanade.tachiyomi.source.model.Filter + +class TriStateSectionItem(filter: Filter.TriState) : TriStateItem(filter), ISectionable { + + private var head: GroupItem? = null + + override fun getHeader(): GroupItem? = head + + override fun setHeader(header: GroupItem?) { + head = header + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + return filter == (other as TriStateSectionItem).filter + } + + override fun hashCode(): Int { + return filter.hashCode() + } +} + +class TextSectionItem(filter: Filter.Text) : TextItem(filter), ISectionable { + + private var head: GroupItem? = null + + override fun getHeader(): GroupItem? = head + + override fun setHeader(header: GroupItem?) { + head = header + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + return filter == (other as TextSectionItem).filter + } + + override fun hashCode(): Int { + return filter.hashCode() + } +} + +class CheckboxSectionItem(filter: Filter.CheckBox) : CheckboxItem(filter), ISectionable { + + private var head: GroupItem? = null + + override fun getHeader(): GroupItem? = head + + override fun setHeader(header: GroupItem?) { + head = header + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + return filter == (other as CheckboxSectionItem).filter + } + + override fun hashCode(): Int { + return filter.hashCode() + } +} + +class SelectSectionItem(filter: Filter.Select<*>) : SelectItem(filter), ISectionable { + + private var head: GroupItem? = null + + override fun getHeader(): GroupItem? = head + + override fun setHeader(header: GroupItem?) { + head = header + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + return filter == (other as SelectSectionItem).filter + } + + override fun hashCode(): Int { + return filter.hashCode() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SelectItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/SelectItem.kt old mode 100755 new mode 100644 similarity index 89% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SelectItem.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/SelectItem.kt index 7da61fc1a..44649a28e --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SelectItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/SelectItem.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.ui.catalogue.filter +package eu.kanade.tachiyomi.ui.browse.source.filter import android.view.View import android.widget.ArrayAdapter @@ -27,8 +27,10 @@ open class SelectItem(val filter: Filter.Select<*>) : AbstractFlexibleItem(holder.itemView.context, - android.R.layout.simple_spinner_item, filter.values).apply { + spinner.adapter = ArrayAdapter( + holder.itemView.context, + android.R.layout.simple_spinner_item, filter.values + ).apply { setDropDownViewResource(R.layout.common_spinner_item) } spinner.onItemSelectedListener = IgnoreFirstSpinnerListener { pos -> diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SeparatorItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/SeparatorItem.kt old mode 100755 new mode 100644 similarity index 82% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SeparatorItem.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/SeparatorItem.kt index 75c443cc8..af21aeabb --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SeparatorItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/SeparatorItem.kt @@ -1,7 +1,8 @@ -package eu.kanade.tachiyomi.ui.catalogue.filter +package eu.kanade.tachiyomi.ui.browse.source.filter import android.annotation.SuppressLint import android.view.View +import androidx.recyclerview.widget.RecyclerView import com.google.android.material.R import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractHeaderItem @@ -16,12 +17,11 @@ class SeparatorItem(val filter: Filter.Separator) : AbstractHeaderItem>): Holder { + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): Holder { return Holder(view, adapter) } - override fun bindViewHolder(adapter: FlexibleAdapter>, holder: Holder, position: Int, payloads: List?) { - + override fun bindViewHolder(adapter: FlexibleAdapter>, holder: Holder, position: Int, payloads: List?) { } override fun equals(other: Any?): Boolean { @@ -35,4 +35,4 @@ class SeparatorItem(val filter: Filter.Separator) : AbstractHeaderItem) : FlexibleViewHolder(view, adapter) -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SortGroup.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/SortGroup.kt old mode 100755 new mode 100644 similarity index 71% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SortGroup.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/SortGroup.kt index 507616b0c..48dc25fea --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SortGroup.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/SortGroup.kt @@ -1,13 +1,14 @@ -package eu.kanade.tachiyomi.ui.catalogue.filter +package eu.kanade.tachiyomi.ui.browse.source.filter import android.view.View +import androidx.recyclerview.widget.RecyclerView import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractExpandableHeaderItem import eu.davidea.flexibleadapter.items.IFlexible import eu.davidea.flexibleadapter.items.ISectionable import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.source.model.Filter -import eu.kanade.tachiyomi.util.setVectorCompat +import eu.kanade.tachiyomi.util.view.setVectorCompat class SortGroup(val filter: Filter.Sort) : AbstractExpandableHeaderItem>() { @@ -23,20 +24,22 @@ class SortGroup(val filter: Filter.Sort) : AbstractExpandableHeaderItem>): Holder { + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): Holder { return Holder(view, adapter) } - override fun bindViewHolder(adapter: FlexibleAdapter>, holder: Holder, position: Int, payloads: List?) { + override fun bindViewHolder(adapter: FlexibleAdapter>, holder: Holder, position: Int, payloads: List?) { holder.title.text = filter.name - holder.icon.setVectorCompat(if (isExpanded) - R.drawable.ic_expand_more_white_24dp - else - R.drawable.ic_chevron_right_white_24dp) + holder.icon.setVectorCompat( + if (isExpanded) { + R.drawable.ic_expand_more_white_24dp + } else { + R.drawable.ic_chevron_right_white_24dp + } + ) holder.itemView.setOnClickListener(holder) - } override fun equals(other: Any?): Boolean { @@ -50,4 +53,4 @@ class SortGroup(val filter: Filter.Sort) : AbstractExpandableHeaderItem) : GroupItem.Holder(view, adapter) -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SortItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/SortItem.kt old mode 100755 new mode 100644 similarity index 80% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SortItem.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/SortItem.kt index 30fecd62c..8f8e09dac --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SortItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/SortItem.kt @@ -1,8 +1,9 @@ -package eu.kanade.tachiyomi.ui.catalogue.filter +package eu.kanade.tachiyomi.ui.browse.source.filter import android.view.View import android.widget.CheckedTextView import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractSectionableItem @@ -10,7 +11,7 @@ import eu.davidea.flexibleadapter.items.IFlexible import eu.davidea.viewholders.FlexibleViewHolder import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.source.model.Filter -import eu.kanade.tachiyomi.util.getResourceColor +import eu.kanade.tachiyomi.util.system.getResourceColor class SortItem(val name: String, val group: SortGroup) : AbstractSectionableItem(group) { @@ -22,11 +23,11 @@ class SortItem(val name: String, val group: SortGroup) : AbstractSectionableItem return 102 } - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): Holder { + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): Holder { return Holder(view, adapter) } - override fun bindViewHolder(adapter: FlexibleAdapter>, holder: Holder, position: Int, payloads: List?) { + override fun bindViewHolder(adapter: FlexibleAdapter>, holder: Holder, position: Int, payloads: List?) { val view = holder.text view.text = name val filter = group.filter @@ -34,9 +35,11 @@ class SortItem(val name: String, val group: SortGroup) : AbstractSectionableItem val i = filter.values.indexOf(name) fun getIcon() = when (filter.state) { - Filter.Sort.Selection(i, false) -> VectorDrawableCompat.create(view.resources, R.drawable.ic_arrow_down_white_32dp, null) + Filter.Sort.Selection(i, false) -> + VectorDrawableCompat.create(view.resources, R.drawable.ic_arrow_down_white_32dp, null) ?.apply { setTint(view.context.getResourceColor(R.attr.colorAccent)) } - Filter.Sort.Selection(i, true) -> VectorDrawableCompat.create(view.resources, R.drawable.ic_arrow_up_white_32dp, null) + Filter.Sort.Selection(i, true) -> + VectorDrawableCompat.create(view.resources, R.drawable.ic_arrow_up_white_32dp, null) ?.apply { setTint(view.context.getResourceColor(R.attr.colorAccent)) } else -> ContextCompat.getDrawable(view.context, R.drawable.empty_drawable_32dp) } @@ -68,8 +71,6 @@ class SortItem(val name: String, val group: SortGroup) : AbstractSectionableItem } class Holder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) { - val text: CheckedTextView = itemView.findViewById(R.id.nav_view_item) } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/TextItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/TextItem.kt old mode 100755 new mode 100644 similarity index 63% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/TextItem.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/TextItem.kt index 324f48fb7..d3d4eb3f7 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/TextItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/TextItem.kt @@ -1,7 +1,8 @@ -package eu.kanade.tachiyomi.ui.catalogue.filter +package eu.kanade.tachiyomi.ui.browse.source.filter import android.view.View import android.widget.EditText +import androidx.recyclerview.widget.RecyclerView import com.google.android.material.textfield.TextInputLayout import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractFlexibleItem @@ -9,31 +10,31 @@ import eu.davidea.flexibleadapter.items.IFlexible import eu.davidea.viewholders.FlexibleViewHolder import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.source.model.Filter -import eu.kanade.tachiyomi.widget.SimpleTextWatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.android.widget.textChanges open class TextItem(val filter: Filter.Text) : AbstractFlexibleItem() { - private val textWatcher = object : SimpleTextWatcher() { - override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { - filter.state = s.toString() - } - } + + private val scope = CoroutineScope(Job() + Dispatchers.Main) override fun getLayoutRes(): Int { return R.layout.navigation_view_text } - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): Holder { + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): Holder { return Holder(view, adapter) } - override fun bindViewHolder(adapter: FlexibleAdapter>, holder: Holder, position: Int, payloads: List?) { + override fun bindViewHolder(adapter: FlexibleAdapter>, holder: Holder, position: Int, payloads: List?) { holder.wrapper.hint = filter.name holder.edit.setText(filter.state) - holder.edit.addTextChangedListener(textWatcher) - } - - override fun unbindViewHolder(adapter: FlexibleAdapter>, holder: Holder, position: Int) { - holder.edit.removeTextChangedListener(textWatcher) + holder.edit.textChanges() + .onEach { filter.state = it.toString() } + .launchIn(scope) } override fun equals(other: Any?): Boolean { @@ -47,8 +48,7 @@ open class TextItem(val filter: Filter.Text) : AbstractFlexibleItem) : FlexibleViewHolder(view, adapter) { - val wrapper: TextInputLayout = itemView.findViewById(R.id.nav_view_item_wrapper) val edit: EditText = itemView.findViewById(R.id.nav_view_item) } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/TriStateItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/TriStateItem.kt old mode 100755 new mode 100644 similarity index 65% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/TriStateItem.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/TriStateItem.kt index 13681d616..bd8c53f11 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/TriStateItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/filter/TriStateItem.kt @@ -1,17 +1,18 @@ -package eu.kanade.tachiyomi.ui.catalogue.filter +package eu.kanade.tachiyomi.ui.browse.source.filter import android.view.View import android.widget.CheckedTextView +import androidx.recyclerview.widget.RecyclerView import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat import com.google.android.material.R import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractFlexibleItem import eu.davidea.flexibleadapter.items.IFlexible import eu.davidea.viewholders.FlexibleViewHolder -import eu.kanade.tachiyomi.source.model.Filter -import eu.kanade.tachiyomi.util.dpToPx -import eu.kanade.tachiyomi.util.getResourceColor import eu.kanade.tachiyomi.R as TR +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.util.system.dpToPx +import eu.kanade.tachiyomi.util.system.getResourceColor open class TriStateItem(val filter: Filter.TriState) : AbstractFlexibleItem() { @@ -23,26 +24,31 @@ open class TriStateItem(val filter: Filter.TriState) : AbstractFlexibleItem>): Holder { + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): Holder { return Holder(view, adapter) } - override fun bindViewHolder(adapter: FlexibleAdapter>, holder: Holder, position: Int, payloads: List?) { + override fun bindViewHolder(adapter: FlexibleAdapter>, holder: Holder, position: Int, payloads: List?) { val view = holder.text view.text = filter.name - fun getIcon() = VectorDrawableCompat.create(view.resources, when (filter.state) { - Filter.TriState.STATE_IGNORE -> TR.drawable.ic_check_box_outline_blank_24dp - Filter.TriState.STATE_INCLUDE -> TR.drawable.ic_check_box_24dp - Filter.TriState.STATE_EXCLUDE -> TR.drawable.ic_check_box_x_24dp - else -> throw Exception("Unknown state") - }, null)?.apply { - val color = if (filter.state == Filter.TriState.STATE_INCLUDE) - R.attr.colorAccent - else - android.R.attr.textColorSecondary + fun getIcon() = VectorDrawableCompat.create( + view.resources, + when (filter.state) { + Filter.TriState.STATE_IGNORE -> TR.drawable.ic_check_box_outline_blank_24dp + Filter.TriState.STATE_INCLUDE -> TR.drawable.ic_check_box_24dp + Filter.TriState.STATE_EXCLUDE -> TR.drawable.ic_check_box_x_24dp + else -> throw Exception("Unknown state") + }, + null + )?.apply { + val color = if (filter.state == Filter.TriState.STATE_INCLUDE) { + view.context.getResourceColor(R.attr.colorAccent) + } else { + view.context.getResourceColor(R.attr.colorOnBackground, 0.38f) + } - setTint(view.context.getResourceColor(color)) + setTint(color) } view.setCompoundDrawablesWithIntrinsicBounds(getIcon(), null, null, null) @@ -63,7 +69,6 @@ open class TriStateItem(val filter: Filter.TriState) : AbstractFlexibleItem) : FlexibleViewHolder(view, adapter) { - val text: CheckedTextView = itemView.findViewById(TR.id.nav_view_item) init { @@ -72,5 +77,4 @@ open class TriStateItem(val filter: Filter.TriState) : AbstractFlexibleItem(null, controller, true) { +class GlobalSearchAdapter(val controller: GlobalSearchController) : + FlexibleAdapter(null, controller, true) { /** * Listen for more button clicks. @@ -24,12 +24,12 @@ class CatalogueSearchAdapter(val controller: CatalogueSearchController) : */ private var bundle = Bundle() - override fun onBindViewHolder(holder: androidx.recyclerview.widget.RecyclerView.ViewHolder, position: Int, payloads: List) { + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: List) { super.onBindViewHolder(holder, position, payloads) restoreHolderState(holder) } - override fun onViewRecycled(holder: androidx.recyclerview.widget.RecyclerView.ViewHolder) { + override fun onViewRecycled(holder: RecyclerView.ViewHolder) { super.onViewRecycled(holder) saveHolderState(holder, bundle) } @@ -52,8 +52,8 @@ class CatalogueSearchAdapter(val controller: CatalogueSearchController) : * @param holder The holder to save. * @param outState The bundle where the state is saved. */ - private fun saveHolderState(holder: androidx.recyclerview.widget.RecyclerView.ViewHolder, outState: Bundle) { - val key = "holder_${holder.adapterPosition}" + private fun saveHolderState(holder: RecyclerView.ViewHolder, outState: Bundle) { + val key = "holder_${holder.bindingAdapterPosition}" val holderState = SparseArray() holder.itemView.saveHierarchyState(holderState) outState.putSparseParcelableArray(key, holderState) @@ -64,8 +64,8 @@ class CatalogueSearchAdapter(val controller: CatalogueSearchController) : * * @param holder The holder to restore. */ - private fun restoreHolderState(holder: androidx.recyclerview.widget.RecyclerView.ViewHolder) { - val key = "holder_${holder.adapterPosition}" + private fun restoreHolderState(holder: RecyclerView.ViewHolder) { + val key = "holder_${holder.bindingAdapterPosition}" val holderState = bundle.getSparseParcelableArray(key) if (holderState != null) { holder.itemView.restoreHierarchyState(holderState) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchCardAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardAdapter.kt similarity index 57% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchCardAdapter.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardAdapter.kt index 9b3b71b0a..d9a8f430c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchCardAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardAdapter.kt @@ -1,28 +1,27 @@ -package eu.kanade.tachiyomi.ui.catalogue.global_search - -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.kanade.tachiyomi.data.database.models.Manga - -/** - * Adapter that holds the manga items from search results. - * - * @param controller instance of [CatalogueSearchController]. - */ -class CatalogueSearchCardAdapter(controller: CatalogueSearchController) : - FlexibleAdapter(null, controller, true) { - - /** - * Listen for browse item clicks. - */ - val mangaClickListener: OnMangaClickListener = controller - - /** - * Listener which should be called when user clicks browse. - * Note: Should only be handled by [CatalogueSearchController] - */ - interface OnMangaClickListener { - fun onMangaClick(manga: Manga) - fun onMangaLongClick(manga: Manga) - } - -} \ No newline at end of file +package eu.kanade.tachiyomi.ui.browse.source.globalsearch + +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.kanade.tachiyomi.data.database.models.Manga + +/** + * Adapter that holds the manga items from search results. + * + * @param controller instance of [GlobalSearchController]. + */ +class GlobalSearchCardAdapter(controller: GlobalSearchController) : + FlexibleAdapter(null, controller, true) { + + /** + * Listen for browse item clicks. + */ + val mangaClickListener: OnMangaClickListener = controller + + /** + * Listener which should be called when user clicks browse. + * Note: Should only be handled by [GlobalSearchController] + */ + interface OnMangaClickListener { + fun onMangaClick(manga: Manga) + fun onMangaLongClick(manga: Manga) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchCardHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardHolder.kt similarity index 55% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchCardHolder.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardHolder.kt index cf21e7437..6b740cee4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchCardHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardHolder.kt @@ -1,52 +1,54 @@ -package eu.kanade.tachiyomi.ui.catalogue.global_search - -import android.view.View -import com.bumptech.glide.load.engine.DiskCacheStrategy -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.glide.GlideApp -import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder -import eu.kanade.tachiyomi.widget.StateImageViewTarget -import kotlinx.android.synthetic.main.catalogue_global_search_controller_card_item.* - -class CatalogueSearchCardHolder(view: View, adapter: CatalogueSearchCardAdapter) - : BaseFlexibleViewHolder(view, adapter) { - - init { - // Call onMangaClickListener when item is pressed. - itemView.setOnClickListener { - val item = adapter.getItem(adapterPosition) - if (item != null) { - adapter.mangaClickListener.onMangaClick(item.manga) - } - } - itemView.setOnLongClickListener { - val item = adapter.getItem(adapterPosition) - if (item != null) { - adapter.mangaClickListener.onMangaLongClick(item.manga) - } - true - } - } - - fun bind(manga: Manga) { - tvTitle.text = manga.title - // Set alpha of thumbnail. - itemImage.alpha = if (manga.favorite) 0.3f else 1.0f - - setImage(manga) - } - - fun setImage(manga: Manga) { - GlideApp.with(itemView.context).clear(itemImage) - if (!manga.thumbnail_url.isNullOrEmpty()) { - GlideApp.with(itemView.context) - .load(manga) - .diskCacheStrategy(DiskCacheStrategy.DATA) - .centerCrop() - .skipMemoryCache(true) - .placeholder(android.R.color.transparent) - .into(StateImageViewTarget(itemImage, progress)) - } - } - -} \ No newline at end of file +package eu.kanade.tachiyomi.ui.browse.source.globalsearch + +import android.view.View +import com.bumptech.glide.load.engine.DiskCacheStrategy +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.data.glide.toMangaThumbnail +import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder +import eu.kanade.tachiyomi.widget.StateImageViewTarget +import kotlinx.android.synthetic.main.global_search_controller_card_item.itemImage +import kotlinx.android.synthetic.main.global_search_controller_card_item.progress +import kotlinx.android.synthetic.main.global_search_controller_card_item.tvTitle + +class GlobalSearchCardHolder(view: View, adapter: GlobalSearchCardAdapter) : + BaseFlexibleViewHolder(view, adapter) { + + init { + // Call onMangaClickListener when item is pressed. + itemView.setOnClickListener { + val item = adapter.getItem(bindingAdapterPosition) + if (item != null) { + adapter.mangaClickListener.onMangaClick(item.manga) + } + } + itemView.setOnLongClickListener { + val item = adapter.getItem(bindingAdapterPosition) + if (item != null) { + adapter.mangaClickListener.onMangaLongClick(item.manga) + } + true + } + } + + fun bind(manga: Manga) { + tvTitle.text = manga.title + // Set alpha of thumbnail. + itemImage.alpha = if (manga.favorite) 0.3f else 1.0f + + setImage(manga) + } + + fun setImage(manga: Manga) { + GlideApp.with(itemView.context).clear(itemImage) + if (!manga.thumbnail_url.isNullOrEmpty()) { + GlideApp.with(itemView.context) + .load(manga.toMangaThumbnail()) + .diskCacheStrategy(DiskCacheStrategy.DATA) + .centerCrop() + .skipMemoryCache(true) + .placeholder(android.R.color.transparent) + .into(StateImageViewTarget(itemImage, progress)) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardItem.kt new file mode 100644 index 000000000..9c3637a6d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardItem.kt @@ -0,0 +1,40 @@ +package eu.kanade.tachiyomi.ui.browse.source.globalsearch + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractFlexibleItem +import eu.davidea.flexibleadapter.items.IFlexible +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Manga + +class GlobalSearchCardItem(val manga: Manga) : AbstractFlexibleItem() { + + override fun getLayoutRes(): Int { + return R.layout.global_search_controller_card_item + } + + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): GlobalSearchCardHolder { + return GlobalSearchCardHolder(view, adapter as GlobalSearchCardAdapter) + } + + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: GlobalSearchCardHolder, + position: Int, + payloads: List? + ) { + holder.bind(manga) + } + + override fun equals(other: Any?): Boolean { + if (other is GlobalSearchCardItem) { + return manga.id == other.manga.id + } + return false + } + + override fun hashCode(): Int { + return manga.id?.toInt() ?: 0 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchController.kt similarity index 58% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchController.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchController.kt index 75f85ae94..be1da5ecd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchController.kt @@ -1,75 +1,70 @@ -package eu.kanade.tachiyomi.ui.catalogue.global_search +package eu.kanade.tachiyomi.ui.browse.source.globalsearch import android.os.Bundle -import android.view.* +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup import androidx.appcompat.widget.SearchView -import com.jakewharton.rxbinding.support.v7.widget.queryTextChangeEvents +import androidx.recyclerview.widget.LinearLayoutManager import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.databinding.GlobalSearchControllerBinding import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.ui.base.controller.NucleusController import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction -import eu.kanade.tachiyomi.ui.catalogue.browse.BrowseCatalogueController import eu.kanade.tachiyomi.ui.manga.MangaController -import kotlinx.android.synthetic.main.catalogue_global_search_controller.* -import uy.kohesive.injekt.injectLazy +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.appcompat.QueryTextEvent +import reactivecircus.flowbinding.appcompat.queryTextEvents /** * This controller shows and manages the different search result in global search. - * This controller should only handle UI actions, IO actions should be done by [CatalogueSearchPresenter] - * [CatalogueSearchCardAdapter.OnMangaClickListener] called when manga is clicked in global search + * This controller should only handle UI actions, IO actions should be done by [GlobalSearchPresenter] + * [GlobalSearchCardAdapter.OnMangaClickListener] called when manga is clicked in global search */ -open class CatalogueSearchController( - protected val initialQuery: String? = null, - protected val extensionFilter: String? = null -) : NucleusController(), - CatalogueSearchCardAdapter.OnMangaClickListener, CatalogueSearchAdapter.OnMoreClickListener { - - /** - * Application preferences. - */ - private val preferences: PreferencesHelper by injectLazy() +open class GlobalSearchController( + protected val initialQuery: String? = null, + protected val extensionFilter: String? = null +) : NucleusController(), + GlobalSearchCardAdapter.OnMangaClickListener { /** * Adapter containing search results grouped by lang. */ - protected var adapter: CatalogueSearchAdapter? = null + protected var adapter: GlobalSearchAdapter? = null - /** - * Called when controller is initialized. - */ init { setHasOptionsMenu(true) } /** - * Initiate the view with [R.layout.catalogue_global_search_controller]. + * Initiate the view with [R.layout.global_search_controller]. * * @param inflater used to load the layout xml. * @param container containing parent views. * @return inflated view */ - override fun inflateView(inflater: LayoutInflater, container: ViewGroup): android.view.View { - return inflater.inflate(R.layout.catalogue_global_search_controller, container, false) + override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { + binding = GlobalSearchControllerBinding.inflate(inflater) + return binding.root } - /** - * Set the title of controller. - * - * @return title. - */ override fun getTitle(): String? { return presenter.query } /** - * Create the [CatalogueSearchPresenter] used in controller. + * Create the [GlobalSearchPresenter] used in controller. * - * @return instance of [CatalogueSearchPresenter] + * @return instance of [GlobalSearchPresenter] */ - override fun createPresenter(): CatalogueSearchPresenter { - return CatalogueSearchPresenter(initialQuery, extensionFilter) + override fun createPresenter(): GlobalSearchPresenter { + return GlobalSearchPresenter(initialQuery, extensionFilter) } /** @@ -100,11 +95,12 @@ open class CatalogueSearchController( */ override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { // Inflate menu. - inflater.inflate(R.menu.catalogue_new_list, menu) + inflater.inflate(R.menu.global_search, menu) // Initialize search menu val searchItem = menu.findItem(R.id.action_search) val searchView = searchItem.actionView as SearchView + searchView.maxWidth = Int.MAX_VALUE searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { override fun onMenuItemActionExpand(item: MenuItem?): Boolean { @@ -118,13 +114,14 @@ open class CatalogueSearchController( } }) - searchView.queryTextChangeEvents() - .filter { it.isSubmitted } - .subscribeUntilDestroy { - presenter.search(it.queryText().toString()) - searchItem.collapseActionView() - setTitle() // Update toolbar title - } + searchView.queryTextEvents() + .filter { it is QueryTextEvent.QuerySubmitted } + .onEach { + presenter.search(it.queryText.toString()) + searchItem.collapseActionView() + setTitle() // Update toolbar title + } + .launchIn(scope) } /** @@ -135,11 +132,11 @@ open class CatalogueSearchController( override fun onViewCreated(view: View) { super.onViewCreated(view) - adapter = CatalogueSearchAdapter(this) + adapter = GlobalSearchAdapter(this) // Create recycler and set adapter. - recycler.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(view.context) - recycler.adapter = adapter + binding.recycler.layoutManager = LinearLayoutManager(view.context) + binding.recycler.adapter = adapter } override fun onDestroyView(view: View) { @@ -163,13 +160,13 @@ open class CatalogueSearchController( * @param source used to find holder containing source * @return the holder of the manga or null if it's not bound. */ - private fun getHolder(source: CatalogueSource): CatalogueSearchHolder? { + private fun getHolder(source: CatalogueSource): GlobalSearchHolder? { val adapter = adapter ?: return null adapter.allBoundViewHolders.forEach { holder -> - val item = adapter.getItem(holder.adapterPosition) + val item = adapter.getItem(holder.bindingAdapterPosition) if (item != null && source.id == item.source.id) { - return holder as CatalogueSearchHolder + return holder as GlobalSearchHolder } } @@ -181,7 +178,7 @@ open class CatalogueSearchController( * * @param searchResult result of search. */ - fun setItems(searchResult: List) { + fun setItems(searchResult: List) { adapter?.updateDataSet(searchResult) } @@ -193,17 +190,4 @@ open class CatalogueSearchController( fun onMangaInitialized(source: CatalogueSource, manga: Manga) { getHolder(source)?.setImage(manga) } - - override fun onMoreClick(source: CatalogueSource) { - openCatalogue(source, BrowseCatalogueController(source, presenter.query)) - } - - /** - * Opens a catalogue with the given controller. - */ - private fun openCatalogue(source: CatalogueSource, controller: BrowseCatalogueController) { - preferences.lastUsedCatalogueSource().set(source.id) - router.pushController(controller.withFadeTransaction()) - } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchHolder.kt similarity index 60% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchHolder.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchHolder.kt index 41bf0fb91..f1cb5a74c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchHolder.kt @@ -1,40 +1,36 @@ -package eu.kanade.tachiyomi.ui.catalogue.global_search +package eu.kanade.tachiyomi.ui.browse.source.globalsearch import android.view.View import androidx.recyclerview.widget.LinearLayoutManager import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder -import eu.kanade.tachiyomi.util.gone -import eu.kanade.tachiyomi.util.visible -import kotlinx.android.synthetic.main.catalogue_global_search_controller_card.* +import eu.kanade.tachiyomi.util.view.gone +import eu.kanade.tachiyomi.util.view.visible +import kotlinx.android.synthetic.main.global_search_controller_card.progress +import kotlinx.android.synthetic.main.global_search_controller_card.recycler +import kotlinx.android.synthetic.main.global_search_controller_card.source_card +import kotlinx.android.synthetic.main.global_search_controller_card.title /** - * Holder that binds the [CatalogueSearchItem] containing catalogue cards. + * Holder that binds the [GlobalSearchItem] containing catalogue cards. * - * @param view view of [CatalogueSearchItem] - * @param adapter instance of [CatalogueSearchAdapter] + * @param view view of [GlobalSearchItem] + * @param adapter instance of [GlobalSearchAdapter] */ -class CatalogueSearchHolder(view: View, val adapter: CatalogueSearchAdapter) : - BaseFlexibleViewHolder(view, adapter) { +class GlobalSearchHolder(view: View, val adapter: GlobalSearchAdapter) : + BaseFlexibleViewHolder(view, adapter) { /** * Adapter containing manga from search results. */ - private val mangaAdapter = CatalogueSearchCardAdapter(adapter.controller) + private val mangaAdapter = GlobalSearchCardAdapter(adapter.controller) - private var lastBoundResults: List? = null + private var lastBoundResults: List? = null init { // Set layout horizontal. - recycler.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(view.context, androidx.recyclerview.widget.LinearLayoutManager.HORIZONTAL, false) + recycler.layoutManager = LinearLayoutManager(view.context, LinearLayoutManager.HORIZONTAL, false) recycler.adapter = mangaAdapter - - more.setOnClickListener { - val item = adapter.getItem(adapterPosition) - if (item != null) { - adapter.moreClickListener.onMoreClick(item.source) - } - } } /** @@ -42,7 +38,7 @@ class CatalogueSearchHolder(view: View, val adapter: CatalogueSearchAdapter) : * * @param item item of card. */ - fun bind(item: CatalogueSearchItem) { + fun bind(item: GlobalSearchItem) { val source = item.source val results = item.results @@ -87,11 +83,11 @@ class CatalogueSearchHolder(view: View, val adapter: CatalogueSearchAdapter) : * @param manga the manga to find. * @return the holder of the manga or null if it's not bound. */ - private fun getHolder(manga: Manga): CatalogueSearchCardHolder? { + private fun getHolder(manga: Manga): GlobalSearchCardHolder? { mangaAdapter.allBoundViewHolders.forEach { holder -> - val item = mangaAdapter.getItem(holder.adapterPosition) + val item = mangaAdapter.getItem(holder.bindingAdapterPosition) if (item != null && item.manga.id!! == manga.id!!) { - return holder as CatalogueSearchCardHolder + return holder as GlobalSearchCardHolder } } @@ -101,13 +97,10 @@ class CatalogueSearchHolder(view: View, val adapter: CatalogueSearchAdapter) : private fun showHolder() { title.visible() source_card.visible() - more.visible() // EXH } private fun hideHolder() { title.gone() source_card.gone() - more.gone() // EXH } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchItem.kt similarity index 60% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchItem.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchItem.kt index 5c06d15c5..fbfb28bc2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchItem.kt @@ -1,6 +1,7 @@ -package eu.kanade.tachiyomi.ui.catalogue.global_search +package eu.kanade.tachiyomi.ui.browse.source.globalsearch import android.view.View +import androidx.recyclerview.widget.RecyclerView import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractFlexibleItem import eu.davidea.flexibleadapter.items.IFlexible @@ -14,8 +15,8 @@ import eu.kanade.tachiyomi.source.CatalogueSource * @param results the search results. * @param highlighted whether this search item should be highlighted/marked in the catalogue search view. */ -class CatalogueSearchItem(val source: CatalogueSource, val results: List?, val highlighted: Boolean = false) - : AbstractFlexibleItem() { +class GlobalSearchItem(val source: CatalogueSource, val results: List?, val highlighted: Boolean = false) : + AbstractFlexibleItem() { /** * Set view. @@ -23,23 +24,27 @@ class CatalogueSearchItem(val source: CatalogueSource, val results: List>): CatalogueSearchHolder { - return CatalogueSearchHolder(view, adapter as CatalogueSearchAdapter) + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): GlobalSearchHolder { + return GlobalSearchHolder(view, adapter as GlobalSearchAdapter) } /** * Bind item to view. */ - override fun bindViewHolder(adapter: FlexibleAdapter>, holder: CatalogueSearchHolder, - position: Int, payloads: List?) { + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: GlobalSearchHolder, + position: Int, + payloads: List? + ) { holder.bind(this) } @@ -49,7 +54,7 @@ class CatalogueSearchItem(val source: CatalogueSource, val results: List() { +open class GlobalSearchPresenter( + val initialQuery: String? = "", + val initialExtensionFilter: String? = null, + val sourceManager: SourceManager = Injekt.get(), + val db: DatabaseHelper = Injekt.get(), + val preferences: PreferencesHelper = Injekt.get() +) : BasePresenter() { /** * Enabled sources. @@ -74,11 +72,14 @@ open class CatalogueSearchPresenter( override fun onCreate(savedState: Bundle?) { super.onCreate(savedState) - extensionFilter = savedState?.getString(CatalogueSearchPresenter::extensionFilter.name) ?: - initialExtensionFilter + extensionFilter = savedState?.getString(GlobalSearchPresenter::extensionFilter.name) + ?: initialExtensionFilter // Perform a search with previous or initial state - search(savedState?.getString(BrowseCataloguePresenter::query.name) ?: initialQuery.orEmpty()) + search( + savedState?.getString(BrowseSourcePresenter::query.name) + ?: initialQuery.orEmpty() + ) } override fun onDestroy() { @@ -88,52 +89,57 @@ open class CatalogueSearchPresenter( } override fun onSave(state: Bundle) { - state.putString(BrowseCataloguePresenter::query.name, query) - state.putString(CatalogueSearchPresenter::extensionFilter.name, extensionFilter) + state.putString(BrowseSourcePresenter::query.name, query) + state.putString(GlobalSearchPresenter::extensionFilter.name, extensionFilter) super.onSave(state) } /** - * Returns a list of enabled sources ordered by language and name. + * Returns a list of enabled sources ordered by language and name, with pinned catalogues + * prioritized. * * @return list containing enabled sources. */ protected open fun getEnabledSources(): List { - val languages = preferencesHelper.enabledLanguages().getOrDefault() - val hiddenCatalogues = preferencesHelper.hiddenCatalogues().getOrDefault() + val languages = preferences.enabledLanguages().get() + val hiddenCatalogues = preferences.hiddenCatalogues().get() + val pinnedCatalogues = preferences.pinnedCatalogues().get() return sourceManager.getVisibleCatalogueSources() - .filter { it.lang in languages } - .filterNot { it is LoginSource && !it.isLogged() } - .filterNot { it.id.toString() in hiddenCatalogues } - .sortedBy { "(${it.lang}) ${it.name}" } + .filter { it.lang in languages } + .filterNot { it.id.toString() in hiddenCatalogues } + .sortedWith(compareBy({ it.id.toString() !in pinnedCatalogues }, { "(${it.lang}) ${it.name}" })) } private fun getSourcesToQuery(): List { val filter = extensionFilter val enabledSources = getEnabledSources() - if (filter.isNullOrEmpty()) { - return enabledSources + var filteredSources: List? = null + + if (!filter.isNullOrEmpty()) { + filteredSources = extensionManager.installedExtensions + .filter { it.pkgName == filter } + .flatMap { it.sources } + .filter { it in enabledSources } + .filterIsInstance() } - val filterSources = extensionManager.installedExtensions - .filter { it.pkgName == filter } - .flatMap { it.sources } - .filter { it in enabledSources } - .filterIsInstance() - - if (filterSources.isEmpty()) { - return enabledSources + if (filteredSources != null && filteredSources.isNotEmpty()) { + return filteredSources } - return filterSources + val onlyPinnedSources = preferences.searchPinnedSourcesOnly() + val pinnedCatalogues = preferences.pinnedCatalogues().get() + + return enabledSources + .filter { if (onlyPinnedSources) it.id.toString() in pinnedCatalogues else true } } /** * Creates a catalogue search item */ - protected open fun createCatalogueSearchItem(source: CatalogueSource, results: List?): CatalogueSearchItem { - return CatalogueSearchItem(source, results) + protected open fun createCatalogueSearchItem(source: CatalogueSource, results: List?): GlobalSearchItem { + return GlobalSearchItem(source, results) } /** @@ -157,29 +163,35 @@ open class CatalogueSearchPresenter( fetchSourcesSubscription?.unsubscribe() fetchSourcesSubscription = Observable.from(sources) - .flatMap({ source -> + .flatMap( + { source -> Observable.defer { source.fetchSearchManga(1, query, FilterList()) } - .subscribeOn(Schedulers.io()) - .onErrorReturn { MangasPage(emptyList(), false) } // Ignore timeouts or other exceptions - .map { it.mangas.take(10) } // Get at most 10 manga from search result. - .map { it.map { networkToLocalManga(it, source.id) } } // Convert to local manga. - .doOnNext { fetchImage(it, source) } // Load manga covers. - .map { createCatalogueSearchItem(source, it.map { CatalogueSearchCardItem(it) }) } - }, 5) - .observeOn(AndroidSchedulers.mainThread()) - // Update matching source with the obtained results - .map { result -> - items.map { item -> if (item.source == result.source) result else item } - } - // Update current state - .doOnNext { items = it } - // Deliver initial state - .startWith(initialItems) - .subscribeLatestCache({ view, manga -> + .subscribeOn(Schedulers.io()) + .onErrorReturn { MangasPage(emptyList(), false) } // Ignore timeouts or other exceptions + .map { it.mangas.take(10) } // Get at most 10 manga from search result. + .map { list -> list.map { networkToLocalManga(it, source.id) } } // Convert to local manga. + .doOnNext { fetchImage(it, source) } // Load manga covers. + .map { list -> createCatalogueSearchItem(source, list.map { GlobalSearchCardItem(it) }) } + }, + 5 + ) + .observeOn(AndroidSchedulers.mainThread()) + // Update matching source with the obtained results + .map { result -> + items.map { item -> if (item.source == result.source) result else item } + } + // Update current state + .doOnNext { items = it } + // Deliver initial state + .startWith(initialItems) + .subscribeLatestCache( + { view, manga -> view.setItems(manga) - }, { _, error -> + }, + { _, error -> Timber.e(error) - }) + } + ) } /** @@ -197,23 +209,24 @@ open class CatalogueSearchPresenter( private fun initializeFetchImageSubscription() { fetchImageSubscription?.unsubscribe() fetchImageSubscription = fetchImageSubject.observeOn(Schedulers.io()) - .flatMap { - val source = it.second - Observable.from(it.first).filter { it.thumbnail_url == null && !it.initialized } - .map { Pair(it, source) } - .concatMap { getMangaDetailsObservable(it.first, it.second) } - .map { Pair(source as CatalogueSource, it) } - - } - - .onBackpressureBuffer() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ (source, manga) -> + .flatMap { pair -> + val source = pair.second + Observable.from(pair.first).filter { it.thumbnail_url == null && !it.initialized } + .map { Pair(it, source) } + .concatMap { getMangaDetailsObservable(it.first, it.second) } + .map { Pair(source as CatalogueSource, it) } + } + .onBackpressureBuffer() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { (source, manga) -> @Suppress("DEPRECATION") view?.onMangaInitialized(source, manga) - }, { error -> + }, + { error -> Timber.e(error) - }) + } + ) } /** @@ -224,13 +237,13 @@ open class CatalogueSearchPresenter( */ private fun getMangaDetailsObservable(manga: Manga, source: Source): Observable { return source.fetchMangaDetails(manga) - .flatMap { networkManga -> - manga.copyFrom(networkManga) - manga.initialized = true - db.insertManga(manga).executeAsBlocking() - Observable.just(manga) - } - .onErrorResumeNext { Observable.just(manga) } + .flatMap { networkManga -> + manga.copyFrom(networkManga) + manga.initialized = true + db.insertManga(manga).executeAsBlocking() + Observable.just(manga) + } + .onErrorResumeNext { Observable.just(manga) } } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesController.kt new file mode 100644 index 000000000..ba2e504a9 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesController.kt @@ -0,0 +1,33 @@ +package eu.kanade.tachiyomi.ui.browse.source.latest + +import android.os.Bundle +import android.view.Menu +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController +import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter + +/** + * Controller that shows the latest manga from the catalogue. Inherit [BrowseSourceController]. + */ +class LatestUpdatesController(bundle: Bundle) : BrowseSourceController(bundle) { + + constructor(source: CatalogueSource) : this( + Bundle().apply { + putLong(SOURCE_ID_KEY, source.id) + } + ) + + override fun createPresenter(): BrowseSourcePresenter { + return LatestUpdatesPresenter(args.getLong(SOURCE_ID_KEY)) + } + + override fun onPrepareOptionsMenu(menu: Menu) { + super.onPrepareOptionsMenu(menu) + menu.findItem(R.id.action_search).isVisible = false + } + + override fun initFilterSheet() { + // No-op: we don't allow filtering in latest + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/latest/LatestUpdatesPager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesPager.kt old mode 100755 new mode 100644 similarity index 54% rename from app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/latest/LatestUpdatesPager.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesPager.kt index 2e646638b..97eccfc14 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/latest/LatestUpdatesPager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesPager.kt @@ -1,8 +1,8 @@ -package eu.kanade.tachiyomi.ui.catalogue.latest +package eu.kanade.tachiyomi.ui.browse.source.latest import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.model.MangasPage -import eu.kanade.tachiyomi.ui.catalogue.browse.Pager +import eu.kanade.tachiyomi.ui.browse.source.browse.Pager import rx.Observable import rx.android.schedulers.AndroidSchedulers import rx.schedulers.Schedulers @@ -10,13 +10,12 @@ import rx.schedulers.Schedulers /** * LatestUpdatesPager inherited from the general Pager. */ -class LatestUpdatesPager(val source: CatalogueSource): Pager() { +class LatestUpdatesPager(val source: CatalogueSource) : Pager() { override fun requestNext(): Observable { return source.fetchLatestUpdates(currentPage) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext { onPageReceived(it) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { onPageReceived(it) } } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesPresenter.kt new file mode 100644 index 000000000..964c0b3c6 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesPresenter.kt @@ -0,0 +1,15 @@ +package eu.kanade.tachiyomi.ui.browse.source.latest + +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter +import eu.kanade.tachiyomi.ui.browse.source.browse.Pager + +/** + * Presenter of [LatestUpdatesController]. Inherit BrowseCataloguePresenter. + */ +class LatestUpdatesPresenter(sourceId: Long) : BrowseSourcePresenter(sourceId) { + + override fun createPager(query: String, filters: FilterList): Pager { + return LatestUpdatesPager(source) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueController.kt deleted file mode 100755 index c194d7b03..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueController.kt +++ /dev/null @@ -1,270 +0,0 @@ -package eu.kanade.tachiyomi.ui.catalogue - -import android.Manifest.permission.WRITE_EXTERNAL_STORAGE -import android.os.Bundle -import android.os.Parcelable -import android.view.* -import androidx.appcompat.widget.SearchView -import com.bluelinelabs.conductor.ControllerChangeHandler -import com.bluelinelabs.conductor.ControllerChangeType -import com.bluelinelabs.conductor.RouterTransaction -import com.bluelinelabs.conductor.changehandler.FadeChangeHandler -import com.jakewharton.rxbinding.support.v7.widget.queryTextChangeEvents -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.IFlexible -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.source.CatalogueSource -import eu.kanade.tachiyomi.source.online.LoginSource -import eu.kanade.tachiyomi.ui.base.controller.NucleusController -import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe -import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction -import eu.kanade.tachiyomi.ui.catalogue.browse.BrowseCatalogueController -import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchController -import eu.kanade.tachiyomi.ui.catalogue.latest.LatestUpdatesController -import eu.kanade.tachiyomi.ui.setting.SettingsSourcesController -import eu.kanade.tachiyomi.widget.preference.SourceLoginDialog -import exh.ui.smartsearch.SmartSearchController -import kotlinx.android.parcel.Parcelize -import kotlinx.android.synthetic.main.catalogue_main_controller.* -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get - -/** - * This controller shows and manages the different catalogues enabled by the user. - * This controller should only handle UI actions, IO actions should be done by [CataloguePresenter] - * [SourceLoginDialog.Listener] refreshes the adapter on successful login of catalogues. - * [CatalogueAdapter.OnBrowseClickListener] call function data on browse item click. - * [CatalogueAdapter.OnLatestClickListener] call function data on latest item click - */ -class CatalogueController(bundle: Bundle? = null) : NucleusController(bundle), - SourceLoginDialog.Listener, - FlexibleAdapter.OnItemClickListener, - CatalogueAdapter.OnBrowseClickListener, - CatalogueAdapter.OnLatestClickListener { - - /** - * Application preferences. - */ - private val preferences: PreferencesHelper = Injekt.get() - - /** - * Adapter containing sources. - */ - private var adapter: CatalogueAdapter? = null - - private val smartSearchConfig: SmartSearchConfig? = args.getParcelable(SMART_SEARCH_CONFIG) - - // EXH --> - private val mode = if(smartSearchConfig == null) Mode.CATALOGUE else Mode.SMART_SEARCH - // EXH <-- - - /** - * Called when controller is initialized. - */ - init { - // Enable the option menu - setHasOptionsMenu(mode == Mode.CATALOGUE) - } - - /** - * Set the title of controller. - * - * @return title. - */ - override fun getTitle(): String? { - return when(mode) { - Mode.CATALOGUE -> applicationContext?.getString(R.string.label_catalogues) - Mode.SMART_SEARCH -> "Find in another source" - } - } - - /** - * Create the [CataloguePresenter] used in controller. - * - * @return instance of [CataloguePresenter] - */ - override fun createPresenter(): CataloguePresenter { - return CataloguePresenter(controllerMode = mode) - } - - /** - * Initiate the view with [R.layout.catalogue_main_controller]. - * - * @param inflater used to load the layout xml. - * @param container containing parent views. - * @return inflated view. - */ - override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.catalogue_main_controller, container, false) - } - - /** - * Called when the view is created - * - * @param view view of controller - */ - override fun onViewCreated(view: View) { - super.onViewCreated(view) - - adapter = CatalogueAdapter(this) - - // Create recycler and set adapter. - recycler.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(view.context) - recycler.adapter = adapter - recycler.addItemDecoration(SourceDividerItemDecoration(view.context)) - - requestPermissionsSafe(arrayOf(WRITE_EXTERNAL_STORAGE), 301) - } - - override fun onDestroyView(view: View) { - adapter = null - super.onDestroyView(view) - } - - override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { - super.onChangeStarted(handler, type) - if (!type.isPush && handler is SettingsSourcesFadeChangeHandler) { - presenter.updateSources() - } - } - - /** - * Called when login dialog is closed, refreshes the adapter. - * - * @param source clicked item containing source information. - */ - override fun loginDialogClosed(source: LoginSource) { - if (source.isLogged()) { - adapter?.clear() - presenter.loadSources() - } - } - - /** - * Called when item is clicked - */ - override fun onItemClick(view: View?, position: Int): Boolean { - val item = adapter?.getItem(position) as? SourceItem ?: return false - val source = item.source - if (source is LoginSource && !source.isLogged()) { - val dialog = SourceLoginDialog(source) - dialog.targetController = this - dialog.showDialog(router) - } else { - when(mode) { - Mode.CATALOGUE -> { - // Open the catalogue view. - openCatalogue(source, BrowseCatalogueController(source)) - } - Mode.SMART_SEARCH -> router.pushController(SmartSearchController(Bundle().apply { - putLong(SmartSearchController.ARG_SOURCE_ID, source.id) - putParcelable(SmartSearchController.ARG_SMART_SEARCH_CONFIG, smartSearchConfig) - }).withFadeTransaction()) - } - } - return false - } - - /** - * Called when browse is clicked in [CatalogueAdapter] - */ - override fun onBrowseClick(position: Int) { - onItemClick(null, position) - } - - /** - * Called when latest is clicked in [CatalogueAdapter] - */ - override fun onLatestClick(position: Int) { - val item = adapter?.getItem(position) as? SourceItem ?: return - openCatalogue(item.source, LatestUpdatesController(item.source)) - } - - /** - * Opens a catalogue with the given controller. - */ - private fun openCatalogue(source: CatalogueSource, controller: BrowseCatalogueController) { - preferences.lastUsedCatalogueSource().set(source.id) - router.pushController(controller.withFadeTransaction()) - } - - /** - * Adds items to the options menu. - * - * @param menu menu containing options. - * @param inflater used to load the menu xml. - */ - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - // Inflate menu - inflater.inflate(R.menu.catalogue_main, menu) - - // Initialize search option. - val searchItem = menu.findItem(R.id.action_search) - val searchView = searchItem.actionView as SearchView - - // Change hint to show global search. - searchView.queryHint = applicationContext?.getString(R.string.action_global_search_hint) - - // Create query listener which opens the global search view. - searchView.queryTextChangeEvents() - .filter { it.isSubmitted } - .subscribeUntilDestroy { performGlobalSearch(it.queryText().toString()) } - } - - fun performGlobalSearch(query: String){ - router.pushController(CatalogueSearchController(query).withFadeTransaction()) - } - - /** - * Called when an option menu item has been selected by the user. - * - * @param item The selected item. - * @return True if this event has been consumed, false if it has not. - */ - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - // Initialize option to open catalogue settings. - R.id.action_settings -> { - router.pushController((RouterTransaction.with(SettingsSourcesController())) - .popChangeHandler(SettingsSourcesFadeChangeHandler()) - .pushChangeHandler(FadeChangeHandler())) - } - else -> return super.onOptionsItemSelected(item) - } - return true - } - - /** - * Called to update adapter containing sources. - */ - fun setSources(sources: List>) { - adapter?.updateDataSet(sources) - } - - /** - * Called to set the last used catalogue at the top of the view. - */ - fun setLastUsedSource(item: SourceItem?) { - adapter?.removeAllScrollableHeaders() - if (item != null) { - adapter?.addScrollableHeader(item) - } - } - - class SettingsSourcesFadeChangeHandler : FadeChangeHandler() - - // EXH --> - @Parcelize - data class SmartSearchConfig(val origTitle: String, val origMangaId: Long) : Parcelable - // EXH <-- - - enum class Mode { - CATALOGUE, - SMART_SEARCH - } - - companion object { - const val SMART_SEARCH_CONFIG = "SMART_SEARCH_CONFIG" - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/LangHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/LangHolder.kt deleted file mode 100644 index 66c94967d..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/LangHolder.kt +++ /dev/null @@ -1,15 +0,0 @@ -package eu.kanade.tachiyomi.ui.catalogue - -import android.view.View -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder -import eu.kanade.tachiyomi.util.LocaleHelper -import kotlinx.android.synthetic.main.catalogue_main_controller_card.* - -class LangHolder(view: View, adapter: FlexibleAdapter<*>) : - BaseFlexibleViewHolder(view, adapter) { - - fun bind(item: LangItem) { - title.text = LocaleHelper.getDisplayName(item.code, itemView.context) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/SourceHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/SourceHolder.kt deleted file mode 100644 index 984b5f8d7..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/SourceHolder.kt +++ /dev/null @@ -1,65 +0,0 @@ -package eu.kanade.tachiyomi.ui.catalogue - -import android.view.View -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.source.online.LoginSource -import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder -import eu.kanade.tachiyomi.ui.base.holder.SlicedHolder -import eu.kanade.tachiyomi.util.getRound -import eu.kanade.tachiyomi.util.gone -import eu.kanade.tachiyomi.util.visible -import io.github.mthli.slice.Slice -import kotlinx.android.synthetic.main.catalogue_main_controller_card_item.* - -class SourceHolder(view: View, override val adapter: CatalogueAdapter, val showButtons: Boolean) : - BaseFlexibleViewHolder(view, adapter), - SlicedHolder { - - override val slice = Slice(card).apply { - setColor(adapter.cardBackground) - } - - override val viewToSlice: View - get() = card - - init { - source_browse.setOnClickListener { - adapter.browseClickListener.onBrowseClick(adapterPosition) - } - - source_latest.setOnClickListener { - adapter.latestClickListener.onLatestClick(adapterPosition) - } - - if(!showButtons) { - source_browse.gone() - source_latest.gone() - } - } - - fun bind(item: SourceItem) { - val source = item.source - setCardEdges(item) - - // Set source name - title.text = source.name - - // Set circle letter image. - itemView.post { - image.setImageDrawable(image.getRound(source.name.take(1).toUpperCase(), false)) - } - - // If source is login, show only login option - if (source is LoginSource && !source.isLogged()) { - source_browse.setText(R.string.login) - source_latest.gone() - } else { - source_browse.setText(R.string.browse) - if (source.supportsLatest && showButtons) { - source_latest.visible() - } else { - source_latest.gone() - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/BrowseCatalogueController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/BrowseCatalogueController.kt deleted file mode 100644 index 74936d3d2..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/BrowseCatalogueController.kt +++ /dev/null @@ -1,650 +0,0 @@ -package eu.kanade.tachiyomi.ui.catalogue.browse - -import android.content.res.Configuration -import android.os.Bundle -import android.view.* -import androidx.appcompat.widget.SearchView -import androidx.core.view.GravityCompat -import androidx.drawerlayout.widget.DrawerLayout -import androidx.recyclerview.widget.RecyclerView -import com.afollestad.materialdialogs.MaterialDialog -import com.elvishew.xlog.XLog -import com.f2prateek.rx.preferences.Preference -import com.google.android.material.snackbar.Snackbar -import com.jakewharton.rxbinding.support.v7.widget.queryTextChangeEvents -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.IFlexible -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Category -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.source.CatalogueSource -import eu.kanade.tachiyomi.source.model.FilterList -import eu.kanade.tachiyomi.source.online.HttpSource -import eu.kanade.tachiyomi.ui.base.controller.NucleusController -import eu.kanade.tachiyomi.ui.base.controller.SecondaryDrawerController -import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction -import eu.kanade.tachiyomi.ui.catalogue.CatalogueController -import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog -import eu.kanade.tachiyomi.ui.manga.MangaController -import eu.kanade.tachiyomi.ui.manga.info.MangaWebViewController -import eu.kanade.tachiyomi.util.* -import eu.kanade.tachiyomi.widget.AutofitRecyclerView -import exh.EXHSavedSearch -import kotlinx.android.synthetic.main.catalogue_controller.* -import kotlinx.android.synthetic.main.main_activity.* -import rx.Observable -import rx.Subscription -import rx.android.schedulers.AndroidSchedulers -import rx.subscriptions.Subscriptions -import uy.kohesive.injekt.injectLazy -import java.util.concurrent.TimeUnit - -/** - * Controller to manage the catalogues available in the app. - */ -open class BrowseCatalogueController(bundle: Bundle) : - NucleusController(bundle), - SecondaryDrawerController, - FlexibleAdapter.OnItemClickListener, - FlexibleAdapter.OnItemLongClickListener, - FlexibleAdapter.EndlessScrollListener, - ChangeMangaCategoriesDialog.Listener { - - constructor(source: CatalogueSource, - searchQuery: String? = null, - smartSearchConfig: CatalogueController.SmartSearchConfig? = null) : this(Bundle().apply { - putLong(SOURCE_ID_KEY, source.id) - - if(searchQuery != null) - putString(SEARCH_QUERY_KEY, searchQuery) - - if (smartSearchConfig != null) - putParcelable(SMART_SEARCH_CONFIG_KEY, smartSearchConfig) - }) - - /** - * Preferences helper. - */ - private val preferences: PreferencesHelper by injectLazy() - - /** - * Adapter containing the list of manga from the catalogue. - */ - private var adapter: FlexibleAdapter>? = null - - /** - * Snackbar containing an error message when a request fails. - */ - private var snack: Snackbar? = null - - /** - * Navigation view containing filter items. - */ - private var navView: CatalogueNavigationView? = null - - /** - * Recycler view with the list of results. - */ - private var recycler: androidx.recyclerview.widget.RecyclerView? = null - - /** - * Subscription for the search view. - */ - private var searchViewSubscription: Subscription? = null - - /** - * Subscription for the number of manga per row. - */ - private var numColumnsSubscription: Subscription? = null - - /** - * Endless loading item. - */ - private var progressItem: ProgressItem? = null - - init { - setHasOptionsMenu(true) - } - - override fun getTitle(): String? { - return presenter.source.name - } - - override fun createPresenter(): BrowseCataloguePresenter { - return BrowseCataloguePresenter(args.getLong(SOURCE_ID_KEY), - args.getString(SEARCH_QUERY_KEY)) - } - - override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.catalogue_controller, container, false) - } - - override fun onViewCreated(view: View) { - super.onViewCreated(view) - - // Initialize adapter, scroll listener and recycler views - adapter = FlexibleAdapter(null, this) - setupRecycler(view) - - navView?.setFilters(presenter.filterItems) - - progress?.visible() - } - - override fun onDestroyView(view: View) { - numColumnsSubscription?.unsubscribe() - numColumnsSubscription = null - searchViewSubscription?.unsubscribe() - searchViewSubscription = null - adapter = null - snack = null - recycler = null - super.onDestroyView(view) - } - - override fun createSecondaryDrawer(drawer: androidx.drawerlayout.widget.DrawerLayout): ViewGroup? { - // Inflate and prepare drawer - val navView = drawer.inflate(R.layout.catalogue_drawer) as CatalogueNavigationView //TODO whatever this is - this.navView = navView - navView.setFilters(presenter.filterItems) - - drawer.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED, GravityCompat.END) - - // EXH --> - navView.setSavedSearches(presenter.loadSearches()) - navView.onSaveClicked = { - MaterialDialog.Builder(navView.context) - .title("Save current search query?") - .input("My search name", "") { _, searchName -> - val oldSavedSearches = presenter.loadSearches() - if(searchName.isNotBlank() - && oldSavedSearches.size < CatalogueNavigationView.MAX_SAVED_SEARCHES) { - val newSearches = oldSavedSearches + EXHSavedSearch( - searchName.toString().trim(), - presenter.query, - presenter.sourceFilters - ) - presenter.saveSearches(newSearches) - navView.setSavedSearches(newSearches) - } - } - .positiveText("Save") - .negativeText("Cancel") - .cancelable(true) - .canceledOnTouchOutside(true) - .show() - } - - navView.onSavedSearchClicked = cb@{ indexToSearch -> - val savedSearches = presenter.loadSearches() - - val search = savedSearches.getOrNull(indexToSearch) - - if(search == null) { - MaterialDialog.Builder(navView.context) - .title("Failed to load saved searches!") - .content("An error occurred while loading your saved searches.") - .cancelable(true) - .canceledOnTouchOutside(true) - .show() - return@cb - } - - presenter.sourceFilters = FilterList(search.filterList) - navView.setFilters(presenter.filterItems) - val allDefault = presenter.sourceFilters == presenter.source.getFilterList() - - showProgressBar() - adapter?.clear() - drawer.closeDrawer(GravityCompat.END) - presenter.restartPager(search.query, if (allDefault) FilterList() else presenter.sourceFilters) - activity?.invalidateOptionsMenu() - } - - navView.onSavedSearchDeleteClicked = cb@{ indexToDelete, name -> - val savedSearches = presenter.loadSearches() - - val search = savedSearches.getOrNull(indexToDelete) - - if(search == null || search.name != name) { - MaterialDialog.Builder(navView.context) - .title("Failed to delete saved search!") - .content("An error occurred while deleting the search.") - .cancelable(true) - .canceledOnTouchOutside(true) - .show() - return@cb - } - - MaterialDialog.Builder(navView.context) - .title("Delete saved search query?") - .content("Are you sure you wish to delete your saved search query: '${search.name}'?") - .positiveText("Cancel") - .negativeText("Confirm") - .onNegative { _, _ -> - val newSearches = savedSearches.filterIndexed { index, _ -> - index != indexToDelete - } - presenter.saveSearches(newSearches) - navView.setSavedSearches(newSearches) - } - .cancelable(true) - .canceledOnTouchOutside(true) - .show() - } - // EXH <-- - - navView.onSearchClicked = { - val allDefault = presenter.sourceFilters == presenter.source.getFilterList() - showProgressBar() - adapter?.clear() - drawer.closeDrawer(GravityCompat.END) - presenter.setSourceFilter(if (allDefault) FilterList() else presenter.sourceFilters) - } - - navView.onResetClicked = { - presenter.appliedFilters = FilterList() - val newFilters = presenter.source.getFilterList() - presenter.sourceFilters = newFilters - navView.setFilters(presenter.filterItems) - } - return navView as ViewGroup //TODO fix this bullshit - } - - override fun cleanupSecondaryDrawer(drawer: androidx.drawerlayout.widget.DrawerLayout) { - navView = null - } - - private fun setupRecycler(view: View) { - numColumnsSubscription?.unsubscribe() - - var oldPosition = androidx.recyclerview.widget.RecyclerView.NO_POSITION - val oldRecycler = catalogue_view?.getChildAt(1) - if (oldRecycler is androidx.recyclerview.widget.RecyclerView) { - oldPosition = (oldRecycler.layoutManager as androidx.recyclerview.widget.LinearLayoutManager).findFirstVisibleItemPosition() - oldRecycler.adapter = null - - catalogue_view?.removeView(oldRecycler) - } - - val recycler = if (presenter.isListMode) { - androidx.recyclerview.widget.RecyclerView(view.context).apply { - id = R.id.recycler - layoutManager = androidx.recyclerview.widget.LinearLayoutManager(context) - layoutParams = androidx.recyclerview.widget.RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - addItemDecoration(androidx.recyclerview.widget.DividerItemDecoration(context, androidx.recyclerview.widget.DividerItemDecoration.VERTICAL)) - } - } else { - (catalogue_view.inflate(R.layout.catalogue_recycler_autofit) as AutofitRecyclerView).apply { - numColumnsSubscription = getColumnsPreferenceForCurrentOrientation().asObservable() - .doOnNext { spanCount = it } - .skip(1) - // Set again the adapter to recalculate the covers height - .subscribe { adapter = this@BrowseCatalogueController.adapter } - - (layoutManager as androidx.recyclerview.widget.GridLayoutManager).spanSizeLookup = object : androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup() { - override fun getSpanSize(position: Int): Int { - return when (adapter?.getItemViewType(position)) { - R.layout.catalogue_grid_item, null -> 1 - else -> spanCount - } - } - } - } - } - recycler.setHasFixedSize(true) - recycler.adapter = adapter - - catalogue_view.addView(recycler, 1) - - if (oldPosition != androidx.recyclerview.widget.RecyclerView.NO_POSITION) { - recycler.layoutManager?.scrollToPosition(oldPosition) - } - this.recycler = recycler - } - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.catalogue_list, menu) - - // Initialize search menu - menu.findItem(R.id.action_search).apply { - val searchView = actionView as SearchView - - val query = presenter.query - if (!query.isBlank()) { - expandActionView() - searchView.setQuery(query, true) - searchView.clearFocus() - } - - val searchEventsObservable = searchView.queryTextChangeEvents() - .skip(1) - .share() - val writingObservable = searchEventsObservable - .filter { !it.isSubmitted } - .debounce(1250, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) - val submitObservable = searchEventsObservable - .filter { it.isSubmitted } - - searchViewSubscription?.unsubscribe() - searchViewSubscription = Observable.merge(writingObservable, submitObservable) - .map { it.queryText().toString() } - .distinctUntilChanged() - .subscribeUntilDestroy { searchWithQuery(it) } - - untilDestroySubscriptions.add( - Subscriptions.create { if (isActionViewExpanded) collapseActionView() }) - } - - // Setup filters button - menu.findItem(R.id.action_set_filter).apply { - icon.mutate() - if (presenter.sourceFilters.isEmpty()) { -// isEnabled = false [EXH] - icon.alpha = 128 - } else { -// isEnabled = true [EXH] - icon.alpha = 255 - } - } - - // Show next display mode - menu.findItem(R.id.action_display_mode).apply { - val icon = if (presenter.isListMode) - R.drawable.ic_view_module_white_24dp - else - R.drawable.ic_view_list_white_24dp - setIcon(icon) - } - } - - override fun onPrepareOptionsMenu(menu: Menu) { - super.onPrepareOptionsMenu(menu) - - val isHttpSource = presenter.source is HttpSource - menu.findItem(R.id.action_open_in_browser).isVisible = isHttpSource - menu.findItem(R.id.action_open_in_web_view).isVisible = isHttpSource - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_display_mode -> swapDisplayMode() - R.id.action_set_filter -> navView?.let { activity?.drawer?.openDrawer(GravityCompat.END) } - R.id.action_open_in_browser -> openInBrowser() - R.id.action_open_in_web_view -> openInWebView() - else -> return super.onOptionsItemSelected(item) - } - return true - } - - private fun openInBrowser() { - val source = presenter.source as? HttpSource ?: return - - activity?.openInBrowser(source.baseUrl) - } - - private fun openInWebView() { - val source = presenter.source as? HttpSource ?: return - - router.pushController(MangaWebViewController(source.id, source.baseUrl) - .withFadeTransaction()) - } - - /** - * Restarts the request with a new query. - * - * @param newQuery the new query. - */ - private fun searchWithQuery(newQuery: String) { - // If text didn't change, do nothing - if (presenter.query == newQuery) - return - - // FIXME dirty fix to restore the toolbar buttons after closing search mode. - if (newQuery == "") { - activity?.invalidateOptionsMenu() - } - - showProgressBar() - adapter?.clear() - - presenter.restartPager(newQuery) - } - - /** - * Called from the presenter when the network request is received. - * - * @param page the current page. - * @param mangas the list of manga of the page. - */ - fun onAddPage(page: Int, mangas: List) { - val adapter = adapter ?: return - hideProgressBar() - if (page == 1) { - adapter.clear() - resetProgressItem() - } - adapter.onLoadMoreComplete(mangas) - } - - /** - * Called from the presenter when the network request fails. - * - * @param error the error received. - */ - fun onAddPageError(error: Throwable) { - XLog.w("> Failed to load next catalogue page!", error) - XLog.w("> (source.id: %s, source.name: %s)", - presenter.source.id, - presenter.source.name) - - val adapter = adapter ?: return - adapter.onLoadMoreComplete(null) - hideProgressBar() - - snack?.dismiss() - - if (catalogue_view != null) { - val message = if (error is NoResultsException) catalogue_view.context.getString(R.string.no_results_found) else (error.message ?: "") - - snack = catalogue_view.snack(message, Snackbar.LENGTH_INDEFINITE) { - setAction(R.string.action_retry) { - // If not the first page, show bottom progress bar. - if (adapter.mainItemCount > 0) { - val item = progressItem ?: return@setAction - adapter.addScrollableFooterWithDelay(item, 0, true) - } else { - showProgressBar() - } - presenter.requestNext() - } - } - } - } - - /** - * Sets a new progress item and reenables the scroll listener. - */ - private fun resetProgressItem() { - progressItem = ProgressItem() - adapter?.endlessTargetCount = 0 - adapter?.setEndlessScrollListener(this, progressItem!!) - } - - /** - * Called by the adapter when scrolled near the bottom. - */ - override fun onLoadMore(lastPosition: Int, currentPage: Int) { - if (presenter.hasNextPage()) { - presenter.requestNext() - } else { - adapter?.onLoadMoreComplete(null) - adapter?.endlessTargetCount = 1 - } - } - - override fun noMoreLoad(newItemsSize: Int) { - } - - /** - * Called from the presenter when a manga is initialized. - * - * @param manga the manga initialized - */ - fun onMangaInitialized(manga: Manga) { - getHolder(manga)?.setImage(manga) - } - - /** - * Swaps the current display mode. - */ - fun swapDisplayMode() { - val view = view ?: return - val adapter = adapter ?: return - - presenter.swapDisplayMode() - val isListMode = presenter.isListMode - activity?.invalidateOptionsMenu() - setupRecycler(view) - if (!isListMode || !view.context.connectivityManager.isActiveNetworkMetered) { - // Initialize mangas if going to grid view or if over wifi when going to list view - val mangas = (0 until adapter.itemCount).mapNotNull { - (adapter.getItem(it) as? CatalogueItem)?.manga - } - presenter.initializeMangas(mangas) - } - } - - /** - * Returns a preference for the number of manga per row based on the current orientation. - * - * @return the preference. - */ - fun getColumnsPreferenceForCurrentOrientation(): Preference { - return if (resources?.configuration?.orientation == Configuration.ORIENTATION_PORTRAIT) - preferences.portraitColumns() - else - preferences.landscapeColumns() - } - - /** - * Returns the view holder for the given manga. - * - * @param manga the manga to find. - * @return the holder of the manga or null if it's not bound. - */ - private fun getHolder(manga: Manga): CatalogueHolder? { - val adapter = adapter ?: return null - - adapter.allBoundViewHolders.forEach { holder -> - val item = adapter.getItem(holder.adapterPosition) as? CatalogueItem - if (item != null && item.manga.id!! == manga.id!!) { - return holder as CatalogueHolder - } - } - - return null - } - - /** - * Shows the progress bar. - */ - private fun showProgressBar() { - progress?.visible() - snack?.dismiss() - snack = null - } - - /** - * Hides active progress bars. - */ - private fun hideProgressBar() { - progress?.gone() - } - - /** - * Called when a manga is clicked. - * - * @param position the position of the element clicked. - * @return true if the item should be selected, false otherwise. - */ - override fun onItemClick(view: View, position: Int): Boolean { - val item = adapter?.getItem(position) as? CatalogueItem ?: return false - router.pushController(MangaController(item.manga, - true, - args.getParcelable(SMART_SEARCH_CONFIG_KEY)).withFadeTransaction()) - - return false - } - - /** - * Called when a manga is long clicked. - * - * Adds the manga to the default category if none is set it shows a list of categories for the user to put the manga - * in, the list consists of the default category plus the user's categories. The default category is preselected on - * new manga, and on already favorited manga the manga's categories are preselected. - * - * @param position the position of the element clicked. - */ - override fun onItemLongClick(position: Int) { - val activity = activity ?: return - val manga = (adapter?.getItem(position) as? CatalogueItem?)?.manga ?: return - if (manga.favorite) { - MaterialDialog.Builder(activity) - .items(activity.getString(R.string.remove_from_library)) - .itemsCallback { _, _, which, _ -> - when (which) { - 0 -> { - presenter.changeMangaFavorite(manga) - adapter?.notifyItemChanged(position) - activity?.toast(activity?.getString(R.string.manga_removed_library)) - } - } - }.show() - } else { - presenter.changeMangaFavorite(manga) - adapter?.notifyItemChanged(position) - - val categories = presenter.getCategories() - val defaultCategoryId = preferences.defaultCategory() - val defaultCategory = categories.find { it.id == defaultCategoryId } - when { - defaultCategory != null -> presenter.moveMangaToCategory(manga, defaultCategory) - defaultCategoryId == 0 || categories.isEmpty() -> // 'Default' or no category - presenter.moveMangaToCategory(manga, null) - else -> { - val ids = presenter.getMangaCategoryIds(manga) - val preselected = ids.mapNotNull { id -> - categories.indexOfFirst { it.id == id }.takeIf { it != -1 } - }.toTypedArray() - - ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected) - .showDialog(router) - } - } - activity?.toast(activity?.getString(R.string.manga_added_library)) - } - - } - - /** - * Update manga to use selected categories. - * - * @param mangas The list of manga to move to categories. - * @param categories The list of categories where manga will be placed. - */ - override fun updateCategoriesForMangas(mangas: List, categories: List) { - val manga = mangas.firstOrNull() ?: return - presenter.updateMangaCategories(manga, categories) - } - - protected companion object { - const val SOURCE_ID_KEY = "sourceId" - const val SEARCH_QUERY_KEY = "searchQuery" - // EXH --> - const val SMART_SEARCH_CONFIG_KEY = "smartSearchConfig" - // EXH <-- - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/CatalogueNavigationView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/CatalogueNavigationView.kt deleted file mode 100755 index afff33e80..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/CatalogueNavigationView.kt +++ /dev/null @@ -1,90 +0,0 @@ -package eu.kanade.tachiyomi.ui.catalogue.browse - -import android.content.Context -import android.util.AttributeSet -import android.util.TypedValue -import android.view.Gravity -import android.view.View -import android.view.ViewGroup -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import android.view.ViewGroup.LayoutParams.WRAP_CONTENT -import android.widget.LinearLayout -import android.widget.TextView -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.IFlexible -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.util.dpToPx -import eu.kanade.tachiyomi.util.inflate -import eu.kanade.tachiyomi.widget.SimpleNavigationView -import exh.EXHSavedSearch -import kotlinx.android.synthetic.main.catalogue_drawer_content.view.* - -class CatalogueNavigationView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) - : SimpleNavigationView(context, attrs) { - - val adapter: FlexibleAdapter> = FlexibleAdapter>(null) - .setDisplayHeadersAtStartUp(true) - .setStickyHeaders(true) - - var onSearchClicked = {} - - var onResetClicked = {} - - // EXH --> - var onSaveClicked = {} - // EXH <-- - - // EXH --> - var onSavedSearchClicked: (Int) -> Unit = {} - // EXH <-- - - // EXH --> - var onSavedSearchDeleteClicked: (Int, String) -> Unit = { index, name -> } - // EXH <-- - - init { - recycler.adapter = adapter - recycler.setHasFixedSize(true) - val view = inflate(R.layout.catalogue_drawer_content) - ((view as ViewGroup).getChildAt(1) as ViewGroup).addView(recycler) - addView(view) - title.text = context.getString(R.string.source_search_options) - save_search_btn.setOnClickListener { onSaveClicked() } - search_btn.setOnClickListener { onSearchClicked() } - reset_btn.setOnClickListener { onResetClicked() } - } - - fun setFilters(items: List>) { - adapter.updateDataSet(items) - } - - // EXH --> - fun setSavedSearches(searches: List) { - saved_searches.removeAllViews() - - val outValue = TypedValue() - context.theme.resolveAttribute(android.R.attr.selectableItemBackground, outValue, true) - - save_search_btn.visibility = if(searches.size < 5) View.VISIBLE else View.GONE - - searches.withIndex().sortedBy { it.value.name }.forEach { (index, search) -> - val restoreBtn = TextView(context) - restoreBtn.text = search.name - val params = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) - params.gravity = Gravity.CENTER - restoreBtn.layoutParams = params - restoreBtn.gravity = Gravity.CENTER - restoreBtn.setBackgroundResource(outValue.resourceId) - restoreBtn.setPadding(8.dpToPx, 8.dpToPx, 8.dpToPx, 8.dpToPx) - restoreBtn.setOnClickListener { onSavedSearchClicked(index) } - restoreBtn.setOnLongClickListener { onSavedSearchDeleteClicked(index, search.name); true } - saved_searches.addView(restoreBtn) - } - } - - companion object { - const val MAX_SAVED_SEARCHES = 5 - } - // EXH <-- - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/NoResultsException.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/NoResultsException.kt deleted file mode 100644 index 723782f5e..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/NoResultsException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package eu.kanade.tachiyomi.ui.catalogue.browse - -class NoResultsException : Exception() \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/HelpDialogItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/HelpDialogItem.kt deleted file mode 100755 index cf4c5626a..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/HelpDialogItem.kt +++ /dev/null @@ -1,60 +0,0 @@ -package eu.kanade.tachiyomi.ui.catalogue.filter - -import android.annotation.SuppressLint -import android.view.View -import android.widget.Button -import android.widget.TextView -import com.afollestad.materialdialogs.MaterialDialog -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.AbstractHeaderItem -import eu.davidea.flexibleadapter.items.IFlexible -import eu.davidea.viewholders.FlexibleViewHolder -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.source.model.Filter -import io.noties.markwon.Markwon -import uy.kohesive.injekt.injectLazy - -class HelpDialogItem(val filter: Filter.HelpDialog) : AbstractHeaderItem() { - private val markwon: Markwon by injectLazy() - - @SuppressLint("PrivateResource") - override fun getLayoutRes(): Int { - return R.layout.navigation_view_help_dialog - } - - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): Holder { - return Holder(view, adapter) - } - - override fun bindViewHolder(adapter: FlexibleAdapter>, holder: Holder, position: Int, payloads: List?) { - val view = holder.button as TextView - view.text = filter.name - view.setOnClickListener { - val v = TextView(view.context) - - val parsed = markwon.parse(filter.markdown) - val rendered = markwon.render(parsed) - markwon.setParsedMarkdown(v, rendered) - - MaterialDialog.Builder(view.context) - .title(filter.dialogTitle) - .customView(v, true) - .positiveText("Ok") - .show() - } - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - return filter == (other as HelpDialogItem).filter - } - - override fun hashCode(): Int { - return filter.hashCode() - } - - class Holder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) { - val button: Button = itemView.findViewById(R.id.dialog_open_button) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchCardItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchCardItem.kt deleted file mode 100644 index 1719bb884..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/global_search/CatalogueSearchCardItem.kt +++ /dev/null @@ -1,36 +0,0 @@ -package eu.kanade.tachiyomi.ui.catalogue.global_search - -import android.view.View -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.AbstractFlexibleItem -import eu.davidea.flexibleadapter.items.IFlexible -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Manga - -class CatalogueSearchCardItem(val manga: Manga) : AbstractFlexibleItem() { - - override fun getLayoutRes(): Int { - return R.layout.catalogue_global_search_controller_card_item - } - - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): CatalogueSearchCardHolder { - return CatalogueSearchCardHolder(view, adapter as CatalogueSearchCardAdapter) - } - - override fun bindViewHolder(adapter: FlexibleAdapter>, holder: CatalogueSearchCardHolder, - position: Int, payloads: List?) { - holder.bind(manga) - } - - override fun equals(other: Any?): Boolean { - if (other is CatalogueSearchCardItem) { - return manga.id == other.manga.id - } - return false - } - - override fun hashCode(): Int { - return manga.id?.toInt() ?: 0 - } - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/latest/LatestUpdatesController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/latest/LatestUpdatesController.kt deleted file mode 100644 index 2475151a6..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/latest/LatestUpdatesController.kt +++ /dev/null @@ -1,38 +0,0 @@ -package eu.kanade.tachiyomi.ui.catalogue.latest - -import android.os.Bundle -import android.view.Menu -import android.view.ViewGroup -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.source.CatalogueSource -import eu.kanade.tachiyomi.ui.catalogue.browse.BrowseCatalogueController -import eu.kanade.tachiyomi.ui.catalogue.browse.BrowseCataloguePresenter - -/** - * Controller that shows the latest manga from the catalogue. Inherit [BrowseCatalogueController]. - */ -class LatestUpdatesController(bundle: Bundle) : BrowseCatalogueController(bundle) { - - constructor(source: CatalogueSource) : this(Bundle().apply { - putLong(SOURCE_ID_KEY, source.id) - }) - - override fun createPresenter(): BrowseCataloguePresenter { - return LatestUpdatesPresenter(args.getLong(SOURCE_ID_KEY)) - } - - override fun onPrepareOptionsMenu(menu: Menu) { - super.onPrepareOptionsMenu(menu) - menu.findItem(R.id.action_search).isVisible = false - menu.findItem(R.id.action_set_filter).isVisible = false - } - - override fun createSecondaryDrawer(drawer: androidx.drawerlayout.widget.DrawerLayout): ViewGroup? { - return null - } - - override fun cleanupSecondaryDrawer(drawer: androidx.drawerlayout.widget.DrawerLayout) { - - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/latest/LatestUpdatesPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/latest/LatestUpdatesPresenter.kt deleted file mode 100644 index a1be55797..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/latest/LatestUpdatesPresenter.kt +++ /dev/null @@ -1,16 +0,0 @@ -package eu.kanade.tachiyomi.ui.catalogue.latest - -import eu.kanade.tachiyomi.source.model.FilterList -import eu.kanade.tachiyomi.ui.catalogue.browse.BrowseCataloguePresenter -import eu.kanade.tachiyomi.ui.catalogue.browse.Pager - -/** - * Presenter of [LatestUpdatesController]. Inherit BrowseCataloguePresenter. - */ -class LatestUpdatesPresenter(sourceId: Long) : BrowseCataloguePresenter(sourceId) { - - override fun createPager(query: String, filters: FilterList): Pager { - return LatestUpdatesPager(source) - } - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryAdapter.kt index 0ad0eb90c..b9510868a 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryAdapter.kt @@ -8,7 +8,7 @@ import eu.davidea.flexibleadapter.FlexibleAdapter * @param controller The containing controller. */ class CategoryAdapter(controller: CategoryController) : - FlexibleAdapter(null, controller, true) { + FlexibleAdapter(null, controller, true) { /** * Listener called when an item of the list is released. @@ -23,13 +23,6 @@ class CategoryAdapter(controller: CategoryController) : (0 until itemCount).forEach { getItem(it)?.isSelected = false } } - /** - * Clears the active selections from the model. - */ - fun clearModelSelection() { - selectedPositions.forEach { getItem(it)?.isSelected = false } - } - /** * Toggles the selection of the given position. * @@ -46,5 +39,4 @@ class CategoryAdapter(controller: CategoryController) : */ fun onItemReleased(position: Int) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryController.kt index 950d0460d..5db3fc623 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryController.kt @@ -1,31 +1,40 @@ package eu.kanade.tachiyomi.ui.category -import android.view.* +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode +import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.snackbar.Snackbar -import com.jakewharton.rxbinding.view.clicks import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.SelectableAdapter import eu.davidea.flexibleadapter.helpers.UndoHelper import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.databinding.CategoriesControllerBinding import eu.kanade.tachiyomi.ui.base.controller.NucleusController -import eu.kanade.tachiyomi.util.toast -import kotlinx.android.synthetic.main.categories_controller.* +import eu.kanade.tachiyomi.ui.main.offsetFabAppbarHeight +import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.android.view.clicks /** * Controller to manage the categories for the users' library. */ -class CategoryController : NucleusController(), - ActionMode.Callback, - FlexibleAdapter.OnItemClickListener, - FlexibleAdapter.OnItemLongClickListener, - CategoryAdapter.OnItemReleaseListener, - CategoryCreateDialog.Listener, - CategoryRenameDialog.Listener, - UndoHelper.OnActionListener { +class CategoryController : + NucleusController(), + ActionMode.Callback, + FlexibleAdapter.OnItemClickListener, + FlexibleAdapter.OnItemLongClickListener, + CategoryAdapter.OnItemReleaseListener, + CategoryCreateDialog.Listener, + CategoryRenameDialog.Listener, + UndoHelper.OnActionListener { /** * Object used to show ActionMode toolbar. @@ -61,7 +70,8 @@ class CategoryController : NucleusController(), * @param container The parent view for this one. */ override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.categories_controller, container, false) + binding = CategoriesControllerBinding.inflate(inflater) + return binding.root } /** @@ -73,15 +83,19 @@ class CategoryController : NucleusController(), super.onViewCreated(view) adapter = CategoryAdapter(this@CategoryController) - recycler.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(view.context) - recycler.setHasFixedSize(true) - recycler.adapter = adapter + binding.recycler.layoutManager = LinearLayoutManager(view.context) + binding.recycler.setHasFixedSize(true) + binding.recycler.adapter = adapter adapter?.isHandleDragEnabled = true adapter?.isPermanentDelete = false - fab.clicks().subscribeUntilDestroy { - CategoryCreateDialog(this@CategoryController).showDialog(router, null) - } + binding.fab.clicks() + .onEach { + CategoryCreateDialog(this@CategoryController).showDialog(router, null) + } + .launchIn(scope) + + binding.fab.offsetFabAppbarHeight(activity!!) } /** @@ -107,13 +121,13 @@ class CategoryController : NucleusController(), actionMode?.finish() adapter?.updateDataSet(categories) if (categories.isNotEmpty()) { - empty_view.hide() + binding.emptyView.hide() val selected = categories.filter { it.isSelected } if (selected.isNotEmpty()) { selected.forEach { onItemLongClick(categories.indexOf(it)) } } } else { - empty_view.show(R.drawable.ic_shape_black_128dp, R.string.information_empty_category) + binding.emptyView.show(R.string.information_empty_category) } } @@ -144,7 +158,7 @@ class CategoryController : NucleusController(), override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { val adapter = adapter ?: return false val count = adapter.selectedItemCount - mode.title = resources?.getString(R.string.label_selected, count) + mode.title = count.toString() // Show edit button only when one item is selected val editItem = mode.menu.findItem(R.id.action_edit) @@ -166,8 +180,10 @@ class CategoryController : NucleusController(), when (item.itemId) { R.id.action_delete -> { undoHelper = UndoHelper(adapter, this) - undoHelper?.start(adapter.selectedPositions, view!!, - R.string.snack_categories_deleted, R.string.action_undo, 3000) + undoHelper?.start( + adapter.selectedPositions, view!!, + R.string.snack_categories_deleted, R.string.action_undo, 3000 + ) mode.finish() } @@ -206,11 +222,11 @@ class CategoryController : NucleusController(), */ override fun onItemClick(view: View, position: Int): Boolean { // Check if action mode is initialized and selected item exist. - if (actionMode != null && position != androidx.recyclerview.widget.RecyclerView.NO_POSITION) { + return if (actionMode != null && position != RecyclerView.NO_POSITION) { toggleSelection(position) - return true + true } else { - return false + false } } @@ -241,7 +257,7 @@ class CategoryController : NucleusController(), private fun toggleSelection(position: Int) { val adapter = adapter ?: return - //Mark the position selected + // Mark the position selected adapter.toggleSelection(position) if (adapter.selectedItemCount == 0) { @@ -318,5 +334,4 @@ class CategoryController : NucleusController(), fun onCategoryExistsError() { activity?.toast(R.string.error_category_exists) } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryCreateDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryCreateDialog.kt index dfa4bad32..bdaa51ad1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryCreateDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryCreateDialog.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.category import android.app.Dialog import android.os.Bundle import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.input.input import com.bluelinelabs.conductor.Controller import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.base.controller.DialogController @@ -29,19 +30,21 @@ class CategoryCreateDialog(bundle: Bundle? = null) : DialogController(bundle) * @return a new dialog instance. */ override fun onCreateDialog(savedViewState: Bundle?): Dialog { - return MaterialDialog.Builder(activity!!) - .title(R.string.action_add_category) - .negativeText(android.R.string.cancel) - .alwaysCallInputCallback() - .input(resources?.getString(R.string.name), currentName, false, { _, input -> - currentName = input.toString() - }) - .onPositive { _, _ -> (targetController as? Listener)?.createCategory(currentName) } - .build() + return MaterialDialog(activity!!) + .title(R.string.action_add_category) + .negativeButton(android.R.string.cancel) + .input( + hint = resources?.getString(R.string.name), + prefill = currentName + ) { _, input -> + currentName = input.toString() + } + .positiveButton(android.R.string.ok) { + (targetController as? Listener)?.createCategory(currentName) + } } interface Listener { fun createCategory(name: String) } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryHolder.kt index b37df4022..2549592a7 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryHolder.kt @@ -3,8 +3,8 @@ package eu.kanade.tachiyomi.ui.category import android.view.View import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder -import eu.kanade.tachiyomi.util.getRound -import kotlinx.android.synthetic.main.categories_item.* +import kotlinx.android.synthetic.main.categories_item.reorder +import kotlinx.android.synthetic.main.categories_item.title /** * Holder used to display category items. @@ -15,12 +15,6 @@ import kotlinx.android.synthetic.main.categories_item.* class CategoryHolder(view: View, val adapter: CategoryAdapter) : BaseFlexibleViewHolder(view, adapter) { init { - // Create round letter image onclick to simulate long click - image.setOnClickListener { - // Simulate long click on this view to enter selection mode - onLongClick(view) - } - setDragHandleView(reorder) } @@ -32,11 +26,6 @@ class CategoryHolder(view: View, val adapter: CategoryAdapter) : BaseFlexibleVie fun bind(category: Category) { // Set capitalized title. title.text = category.name.capitalize() - - // Update circle letter image. - itemView.post { - image.setImageDrawable(image.getRound(category.name.take(1).toUpperCase(),false)) - } } /** @@ -48,5 +37,4 @@ class CategoryHolder(view: View, val adapter: CategoryAdapter) : BaseFlexibleVie super.onItemReleased(position) adapter.onItemReleaseListener.onItemReleased(position) } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryItem.kt index d168a2afd..cdcde2a8b 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryItem.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.ui.category import android.view.View +import androidx.recyclerview.widget.RecyclerView import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractFlexibleItem import eu.davidea.flexibleadapter.items.IFlexible @@ -30,7 +31,7 @@ class CategoryItem(val category: Category) : AbstractFlexibleItem>): CategoryHolder { + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): CategoryHolder { return CategoryHolder(view, adapter as CategoryAdapter) } @@ -42,11 +43,12 @@ class CategoryItem(val category: Category) : AbstractFlexibleItem>, - holder: CategoryHolder, - position: Int, - payloads: List?) { - + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: CategoryHolder, + position: Int, + payloads: List? + ) { holder.bind(category) } @@ -68,5 +70,4 @@ class CategoryItem(val category: Category) : AbstractFlexibleItem() { /** @@ -30,10 +30,10 @@ class CategoryPresenter( super.onCreate(savedState) db.getCategories().asRxObservable() - .doOnNext { categories = it } - .map { it.map(::CategoryItem) } - .observeOn(AndroidSchedulers.mainThread()) - .subscribeLatestCache(CategoryController::setCategories) + .doOnNext { categories = it } + .map { it.map(::CategoryItem) } + .observeOn(AndroidSchedulers.mainThread()) + .subscribeLatestCache(CategoryController::setCategories) } /** @@ -100,8 +100,7 @@ class CategoryPresenter( /** * Returns true if a category with the given name already exists. */ - fun categoryExists(name: String): Boolean { + private fun categoryExists(name: String): Boolean { return categories.any { it.name.equals(name, true) } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryRenameDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryRenameDialog.kt index 286093b06..ac1073b65 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryRenameDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryRenameDialog.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.category import android.app.Dialog import android.os.Bundle import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.input.input import com.bluelinelabs.conductor.Controller import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Category @@ -34,15 +35,16 @@ class CategoryRenameDialog(bundle: Bundle? = null) : DialogController(bundle) * @return a new dialog instance. */ override fun onCreateDialog(savedViewState: Bundle?): Dialog { - return MaterialDialog.Builder(activity!!) - .title(R.string.action_rename_category) - .negativeText(android.R.string.cancel) - .alwaysCallInputCallback() - .input(resources!!.getString(R.string.name), currentName, false, { _, input -> - currentName = input.toString() - }) - .onPositive { _, _ -> onPositive() } - .build() + return MaterialDialog(activity!!) + .title(R.string.action_rename_category) + .negativeButton(android.R.string.cancel) + .input( + hint = resources?.getString(R.string.name), + prefill = currentName + ) { _, input -> + currentName = input.toString() + } + .positiveButton(android.R.string.ok) { onPositive() } } /** @@ -82,5 +84,4 @@ class CategoryRenameDialog(bundle: Bundle? = null) : DialogController(bundle) private companion object { const val CATEGORY_KEY = "CategoryRenameDialog.category" } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadAdapter.kt index 76bc02a33..01c72f2df 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadAdapter.kt @@ -1,5 +1,6 @@ package eu.kanade.tachiyomi.ui.download +import android.view.MenuItem import eu.davidea.flexibleadapter.FlexibleAdapter /** @@ -7,18 +8,19 @@ import eu.davidea.flexibleadapter.FlexibleAdapter * * @param context the context of the fragment containing this adapter. */ -class DownloadAdapter(controller: DownloadController) : FlexibleAdapter(null, controller, - true) { +class DownloadAdapter(controller: DownloadController) : FlexibleAdapter( + null, + controller, + true +) { /** * Listener called when an item of the list is released. */ - val onItemReleaseListener: OnItemReleaseListener = controller + val downloadItemListener: DownloadItemListener = controller - interface OnItemReleaseListener { - /** - * Called when an item of the list is released. - */ + interface DownloadItemListener { fun onItemReleased(position: Int) + fun onMenuItemClick(position: Int, menuItem: MenuItem) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadController.kt index 17c441014..be08c93bb 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadController.kt @@ -1,24 +1,35 @@ package eu.kanade.tachiyomi.ui.download -import android.view.* +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.LinearLayoutManager import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.download.DownloadService import eu.kanade.tachiyomi.data.download.model.Download +import eu.kanade.tachiyomi.databinding.DownloadControllerBinding import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.base.controller.NucleusController -import kotlinx.android.synthetic.main.download_controller.* +import eu.kanade.tachiyomi.ui.main.offsetFabAppbarHeight +import java.util.HashMap +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.android.view.clicks import rx.Observable import rx.Subscription import rx.android.schedulers.AndroidSchedulers -import java.util.* -import java.util.concurrent.TimeUnit /** * Controller that shows the currently active downloads. * Uses R.layout.fragment_download_queue. */ -class DownloadController : NucleusController(), - DownloadAdapter.OnItemReleaseListener { +class DownloadController : + NucleusController(), + DownloadAdapter.DownloadItemListener { /** * Adapter containing the active downloads. @@ -40,7 +51,8 @@ class DownloadController : NucleusController(), } override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.download_controller, container, false) + binding = DownloadControllerBinding.inflate(inflater) + return binding.root } override fun createPresenter(): DownloadPresenter { @@ -59,25 +71,42 @@ class DownloadController : NucleusController(), // Initialize adapter. adapter = DownloadAdapter(this@DownloadController) - recycler.adapter = adapter + binding.recycler.adapter = adapter adapter?.isHandleDragEnabled = true // Set the layout manager for the recycler and fixed size. - recycler.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(view.context) - recycler.setHasFixedSize(true) + binding.recycler.layoutManager = LinearLayoutManager(view.context) + binding.recycler.setHasFixedSize(true) - // Suscribe to changes + binding.fab.clicks() + .onEach { + val context = applicationContext ?: return@onEach + + if (isRunning) { + DownloadService.stop(context) + presenter.pauseDownloads() + } else { + DownloadService.start(context) + } + + setInformationView() + } + .launchIn(scope) + + binding.fab.offsetFabAppbarHeight(activity!!) + + // Subscribe to changes DownloadService.runningRelay - .observeOn(AndroidSchedulers.mainThread()) - .subscribeUntilDestroy { onQueueStatusChange(it) } + .observeOn(AndroidSchedulers.mainThread()) + .subscribeUntilDestroy { onQueueStatusChange(it) } presenter.getDownloadStatusObservable() - .observeOn(AndroidSchedulers.mainThread()) - .subscribeUntilDestroy { onStatusChange(it) } + .observeOn(AndroidSchedulers.mainThread()) + .subscribeUntilDestroy { onStatusChange(it) } presenter.getDownloadProgressObservable() - .observeOn(AndroidSchedulers.mainThread()) - .subscribeUntilDestroy { onUpdateDownloadedPages(it) } + .observeOn(AndroidSchedulers.mainThread()) + .subscribeUntilDestroy { onUpdateDownloadedPages(it) } } override fun onDestroyView(view: View) { @@ -94,31 +123,30 @@ class DownloadController : NucleusController(), } override fun onPrepareOptionsMenu(menu: Menu) { - // Set start button visibility. - menu.findItem(R.id.start_queue).isVisible = !isRunning && !presenter.downloadQueue.isEmpty() - - // Set pause button visibility. - menu.findItem(R.id.pause_queue).isVisible = isRunning - - // Set clear button visibility. menu.findItem(R.id.clear_queue).isVisible = !presenter.downloadQueue.isEmpty() + menu.findItem(R.id.reorder).isVisible = !presenter.downloadQueue.isEmpty() } override fun onOptionsItemSelected(item: MenuItem): Boolean { val context = applicationContext ?: return false when (item.itemId) { - R.id.start_queue -> DownloadService.start(context) - R.id.pause_queue -> { - DownloadService.stop(context) - presenter.pauseDownloads() - } R.id.clear_queue -> { DownloadService.stop(context) presenter.clearQueue() } - else -> return super.onOptionsItemSelected(item) + R.id.newest, R.id.oldest -> { + val adapter = adapter ?: return false + val items = adapter.currentItems.sortedBy { it.download.chapter.date_upload } + .toMutableList() + if (item.itemId == R.id.newest) { + items.reverse() + } + adapter.updateDataSet(items) + val downloads = items.mapNotNull { it.download } + presenter.reorder(downloads) + } } - return true + return super.onOptionsItemSelected(item) } /** @@ -149,22 +177,22 @@ class DownloadController : NucleusController(), */ private fun observeProgress(download: Download) { val subscription = Observable.interval(50, TimeUnit.MILLISECONDS) - // Get the sum of percentages for all the pages. - .flatMap { - Observable.from(download.pages) - .map(Page::progress) - .reduce { x, y -> x + y } - } - // Keep only the latest emission to avoid backpressure. - .onBackpressureLatest() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { progress -> - // Update the view only if the progress has changed. - if (download.totalProgress != progress) { - download.totalProgress = progress - onUpdateProgress(download) - } + // Get the sum of percentages for all the pages. + .flatMap { + Observable.from(download.pages) + .map(Page::progress) + .reduce { x, y -> x + y } + } + // Keep only the latest emission to avoid backpressure. + .onBackpressureLatest() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { progress -> + // Update the view only if the progress has changed. + if (download.totalProgress != progress) { + download.totalProgress = progress + onUpdateProgress(download) } + } // Avoid leaking subscriptions progressSubscriptions.remove(download)?.unsubscribe() @@ -210,7 +238,7 @@ class DownloadController : NucleusController(), * * @param download the download whose progress has changed. */ - fun onUpdateProgress(download: Download) { + private fun onUpdateProgress(download: Download) { getHolder(download)?.notifyProgress() } @@ -219,7 +247,7 @@ class DownloadController : NucleusController(), * * @param download the download whose page has been downloaded. */ - fun onUpdateDownloadedPages(download: Download) { + private fun onUpdateDownloadedPages(download: Download) { getHolder(download)?.notifyDownloadedPages() } @@ -230,7 +258,7 @@ class DownloadController : NucleusController(), * @return the holder of the download or null if it's not bound. */ private fun getHolder(download: Download): DownloadHolder? { - return recycler?.findViewHolderForItemId(download.chapter.id!!) as? DownloadHolder + return binding.recycler.findViewHolderForItemId(download.chapter.id!!) as? DownloadHolder } /** @@ -238,10 +266,19 @@ class DownloadController : NucleusController(), */ private fun setInformationView() { if (presenter.downloadQueue.isEmpty()) { - empty_view?.show(R.drawable.ic_file_download_black_128dp, - R.string.information_no_downloads) + binding.emptyView.show(R.string.information_no_downloads) + binding.fab.hide() } else { - empty_view?.hide() + binding.emptyView.hide() + binding.fab.show() + + binding.fab.setImageResource( + if (isRunning) { + R.drawable.ic_pause_24dp + } else { + R.drawable.ic_play_arrow_24dp + } + ) } } @@ -256,4 +293,38 @@ class DownloadController : NucleusController(), presenter.reorder(downloads) } + /** + * Called when the menu item of a download is pressed + * + * @param position The position of the item + * @param menuItem The menu Item pressed + */ + override fun onMenuItemClick(position: Int, menuItem: MenuItem) { + when (menuItem.itemId) { + R.id.move_to_top, R.id.move_to_bottom -> { + val items = adapter?.currentItems?.toMutableList() ?: return + val item = items[position] + items.remove(item) + if (menuItem.itemId == R.id.move_to_top) { + items.add(0, item) + } else { + items.add(item) + } + + val adapter = adapter ?: return + adapter.updateDataSet(items) + val downloads = adapter.currentItems.mapNotNull { it?.download } + presenter.reorder(downloads) + } + R.id.cancel_download -> { + val download = adapter?.getItem(position)?.download ?: return + presenter.cancelDownload(download) + + val adapter = adapter ?: return + adapter.removeItem(position) + val downloads = adapter.currentItems.mapNotNull { it?.download } + presenter.reorder(downloads) + } + } + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadHolder.kt index 57966518d..45fc79e69 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadHolder.kt @@ -1,9 +1,16 @@ package eu.kanade.tachiyomi.ui.download import android.view.View +import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder -import kotlinx.android.synthetic.main.download_item.* +import eu.kanade.tachiyomi.util.view.popupMenu +import kotlinx.android.synthetic.main.download_item.chapter_title +import kotlinx.android.synthetic.main.download_item.download_progress +import kotlinx.android.synthetic.main.download_item.download_progress_text +import kotlinx.android.synthetic.main.download_item.manga_full_title +import kotlinx.android.synthetic.main.download_item.menu +import kotlinx.android.synthetic.main.download_item.reorder /** * Class used to hold the data of a download. @@ -12,27 +19,28 @@ import kotlinx.android.synthetic.main.download_item.* * @param view the inflated view for this holder. * @constructor creates a new download holder. */ -class DownloadHolder(view: View, val adapter: DownloadAdapter) : BaseFlexibleViewHolder(view, adapter) { +class DownloadHolder(private val view: View, val adapter: DownloadAdapter) : + BaseFlexibleViewHolder(view, adapter) { init { setDragHandleView(reorder) + menu.setOnClickListener { it.post { showPopupMenu(it) } } } private lateinit var download: Download /** - * Binds this holder with the given category + * Binds this holder with the given category. * - * @param category The category to bind + * @param category The category to bind. */ fun bind(download: Download) { this.download = download - // Update the chapter name. chapter_title.text = download.chapter.name // Update the manga title - manga_title.text = download.manga.title + manga_full_title.text = download.manga.title // Update the progress bar and the number of downloaded pages val pages = download.pages @@ -68,7 +76,21 @@ class DownloadHolder(view: View, val adapter: DownloadAdapter) : BaseFlexibleVie override fun onItemReleased(position: Int) { super.onItemReleased(position) - adapter.onItemReleaseListener.onItemReleased(position) + adapter.downloadItemListener.onItemReleased(position) } + private fun showPopupMenu(view: View) { + view.popupMenu( + R.menu.download_single, + { + findItem(R.id.move_to_top).isVisible = bindingAdapterPosition != 0 + findItem(R.id.move_to_bottom).isVisible = + bindingAdapterPosition != adapter.itemCount - 1 + }, + { + adapter.downloadItemListener.onMenuItemClick(bindingAdapterPosition, this) + true + } + ) + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadItem.kt index b68da76b0..e523bbe02 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadItem.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.ui.download import android.view.View +import androidx.recyclerview.widget.RecyclerView import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractFlexibleItem import eu.davidea.flexibleadapter.items.IFlexible @@ -9,14 +10,6 @@ import eu.kanade.tachiyomi.data.download.model.Download class DownloadItem(val download: Download) : AbstractFlexibleItem() { - /** - * Whether this item is currently selected. - */ - var isSelected = false - - /** - * Returns the layout resource for this item. - */ override fun getLayoutRes(): Int { return R.layout.download_item } @@ -27,8 +20,10 @@ class DownloadItem(val download: Download) : AbstractFlexibleItem>): DownloadHolder { + override fun createViewHolder( + view: View, + adapter: FlexibleAdapter> + ): DownloadHolder { return DownloadHolder(view, adapter as DownloadAdapter) } @@ -40,8 +35,12 @@ class DownloadItem(val download: Download) : AbstractFlexibleItem>, - holder: DownloadHolder, position: Int, payloads: MutableList) { + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: DownloadHolder, + position: Int, + payloads: MutableList + ) { holder.bind(download) } @@ -63,5 +62,4 @@ class DownloadItem(val download: Download) : AbstractFlexibleItem() { - /** - * Download manager. - */ val downloadManager: DownloadManager by injectLazy() /** @@ -31,21 +27,21 @@ class DownloadPresenter : BasePresenter() { super.onCreate(savedState) downloadQueue.getUpdatedObservable() - .observeOn(AndroidSchedulers.mainThread()) - .map { it.map(::DownloadItem) } - .subscribeLatestCache(DownloadController::onNextDownloads) { _, error -> - Timber.e(error) - } + .observeOn(AndroidSchedulers.mainThread()) + .map { it.map(::DownloadItem) } + .subscribeLatestCache(DownloadController::onNextDownloads) { _, error -> + Timber.e(error) + } } fun getDownloadStatusObservable(): Observable { return downloadQueue.getStatusObservable() - .startWith(downloadQueue.getActiveDownloads()) + .startWith(downloadQueue.getActiveDownloads()) } fun getDownloadProgressObservable(): Observable { return downloadQueue.getProgressObservable() - .onBackpressureBuffer() + .onBackpressureBuffer() } /** @@ -65,4 +61,8 @@ class DownloadPresenter : BasePresenter() { fun reorder(downloads: List) { downloadManager.reorderQueue(downloads) } + + fun cancelDownload(download: Download) { + downloadManager.deletePendingDownload(download) + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionTrustDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionTrustDialog.kt deleted file mode 100644 index 6c1f8bc0a..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionTrustDialog.kt +++ /dev/null @@ -1,44 +0,0 @@ -package eu.kanade.tachiyomi.ui.extension - -import android.app.Dialog -import android.os.Bundle -import com.afollestad.materialdialogs.MaterialDialog -import com.bluelinelabs.conductor.Controller -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.base.controller.DialogController - -class ExtensionTrustDialog(bundle: Bundle? = null) : DialogController(bundle) - where T : Controller, T: ExtensionTrustDialog.Listener { - - constructor(target: T, signatureHash: String, pkgName: String) : this(Bundle().apply { - putString(SIGNATURE_KEY, signatureHash) - putString(PKGNAME_KEY, pkgName) - }) { - targetController = target - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - return MaterialDialog.Builder(activity!!) - .title(R.string.untrusted_extension) - .content(R.string.untrusted_extension_message) - .positiveText(R.string.ext_trust) - .negativeText(R.string.ext_uninstall) - .onPositive { _, _ -> - (targetController as? Listener)?.trustSignature(args.getString(SIGNATURE_KEY)!!) - } - .onNegative { _, _ -> - (targetController as? Listener)?.uninstallExtension(args.getString(PKGNAME_KEY)!!) - } - .build() - } - - private companion object { - const val SIGNATURE_KEY = "signature_key" - const val PKGNAME_KEY = "pkgname_key" - } - - interface Listener { - fun trustSignature(signatureHash: String) - fun uninstallExtension(pkgName: String) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/ChangeMangaCategoriesDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/ChangeMangaCategoriesDialog.kt index 08f933c8e..5d267927f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/ChangeMangaCategoriesDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/ChangeMangaCategoriesDialog.kt @@ -1,48 +1,52 @@ -package eu.kanade.tachiyomi.ui.library - -import android.app.Dialog -import android.os.Bundle -import com.afollestad.materialdialogs.MaterialDialog -import com.bluelinelabs.conductor.Controller -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Category -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.ui.base.controller.DialogController - -class ChangeMangaCategoriesDialog(bundle: Bundle? = null) : - DialogController(bundle) where T : Controller, T : ChangeMangaCategoriesDialog.Listener { - - private var mangas = emptyList() - - private var categories = emptyList() - - private var preselected = emptyArray() - - constructor(target: T, mangas: List, categories: List, - preselected: Array) : this() { - - this.mangas = mangas - this.categories = categories - this.preselected = preselected - targetController = target - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - return MaterialDialog.Builder(activity!!) - .title(R.string.action_move_category) - .items(categories.map { it.name }) - .itemsCallbackMultiChoice(preselected) { dialog, _, _ -> - val newCategories = dialog.selectedIndices?.map { categories[it] }.orEmpty() - (targetController as? Listener)?.updateCategoriesForMangas(mangas, newCategories) - true - } - .positiveText(android.R.string.ok) - .negativeText(android.R.string.cancel) - .build() - } - - interface Listener { - fun updateCategoriesForMangas(mangas: List, categories: List) - } - -} \ No newline at end of file +package eu.kanade.tachiyomi.ui.library + +import android.app.Dialog +import android.os.Bundle +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.list.listItemsMultiChoice +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.ui.base.controller.DialogController + +class ChangeMangaCategoriesDialog(bundle: Bundle? = null) : + DialogController(bundle) where T : Controller, T : ChangeMangaCategoriesDialog.Listener { + + private var mangas = emptyList() + + private var categories = emptyList() + + private var preselected = emptyArray() + + constructor( + target: T, + mangas: List, + categories: List, + preselected: Array + ) : this() { + this.mangas = mangas + this.categories = categories + this.preselected = preselected + targetController = target + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + return MaterialDialog(activity!!) + .title(R.string.action_move_category) + .listItemsMultiChoice( + items = categories.map { it.name }, + initialSelection = preselected.toIntArray(), + allowEmptySelection = true + ) { _, selections, _ -> + val newCategories = selections.map { categories[it] } + (targetController as? Listener)?.updateCategoriesForMangas(mangas, newCategories) + } + .positiveButton(android.R.string.ok) + .negativeButton(android.R.string.cancel) + } + + interface Listener { + fun updateCategoriesForMangas(mangas: List, categories: List) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/DeleteLibraryMangasDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/DeleteLibraryMangasDialog.kt index 1aa376eb8..969f19f57 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/DeleteLibraryMangasDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/DeleteLibraryMangasDialog.kt @@ -1,43 +1,45 @@ -package eu.kanade.tachiyomi.ui.library - -import android.app.Dialog -import android.os.Bundle -import com.afollestad.materialdialogs.MaterialDialog -import com.bluelinelabs.conductor.Controller -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.ui.base.controller.DialogController -import eu.kanade.tachiyomi.widget.DialogCheckboxView - -class DeleteLibraryMangasDialog(bundle: Bundle? = null) : - DialogController(bundle) where T : Controller, T: DeleteLibraryMangasDialog.Listener { - - private var mangas = emptyList() - - constructor(target: T, mangas: List) : this() { - this.mangas = mangas - targetController = target - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val view = DialogCheckboxView(activity!!).apply { - setDescription(R.string.confirm_delete_manga) - setOptionDescription(R.string.also_delete_chapters) - } - - return MaterialDialog.Builder(activity!!) - .title(R.string.action_remove) - .customView(view, true) - .positiveText(android.R.string.yes) - .negativeText(android.R.string.no) - .onPositive { _, _ -> - val deleteChapters = view.isChecked() - (targetController as? Listener)?.deleteMangasFromLibrary(mangas, deleteChapters) - } - .build() - } - - interface Listener { - fun deleteMangasFromLibrary(mangas: List, deleteChapters: Boolean) - } -} \ No newline at end of file +package eu.kanade.tachiyomi.ui.library + +import android.app.Dialog +import android.os.Bundle +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.customview.customView +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import eu.kanade.tachiyomi.widget.DialogCheckboxView + +class DeleteLibraryMangasDialog(bundle: Bundle? = null) : + DialogController(bundle) where T : Controller, T : DeleteLibraryMangasDialog.Listener { + + private var mangas = emptyList() + + constructor(target: T, mangas: List) : this() { + this.mangas = mangas + targetController = target + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val view = DialogCheckboxView(activity!!).apply { + setDescription(R.string.confirm_delete_manga) + setOptionDescription(R.string.also_delete_chapters) + } + + return MaterialDialog(activity!!) + .title(R.string.action_remove) + .customView( + view = view, + horizontalPadding = true + ) + .positiveButton(android.R.string.ok) { + val deleteChapters = view.isChecked() + (targetController as? Listener)?.deleteMangasFromLibrary(mangas, deleteChapters) + } + .negativeButton(android.R.string.cancel) + } + + interface Listener { + fun deleteMangasFromLibrary(mangas: List, deleteChapters: Boolean) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryAdapter.kt index 1557a0edd..27bb23182 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryAdapter.kt @@ -1,103 +1,102 @@ -package eu.kanade.tachiyomi.ui.library - -import android.view.View -import android.view.ViewGroup -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Category -import eu.kanade.tachiyomi.util.inflate -import eu.kanade.tachiyomi.widget.RecyclerViewPagerAdapter - -/** - * This adapter stores the categories from the library, used with a ViewPager. - * - * @constructor creates an instance of the adapter. - */ -class LibraryAdapter(private val controller: LibraryController) : RecyclerViewPagerAdapter() { - - /** - * The categories to bind in the adapter. - */ - var categories: List = emptyList() - // This setter helps to not refresh the adapter if the reference to the list doesn't change. - set(value) { - if (field !== value) { - field = value - notifyDataSetChanged() - } - } - - private var boundViews = arrayListOf() - - /** - * Creates a new view for this adapter. - * - * @return a new view. - */ - override fun createView(container: ViewGroup): View { - val view = container.inflate(R.layout.library_category) as LibraryCategoryView - view.onCreate(controller) - return view - } - - /** - * Binds a view with a position. - * - * @param view the view to bind. - * @param position the position in the adapter. - */ - override fun bindView(view: View, position: Int) { - (view as LibraryCategoryView).onBind(categories[position]) - boundViews.add(view) - } - - /** - * Recycles a view. - * - * @param view the view to recycle. - * @param position the position in the adapter. - */ - override fun recycleView(view: View, position: Int) { - (view as LibraryCategoryView).onRecycle() - boundViews.remove(view) - } - - /** - * Returns the number of categories. - * - * @return the number of categories or 0 if the list is null. - */ - override fun getCount(): Int { - return categories.size - } - - /** - * Returns the title to display for a category. - * - * @param position the position of the element. - * @return the title to display. - */ - override fun getPageTitle(position: Int): CharSequence { - return categories[position].name - } - - /** - * Returns the position of the view. - */ - override fun getItemPosition(obj: Any): Int { - val view = obj as? LibraryCategoryView ?: return POSITION_NONE - val index = categories.indexOfFirst { it.id == view.category.id } - return if (index == -1) POSITION_NONE else index - } - - /** - * Called when the view of this adapter is being destroyed. - */ - fun onDestroy() { - for (view in boundViews) { - if (view is LibraryCategoryView) { - view.unsubscribe() - } - } - } - -} \ No newline at end of file +package eu.kanade.tachiyomi.ui.library + +import android.view.View +import android.view.ViewGroup +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.util.view.inflate +import eu.kanade.tachiyomi.widget.RecyclerViewPagerAdapter + +/** + * This adapter stores the categories from the library, used with a ViewPager. + * + * @constructor creates an instance of the adapter. + */ +class LibraryAdapter(private val controller: LibraryController) : RecyclerViewPagerAdapter() { + + /** + * The categories to bind in the adapter. + */ + var categories: List = emptyList() + // This setter helps to not refresh the adapter if the reference to the list doesn't change. + set(value) { + if (field !== value) { + field = value + notifyDataSetChanged() + } + } + + private var boundViews = arrayListOf() + + /** + * Creates a new view for this adapter. + * + * @return a new view. + */ + override fun createView(container: ViewGroup): View { + val view = container.inflate(R.layout.library_category) as LibraryCategoryView + view.onCreate(controller) + return view + } + + /** + * Binds a view with a position. + * + * @param view the view to bind. + * @param position the position in the adapter. + */ + override fun bindView(view: View, position: Int) { + (view as LibraryCategoryView).onBind(categories[position]) + boundViews.add(view) + } + + /** + * Recycles a view. + * + * @param view the view to recycle. + * @param position the position in the adapter. + */ + override fun recycleView(view: View, position: Int) { + (view as LibraryCategoryView).onRecycle() + boundViews.remove(view) + } + + /** + * Returns the number of categories. + * + * @return the number of categories or 0 if the list is null. + */ + override fun getCount(): Int { + return categories.size + } + + /** + * Returns the title to display for a category. + * + * @param position the position of the element. + * @return the title to display. + */ + override fun getPageTitle(position: Int): CharSequence { + return categories[position].name + } + + /** + * Returns the position of the view. + */ + override fun getItemPosition(obj: Any): Int { + val view = obj as? LibraryCategoryView ?: return POSITION_NONE + val index = categories.indexOfFirst { it.id == view.category.id } + return if (index == -1) POSITION_NONE else index + } + + /** + * Called when the view of this adapter is being destroyed. + */ + fun onDestroy() { + for (view in boundViews) { + if (view is LibraryCategoryView) { + view.unsubscribe() + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryAdapter.kt index e982c3c87..353d50dc1 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryAdapter.kt @@ -22,8 +22,8 @@ import uy.kohesive.injekt.injectLazy * * @param view the fragment containing this adapter. */ -class LibraryCategoryAdapter(val view: LibraryCategoryView) : - FlexibleAdapter(null, view, true) { +class LibraryCategoryAdapter(view: LibraryCategoryView) : + FlexibleAdapter(null, view, true) { // EXH --> private val db: DatabaseHelper by injectLazy() private val searchEngine = SearchEngine() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryView.kt index 07475ab96..f07e09027 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryView.kt @@ -4,6 +4,8 @@ import android.content.Context import android.util.AttributeSet import android.view.View import android.widget.FrameLayout +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.SelectableAdapter import eu.kanade.tachiyomi.R @@ -11,36 +13,38 @@ import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.util.inflate -import eu.kanade.tachiyomi.util.plusAssign -import eu.kanade.tachiyomi.util.toast +import eu.kanade.tachiyomi.util.lang.plusAssign +import eu.kanade.tachiyomi.util.system.toast +import eu.kanade.tachiyomi.util.view.inflate import eu.kanade.tachiyomi.widget.AutofitRecyclerView -import exh.ui.LoadingHandle -import kotlinx.android.synthetic.main.library_category.view.* -import kotlinx.coroutines.* -import rx.android.schedulers.AndroidSchedulers +import kotlinx.android.synthetic.main.library_category.view.fast_scroller +import kotlinx.android.synthetic.main.library_category.view.swipe_refresh +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.recyclerview.scrollStateChanges +import reactivecircus.flowbinding.swiperefreshlayout.refreshes import rx.subscriptions.CompositeSubscription import uy.kohesive.injekt.injectLazy -import java.util.concurrent.TimeUnit /** * Fragment containing the library manga for a certain category. */ class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : - FrameLayout(context, attrs), - FlexibleAdapter.OnItemClickListener, - FlexibleAdapter.OnItemLongClickListener { + FrameLayout(context, attrs), + FlexibleAdapter.OnItemClickListener, + FlexibleAdapter.OnItemLongClickListener { + + private val scope = CoroutineScope(Job() + Dispatchers.Main) - /** - * Preferences. - */ private val preferences: PreferencesHelper by injectLazy() /** * The fragment containing this view. */ - lateinit var controller: LibraryController + private lateinit var controller: LibraryController /** * Category for this view. @@ -51,7 +55,7 @@ class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: Att /** * Recycler view of the list of manga. */ - private lateinit var recycler: androidx.recyclerview.widget.RecyclerView + private lateinit var recycler: RecyclerView /** * Adapter to hold the manga in this category. @@ -77,9 +81,9 @@ class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: Att fun onCreate(controller: LibraryController) { this.controller = controller - recycler = if (preferences.libraryAsList().getOrDefault()) { - (swipe_refresh.inflate(R.layout.library_list_recycler) as androidx.recyclerview.widget.RecyclerView).apply { - layoutManager = androidx.recyclerview.widget.LinearLayoutManager(context) + recycler = if (preferences.libraryAsList().get()) { + (swipe_refresh.inflate(R.layout.library_list_recycler) as RecyclerView).apply { + layoutManager = LinearLayoutManager(context) } } else { (swipe_refresh.inflate(R.layout.library_grid_recycler) as AutofitRecyclerView).apply { @@ -92,26 +96,29 @@ class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: Att recycler.setHasFixedSize(true) recycler.adapter = adapter swipe_refresh.addView(recycler) + adapter.fastScroller = fast_scroller - recycler.addOnScrollListener(object : androidx.recyclerview.widget.RecyclerView.OnScrollListener() { - override fun onScrollStateChanged(recycler: androidx.recyclerview.widget.RecyclerView, newState: Int) { + recycler.scrollStateChanges() + .onEach { // Disable swipe refresh when view is not at the top - val firstPos = (recycler.layoutManager as androidx.recyclerview.widget.LinearLayoutManager) - .findFirstCompletelyVisibleItemPosition() + val firstPos = (recycler.layoutManager as LinearLayoutManager) + .findFirstCompletelyVisibleItemPosition() swipe_refresh.isEnabled = firstPos <= 0 } - }) + .launchIn(scope) // Double the distance required to trigger sync swipe_refresh.setDistanceToTriggerSync((2 * 64 * resources.displayMetrics.density).toInt()) - swipe_refresh.setOnRefreshListener { - if (!LibraryUpdateService.isRunning(context)) { - LibraryUpdateService.start(context, category) - context.toast(R.string.updating_category) + swipe_refresh.refreshes() + .onEach { + if (LibraryUpdateService.start(context, category)) { + context.toast(R.string.updating_category) + } + + // It can be a very long operation, so we disable swipe refresh and show a toast. + swipe_refresh.isRefreshing = false } - // It can be a very long operation, so we disable swipe refresh and show a toast. - swipe_refresh.isRefreshing = false - } + .launchIn(scope) } fun onBind(category: Category) { @@ -164,17 +171,25 @@ class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: Att } subscriptions += controller.selectionRelay - .subscribe { onSelectionChanged(it) } + .subscribe { onSelectionChanged(it) } subscriptions += controller.selectAllRelay - .subscribe { - if (it == category.id) { - adapter.currentItems.forEach { item -> - controller.setSelection(item.manga, true) - } - controller.invalidateActionMode() - } + .filter { it == category.id } + .subscribe { + adapter.currentItems.forEach { item -> + controller.setSelection(item.manga, true) } + controller.invalidateActionMode() + } + + subscriptions += controller.selectInverseRelay + .filter { it == category.id } + .subscribe { + adapter.currentItems.forEach { item -> + controller.toggleSelection(item.manga) + } + controller.invalidateActionMode() + } } fun onRecycle() { @@ -265,16 +280,16 @@ class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: Att * @param position the position of the element clicked. * @return true if the item should be selected, false otherwise. */ - override fun onItemClick(view: View, position: Int): Boolean { + override fun onItemClick(view: View?, position: Int): Boolean { // If the action mode is created and the position is valid, toggle the selection. val item = adapter.getItem(position) ?: return false - if (adapter.mode == SelectableAdapter.Mode.MULTI) { + return if (adapter.mode == SelectableAdapter.Mode.MULTI) { lastClickPosition = position toggleSelection(position) - return true + true } else { openManga(item.manga) - return false + false } } @@ -287,14 +302,15 @@ class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: Att controller.createActionModeIfNeeded() when { lastClickPosition == -1 -> setSelection(position) - lastClickPosition > position -> for (i in position until lastClickPosition) - setSelection(i) - lastClickPosition < position -> for (i in lastClickPosition + 1..position) - setSelection(i) + lastClickPosition > position -> + for (i in position until lastClickPosition) + setSelection(i) + lastClickPosition < position -> + for (i in lastClickPosition + 1..position) + setSelection(i) else -> setSelection(position) } lastClickPosition = position - } /** @@ -317,6 +333,7 @@ class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: Att controller.setSelection(item.manga, !adapter.isSelected(position)) controller.invalidateActionMode() } + /** * Tells the presenter to set the selection for the given position. * @@ -328,5 +345,4 @@ class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: Att controller.setSelection(item.manga, true) controller.invalidateActionMode() } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt index 97767fd95..a9966aac1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt @@ -3,21 +3,22 @@ package eu.kanade.tachiyomi.ui.library import android.app.Activity import android.content.Intent import android.content.res.Configuration -import android.graphics.Color +import android.net.Uri import android.os.Bundle -import android.view.* +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.SearchView import androidx.core.graphics.drawable.DrawableCompat -import androidx.drawerlayout.widget.DrawerLayout -import com.afollestad.materialdialogs.MaterialDialog import com.bluelinelabs.conductor.ControllerChangeHandler import com.bluelinelabs.conductor.ControllerChangeType import com.f2prateek.rx.preferences.Preference import com.google.android.material.tabs.TabLayout -import com.jakewharton.rxbinding.support.v4.view.pageSelections -import com.jakewharton.rxbinding.support.v7.widget.queryTextChanges import com.jakewharton.rxrelay.BehaviorRelay import com.jakewharton.rxrelay.PublishRelay import eu.kanade.tachiyomi.R @@ -25,47 +26,43 @@ import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.databinding.LibraryControllerBinding import eu.kanade.tachiyomi.ui.base.controller.NucleusController -import eu.kanade.tachiyomi.ui.base.controller.SecondaryDrawerController +import eu.kanade.tachiyomi.ui.base.controller.RootController import eu.kanade.tachiyomi.ui.base.controller.TabbedController import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction -import eu.kanade.tachiyomi.ui.category.CategoryController import eu.kanade.tachiyomi.ui.main.MainActivity +import eu.kanade.tachiyomi.ui.main.offsetFabAppbarHeight import eu.kanade.tachiyomi.ui.manga.MangaController -import eu.kanade.tachiyomi.ui.migration.MigrationController -import eu.kanade.tachiyomi.util.inflate -import eu.kanade.tachiyomi.util.toast -import exh.favorites.FavoritesIntroDialog -import exh.favorites.FavoritesSyncStatus -import exh.ui.LoaderManager -import exh.ui.migration.manga.design.MigrationDesignController -import kotlinx.android.synthetic.main.library_controller.* -import kotlinx.android.synthetic.main.main_activity.* +import eu.kanade.tachiyomi.util.system.getResourceColor +import eu.kanade.tachiyomi.util.system.toast +import eu.kanade.tachiyomi.util.view.visible +import java.io.IOException +import kotlinx.android.synthetic.main.main_activity.tabs +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.appcompat.queryTextChanges +import reactivecircus.flowbinding.viewpager.pageSelections import rx.Subscription -import rx.android.schedulers.AndroidSchedulers import timber.log.Timber import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.io.IOException -import java.util.concurrent.TimeUnit - class LibraryController( - bundle: Bundle? = null, - private val preferences: PreferencesHelper = Injekt.get() -) : NucleusController(bundle), - TabbedController, - SecondaryDrawerController, - ActionMode.Callback, - ChangeMangaCategoriesDialog.Listener, - DeleteLibraryMangasDialog.Listener { + bundle: Bundle? = null, + private val preferences: PreferencesHelper = Injekt.get() +) : NucleusController(bundle), + RootController, + TabbedController, + ActionMode.Callback, + ChangeMangaCategoriesDialog.Listener, + DeleteLibraryMangasDialog.Listener { /** * Position of the active category. */ - var activeCategory: Int = preferences.lastUsedCategory().getOrDefault() - private set + private var activeCategory: Int = preferences.lastUsedCategory().get() /** * Action mode for selections. @@ -99,8 +96,16 @@ class LibraryController( */ val libraryMangaRelay: BehaviorRelay = BehaviorRelay.create() + /** + * Relay to notify the library's viewpager to select all manga + */ val selectAllRelay: PublishRelay = PublishRelay.create() + /** + * Relay to notify the library's viewpager to select the inverse + */ + val selectInverseRelay: PublishRelay = PublishRelay.create() + /** * Number of manga per row in grid mode. */ @@ -113,21 +118,14 @@ class LibraryController( private var adapter: LibraryAdapter? = null /** - * Navigation view containing filter/sort/display items. + * Sheet containing filter/sort/display items. */ - private var navView: LibraryNavigationView? = null - - /** - * Drawer listener to allow swipe only for closing the drawer. - */ - private var drawerListener: androidx.drawerlayout.widget.DrawerLayout.DrawerListener? = null + private var settingsSheet: LibrarySettingsSheet? = null private var tabsVisibilityRelay: BehaviorRelay = BehaviorRelay.create(false) private var tabsVisibilitySubscription: Subscription? = null - private var searchViewSubscription: Subscription? = null - // --> EH //Sync dialog private var favSyncDialog: MaterialDialog? = null @@ -152,77 +150,67 @@ class LibraryController( } override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.library_controller, container, false) + binding = LibraryControllerBinding.inflate(inflater) + return binding.root } override fun onViewCreated(view: View) { super.onViewCreated(view) adapter = LibraryAdapter(this) - library_pager.adapter = adapter - library_pager.pageSelections().skip(1).subscribeUntilDestroy { - preferences.lastUsedCategory().set(it) - activeCategory = it - } + binding.libraryPager.adapter = adapter + binding.libraryPager.pageSelections() + .onEach { + preferences.lastUsedCategory().set(it) + activeCategory = it + } + .launchIn(scope) getColumnsPreferenceForCurrentOrientation().asObservable() - .doOnNext { mangaPerRow = it } - .skip(1) - // Set again the adapter to recalculate the covers height - .subscribeUntilDestroy { reattachAdapter() } + .doOnNext { mangaPerRow = it } + .skip(1) + // Set again the adapter to recalculate the covers height + .subscribeUntilDestroy { reattachAdapter() } if (selectedMangas.isNotEmpty()) { createActionModeIfNeeded() } - // EXH --> - loaderManager.loadingChangeListener = { - library_progress.visibility = if(it) View.VISIBLE else View.GONE + settingsSheet = LibrarySettingsSheet(activity!!) { group -> + when (group) { + is LibrarySettingsSheet.Filter.FilterGroup -> onFilterChanged() + is LibrarySettingsSheet.Sort.SortGroup -> onSortChanged() + is LibrarySettingsSheet.Display.DisplayGroup -> reattachAdapter() + is LibrarySettingsSheet.Display.BadgeGroup -> onDownloadBadgeChanged() + } } - // EXH <-- + + if (preferences.downloadedOnly().get()) { + binding.downloadedOnly.visible() + } + + binding.actionToolbar.offsetFabAppbarHeight(activity!!) } override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { super.onChangeStarted(handler, type) if (type.isEnter) { - activity?.tabs?.setupWithViewPager(library_pager) + activity?.tabs?.setupWithViewPager(binding.libraryPager) presenter.subscribeLibrary() } } override fun onDestroyView(view: View) { + destroyActionModeIfNeeded() + binding.actionToolbar.destroy() adapter?.onDestroy() adapter = null - actionMode = null + settingsSheet = null tabsVisibilitySubscription?.unsubscribe() tabsVisibilitySubscription = null - // EXH --> - loaderManager.loadingChangeListener = null - // EXH <-- super.onDestroyView(view) } - override fun createSecondaryDrawer(drawer: androidx.drawerlayout.widget.DrawerLayout): ViewGroup { - val view = drawer.inflate(R.layout.library_drawer) as LibraryNavigationView - navView = view - drawer.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED, Gravity.END) - - navView?.onGroupClicked = { group -> - when (group) { - is LibraryNavigationView.FilterGroup -> onFilterChanged() - is LibraryNavigationView.SortGroup -> onSortChanged() - is LibraryNavigationView.DisplayGroup -> reattachAdapter() - is LibraryNavigationView.BadgeGroup -> onDownloadBadgeChanged() - } - } - - return view - } - - override fun cleanupSecondaryDrawer(drawer: androidx.drawerlayout.widget.DrawerLayout) { - navView = null - } - override fun configureTabs(tabs: TabLayout) { with(tabs) { tabGravity = TabLayout.GRAVITY_CENTER @@ -244,35 +232,40 @@ class LibraryController( tabsVisibilitySubscription = null } + fun showSettingsSheet() { + settingsSheet?.show() + } + fun onNextLibraryUpdate(categories: List, mangaMap: Map>) { val view = view ?: return val adapter = adapter ?: return // Show empty view if needed if (mangaMap.isNotEmpty()) { - empty_view.hide() + binding.emptyView.hide() } else { - empty_view.show(R.drawable.ic_book_black_128dp, R.string.information_empty_library) + binding.emptyView.show(R.string.information_empty_library) } // Get the current active category. - val activeCat = if (adapter.categories.isNotEmpty()) - library_pager.currentItem - else + val activeCat = if (adapter.categories.isNotEmpty()) { + binding.libraryPager.currentItem + } else { activeCategory + } // Set the categories adapter.categories = categories // Restore active category. - library_pager.setCurrentItem(activeCat, false) + binding.libraryPager.setCurrentItem(activeCat, false) tabsVisibilityRelay.call(categories.size > 1) // Delay the scroll position to allow the view to be properly measured. view.post { if (isAttached) { - activity?.tabs?.setScrollPosition(library_pager.currentItem, 0f, true) + activity?.tabs?.setScrollPosition(binding.libraryPager.currentItem, 0f, true) } } @@ -286,10 +279,11 @@ class LibraryController( * @return the preference. */ private fun getColumnsPreferenceForCurrentOrientation(): Preference { - return if (resources?.configuration?.orientation == Configuration.ORIENTATION_PORTRAIT) + return if (resources?.configuration?.orientation == Configuration.ORIENTATION_PORTRAIT) { preferences.portraitColumns() - else + } else { preferences.landscapeColumns() + } } /** @@ -317,11 +311,11 @@ class LibraryController( private fun reattachAdapter() { val adapter = adapter ?: return - val position = library_pager.currentItem + val position = binding.libraryPager.currentItem adapter.recycle = false - library_pager.adapter = adapter - library_pager.currentItem = position + binding.libraryPager.adapter = adapter + binding.libraryPager.currentItem = position adapter.recycle = true } @@ -331,13 +325,18 @@ class LibraryController( fun createActionModeIfNeeded() { if (actionMode == null) { actionMode = (activity as AppCompatActivity).startSupportActionMode(this) + binding.actionToolbar.show( + actionMode!!, + R.menu.library_selection + ) { onActionItemClicked(actionMode!!, it!!) } + (activity as? MainActivity)?.showBottomNav(visible = false, collapse = true) } } /** * Destroys the action mode. */ - fun destroyActionModeIfNeeded() { + private fun destroyActionModeIfNeeded() { actionMode?.finish() } @@ -346,48 +345,58 @@ class LibraryController( val searchItem = menu.findItem(R.id.action_search) val searchView = searchItem.actionView as SearchView + searchView.maxWidth = Int.MAX_VALUE - if (!query.isEmpty()) { + searchView.queryTextChanges() + // Ignore events if this controller isn't at the top + .filter { router.backstack.lastOrNull()?.controller() == this } + .onEach { + query = it.toString() + searchRelay.call(query) + } + .launchIn(scope) + + if (query.isNotEmpty()) { searchItem.expandActionView() searchView.setQuery(query, true) searchView.clearFocus() + + // Manually trigger the search since the binding doesn't trigger for some reason + searchRelay.call(query) } + searchItem.fixExpand(onExpand = { invalidateMenuOnExpand() }) + // Mutate the filter icon because it needs to be tinted and the resource is shared. menu.findItem(R.id.action_filter).icon.mutate() + } - searchViewSubscription?.unsubscribe() - searchViewSubscription = searchView.queryTextChanges() - // Ignore events if this controller isn't at the top - .filter { router.backstack.lastOrNull()?.controller() == this } - .subscribeUntilDestroy { - query = it.toString() - searchRelay.call(query) - } - - searchItem.fixExpand() + fun search(query: String) { + this.query = query } override fun onPrepareOptionsMenu(menu: Menu) { - val navView = navView ?: return + val settingsSheet = settingsSheet ?: return val filterItem = menu.findItem(R.id.action_filter) // Tint icon if there's a filter active - val filterColor = if (navView.hasActiveFilters()) Color.rgb(255, 238, 7) else Color.WHITE - DrawableCompat.setTint(filterItem.icon, filterColor) + if (settingsSheet.filters.hasActiveFilters()) { + val filterColor = activity!!.getResourceColor(R.attr.colorFilterActive) + DrawableCompat.setTint(filterItem.icon, filterColor) + } } override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { - R.id.action_filter -> { - navView?.let { activity?.drawer?.openDrawer(Gravity.END) } - } + R.id.action_search -> expandActionViewFromInteraction = true + R.id.action_filter -> showSettingsSheet() R.id.action_update_library -> { - activity?.let { LibraryUpdateService.start(it) } - } - R.id.action_edit_categories -> { - router.pushController(CategoryController().withFadeTransaction()) + activity?.let { + if (LibraryUpdateService.start(it)) { + it.toast(R.string.updating_library) + } + } } R.id.action_source_migration -> { router.pushController(MigrationController().withFadeTransaction()) @@ -400,10 +409,9 @@ class LibraryController( presenter.favoritesSync.runSync() } // <-- EXH - else -> return super.onOptionsItemSelected(item) } - return true + return super.onOptionsItemSelected(item) } /** @@ -414,7 +422,7 @@ class LibraryController( } override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.library_selection, menu) + mode.menuInflater.inflate(R.menu.generic_selection, menu) return true } @@ -424,8 +432,9 @@ class LibraryController( // Destroy action mode if there are no items selected. destroyActionModeIfNeeded() } else { - mode.title = resources?.getString(R.string.label_selected, count) - menu.findItem(R.id.action_edit_cover)?.isVisible = count == 1 + mode.title = count.toString() + + binding.actionToolbar.findItem(R.id.action_edit_cover)?.isVisible = count == 1 } return false } @@ -438,11 +447,8 @@ class LibraryController( } R.id.action_move_to_category -> showChangeMangaCategoriesDialog() R.id.action_delete -> showDeleteMangaDialog() - R.id.action_select_all -> { - adapter?.categories?.getOrNull(library_pager.currentItem)?.id?.let { - selectAllRelay.call(it) - } - } + R.id.action_select_all -> selectAllCategoryManga() + R.id.action_select_inverse -> selectInverseCategoryManga() R.id.action_auto_source_migration -> { router.pushController(MigrationDesignController.create( selectedMangas.mapNotNull { it.id } @@ -458,6 +464,10 @@ class LibraryController( // Clear all the manga selections and notify child views. selectedMangas.clear() selectionRelay.call(LibrarySelectionEvent.Cleared()) + + binding.actionToolbar.hide() + (activity as? MainActivity)?.showBottomNav(visible = true, collapse = true) + actionMode = null } @@ -486,6 +496,19 @@ class LibraryController( } } + /** + * Toggles the current selection state for a given manga. + * + * @param manga the manga whose selection to change. + */ + fun toggleSelection(manga: Manga) { + if (selectedMangas.add(manga)) { + selectionRelay.call(LibrarySelectionEvent.Selected(manga)) + } else if (selectedMangas.remove(manga)) { + selectionRelay.call(LibrarySelectionEvent.Unselected(manga)) + } + } + /** * Move the selected manga to a list of categories. */ @@ -498,11 +521,11 @@ class LibraryController( // Get indexes of the common categories to preselect. val commonCategoriesIndexes = presenter.getCommonCategories(mangas) - .map { categories.indexOf(it) } - .toTypedArray() + .map { categories.indexOf(it) } + .toTypedArray() ChangeMangaCategoriesDialog(this, mangas, categories, commonCategoriesIndexes) - .showDialog(router) + .showDialog(router) } private fun showDeleteMangaDialog() { @@ -529,8 +552,13 @@ class LibraryController( if (manga.favorite) { val intent = Intent(Intent.ACTION_GET_CONTENT) intent.type = "image/*" - startActivityForResult(Intent.createChooser(intent, - resources?.getString(R.string.file_select_cover)), REQUEST_IMAGE_OPEN) + startActivityForResult( + Intent.createChooser( + intent, + resources?.getString(R.string.file_select_cover) + ), + REQUEST_IMAGE_OPEN + ) } else { activity?.toast(R.string.notification_first_add_to_library) } @@ -558,6 +586,18 @@ class LibraryController( cleanupSyncState() } + private fun selectAllCategoryManga() { + adapter?.categories?.getOrNull(binding.libraryPager.currentItem)?.id?.let { + selectAllRelay.call(it) + } + } + + private fun selectInverseCategoryManga() { + adapter?.categories?.getOrNull(binding.libraryPager.currentItem)?.id?.let { + selectInverseRelay.call(it) + } + } + // --> EXH private fun cleanupSyncState() { favoritesSyncSubscription?.unsubscribe() @@ -670,9 +710,9 @@ class LibraryController( try { // Get the file's input stream from the incoming Intent - activity.contentResolver.openInputStream(data.data).use { + activity.contentResolver.openInputStream(data.data ?: Uri.EMPTY).use { // Update cover to selected file, show error if something went wrong - if (presenter.editCoverWithStream(it, manga)) { + if (it != null && presenter.editCoverWithStream(it, manga)) { // TODO refresh cover } else { activity.toast(R.string.notification_cover_update_failed) @@ -692,5 +732,4 @@ class LibraryController( */ const val REQUEST_IMAGE_OPEN = 101 } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryGridHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryGridHolder.kt index 2bc68cf3d..5c1bf711a 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryGridHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryGridHolder.kt @@ -1,57 +1,61 @@ -package eu.kanade.tachiyomi.ui.library - -import android.view.View -import com.bumptech.glide.load.engine.DiskCacheStrategy -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.kanade.tachiyomi.data.glide.GlideApp -import eu.kanade.tachiyomi.source.LocalSource -import kotlinx.android.synthetic.main.catalogue_grid_item.* - -/** - * Class used to hold the displayed data of a manga in the library, like the cover or the title. - * All the elements from the layout file "item_catalogue_grid" are available in this class. - * - * @param view the inflated view for this holder. - * @param adapter the adapter handling this holder. - * @param listener a listener to react to single tap and long tap events. - * @constructor creates a new library holder. - */ -class LibraryGridHolder( - private val view: View, - private val adapter: FlexibleAdapter<*> - -) : LibraryHolder(view, adapter) { - - /** - * Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this - * holder with the given manga. - * - * @param item the manga item to bind. - */ - override fun onSetValues(item: LibraryItem) { - // Update the title of the manga. - title.text = item.manga.title - - // Update the unread count and its visibility. - with(unread_text) { - visibility = if (item.manga.unread > 0) View.VISIBLE else View.GONE - text = item.manga.unread.toString() - } - // Update the download count and its visibility. - with(download_text) { - visibility = if (item.downloadCount > 0) View.VISIBLE else View.GONE - text = item.downloadCount.toString() - } - //set local visibility if its local manga - local_text.visibility = if(item.manga.source == LocalSource.ID) View.VISIBLE else View.GONE - - // Update the cover. - GlideApp.with(view.context).clear(thumbnail) - GlideApp.with(view.context) - .load(item.manga) - .diskCacheStrategy(DiskCacheStrategy.RESOURCE) - .centerCrop() - .into(thumbnail) - } - -} +package eu.kanade.tachiyomi.ui.library + +import android.view.View +import com.bumptech.glide.load.engine.DiskCacheStrategy +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.data.glide.toMangaThumbnail +import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.util.view.visibleIf +import kotlinx.android.synthetic.main.source_grid_item.download_text +import kotlinx.android.synthetic.main.source_grid_item.local_text +import kotlinx.android.synthetic.main.source_grid_item.thumbnail +import kotlinx.android.synthetic.main.source_grid_item.title +import kotlinx.android.synthetic.main.source_grid_item.unread_text + +/** + * Class used to hold the displayed data of a manga in the library, like the cover or the title. + * All the elements from the layout file "item_source_grid" are available in this class. + * + * @param view the inflated view for this holder. + * @param adapter the adapter handling this holder. + * @param listener a listener to react to single tap and long tap events. + * @constructor creates a new library holder. + */ +class LibraryGridHolder( + private val view: View, + private val adapter: FlexibleAdapter<*> +) : LibraryHolder(view, adapter) { + + /** + * Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this + * holder with the given manga. + * + * @param item the manga item to bind. + */ + override fun onSetValues(item: LibraryItem) { + // Update the title of the manga. + title.text = item.manga.title + + // Update the unread count and its visibility. + with(unread_text) { + visibleIf { item.manga.unread > 0 } + text = item.manga.unread.toString() + } + // Update the download count and its visibility. + with(download_text) { + visibleIf { item.downloadCount > 0 } + text = item.downloadCount.toString() + } + // set local visibility if its local manga + local_text.visibleIf { item.manga.source == LocalSource.ID } + + // Update the cover. + GlideApp.with(view.context).clear(thumbnail) + GlideApp.with(view.context) + .load(item.manga.toMangaThumbnail()) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .centerCrop() + .into(thumbnail) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHolder.kt index 41d7f9879..b5715de23 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHolder.kt @@ -1,27 +1,26 @@ -package eu.kanade.tachiyomi.ui.library - -import android.view.View -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder - -/** - * Generic class used to hold the displayed data of a manga in the library. - * @param view the inflated view for this holder. - * @param adapter the adapter handling this holder. - * @param listener a listener to react to the single tap and long tap events. - */ - -abstract class LibraryHolder( - view: View, - adapter: FlexibleAdapter<*> -) : BaseFlexibleViewHolder(view, adapter) { - - /** - * Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this - * holder with the given manga. - * - * @param item the manga item to bind. - */ - abstract fun onSetValues(item: LibraryItem) - -} +package eu.kanade.tachiyomi.ui.library + +import android.view.View +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder + +/** + * Generic class used to hold the displayed data of a manga in the library. + * @param view the inflated view for this holder. + * @param adapter the adapter handling this holder. + * @param listener a listener to react to the single tap and long tap events. + */ + +abstract class LibraryHolder( + view: View, + adapter: FlexibleAdapter<*> +) : BaseFlexibleViewHolder(view, adapter) { + + /** + * Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this + * holder with the given manga. + * + * @param item the manga item to bind. + */ + abstract fun onSetValues(item: LibraryItem) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt index 6eb34e2f9..1d13c50a6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt @@ -4,36 +4,45 @@ import android.view.Gravity import android.view.View import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout -import com.f2prateek.rx.preferences.Preference +import androidx.recyclerview.widget.RecyclerView +import com.tfcporciuncula.flow.Preference import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractFlexibleItem import eu.davidea.flexibleadapter.items.IFilterable import eu.davidea.flexibleadapter.items.IFlexible import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.LibraryManga -import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.widget.AutofitRecyclerView -import kotlinx.android.synthetic.main.catalogue_grid_item.view.* +import kotlinx.android.synthetic.main.source_grid_item.view.card +import kotlinx.android.synthetic.main.source_grid_item.view.gradient +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get class LibraryItem(val manga: LibraryManga, private val libraryAsList: Preference) : - AbstractFlexibleItem(), IFilterable { + AbstractFlexibleItem(), IFilterable { + + private val sourceManager: SourceManager = Injekt.get() + var downloadCount = -1 override fun getLayoutRes(): Int { - return if (libraryAsList.getOrDefault()) - R.layout.catalogue_list_item - else - R.layout.catalogue_grid_item + return if (libraryAsList.get()) { + R.layout.source_list_item + } else { + R.layout.source_grid_item + } } - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): LibraryHolder { + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): LibraryHolder { val parent = adapter.recyclerView return if (parent is AutofitRecyclerView) { view.apply { val coverHeight = parent.itemWidth / 3 * 4 card.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, coverHeight) gradient.layoutParams = FrameLayout.LayoutParams( - MATCH_PARENT, coverHeight / 2, Gravity.BOTTOM) + MATCH_PARENT, coverHeight / 2, Gravity.BOTTOM + ) } LibraryGridHolder(view, adapter) } else { @@ -41,11 +50,12 @@ class LibraryItem(val manga: LibraryManga, private val libraryAsList: Preference } } - override fun bindViewHolder(adapter: FlexibleAdapter>, - holder: LibraryHolder, - position: Int, - payloads: List?) { - + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: LibraryHolder, + position: Int, + payloads: List? + ) { holder.onSetValues(this) } @@ -58,6 +68,8 @@ class LibraryItem(val manga: LibraryManga, private val libraryAsList: Preference override fun filter(constraint: String): Boolean { return manga.title.contains(constraint, true) || (manga.author?.contains(constraint, true) ?: false) || + (manga.artist?.contains(constraint, true) ?: false) || + sourceManager.getOrStub(manga.source).name.contains(constraint, true) || if (constraint.contains(" ") || constraint.contains("\"")) { val genres = manga.genre?.split(", ")?.map { it.drop(it.indexOfFirst{it==':'}+1).toLowerCase().trim() //tachiEH tag namespaces @@ -85,14 +97,15 @@ class LibraryItem(val manga: LibraryManga, private val libraryAsList: Preference } private fun containsGenre(tag: String, genres: List?): Boolean { - return if (tag.startsWith("-")) + return if (tag.startsWith("-")) { genres?.find { it.trim().toLowerCase() == tag.substringAfter("-").toLowerCase() } == null - else + } else { genres?.find { it.trim().toLowerCase() == tag.toLowerCase() } != null + } } override fun equals(other: Any?): Boolean { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryListHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryListHolder.kt index 83cc69e25..9fcb3a346 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryListHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryListHolder.kt @@ -1,65 +1,70 @@ -package eu.kanade.tachiyomi.ui.library - -import android.view.View -import com.bumptech.glide.load.engine.DiskCacheStrategy -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.kanade.tachiyomi.data.glide.GlideApp -import eu.kanade.tachiyomi.source.LocalSource -import kotlinx.android.synthetic.main.catalogue_list_item.* - -/** - * Class used to hold the displayed data of a manga in the library, like the cover or the title. - * All the elements from the layout file "item_library_list" are available in this class. - * - * @param view the inflated view for this holder. - * @param adapter the adapter handling this holder. - * @param listener a listener to react to single tap and long tap events. - * @constructor creates a new library holder. - */ - -class LibraryListHolder( - private val view: View, - private val adapter: FlexibleAdapter<*> -) : LibraryHolder(view, adapter) { - - /** - * Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this - * holder with the given manga. - * - * @param item the manga item to bind. - */ - override fun onSetValues(item: LibraryItem) { - // Update the title of the manga. - title.text = item.manga.title - - // Update the unread count and its visibility. - with(unread_text) { - visibility = if (item.manga.unread > 0) View.VISIBLE else View.GONE - text = item.manga.unread.toString() - } - // Update the download count and its visibility. - with(download_text) { - visibility = if (item.downloadCount > 0) View.VISIBLE else View.GONE - text = "${item.downloadCount}" - } - //show local text badge if local manga - local_text.visibility = if (item.manga.source == LocalSource.ID) View.VISIBLE else View.GONE - - // Create thumbnail onclick to simulate long click - thumbnail.setOnClickListener { - // Simulate long click on this view to enter selection mode - onLongClick(itemView) - } - - // Update the cover. - GlideApp.with(itemView.context).clear(thumbnail) - GlideApp.with(itemView.context) - .load(item.manga) - .diskCacheStrategy(DiskCacheStrategy.RESOURCE) - .centerCrop() - .circleCrop() - .dontAnimate() - .into(thumbnail) - } - -} +package eu.kanade.tachiyomi.ui.library + +import android.view.View +import com.bumptech.glide.load.engine.DiskCacheStrategy +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.data.glide.toMangaThumbnail +import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.util.view.visibleIf +import kotlinx.android.synthetic.main.source_list_item.download_text +import kotlinx.android.synthetic.main.source_list_item.local_text +import kotlinx.android.synthetic.main.source_list_item.thumbnail +import kotlinx.android.synthetic.main.source_list_item.title +import kotlinx.android.synthetic.main.source_list_item.unread_text + +/** + * Class used to hold the displayed data of a manga in the library, like the cover or the title. + * All the elements from the layout file "item_library_list" are available in this class. + * + * @param view the inflated view for this holder. + * @param adapter the adapter handling this holder. + * @param listener a listener to react to single tap and long tap events. + * @constructor creates a new library holder. + */ + +class LibraryListHolder( + private val view: View, + private val adapter: FlexibleAdapter<*> +) : LibraryHolder(view, adapter) { + + /** + * Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this + * holder with the given manga. + * + * @param item the manga item to bind. + */ + override fun onSetValues(item: LibraryItem) { + // Update the title of the manga. + title.text = item.manga.title + + // Update the unread count and its visibility. + with(unread_text) { + visibleIf { item.manga.unread > 0 } + text = item.manga.unread.toString() + } + // Update the download count and its visibility. + with(download_text) { + visibleIf { item.downloadCount > 0 } + text = "${item.downloadCount}" + } + // show local text badge if local manga + local_text.visibleIf { item.manga.source == LocalSource.ID } + + // Create thumbnail onclick to simulate long click + thumbnail.setOnClickListener { + // Simulate long click on this view to enter selection mode + onLongClick(itemView) + } + + // Update the cover. + GlideApp.with(itemView.context).clear(thumbnail) + GlideApp.with(itemView.context) + .load(item.manga.toMangaThumbnail()) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .centerCrop() + .circleCrop() + .dontAnimate() + .into(thumbnail) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryNavigationView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryNavigationView.kt deleted file mode 100755 index fac912278..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryNavigationView.kt +++ /dev/null @@ -1,230 +0,0 @@ -package eu.kanade.tachiyomi.ui.library - -import android.content.Context -import android.util.AttributeSet -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.ui.catalogue.filter.TriStateItem -import eu.kanade.tachiyomi.widget.ExtendedNavigationView -import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_ASC -import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_DESC -import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_NONE -import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.Companion.STATE_IGNORE -import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.Companion.STATE_INCLUDE -import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.Companion.STATE_EXCLUDE -import uy.kohesive.injekt.injectLazy - -/** - * The navigation view shown in a drawer with the different options to show the library. - */ -class LibraryNavigationView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) - : ExtendedNavigationView(context, attrs) { - - /** - * Preferences helper. - */ - private val preferences: PreferencesHelper by injectLazy() - - /** - * List of groups shown in the view. - */ - private val groups = listOf(FilterGroup(), SortGroup(), DisplayGroup(), BadgeGroup()) - - /** - * Adapter instance. - */ - private val adapter = Adapter(groups.map { it.createItems() }.flatten()) - - /** - * Click listener to notify the parent fragment when an item from a group is clicked. - */ - var onGroupClicked: (Group) -> Unit = {} - - init { - recycler.adapter = adapter - addView(recycler) - - groups.forEach { it.initModels() } - } - - /** - * Returns true if there's at least one filter from [FilterGroup] active. - */ - fun hasActiveFilters(): Boolean { - return (groups[0] as FilterGroup).items.any { it.state != STATE_IGNORE } //j2k it.checked -> this - } - - /** - * Adapter of the recycler view. - */ - inner class Adapter(items: List) : ExtendedNavigationView.Adapter(items) { - - override fun onItemClicked(item: Item) { - if (item is GroupedItem) { - item.group.onItemClicked(item) - onGroupClicked(item.group) - } - } - } - - /** - * Filters group (unread, downloaded, ...). - */ - inner class FilterGroup : Group { - - private val downloaded = Item.TriStateGroup(R.string.action_filter_downloaded, this) - - private val unread = Item.TriStateGroup(R.string.action_filter_unread, this) - - private val completed = Item.TriStateGroup(R.string.completed, this) - - override val items = listOf(downloaded, unread, completed) - - override val header = Item.Header(R.string.action_filter) - - override val footer = Item.Separator() - - override fun initModels() { //j2k changes - try { - downloaded.state = preferences.filterDownloaded().getOrDefault() - unread.state = preferences.filterUnread().getOrDefault() - completed.state = preferences.filterCompleted().getOrDefault() - } catch (e: Exception) { - preferences.upgradeFilters() - } - } - - override fun onItemClicked(item: Item) { //j2k changes - item as Item.TriStateGroup - val newState = when (item.state) { - STATE_IGNORE -> STATE_INCLUDE - STATE_INCLUDE -> STATE_EXCLUDE - else -> STATE_IGNORE - } - item.state = newState - when (item) { - downloaded -> preferences.filterDownloaded().set(item.state) - unread -> preferences.filterUnread().set(item.state) - completed -> preferences.filterCompleted().set(item.state) - } - - adapter.notifyItemChanged(item) - } - } - - /** - * Sorting group (alphabetically, by last read, ...) and ascending or descending. - */ - inner class SortGroup : Group { - - private val alphabetically = Item.MultiSort(R.string.action_sort_alpha, this) - - private val total = Item.MultiSort(R.string.action_sort_total, this) - - private val lastRead = Item.MultiSort(R.string.action_sort_last_read, this) - - private val lastUpdated = Item.MultiSort(R.string.action_sort_last_updated, this) - - private val unread = Item.MultiSort(R.string.action_filter_unread, this) - - private val source = Item.MultiSort(R.string.manga_info_source_label, this) - - override val items = listOf(alphabetically, lastRead, lastUpdated, unread, total, source) - - override val header = Item.Header(R.string.action_sort) - - override val footer = Item.Separator() - - override fun initModels() { - val sorting = preferences.librarySortingMode().getOrDefault() - val order = if (preferences.librarySortingAscending().getOrDefault()) - SORT_ASC else SORT_DESC - - alphabetically.state = if (sorting == LibrarySort.ALPHA) order else SORT_NONE - lastRead.state = if (sorting == LibrarySort.LAST_READ) order else SORT_NONE - lastUpdated.state = if (sorting == LibrarySort.LAST_UPDATED) order else SORT_NONE - unread.state = if (sorting == LibrarySort.UNREAD) order else SORT_NONE - total.state = if (sorting == LibrarySort.TOTAL) order else SORT_NONE - source.state = if (sorting == LibrarySort.SOURCE) order else SORT_NONE - } - - override fun onItemClicked(item: Item) { - item as Item.MultiStateGroup - val prevState = item.state - - item.group.items.forEach { (it as Item.MultiStateGroup).state = SORT_NONE } - item.state = when (prevState) { - SORT_NONE -> SORT_ASC - SORT_ASC -> SORT_DESC - SORT_DESC -> SORT_ASC - else -> throw Exception("Unknown state") - } - - preferences.librarySortingMode().set(when (item) { - alphabetically -> LibrarySort.ALPHA - lastRead -> LibrarySort.LAST_READ - lastUpdated -> LibrarySort.LAST_UPDATED - unread -> LibrarySort.UNREAD - total -> LibrarySort.TOTAL - source -> LibrarySort.SOURCE - else -> throw Exception("Unknown sorting") - }) - preferences.librarySortingAscending().set(if (item.state == SORT_ASC) true else false) - - item.group.items.forEach { adapter.notifyItemChanged(it) } - } - - } - - inner class BadgeGroup : Group { - private val downloadBadge = Item.CheckboxGroup(R.string.action_display_download_badge, this) - override val header = null - override val footer = null - override val items = listOf(downloadBadge) - override fun initModels() { - downloadBadge.checked = preferences.downloadBadge().getOrDefault() - } - - override fun onItemClicked(item: Item) { - item as Item.CheckboxGroup - item.checked = !item.checked - preferences.downloadBadge().set((item.checked)) - adapter.notifyItemChanged(item) - } - } - - /** - * Display group, to show the library as a list or a grid. - */ - inner class DisplayGroup : Group { - - private val grid = Item.Radio(R.string.action_display_grid, this) - - private val list = Item.Radio(R.string.action_display_list, this) - - override val items = listOf(grid, list) - - override val header = Item.Header(R.string.action_display) - - override val footer = null - - override fun initModels() { - val asList = preferences.libraryAsList().getOrDefault() - grid.checked = !asList - list.checked = asList - } - - override fun onItemClicked(item: Item) { - item as Item.Radio - if (item.checked) return - - item.group.items.forEach { (it as Item.Radio).checked = false } - item.checked = true - - preferences.libraryAsList().set(if (item == list) true else false) - - item.group.items.forEach { adapter.notifyItemChanged(it) } - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt index dcb706779..910d5683f 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt @@ -1,375 +1,381 @@ -package eu.kanade.tachiyomi.ui.library - -import android.os.Bundle -import com.jakewharton.rxrelay.BehaviorRelay -import eu.kanade.tachiyomi.data.cache.CoverCache -import eu.kanade.tachiyomi.data.database.DatabaseHelper -import eu.kanade.tachiyomi.data.database.models.Category -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.database.models.MangaCategory -import eu.kanade.tachiyomi.data.download.DownloadManager -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.source.LocalSource -import eu.kanade.tachiyomi.source.SourceManager -import eu.kanade.tachiyomi.source.model.SManga -import eu.kanade.tachiyomi.source.online.HttpSource -import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.Companion.STATE_EXCLUDE -import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.Companion.STATE_INCLUDE -import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.Companion.STATE_IGNORE -import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter -import eu.kanade.tachiyomi.util.combineLatest -import eu.kanade.tachiyomi.util.isNullOrUnsubscribed -import exh.favorites.FavoritesSyncHelper -import rx.Observable -import rx.Subscription -import rx.android.schedulers.AndroidSchedulers -import rx.schedulers.Schedulers -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import java.io.IOException -import java.io.InputStream -import java.util.ArrayList -import java.util.Collections -import java.util.Comparator - -/** - * Class containing library information. - */ -private data class Library(val categories: List, val mangaMap: LibraryMap) - -/** - * Typealias for the library manga, using the category as keys, and list of manga as values. - */ -private typealias LibraryMap = Map> - -/** - * Presenter of [LibraryController]. - */ -class LibraryPresenter( - private val db: DatabaseHelper = Injekt.get(), - private val preferences: PreferencesHelper = Injekt.get(), - private val coverCache: CoverCache = Injekt.get(), - private val sourceManager: SourceManager = Injekt.get(), - private val downloadManager: DownloadManager = Injekt.get() -) : BasePresenter() { - - private val context = preferences.context - - /** - * Categories of the library. - */ - var categories: List = emptyList() - private set - - /** - * Relay used to apply the UI filters to the last emission of the library. - */ - private val filterTriggerRelay = BehaviorRelay.create(Unit) - - /** - * Relay used to apply the UI update to the last emission of the library. - */ - private val downloadTriggerRelay = BehaviorRelay.create(Unit) - - /** - * Relay used to apply the selected sorting method to the last emission of the library. - */ - private val sortTriggerRelay = BehaviorRelay.create(Unit) - - /** - * Library subscription. - */ - private var librarySubscription: Subscription? = null - - // --> EXH - val favoritesSync = FavoritesSyncHelper(context) - // <-- EXH - - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) - subscribeLibrary() - } - - /** - * Subscribes to library if needed. - */ - fun subscribeLibrary() { - if (librarySubscription.isNullOrUnsubscribed()) { - librarySubscription = getLibraryObservable() - .combineLatest(downloadTriggerRelay.observeOn(Schedulers.io()), - { lib, _ -> lib.apply { setDownloadCount(mangaMap) } }) - .combineLatest(filterTriggerRelay.observeOn(Schedulers.io()), - { lib, _ -> lib.copy(mangaMap = applyFilters(lib.mangaMap)) }) - .combineLatest(sortTriggerRelay.observeOn(Schedulers.io()), - { lib, _ -> lib.copy(mangaMap = applySort(lib.mangaMap)) }) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeLatestCache({ view, (categories, mangaMap) -> - view.onNextLibraryUpdate(categories, mangaMap) - }) - } - } - - /** - * Applies library filters to the given map of manga. - * - * @param map the map to filter. - */ - private fun applyFilters(map: LibraryMap): LibraryMap { - val filterDownloaded = preferences.filterDownloaded().getOrDefault() - - val filterUnread = preferences.filterUnread().getOrDefault() - - val filterCompleted = preferences.filterCompleted().getOrDefault() - - val filterFn: (LibraryItem) -> Boolean = f@ { item -> - // Filter when there isn't unread chapters. - if (filterUnread == STATE_INCLUDE && item.manga.unread == 0) {return@f false} - if (filterUnread == STATE_EXCLUDE && item.manga.unread > 0) {return@f false} - if (filterCompleted == STATE_INCLUDE && item.manga.status != SManga.COMPLETED) { - return@f false - } - if (filterCompleted == STATE_EXCLUDE && item.manga.status == SManga.COMPLETED) { - return@f false - } - // Filter when there are no downloads. - if (filterDownloaded != STATE_IGNORE) { - val isDownloaded = when { - item.manga.source == LocalSource.ID -> true - item.downloadCount != -1 -> item.downloadCount > 0 - else -> downloadManager.getDownloadCount(item.manga) > 0 - } - return@f if (filterDownloaded == STATE_INCLUDE) isDownloaded else !isDownloaded - } - true - } - - return map.mapValues { entry -> entry.value.filter(filterFn) } - } - - /** - * Sets downloaded chapter count to each manga. - * - * @param map the map of manga. - */ - private fun setDownloadCount(map: LibraryMap) { - if (!preferences.downloadBadge().getOrDefault()) { - // Unset download count if the preference is not enabled. - for ((_, itemList) in map) { - for (item in itemList) { - item.downloadCount = -1 - } - } - return - } - - for ((_, itemList) in map) { - for (item in itemList) { - item.downloadCount = downloadManager.getDownloadCount(item.manga) - } - } - } - - /** - * Applies library sorting to the given map of manga. - * - * @param map the map to sort. - */ - private fun applySort(map: LibraryMap): LibraryMap { - val sortingMode = preferences.librarySortingMode().getOrDefault() - - val lastReadManga by lazy { - var counter = 0 - db.getLastReadManga().executeAsBlocking().associate { it.id!! to counter++ } - } - val totalChapterManga by lazy { - var counter = 0 - db.getTotalChapterManga().executeAsBlocking().associate { it.id!! to counter++ } - } - - val sortFn: (LibraryItem, LibraryItem) -> Int = { i1, i2 -> - when (sortingMode) { - LibrarySort.ALPHA -> i1.manga.title.compareTo(i2.manga.title, true) - LibrarySort.LAST_READ -> { - // Get index of manga, set equal to list if size unknown. - val manga1LastRead = lastReadManga[i1.manga.id!!] ?: lastReadManga.size - val manga2LastRead = lastReadManga[i2.manga.id!!] ?: lastReadManga.size - manga1LastRead.compareTo(manga2LastRead) - } - LibrarySort.LAST_UPDATED -> i2.manga.last_update.compareTo(i1.manga.last_update) - LibrarySort.UNREAD -> i1.manga.unread.compareTo(i2.manga.unread) - LibrarySort.TOTAL -> { - val manga1TotalChapter = totalChapterManga[i1.manga.id!!] ?: 0 - val mange2TotalChapter = totalChapterManga[i2.manga.id!!] ?: 0 - manga1TotalChapter.compareTo(mange2TotalChapter) - } - LibrarySort.SOURCE -> { - val source1Name = sourceManager.getOrStub(i1.manga.source).name - val source2Name = sourceManager.getOrStub(i2.manga.source).name - source1Name.compareTo(source2Name) - } - else -> throw Exception("Unknown sorting mode") - } - } - - val comparator = if (preferences.librarySortingAscending().getOrDefault()) - Comparator(sortFn) - else - Collections.reverseOrder(sortFn) - - return map.mapValues { entry -> entry.value.sortedWith(comparator) } - } - - /** - * Get the categories and all its manga from the database. - * - * @return an observable of the categories and its manga. - */ - private fun getLibraryObservable(): Observable { - return Observable.combineLatest(getCategoriesObservable(), getLibraryMangasObservable(), - { dbCategories, libraryManga -> - val categories = if (libraryManga.containsKey(0)) - arrayListOf(Category.createDefault()) + dbCategories - else - dbCategories - - this.categories = categories - Library(categories, libraryManga) - }) - } - - /** - * Get the categories from the database. - * - * @return an observable of the categories. - */ - private fun getCategoriesObservable(): Observable> { - return db.getCategories().asRxObservable() - } - - /** - * Get the manga grouped by categories. - * - * @return an observable containing a map with the category id as key and a list of manga as the - * value. - */ - private fun getLibraryMangasObservable(): Observable { - val libraryAsList = preferences.libraryAsList() - return db.getLibraryMangas().asRxObservable() - .map { list -> - list.map { LibraryItem(it, libraryAsList) }.groupBy { it.manga.category } - } - } - - /** - * Requests the library to be filtered. - */ - fun requestFilterUpdate() { - filterTriggerRelay.call(Unit) - } - - /** - * Requests the library to have download badges added. - */ - fun requestDownloadBadgesUpdate() { - downloadTriggerRelay.call(Unit) - } - - /** - * Requests the library to be sorted. - */ - fun requestSortUpdate() { - sortTriggerRelay.call(Unit) - } - - /** - * Called when a manga is opened. - */ - fun onOpenManga() { - // Avoid further db updates for the library when it's not needed - librarySubscription?.let { remove(it) } - } - - /** - * Returns the common categories for the given list of manga. - * - * @param mangas the list of manga. - */ - fun getCommonCategories(mangas: List): Collection { - if (mangas.isEmpty()) return emptyList() - return mangas.toSet() - .map { db.getCategoriesForManga(it).executeAsBlocking() } - .reduce { set1: Iterable, set2 -> set1.intersect(set2) } - } - - /** - * Remove the selected manga from the library. - * - * @param mangas the list of manga to delete. - * @param deleteChapters whether to also delete downloaded chapters. - */ - fun removeMangaFromLibrary(mangas: List, deleteChapters: Boolean) { - // Create a set of the list - val mangaToDelete = mangas.distinctBy { it.id } - mangaToDelete.forEach { it.favorite = false } - - Observable.fromCallable { db.insertMangas(mangaToDelete).executeAsBlocking() } - .onErrorResumeNext { Observable.empty() } - .subscribeOn(Schedulers.io()) - .subscribe() - - Observable.fromCallable { - mangaToDelete.forEach { manga -> - coverCache.deleteFromCache(manga.thumbnail_url) - if (deleteChapters) { - val source = sourceManager.get(manga.source) as? HttpSource - if (source != null) { - downloadManager.deleteManga(manga, source) - } - } - } - } - .subscribeOn(Schedulers.io()) - .subscribe() - } - - /** - * Move the given list of manga to categories. - * - * @param categories the selected categories. - * @param mangas the list of manga to move. - */ - fun moveMangasToCategories(categories: List, mangas: List) { - val mc = ArrayList() - - for (manga in mangas) { - for (cat in categories) { - mc.add(MangaCategory.create(manga, cat)) - } - } - - db.setMangaCategories(mc, mangas) - } - - /** - * Update cover with local file. - * - * @param inputStream the new cover. - * @param manga the manga edited. - * @return true if the cover is updated, false otherwise - */ - @Throws(IOException::class) - fun editCoverWithStream(inputStream: InputStream, manga: Manga): Boolean { - if (manga.source == LocalSource.ID) { - LocalSource.updateCover(context, manga, inputStream) - return true - } - - if (manga.thumbnail_url != null && manga.favorite) { - coverCache.copyToCache(manga.thumbnail_url!!, inputStream) - return true - } - return false - } - -} +package eu.kanade.tachiyomi.ui.library + +import android.os.Bundle +import com.jakewharton.rxrelay.BehaviorRelay +import eu.kanade.tachiyomi.data.cache.CoverCache +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.MangaCategory +import eu.kanade.tachiyomi.data.download.DownloadManager +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import eu.kanade.tachiyomi.util.lang.combineLatest +import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed +import java.io.IOException +import java.io.InputStream +import java.util.ArrayList +import java.util.Collections +import java.util.Comparator +import rx.Observable +import rx.Subscription +import rx.android.schedulers.AndroidSchedulers +import rx.schedulers.Schedulers +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +/** + * Class containing library information. + */ +private data class Library(val categories: List, val mangaMap: LibraryMap) + +/** + * Typealias for the library manga, using the category as keys, and list of manga as values. + */ +private typealias LibraryMap = Map> + +/** + * Presenter of [LibraryController]. + */ +class LibraryPresenter( + private val db: DatabaseHelper = Injekt.get(), + private val preferences: PreferencesHelper = Injekt.get(), + private val coverCache: CoverCache = Injekt.get(), + private val sourceManager: SourceManager = Injekt.get(), + private val downloadManager: DownloadManager = Injekt.get() +) : BasePresenter() { + + private val context = preferences.context + + /** + * Categories of the library. + */ + var categories: List = emptyList() + private set + + /** + * Relay used to apply the UI filters to the last emission of the library. + */ + private val filterTriggerRelay = BehaviorRelay.create(Unit) + + /** + * Relay used to apply the UI update to the last emission of the library. + */ + private val downloadTriggerRelay = BehaviorRelay.create(Unit) + + /** + * Relay used to apply the selected sorting method to the last emission of the library. + */ + private val sortTriggerRelay = BehaviorRelay.create(Unit) + + /** + * Library subscription. + */ + private var librarySubscription: Subscription? = null + + // --> EXH + val favoritesSync = FavoritesSyncHelper(context) + // <-- EXH + + override fun onCreate(savedState: Bundle?) { + super.onCreate(savedState) + subscribeLibrary() + } + + /** + * Subscribes to library if needed. + */ + fun subscribeLibrary() { + if (librarySubscription.isNullOrUnsubscribed()) { + librarySubscription = getLibraryObservable() + .combineLatest(downloadTriggerRelay.observeOn(Schedulers.io())) { lib, _ -> + lib.apply { setDownloadCount(mangaMap) } + } + .combineLatest(filterTriggerRelay.observeOn(Schedulers.io())) { lib, _ -> + lib.copy(mangaMap = applyFilters(lib.mangaMap)) + } + .combineLatest(sortTriggerRelay.observeOn(Schedulers.io())) { lib, _ -> + lib.copy(mangaMap = applySort(lib.mangaMap)) + } + .observeOn(AndroidSchedulers.mainThread()) + .subscribeLatestCache({ view, (categories, mangaMap) -> + view.onNextLibraryUpdate(categories, mangaMap) + }) + } + } + + /** + * Applies library filters to the given map of manga. + * + * @param map the map to filter. + */ + private fun applyFilters(map: LibraryMap): LibraryMap { + val filterDownloaded = preferences.downloadedOnly().get() || preferences.filterDownloaded().get() + val filterUnread = preferences.filterUnread().get() + val filterCompleted = preferences.filterCompleted().get() + + val filterFn: (LibraryItem) -> Boolean = f@{ item -> + // Filter when there isn't unread chapters. + if (filterUnread && item.manga.unread == 0) { + return@f false + } + + if (filterCompleted && item.manga.status != SManga.COMPLETED) { + return@f false + } + + // Filter when there are no downloads. + if (filterDownloaded) { + // Local manga are always downloaded + if (item.manga.source == LocalSource.ID) { + return@f true + } + // Don't bother with directory checking if download count has been set. + if (item.downloadCount != -1) { + return@f item.downloadCount > 0 + } + + return@f downloadManager.getDownloadCount(item.manga) > 0 + } + true + } + + return map.mapValues { entry -> entry.value.filter(filterFn) } + } + + /** + * Sets downloaded chapter count to each manga. + * + * @param map the map of manga. + */ + private fun setDownloadCount(map: LibraryMap) { + if (!preferences.downloadBadge().get()) { + // Unset download count if the preference is not enabled. + for ((_, itemList) in map) { + for (item in itemList) { + item.downloadCount = -1 + } + } + return + } + + for ((_, itemList) in map) { + for (item in itemList) { + item.downloadCount = downloadManager.getDownloadCount(item.manga) + } + } + } + + /** + * Applies library sorting to the given map of manga. + * + * @param map the map to sort. + */ + private fun applySort(map: LibraryMap): LibraryMap { + val sortingMode = preferences.librarySortingMode().get() + + val lastReadManga by lazy { + var counter = 0 + db.getLastReadManga().executeAsBlocking().associate { it.id!! to counter++ } + } + val totalChapterManga by lazy { + var counter = 0 + db.getTotalChapterManga().executeAsBlocking().associate { it.id!! to counter++ } + } + val latestChapterManga by lazy { + var counter = 0 + db.getLatestChapterManga().executeAsBlocking().associate { it.id!! to counter++ } + } + + val sortFn: (LibraryItem, LibraryItem) -> Int = { i1, i2 -> + when (sortingMode) { + LibrarySort.ALPHA -> i1.manga.title.compareTo(i2.manga.title, true) + LibrarySort.LAST_READ -> { + // Get index of manga, set equal to list if size unknown. + val manga1LastRead = lastReadManga[i1.manga.id!!] ?: lastReadManga.size + val manga2LastRead = lastReadManga[i2.manga.id!!] ?: lastReadManga.size + manga1LastRead.compareTo(manga2LastRead) + } + LibrarySort.LAST_CHECKED -> i2.manga.last_update.compareTo(i1.manga.last_update) + LibrarySort.UNREAD -> i1.manga.unread.compareTo(i2.manga.unread) + LibrarySort.TOTAL -> { + val manga1TotalChapter = totalChapterManga[i1.manga.id!!] ?: 0 + val mange2TotalChapter = totalChapterManga[i2.manga.id!!] ?: 0 + manga1TotalChapter.compareTo(mange2TotalChapter) + } + LibrarySort.LATEST_CHAPTER -> { + val manga1latestChapter = latestChapterManga[i1.manga.id!!] + ?: latestChapterManga.size + val manga2latestChapter = latestChapterManga[i2.manga.id!!] + ?: latestChapterManga.size + manga1latestChapter.compareTo(manga2latestChapter) + } + else -> throw Exception("Unknown sorting mode") + } + } + + val comparator = if (preferences.librarySortingAscending().get()) { + Comparator(sortFn) + } else { + Collections.reverseOrder(sortFn) + } + + return map.mapValues { entry -> entry.value.sortedWith(comparator) } + } + + /** + * Get the categories and all its manga from the database. + * + * @return an observable of the categories and its manga. + */ + private fun getLibraryObservable(): Observable { + return Observable.combineLatest(getCategoriesObservable(), getLibraryMangasObservable()) { dbCategories, libraryManga -> + val categories = if (libraryManga.containsKey(0)) { + arrayListOf(Category.createDefault()) + dbCategories + } else { + dbCategories + } + + this.categories = categories + Library(categories, libraryManga) + } + } + + /** + * Get the categories from the database. + * + * @return an observable of the categories. + */ + private fun getCategoriesObservable(): Observable> { + return db.getCategories().asRxObservable() + } + + /** + * Get the manga grouped by categories. + * + * @return an observable containing a map with the category id as key and a list of manga as the + * value. + */ + private fun getLibraryMangasObservable(): Observable { + val libraryAsList = preferences.libraryAsList() + return db.getLibraryMangas().asRxObservable() + .map { list -> + list.map { LibraryItem(it, libraryAsList) }.groupBy { it.manga.category } + } + } + + /** + * Requests the library to be filtered. + */ + fun requestFilterUpdate() { + filterTriggerRelay.call(Unit) + } + + /** + * Requests the library to have download badges added. + */ + fun requestDownloadBadgesUpdate() { + downloadTriggerRelay.call(Unit) + } + + /** + * Requests the library to be sorted. + */ + fun requestSortUpdate() { + sortTriggerRelay.call(Unit) + } + + /** + * Called when a manga is opened. + */ + fun onOpenManga() { + // Avoid further db updates for the library when it's not needed + librarySubscription?.let { remove(it) } + } + + /** + * Returns the common categories for the given list of manga. + * + * @param mangas the list of manga. + */ + fun getCommonCategories(mangas: List): Collection { + if (mangas.isEmpty()) return emptyList() + return mangas.toSet() + .map { db.getCategoriesForManga(it).executeAsBlocking() } + .reduce { set1: Iterable, set2 -> set1.intersect(set2).toMutableList() } + } + + /** + * Remove the selected manga from the library. + * + * @param mangas the list of manga to delete. + * @param deleteChapters whether to also delete downloaded chapters. + */ + fun removeMangaFromLibrary(mangas: List, deleteChapters: Boolean) { + // Create a set of the list + val mangaToDelete = mangas.distinctBy { it.id } + mangaToDelete.forEach { it.favorite = false } + + Observable.fromCallable { db.insertMangas(mangaToDelete).executeAsBlocking() } + .onErrorResumeNext { Observable.empty() } + .subscribeOn(Schedulers.io()) + .subscribe() + + Observable.fromCallable { + mangaToDelete.forEach { manga -> + coverCache.deleteFromCache(manga.thumbnail_url) + if (deleteChapters) { + val source = sourceManager.get(manga.source) as? HttpSource + if (source != null) { + downloadManager.deleteManga(manga, source) + } + } + } + } + .subscribeOn(Schedulers.io()) + .subscribe() + } + + /** + * Move the given list of manga to categories. + * + * @param categories the selected categories. + * @param mangas the list of manga to move. + */ + fun moveMangasToCategories(categories: List, mangas: List) { + val mc = ArrayList() + + for (manga in mangas) { + for (cat in categories) { + mc.add(MangaCategory.create(manga, cat)) + } + } + + db.setMangaCategories(mc, mangas) + } + + /** + * Update cover with local file. + * + * @param inputStream the new cover. + * @param manga the manga edited. + * @return true if the cover is updated, false otherwise + */ + @Throws(IOException::class) + fun editCoverWithStream(inputStream: InputStream, manga: Manga): Boolean { + if (manga.source == LocalSource.ID) { + LocalSource.updateCover(context, manga, inputStream) + return true + } + + if (manga.thumbnail_url != null && manga.favorite) { + coverCache.copyToCache(manga.thumbnail_url!!, inputStream) + return true + } + return false + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySelectionEvent.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySelectionEvent.kt index e490e4364..58f89a4aa 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySelectionEvent.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySelectionEvent.kt @@ -6,5 +6,5 @@ sealed class LibrarySelectionEvent { class Selected(val manga: Manga) : LibrarySelectionEvent() class Unselected(val manga: Manga) : LibrarySelectionEvent() - class Cleared() : LibrarySelectionEvent() -} \ No newline at end of file + class Cleared : LibrarySelectionEvent() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySettingsSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySettingsSheet.kt new file mode 100644 index 000000000..c93f8cce8 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySettingsSheet.kt @@ -0,0 +1,263 @@ +package eu.kanade.tachiyomi.ui.library + +import android.app.Activity +import android.content.Context +import android.util.AttributeSet +import android.view.View +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.widget.ExtendedNavigationView +import eu.kanade.tachiyomi.widget.TabbedBottomSheetDialog +import uy.kohesive.injekt.injectLazy + +class LibrarySettingsSheet( + private val activity: Activity, + onGroupClickListener: (ExtendedNavigationView.Group) -> Unit +) : TabbedBottomSheetDialog(activity) { + + val filters: Filter + private val sort: Sort + private val display: Display + + init { + filters = Filter(activity) + filters.onGroupClicked = onGroupClickListener + + sort = Sort(activity) + sort.onGroupClicked = onGroupClickListener + + display = Display(activity) + display.onGroupClicked = onGroupClickListener + } + + override fun getTabViews(): List = listOf( + filters, + sort, + display + ) + + override fun getTabTitles(): List = listOf( + R.string.action_filter, + R.string.action_sort, + R.string.action_display + ) + + /** + * Filters group (unread, downloaded, ...). + */ + inner class Filter @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + Settings(context, attrs) { + + private val filterGroup = FilterGroup() + + init { + setGroups(listOf(filterGroup)) + } + + /** + * Returns true if there's at least one filter from [FilterGroup] active. + */ + fun hasActiveFilters(): Boolean { + return filterGroup.items.any { it.checked } + } + + inner class FilterGroup : Group { + + private val downloaded = Item.CheckboxGroup(R.string.action_filter_downloaded, this) + private val unread = Item.CheckboxGroup(R.string.action_filter_unread, this) + private val completed = Item.CheckboxGroup(R.string.completed, this) + + override val header = null + override val items = listOf(downloaded, unread, completed) + override val footer = null + + override fun initModels() { + downloaded.checked = preferences.downloadedOnly().get() || preferences.filterDownloaded().get() + downloaded.enabled = !preferences.downloadedOnly().get() + unread.checked = preferences.filterUnread().get() + completed.checked = preferences.filterCompleted().get() + } + + override fun onItemClicked(item: Item) { + item as Item.CheckboxGroup + item.checked = !item.checked + when (item) { + downloaded -> preferences.filterDownloaded().set(item.checked) + unread -> preferences.filterUnread().set(item.checked) + completed -> preferences.filterCompleted().set(item.checked) + } + + adapter.notifyItemChanged(item) + } + } + } + + /** + * Sorting group (alphabetically, by last read, ...) and ascending or descending. + */ + inner class Sort @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + Settings(context, attrs) { + + init { + setGroups(listOf(SortGroup())) + } + + inner class SortGroup : Group { + + private val alphabetically = Item.MultiSort(R.string.action_sort_alpha, this) + private val total = Item.MultiSort(R.string.action_sort_total, this) + private val lastRead = Item.MultiSort(R.string.action_sort_last_read, this) + private val lastChecked = Item.MultiSort(R.string.action_sort_last_checked, this) + private val unread = Item.MultiSort(R.string.action_filter_unread, this) + private val latestChapter = Item.MultiSort(R.string.action_sort_latest_chapter, this) + + override val header = null + override val items = + listOf(alphabetically, lastRead, lastChecked, unread, total, latestChapter) + override val footer = null + + override fun initModels() { + val sorting = preferences.librarySortingMode().get() + val order = if (preferences.librarySortingAscending().get()) { + Item.MultiSort.SORT_ASC + } else { + Item.MultiSort.SORT_DESC + } + + alphabetically.state = + if (sorting == LibrarySort.ALPHA) order else Item.MultiSort.SORT_NONE + lastRead.state = + if (sorting == LibrarySort.LAST_READ) order else Item.MultiSort.SORT_NONE + lastChecked.state = + if (sorting == LibrarySort.LAST_CHECKED) order else Item.MultiSort.SORT_NONE + unread.state = + if (sorting == LibrarySort.UNREAD) order else Item.MultiSort.SORT_NONE + total.state = if (sorting == LibrarySort.TOTAL) order else Item.MultiSort.SORT_NONE + latestChapter.state = + if (sorting == LibrarySort.LATEST_CHAPTER) order else Item.MultiSort.SORT_NONE + } + + override fun onItemClicked(item: Item) { + item as Item.MultiStateGroup + val prevState = item.state + + item.group.items.forEach { + (it as Item.MultiStateGroup).state = + Item.MultiSort.SORT_NONE + } + item.state = when (prevState) { + Item.MultiSort.SORT_NONE -> Item.MultiSort.SORT_ASC + Item.MultiSort.SORT_ASC -> Item.MultiSort.SORT_DESC + Item.MultiSort.SORT_DESC -> Item.MultiSort.SORT_ASC + else -> throw Exception("Unknown state") + } + + preferences.librarySortingMode().set( + when (item) { + alphabetically -> LibrarySort.ALPHA + lastRead -> LibrarySort.LAST_READ + lastChecked -> LibrarySort.LAST_CHECKED + unread -> LibrarySort.UNREAD + total -> LibrarySort.TOTAL + latestChapter -> LibrarySort.LATEST_CHAPTER + else -> throw Exception("Unknown sorting") + } + ) + preferences.librarySortingAscending().set(item.state == Item.MultiSort.SORT_ASC) + + item.group.items.forEach { adapter.notifyItemChanged(it) } + } + } + } + + /** + * Display group, to show the library as a list or a grid. + */ + inner class Display @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + Settings(context, attrs) { + + init { + setGroups(listOf(DisplayGroup(), BadgeGroup())) + } + + inner class DisplayGroup : Group { + + private val grid = Item.Radio(R.string.action_display_grid, this) + private val list = Item.Radio(R.string.action_display_list, this) + + override val header = null + override val items = listOf(grid, list) + override val footer = null + + override fun initModels() { + val asList = preferences.libraryAsList().get() + grid.checked = !asList + list.checked = asList + } + + override fun onItemClicked(item: Item) { + item as Item.Radio + if (item.checked) return + + item.group.items.forEach { (it as Item.Radio).checked = false } + item.checked = true + + preferences.libraryAsList().set(item == list) + + item.group.items.forEach { adapter.notifyItemChanged(it) } + } + } + + inner class BadgeGroup : Group { + private val downloadBadge = Item.CheckboxGroup(R.string.action_display_download_badge, this) + + override val header = null + override val items = listOf(downloadBadge) + override val footer = null + + override fun initModels() { + downloadBadge.checked = preferences.downloadBadge().get() + } + + override fun onItemClicked(item: Item) { + item as Item.CheckboxGroup + item.checked = !item.checked + preferences.downloadBadge().set((item.checked)) + adapter.notifyItemChanged(item) + } + } + } + + open inner class Settings(context: Context, attrs: AttributeSet?) : + ExtendedNavigationView(context, attrs) { + + val preferences: PreferencesHelper by injectLazy() + lateinit var adapter: Adapter + + /** + * Click listener to notify the parent fragment when an item from a group is clicked. + */ + var onGroupClicked: (Group) -> Unit = {} + + fun setGroups(groups: List) { + adapter = Adapter(groups.map { it.createItems() }.flatten()) + recycler.adapter = adapter + + groups.forEach { it.initModels() } + addView(recycler) + } + + /** + * Adapter of the recycler view. + */ + inner class Adapter(items: List) : ExtendedNavigationView.Adapter(items) { + + override fun onItemClicked(item: Item) { + if (item is GroupedItem) { + item.group.onItemClicked(item) + onGroupClicked(item.group) + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySort.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySort.kt index 57c9f28b1..5dfdff91f 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySort.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySort.kt @@ -1,11 +1,14 @@ -package eu.kanade.tachiyomi.ui.library - -object LibrarySort { - - const val ALPHA = 0 - const val LAST_READ = 1 - const val LAST_UPDATED = 2 - const val UNREAD = 3 - const val TOTAL = 4 - const val SOURCE = 5 -} \ No newline at end of file +package eu.kanade.tachiyomi.ui.library + +object LibrarySort { + + const val ALPHA = 0 + const val LAST_READ = 1 + const val LAST_CHECKED = 2 + const val UNREAD = 3 + const val TOTAL = 4 + const val LATEST_CHAPTER = 6 + + @Deprecated("Removed in favor of searching by source") + const val SOURCE = 5 +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/ChangelogDialogController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/ChangelogDialogController.kt index 4d24b7e20..900ca4f54 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/ChangelogDialogController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/ChangelogDialogController.kt @@ -1,32 +1,32 @@ -package eu.kanade.tachiyomi.ui.main - -import android.app.Dialog -import android.content.Context -import android.os.Bundle -import android.util.AttributeSet -import com.afollestad.materialdialogs.MaterialDialog -import eu.kanade.tachiyomi.BuildConfig -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.base.controller.DialogController -import it.gmariotti.changelibs.library.view.ChangeLogRecyclerView - -class ChangelogDialogController : DialogController() { - - override fun onCreateDialog(savedState: Bundle?): Dialog { - val activity = activity!! - val view = WhatsNewRecyclerView(activity) - return MaterialDialog.Builder(activity) - .title(if (BuildConfig.DEBUG) "Notices" else "Changelog") - .customView(view, false) - .positiveText(android.R.string.yes) - .build() - } - - class WhatsNewRecyclerView(context: Context) : ChangeLogRecyclerView(context) { - override fun initAttrs(attrs: AttributeSet?, defStyle: Int) { - mRowLayoutId = R.layout.changelog_row_layout - mRowHeaderLayoutId = R.layout.changelog_header_layout - mChangeLogFileResourceId = if (BuildConfig.DEBUG) R.raw.changelog_debug else R.raw.changelog_release - } - } -} \ No newline at end of file +package eu.kanade.tachiyomi.ui.main + +import android.app.Dialog +import android.content.Context +import android.os.Bundle +import android.util.AttributeSet +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.customview.customView +import eu.kanade.tachiyomi.BuildConfig +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import it.gmariotti.changelibs.library.view.ChangeLogRecyclerView + +class ChangelogDialogController : DialogController() { + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val activity = activity!! + val view = WhatsNewRecyclerView(activity) + return MaterialDialog(activity) + .title(res = if (BuildConfig.DEBUG) R.string.notices else R.string.changelog) + .customView(view = view) + .positiveButton(R.string.action_close) + } + + class WhatsNewRecyclerView(context: Context) : ChangeLogRecyclerView(context) { + override fun initAttrs(attrs: AttributeSet?, defStyle: Int) { + mRowLayoutId = R.layout.changelog_row_layout + mRowHeaderLayoutId = R.layout.changelog_header_layout + mChangeLogFileResourceId = if (BuildConfig.DEBUG) R.raw.changelog_debug else R.raw.changelog_release + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/DeepLinkActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/DeepLinkActivity.kt new file mode 100644 index 000000000..b1195c99d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/DeepLinkActivity.kt @@ -0,0 +1,19 @@ +package eu.kanade.tachiyomi.ui.main + +import android.app.Activity +import android.content.Intent +import android.os.Bundle + +class DeepLinkActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + intent.apply { + flags = flags or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK + setClass(applicationContext, MainActivity::class.java) + } + startActivity(intent) + finish() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 58eff4f47..d23628e26 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -1,157 +1,119 @@ package eu.kanade.tachiyomi.ui.main -import android.animation.ObjectAnimator -import android.app.ActivityManager +import android.app.Activity import android.app.SearchManager -import android.app.Service -import android.app.usage.UsageStatsManager -import android.content.Context import android.content.Intent -import android.graphics.Color -import android.os.Build import android.os.Bundle -import android.os.Looper -import android.text.TextUtils import android.view.View import android.view.ViewGroup -import androidx.appcompat.graphics.drawable.DrawerArrowDrawable -import androidx.appcompat.widget.Toolbar -import androidx.core.view.GravityCompat -import androidx.drawerlayout.widget.DrawerLayout -import com.bluelinelabs.conductor.* +import android.widget.Toast +import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.bluelinelabs.conductor.Conductor +import com.bluelinelabs.conductor.Controller +import com.bluelinelabs.conductor.ControllerChangeHandler +import com.bluelinelabs.conductor.Router +import com.bluelinelabs.conductor.RouterTransaction +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.behavior.HideBottomViewOnScrollBehavior +import com.google.android.material.tabs.TabLayout +import eu.kanade.tachiyomi.Migrations import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.data.notification.NotificationReceiver +import eu.kanade.tachiyomi.databinding.MainActivityBinding +import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi import eu.kanade.tachiyomi.ui.base.activity.BaseActivity -import eu.kanade.tachiyomi.ui.base.controller.* -import eu.kanade.tachiyomi.ui.catalogue.CatalogueController -import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchController +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import eu.kanade.tachiyomi.ui.base.controller.NoToolbarElevationController +import eu.kanade.tachiyomi.ui.base.controller.RootController +import eu.kanade.tachiyomi.ui.base.controller.TabbedController +import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction +import eu.kanade.tachiyomi.ui.browse.BrowseController +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController import eu.kanade.tachiyomi.ui.download.DownloadController -import eu.kanade.tachiyomi.ui.extension.ExtensionController import eu.kanade.tachiyomi.ui.library.LibraryController import eu.kanade.tachiyomi.ui.manga.MangaController -import eu.kanade.tachiyomi.ui.recent_updates.RecentChaptersController -import eu.kanade.tachiyomi.ui.recently_read.RecentlyReadController -import eu.kanade.tachiyomi.ui.setting.SettingsMainController -import eu.kanade.tachiyomi.util.openInBrowser -import eu.kanade.tachiyomi.util.vibrate -import exh.EXHMigrations -import exh.eh.EHentaiUpdateWorker -import exh.uconfig.WarnConfigureDialogController -import exh.ui.batchadd.BatchAddController -import exh.ui.lock.LockChangeHandler -import exh.ui.lock.LockController -import exh.ui.lock.lockEnabled -import exh.ui.lock.notifyLockSecurity -import exh.ui.migration.MetadataFetchDialog -import kotlinx.android.synthetic.main.main_activity.* +import eu.kanade.tachiyomi.ui.more.MoreController +import eu.kanade.tachiyomi.ui.recent.history.HistoryController +import eu.kanade.tachiyomi.ui.recent.updates.UpdatesController +import eu.kanade.tachiyomi.util.lang.launchUI +import eu.kanade.tachiyomi.util.system.WebViewUtil +import eu.kanade.tachiyomi.util.system.toast +import java.util.Date +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import timber.log.Timber -import uy.kohesive.injekt.injectLazy -import java.util.* -import kotlin.collections.ArrayList - -class MainActivity : BaseActivity() { +class MainActivity : BaseActivity() { private lateinit var router: Router - val preferences: PreferencesHelper by injectLazy() - - private var drawerArrow: DrawerArrowDrawable? = null - - private var secondaryDrawer: ViewGroup? = null - private val startScreenId by lazy { when (preferences.startScreen()) { - 2 -> R.id.nav_drawer_recently_read - 3 -> R.id.nav_drawer_recent_updates - else -> R.id.nav_drawer_library + 2 -> R.id.nav_history + 3 -> R.id.nav_updates + else -> R.id.nav_library } } - lateinit var tabAnimator: TabsAnimator + lateinit var tabAnimator: ViewHeightAnimator + private lateinit var bottomNavAnimator: ViewHeightAnimator - // Idle-until-urgent - private var firstPaint = false - private val iuuQueue = LinkedList<() -> Unit>() - - private fun initWhenIdle(task: () -> Unit) { - // Avoid sync issues by enforcing main thread - if(Looper.myLooper() != Looper.getMainLooper()) - throw IllegalStateException("Can only be called on main thread!") - - if(firstPaint) { - task() - } else { - iuuQueue += task - } - } - - override fun onResume() { - super.onResume() - - if(!firstPaint) { - drawer.postDelayed({ - if(!firstPaint) { - firstPaint = true - iuuQueue.forEach { it() } - } - }, 1000) - } - } + private var isConfirmingExit: Boolean = false + private var isHandlingShortcut: Boolean = false override fun onCreate(savedInstanceState: Bundle?) { - setTheme(when (preferences.theme()) { - 2 -> R.style.Theme_Tachiyomi_Dark - 3 -> R.style.Theme_Tachiyomi_Amoled - 4 -> R.style.Theme_Tachiyomi_DarkBlue - else -> R.style.Theme_Tachiyomi - }) super.onCreate(savedInstanceState) + binding = MainActivityBinding.inflate(layoutInflater) + + // Enforce WebView availability + if (!WebViewUtil.supportsWebView(this)) { + toast(R.string.information_webview_required, Toast.LENGTH_LONG) + finishAndRemoveTask() + } + // Do not let the launcher create a new activity http://stackoverflow.com/questions/16283079 if (!isTaskRoot) { finish() return } - setContentView(R.layout.main_activity) + setContentView(binding.root) - setSupportActionBar(toolbar) + setSupportActionBar(binding.toolbar) - drawerArrow = DrawerArrowDrawable(this) - drawerArrow?.color = Color.WHITE - toolbar.navigationIcon = drawerArrow + tabAnimator = ViewHeightAnimator(binding.tabs) + bottomNavAnimator = ViewHeightAnimator(binding.bottomNav) - tabAnimator = TabsAnimator(tabs) - - // Set behavior of Navigation drawer - nav_view.setNavigationItemSelectedListener { item -> + // Set behavior of bottom nav + binding.bottomNav.setOnNavigationItemSelectedListener { item -> val id = item.itemId val currentRoot = router.backstack.firstOrNull() if (currentRoot?.tag()?.toIntOrNull() != id) { when (id) { - R.id.nav_drawer_library -> setRoot(LibraryController(), id) - R.id.nav_drawer_recent_updates -> setRoot(RecentChaptersController(), id) - R.id.nav_drawer_recently_read -> setRoot(RecentlyReadController(), id) - R.id.nav_drawer_catalogues -> setRoot(CatalogueController(), id) - R.id.nav_drawer_extensions -> setRoot(ExtensionController(), id) + R.id.nav_library -> setRoot(LibraryController(), id) + R.id.nav_updates -> setRoot(UpdatesController(), id) + R.id.nav_history -> setRoot(HistoryController(), id) + R.id.nav_browse -> setRoot(BrowseController(), id) + R.id.nav_more -> setRoot(MoreController(), id) // --> EXH - R.id.nav_drawer_batch_add -> setRoot(BatchAddController(), id) + R.id.nav_batch_add -> setRoot(BatchAddController(), id) // <-- EHX - R.id.nav_drawer_downloads -> { - router.pushController(DownloadController().withFadeTransaction()) - } - R.id.nav_drawer_settings -> { - router.pushController(SettingsMainController().withFadeTransaction()) - } - R.id.nav_drawer_help -> { - openInBrowser(URL_HELP) + } + } else if (!isHandlingShortcut) { + when (id) { + R.id.nav_library -> { + val controller = router.getControllerWithTag(id.toString()) as? LibraryController + controller?.showSettingsSheet() } } } - drawer.closeDrawer(GravityCompat.START) true } @@ -161,63 +123,40 @@ class MainActivity : BaseActivity() { if (!router.hasRootController()) { // Set start screen if (!handleIntentAction(intent)) { - setSelectedDrawerItem(startScreenId) + setSelectedNavItem(startScreenId) } } - toolbar.setNavigationOnClickListener { - if (router.backstackSize == 1) { - drawer.openDrawer(GravityCompat.START) - } else { - onBackPressed() - } + binding.toolbar.setNavigationOnClickListener { + onBackPressed() } router.addChangeListener(object : ControllerChangeHandler.ControllerChangeListener { - override fun onChangeStarted(to: Controller?, from: Controller?, isPush: Boolean, - container: ViewGroup, handler: ControllerChangeHandler) { - + override fun onChangeStarted( + to: Controller?, + from: Controller?, + isPush: Boolean, + container: ViewGroup, + handler: ControllerChangeHandler + ) { syncActivityViewWithController(to, from) } - override fun onChangeCompleted(to: Controller?, from: Controller?, isPush: Boolean, - container: ViewGroup, handler: ControllerChangeHandler) { - + override fun onChangeCompleted( + to: Controller?, + from: Controller?, + isPush: Boolean, + container: ViewGroup, + handler: ControllerChangeHandler + ) { } - }) - // --> EH - initWhenIdle { - //Hook long press hamburger menu to lock - getToolbarNavigationIcon(toolbar)?.setOnLongClickListener { - if(lockEnabled(preferences)) { - doLock(true) - vibrate(50) // Notify user of lock - true - } else false - } - } - - //Show lock - if (savedInstanceState == null) { - if (lockEnabled(preferences)) { - //Special case first lock - doLock() - - //Check lock security - initWhenIdle { - notifyLockSecurity(this) - } - } - } - // <-- EH - syncActivityViewWithController(router.backstack.lastOrNull()?.controller()) if (savedInstanceState == null) { // Show changelog if needed - // TODO + // TODO // if (Migrations.upgrade(preferences)) { // ChangelogDialogController().showDialog(router) // } @@ -240,12 +179,16 @@ class MainActivity : BaseActivity() { WarnConfigureDialogController.uploadSettings(router) // Scheduler uploader job if required - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - EHentaiUpdateWorker.scheduleBackground(this) - } + EHentaiUpdateWorker.scheduleBackground(this) + } // EXH <-- } + + setExtensionsBadge() + preferences.extensionUpdatesCount().asFlow() + .onEach { setExtensionsBadge() } + .launchIn(scope) } override fun onNewIntent(intent: Intent) { @@ -254,70 +197,146 @@ class MainActivity : BaseActivity() { } } + override fun onResume() { + super.onResume() + getExtensionUpdates() + } + + private fun setExtensionsBadge() { + val updates = preferences.extensionUpdatesCount().get() + if (updates > 0) { + binding.bottomNav.getOrCreateBadge(R.id.nav_browse).number = updates + } else { + binding.bottomNav.removeBadge(R.id.nav_browse) + } + } + + private fun getExtensionUpdates() { + // Limit checks to once a day at most + val now = Date().time + if (now < preferences.lastExtCheck().get() + TimeUnit.DAYS.toMillis(1)) { + return + } + + GlobalScope.launch(Dispatchers.IO) { + try { + val pendingUpdates = ExtensionGithubApi().checkForUpdates(this@MainActivity) + preferences.extensionUpdatesCount().set(pendingUpdates.size) + } catch (e: Exception) { + Timber.e(e) + } + } + } + private fun handleIntentAction(intent: Intent): Boolean { + val notificationId = intent.getIntExtra("notificationId", -1) + if (notificationId > -1) { + NotificationReceiver.dismissNotification(applicationContext, notificationId, intent.getIntExtra("groupId", 0)) + } + + isHandlingShortcut = true + when (intent.action) { - SHORTCUT_LIBRARY -> setSelectedDrawerItem(R.id.nav_drawer_library) - SHORTCUT_RECENTLY_UPDATED -> setSelectedDrawerItem(R.id.nav_drawer_recent_updates) - SHORTCUT_RECENTLY_READ -> setSelectedDrawerItem(R.id.nav_drawer_recently_read) - SHORTCUT_CATALOGUES -> setSelectedDrawerItem(R.id.nav_drawer_catalogues) + SHORTCUT_LIBRARY -> setSelectedNavItem(R.id.nav_library) + SHORTCUT_RECENTLY_UPDATED -> setSelectedNavItem(R.id.nav_updates) + SHORTCUT_RECENTLY_READ -> setSelectedNavItem(R.id.nav_history) + SHORTCUT_CATALOGUES -> setSelectedNavItem(R.id.nav_browse) + SHORTCUT_EXTENSIONS -> { + if (router.backstackSize > 1) { + router.popToRoot() + } + setSelectedNavItem(R.id.nav_browse) + router.pushController(BrowseController(true).withFadeTransaction()) + } SHORTCUT_MANGA -> { val extras = intent.extras ?: return false - router.setRoot(RouterTransaction.with(MangaController(extras))) + if (router.backstackSize > 1) { + router.popToRoot() + } + setSelectedNavItem(R.id.nav_library) + router.pushController(RouterTransaction.with(MangaController(extras))) } SHORTCUT_DOWNLOADS -> { - if (router.backstack.none { it.controller() is DownloadController }) { - setSelectedDrawerItem(R.id.nav_drawer_downloads) + if (router.backstackSize > 1) { + router.popToRoot() } + setSelectedNavItem(R.id.nav_more) + router.pushController(RouterTransaction.with(DownloadController())) } Intent.ACTION_SEARCH, "com.google.android.gms.actions.SEARCH_ACTION" -> { - //If the intent match the "standard" Android search intent + // If the intent match the "standard" Android search intent // or the Google-specific search intent (triggered by saying or typing "search *query* on *Tachiyomi*" in Google Search/Google Assistant) - //Get the search query provided in extras, and if not null, perform a global search with it. + // Get the search query provided in extras, and if not null, perform a global search with it. val query = intent.getStringExtra(SearchManager.QUERY) - if (query != null && !query.isEmpty()) { + if (query != null && query.isNotEmpty()) { if (router.backstackSize > 1) { router.popToRoot() } - router.pushController(CatalogueSearchController(query).withFadeTransaction()) + router.pushController(GlobalSearchController(query).withFadeTransaction()) } } INTENT_SEARCH -> { val query = intent.getStringExtra(INTENT_SEARCH_QUERY) val filter = intent.getStringExtra(INTENT_SEARCH_FILTER) - if (query != null && !query.isEmpty()) { + if (query != null && query.isNotEmpty()) { if (router.backstackSize > 1) { router.popToRoot() } - router.pushController(CatalogueSearchController(query, filter).withFadeTransaction()) + router.pushController(GlobalSearchController(query, filter).withFadeTransaction()) } } - else -> return false + else -> { + isHandlingShortcut = false + return false + } } + + isHandlingShortcut = false return true } override fun onDestroy() { super.onDestroy() - nav_view?.setNavigationItemSelectedListener(null) - toolbar?.setNavigationOnClickListener(null) + binding.bottomNav.setOnNavigationItemSelectedListener(null) + binding.toolbar.setNavigationOnClickListener(null) } override fun onBackPressed() { val backstackSize = router.backstackSize - if (drawer.isDrawerOpen(GravityCompat.START) || drawer.isDrawerOpen(GravityCompat.END)) { - drawer.closeDrawers() - } else if (backstackSize == 1 && router.getControllerWithTag("$startScreenId") == null) { - setSelectedDrawerItem(startScreenId) + if (backstackSize == 1 && router.getControllerWithTag("$startScreenId") == null) { + // Return to start screen + setSelectedNavItem(startScreenId) + } else if (shouldHandleExitConfirmation()) { + // Exit confirmation (resets after 2 seconds) + launchUI { resetExitConfirmation() } } else if (backstackSize == 1 || !router.handleBack()) { + // Regular back super.onBackPressed() } } - private fun setSelectedDrawerItem(itemId: Int) { + private suspend fun resetExitConfirmation() { + isConfirmingExit = true + val toast = Toast.makeText(this, R.string.confirm_exit, Toast.LENGTH_LONG) + toast.show() + + delay(2000) + + toast.cancel() + isConfirmingExit = false + } + + private fun shouldHandleExitConfirmation(): Boolean { + return router.backstackSize == 1 && + router.getControllerWithTag("$startScreenId") != null && + preferences.confirmExit() && + !isConfirmingExit + } + + fun setSelectedNavItem(itemId: Int) { if (!isFinishing) { - nav_view.setCheckedItem(itemId) - nav_view.menu.performIdentifierAction(itemId, 0) + binding.bottomNav.selectedItemId = itemId } } @@ -325,140 +344,60 @@ class MainActivity : BaseActivity() { router.setRoot(controller.withFadeTransaction().tag(id.toString())) } - fun getToolbarNavigationIcon(toolbar: Toolbar): View? { - try { - //check if contentDescription previously was set - val hadContentDescription = !TextUtils.isEmpty(toolbar.navigationContentDescription) - val contentDescription = if (!hadContentDescription) toolbar.navigationContentDescription else "navigationIcon" - toolbar.navigationContentDescription = contentDescription - - val potentialViews = ArrayList() - - //find the view based on it's content description, set programmatically or with android:contentDescription - toolbar.findViewsWithText(potentialViews, contentDescription, View.FIND_VIEWS_WITH_CONTENT_DESCRIPTION) - - //Nav icon is always instantiated at this point because calling setNavigationContentDescription ensures its existence - val navIcon = potentialViews.firstOrNull() - - //Clear content description if not previously present - if (!hadContentDescription) - toolbar.navigationContentDescription = null - return navIcon - } catch(t: Throwable) { - Timber.w(t, "Could not find toolbar nav icon!") - return null - } - } - private fun syncActivityViewWithController(to: Controller?, from: Controller? = null) { if (from is DialogController || to is DialogController) { return } - val showHamburger = router.backstackSize == 1 - if (showHamburger) { - drawer.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED) - } else { - drawer.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED) - } + supportActionBar?.setDisplayHomeAsUpEnabled(router.backstackSize != 1) - // --> EH - //Special case and hide drawer arrow for lock controller - if(to is LockController) { - supportActionBar?.setDisplayHomeAsUpEnabled(false) - toolbar.navigationIcon = null - } else { - supportActionBar?.setDisplayHomeAsUpEnabled(true) - toolbar.navigationIcon = drawerArrow - } - // <-- EH + // Always show appbar again when changing controllers + binding.appbar.setExpanded(true) - ObjectAnimator.ofFloat(drawerArrow, "progress", if (showHamburger) 0f else 1f).start() + if ((from == null || from is RootController) && to !is RootController) { + showBottomNav(visible = false, collapse = true) + } + if (to is RootController) { + // Always show bottom nav again when returning to a RootController + showBottomNav(visible = true, collapse = from !is RootController) + } if (from is TabbedController) { - from.cleanupTabs(tabs) + from.cleanupTabs(binding.tabs) } if (to is TabbedController) { tabAnimator.expand() - to.configureTabs(tabs) + to.configureTabs(binding.tabs) } else { tabAnimator.collapse() - tabs.setupWithViewPager(null) - } - - if (from is SecondaryDrawerController) { - if (secondaryDrawer != null) { - from.cleanupSecondaryDrawer(drawer) - drawer.removeView(secondaryDrawer) - secondaryDrawer = null - } - } - if (to is SecondaryDrawerController) { - secondaryDrawer = to.createSecondaryDrawer(drawer)?.also { drawer.addView(it) } + binding.tabs.setupWithViewPager(null) } if (to is NoToolbarElevationController) { - appbar.disableElevation() + binding.appbar.disableElevation() } else { - appbar.enableElevation() + binding.appbar.enableElevation() } } - // --> EH - //Lock code - var willLock = false - override fun onRestart() { - super.onRestart() - if(willLock && lockEnabled()) { - doLock() - } - - willLock = false - } - - override fun onStop() { - super.onStop() - tryLock() - } - - fun tryLock() { - //Do not double-lock - if(router.backstack.lastOrNull()?.controller() is LockController) - return - - //Do not lock if manual lock enabled - if(preferences.eh_lockManually().getOrDefault()) - return - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - val mUsageStatsManager = getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager - val time = System.currentTimeMillis() - // We get usage stats for the last 20 seconds - val sortedStats = - mUsageStatsManager.queryUsageStats(UsageStatsManager.INTERVAL_DAILY, - time - 1000 * 20, - time) - ?.associateBy { - it.lastTimeUsed - }?.toSortedMap() - if(sortedStats != null && sortedStats.isNotEmpty()) - if(sortedStats[sortedStats.lastKey()]?.packageName != packageName) - willLock = true - } else { - val am = getSystemService(Service.ACTIVITY_SERVICE) as ActivityManager - val running = am.getRunningTasks(1)[0] - if (running.topActivity.packageName != packageName) { - willLock = true + fun showBottomNav(visible: Boolean, collapse: Boolean = false) { + val layoutParams = binding.bottomNav.layoutParams as CoordinatorLayout.LayoutParams + val bottomViewNavigationBehavior = layoutParams.behavior as HideBottomViewOnScrollBehavior + if (visible) { + if (collapse) { + bottomNavAnimator.expand() } + + bottomViewNavigationBehavior.slideUp(binding.bottomNav) + } else { + if (collapse) { + bottomNavAnimator.collapse() + } + + bottomViewNavigationBehavior.slideDown(binding.bottomNav) } } - fun doLock(animate: Boolean = false) { - router.pushController(RouterTransaction.with(LockController()) - .popChangeHandler(LockChangeHandler(animate))) - } - // <-- EH - companion object { // Shortcut actions const val SHORTCUT_LIBRARY = "eu.kanade.tachiyomi.SHOW_LIBRARY" @@ -467,12 +406,25 @@ class MainActivity : BaseActivity() { const val SHORTCUT_CATALOGUES = "eu.kanade.tachiyomi.SHOW_CATALOGUES" const val SHORTCUT_DOWNLOADS = "eu.kanade.tachiyomi.SHOW_DOWNLOADS" const val SHORTCUT_MANGA = "eu.kanade.tachiyomi.SHOW_MANGA" + const val SHORTCUT_EXTENSIONS = "eu.kanade.tachiyomi.EXTENSIONS" const val INTENT_SEARCH = "eu.kanade.tachiyomi.SEARCH" const val INTENT_SEARCH_QUERY = "query" const val INTENT_SEARCH_FILTER = "filter" - - private const val URL_HELP = "https://tachiyomi.org/help/" } - +} + +/** + * Used to manually offset a FAB within child views that might be cut off due to the collapsing + * AppBarLayout. + */ +fun View.offsetFabAppbarHeight(activity: Activity) { + val appbar: AppBarLayout = activity.findViewById(R.id.appbar) + val tabs: TabLayout = activity.findViewById(R.id.tabs) + appbar.addOnOffsetChangedListener( + AppBarLayout.OnOffsetChangedListener { appBarLayout, verticalOffset -> + val maxAbsOffset = appBarLayout.measuredHeight - tabs.measuredHeight + translationY = -maxAbsOffset - verticalOffset.toFloat() + } + ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/TabsAnimator.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/ViewHeightAnimator.kt similarity index 68% rename from app/src/main/java/eu/kanade/tachiyomi/ui/main/TabsAnimator.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/main/ViewHeightAnimator.kt index f3d8d955b..1b12429e3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/TabsAnimator.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/ViewHeightAnimator.kt @@ -1,45 +1,46 @@ package eu.kanade.tachiyomi.ui.main import android.animation.ObjectAnimator +import android.view.View import android.view.ViewTreeObserver import android.view.animation.DecelerateInterpolator -import com.google.android.material.tabs.TabLayout +import androidx.annotation.Keep -class TabsAnimator(val tabs: TabLayout) { +class ViewHeightAnimator(val view: View) { /** - * The default height of the tab layout. It's unknown until the view is layout. + * The default height of the view. It's unknown until the view is layout. */ - private var tabsHeight = 0 + private var height = 0 /** - * Whether the last state of the tab layout is shown or hidden. + * Whether the last state of the view is shown or hidden. */ private var isLastStateShown = true /** - * Animation used to expand and collapse the tab layout. + * Animation used to expand and collapse the view. */ private val animation by lazy { - ObjectAnimator.ofInt(this, "height", tabsHeight).apply { + ObjectAnimator.ofInt(this, "height", height).apply { duration = 300L interpolator = DecelerateInterpolator() } } init { - tabs.viewTreeObserver.addOnGlobalLayoutListener( + view.viewTreeObserver.addOnGlobalLayoutListener( object : ViewTreeObserver.OnGlobalLayoutListener { override fun onGlobalLayout() { - if (tabs.height > 0) { - tabs.viewTreeObserver.removeOnGlobalLayoutListener(this) + if (view.height > 0) { + view.viewTreeObserver.removeOnGlobalLayoutListener(this) // Save the tabs default height. - tabsHeight = tabs.height + height = view.height // Now that we know the height, set the initial height. if (isLastStateShown) { - setHeight(tabsHeight) + setHeight(height) } else { setHeight(0) } @@ -54,9 +55,10 @@ class TabsAnimator(val tabs: TabLayout) { * * @param newHeight The new height of the tab layout. */ + @Keep fun setHeight(newHeight: Int) { - tabs.layoutParams.height = newHeight - tabs.requestLayout() + view.layoutParams.height = newHeight + view.requestLayout() } /** @@ -64,7 +66,7 @@ class TabsAnimator(val tabs: TabLayout) { * reflection. */ fun getHeight(): Int { - return tabs.layoutParams.height + return view.layoutParams.height } /** @@ -72,8 +74,8 @@ class TabsAnimator(val tabs: TabLayout) { */ fun expand() { if (isMeasured) { - if (getHeight() != tabsHeight) { - animation.setIntValues(tabsHeight) + if (getHeight() != height) { + animation.setIntValues(height) animation.start() } else { animation.cancel() @@ -101,6 +103,5 @@ class TabsAnimator(val tabs: TabLayout) { * Returns whether the tab layout has a known height. */ private val isMeasured: Boolean - get() = tabsHeight > 0 - -} \ No newline at end of file + get() = height > 0 +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt index 71818aeaf..8a66b1dc5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt @@ -5,8 +5,6 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.LinearLayout -import android.widget.TextView import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat import com.bluelinelabs.conductor.ControllerChangeHandler import com.bluelinelabs.conductor.ControllerChangeType @@ -20,35 +18,32 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.databinding.PagerControllerBinding import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.base.controller.RxController import eu.kanade.tachiyomi.ui.base.controller.TabbedController import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe -import eu.kanade.tachiyomi.ui.catalogue.CatalogueController import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersController -import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersPresenter import eu.kanade.tachiyomi.ui.manga.info.MangaInfoController import eu.kanade.tachiyomi.ui.manga.track.TrackController -import eu.kanade.tachiyomi.util.toast -import kotlinx.android.synthetic.main.main_activity.* -import kotlinx.android.synthetic.main.manga_controller.* +import eu.kanade.tachiyomi.util.system.toast +import java.util.Date +import kotlinx.android.synthetic.main.main_activity.tabs import rx.Subscription import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.util.* -class MangaController : RxController, TabbedController { +class MangaController : RxController, TabbedController { - constructor(manga: Manga?, - fromCatalogue: Boolean = false, - smartSearchConfig: CatalogueController.SmartSearchConfig? = null, - update: Boolean = false) : super(Bundle().apply { - putLong(MANGA_EXTRA, manga?.id ?: 0) - putBoolean(FROM_CATALOGUE_EXTRA, fromCatalogue) - putParcelable(SMART_SEARCH_CONFIG_EXTRA, smartSearchConfig) - putBoolean(UPDATE_EXTRA, update) - }) { + constructor(manga: Manga?, fromSource: Boolean = false, smartSearchConfig: SourceController.SmartSearchConfig? = null, update: Boolean = false) : super( + Bundle().apply { + putLong(MANGA_EXTRA, manga?.id ?: 0) + putBoolean(FROM_SOURCE_EXTRA, fromSource) + putParcelable(SMART_SEARCH_CONFIG_EXTRA, smartSearchConfig) + putBoolean(UPDATE_EXTRA, update) + } + ) { this.manga = manga if (manga != null) { source = Injekt.get().getOrStub(manga.source) @@ -68,7 +63,7 @@ class MangaController : RxController, TabbedController { // EXH <-- constructor(mangaId: Long) : this( - Injekt.get().getManga(mangaId).executeAsBlocking()) + Injekt.get().getManga(mangaId).executeAsBlocking()) @Suppress("unused") constructor(bundle: Bundle) : this(bundle.getLong(MANGA_EXTRA)) @@ -81,12 +76,12 @@ class MangaController : RxController, TabbedController { private var adapter: MangaDetailAdapter? = null - val fromCatalogue = args.getBoolean(FROM_CATALOGUE_EXTRA, false) + val fromSource = args.getBoolean(FROM_SOURCE_EXTRA, false) var update = args.getBoolean(UPDATE_EXTRA, false) // EXH --> - val smartSearchConfig: CatalogueController.SmartSearchConfig? = args.getParcelable(SMART_SEARCH_CONFIG_EXTRA) + val smartSearchConfig: SourceController.SmartSearchConfig? = args.getParcelable(SMART_SEARCH_CONFIG_EXTRA) // EXH <-- val lastUpdateRelay: BehaviorRelay = BehaviorRelay.create() @@ -104,7 +99,8 @@ class MangaController : RxController, TabbedController { } override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.manga_controller, container, false) + binding = PagerControllerBinding.inflate(inflater) + return binding.root } override fun onViewCreated(view: View) { @@ -115,11 +111,12 @@ class MangaController : RxController, TabbedController { requestPermissionsSafe(arrayOf(WRITE_EXTERNAL_STORAGE), 301) adapter = MangaDetailAdapter() - manga_pager.offscreenPageLimit = 3 - manga_pager.adapter = adapter + binding.pager.offscreenPageLimit = 3 + binding.pager.adapter = adapter - if (!fromCatalogue) - manga_pager.currentItem = CHAPTERS_CONTROLLER + if (!fromSource) { + binding.pager.currentItem = CHAPTERS_CONTROLLER + } } override fun onDestroyView(view: View) { @@ -130,7 +127,7 @@ class MangaController : RxController, TabbedController { override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { super.onChangeStarted(handler, type) if (type.isEnter) { - activity?.tabs?.setupWithViewPager(manga_pager) + activity?.tabs?.setupWithViewPager(binding.pager) trackingIconSubscription = trackingIconRelay.subscribe { setTrackingIconInternal(it) } } } @@ -161,14 +158,13 @@ class MangaController : RxController, TabbedController { private fun setTrackingIconInternal(visible: Boolean) { val tab = activity?.tabs?.getTabAt(TRACK_CONTROLLER) ?: return - val drawable = if (visible) + val drawable = if (visible) { VectorDrawableCompat.create(resources!!, R.drawable.ic_done_white_18dp, null) - else null + } else { + null + } - val view = tabField.get(tab) as LinearLayout - val textView = view.getChildAt(1) as TextView - textView.setCompoundDrawablesWithIntrinsicBounds(null, null, drawable, null) - textView.compoundDrawablePadding = if (visible) 4 else 0 + tab.icon = drawable } private inner class MangaDetailAdapter : RouterPagerAdapter(this@MangaController) { @@ -176,10 +172,11 @@ class MangaController : RxController, TabbedController { private val tabCount = if (Injekt.get().hasLoggedServices()) 3 else 2 private val tabTitles = listOf( - R.string.manga_detail_tab, - R.string.manga_chapters_tab, - R.string.manga_tracking_tab) - .map { resources!!.getString(it) } + R.string.manga_detail_tab, + R.string.manga_chapters_tab, + R.string.manga_tracking_tab + ) + .map { resources!!.getString(it) } override fun getCount(): Int { return tabCount @@ -188,7 +185,7 @@ class MangaController : RxController, TabbedController { override fun configureRouter(router: Router, position: Int) { if (!router.hasRootController()) { val controller = when (position) { - INFO_CONTROLLER -> MangaInfoController() + INFO_CONTROLLER -> MangaInfoController(fromSource) CHAPTERS_CONTROLLER -> ChaptersController() TRACK_CONTROLLER -> TrackController() else -> error("Wrong position $position") @@ -200,7 +197,6 @@ class MangaController : RxController, TabbedController { override fun getPageTitle(position: Int): CharSequence { return tabTitles[position] } - } companion object { @@ -208,15 +204,11 @@ class MangaController : RxController, TabbedController { const val UPDATE_EXTRA = "update" const val SMART_SEARCH_CONFIG_EXTRA = "smartSearchConfig" // EXH <-- - const val FROM_CATALOGUE_EXTRA = "from_catalogue" + const val FROM_SOURCE_EXTRA = "from_source" const val MANGA_EXTRA = "manga" const val INFO_CONTROLLER = 0 const val CHAPTERS_CONTROLLER = 1 const val TRACK_CONTROLLER = 2 - - private val tabField = TabLayout.Tab::class.java.getDeclaredField("view") - .apply { isAccessible = true } } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt index e684d94df..119a61073 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt @@ -1,128 +1,73 @@ -package eu.kanade.tachiyomi.ui.manga.chapter - -import android.view.View -import android.widget.PopupMenu -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.download.model.Download -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder -import eu.kanade.tachiyomi.util.getResourceColor -import eu.kanade.tachiyomi.util.gone -import eu.kanade.tachiyomi.util.setVectorCompat -import kotlinx.android.synthetic.main.chapters_item.* -import uy.kohesive.injekt.injectLazy -import java.util.* - -class ChapterHolder( - private val view: View, - private val adapter: ChaptersAdapter -) : BaseFlexibleViewHolder(view, adapter) { - private val prefs: PreferencesHelper by injectLazy() - - init { - // We need to post a Runnable to show the popup to make sure that the PopupMenu is - // correctly positioned. The reason being that the view may change position before the - // PopupMenu is shown. - chapter_menu.setOnClickListener { it.post { showPopupMenu(it) } } - } - - fun bind(item: ChapterItem, manga: Manga) { - val chapter = item.chapter - - chapter_title.text = when (manga.displayMode) { - Manga.DISPLAY_NUMBER -> { - val number = adapter.decimalFormat.format(chapter.chapter_number.toDouble()) - itemView.context.getString(R.string.display_mode_chapter, number) - } - else -> chapter.name - } - - // Set the correct drawable for dropdown and update the tint to match theme. - chapter_menu.setVectorCompat(R.drawable.ic_more_vert_black_24dp, view.context.getResourceColor(R.attr.icon_color)) - - // Set correct text color - chapter_title.setTextColor(if (chapter.read) adapter.readColor else adapter.unreadColor) - if (chapter.bookmark) chapter_title.setTextColor(adapter.bookmarkedColor) - - if (chapter.date_upload > 0) { - chapter_date.text = adapter.dateFormat.format(Date(chapter.date_upload)) - chapter_date.setTextColor(if (chapter.read) adapter.readColor else adapter.unreadColor) - } else { - chapter_date.text = "" - } - - //add scanlator if exists - chapter_scanlator.text = chapter.scanlator - //allow longer titles if there is no scanlator (most sources) - if (chapter_scanlator.text.isNullOrBlank()) { - chapter_title.maxLines = 2 - chapter_scanlator.gone() - } else { - chapter_title.maxLines = 1 - } - - chapter_pages.text = if ((!chapter.read /* --> EH */ || prefs.eh_preserveReadingPosition() - .getOrDefault()) /* <-- EH */ && chapter.last_page_read > 0) { - itemView.context.getString(R.string.chapter_progress, chapter.last_page_read + 1) - } else { - "" - } - - notifyStatus(item.status) - } - - fun notifyStatus(status: Int) = with(download_text) { - when (status) { - Download.QUEUE -> setText(R.string.chapter_queued) - Download.DOWNLOADING -> setText(R.string.chapter_downloading) - Download.DOWNLOADED -> setText(R.string.chapter_downloaded) - Download.ERROR -> setText(R.string.chapter_error) - else -> text = "" - } - } - - private fun showPopupMenu(view: View) { - val item = adapter.getItem(adapterPosition) ?: return - - // Create a PopupMenu, giving it the clicked view for an anchor - val popup = PopupMenu(view.context, view) - - // Inflate our menu resource into the PopupMenu's Menu - popup.menuInflater.inflate(R.menu.chapter_single, popup.menu) - - val chapter = item.chapter - - // Hide download and show delete if the chapter is downloaded - if (item.isDownloaded) { - popup.menu.findItem(R.id.action_download).isVisible = false - popup.menu.findItem(R.id.action_delete).isVisible = true - } - - // Hide bookmark if bookmark - popup.menu.findItem(R.id.action_bookmark).isVisible = !chapter.bookmark - popup.menu.findItem(R.id.action_remove_bookmark).isVisible = chapter.bookmark - - // Hide mark as unread when the chapter is unread - if (!chapter.read && (chapter.last_page_read == 0 /* --> EH */ || prefs.eh_preserveReadingPosition() - .getOrDefault()) /* <-- EH */) { - popup.menu.findItem(R.id.action_mark_as_unread).isVisible = false - } - - // Hide mark as read when the chapter is read - if (chapter.read) { - popup.menu.findItem(R.id.action_mark_as_read).isVisible = false - } - - // Set a listener so we are notified if a menu item is clicked - popup.setOnMenuItemClickListener { menuItem -> - adapter.menuItemListener.onMenuItemClick(adapterPosition, menuItem) - true - } - - // Finally show the PopupMenu - popup.show() - } - -} +package eu.kanade.tachiyomi.ui.manga.chapter + +import android.text.SpannableString +import android.text.SpannableStringBuilder +import android.text.style.ForegroundColorSpan +import android.view.View +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.download.model.Download +import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder +import java.util.Date +import kotlinx.android.synthetic.main.chapters_item.chapter_description +import kotlinx.android.synthetic.main.chapters_item.chapter_title +import kotlinx.android.synthetic.main.chapters_item.download_text + +class ChapterHolder( + view: View, + private val adapter: ChaptersAdapter +) : BaseFlexibleViewHolder(view, adapter) { + + fun bind(item: ChapterItem, manga: Manga) { + val chapter = item.chapter + + chapter_title.text = when (manga.displayMode) { + Manga.DISPLAY_NUMBER -> { + val number = adapter.decimalFormat.format(chapter.chapter_number.toDouble()) + itemView.context.getString(R.string.display_mode_chapter, number) + } + else -> chapter.name + } + + // Set correct text color + val chapterColor = if (chapter.read) adapter.readColor else adapter.unreadColor + chapter_title.setTextColor(chapterColor) + chapter_description.setTextColor(chapterColor) + if (chapter.bookmark) { + chapter_title.setTextColor(adapter.bookmarkedColor) + } + + val descriptions = mutableListOf() + + if (chapter.date_upload > 0) { + descriptions.add(adapter.dateFormat.format(Date(chapter.date_upload))) + } + if (!chapter.read && chapter.last_page_read > 0) { + val lastPageRead = SpannableString(itemView.context.getString(R.string.chapter_progress, chapter.last_page_read + 1)).apply { + setSpan(ForegroundColorSpan(adapter.readColor), 0, length, SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE) + } + descriptions.add(lastPageRead) + } + if (!chapter.scanlator.isNullOrBlank()) { + descriptions.add(chapter.scanlator!!) + } + + if (descriptions.isNotEmpty()) { + chapter_description.text = descriptions.joinTo(SpannableStringBuilder(), " • ") + } else { + chapter_description.text = "" + } + + notifyStatus(item.status) + } + + fun notifyStatus(status: Int) = with(download_text) { + when (status) { + Download.QUEUE -> setText(R.string.chapter_queued) + Download.DOWNLOADING -> setText(R.string.chapter_downloading) + Download.DOWNLOADED -> setText(R.string.chapter_downloaded) + Download.ERROR -> setText(R.string.chapter_error) + else -> text = "" + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterItem.kt index f68536e30..649cab753 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterItem.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.ui.manga.chapter import android.view.View +import androidx.recyclerview.widget.RecyclerView import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractFlexibleItem import eu.davidea.flexibleadapter.items.IFlexible @@ -9,16 +10,20 @@ import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.download.model.Download -class ChapterItem(val chapter: Chapter, val manga: Manga) : AbstractFlexibleItem(), - Chapter by chapter { +class ChapterItem(val chapter: Chapter, val manga: Manga) : + AbstractFlexibleItem(), + Chapter by chapter { private var _status: Int = 0 var status: Int get() = download?.status ?: _status - set(value) { _status = value } + set(value) { + _status = value + } - @Transient var download: Download? = null + @Transient + var download: Download? = null val isDownloaded: Boolean get() = status == Download.DOWNLOADED @@ -27,15 +32,16 @@ class ChapterItem(val chapter: Chapter, val manga: Manga) : AbstractFlexibleItem return R.layout.chapters_item } - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): ChapterHolder { + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): ChapterHolder { return ChapterHolder(view, adapter as ChaptersAdapter) } - override fun bindViewHolder(adapter: FlexibleAdapter>, - holder: ChapterHolder, - position: Int, - payloads: List?) { - + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: ChapterHolder, + position: Int, + payloads: List? + ) { holder.bind(this, manga) } @@ -50,5 +56,4 @@ class ChapterItem(val chapter: Chapter, val manga: Manga) : AbstractFlexibleItem override fun hashCode(): Int { return chapter.id!!.hashCode() } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersAdapter.kt index f22b57613..9e696fc4f 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersAdapter.kt @@ -1,45 +1,44 @@ -package eu.kanade.tachiyomi.ui.manga.chapter - -import android.content.Context -import android.view.MenuItem -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.util.getResourceColor -import java.text.DateFormat -import java.text.DecimalFormat -import java.text.DecimalFormatSymbols - -class ChaptersAdapter( - controller: ChaptersController, - context: Context -) : FlexibleAdapter(null, controller, true) { - - var items: List = emptyList() - - val menuItemListener: OnMenuItemClickListener = controller - - val readColor = context.getResourceColor(android.R.attr.textColorHint) - - val unreadColor = context.getResourceColor(android.R.attr.textColorPrimary) - - val bookmarkedColor = context.getResourceColor(R.attr.colorAccent) - - val decimalFormat = DecimalFormat("#.###", DecimalFormatSymbols() - .apply { decimalSeparator = '.' }) - - val dateFormat: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT) - - override fun updateDataSet(items: List?) { - this.items = items ?: emptyList() - super.updateDataSet(items) - } - - fun indexOf(item: ChapterItem): Int { - return items.indexOf(item) - } - - interface OnMenuItemClickListener { - fun onMenuItemClick(position: Int, item: MenuItem) - } - -} +package eu.kanade.tachiyomi.ui.manga.chapter + +import android.content.Context +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.util.system.getResourceColor +import java.text.DateFormat +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import uy.kohesive.injekt.injectLazy + +class ChaptersAdapter( + controller: ChaptersController, + context: Context +) : FlexibleAdapter(null, controller, true) { + + val preferences: PreferencesHelper by injectLazy() + + var items: List = emptyList() + + val readColor = context.getResourceColor(R.attr.colorOnSurface, 0.38f) + val unreadColor = context.getResourceColor(R.attr.colorOnSurface) + + val bookmarkedColor = context.getResourceColor(R.attr.colorAccent) + + val decimalFormat = DecimalFormat( + "#.###", + DecimalFormatSymbols() + .apply { decimalSeparator = '.' } + ) + + val dateFormat: DateFormat = preferences.dateFormat().getOrDefault() + + override fun updateDataSet(items: List?) { + this.items = items ?: emptyList() + super.updateDataSet(items) + } + + fun indexOf(item: ChapterItem): Int { + return items.indexOf(item) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersController.kt index 6a0ea67a5..17e649f93 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersController.kt @@ -2,46 +2,53 @@ package eu.kanade.tachiyomi.ui.manga.chapter import android.animation.Animator import android.animation.AnimatorListenerAdapter -import android.annotation.SuppressLint import android.app.Activity import android.content.Intent -import android.view.* +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode -import com.bluelinelabs.conductor.RouterTransaction -import com.elvishew.xlog.XLog +import androidx.core.graphics.drawable.DrawableCompat +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.snackbar.Snackbar -import com.jakewharton.rxbinding.support.v4.widget.refreshes -import com.jakewharton.rxbinding.view.clicks import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.SelectableAdapter import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.download.model.Download +import eu.kanade.tachiyomi.databinding.ChaptersControllerBinding import eu.kanade.tachiyomi.ui.base.controller.NucleusController -import eu.kanade.tachiyomi.ui.base.controller.popControllerWithTag +import eu.kanade.tachiyomi.ui.main.offsetFabAppbarHeight import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.reader.ReaderActivity -import eu.kanade.tachiyomi.util.getCoordinates -import eu.kanade.tachiyomi.util.snack -import eu.kanade.tachiyomi.util.toast +import eu.kanade.tachiyomi.util.system.getResourceColor +import eu.kanade.tachiyomi.util.system.toast +import eu.kanade.tachiyomi.util.view.getCoordinates +import eu.kanade.tachiyomi.util.view.gone +import eu.kanade.tachiyomi.util.view.shrinkOnScroll +import eu.kanade.tachiyomi.util.view.snack +import eu.kanade.tachiyomi.util.view.visible import exh.EH_SOURCE_ID import exh.EXH_SOURCE_ID -import kotlinx.android.synthetic.main.chapters_controller.* -import rx.android.schedulers.AndroidSchedulers +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.android.view.clicks +import reactivecircus.flowbinding.swiperefreshlayout.refreshes import timber.log.Timber -class ChaptersController : NucleusController(), - ActionMode.Callback, - FlexibleAdapter.OnItemClickListener, - FlexibleAdapter.OnItemLongClickListener, - ChaptersAdapter.OnMenuItemClickListener, - SetDisplayModeDialog.Listener, - SetSortingDialog.Listener, - DownloadChaptersDialog.Listener, - DownloadCustomChaptersDialog.Listener, - DeleteChaptersDialog.Listener { +class ChaptersController : + NucleusController(), + ActionMode.Callback, + FlexibleAdapter.OnItemClickListener, + FlexibleAdapter.OnItemLongClickListener, + DownloadCustomChaptersDialog.Listener, + DeleteChaptersDialog.Listener { /** * Adapter containing a list of chapters. @@ -67,12 +74,15 @@ class ChaptersController : NucleusController(), override fun createPresenter(): ChaptersPresenter { val ctrl = parentController as MangaController - return ChaptersPresenter(ctrl.manga!!, ctrl.source!!, - ctrl.chapterCountRelay, ctrl.lastUpdateRelay, ctrl.mangaFavoriteRelay) + return ChaptersPresenter( + ctrl.manga!!, ctrl.source!!, + ctrl.chapterCountRelay, ctrl.lastUpdateRelay, ctrl.mangaFavoriteRelay + ) } override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.chapters_controller, container, false) + binding = ChaptersControllerBinding.inflate(inflater) + return binding.root } override fun onViewCreated(view: View) { @@ -81,46 +91,48 @@ class ChaptersController : NucleusController(), // Init RecyclerView and adapter adapter = ChaptersAdapter(this, view.context) - recycler.adapter = adapter - recycler.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(view.context) - recycler.addItemDecoration(androidx.recyclerview.widget.DividerItemDecoration(view.context, androidx.recyclerview.widget.DividerItemDecoration.VERTICAL)) - recycler.setHasFixedSize(true) - adapter?.fastScroller = fast_scroller + binding.recycler.adapter = adapter + binding.recycler.layoutManager = LinearLayoutManager(view.context) + binding.recycler.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) + binding.recycler.setHasFixedSize(true) + adapter?.fastScroller = binding.fastScroller - swipe_refresh.refreshes().subscribeUntilDestroy { fetchChaptersFromSource() } + binding.swipeRefresh.refreshes() + .onEach { fetchChaptersFromSource() } + .launchIn(scope) - fab.clicks().subscribeUntilDestroy { - val item = presenter.getNextUnreadChapter() - if (item != null) { - // Create animation listener - val revealAnimationListener: Animator.AnimatorListener = object : AnimatorListenerAdapter() { - override fun onAnimationStart(animation: Animator?) { - openChapter(item.chapter, true) + binding.fab.clicks() + .onEach { + val item = presenter.getNextUnreadChapter() + if (item != null) { + // Create animation listener + val revealAnimationListener: Animator.AnimatorListener = object : AnimatorListenerAdapter() { + override fun onAnimationStart(animation: Animator?) { + openChapter(item.chapter, true) + } } - } - // Get coordinates and start animation - val coordinates = fab.getCoordinates() - if (!reveal_view.showRevealEffect(coordinates.x, coordinates.y, revealAnimationListener)) { - openChapter(item.chapter) + // Get coordinates and start animation + val coordinates = binding.fab.getCoordinates() + if (!binding.revealView.showRevealEffect(coordinates.x, coordinates.y, revealAnimationListener)) { + openChapter(item.chapter) + } + } else { + view.context.toast(R.string.no_next_chapter) } - } else { - view.context.toast(R.string.no_next_chapter) } - } + .launchIn(scope) - presenter.redirectUserRelay - .observeOn(AndroidSchedulers.mainThread()) - .subscribeUntilDestroy { redirect -> - XLog.d("Redirecting to updated manga (manga.id: %s, manga.title: %s, update: %s)!", redirect.manga.id, redirect.manga.title, redirect.update) - // Replace self - parentController?.router?.replaceTopController(RouterTransaction.with(MangaController(redirect))) - } + binding.fab.shrinkOnScroll(binding.recycler) + + binding.actionToolbar.offsetFabAppbarHeight(activity!!) + binding.fab.offsetFabAppbarHeight(activity!!) } override fun onDestroyView(view: View) { + destroyActionModeIfNeeded() + binding.actionToolbar.destroy() adapter = null - actionMode = null super.onDestroyView(view) } @@ -128,11 +140,12 @@ class ChaptersController : NucleusController(), if (view == null) return // Check if animation view is visible - if (reveal_view.visibility == View.VISIBLE) { - // Show the unReveal effect - val coordinates = fab.getCoordinates() - reveal_view.hideRevealEffect(coordinates.x, coordinates.y, 1920) + if (binding.revealView.visibility == View.VISIBLE) { + // Show the unreveal effect + val coordinates = binding.fab.getCoordinates() + binding.revealView.hideRevealEffect(coordinates.x, coordinates.y, 1920) } + super.onActivityResumed(activity) } @@ -146,26 +159,73 @@ class ChaptersController : NucleusController(), val menuFilterUnread = menu.findItem(R.id.action_filter_unread) val menuFilterDownloaded = menu.findItem(R.id.action_filter_downloaded) val menuFilterBookmarked = menu.findItem(R.id.action_filter_bookmarked) + val menuFilterEmpty = menu.findItem(R.id.action_filter_empty) // Set correct checkbox values. menuFilterRead.isChecked = presenter.onlyRead() menuFilterUnread.isChecked = presenter.onlyUnread() menuFilterDownloaded.isChecked = presenter.onlyDownloaded() + menuFilterDownloaded.isEnabled = !presenter.forceDownloaded() menuFilterBookmarked.isChecked = presenter.onlyBookmarked() - if (presenter.onlyRead()) - //Disable unread filter option if read filter is enabled. + val filterSet = presenter.onlyRead() || presenter.onlyUnread() || presenter.onlyDownloaded() || presenter.onlyBookmarked() + + if (filterSet) { + val filterColor = activity!!.getResourceColor(R.attr.colorFilterActive) + DrawableCompat.setTint(menu.findItem(R.id.action_filter).icon, filterColor) + } + + // Only show remove filter option if there's a filter set. + menuFilterEmpty.isVisible = filterSet + + // Disable unread filter option if read filter is enabled. + if (presenter.onlyRead()) { menuFilterUnread.isEnabled = false - if (presenter.onlyUnread()) - //Disable read filter option if unread filter is enabled. + } + // Disable read filter option if unread filter is enabled. + if (presenter.onlyUnread()) { menuFilterRead.isEnabled = false + } + + // Display mode submenu + if (presenter.manga.displayMode == Manga.DISPLAY_NAME) { + menu.findItem(R.id.display_title).isChecked = true + } else { + menu.findItem(R.id.display_chapter_number).isChecked = true + } + + // Sorting mode submenu + if (presenter.manga.sorting == Manga.SORTING_SOURCE) { + menu.findItem(R.id.sort_by_source).isChecked = true + } else { + menu.findItem(R.id.sort_by_number).isChecked = true + } } override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { - R.id.action_display_mode -> showDisplayModeDialog() - R.id.manga_download -> showDownloadDialog() - R.id.action_sorting_mode -> showSortingDialog() + R.id.display_title -> { + item.isChecked = true + setDisplayMode(Manga.DISPLAY_NAME) + } + R.id.display_chapter_number -> { + item.isChecked = true + setDisplayMode(Manga.DISPLAY_NUMBER) + } + + R.id.sort_by_source -> { + item.isChecked = true + presenter.setSorting(Manga.SORTING_SOURCE) + } + R.id.sort_by_number -> { + item.isChecked = true + presenter.setSorting(Manga.SORTING_NUMBER) + } + + R.id.download_next, R.id.download_next_5, R.id.download_next_10, + R.id.download_custom, R.id.download_unread, R.id.download_all + -> downloadChapters(item.itemId) + R.id.action_filter_unread -> { item.isChecked = !item.isChecked presenter.setUnreadFilter(item.isChecked) @@ -179,26 +239,28 @@ class ChaptersController : NucleusController(), R.id.action_filter_downloaded -> { item.isChecked = !item.isChecked presenter.setDownloadedFilter(item.isChecked) + activity?.invalidateOptionsMenu() } R.id.action_filter_bookmarked -> { item.isChecked = !item.isChecked presenter.setBookmarkedFilter(item.isChecked) + activity?.invalidateOptionsMenu() } R.id.action_filter_empty -> { presenter.removeFilters() activity?.invalidateOptionsMenu() } R.id.action_sort -> presenter.revertSortOrder() - else -> return super.onOptionsItemSelected(item) } - return true + return super.onOptionsItemSelected(item) } fun onNextChapters(chapters: List) { // If the list is empty, fetch chapters from source if the conditions are met // We use presenter chapters instead because they are always unfiltered - if (presenter.chapters.isEmpty()) + if (presenter.chapters.isEmpty()) { initialFetchChapters() + } val mangaController = parentController as MangaController if (mangaController.update @@ -207,7 +269,6 @@ class ChaptersController : NucleusController(), && chapters.size == 1 && chapters.first().date_upload == 0L)) { mangaController.update = false fetchChaptersFromSource() - } val adapter = adapter ?: return adapter.updateDataSet(chapters) @@ -223,35 +284,27 @@ class ChaptersController : NucleusController(), } actionMode?.invalidate() } - } private fun initialFetchChapters() { // Only fetch if this view is from the catalog and it hasn't requested previously - if ((parentController as MangaController).fromCatalogue && !presenter.hasRequested) { + if ((parentController as MangaController).fromSource && !presenter.hasRequested) { fetchChaptersFromSource() } } private fun fetchChaptersFromSource() { - swipe_refresh?.isRefreshing = true + binding.swipeRefresh.isRefreshing = true presenter.fetchChaptersFromSource() } fun onFetchChaptersDone() { - swipe_refresh?.isRefreshing = false + binding.swipeRefresh.isRefreshing = false } fun onFetchChaptersError(error: Throwable) { - swipe_refresh?.isRefreshing = false + binding.swipeRefresh.isRefreshing = false activity?.toast(error.message) - // [EXH] - XLog.w("> Failed to fetch chapters!", error) - XLog.w("> (source.id: %s, source.name: %s, manga.id: %s, manga.url: %s)", - presenter.source.id, - presenter.source.name, - presenter.manga.id, - presenter.manga.url) } fun onChapterStatusChange(download: Download) { @@ -259,7 +312,7 @@ class ChaptersController : NucleusController(), } private fun getHolder(chapter: Chapter): ChapterHolder? { - return recycler?.findViewHolderForItemId(chapter.id!!) as? ChapterHolder + return binding.recycler.findViewHolderForItemId(chapter.id!!) as? ChapterHolder } fun openChapter(chapter: Chapter, hasAnimation: Boolean = false) { @@ -271,16 +324,16 @@ class ChaptersController : NucleusController(), startActivity(intent) } - override fun onItemClick(view: View, position: Int): Boolean { + override fun onItemClick(view: View?, position: Int): Boolean { val adapter = adapter ?: return false val item = adapter.getItem(position) ?: return false - if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) { + return if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) { lastClickPosition = position toggleSelection(position) - return true + true } else { openChapter(item.chapter) - return false + false } } @@ -288,10 +341,12 @@ class ChaptersController : NucleusController(), createActionModeIfNeeded() when { lastClickPosition == -1 -> setSelection(position) - lastClickPosition > position -> for (i in position until lastClickPosition) - setSelection(i) - lastClickPosition < position -> for (i in lastClickPosition + 1..position) - setSelection(i) + lastClickPosition > position -> + for (i in position until lastClickPosition) + setSelection(i) + lastClickPosition < position -> + for (i in lastClickPosition + 1..position) + setSelection(i) else -> setSelection(position) } lastClickPosition = position @@ -331,6 +386,10 @@ class ChaptersController : NucleusController(), private fun createActionModeIfNeeded() { if (actionMode == null) { actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this) + binding.actionToolbar.show( + actionMode!!, + R.menu.chapter_selection + ) { onActionItemClicked(actionMode!!, it!!) } } } @@ -340,19 +399,30 @@ class ChaptersController : NucleusController(), } override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.chapter_selection, menu) + mode.menuInflater.inflate(R.menu.generic_selection, menu) adapter?.mode = SelectableAdapter.Mode.MULTI return true } - @SuppressLint("StringFormatInvalid") override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { val count = adapter?.selectedItemCount ?: 0 if (count == 0) { // Destroy action mode if there are no items selected. destroyActionModeIfNeeded() } else { - mode.title = resources?.getString(R.string.label_selected, count) + mode.title = count.toString() + + val chapters = getSelectedChapters() + binding.actionToolbar.findItem(R.id.action_download)?.isVisible = chapters.any { !it.isDownloaded } + binding.actionToolbar.findItem(R.id.action_delete)?.isVisible = chapters.any { it.isDownloaded } + binding.actionToolbar.findItem(R.id.action_bookmark)?.isVisible = chapters.any { !it.chapter.bookmark } + binding.actionToolbar.findItem(R.id.action_remove_bookmark)?.isVisible = chapters.all { it.chapter.bookmark } + binding.actionToolbar.findItem(R.id.action_mark_as_read)?.isVisible = chapters.any { !it.chapter.read } + binding.actionToolbar.findItem(R.id.action_mark_as_unread)?.isVisible = chapters.all { it.chapter.read } + + // Hide FAB to avoid interfering with the bottom action toolbar + // binding.fab.hide() + binding.fab.gone() } return false } @@ -360,35 +430,35 @@ class ChaptersController : NucleusController(), override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { when (item.itemId) { R.id.action_select_all -> selectAll() - R.id.action_mark_as_read -> markAsRead(getSelectedChapters()) - R.id.action_mark_as_unread -> markAsUnread(getSelectedChapters()) + R.id.action_select_inverse -> selectInverse() R.id.action_download -> downloadChapters(getSelectedChapters()) R.id.action_delete -> showDeleteChaptersConfirmationDialog() + R.id.action_bookmark -> bookmarkChapters(getSelectedChapters(), true) + R.id.action_remove_bookmark -> bookmarkChapters(getSelectedChapters(), false) + R.id.action_mark_as_read -> markAsRead(getSelectedChapters()) + R.id.action_mark_as_unread -> markAsUnread(getSelectedChapters()) + R.id.action_mark_previous_as_read -> markPreviousAsRead(getSelectedChapters()) else -> return false } return true } override fun onDestroyActionMode(mode: ActionMode) { + binding.actionToolbar.hide() adapter?.mode = SelectableAdapter.Mode.SINGLE adapter?.clearSelection() selectedItems.clear() actionMode = null + + // TODO: there seems to be a bug in MaterialComponents where the [ExtendedFloatingActionButton] + // fails to show up properly + // binding.fab.show() + binding.fab.visible() } - override fun onMenuItemClick(position: Int, item: MenuItem) { - val chapter = adapter?.getItem(position) ?: return - val chapters = listOf(chapter) - - when (item.itemId) { - R.id.action_download -> downloadChapters(chapters) - R.id.action_bookmark -> bookmarkChapters(chapters, true) - R.id.action_remove_bookmark -> bookmarkChapters(chapters, false) - R.id.action_delete -> deleteChapters(chapters) - R.id.action_mark_as_read -> markAsRead(chapters) - R.id.action_mark_as_unread -> markAsUnread(chapters) - R.id.action_mark_previous_as_read -> markPreviousAsRead(chapter) - } + override fun onDetach(view: View) { + destroyActionModeIfNeeded() + super.onDetach(view) } // SELECTION MODE ACTIONS @@ -400,6 +470,19 @@ class ChaptersController : NucleusController(), actionMode?.invalidate() } + private fun selectInverse() { + val adapter = adapter ?: return + + selectedItems.clear() + for (i in 0..adapter.itemCount) { + adapter.toggleSelection(i) + } + selectedItems.addAll(adapter.selectedPositions.mapNotNull { adapter.getItem(it) }) + + actionMode?.invalidate() + adapter.notifyDataSetChanged() + } + private fun markAsRead(chapters: List) { presenter.markChaptersRead(chapters, true) if (presenter.preferences.removeAfterMarkedAsRead()) { @@ -413,10 +496,9 @@ class ChaptersController : NucleusController(), private fun downloadChapters(chapters: List) { val view = view - destroyActionModeIfNeeded() presenter.downloadChapters(chapters) if (view != null && !presenter.manga.favorite) { - recycler?.snack(view.context.getString(R.string.snack_add_to_library), Snackbar.LENGTH_INDEFINITE) { + binding.recycler.snack(view.context.getString(R.string.snack_add_to_library), Snackbar.LENGTH_INDEFINITE) { setAction(R.string.action_add) { presenter.addToLibrary() } @@ -424,7 +506,6 @@ class ChaptersController : NucleusController(), } } - private fun showDeleteChaptersConfirmationDialog() { DeleteChaptersDialog(this).showDialog(router) } @@ -433,74 +514,62 @@ class ChaptersController : NucleusController(), deleteChapters(getSelectedChapters()) } - private fun markPreviousAsRead(chapter: ChapterItem) { + private fun markPreviousAsRead(chapters: List) { val adapter = adapter ?: return - val chapters = if (presenter.sortDescending()) adapter.items.reversed() else adapter.items - val chapterPos = chapters.indexOf(chapter) + val prevChapters = if (presenter.sortDescending()) adapter.items.reversed() else adapter.items + val chapterPos = prevChapters.indexOf(chapters.last()) if (chapterPos != -1) { - markAsRead(chapters.take(chapterPos)) + markAsRead(prevChapters.take(chapterPos)) } } private fun bookmarkChapters(chapters: List, bookmarked: Boolean) { - destroyActionModeIfNeeded() presenter.bookmarkChapters(chapters, bookmarked) } fun deleteChapters(chapters: List) { - destroyActionModeIfNeeded() if (chapters.isEmpty()) return - DeletingChaptersDialog().showDialog(router) presenter.deleteChapters(chapters) } - fun onChaptersDeleted() { - dismissDeletingDialog() + fun onChaptersDeleted(chapters: List) { + // this is needed so the downloaded text gets removed from the item + chapters.forEach { + adapter?.updateItem(it) + } adapter?.notifyDataSetChanged() } fun onChaptersDeletedError(error: Throwable) { - dismissDeletingDialog() Timber.e(error) } - private fun dismissDeletingDialog() { - router.popControllerWithTag(DeletingChaptersDialog.TAG) - } - // OVERFLOW MENU DIALOGS - private fun showDisplayModeDialog() { - val preselected = if (presenter.manga.displayMode == Manga.DISPLAY_NAME) 0 else 1 - SetDisplayModeDialog(this, preselected).showDialog(router) - } - - override fun setDisplayMode(id: Int) { + private fun setDisplayMode(id: Int) { presenter.setDisplayMode(id) adapter?.notifyDataSetChanged() } - private fun showSortingDialog() { - val preselected = if (presenter.manga.sorting == Manga.SORTING_SOURCE) 0 else 1 - SetSortingDialog(this, preselected).showDialog(router) - } - - override fun setSorting(id: Int) { - presenter.setSorting(id) - } - - private fun showDownloadDialog() { - DownloadChaptersDialog(this).showDialog(router) - } - private fun getUnreadChaptersSorted() = presenter.chapters - .filter { !it.read && it.status == Download.NOT_DOWNLOADED } - .distinctBy { it.name } - .sortedByDescending { it.source_order } + .filter { !it.read && it.status == Download.NOT_DOWNLOADED } + .distinctBy { it.name } + .sortedByDescending { it.source_order } - override fun downloadCustomChapters(amount: Int) { - val chaptersToDownload = getUnreadChaptersSorted().take(amount) + private fun downloadChapters(choice: Int) { + val chaptersToDownload = when (choice) { + R.id.download_next -> getUnreadChaptersSorted().take(1) + R.id.download_next_5 -> getUnreadChaptersSorted().take(5) + R.id.download_next_10 -> getUnreadChaptersSorted().take(10) + R.id.download_custom -> { + showCustomDownloadDialog() + return + } + R.id.download_unread -> presenter.chapters.filter { !it.read } + R.id.download_all -> presenter.chapters + else -> emptyList() + } if (chaptersToDownload.isNotEmpty()) { downloadChapters(chaptersToDownload) } @@ -510,26 +579,8 @@ class ChaptersController : NucleusController(), DownloadCustomChaptersDialog(this, presenter.chapters.size).showDialog(router) } - - override fun downloadChapters(choice: Int) { - // i = 0: Download 1 - // i = 1: Download 5 - // i = 2: Download 10 - // i = 3: Download x - // i = 4: Download unread - // i = 5: Download all - val chaptersToDownload = when (choice) { - 0 -> getUnreadChaptersSorted().take(1) - 1 -> getUnreadChaptersSorted().take(5) - 2 -> getUnreadChaptersSorted().take(10) - 3 -> { - showCustomDownloadDialog() - return - } - 4 -> presenter.chapters.filter { !it.read } - 5 -> presenter.chapters - else -> emptyList() - } + override fun downloadCustomChapters(amount: Int) { + val chaptersToDownload = getUnreadChaptersSorted().take(amount) if (chaptersToDownload.isNotEmpty()) { downloadChapters(chaptersToDownload) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersPresenter.kt index 422eac5e8..beccfbf1d 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersPresenter.kt @@ -1,454 +1,469 @@ -package eu.kanade.tachiyomi.ui.manga.chapter - -import android.os.Bundle -import com.jakewharton.rxrelay.BehaviorRelay -import com.jakewharton.rxrelay.PublishRelay -import eu.kanade.tachiyomi.data.database.DatabaseHelper -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.download.DownloadManager -import eu.kanade.tachiyomi.data.download.model.Download -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.source.LocalSource -import eu.kanade.tachiyomi.source.Source -import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter -import eu.kanade.tachiyomi.util.isNullOrUnsubscribed -import eu.kanade.tachiyomi.util.syncChaptersWithSource -import exh.EH_SOURCE_ID -import exh.EXH_SOURCE_ID -import exh.debug.DebugToggles -import exh.eh.EHentaiUpdateHelper -import exh.isEhBasedSource -import rx.Observable -import rx.Subscription -import rx.android.schedulers.AndroidSchedulers -import rx.schedulers.Schedulers -import timber.log.Timber -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import uy.kohesive.injekt.injectLazy -import java.util.Date - -/** - * Presenter of [ChaptersController]. - */ -class ChaptersPresenter( - val manga: Manga, - val source: Source, - private val chapterCountRelay: BehaviorRelay, - private val lastUpdateRelay: BehaviorRelay, - private val mangaFavoriteRelay: PublishRelay, - val preferences: PreferencesHelper = Injekt.get(), - private val db: DatabaseHelper = Injekt.get(), - private val downloadManager: DownloadManager = Injekt.get() -) : BasePresenter() { - - /** - * List of chapters of the manga. It's always unfiltered and unsorted. - */ - var chapters: List = emptyList() - private set - - /** - * Subject of list of chapters to allow updating the view without going to DB. - */ - val chaptersRelay: PublishRelay> - by lazy { PublishRelay.create>() } - - /** - * Whether the chapter list has been requested to the source. - */ - var hasRequested = false - private set - - /** - * Subscription to retrieve the new list of chapters from the source. - */ - private var fetchChaptersSubscription: Subscription? = null - - /** - * Subscription to observe download status changes. - */ - private var observeDownloadsSubscription: Subscription? = null - - // EXH --> - private val updateHelper: EHentaiUpdateHelper by injectLazy() - - val redirectUserRelay = BehaviorRelay.create() - - data class EXHRedirect(val manga: Manga, val update: Boolean) - // EXH <-- - - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) - - // Prepare the relay. - chaptersRelay.flatMap { applyChapterFilters(it) } - .observeOn(AndroidSchedulers.mainThread()) - .subscribeLatestCache(ChaptersController::onNextChapters, - { _, error -> Timber.e(error) }) - - // Add the subscription that retrieves the chapters from the database, keeps subscribed to - // changes, and sends the list of chapters to the relay. - add(db.getChapters(manga).asRxObservable() - .map { chapters -> - // Convert every chapter to a model. - chapters.map { it.toModel() } - } - .doOnNext { chapters -> - // Find downloaded chapters - setDownloadedChapters(chapters) - - // Store the last emission - this.chapters = chapters - - // Listen for download status changes - observeDownloads() - - // Emit the number of chapters to the info tab. - chapterCountRelay.call(chapters.maxBy { it.chapter_number }?.chapter_number - ?: 0f) - - // Emit the upload date of the most recent chapter - lastUpdateRelay.call(Date(chapters.maxBy { it.date_upload }?.date_upload - ?: 0)) - - // EXH --> - if(chapters.isNotEmpty() - && (source.id == EXH_SOURCE_ID || source.id == EH_SOURCE_ID) - && DebugToggles.ENABLE_EXH_ROOT_REDIRECT.enabled) { - // Check for gallery in library and accept manga with lowest id - // Find chapters sharing same root - add(updateHelper.findAcceptedRootAndDiscardOthers(manga.source, chapters) - .subscribeOn(Schedulers.io()) - .subscribe { (acceptedChain, _) -> - // Redirect if we are not the accepted root - if(manga.id != acceptedChain.manga.id) { - // Update if any of our chapters are not in accepted manga's chapters - val ourChapterUrls = chapters.map { it.url }.toSet() - val acceptedChapterUrls = acceptedChain.chapters.map { it.url }.toSet() - val update = (ourChapterUrls - acceptedChapterUrls).isNotEmpty() - redirectUserRelay.call(EXHRedirect(acceptedChain.manga, update)) - } - }) - } - // EXH <-- - } - .subscribe { chaptersRelay.call(it) }) - } - - private fun observeDownloads() { - observeDownloadsSubscription?.let { remove(it) } - observeDownloadsSubscription = downloadManager.queue.getStatusObservable() - .observeOn(AndroidSchedulers.mainThread()) - .filter { download -> download.manga.id == manga.id } - .doOnNext { onDownloadStatusChange(it) } - .subscribeLatestCache(ChaptersController::onChapterStatusChange, - { _, error -> Timber.e(error) }) - } - - /** - * Converts a chapter from the database to an extended model, allowing to store new fields. - */ - private fun Chapter.toModel(): ChapterItem { - // Create the model object. - val model = ChapterItem(this, manga) - - // Find an active download for this chapter. - val download = downloadManager.queue.find { it.chapter.id == id } - - if (download != null) { - // If there's an active download, assign it. - model.download = download - } - return model - } - - /** - * Finds and assigns the list of downloaded chapters. - * - * @param chapters the list of chapter from the database. - */ - private fun setDownloadedChapters(chapters: List) { - for (chapter in chapters) { - if (downloadManager.isChapterDownloaded(chapter, manga)) { - chapter.status = Download.DOWNLOADED - } - } - } - - /** - * Requests an updated list of chapters from the source. - */ - fun fetchChaptersFromSource() { - hasRequested = true - - if (!fetchChaptersSubscription.isNullOrUnsubscribed()) return - fetchChaptersSubscription = Observable.defer { source.fetchChapterList(manga) } - .subscribeOn(Schedulers.io()) - .map { syncChaptersWithSource(db, it, manga, source) } - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst({ view, _ -> - view.onFetchChaptersDone() - }, ChaptersController::onFetchChaptersError) - } - - /** - * Updates the UI after applying the filters. - */ - private fun refreshChapters() { - chaptersRelay.call(chapters) - } - - /** - * Applies the view filters to the list of chapters obtained from the database. - * @param chapters the list of chapters from the database - * @return an observable of the list of chapters filtered and sorted. - */ - private fun applyChapterFilters(chapters: List): Observable> { - var observable = Observable.from(chapters).subscribeOn(Schedulers.io()) - if (onlyUnread()) { - observable = observable.filter { !it.read } - } - else if (onlyRead()) { - observable = observable.filter { it.read } - } - if (onlyDownloaded()) { - observable = observable.filter { it.isDownloaded || it.manga.source == LocalSource.ID } - } - if (onlyBookmarked()) { - observable = observable.filter { it.bookmark } - } - val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) { - Manga.SORTING_SOURCE -> when (sortDescending()) { - true -> { c1, c2 -> c1.source_order.compareTo(c2.source_order) } - false -> { c1, c2 -> c2.source_order.compareTo(c1.source_order) } - } - Manga.SORTING_NUMBER -> when (sortDescending()) { - true -> { c1, c2 -> c2.chapter_number.compareTo(c1.chapter_number) } - false -> { c1, c2 -> c1.chapter_number.compareTo(c2.chapter_number) } - } - else -> throw NotImplementedError("Unimplemented sorting method") - } - return observable.toSortedList(sortFunction) - } - - /** - * Called when a download for the active manga changes status. - * @param download the download whose status changed. - */ - fun onDownloadStatusChange(download: Download) { - // Assign the download to the model object. - if (download.status == Download.QUEUE) { - chapters.find { it.id == download.chapter.id }?.let { - if (it.download == null) { - it.download = download - } - } - } - - // Force UI update if downloaded filter active and download finished. - if (onlyDownloaded() && download.status == Download.DOWNLOADED) - refreshChapters() - } - - /** - * Returns the next unread chapter or null if everything is read. - */ - fun getNextUnreadChapter(): ChapterItem? { - return chapters.sortedByDescending { it.source_order }.find { !it.read } - } - - /** - * Mark the selected chapter list as read/unread. - * @param selectedChapters the list of selected chapters. - * @param read whether to mark chapters as read or unread. - */ - fun markChaptersRead(selectedChapters: List, read: Boolean) { - Observable.from(selectedChapters) - .doOnNext { chapter -> - chapter.read = read - if (!read /* --> EH */ && !preferences - .eh_preserveReadingPosition() - .getOrDefault() /* <-- EH */) { - chapter.last_page_read = 0 - } - } - .toList() - .flatMap { db.updateChaptersProgress(it).asRxObservable() } - .subscribeOn(Schedulers.io()) - .subscribe() - } - - /** - * Downloads the given list of chapters with the manager. - * @param chapters the list of chapters to download. - */ - fun downloadChapters(chapters: List) { - downloadManager.downloadChapters(manga, chapters) - } - - /** - * Bookmarks the given list of chapters. - * @param selectedChapters the list of chapters to bookmark. - */ - fun bookmarkChapters(selectedChapters: List, bookmarked: Boolean) { - Observable.from(selectedChapters) - .doOnNext { chapter -> - chapter.bookmark = bookmarked - } - .toList() - .flatMap { db.updateChaptersProgress(it).asRxObservable() } - .subscribeOn(Schedulers.io()) - .subscribe() - } - - /** - * Deletes the given list of chapter. - * @param chapters the list of chapters to delete. - */ - fun deleteChapters(chapters: List) { - Observable.just(chapters) - .doOnNext { deleteChaptersInternal(chapters) } - .doOnNext { if (onlyDownloaded()) refreshChapters() } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst({ view, _ -> - view.onChaptersDeleted() - }, ChaptersController::onChaptersDeletedError) - } - - /** - * Deletes a list of chapters from disk. This method is called in a background thread. - * @param chapters the chapters to delete. - */ - private fun deleteChaptersInternal(chapters: List) { - downloadManager.deleteChapters(chapters, manga, source) - chapters.forEach { - it.status = Download.NOT_DOWNLOADED - it.download = null - } - } - - /** - * Reverses the sorting and requests an UI update. - */ - fun revertSortOrder() { - manga.setChapterOrder(if (sortDescending()) Manga.SORT_ASC else Manga.SORT_DESC) - db.updateFlags(manga).executeAsBlocking() - refreshChapters() - } - - /** - * Sets the read filter and requests an UI update. - * @param onlyUnread whether to display only unread chapters or all chapters. - */ - fun setUnreadFilter(onlyUnread: Boolean) { - manga.readFilter = if (onlyUnread) Manga.SHOW_UNREAD else Manga.SHOW_ALL - db.updateFlags(manga).executeAsBlocking() - refreshChapters() - } - - /** - * Sets the read filter and requests an UI update. - * @param onlyRead whether to display only read chapters or all chapters. - */ - fun setReadFilter(onlyRead: Boolean) { - manga.readFilter = if (onlyRead) Manga.SHOW_READ else Manga.SHOW_ALL - db.updateFlags(manga).executeAsBlocking() - refreshChapters() - } - - /** - * Sets the download filter and requests an UI update. - * @param onlyDownloaded whether to display only downloaded chapters or all chapters. - */ - fun setDownloadedFilter(onlyDownloaded: Boolean) { - manga.downloadedFilter = if (onlyDownloaded) Manga.SHOW_DOWNLOADED else Manga.SHOW_ALL - db.updateFlags(manga).executeAsBlocking() - refreshChapters() - } - - /** - * Sets the bookmark filter and requests an UI update. - * @param onlyBookmarked whether to display only bookmarked chapters or all chapters. - */ - fun setBookmarkedFilter(onlyBookmarked: Boolean) { - manga.bookmarkedFilter = if (onlyBookmarked) Manga.SHOW_BOOKMARKED else Manga.SHOW_ALL - db.updateFlags(manga).executeAsBlocking() - refreshChapters() - } - - /** - * Removes all filters and requests an UI update. - */ - fun removeFilters() { - manga.readFilter = Manga.SHOW_ALL - manga.downloadedFilter = Manga.SHOW_ALL - manga.bookmarkedFilter = Manga.SHOW_ALL - db.updateFlags(manga).executeAsBlocking() - refreshChapters() - } - - /** - * Adds manga to library - */ - fun addToLibrary() { - mangaFavoriteRelay.call(true) - } - - /** - * Sets the active display mode. - * @param mode the mode to set. - */ - fun setDisplayMode(mode: Int) { - manga.displayMode = mode - db.updateFlags(manga).executeAsBlocking() - } - - /** - * Sets the sorting method and requests an UI update. - * @param sort the sorting mode. - */ - fun setSorting(sort: Int) { - manga.sorting = sort - db.updateFlags(manga).executeAsBlocking() - refreshChapters() - } - - /** - * Whether the display only downloaded filter is enabled. - */ - fun onlyDownloaded(): Boolean { - return manga.downloadedFilter == Manga.SHOW_DOWNLOADED - } - - /** - * Whether the display only downloaded filter is enabled. - */ - fun onlyBookmarked(): Boolean { - return manga.bookmarkedFilter == Manga.SHOW_BOOKMARKED - } - - /** - * Whether the display only unread filter is enabled. - */ - fun onlyUnread(): Boolean { - return manga.readFilter == Manga.SHOW_UNREAD - } - - /** - * Whether the display only read filter is enabled. - */ - fun onlyRead(): Boolean { - return manga.readFilter == Manga.SHOW_READ - } - - /** - * Whether the sorting method is descending or ascending. - */ - fun sortDescending(): Boolean { - return manga.sortDescending() - } -} +package eu.kanade.tachiyomi.ui.manga.chapter + +import android.os.Bundle +import com.jakewharton.rxrelay.BehaviorRelay +import com.jakewharton.rxrelay.PublishRelay +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.download.DownloadManager +import eu.kanade.tachiyomi.data.download.model.Download +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource +import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed +import java.util.Date +import rx.Observable +import rx.Subscription +import rx.android.schedulers.AndroidSchedulers +import rx.schedulers.Schedulers +import timber.log.Timber +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy + +/** + * Presenter of [ChaptersController]. + */ +class ChaptersPresenter( + val manga: Manga, + val source: Source, + private val chapterCountRelay: BehaviorRelay, + private val lastUpdateRelay: BehaviorRelay, + private val mangaFavoriteRelay: PublishRelay, + val preferences: PreferencesHelper = Injekt.get(), + private val db: DatabaseHelper = Injekt.get(), + private val downloadManager: DownloadManager = Injekt.get() +) : BasePresenter() { + + /** + * List of chapters of the manga. It's always unfiltered and unsorted. + */ + var chapters: List = emptyList() + private set + + /** + * Subject of list of chapters to allow updating the view without going to DB. + */ + val chaptersRelay: PublishRelay> + by lazy { PublishRelay.create>() } + + /** + * Whether the chapter list has been requested to the source. + */ + var hasRequested = false + private set + + /** + * Subscription to retrieve the new list of chapters from the source. + */ + private var fetchChaptersSubscription: Subscription? = null + + /** + * Subscription to observe download status changes. + */ + private var observeDownloadsSubscription: Subscription? = null + + // EXH --> + private val updateHelper: EHentaiUpdateHelper by injectLazy() + + val redirectUserRelay = BehaviorRelay.create() + + data class EXHRedirect(val manga: Manga, val update: Boolean) + // EXH <-- + + override fun onCreate(savedState: Bundle?) { + super.onCreate(savedState) + + // Prepare the relay. + chaptersRelay.flatMap { applyChapterFilters(it) } + .observeOn(AndroidSchedulers.mainThread()) + .subscribeLatestCache(ChaptersController::onNextChapters) { _, error -> Timber.e(error) } + + // Add the subscription that retrieves the chapters from the database, keeps subscribed to + // changes, and sends the list of chapters to the relay. + add( + db.getChapters(manga).asRxObservable() + .map { chapters -> + // Convert every chapter to a model. + chapters.map { it.toModel() } + } + .doOnNext { chapters -> + // Find downloaded chapters + setDownloadedChapters(chapters) + + // Store the last emission + this.chapters = chapters + + // Listen for download status changes + observeDownloads() + + // Emit the number of chapters to the info tab. + chapterCountRelay.call( + chapters.maxBy { it.chapter_number }?.chapter_number + ?: 0f + ) + + // Emit the upload date of the most recent chapter + lastUpdateRelay.call( + Date( + chapters.maxBy { it.date_upload }?.date_upload + ?: 0 + ) + ) + // EXH --> + if(chapters.isNotEmpty() + && (source.id == EXH_SOURCE_ID || source.id == EH_SOURCE_ID) + && DebugToggles.ENABLE_EXH_ROOT_REDIRECT.enabled) { + // Check for gallery in library and accept manga with lowest id + // Find chapters sharing same root + add(updateHelper.findAcceptedRootAndDiscardOthers(manga.source, chapters) + .subscribeOn(Schedulers.io()) + .subscribe { (acceptedChain, _) -> + // Redirect if we are not the accepted root + if(manga.id != acceptedChain.manga.id) { + // Update if any of our chapters are not in accepted manga's chapters + val ourChapterUrls = chapters.map { it.url }.toSet() + val acceptedChapterUrls = acceptedChain.chapters.map { it.url }.toSet() + val update = (ourChapterUrls - acceptedChapterUrls).isNotEmpty() + redirectUserRelay.call(EXHRedirect(acceptedChain.manga, update)) + } + }) + } + // EXH <-- + } + } + .subscribe { chaptersRelay.call(it) } + ) + } + + private fun observeDownloads() { + observeDownloadsSubscription?.let { remove(it) } + observeDownloadsSubscription = downloadManager.queue.getStatusObservable() + .observeOn(AndroidSchedulers.mainThread()) + .filter { download -> download.manga.id == manga.id } + .doOnNext { onDownloadStatusChange(it) } + .subscribeLatestCache(ChaptersController::onChapterStatusChange) { _, error -> + Timber.e(error) + } + } + + /** + * Converts a chapter from the database to an extended model, allowing to store new fields. + */ + private fun Chapter.toModel(): ChapterItem { + // Create the model object. + val model = ChapterItem(this, manga) + + // Find an active download for this chapter. + val download = downloadManager.queue.find { it.chapter.id == id } + + if (download != null) { + // If there's an active download, assign it. + model.download = download + } + return model + } + + /** + * Finds and assigns the list of downloaded chapters. + * + * @param chapters the list of chapter from the database. + */ + private fun setDownloadedChapters(chapters: List) { + for (chapter in chapters) { + if (downloadManager.isChapterDownloaded(chapter, manga)) { + chapter.status = Download.DOWNLOADED + } + } + } + + /** + * Requests an updated list of chapters from the source. + */ + fun fetchChaptersFromSource() { + hasRequested = true + + if (!fetchChaptersSubscription.isNullOrUnsubscribed()) return + fetchChaptersSubscription = Observable.defer { source.fetchChapterList(manga) } + .subscribeOn(Schedulers.io()) + .map { syncChaptersWithSource(db, it, manga, source) } + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst( + { view, _ -> + view.onFetchChaptersDone() + }, + ChaptersController::onFetchChaptersError + ) + } + + /** + * Updates the UI after applying the filters. + */ + private fun refreshChapters() { + chaptersRelay.call(chapters) + } + + /** + * Applies the view filters to the list of chapters obtained from the database. + * @param chapters the list of chapters from the database + * @return an observable of the list of chapters filtered and sorted. + */ + private fun applyChapterFilters(chapters: List): Observable> { + var observable = Observable.from(chapters).subscribeOn(Schedulers.io()) + if (onlyUnread()) { + observable = observable.filter { !it.read } + } else if (onlyRead()) { + observable = observable.filter { it.read } + } + if (onlyDownloaded()) { + observable = observable.filter { it.isDownloaded || it.manga.source == LocalSource.ID } + } + if (onlyBookmarked()) { + observable = observable.filter { it.bookmark } + } + val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) { + Manga.SORTING_SOURCE -> when (sortDescending()) { + true -> { c1, c2 -> c1.source_order.compareTo(c2.source_order) } + false -> { c1, c2 -> c2.source_order.compareTo(c1.source_order) } + } + Manga.SORTING_NUMBER -> when (sortDescending()) { + true -> { c1, c2 -> c2.chapter_number.compareTo(c1.chapter_number) } + false -> { c1, c2 -> c1.chapter_number.compareTo(c2.chapter_number) } + } + else -> throw NotImplementedError("Unimplemented sorting method") + } + return observable.toSortedList(sortFunction) + } + + /** + * Called when a download for the active manga changes status. + * @param download the download whose status changed. + */ + fun onDownloadStatusChange(download: Download) { + // Assign the download to the model object. + if (download.status == Download.QUEUE) { + chapters.find { it.id == download.chapter.id }?.let { + if (it.download == null) { + it.download = download + } + } + } + + // Force UI update if downloaded filter active and download finished. + if (onlyDownloaded() && download.status == Download.DOWNLOADED) { + refreshChapters() + } + } + + /** + * Returns the next unread chapter or null if everything is read. + */ + fun getNextUnreadChapter(): ChapterItem? { + return chapters.sortedByDescending { it.source_order }.find { !it.read } + } + + /** + * Mark the selected chapter list as read/unread. + * @param selectedChapters the list of selected chapters. + * @param read whether to mark chapters as read or unread. + */ + fun markChaptersRead(selectedChapters: List, read: Boolean) { + Observable.from(selectedChapters) + .doOnNext { chapter -> + chapter.read = read + if (!read /* --> EH */ && !preferences + .eh_preserveReadingPosition() + .getOrDefault() /* <-- EH */) { + chapter.last_page_read = 0 + } + } + .toList() + .flatMap { db.updateChaptersProgress(it).asRxObservable() } + .subscribeOn(Schedulers.io()) + .subscribe() + } + + /** + * Downloads the given list of chapters with the manager. + * @param chapters the list of chapters to download. + */ + fun downloadChapters(chapters: List) { + downloadManager.downloadChapters(manga, chapters) + } + + /** + * Bookmarks the given list of chapters. + * @param selectedChapters the list of chapters to bookmark. + */ + fun bookmarkChapters(selectedChapters: List, bookmarked: Boolean) { + Observable.from(selectedChapters) + .doOnNext { chapter -> + chapter.bookmark = bookmarked + } + .toList() + .flatMap { db.updateChaptersProgress(it).asRxObservable() } + .subscribeOn(Schedulers.io()) + .subscribe() + } + + /** + * Deletes the given list of chapter. + * @param chapters the list of chapters to delete. + */ + fun deleteChapters(chapters: List) { + Observable.just(chapters) + .doOnNext { deleteChaptersInternal(chapters) } + .doOnNext { if (onlyDownloaded()) refreshChapters() } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst( + { view, _ -> + view.onChaptersDeleted(chapters) + }, + ChaptersController::onChaptersDeletedError + ) + } + + /** + * Deletes a list of chapters from disk. This method is called in a background thread. + * @param chapters the chapters to delete. + */ + private fun deleteChaptersInternal(chapters: List) { + downloadManager.deleteChapters(chapters, manga, source) + chapters.forEach { + it.status = Download.NOT_DOWNLOADED + it.download = null + } + } + + /** + * Reverses the sorting and requests an UI update. + */ + fun revertSortOrder() { + manga.setChapterOrder(if (sortDescending()) Manga.SORT_ASC else Manga.SORT_DESC) + db.updateFlags(manga).executeAsBlocking() + refreshChapters() + } + + /** + * Sets the read filter and requests an UI update. + * @param onlyUnread whether to display only unread chapters or all chapters. + */ + fun setUnreadFilter(onlyUnread: Boolean) { + manga.readFilter = if (onlyUnread) Manga.SHOW_UNREAD else Manga.SHOW_ALL + db.updateFlags(manga).executeAsBlocking() + refreshChapters() + } + + /** + * Sets the read filter and requests an UI update. + * @param onlyRead whether to display only read chapters or all chapters. + */ + fun setReadFilter(onlyRead: Boolean) { + manga.readFilter = if (onlyRead) Manga.SHOW_READ else Manga.SHOW_ALL + db.updateFlags(manga).executeAsBlocking() + refreshChapters() + } + + /** + * Sets the download filter and requests an UI update. + * @param onlyDownloaded whether to display only downloaded chapters or all chapters. + */ + fun setDownloadedFilter(onlyDownloaded: Boolean) { + manga.downloadedFilter = if (onlyDownloaded) Manga.SHOW_DOWNLOADED else Manga.SHOW_ALL + db.updateFlags(manga).executeAsBlocking() + refreshChapters() + } + + /** + * Sets the bookmark filter and requests an UI update. + * @param onlyBookmarked whether to display only bookmarked chapters or all chapters. + */ + fun setBookmarkedFilter(onlyBookmarked: Boolean) { + manga.bookmarkedFilter = if (onlyBookmarked) Manga.SHOW_BOOKMARKED else Manga.SHOW_ALL + db.updateFlags(manga).executeAsBlocking() + refreshChapters() + } + + /** + * Removes all filters and requests an UI update. + */ + fun removeFilters() { + manga.readFilter = Manga.SHOW_ALL + manga.downloadedFilter = Manga.SHOW_ALL + manga.bookmarkedFilter = Manga.SHOW_ALL + db.updateFlags(manga).executeAsBlocking() + refreshChapters() + } + + /** + * Adds manga to library + */ + fun addToLibrary() { + mangaFavoriteRelay.call(true) + } + + /** + * Sets the active display mode. + * @param mode the mode to set. + */ + fun setDisplayMode(mode: Int) { + manga.displayMode = mode + db.updateFlags(manga).executeAsBlocking() + } + + /** + * Sets the sorting method and requests an UI update. + * @param sort the sorting mode. + */ + fun setSorting(sort: Int) { + manga.sorting = sort + db.updateFlags(manga).executeAsBlocking() + refreshChapters() + } + + /** + * Whether downloaded only mode is enabled. + */ + fun forceDownloaded(): Boolean { + return manga.favorite && preferences.downloadedOnly().get() + } + + /** + * Whether the display only downloaded filter is enabled. + */ + fun onlyDownloaded(): Boolean { + return forceDownloaded() || manga.downloadedFilter == Manga.SHOW_DOWNLOADED + } + + /** + * Whether the display only downloaded filter is enabled. + */ + fun onlyBookmarked(): Boolean { + return manga.bookmarkedFilter == Manga.SHOW_BOOKMARKED + } + + /** + * Whether the display only unread filter is enabled. + */ + fun onlyUnread(): Boolean { + return manga.readFilter == Manga.SHOW_UNREAD + } + + /** + * Whether the display only read filter is enabled. + */ + fun onlyRead(): Boolean { + return manga.readFilter == Manga.SHOW_READ + } + + /** + * Whether the sorting method is descending or ascending. + */ + fun sortDescending(): Boolean { + return manga.sortDescending() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DeleteChaptersDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DeleteChaptersDialog.kt index a269fe085..5c0f95271 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DeleteChaptersDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DeleteChaptersDialog.kt @@ -1,32 +1,29 @@ -package eu.kanade.tachiyomi.ui.manga.chapter - -import android.app.Dialog -import android.os.Bundle -import com.afollestad.materialdialogs.MaterialDialog -import com.bluelinelabs.conductor.Controller -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.base.controller.DialogController - -class DeleteChaptersDialog(bundle: Bundle? = null) : DialogController(bundle) - where T : Controller, T : DeleteChaptersDialog.Listener { - - constructor(target: T) : this() { - targetController = target - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - return MaterialDialog.Builder(activity!!) - .content(R.string.confirm_delete_chapters) - .positiveText(android.R.string.yes) - .negativeText(android.R.string.no) - .onPositive { _, _ -> - (targetController as? Listener)?.deleteChapters() - } - .show() - } - - interface Listener { - fun deleteChapters() - } - -} \ No newline at end of file +package eu.kanade.tachiyomi.ui.manga.chapter + +import android.app.Dialog +import android.os.Bundle +import com.afollestad.materialdialogs.MaterialDialog +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.base.controller.DialogController + +class DeleteChaptersDialog(bundle: Bundle? = null) : DialogController(bundle) + where T : Controller, T : DeleteChaptersDialog.Listener { + + constructor(target: T) : this() { + targetController = target + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + return MaterialDialog(activity!!) + .message(R.string.confirm_delete_chapters) + .positiveButton(android.R.string.ok) { + (targetController as? Listener)?.deleteChapters() + } + .negativeButton(android.R.string.cancel) + } + + interface Listener { + fun deleteChapters() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DeletingChaptersDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DeletingChaptersDialog.kt deleted file mode 100644 index fcfd6b9ad..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DeletingChaptersDialog.kt +++ /dev/null @@ -1,27 +0,0 @@ -package eu.kanade.tachiyomi.ui.manga.chapter - -import android.app.Dialog -import android.os.Bundle -import com.afollestad.materialdialogs.MaterialDialog -import com.bluelinelabs.conductor.Router -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.base.controller.DialogController - -class DeletingChaptersDialog(bundle: Bundle? = null) : DialogController(bundle) { - - companion object { - const val TAG = "deleting_dialog" - } - - override fun onCreateDialog(savedState: Bundle?): Dialog { - return MaterialDialog.Builder(activity!!) - .progress(true, 0) - .content(R.string.deleting) - .build() - } - - override fun showDialog(router: Router) { - showDialog(router, TAG) - } - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DownloadChaptersDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DownloadChaptersDialog.kt deleted file mode 100644 index c3016841c..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DownloadChaptersDialog.kt +++ /dev/null @@ -1,42 +0,0 @@ -package eu.kanade.tachiyomi.ui.manga.chapter - -import android.app.Dialog -import android.os.Bundle -import com.afollestad.materialdialogs.MaterialDialog -import com.bluelinelabs.conductor.Controller -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.base.controller.DialogController - -class DownloadChaptersDialog(bundle: Bundle? = null) : DialogController(bundle) - where T : Controller, T : DownloadChaptersDialog.Listener { - - constructor(target: T) : this() { - targetController = target - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val activity = activity!! - - val choices = intArrayOf( - R.string.download_1, - R.string.download_5, - R.string.download_10, - R.string.download_custom, - R.string.download_unread, - R.string.download_all - ).map { activity.getString(it) } - - return MaterialDialog.Builder(activity) - .negativeText(android.R.string.cancel) - .items(choices) - .itemsCallback { _, _, position, _ -> - (targetController as? Listener)?.downloadChapters(position) - } - .build() - } - - interface Listener { - fun downloadChapters(choice: Int) - } - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DownloadCustomChaptersDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DownloadCustomChaptersDialog.kt index 22ddee7bf..37b00d3a3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DownloadCustomChaptersDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/DownloadCustomChaptersDialog.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.manga.chapter import android.app.Dialog import android.os.Bundle import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.customview.customView import com.bluelinelabs.conductor.Controller import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.base.controller.DialogController @@ -23,10 +24,12 @@ class DownloadCustomChaptersDialog : DialogController * Initialize dialog. * @param maxChapters maximal number of chapters that user can download. */ - constructor(target: T, maxChapters: Int) : super(Bundle().apply { - // Add maximum number of chapters to download value to bundle. - putInt(KEY_ITEM_MAX, maxChapters) - }) { + constructor(target: T, maxChapters: Int) : super( + Bundle().apply { + // Add maximum number of chapters to download value to bundle. + putInt(KEY_ITEM_MAX, maxChapters) + } + ) { targetController = target this.maxChapters = maxChapters } @@ -55,15 +58,13 @@ class DownloadCustomChaptersDialog : DialogController // Build dialog. // when positive dialog is pressed call custom listener. - return MaterialDialog.Builder(activity) - .title(R.string.custom_download) - .customView(view, true) - .positiveText(android.R.string.ok) - .negativeText(android.R.string.cancel) - .onPositive { _, _ -> - (targetController as? Listener)?.downloadCustomChapters(view.amount) - } - .build() + return MaterialDialog(activity) + .title(R.string.custom_download) + .customView(view = view, scrollable = true) + .positiveButton(android.R.string.ok) { + (targetController as? Listener)?.downloadCustomChapters(view.amount) + } + .negativeButton(android.R.string.cancel) } interface Listener { @@ -74,4 +75,4 @@ class DownloadCustomChaptersDialog : DialogController // Key to retrieve max chapters from bundle on process death. const val KEY_ITEM_MAX = "DownloadCustomChaptersDialog.int.maxChapters" } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/SetDisplayModeDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/SetDisplayModeDialog.kt deleted file mode 100644 index 608742b74..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/SetDisplayModeDialog.kt +++ /dev/null @@ -1,43 +0,0 @@ -package eu.kanade.tachiyomi.ui.manga.chapter - -import android.app.Dialog -import android.os.Bundle -import com.afollestad.materialdialogs.MaterialDialog -import com.bluelinelabs.conductor.Controller -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.ui.base.controller.DialogController - -class SetDisplayModeDialog(bundle: Bundle? = null) : DialogController(bundle) - where T : Controller, T : SetDisplayModeDialog.Listener { - - private val selectedIndex = args.getInt("selected", -1) - - constructor(target: T, selectedIndex: Int = -1) : this(Bundle().apply { - putInt("selected", selectedIndex) - }) { - targetController = target - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val activity = activity!! - val ids = intArrayOf(Manga.DISPLAY_NAME, Manga.DISPLAY_NUMBER) - val choices = intArrayOf(R.string.show_title, R.string.show_chapter_number) - .map { activity.getString(it) } - - return MaterialDialog.Builder(activity) - .title(R.string.action_display_mode) - .items(choices) - .itemsIds(ids) - .itemsCallbackSingleChoice(selectedIndex) { _, itemView, _, _ -> - (targetController as? Listener)?.setDisplayMode(itemView.id) - true - } - .build() - } - - interface Listener { - fun setDisplayMode(id: Int) - } - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/SetSortingDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/SetSortingDialog.kt deleted file mode 100644 index c6baca5b9..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/SetSortingDialog.kt +++ /dev/null @@ -1,43 +0,0 @@ -package eu.kanade.tachiyomi.ui.manga.chapter - -import android.app.Dialog -import android.os.Bundle -import com.afollestad.materialdialogs.MaterialDialog -import com.bluelinelabs.conductor.Controller -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.ui.base.controller.DialogController - -class SetSortingDialog(bundle: Bundle? = null) : DialogController(bundle) - where T : Controller, T : SetSortingDialog.Listener { - - private val selectedIndex = args.getInt("selected", -1) - - constructor(target: T, selectedIndex: Int = -1) : this(Bundle().apply { - putInt("selected", selectedIndex) - }) { - targetController = target - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val activity = activity!! - val ids = intArrayOf(Manga.SORTING_SOURCE, Manga.SORTING_NUMBER) - val choices = intArrayOf(R.string.sort_by_source, R.string.sort_by_number) - .map { activity.getString(it) } - - return MaterialDialog.Builder(activity) - .title(R.string.sorting_mode) - .items(choices) - .itemsIds(ids) - .itemsCallbackSingleChoice(selectedIndex) { _, itemView, _, _ -> - (targetController as? Listener)?.setSorting(itemView.id) - true - } - .build() - } - - interface Listener { - fun setSorting(id: Int) - } - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoController.kt index 7b2cecbf8..13ee93519 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoController.kt @@ -1,80 +1,74 @@ package eu.kanade.tachiyomi.ui.manga.info -import android.app.Dialog -import android.app.PendingIntent -import android.content.ClipData -import android.content.ClipboardManager import android.content.Context import android.content.Intent -import android.graphics.Bitmap -import android.graphics.drawable.Drawable -import android.os.Build -import android.os.Bundle -import android.view.* -import android.widget.Toast -import androidx.core.content.pm.ShortcutInfoCompat -import androidx.core.content.pm.ShortcutManagerCompat -import androidx.core.graphics.drawable.IconCompat -import com.afollestad.materialdialogs.MaterialDialog +import android.text.TextUtils +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat import com.bumptech.glide.load.engine.DiskCacheStrategy -import com.bumptech.glide.load.resource.bitmap.RoundedCorners -import com.bumptech.glide.request.target.SimpleTarget -import com.bumptech.glide.request.transition.Transition -import com.bumptech.glide.signature.ObjectKey -import com.elvishew.xlog.XLog -import com.google.gson.Gson -import com.jakewharton.rxbinding.support.v4.widget.refreshes -import com.jakewharton.rxbinding.view.clicks -import com.jakewharton.rxbinding.view.longClicks import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.glide.GlideApp -import eu.kanade.tachiyomi.data.notification.NotificationReceiver +import eu.kanade.tachiyomi.data.glide.toMangaThumbnail import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.databinding.MangaInfoControllerBinding import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.online.HttpSource -import eu.kanade.tachiyomi.source.online.all.MergedSource -import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.ui.base.controller.NucleusController import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction -import eu.kanade.tachiyomi.ui.catalogue.CatalogueController -import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchController +import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog +import eu.kanade.tachiyomi.ui.library.LibraryController import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.manga.MangaController -import eu.kanade.tachiyomi.util.* -import exh.EH_SOURCE_ID -import exh.EXH_SOURCE_ID -import exh.MERGED_SOURCE_ID -import exh.NHENTAI_SOURCE_ID -import exh.ui.webview.WebViewActivity -import jp.wasabeef.glide.transformations.CropSquareTransformation -import jp.wasabeef.glide.transformations.MaskTransformation -import kotlinx.android.synthetic.main.manga_info_controller.* -import kotlinx.coroutines.* -import uy.kohesive.injekt.injectLazy +import eu.kanade.tachiyomi.ui.recent.history.HistoryController +import eu.kanade.tachiyomi.ui.recent.updates.UpdatesController +import eu.kanade.tachiyomi.ui.webview.WebViewActivity +import eu.kanade.tachiyomi.util.system.copyToClipboard +import eu.kanade.tachiyomi.util.system.toast +import eu.kanade.tachiyomi.util.view.gone +import eu.kanade.tachiyomi.util.view.setChips +import eu.kanade.tachiyomi.util.view.snack +import eu.kanade.tachiyomi.util.view.visible +import eu.kanade.tachiyomi.util.view.visibleIf import java.text.DateFormat import java.text.DecimalFormat -import java.util.* -import kotlin.coroutines.CoroutineContext +import java.util.Date +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.android.view.clicks +import reactivecircus.flowbinding.android.view.longClicks +import reactivecircus.flowbinding.swiperefreshlayout.refreshes +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy /** * Fragment that shows manga information. * Uses R.layout.manga_info_controller. * UI related actions should be called from here. */ -class MangaInfoController : NucleusController(), - ChangeMangaCategoriesDialog.Listener, CoroutineScope { +class MangaInfoController(private val fromSource: Boolean = false) : + NucleusController(), + ChangeMangaCategoriesDialog.Listener { - /** - * Preferences helper. - */ private val preferences: PreferencesHelper by injectLazy() + private val dateFormat: DateFormat by lazy { + preferences.dateFormat().getOrDefault() + } + + private var initialLoad: Boolean = true + + private var thumbnailUrl: String? = null + // EXH --> private var lastMangaThumbnail: String? = null @@ -87,156 +81,156 @@ class MangaInfoController : NucleusController(), private val sourceManager: SourceManager by injectLazy() // EXH <-- - init { - setHasOptionsMenu(true) - setOptionsMenuHidden(true) - } - override fun createPresenter(): MangaInfoPresenter { val ctrl = parentController as MangaController - return MangaInfoPresenter(ctrl.manga!!, ctrl.source!!, ctrl.smartSearchConfig, - ctrl.chapterCountRelay, ctrl.lastUpdateRelay, ctrl.mangaFavoriteRelay) + return MangaInfoPresenter( + ctrl.manga!!, ctrl.source!!, + ctrl.chapterCountRelay, ctrl.lastUpdateRelay, ctrl.mangaFavoriteRelay + ) } override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.manga_info_controller, container, false) + binding = MangaInfoControllerBinding.inflate(inflater) + return binding.root } override fun onViewCreated(view: View) { super.onViewCreated(view) - // Set onclickListener to toggle favorite when FAB clicked. - fab_favorite.clicks().subscribeUntilDestroy { onFabClick() } + binding.btnFavorite.clicks() + .onEach { onFavoriteClick() } + .launchIn(scope) - // Set onLongClickListener to manage categories when FAB is clicked. - fab_favorite.longClicks().subscribeUntilDestroy{ onFabLongClick() } + if (presenter.manga.favorite && presenter.getCategories().isNotEmpty()) { + binding.btnCategories.visible() + } + binding.btnCategories.clicks() + .onEach { onCategoriesClick() } + .launchIn(scope) + + if (presenter.source is HttpSource) { + binding.btnWebview.visible() + binding.btnShare.visible() + + binding.btnWebview.clicks() + .onEach { openInWebView() } + .launchIn(scope) + binding.btnShare.clicks() + .onEach { shareManga() } + .launchIn(scope) + } // Set SwipeRefresh to refresh manga data. - swipe_refresh.refreshes().subscribeUntilDestroy { fetchMangaFromSource() } + binding.swipeRefresh.refreshes() + .onEach { fetchMangaFromSource() } + .launchIn(scope) - manga_full_title.longClicks().subscribeUntilDestroy { - copyToClipboard(view.context.getString(R.string.title), manga_full_title.text.toString()) - } - - manga_full_title.clicks().subscribeUntilDestroy { - performGlobalSearch(manga_full_title.text.toString()) - } - - manga_artist.longClicks().subscribeUntilDestroy { - copyToClipboard(manga_artist_label.text.toString(), manga_artist.text.toString()) - } - - manga_artist.clicks().subscribeUntilDestroy { - //EXH Special case E-Hentai/ExHentai to use tag based search - var text = manga_artist.text.toString() - if(isEHentaiBasedSource()) - text = wrapTag("artist", text) - performGlobalSearch(text) - } - - manga_author.longClicks().subscribeUntilDestroy { - //EXH Special case E-Hentai/ExHentai to ignore author field (unused) - if(!isEHentaiBasedSource()) - copyToClipboard(manga_author.text.toString(), manga_author.text.toString()) - } - - manga_author.clicks().subscribeUntilDestroy { - //EXH Special case E-Hentai/ExHentai to ignore author field (unused) - if(!isEHentaiBasedSource()) - performGlobalSearch(manga_author.text.toString()) - } - - manga_summary.longClicks().subscribeUntilDestroy { - copyToClipboard(view.context.getString(R.string.description), manga_summary.text.toString()) - } - - manga_genres_tags.setOnTagClickListener { tag -> - //EXH Special case E-Hentai/ExHentai to use tag based search - var text = tag - if(isEHentaiBasedSource() || presenter.source.id == NHENTAI_SOURCE_ID) { - val parsed = parseTag(text) - text = wrapTag(parsed.first, parsed.second.substringBefore('|').trim()) + binding.mangaFullTitle.longClicks() + .onEach { + activity?.copyToClipboard(view.context.getString(R.string.title), binding.mangaFullTitle.text.toString()) } - performGlobalSearch(text) - } + .launchIn(scope) - manga_cover.longClicks().subscribeUntilDestroy { - copyToClipboard(view.context.getString(R.string.title), presenter.manga.title) - } + binding.mangaFullTitle.clicks() + .onEach { + performGlobalSearch(binding.mangaFullTitle.text.toString()) + } + .launchIn(scope) + + binding.mangaArtist.longClicks() + .onEach { + activity?.copyToClipboard(binding.mangaArtistLabel.text.toString(), binding.mangaArtist.text.toString()) + } + .launchIn(scope) + + binding.mangaArtist.clicks() + .onEach { + var text = binding.mangaArtist.text.toString() + if (isEHentaiBasedSource()) { + text = wrapTag("artist", text) + } + performGlobalSearch(text) + } + .launchIn(scope) + + binding.mangaAuthor.longClicks() + .onEach { + // EXH Special case E-Hentai/ExHentai to ignore author field (unused) + if (!isEHentaiBasedSource()) { + activity?.copyToClipboard(binding.mangaAuthor.text.toString(), binding.mangaAuthor.text.toString()) + } + } + .launchIn(scope) + + binding.mangaAuthor.clicks() + .onEach { + // EXH Special case E-Hentai/ExHentai to ignore author field (unused) + if (!isEHentaiBasedSource()) { + performGlobalSearch(binding.mangaAuthor.text.toString()) + } + } + .launchIn(scope) + + binding.mangaSummary.longClicks() + .onEach { + activity?.copyToClipboard(view.context.getString(R.string.description), binding.mangaSummary.text.toString()) + } + .launchIn(scope) + + binding.mangaCover.longClicks() + .onEach { + activity?.copyToClipboard(view.context.getString(R.string.title), presenter.manga.title) + } + .launchIn(scope) // EXH --> smartSearchConfig?.let { smartSearchConfig -> - smartsearch_buttons.visible() + binding.mergeBtn.visible() - smartsearch_merge_btn.clicks().subscribeUntilDestroy { - // Init presenter here to avoid threading issues - presenter + binding.mergeBtn.clicks() + .onEach { + // Init presenter here to avoid threading issues + presenter - launch { - try { - val mergedManga = withContext(Dispatchers.IO + NonCancellable) { - presenter.smartSearchMerge(presenter.manga, smartSearchConfig.origMangaId) - } + launch { + try { + val mergedManga = withContext(Dispatchers.IO + NonCancellable) { + presenter.smartSearchMerge(presenter.manga, smartSearchConfig.origMangaId) + } - parentController?.router?.pushController(MangaController(mergedManga, - true, - update = true).withFadeTransaction()) - applicationContext?.toast("Manga merged!") - } catch(e: Exception) { - if(e is CancellationException) throw e - else { - applicationContext?.toast("Failed to merge manga: ${e.message}") + parentController?.router?.pushController( + MangaController( + mergedManga, + true, + update = true + ).withFadeTransaction() + ) + applicationContext?.toast("Manga merged!") + } catch (e: Exception) { + if (e is CancellationException) throw e + else { + applicationContext?.toast("Failed to merge manga: ${e.message}") + } } } } - } + .launchIn(scope) } // EXH <-- } - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.manga_info, menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - // EXH --> -// R.id.action_smart_search -> openSmartSearch() - // EXH <-- - R.id.action_open_in_browser -> openInBrowser() - R.id.action_open_in_web_view -> openInWebView() - R.id.action_share -> shareManga() - R.id.action_add_to_home_screen -> addToHomeScreen() - else -> return super.onOptionsItemSelected(item) - } - return true - } - - - // EXH --> - private fun openSmartSearch() { - val smartSearchConfig = CatalogueController.SmartSearchConfig(presenter.manga.title, presenter.manga.id!!) - - parentController?.router?.pushController(CatalogueController(Bundle().apply { - putParcelable(CatalogueController.SMART_SEARCH_CONFIG, smartSearchConfig) - }).withFadeTransaction()) - } - // EXH <-- - /** * Check if manga is initialized. * If true update view with manga information, * if false fetch manga information * - * @param manga manga object containing information about manga. + * @param manga manga object containing information about manga. * @param source the source of the manga. */ fun onNextManga(manga: Manga, source: Source) { if (manga.initialized) { // Update view. setMangaInfo(manga, source) - - if((parentController as MangaController).update) fetchMangaFromSource() } else { // Initialize manga. fetchMangaFromSource() @@ -252,104 +246,124 @@ class MangaInfoController : NucleusController(), private fun setMangaInfo(manga: Manga, source: Source?) { val view = view ?: return - // TODO Duplicated in MigrationProcedureAdapter - - //update full title TextView. - manga_full_title.text = if (manga.title.isBlank()) { + // update full title TextView. + binding.mangaFullTitle.text = if (manga.title.isBlank()) { view.context.getString(R.string.unknown) } else { manga.title } // Update artist TextView. - manga_artist.text = if (manga.artist.isNullOrBlank()) { + binding.mangaArtist.text = if (manga.artist.isNullOrBlank()) { view.context.getString(R.string.unknown) } else { manga.artist } // Update author TextView. - manga_author.text = if (manga.author.isNullOrBlank()) { + binding.mangaAuthor.text = if (manga.author.isNullOrBlank()) { view.context.getString(R.string.unknown) } else { manga.author } // If manga source is known update source TextView. - manga_source.text = if (source == null) { - view.context.getString(R.string.unknown) + val mangaSource = source?.toString() + with(binding.mangaSource) { // EXH --> - } else if(source.id == MERGED_SOURCE_ID) { - MergedSource.MangaConfig.readFromUrl(gson, manga.url).children.map { - sourceManager.getOrStub(it.source).toString() - }.distinct().joinToString() + if (mangaSource == null) { + text = view.context.getString(R.string.unknown) + } else if (source.id == MERGED_SOURCE_ID) { + text = MergedSource.MangaConfig.readFromUrl(gson, manga.url).children.map { + sourceManager.getOrStub(it.source).toString() + }.distinct().joinToString() + + } else { + text = mangaSource + setOnClickListener { + val sourceManager = Injekt.get() + performSearch(sourceManager.getOrStub(source.id).name) + } + } // EXH <-- - } else { - source.toString() } // EXH --> if(source?.id == MERGED_SOURCE_ID) { - manga_source_label.text = "Sources" + binding.sourceLabel.text = "Sources" } else { - manga_source_label.setText(R.string.manga_info_source_label) + binding.sourceLabel.setText(R.string.manga_info_source_label) } // EXH <-- - // Update genres list - if (manga.genre.isNullOrBlank().not()) { - manga_genres_tags.setTags(manga.genre?.split(", ")) - } - - // Update description TextView. - manga_summary.text = if (manga.description.isNullOrBlank()) { - view.context.getString(R.string.unknown) - } else { - manga.description - } - // Update status TextView. - manga_status.setText(when (manga.status) { - SManga.ONGOING -> R.string.ongoing - SManga.COMPLETED -> R.string.completed - SManga.LICENSED -> R.string.licensed - else -> R.string.unknown - }) + binding.mangaStatus.setText( + when (manga.status) { + SManga.ONGOING -> R.string.ongoing + SManga.COMPLETED -> R.string.completed + SManga.LICENSED -> R.string.licensed + else -> R.string.unknown + } + ) // Set the favorite drawable to the correct one. - setFavoriteDrawable(manga.favorite) + setFavoriteButtonState(manga.favorite) - // Set cover if it matches - val tagMatches = lastMangaThumbnail == manga.thumbnail_url - val coverLoaded = manga_cover.drawable != null - if ((!tagMatches || !coverLoaded) && !manga.thumbnail_url.isNullOrEmpty()) { - lastMangaThumbnail = manga.thumbnail_url - - val coverSig = ObjectKey(manga.thumbnail_url ?: "") + // Set cover if it wasn't already. + if (binding.mangaCover.drawable == null || manga.thumbnail_url != thumbnailUrl) { + thumbnailUrl = manga.thumbnail_url + val mangaThumbnail = manga.toMangaThumbnail() GlideApp.with(view.context) - .load(manga) - .signature(coverSig) + .load(mangaThumbnail) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .centerCrop() + .into(binding.mangaCover) + + if (binding.backdrop != null) { + GlideApp.with(view.context) + .load(mangaThumbnail) .diskCacheStrategy(DiskCacheStrategy.RESOURCE) .centerCrop() - .into(manga_cover) - - if (backdrop != null) { - GlideApp.with(view.context) - .load(manga) - .signature(coverSig) - .diskCacheStrategy(DiskCacheStrategy.RESOURCE) - .centerCrop() - .into(backdrop) + .into(binding.backdrop!!) } } - } + // Manga info section + if (manga.description.isNullOrBlank() && manga.genre.isNullOrBlank()) { + hideMangaInfo() + } else { + // Update description TextView. + binding.mangaSummary.text = if (manga.description.isNullOrBlank()) { + view.context.getString(R.string.unknown) + } else { + manga.description + } + + // Update genres list + if (!manga.genre.isNullOrBlank()) { + binding.mangaGenresTagsCompactChips.setChips(manga.getGenres(), this::performSearch) + binding.mangaGenresTagsFullChips.setChips(manga.getGenres(), this::performSearch) + } else { + binding.mangaGenresTagsWrapper.gone() + } + + // Handle showing more or less info + binding.mangaSummary.clicks() + .onEach { toggleMangaInfo(view.context) } + .launchIn(scope) override fun onDestroyView(view: View) { manga_genres_tags.setOnTagClickListener(null) super.onDestroyView(view) } + private fun hideMangaInfo() { + binding.mangaSummaryLabel.gone() + binding.mangaSummary.gone() + binding.mangaGenresTagsWrapper.gone() + binding.mangaInfoToggle.gone() + } + // EXH --> override fun onDestroy() { super.onDestroy() @@ -357,6 +371,36 @@ class MangaInfoController : NucleusController(), } // EXH <-- + private fun toggleMangaInfo(context: Context) { + val isExpanded = binding.mangaInfoToggle.text == context.getString(R.string.manga_info_collapse) + + binding.mangaInfoToggle.text = + if (isExpanded) { + context.getString(R.string.manga_info_expand) + } else { + context.getString(R.string.manga_info_collapse) + } + + with(binding.mangaSummary) { + maxLines = + if (isExpanded) { + 3 + } else { + Int.MAX_VALUE + } + + ellipsize = + if (isExpanded) { + TextUtils.TruncateAt.END + } else { + null + } + } + + binding.mangaGenresTagsCompact.visibleIf { isExpanded } + binding.mangaGenresTagsFullChips.visibleIf { !isExpanded } + } + /** * Update chapter count TextView. * @@ -364,17 +408,17 @@ class MangaInfoController : NucleusController(), */ fun setChapterCount(count: Float) { if (count > 0f) { - manga_chapters?.text = DecimalFormat("#.#").format(count) + binding.mangaChapters.text = DecimalFormat("#.#").format(count) } else { - manga_chapters?.text = resources?.getString(R.string.unknown) + binding.mangaChapters.text = resources?.getString(R.string.unknown) } } fun setLastUpdateDate(date: Date) { if (date.time != 0L) { - manga_last_update?.text = DateFormat.getDateInstance(DateFormat.SHORT).format(date) + binding.mangaLastUpdate.text = dateFormat.format(date) } else { - manga_last_update?.text = resources?.getString(R.string.unknown) + binding.mangaLastUpdate.text = resources?.getString(R.string.unknown) } } @@ -392,29 +436,8 @@ class MangaInfoController : NucleusController(), } } } - } - /** - * Open the manga in browser. - */ - private fun openInBrowser() { - val context = view?.context ?: return - val source = presenter.source as? HttpSource ?: return - - try { - // --> EH - val urlString = source.mangaDetailsRequest(presenter.manga).url.toString() - if(preferences.eh_incogWebview().getOrDefault()) { - activity?.startActivity(Intent(activity, WebViewActivity::class.java).apply { - putExtra(WebViewActivity.KEY_URL, urlString) - }) - } else { - context.openInBrowser(source.mangaDetailsRequest(presenter.manga).url.toString()) - } - // <-- EH - } catch (e: Exception) { - context.toast(e.message) - } + binding.btnCategories.visibleIf { isNowFavorite && presenter.getCategories().isNotEmpty() } } private fun openInWebView() { @@ -426,8 +449,9 @@ class MangaInfoController : NucleusController(), return } - parentController?.router?.pushController(MangaWebViewController(source.id, url) - .withFadeTransaction()) + val activity = activity ?: return + val intent = WebViewActivity.newIntent(activity, url, source.id, presenter.manga.title) + startActivity(intent) } /** @@ -450,17 +474,18 @@ class MangaInfoController : NucleusController(), } /** - * Update FAB with correct drawable. + * Update favorite button with correct drawable and text. * * @param isFavorite determines if manga is favorite or not. */ - private fun setFavoriteDrawable(isFavorite: Boolean) { + private fun setFavoriteButtonState(isFavorite: Boolean) { // Set the Favorite drawable to the correct one. // Border drawable if false, filled drawable if true. - fab_favorite?.setImageResource(if (isFavorite) - R.drawable.ic_bookmark_white_24dp - else - R.drawable.ic_add_to_library_24dp) + binding.btnFavorite.apply { + icon = ContextCompat.getDrawable(context, if (isFavorite) R.drawable.ic_favorite_24dp else R.drawable.ic_favorite_border_24dp) + text = context.getString(if (isFavorite) R.string.in_library else R.string.add_to_library) + isChecked = isFavorite + } } /** @@ -472,7 +497,6 @@ class MangaInfoController : NucleusController(), presenter.fetchMangaFromSource() } - /** * Update swipe refresh to stop showing refresh in progress spinner. */ @@ -486,14 +510,6 @@ class MangaInfoController : NucleusController(), fun onFetchMangaError(error: Throwable) { setRefreshing(false) activity?.toast(error.message) - - // [EXH] - XLog.w("> Failed to fetch manga details!", error) - XLog.w("> (source.id: %s, source.name: %s, manga.id: %s, manga.url: %s)", - presenter.source.id, - presenter.source.name, - presenter.manga.id, - presenter.manga.url) } /** @@ -502,22 +518,36 @@ class MangaInfoController : NucleusController(), * @param value whether it should be refreshing or not. */ private fun setRefreshing(value: Boolean) { - swipe_refresh?.isRefreshing = value + binding.swipeRefresh.isRefreshing = value } - /** - * Called when the fab is clicked. - */ - private fun onFabClick() { + private fun onFavoriteClick() { val manga = presenter.manga - toggleFavorite() + if (manga.favorite) { + toggleFavorite() + activity?.toast(activity?.getString(R.string.manga_removed_library)) + } else { val categories = presenter.getCategories() - val defaultCategory = categories.find { it.id == preferences.defaultCategory() } + val defaultCategoryId = preferences.defaultCategory() + val defaultCategory = categories.find { it.id == defaultCategoryId } + when { - defaultCategory != null -> presenter.moveMangaToCategory(manga, defaultCategory) - categories.size <= 1 -> // default or the one from the user - presenter.moveMangaToCategory(manga, categories.firstOrNull()) + // Default category set + defaultCategory != null -> { + toggleFavorite() + presenter.moveMangaToCategory(manga, defaultCategory) + activity?.toast(activity?.getString(R.string.manga_added_library)) + } + + // Automatic 'Default' or no categories + defaultCategoryId == 0 || categories.isEmpty() -> { + toggleFavorite() + presenter.moveMangaToCategory(manga, null) + activity?.toast(activity?.getString(R.string.manga_added_library)) + } + + // Choose a category else -> { val ids = presenter.getMangaCategoryIds(manga) val preselected = ids.mapNotNull { id -> @@ -525,140 +555,44 @@ class MangaInfoController : NucleusController(), }.toTypedArray() ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected) - .showDialog(router) + .showDialog(router) } } - activity?.toast(activity?.getString(R.string.manga_added_library)) - } else { - activity?.toast(activity?.getString(R.string.manga_removed_library)) } } - /** - * Called when the fab is long clicked. - */ - private fun onFabLongClick() { + private fun onCategoriesClick() { val manga = presenter.manga - if (!manga.favorite) { - toggleFavorite() - activity?.toast(activity?.getString(R.string.manga_added_library)) - } val categories = presenter.getCategories() - if (categories.size <= 1) { - // default or the one from the user then just add to favorite. - presenter.moveMangaToCategory(manga, categories.firstOrNull()) - } else { - val ids = presenter.getMangaCategoryIds(manga) - val preselected = ids.mapNotNull { id -> - categories.indexOfFirst { it.id == id }.takeIf { it != -1 } - }.toTypedArray() - ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected) - .showDialog(router) - } + val ids = presenter.getMangaCategoryIds(manga) + val preselected = ids.mapNotNull { id -> + categories.indexOfFirst { it.id == id }.takeIf { it != -1 } + }.toTypedArray() + + ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected) + .showDialog(router) } override fun updateCategoriesForMangas(mangas: List, categories: List) { val manga = mangas.firstOrNull() ?: return + + if (!manga.favorite) { + toggleFavorite() + activity?.toast(activity?.getString(R.string.manga_added_library)) + } + presenter.moveMangaToCategories(manga, categories) } - /** - * Add a shortcut of the manga to the home screen - */ - private fun addToHomeScreen() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // TODO are transformations really unsupported or is it just the Pixel Launcher? - createShortcutForShape() - } else { - ChooseShapeDialog(this).showDialog(router) - } - } - - /** - * Dialog to choose a shape for the icon. - */ - private class ChooseShapeDialog(bundle: Bundle? = null) : DialogController(bundle) { - - constructor(target: MangaInfoController) : this() { - targetController = target - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val modes = intArrayOf(R.string.circular_icon, - R.string.rounded_icon, - R.string.square_icon, - R.string.star_icon) - - return MaterialDialog.Builder(activity!!) - .title(R.string.icon_shape) - .negativeText(android.R.string.cancel) - .items(modes.map { activity?.getString(it) }) - .itemsCallback { _, _, i, _ -> - (targetController as? MangaInfoController)?.createShortcutForShape(i) - } - .build() - } - } - - /** - * Retrieves the bitmap of the shortcut with the requested shape and calls [createShortcut] when - * the resource is available. - * - * @param i The shape index to apply. Defaults to circle crop transformation. - */ - private fun createShortcutForShape(i: Int = 0) { - if (activity == null) return - GlideApp.with(activity!!) - .asBitmap() - .load(presenter.manga) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .apply { - when (i) { - 0 -> circleCrop() - 1 -> transform(RoundedCorners(5)) - 2 -> transform(CropSquareTransformation()) - 3 -> centerCrop().transform(MaskTransformation(R.drawable.mask_star)) - } - } - .into(object : SimpleTarget(96, 96) { - override fun onResourceReady(resource: Bitmap, transition: Transition?) { - createShortcut(resource) - } - - override fun onLoadFailed(errorDrawable: Drawable?) { - activity?.toast(R.string.icon_creation_fail) - } - }) - } - - /** - * Copies a string to clipboard - * - * @param label Label to show to the user describing the content - * @param content the actual text to copy to the board - */ - private fun copyToClipboard(label: String, content: String) { - if (content.isBlank()) return - - val activity = activity ?: return - val view = view ?: return - - val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.primaryClip = ClipData.newPlainText(label, content) - - activity.toast(view.context.getString(R.string.copied_to_clipboard, content.truncateCenter(20)), - Toast.LENGTH_SHORT) - } - /** * Perform a global search using the provided query. * * @param query the search query to pass to the search controller */ - fun performGlobalSearch(query: String) { + private fun performGlobalSearch(query: String) { val router = parentController?.router ?: return - router.pushController(CatalogueSearchController(query).withFadeTransaction()) + router.pushController(GlobalSearchController(query).withFadeTransaction()) } // --> EH @@ -678,46 +612,34 @@ class MangaInfoController : NucleusController(), // <-- EH /** - * Create shortcut using ShortcutManager. + * Perform a search using the provided query. * - * @param icon The image of the shortcut. + * @param query the search query to the parent controller */ - private fun createShortcut(icon: Bitmap) { - val activity = activity ?: return - val mangaControllerArgs = parentController?.args ?: return + private fun performSearch(query: String) { + val router = parentController?.router ?: return - // Create the shortcut intent. - val shortcutIntent = activity.intent - .setAction(MainActivity.SHORTCUT_MANGA) - .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - .putExtra(MangaController.MANGA_EXTRA, - mangaControllerArgs.getLong(MangaController.MANGA_EXTRA)) + if (router.backstackSize < 2) { + return + } - // Check if shortcut placement is supported - if (ShortcutManagerCompat.isRequestPinShortcutSupported(activity)) { - val shortcutId = "manga-shortcut-${presenter.manga.title}-${presenter.source.name}" - - // Create shortcut info - val shortcutInfo = ShortcutInfoCompat.Builder(activity, shortcutId) - .setShortLabel(presenter.manga.title) - .setIcon(IconCompat.createWithBitmap(icon)) - .setIntent(shortcutIntent) - .build() - - val successCallback = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // Create the CallbackIntent. - val intent = ShortcutManagerCompat.createShortcutResultIntent(activity, shortcutInfo) - - // Configure the intent so that the broadcast receiver gets the callback successfully. - PendingIntent.getBroadcast(activity, 0, intent, 0) - } else { - NotificationReceiver.shortcutCreatedBroadcast(activity) + when (val previousController = router.backstack[router.backstackSize - 2].controller()) { + is LibraryController -> { + router.handleBack() + previousController.search(query) + } + is UpdatesController, + is HistoryController -> { + // Manually navigate to LibraryController + router.handleBack() + (router.activity as MainActivity).setSelectedNavItem(R.id.nav_library) + val controller = router.getControllerWithTag(R.id.nav_library.toString()) as LibraryController + controller.search(query) + } + is BrowseSourceController -> { + router.handleBack() + previousController.searchWithQuery(query) } - - // Request shortcut. - ShortcutManagerCompat.requestPinShortcut(activity, shortcutInfo, - successCallback.intentSender) } } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoPresenter.kt index 4d4d74f43..e4127cdde 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoPresenter.kt @@ -1,7 +1,6 @@ package eu.kanade.tachiyomi.ui.manga.info import android.os.Bundle -import com.google.gson.Gson import com.jakewharton.rxrelay.BehaviorRelay import com.jakewharton.rxrelay.PublishRelay import eu.kanade.tachiyomi.data.cache.CoverCache @@ -11,12 +10,11 @@ import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.MangaCategory import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.source.Source -import eu.kanade.tachiyomi.source.online.all.MergedSource import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter -import eu.kanade.tachiyomi.ui.catalogue.CatalogueController -import eu.kanade.tachiyomi.util.isNullOrUnsubscribed +import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed import exh.MERGED_SOURCE_ID import exh.util.await +import java.util.Date import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext import rx.Observable @@ -25,7 +23,6 @@ import rx.android.schedulers.AndroidSchedulers import rx.schedulers.Schedulers import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.util.* /** * Presenter of MangaInfoFragment. @@ -33,16 +30,16 @@ import java.util.* * Observable updates should be called from here. */ class MangaInfoPresenter( - val manga: Manga, - val source: Source, - val smartSearchConfig: CatalogueController.SmartSearchConfig?, - private val chapterCountRelay: BehaviorRelay, - private val lastUpdateRelay: BehaviorRelay, - private val mangaFavoriteRelay: PublishRelay, - private val db: DatabaseHelper = Injekt.get(), - private val downloadManager: DownloadManager = Injekt.get(), - private val coverCache: CoverCache = Injekt.get(), - private val gson: Gson = Injekt.get() + val manga: Manga, + val source: Source, + val smartSearchConfig: CatalogueController.SmartSearchConfig?, + private val chapterCountRelay: BehaviorRelay, + private val lastUpdateRelay: BehaviorRelay, + private val mangaFavoriteRelay: PublishRelay, + private val db: DatabaseHelper = Injekt.get(), + private val downloadManager: DownloadManager = Injekt.get(), + private val coverCache: CoverCache = Injekt.get(), + private val gson: Gson = Injekt.get() ) : BasePresenter() { /** @@ -61,16 +58,16 @@ class MangaInfoPresenter( // Update chapter count chapterCountRelay.observeOn(AndroidSchedulers.mainThread()) - .subscribeLatestCache(MangaInfoController::setChapterCount) + .subscribeLatestCache(MangaInfoController::setChapterCount) // Update favorite status mangaFavoriteRelay.observeOn(AndroidSchedulers.mainThread()) - .subscribe { setFavorite(it) } - .apply { add(this) } + .subscribe { setFavorite(it) } + .apply { add(this) } - //update last update date + // update last update date lastUpdateRelay.observeOn(AndroidSchedulers.mainThread()) - .subscribeLatestCache(MangaInfoController::setLastUpdateDate) + .subscribeLatestCache(MangaInfoController::setLastUpdateDate) } /** @@ -79,7 +76,7 @@ class MangaInfoPresenter( fun sendMangaToView() { viewMangaSubscription?.let { remove(it) } viewMangaSubscription = Observable.just(manga) - .subscribeLatestCache({ view, manga -> view.onNextManga(manga, source) }) + .subscribeLatestCache({ view, manga -> view.onNextManga(manga, source) }) } /** @@ -88,18 +85,21 @@ class MangaInfoPresenter( fun fetchMangaFromSource() { if (!fetchMangaSubscription.isNullOrUnsubscribed()) return fetchMangaSubscription = Observable.defer { source.fetchMangaDetails(manga) } - .map { networkManga -> - manga.copyFrom(networkManga) - manga.initialized = true - db.insertManga(manga).executeAsBlocking() - manga - } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext { sendMangaToView() } - .subscribeFirst({ view, _ -> + .map { networkManga -> + manga.copyFrom(networkManga) + manga.initialized = true + db.insertManga(manga).executeAsBlocking() + manga + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { sendMangaToView() } + .subscribeFirst( + { view, _ -> view.onFetchMangaDone() - }, MangaInfoController::onFetchMangaError) + }, + MangaInfoController::onFetchMangaError + ) } /** @@ -139,9 +139,9 @@ class MangaInfoPresenter( } /** - * Get the default, and user categories. + * Get user categories. * - * @return List of categories, default plus user categories + * @return List of categories, not including the default category */ fun getCategories(): List { return db.getCategories().executeAsBlocking() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaWebViewController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaWebViewController.kt deleted file mode 100644 index 2ae3667e8..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaWebViewController.kt +++ /dev/null @@ -1,94 +0,0 @@ -package eu.kanade.tachiyomi.ui.manga.info - -import android.os.Bundle -import android.view.* -import android.webkit.WebView -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.source.SourceManager -import eu.kanade.tachiyomi.source.online.HttpSource -import eu.kanade.tachiyomi.ui.base.controller.BaseController -import eu.kanade.tachiyomi.util.WebViewClientCompat -import uy.kohesive.injekt.injectLazy - -class MangaWebViewController(bundle: Bundle? = null) : BaseController(bundle) { - - private val sourceManager by injectLazy() - - init { - setHasOptionsMenu(true) - } - - constructor(sourceId: Long, url: String) : this(Bundle().apply { - putLong(SOURCE_KEY, sourceId) - putString(URL_KEY, url) - }) - - override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.manga_info_web_controller, container, false) - } - - override fun onViewCreated(view: View) { - super.onViewCreated(view) - val source = sourceManager.get(args.getLong(SOURCE_KEY)) as? HttpSource ?: return - val url = args.getString(URL_KEY) ?: return - val headers = source.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" } - - val web = view as WebView - web.webViewClient = object : WebViewClientCompat() { - override fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean { - view.loadUrl(url) - return true - } - } - web.settings.javaScriptEnabled = true - web.settings.userAgentString = source.headers["User-Agent"] - web.loadUrl(url, headers) - } - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.web_view, menu) - } - - override fun onPrepareOptionsMenu(menu: Menu) { - val web = view as WebView - menu.findItem(R.id.action_forward).isVisible = web.canGoForward() - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_forward -> { - val web = view as WebView - if (web.canGoForward()) web.goForward() - } - R.id.action_refresh -> { - val web = view as WebView - web.reload() - } - R.id.action_close -> router.popController(this) - else -> return super.onOptionsItemSelected(item) - } - return true - } - - override fun handleBack(): Boolean { - val web = view as WebView - if (web.canGoBack()) { - web.goBack() - return true - } - return super.handleBack() - } - - override fun onDestroyView(view: View) { - val web = view as WebView - web.stopLoading() - web.destroy() - super.onDestroyView(view) - } - - private companion object { - const val SOURCE_KEY = "source_key" - const val URL_KEY = "url_key" - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackChaptersDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackChaptersDialog.kt index 249d96562..311bab862 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackChaptersDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackChaptersDialog.kt @@ -1,74 +1,77 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import android.app.Dialog -import android.os.Bundle -import android.widget.NumberPicker -import com.afollestad.materialdialogs.MaterialDialog -import com.bluelinelabs.conductor.Controller -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.track.TrackManager -import eu.kanade.tachiyomi.ui.base.controller.DialogController -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get - -class SetTrackChaptersDialog : DialogController - where T : Controller, T : SetTrackChaptersDialog.Listener { - - private val item: TrackItem - - constructor(target: T, item: TrackItem) : super(Bundle().apply { - putSerializable(KEY_ITEM_TRACK, item.track) - }) { - targetController = target - this.item = item - } - - @Suppress("unused") - constructor(bundle: Bundle) : super(bundle) { - val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track - val service = Injekt.get().getService(track.sync_id)!! - item = TrackItem(track, service) - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val item = item - - val dialog = MaterialDialog.Builder(activity!!) - .title(R.string.chapters) - .customView(R.layout.track_chapters_dialog, false) - .positiveText(android.R.string.ok) - .negativeText(android.R.string.cancel) - .onPositive { dialog, _ -> - val view = dialog.customView - if (view != null) { - // Remove focus to update selected number - val np: NumberPicker = view.findViewById(R.id.chapters_picker) - np.clearFocus() - - (targetController as? Listener)?.setChaptersRead(item, np.value) - } - } - .build() - - val view = dialog.customView - if (view != null) { - val np: NumberPicker = view.findViewById(R.id.chapters_picker) - // Set initial value - np.value = item.track?.last_chapter_read ?: 0 - // Don't allow to go from 0 to 9999 - np.wrapSelectorWheel = false - } - - return dialog - } - - interface Listener { - fun setChaptersRead(item: TrackItem, chaptersRead: Int) - } - - private companion object { - const val KEY_ITEM_TRACK = "SetTrackChaptersDialog.item.track" - } - -} \ No newline at end of file +package eu.kanade.tachiyomi.ui.manga.track + +import android.app.Dialog +import android.os.Bundle +import android.widget.NumberPicker +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.customview.customView +import com.afollestad.materialdialogs.customview.getCustomView +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class SetTrackChaptersDialog : DialogController + where T : Controller, T : SetTrackChaptersDialog.Listener { + + private val item: TrackItem + + constructor(target: T, item: TrackItem) : super( + Bundle().apply { + putSerializable(KEY_ITEM_TRACK, item.track) + } + ) { + targetController = target + this.item = item + } + + @Suppress("unused") + constructor(bundle: Bundle) : super(bundle) { + val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track + val service = Injekt.get().getService(track.sync_id)!! + item = TrackItem(track, service) + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val item = item + + val dialog = MaterialDialog(activity!!) + .title(R.string.chapters) + .customView(R.layout.track_chapters_dialog, dialogWrapContent = false) + .positiveButton(android.R.string.ok) { dialog -> + val view = dialog.getCustomView() + // Remove focus to update selected number + val np: NumberPicker = view.findViewById(R.id.chapters_picker) + np.clearFocus() + + (targetController as? Listener)?.setChaptersRead(item, np.value) + } + .negativeButton(android.R.string.cancel) + + val view = dialog.getCustomView() + val np: NumberPicker = view.findViewById(R.id.chapters_picker) + // Set initial value + np.value = item.track?.last_chapter_read ?: 0 + + // Enforce maximum value if tracker has total number of chapters set + if (item.track != null && item.track.total_chapters > 0) { + np.maxValue = item.track.total_chapters + } + + // Don't allow to go from 0 to 9999 + np.wrapSelectorWheel = false + + return dialog + } + + interface Listener { + fun setChaptersRead(item: TrackItem, chaptersRead: Int) + } + + private companion object { + const val KEY_ITEM_TRACK = "SetTrackChaptersDialog.item.track" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackReadingDatesDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackReadingDatesDialog.kt new file mode 100644 index 000000000..00aed713b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackReadingDatesDialog.kt @@ -0,0 +1,86 @@ +package eu.kanade.tachiyomi.ui.manga.track + +import android.app.Dialog +import android.os.Bundle +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.datetime.datePicker +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import java.util.Calendar +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class SetTrackReadingDatesDialog : DialogController + where T : Controller, T : SetTrackReadingDatesDialog.Listener { + + private val item: TrackItem + + private val dateToUpdate: ReadingDate + + constructor(target: T, dateToUpdate: ReadingDate, item: TrackItem) : super( + Bundle().apply { + putSerializable(SetTrackReadingDatesDialog.KEY_ITEM_TRACK, item.track) + } + ) { + targetController = target + this.item = item + this.dateToUpdate = dateToUpdate + } + + @Suppress("unused") + constructor(bundle: Bundle) : super(bundle) { + val track = bundle.getSerializable(SetTrackReadingDatesDialog.KEY_ITEM_TRACK) as Track + val service = Injekt.get().getService(track.sync_id)!! + item = TrackItem(track, service) + dateToUpdate = ReadingDate.Start + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val listener = (targetController as? Listener) + + return MaterialDialog(activity!!) + .title( + when (dateToUpdate) { + ReadingDate.Start -> R.string.track_started_reading_date + ReadingDate.Finish -> R.string.track_finished_reading_date + } + ) + .datePicker(currentDate = getCurrentDate()) { _, date -> + listener?.setReadingDate(item, dateToUpdate, date.timeInMillis) + } + .neutralButton(R.string.action_remove) { + listener?.setReadingDate(item, dateToUpdate, 0L) + } + } + + private fun getCurrentDate(): Calendar { + // Today if no date is set, otherwise the already set date + return Calendar.getInstance().apply { + item.track?.let { + val date = when (dateToUpdate) { + ReadingDate.Start -> it.started_reading_date + ReadingDate.Finish -> it.finished_reading_date + } + if (date != 0L) { + timeInMillis = date + } + } + } + } + + interface Listener { + fun setReadingDate(item: TrackItem, type: ReadingDate, date: Long) + } + + enum class ReadingDate { + Start, + Finish + } + + companion object { + private const val KEY_ITEM_TRACK = "SetTrackReadingDatesDialog.item.track" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackScoreDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackScoreDialog.kt index 44734f64b..266b49f9f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackScoreDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackScoreDialog.kt @@ -1,80 +1,77 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import android.app.Dialog -import android.os.Bundle -import android.widget.NumberPicker -import com.afollestad.materialdialogs.MaterialDialog -import com.bluelinelabs.conductor.Controller -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.track.TrackManager -import eu.kanade.tachiyomi.ui.base.controller.DialogController -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get - -class SetTrackScoreDialog : DialogController - where T : Controller, T : SetTrackScoreDialog.Listener { - - private val item: TrackItem - - constructor(target: T, item: TrackItem) : super(Bundle().apply { - putSerializable(KEY_ITEM_TRACK, item.track) - }) { - targetController = target - this.item = item - } - - @Suppress("unused") - constructor(bundle: Bundle) : super(bundle) { - val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track - val service = Injekt.get().getService(track.sync_id)!! - item = TrackItem(track, service) - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val item = item - - val dialog = MaterialDialog.Builder(activity!!) - .title(R.string.score) - .customView(R.layout.track_score_dialog, false) - .positiveText(android.R.string.ok) - .negativeText(android.R.string.cancel) - .onPositive { dialog, _ -> - val view = dialog.customView - if (view != null) { - // Remove focus to update selected number - val np: NumberPicker = view.findViewById(R.id.score_picker) - np.clearFocus() - - (targetController as? Listener)?.setScore(item, np.value) - } - } - .show() - - val view = dialog.customView - if (view != null) { - val np: NumberPicker = view.findViewById(R.id.score_picker) - val scores = item.service.getScoreList().toTypedArray() - np.maxValue = scores.size - 1 - np.displayedValues = scores - - // Set initial value - val displayedScore = item.service.displayScore(item.track!!) - if (displayedScore != "-") { - val index = scores.indexOf(displayedScore) - np.value = if (index != -1) index else 0 - } - } - - return dialog - } - - interface Listener { - fun setScore(item: TrackItem, score: Int) - } - - private companion object { - const val KEY_ITEM_TRACK = "SetTrackScoreDialog.item.track" - } - -} \ No newline at end of file +package eu.kanade.tachiyomi.ui.manga.track + +import android.app.Dialog +import android.os.Bundle +import android.widget.NumberPicker +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.customview.customView +import com.afollestad.materialdialogs.customview.getCustomView +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class SetTrackScoreDialog : DialogController + where T : Controller, T : SetTrackScoreDialog.Listener { + + private val item: TrackItem + + constructor(target: T, item: TrackItem) : super( + Bundle().apply { + putSerializable(KEY_ITEM_TRACK, item.track) + } + ) { + targetController = target + this.item = item + } + + @Suppress("unused") + constructor(bundle: Bundle) : super(bundle) { + val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track + val service = Injekt.get().getService(track.sync_id)!! + item = TrackItem(track, service) + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val item = item + + val dialog = MaterialDialog(activity!!) + .title(R.string.score) + .customView(R.layout.track_score_dialog, dialogWrapContent = false) + .positiveButton(android.R.string.ok) { dialog -> + val view = dialog.getCustomView() + // Remove focus to update selected number + val np: NumberPicker = view.findViewById(R.id.score_picker) + np.clearFocus() + + (targetController as? Listener)?.setScore(item, np.value) + } + .negativeButton(android.R.string.cancel) + + val view = dialog.getCustomView() + val np: NumberPicker = view.findViewById(R.id.score_picker) + val scores = item.service.getScoreList().toTypedArray() + np.maxValue = scores.size - 1 + np.displayedValues = scores + + // Set initial value + val displayedScore = item.service.displayScore(item.track!!) + if (displayedScore != "-") { + val index = scores.indexOf(displayedScore) + np.value = if (index != -1) index else 0 + } + + return dialog + } + + interface Listener { + fun setScore(item: TrackItem, score: Int) + } + + private companion object { + const val KEY_ITEM_TRACK = "SetTrackScoreDialog.item.track" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackStatusDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackStatusDialog.kt index 6ad057951..a7116362e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackStatusDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackStatusDialog.kt @@ -1,58 +1,62 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import android.app.Dialog -import android.os.Bundle -import com.afollestad.materialdialogs.MaterialDialog -import com.bluelinelabs.conductor.Controller -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.track.TrackManager -import eu.kanade.tachiyomi.ui.base.controller.DialogController -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get - -class SetTrackStatusDialog : DialogController - where T : Controller, T : SetTrackStatusDialog.Listener { - - private val item: TrackItem - - constructor(target: T, item: TrackItem) : super(Bundle().apply { - putSerializable(KEY_ITEM_TRACK, item.track) - }) { - targetController = target - this.item = item - } - - @Suppress("unused") - constructor(bundle: Bundle) : super(bundle) { - val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track - val service = Injekt.get().getService(track.sync_id)!! - item = TrackItem(track, service) - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val item = item - val statusList = item.service.getStatusList().orEmpty() - val statusString = statusList.mapNotNull { item.service.getStatus(it) } - val selectedIndex = statusList.indexOf(item.track?.status) - - return MaterialDialog.Builder(activity!!) - .title(R.string.status) - .negativeText(android.R.string.cancel) - .items(statusString) - .itemsCallbackSingleChoice(selectedIndex, { _, _, i, _ -> - (targetController as? Listener)?.setStatus(item, i) - true - }) - .build() - } - - interface Listener { - fun setStatus(item: TrackItem, selection: Int) - } - - private companion object { - const val KEY_ITEM_TRACK = "SetTrackStatusDialog.item.track" - } - -} \ No newline at end of file +package eu.kanade.tachiyomi.ui.manga.track + +import android.app.Dialog +import android.os.Bundle +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.list.listItemsSingleChoice +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class SetTrackStatusDialog : DialogController + where T : Controller, T : SetTrackStatusDialog.Listener { + + private val item: TrackItem + + constructor(target: T, item: TrackItem) : super( + Bundle().apply { + putSerializable(KEY_ITEM_TRACK, item.track) + } + ) { + targetController = target + this.item = item + } + + @Suppress("unused") + constructor(bundle: Bundle) : super(bundle) { + val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track + val service = Injekt.get().getService(track.sync_id)!! + item = TrackItem(track, service) + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val item = item + val statusList = item.service.getStatusList() + val statusString = statusList.map { item.service.getStatus(it) } + val selectedIndex = statusList.indexOf(item.track?.status) + + return MaterialDialog(activity!!) + .title(R.string.status) + .negativeButton(android.R.string.cancel) + .listItemsSingleChoice( + items = statusString, + initialSelection = selectedIndex, + waitForPositiveButton = false + ) { dialog, position, _ -> + (targetController as? Listener)?.setStatus(item, position) + dialog.dismiss() + } + } + + interface Listener { + fun setStatus(item: TrackItem, selection: Int) + } + + private companion object { + const val KEY_ITEM_TRACK = "SetTrackStatusDialog.item.track" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt index 873a37a7e..9f25a46b1 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt @@ -1,10 +1,11 @@ package eu.kanade.tachiyomi.ui.manga.track import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.util.inflate +import eu.kanade.tachiyomi.util.view.inflate -class TrackAdapter(controller: TrackController) : androidx.recyclerview.widget.RecyclerView.Adapter() { +class TrackAdapter(controller: TrackController) : RecyclerView.Adapter() { var items = emptyList() set(value) { @@ -35,10 +36,11 @@ class TrackAdapter(controller: TrackController) : androidx.recyclerview.widget.R interface OnClickListener { fun onLogoClick(position: Int) - fun onTitleClick(position: Int) + fun onSetClick(position: Int) fun onStatusClick(position: Int) fun onChaptersClick(position: Int) fun onScoreClick(position: Int) + fun onStartDateClick(position: Int) + fun onFinishDateClick(position: Int) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackController.kt index 19d8bc1d6..d7e0824c6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackController.kt @@ -5,20 +5,25 @@ import android.net.Uri import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import com.jakewharton.rxbinding.support.v4.widget.refreshes +import androidx.recyclerview.widget.LinearLayoutManager import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.databinding.TrackControllerBinding import eu.kanade.tachiyomi.ui.base.controller.NucleusController import eu.kanade.tachiyomi.ui.manga.MangaController -import eu.kanade.tachiyomi.util.toast -import kotlinx.android.synthetic.main.track_controller.* +import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.swiperefreshlayout.refreshes import timber.log.Timber -class TrackController : NucleusController(), - TrackAdapter.OnClickListener, - SetTrackStatusDialog.Listener, - SetTrackChaptersDialog.Listener, - SetTrackScoreDialog.Listener { +class TrackController : + NucleusController(), + TrackAdapter.OnClickListener, + SetTrackStatusDialog.Listener, + SetTrackChaptersDialog.Listener, + SetTrackScoreDialog.Listener, + SetTrackReadingDatesDialog.Listener { private var adapter: TrackAdapter? = null @@ -33,19 +38,20 @@ class TrackController : NucleusController(), } override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.track_controller, container, false) + binding = TrackControllerBinding.inflate(inflater) + return binding.root } override fun onViewCreated(view: View) { super.onViewCreated(view) adapter = TrackAdapter(this) - with(view) { - track_recycler.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(context) - track_recycler.adapter = adapter - swipe_refresh.isEnabled = false - swipe_refresh.refreshes().subscribeUntilDestroy { presenter.refresh() } - } + binding.trackRecycler.layoutManager = LinearLayoutManager(view.context) + binding.trackRecycler.adapter = adapter + binding.swipeRefresh.isEnabled = false + binding.swipeRefresh.refreshes() + .onEach { presenter.refresh() } + .launchIn(scope) } override fun onDestroyView(view: View) { @@ -56,7 +62,7 @@ class TrackController : NucleusController(), fun onNextTrackings(trackings: List) { val atLeastOneLink = trackings.any { it.track != null } adapter?.items = trackings - swipe_refresh?.isEnabled = atLeastOneLink + binding.swipeRefresh.isEnabled = atLeastOneLink (parentController as? MangaController)?.setTrackingIcon(atLeastOneLink) } @@ -75,25 +81,25 @@ class TrackController : NucleusController(), } fun onRefreshDone() { - swipe_refresh?.isRefreshing = false + binding.swipeRefresh.isRefreshing = false } fun onRefreshError(error: Throwable) { - swipe_refresh?.isRefreshing = false + binding.swipeRefresh.isRefreshing = false activity?.toast(error.message) } override fun onLogoClick(position: Int) { val track = adapter?.getItem(position)?.track ?: return - if (track.tracking_url.isNullOrBlank()) { + if (track.tracking_url.isBlank()) { activity?.toast(R.string.url_not_set) } else { activity?.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(track.tracking_url))) } } - override fun onTitleClick(position: Int) { + override fun onSetClick(position: Int) { val item = adapter?.getItem(position) ?: return TrackSearchDialog(this, item.service).showDialog(router, TAG_SEARCH_CONTROLLER) } @@ -119,23 +125,44 @@ class TrackController : NucleusController(), SetTrackScoreDialog(this, item).showDialog(router) } + override fun onStartDateClick(position: Int) { + val item = adapter?.getItem(position) ?: return + if (item.track == null) return + + SetTrackReadingDatesDialog(this, SetTrackReadingDatesDialog.ReadingDate.Start, item).showDialog(router) + } + + override fun onFinishDateClick(position: Int) { + val item = adapter?.getItem(position) ?: return + if (item.track == null) return + + SetTrackReadingDatesDialog(this, SetTrackReadingDatesDialog.ReadingDate.Finish, item).showDialog(router) + } + override fun setStatus(item: TrackItem, selection: Int) { presenter.setStatus(item, selection) - swipe_refresh?.isRefreshing = true + binding.swipeRefresh.isRefreshing = true } override fun setScore(item: TrackItem, score: Int) { presenter.setScore(item, score) - swipe_refresh?.isRefreshing = true + binding.swipeRefresh.isRefreshing = true } override fun setChaptersRead(item: TrackItem, chaptersRead: Int) { presenter.setLastChapterRead(item, chaptersRead) - swipe_refresh?.isRefreshing = true + binding.swipeRefresh.isRefreshing = true + } + + override fun setReadingDate(item: TrackItem, type: SetTrackReadingDatesDialog.ReadingDate, date: Long) { + when (type) { + SetTrackReadingDatesDialog.ReadingDate.Start -> presenter.setStartDate(item, date) + SetTrackReadingDatesDialog.ReadingDate.Finish -> presenter.setFinishDate(item, date) + } + binding.swipeRefresh.isRefreshing = true } private companion object { const val TAG_SEARCH_CONTROLLER = "track_search_controller" } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt index 2f018f19d..7f76b7af5 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt @@ -1,42 +1,76 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import android.annotation.SuppressLint -import android.view.View -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.base.holder.BaseViewHolder -import kotlinx.android.synthetic.main.track_item.* - -class TrackHolder(view: View, adapter: TrackAdapter) : BaseViewHolder(view) { - - init { - val listener = adapter.rowClickListener - logo_container.setOnClickListener { listener.onLogoClick(adapterPosition) } - title_container.setOnClickListener { listener.onTitleClick(adapterPosition) } - status_container.setOnClickListener { listener.onStatusClick(adapterPosition) } - chapters_container.setOnClickListener { listener.onChaptersClick(adapterPosition) } - score_container.setOnClickListener { listener.onScoreClick(adapterPosition) } - } - - @SuppressLint("SetTextI18n") - @Suppress("DEPRECATION") - fun bind(item: TrackItem) { - val track = item.track - track_logo.setImageResource(item.service.getLogo()) - logo_container.setBackgroundColor(item.service.getLogoColor()) - if (track != null) { - track_title.setTextAppearance(itemView.context, R.style.TextAppearance_Regular_Body1_Secondary) - track_title.setAllCaps(false) - track_title.text = track.title - track_chapters.text = "${track.last_chapter_read}/" + - if (track.total_chapters > 0) track.total_chapters else "-" - track_status.text = item.service.getStatus(track.status) - track_score.text = if (track.score == 0f) "-" else item.service.displayScore(track) - } else { - track_title.setTextAppearance(itemView.context, R.style.TextAppearance_Medium_Button) - track_title.setText(R.string.action_edit) - track_chapters.text = "" - track_score.text = "" - track_status.text = "" - } - } -} +package eu.kanade.tachiyomi.ui.manga.track + +import android.annotation.SuppressLint +import android.view.View +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.ui.base.holder.BaseViewHolder +import eu.kanade.tachiyomi.util.view.gone +import eu.kanade.tachiyomi.util.view.visibleIf +import java.text.DateFormat +import kotlinx.android.synthetic.main.track_item.bottom_divider +import kotlinx.android.synthetic.main.track_item.logo_container +import kotlinx.android.synthetic.main.track_item.track_chapters +import kotlinx.android.synthetic.main.track_item.track_details +import kotlinx.android.synthetic.main.track_item.track_finish_date +import kotlinx.android.synthetic.main.track_item.track_logo +import kotlinx.android.synthetic.main.track_item.track_score +import kotlinx.android.synthetic.main.track_item.track_set +import kotlinx.android.synthetic.main.track_item.track_start_date +import kotlinx.android.synthetic.main.track_item.track_status +import kotlinx.android.synthetic.main.track_item.track_title +import kotlinx.android.synthetic.main.track_item.vert_divider_3 +import uy.kohesive.injekt.injectLazy + +class TrackHolder(view: View, adapter: TrackAdapter) : BaseViewHolder(view) { + + private val preferences: PreferencesHelper by injectLazy() + + private val dateFormat: DateFormat by lazy { + preferences.dateFormat().getOrDefault() + } + + init { + val listener = adapter.rowClickListener + + logo_container.setOnClickListener { listener.onLogoClick(bindingAdapterPosition) } + track_set.setOnClickListener { listener.onSetClick(bindingAdapterPosition) } + track_title.setOnClickListener { listener.onSetClick(bindingAdapterPosition) } + track_status.setOnClickListener { listener.onStatusClick(bindingAdapterPosition) } + track_chapters.setOnClickListener { listener.onChaptersClick(bindingAdapterPosition) } + track_score.setOnClickListener { listener.onScoreClick(bindingAdapterPosition) } + track_start_date.setOnClickListener { listener.onStartDateClick(bindingAdapterPosition) } + track_finish_date.setOnClickListener { listener.onFinishDateClick(bindingAdapterPosition) } + } + + @SuppressLint("SetTextI18n") + fun bind(item: TrackItem) { + val track = item.track + track_logo.setImageResource(item.service.getLogo()) + logo_container.setBackgroundColor(item.service.getLogoColor()) + + track_set.visibleIf { track == null } + track_title.visibleIf { track != null } + + track_details.visibleIf { track != null } + if (track != null) { + track_title.text = track.title + track_chapters.text = "${track.last_chapter_read}/" + + if (track.total_chapters > 0) track.total_chapters else "-" + track_status.text = item.service.getStatus(track.status) + track_score.text = if (track.score == 0f) "-" else item.service.displayScore(track) + + if (item.service.supportsReadingDates) { + track_start_date.text = + if (track.started_reading_date != 0L) dateFormat.format(track.started_reading_date) else "-" + track_finish_date.text = + if (track.finished_reading_date != 0L) dateFormat.format(track.finished_reading_date) else "-" + } else { + bottom_divider.gone() + vert_divider_3.gone() + track_start_date.gone() + track_finish_date.gone() + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackItem.kt index 6e7c3ebec..a751434d9 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackItem.kt @@ -1,6 +1,6 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.track.TrackService - -data class TrackItem(val track: Track?, val service: TrackService) +package eu.kanade.tachiyomi.ui.manga.track + +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.TrackService + +data class TrackItem(val track: Track?, val service: TrackService) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackPresenter.kt index ac8592ed9..31794cb9e 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackPresenter.kt @@ -1,130 +1,160 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import android.os.Bundle -import eu.kanade.tachiyomi.data.database.DatabaseHelper -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.track.TrackManager -import eu.kanade.tachiyomi.data.track.TrackService -import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter -import eu.kanade.tachiyomi.util.toast -import rx.Observable -import rx.Subscription -import rx.android.schedulers.AndroidSchedulers -import rx.schedulers.Schedulers -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get - - -class TrackPresenter( - val manga: Manga, - preferences: PreferencesHelper = Injekt.get(), - private val db: DatabaseHelper = Injekt.get(), - private val trackManager: TrackManager = Injekt.get() -) : BasePresenter() { - - private val context = preferences.context - - private var trackList: List = emptyList() - - private val loggedServices by lazy { trackManager.services.filter { it.isLogged } } - - private var trackSubscription: Subscription? = null - - private var searchSubscription: Subscription? = null - - private var refreshSubscription: Subscription? = null - - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) - fetchTrackings() - } - - fun fetchTrackings() { - trackSubscription?.let { remove(it) } - trackSubscription = db.getTracks(manga) - .asRxObservable() - .map { tracks -> - loggedServices.map { service -> - TrackItem(tracks.find { it.sync_id == service.id }, service) - } - } - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext { trackList = it } - .subscribeLatestCache(TrackController::onNextTrackings) - } - - fun refresh() { - refreshSubscription?.let { remove(it) } - refreshSubscription = Observable.from(trackList) - .filter { it.track != null } - .concatMap { item -> - item.service.refresh(item.track!!) - .flatMap { db.insertTrack(it).asRxObservable() } - .map { item } - .onErrorReturn { item } - } - .toList() - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst({ view, _ -> view.onRefreshDone() }, - TrackController::onRefreshError) - } - - fun search(query: String, service: TrackService) { - searchSubscription?.let { remove(it) } - searchSubscription = service.search(query) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeLatestCache(TrackController::onSearchResults, - TrackController::onSearchResultsError) - } - - fun registerTracking(item: Track?, service: TrackService) { - if (item != null) { - item.manga_id = manga.id!! - add(service.bind(item) - .flatMap { db.insertTrack(item).asRxObservable() } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ }, - { error -> context.toast(error.message) })) - } else { - db.deleteTrackForManga(manga, service).executeAsBlocking() - } - } - - private fun updateRemote(track: Track, service: TrackService) { - service.update(track) - .flatMap { db.insertTrack(track).asRxObservable() } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst({ view, _ -> view.onRefreshDone() }, - { view, error -> - view.onRefreshError(error) - - // Restart on error to set old values - fetchTrackings() - }) - } - - fun setStatus(item: TrackItem, index: Int) { - val track = item.track!! - track.status = item.service.getStatusList()[index] - updateRemote(track, item.service) - } - - fun setScore(item: TrackItem, index: Int) { - val track = item.track!! - track.score = item.service.indexToScore(index) - updateRemote(track, item.service) - } - - fun setLastChapterRead(item: TrackItem, chapterNumber: Int) { - val track = item.track!! - track.last_chapter_read = chapterNumber - updateRemote(track, item.service) - } - -} \ No newline at end of file +package eu.kanade.tachiyomi.ui.manga.track + +import android.os.Bundle +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.data.track.TrackService +import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import eu.kanade.tachiyomi.util.system.toast +import rx.Observable +import rx.Subscription +import rx.android.schedulers.AndroidSchedulers +import rx.schedulers.Schedulers +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class TrackPresenter( + val manga: Manga, + preferences: PreferencesHelper = Injekt.get(), + private val db: DatabaseHelper = Injekt.get(), + private val trackManager: TrackManager = Injekt.get() +) : BasePresenter() { + + private val context = preferences.context + + private var trackList: List = emptyList() + + private val loggedServices by lazy { trackManager.services.filter { it.isLogged } } + + private var trackSubscription: Subscription? = null + + private var searchSubscription: Subscription? = null + + private var refreshSubscription: Subscription? = null + + override fun onCreate(savedState: Bundle?) { + super.onCreate(savedState) + fetchTrackings() + } + + fun fetchTrackings() { + trackSubscription?.let { remove(it) } + trackSubscription = db.getTracks(manga) + .asRxObservable() + .map { tracks -> + loggedServices.map { service -> + TrackItem(tracks.find { it.sync_id == service.id }, service) + } + } + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { trackList = it } + .subscribeLatestCache(TrackController::onNextTrackings) + } + + fun refresh() { + refreshSubscription?.let { remove(it) } + refreshSubscription = Observable.from(trackList) + .filter { it.track != null } + .concatMap { item -> + item.service.refresh(item.track!!) + .flatMap { db.insertTrack(it).asRxObservable() } + .map { item } + .onErrorReturn { item } + } + .toList() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst( + { view, _ -> view.onRefreshDone() }, + TrackController::onRefreshError + ) + } + + fun search(query: String, service: TrackService) { + searchSubscription?.let { remove(it) } + searchSubscription = service.search(query) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeLatestCache( + TrackController::onSearchResults, + TrackController::onSearchResultsError + ) + } + + fun registerTracking(item: Track?, service: TrackService) { + if (item != null) { + item.manga_id = manga.id!! + add( + service.bind(item) + .flatMap { db.insertTrack(item).asRxObservable() } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { }, + { error -> context.toast(error.message) } + ) + ) + } else { + unregisterTracking(service) + } + } + + fun unregisterTracking(service: TrackService) { + db.deleteTrackForManga(manga, service).executeAsBlocking() + } + + private fun updateRemote(track: Track, service: TrackService) { + service.update(track) + .flatMap { db.insertTrack(track).asRxObservable() } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst( + { view, _ -> view.onRefreshDone() }, + { view, error -> + view.onRefreshError(error) + + // Restart on error to set old values + fetchTrackings() + } + ) + } + + fun setStatus(item: TrackItem, index: Int) { + val track = item.track!! + track.status = item.service.getStatusList()[index] + if (track.status == item.service.getCompletionStatus() && track.total_chapters != 0) { + track.last_chapter_read = track.total_chapters + } + updateRemote(track, item.service) + } + + fun setScore(item: TrackItem, index: Int) { + val track = item.track!! + track.score = item.service.indexToScore(index) + updateRemote(track, item.service) + } + + fun setLastChapterRead(item: TrackItem, chapterNumber: Int) { + val track = item.track!! + track.last_chapter_read = chapterNumber + if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { + track.status = item.service.getCompletionStatus() + } + updateRemote(track, item.service) + } + + fun setStartDate(item: TrackItem, date: Long) { + val track = item.track!! + track.started_reading_date = date + updateRemote(track, item.service) + } + + fun setFinishDate(item: TrackItem, date: Long) { + val track = item.track!! + track.finished_reading_date = date + updateRemote(track, item.service) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt index c9b3f3265..99c381a9b 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt @@ -1,79 +1,87 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import android.content.Context -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import com.bumptech.glide.load.engine.DiskCacheStrategy -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.glide.GlideApp -import eu.kanade.tachiyomi.data.track.model.TrackSearch -import eu.kanade.tachiyomi.util.gone -import eu.kanade.tachiyomi.util.inflate -import kotlinx.android.synthetic.main.track_search_item.view.* -import java.util.* - -class TrackSearchAdapter(context: Context) - : ArrayAdapter(context, R.layout.track_search_item, ArrayList()) { - - override fun getView(position: Int, view: View?, parent: ViewGroup): View { - var v = view - // Get the data item for this position - val track = getItem(position) - // Check if an existing view is being reused, otherwise inflate the view - val holder: TrackSearchHolder // view lookup cache stored in tag - if (v == null) { - v = parent.inflate(R.layout.track_search_item) - holder = TrackSearchHolder(v) - v.tag = holder - } else { - holder = v.tag as TrackSearchHolder - } - holder.onSetValues(track) - return v - } - - fun setItems(syncs: List) { - setNotifyOnChange(false) - clear() - addAll(syncs) - notifyDataSetChanged() - } - - class TrackSearchHolder(private val view: View) { - - fun onSetValues(track: TrackSearch) { - view.track_search_title.text = track.title - view.track_search_summary.text = track.summary - GlideApp.with(view.context).clear(view.track_search_cover) - if (!track.cover_url.isNullOrEmpty()) { - GlideApp.with(view.context) - .load(track.cover_url) - .diskCacheStrategy(DiskCacheStrategy.RESOURCE) - .centerCrop() - .into(view.track_search_cover) - } - - if (track.publishing_status.isNullOrBlank()) { - view.track_search_status.gone() - view.track_search_status_result.gone() - } else { - view.track_search_status_result.text = track.publishing_status.capitalize() - } - - if (track.publishing_type.isNullOrBlank()) { - view.track_search_type.gone() - view.track_search_type_result.gone() - } else { - view.track_search_type_result.text = track.publishing_type.capitalize() - } - - if (track.start_date.isNullOrBlank()) { - view.track_search_start.gone() - view.track_search_start_result.gone() - } else { - view.track_search_start_result.text = track.start_date - } - } - } -} \ No newline at end of file +package eu.kanade.tachiyomi.ui.manga.track + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import com.bumptech.glide.load.engine.DiskCacheStrategy +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.util.view.gone +import eu.kanade.tachiyomi.util.view.inflate +import java.util.ArrayList +import kotlinx.android.synthetic.main.track_search_item.view.track_search_cover +import kotlinx.android.synthetic.main.track_search_item.view.track_search_start +import kotlinx.android.synthetic.main.track_search_item.view.track_search_start_result +import kotlinx.android.synthetic.main.track_search_item.view.track_search_status +import kotlinx.android.synthetic.main.track_search_item.view.track_search_status_result +import kotlinx.android.synthetic.main.track_search_item.view.track_search_summary +import kotlinx.android.synthetic.main.track_search_item.view.track_search_title +import kotlinx.android.synthetic.main.track_search_item.view.track_search_type +import kotlinx.android.synthetic.main.track_search_item.view.track_search_type_result + +class TrackSearchAdapter(context: Context) : + ArrayAdapter(context, R.layout.track_search_item, ArrayList()) { + + override fun getView(position: Int, view: View?, parent: ViewGroup): View { + var v = view + // Get the data item for this position + val track = getItem(position)!! + // Check if an existing view is being reused, otherwise inflate the view + val holder: TrackSearchHolder // view lookup cache stored in tag + if (v == null) { + v = parent.inflate(R.layout.track_search_item) + holder = TrackSearchHolder(v) + v.tag = holder + } else { + holder = v.tag as TrackSearchHolder + } + holder.onSetValues(track) + return v + } + + fun setItems(syncs: List) { + setNotifyOnChange(false) + clear() + addAll(syncs) + notifyDataSetChanged() + } + + class TrackSearchHolder(private val view: View) { + + fun onSetValues(track: TrackSearch) { + view.track_search_title.text = track.title + view.track_search_summary.text = track.summary + GlideApp.with(view.context).clear(view.track_search_cover) + if (!track.cover_url.isEmpty()) { + GlideApp.with(view.context) + .load(track.cover_url) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .centerCrop() + .into(view.track_search_cover) + } + + if (track.publishing_status.isBlank()) { + view.track_search_status.gone() + view.track_search_status_result.gone() + } else { + view.track_search_status_result.text = track.publishing_status.capitalize() + } + + if (track.publishing_type.isBlank()) { + view.track_search_type.gone() + view.track_search_type_result.gone() + } else { + view.track_search_type_result.text = track.publishing_type.capitalize() + } + + if (track.start_date.isBlank()) { + view.track_search_start.gone() + view.track_search_start_result.gone() + } else { + view.track_search_start_result.text = track.start_date + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt index 9856ce2e5..6a9f8ad8e 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt @@ -1,144 +1,142 @@ -package eu.kanade.tachiyomi.ui.manga.track - -import android.app.Dialog -import android.os.Bundle -import android.view.View -import com.afollestad.materialdialogs.MaterialDialog -import com.jakewharton.rxbinding.widget.itemClicks -import com.jakewharton.rxbinding.widget.textChanges -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.track.TrackManager -import eu.kanade.tachiyomi.data.track.TrackService -import eu.kanade.tachiyomi.data.track.model.TrackSearch -import eu.kanade.tachiyomi.ui.base.controller.DialogController -import eu.kanade.tachiyomi.util.plusAssign -import kotlinx.android.synthetic.main.track_search_dialog.view.* -import rx.Subscription -import rx.android.schedulers.AndroidSchedulers -import rx.subscriptions.CompositeSubscription -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import java.util.concurrent.TimeUnit - -class TrackSearchDialog : DialogController { - - private var dialogView: View? = null - - private var adapter: TrackSearchAdapter? = null - - private var selectedItem: Track? = null - - private val service: TrackService - - private var subscriptions = CompositeSubscription() - - private var searchTextSubscription: Subscription? = null - - private val trackController - get() = targetController as TrackController - - constructor(target: TrackController, service: TrackService) : super(Bundle().apply { - putInt(KEY_SERVICE, service.id) - }) { - targetController = target - this.service = service - } - - @Suppress("unused") - constructor(bundle: Bundle) : super(bundle) { - service = Injekt.get().getService(bundle.getInt(KEY_SERVICE))!! - } - - override fun onCreateDialog(savedState: Bundle?): Dialog { - val dialog = MaterialDialog.Builder(activity!!) - .customView(R.layout.track_search_dialog, false) - .positiveText(android.R.string.ok) - .negativeText(android.R.string.cancel) - .onPositive { _, _ -> onPositiveButtonClick() } - .build() - - if (subscriptions.isUnsubscribed) { - subscriptions = CompositeSubscription() - } - - dialogView = dialog.view - onViewCreated(dialog.view, savedState) - - return dialog - } - - fun onViewCreated(view: View, savedState: Bundle?) { - // Create adapter - val adapter = TrackSearchAdapter(view.context) - this.adapter = adapter - view.track_search_list.adapter = adapter - - // Set listeners - selectedItem = null - - subscriptions += view.track_search_list.itemClicks().subscribe { position -> - selectedItem = adapter.getItem(position) - } - - // Do an initial search based on the manga's title - if (savedState == null) { - val title = trackController.presenter.manga.title - view.track_search.append(title) - search(title) - } - } - - override fun onDestroyView(view: View) { - super.onDestroyView(view) - subscriptions.unsubscribe() - dialogView = null - adapter = null - } - - override fun onAttach(view: View) { - super.onAttach(view) - searchTextSubscription = dialogView!!.track_search.textChanges() - .skip(1) - .debounce(1, TimeUnit.SECONDS, AndroidSchedulers.mainThread()) - .map { it.toString() } - .filter(String::isNotBlank) - .subscribe { search(it) } - } - - override fun onDetach(view: View) { - super.onDetach(view) - searchTextSubscription?.unsubscribe() - } - - private fun search(query: String) { - val view = dialogView ?: return - view.progress.visibility = View.VISIBLE - view.track_search_list.visibility = View.INVISIBLE - trackController.presenter.search(query, service) - } - - fun onSearchResults(results: List) { - selectedItem = null - val view = dialogView ?: return - view.progress.visibility = View.INVISIBLE - view.track_search_list.visibility = View.VISIBLE - adapter?.setItems(results) - } - - fun onSearchResultsError() { - val view = dialogView ?: return - view.progress.visibility = View.VISIBLE - view.track_search_list.visibility = View.INVISIBLE - adapter?.setItems(emptyList()) - } - - private fun onPositiveButtonClick() { - trackController.presenter.registerTracking(selectedItem, service) - } - - private companion object { - const val KEY_SERVICE = "service_id" - } - -} +package eu.kanade.tachiyomi.ui.manga.track + +import android.app.Dialog +import android.os.Bundle +import android.view.View +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.customview.customView +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.data.track.TrackService +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import eu.kanade.tachiyomi.util.view.invisible +import eu.kanade.tachiyomi.util.view.visible +import java.util.concurrent.TimeUnit +import kotlinx.android.synthetic.main.track_search_dialog.view.progress +import kotlinx.android.synthetic.main.track_search_dialog.view.track_search +import kotlinx.android.synthetic.main.track_search_dialog.view.track_search_list +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.android.widget.itemClicks +import reactivecircus.flowbinding.android.widget.textChanges +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class TrackSearchDialog : DialogController { + + private var dialogView: View? = null + + private var adapter: TrackSearchAdapter? = null + + private var selectedItem: Track? = null + + private val service: TrackService + + private val trackController + get() = targetController as TrackController + + constructor(target: TrackController, service: TrackService) : super( + Bundle().apply { + putInt(KEY_SERVICE, service.id) + } + ) { + targetController = target + this.service = service + } + + @Suppress("unused") + constructor(bundle: Bundle) : super(bundle) { + service = Injekt.get().getService(bundle.getInt(KEY_SERVICE))!! + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val dialog = MaterialDialog(activity!!) + .customView(R.layout.track_search_dialog) + .positiveButton(android.R.string.ok) { onPositiveButtonClick() } + .negativeButton(android.R.string.cancel) + .neutralButton(R.string.action_remove) { onRemoveButtonClick() } + + dialogView = dialog.view + onViewCreated(dialog.view, savedViewState) + + return dialog + } + + fun onViewCreated(view: View, savedState: Bundle?) { + // Create adapter + val adapter = TrackSearchAdapter(view.context) + this.adapter = adapter + view.track_search_list.adapter = adapter + + // Set listeners + selectedItem = null + + view.track_search_list.itemClicks() + .onEach { position -> + selectedItem = adapter.getItem(position) + } + .launchIn(trackController.scope) + + // Do an initial search based on the manga's title + if (savedState == null) { + val title = trackController.presenter.manga.title + view.track_search.append(title) + search(title) + } + } + + override fun onDestroyView(view: View) { + super.onDestroyView(view) + dialogView = null + adapter = null + } + + override fun onAttach(view: View) { + super.onAttach(view) + dialogView!!.track_search.textChanges() + .debounce(TimeUnit.SECONDS.toMillis(1)) + .map { it.toString() } + .filter { it.isNotBlank() } + .onEach { search(it) } + .launchIn(trackController.scope) + } + + private fun search(query: String) { + val view = dialogView ?: return + view.progress.visible() + view.track_search_list.invisible() + trackController.presenter.search(query, service) + } + + fun onSearchResults(results: List) { + selectedItem = null + val view = dialogView ?: return + view.progress.invisible() + view.track_search_list.visible() + adapter?.setItems(results) + } + + fun onSearchResultsError() { + val view = dialogView ?: return + view.progress.visible() + view.track_search_list.invisible() + adapter?.setItems(emptyList()) + } + + private fun onPositiveButtonClick() { + trackController.presenter.registerTracking(selectedItem, service) + } + + private fun onRemoveButtonClick() { + trackController.presenter.unregisterTracking(service) + } + + private companion object { + const val KEY_SERVICE = "service_id" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MangaAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MangaAdapter.kt index 0fb2c24f8..5cf41f0cd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MangaAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MangaAdapter.kt @@ -4,7 +4,7 @@ import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.IFlexible class MangaAdapter(controller: MigrationController) : - FlexibleAdapter>(null, controller) { + FlexibleAdapter>(null, controller) { private var items: List>? = null @@ -14,4 +14,4 @@ class MangaAdapter(controller: MigrationController) : super.updateDataSet(items) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MangaHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MangaHolder.kt index c0fd058cd..b42d42561 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MangaHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MangaHolder.kt @@ -4,12 +4,14 @@ import android.view.View import com.bumptech.glide.load.engine.DiskCacheStrategy import eu.davidea.flexibleadapter.FlexibleAdapter import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.data.glide.toMangaThumbnail import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder -import kotlinx.android.synthetic.main.catalogue_list_item.* +import kotlinx.android.synthetic.main.source_list_item.thumbnail +import kotlinx.android.synthetic.main.source_list_item.title class MangaHolder( - private val view: View, - private val adapter: FlexibleAdapter<*> + view: View, + adapter: FlexibleAdapter<*> ) : BaseFlexibleViewHolder(view, adapter) { fun bind(item: MangaItem) { @@ -25,12 +27,11 @@ class MangaHolder( // Update the cover. GlideApp.with(itemView.context).clear(thumbnail) GlideApp.with(itemView.context) - .load(item.manga) - .diskCacheStrategy(DiskCacheStrategy.RESOURCE) - .centerCrop() - .circleCrop() - .dontAnimate() - .into(thumbnail) + .load(item.manga.toMangaThumbnail()) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .centerCrop() + .circleCrop() + .dontAnimate() + .into(thumbnail) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MangaItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MangaItem.kt index 62f94a7bb..04c951bea 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MangaItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MangaItem.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.ui.migration import android.view.View +import androidx.recyclerview.widget.RecyclerView import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractFlexibleItem import eu.davidea.flexibleadapter.items.IFlexible @@ -10,18 +11,19 @@ import eu.kanade.tachiyomi.data.database.models.Manga class MangaItem(val manga: Manga) : AbstractFlexibleItem() { override fun getLayoutRes(): Int { - return R.layout.catalogue_list_item + return R.layout.source_list_item } - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): MangaHolder { + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): MangaHolder { return MangaHolder(view, adapter) } - override fun bindViewHolder(adapter: FlexibleAdapter>, - holder: MangaHolder, - position: Int, - payloads: List?) { - + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: MangaHolder, + position: Int, + payloads: List? + ) { holder.bind(this) } @@ -35,4 +37,4 @@ class MangaItem(val manga: Manga) : AbstractFlexibleItem() { override fun hashCode(): Int { return manga.id!!.hashCode() } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationController.kt index 64be5b670..fb8463c0b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationController.kt @@ -78,14 +78,14 @@ class MigrationController : NucleusController(), title = resources?.getString(R.string.label_migration) if (adapter !is SourceAdapter) { adapter = SourceAdapter(this) - migration_recycler.adapter = adapter + binding.migrationRecycler.adapter = adapter } adapter?.updateDataSet(state.sourcesWithManga) } else { title = state.selectedSource.toString() if (adapter !is MangaAdapter) { adapter = MangaAdapter(this) - migration_recycler.adapter = adapter + binding.migrationRecycler.adapter = adapter } adapter?.updateDataSet(state.mangaForSource) } @@ -154,4 +154,3 @@ class MigrationController : NucleusController(), const val LOADING_DIALOG_TAG = "LoadingDialog" } -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationFlags.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationFlags.kt index 2e9f3aa8d..b3f9a4ef2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationFlags.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationFlags.kt @@ -4,13 +4,13 @@ import eu.kanade.tachiyomi.R object MigrationFlags { - const val CHAPTERS = 0b001 - const val CATEGORIES = 0b010 - const val TRACK = 0b100 + private const val CHAPTERS = 0b001 + private const val CATEGORIES = 0b010 + private const val TRACK = 0b100 - private const val CHAPTERS2 = 0x1 + private const val CHAPTERS2 = 0x1 private const val CATEGORIES2 = 0x2 - private const val TRACK2 = 0x4 + private const val TRACK2 = 0x4 val titles get() = arrayOf(R.string.chapters, R.string.categories, R.string.track) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationPresenter.kt index 35b6bc06b..77e5b9392 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationPresenter.kt @@ -4,26 +4,19 @@ import android.os.Bundle import com.jakewharton.rxrelay.BehaviorRelay import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.database.models.MangaCategory -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceManager -import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter -import eu.kanade.tachiyomi.util.combineLatest -import eu.kanade.tachiyomi.util.syncChaptersWithSource -import rx.Observable +import eu.kanade.tachiyomi.util.lang.combineLatest import rx.android.schedulers.AndroidSchedulers import rx.schedulers.Schedulers import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get class MigrationPresenter( - private val sourceManager: SourceManager = Injekt.get(), - private val db: DatabaseHelper = Injekt.get(), - private val preferences: PreferencesHelper = Injekt.get() + private val sourceManager: SourceManager = Injekt.get(), + private val db: DatabaseHelper = Injekt.get() ) : BasePresenter() { var state = ViewState() @@ -38,26 +31,22 @@ class MigrationPresenter( super.onCreate(savedState) db.getFavoriteMangas() - .asRxObservable() - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext { state = state.copy(sourcesWithManga = findSourcesWithManga(it)) } - .combineLatest(stateRelay.map { it.selectedSource } - .distinctUntilChanged(), - { library, source -> library to source }) - .filter { (_, source) -> source != null } - .observeOn(Schedulers.io()) - .map { (library, source) -> libraryToMigrationItem(library, source!!.id) } - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext { state = state.copy(mangaForSource = it) } - .subscribe() + .asRxObservable() + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { state = state.copy(sourcesWithManga = findSourcesWithManga(it)) } + .combineLatest( + stateRelay.map { it.selectedSource } + .distinctUntilChanged() + ) { library, source -> library to source } + .filter { (_, source) -> source != null } + .observeOn(Schedulers.io()) + .map { (library, source) -> libraryToMigrationItem(library, source!!.id) } + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { state = state.copy(mangaForSource = it) } + .subscribe() - stateRelay - // Render the view when any field other than isReplacingManga changes - .distinctUntilChanged { t1, t2 -> t1.isReplacingManga != t2.isReplacingManga } - .subscribeLatestCache(MigrationController::render) - - stateRelay.distinctUntilChanged { state -> state.isReplacingManga } - .subscribeLatestCache(MigrationController::renderIsReplacingManga) + // Render the view when any field changes + stateRelay.subscribeLatestCache(MigrationController::render) } fun setSelectedSource(source: Source) { @@ -71,84 +60,11 @@ class MigrationPresenter( private fun findSourcesWithManga(library: List): List { val header = SelectionHeader() return library.map { it.source }.toSet() - .mapNotNull { if (it != LocalSource.ID) sourceManager.getOrStub(it) else null } - .map { SourceItem(it, header) } + .mapNotNull { if (it != LocalSource.ID) sourceManager.getOrStub(it) else null } + .map { SourceItem(it, header) } } private fun libraryToMigrationItem(library: List, sourceId: Long): List { return library.filter { it.source == sourceId }.map(::MangaItem) } - - fun migrateManga(prevManga: Manga, manga: Manga, replace: Boolean) { - val source = sourceManager.get(manga.source) ?: return - - state = state.copy(isReplacingManga = true) - - Observable.defer { source.fetchChapterList(manga) } - .onErrorReturn { emptyList() } - .doOnNext { migrateMangaInternal(source, it, prevManga, manga, replace) } - .onErrorReturn { emptyList() } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnUnsubscribe { state = state.copy(isReplacingManga = false) } - .subscribe() - } - - private fun migrateMangaInternal(source: Source, sourceChapters: List, - prevManga: Manga, manga: Manga, replace: Boolean) { - - val flags = preferences.migrateFlags().getOrDefault() - val migrateChapters = MigrationFlags.hasChapters(flags) - val migrateCategories = MigrationFlags.hasCategories(flags) - val migrateTracks = MigrationFlags.hasTracks(flags) - - db.inTransaction { - // Update chapters read - if (migrateChapters) { - try { - syncChaptersWithSource(db, sourceChapters, manga, source) - } catch (e: Exception) { - // Worst case, chapters won't be synced - } - - val prevMangaChapters = db.getChapters(prevManga).executeAsBlocking() - val maxChapterRead = prevMangaChapters.filter { it.read } - .maxBy { it.chapter_number }?.chapter_number - if (maxChapterRead != null) { - val dbChapters = db.getChapters(manga).executeAsBlocking() - for (chapter in dbChapters) { - if (chapter.isRecognizedNumber && chapter.chapter_number <= maxChapterRead) { - chapter.read = true - } - } - db.insertChapters(dbChapters).executeAsBlocking() - } - } - // Update categories - if (migrateCategories) { - val categories = db.getCategoriesForManga(prevManga).executeAsBlocking() - val mangaCategories = categories.map { MangaCategory.create(manga, it) } - db.setMangaCategories(mangaCategories, listOf(manga)) - } - // Update track - if (migrateTracks) { - val tracks = db.getTracks(prevManga).executeAsBlocking() - for (track in tracks) { - track.id = null - track.manga_id = manga.id!! - } - db.insertTracks(tracks).executeAsBlocking() - } - // Update favorite status - if (replace) { - prevManga.favorite = false - db.updateMangaFavorite(prevManga).executeAsBlocking() - } - manga.favorite = true - db.updateMangaFavorite(manga).executeAsBlocking() - - // SearchPresenter#networkToLocalManga may have updated the manga title, so ensure db gets updated title - db.updateMangaTitle(manga).executeAsBlocking() - } - } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SearchController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SearchController.kt index 275d3a911..43e22220b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SearchController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SearchController.kt @@ -3,22 +3,24 @@ package eu.kanade.tachiyomi.ui.migration import android.app.Dialog import android.os.Bundle import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.list.listItemsMultiChoice import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.ui.base.controller.DialogController -import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchController -import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchPresenter +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchPresenter +import eu.kanade.tachiyomi.util.view.gone +import eu.kanade.tachiyomi.util.view.visible import uy.kohesive.injekt.injectLazy class SearchController( - private var manga: Manga? = null -) : CatalogueSearchController(manga?.title) { + private var manga: Manga? = null +) : GlobalSearchController(manga?.title) { private var newManga: Manga? = null - override fun createPresenter(): CatalogueSearchPresenter { + override fun createPresenter(): GlobalSearchPresenter { return SearchPresenter(initialQuery, manga!!) } @@ -35,21 +37,17 @@ class SearchController( } fun migrateManga() { - val target = targetController as? MigrationController ?: return val manga = manga ?: return val newManga = newManga ?: return - router.popController(this) - target.migrateManga(manga, newManga) + (presenter as? SearchPresenter)?.migrateManga(manga, newManga, true) } fun copyManga() { - val target = targetController as? MigrationController ?: return val manga = manga ?: return val newManga = newManga ?: return - router.popController(this) - target.copyManga(manga, newManga) + (presenter as? SearchPresenter)?.migrateManga(manga, newManga, false) } override fun onMangaClick(manga: Manga) { @@ -64,38 +62,41 @@ class SearchController( super.onMangaClick(manga) } + fun renderIsReplacingManga(isReplacingManga: Boolean) { + if (isReplacingManga) { + binding.progress.visible() + } else { + binding.progress.gone() + router.popController(this) + } + } + class MigrationDialog : DialogController() { private val preferences: PreferencesHelper by injectLazy() override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val prefValue = preferences.migrateFlags().getOrDefault() + val prefValue = preferences.migrateFlags().get() val preselected = MigrationFlags.getEnabledFlagsPositions(prefValue) - return MaterialDialog.Builder(activity!!) - .content(R.string.migration_dialog_what_to_include) - .items(MigrationFlags.titles.map { resources?.getString(it) }) - .alwaysCallMultiChoiceCallback() - .itemsCallbackMultiChoice(preselected.toTypedArray(), { _, positions, _ -> - // Save current settings for the next time - val newValue = MigrationFlags.getFlagsFromPositions(positions) - preferences.migrateFlags().set(newValue) - - true - }) - .positiveText(R.string.migrate) - .negativeText(R.string.copy) - .neutralText(android.R.string.cancel) - .onPositive { _, _ -> - (targetController as? SearchController)?.migrateManga() - } - .onNegative { _, _ -> - (targetController as? SearchController)?.copyManga() - } - .build() + return MaterialDialog(activity!!) + .message(R.string.migration_dialog_what_to_include) + .listItemsMultiChoice( + items = MigrationFlags.titles.map { resources?.getString(it) as CharSequence }, + initialSelection = preselected.toIntArray() + ) { _, positions, _ -> + // Save current settings for the next time + val newValue = MigrationFlags.getFlagsFromPositions(positions.toTypedArray()) + preferences.migrateFlags().set(newValue) + } + .positiveButton(R.string.migrate) { + (targetController as? SearchController)?.migrateManga() + } + .negativeButton(R.string.copy) { + (targetController as? SearchController)?.copyManga() + } + .neutralButton(android.R.string.cancel) } - } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SearchPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SearchPresenter.kt index b4a31c0da..b701e97e5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SearchPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SearchPresenter.kt @@ -1,26 +1,43 @@ package eu.kanade.tachiyomi.ui.migration +import android.os.Bundle +import com.jakewharton.rxrelay.BehaviorRelay import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.MangaCategory import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga -import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchCardItem -import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchItem -import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchPresenter +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchCardItem +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchItem +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchPresenter +import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource +import rx.Observable +import rx.android.schedulers.AndroidSchedulers +import rx.schedulers.Schedulers class SearchPresenter( - initialQuery: String? = "", - private val manga: Manga -) : CatalogueSearchPresenter(initialQuery) { + initialQuery: String? = "", + private val manga: Manga +) : GlobalSearchPresenter(initialQuery) { + + private val replacingMangaRelay = BehaviorRelay.create() + + override fun onCreate(savedState: Bundle?) { + super.onCreate(savedState) + + replacingMangaRelay.subscribeLatestCache({ controller, isReplacingManga -> (controller as? SearchController)?.renderIsReplacingManga(isReplacingManga) }) + } override fun getEnabledSources(): List { // Put the source of the selected manga at the top return super.getEnabledSources() - .sortedByDescending { it.id == manga.source } + .sortedByDescending { it.id == manga.source } } - override fun createCatalogueSearchItem(source: CatalogueSource, results: List?): CatalogueSearchItem { - //Set the catalogue search item as highlighted if the source matches that of the selected manga - return CatalogueSearchItem(source, results, source.id == manga.source) + override fun createCatalogueSearchItem(source: CatalogueSource, results: List?): GlobalSearchItem { + // Set the catalogue search item as highlighted if the source matches that of the selected manga + return GlobalSearchItem(source, results, source.id == manga.source) } override fun networkToLocalManga(sManga: SManga, sourceId: Long): Manga { @@ -29,4 +46,99 @@ class SearchPresenter( localManga.title = sManga.title return localManga } + + fun migrateManga(prevManga: Manga, manga: Manga, replace: Boolean) { + val source = sourceManager.get(manga.source) ?: return + + replacingMangaRelay.call(true) + + Observable.defer { source.fetchChapterList(manga) } + .onErrorReturn { emptyList() } + .doOnNext { migrateMangaInternal(source, it, prevManga, manga, replace) } + .onErrorReturn { emptyList() } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnUnsubscribe { replacingMangaRelay.call(false) } + .subscribe() + } + + private fun migrateMangaInternal( + source: Source, + sourceChapters: List, + prevManga: Manga, + manga: Manga, + replace: Boolean + ) { + val flags = preferences.migrateFlags().get() + val migrateChapters = MigrationFlags.hasChapters(flags) + val migrateCategories = MigrationFlags.hasCategories(flags) + val migrateTracks = MigrationFlags.hasTracks(flags) + + db.inTransaction { + // Update chapters read + if (migrateChapters) { + try { + syncChaptersWithSource(db, sourceChapters, manga, source) + } catch (e: Exception) { + // Worst case, chapters won't be synced + } + + val prevMangaChapters = db.getChapters(prevManga).executeAsBlocking() + val maxChapterRead = prevMangaChapters + .filter { it.read } + .maxBy { it.chapter_number }?.chapter_number + val bookmarkedChapters = prevMangaChapters + .filter { it.bookmark && it.isRecognizedNumber } + .map { it.chapter_number } + if (maxChapterRead != null) { + val dbChapters = db.getChapters(manga).executeAsBlocking() + for (chapter in dbChapters) { + if (chapter.isRecognizedNumber) { + if (chapter.chapter_number <= maxChapterRead) { + chapter.read = true + } + if (chapter.chapter_number in bookmarkedChapters) { + chapter.bookmark = true + } + } + } + db.insertChapters(dbChapters).executeAsBlocking() + } + } + + // Update categories + if (migrateCategories) { + val categories = db.getCategoriesForManga(prevManga).executeAsBlocking() + val mangaCategories = categories.map { MangaCategory.create(manga, it) } + db.setMangaCategories(mangaCategories, listOf(manga)) + } + + // Update track + if (migrateTracks) { + val tracks = db.getTracks(prevManga).executeAsBlocking() + for (track in tracks) { + track.id = null + track.manga_id = manga.id!! + } + db.insertTracks(tracks).executeAsBlocking() + } + + // Update favorite status + if (replace) { + prevManga.favorite = false + db.updateMangaFavorite(prevManga).executeAsBlocking() + } + manga.favorite = true + db.updateMangaFavorite(manga).executeAsBlocking() + + // Update reading preferences + manga.chapter_flags = prevManga.chapter_flags + db.updateFlags(manga).executeAsBlocking() + manga.viewer = prevManga.viewer + db.updateMangaViewer(manga).executeAsBlocking() + + // SearchPresenter#networkToLocalManga may have updated the manga title, so ensure db gets updated title + db.updateMangaTitle(manga).executeAsBlocking() + } + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SelectionHeader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SelectionHeader.kt index 0241b323b..9530c68bc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SelectionHeader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SelectionHeader.kt @@ -1,12 +1,13 @@ package eu.kanade.tachiyomi.ui.migration import android.view.View +import androidx.recyclerview.widget.RecyclerView import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractHeaderItem import eu.davidea.flexibleadapter.items.IFlexible import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder -import kotlinx.android.synthetic.main.catalogue_main_controller_card.* +import kotlinx.android.synthetic.main.source_main_controller_card_header.title /** * Item that contains the selection header. @@ -17,21 +18,25 @@ class SelectionHeader : AbstractHeaderItem() { * Returns the layout resource of this item. */ override fun getLayoutRes(): Int { - return R.layout.catalogue_main_controller_card + return R.layout.source_main_controller_card_header } /** * Creates a new view holder for this item. */ - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): Holder { - return SelectionHeader.Holder(view, adapter) + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): Holder { + return Holder(view, adapter) } /** * Binds this item to the given view holder. */ - override fun bindViewHolder(adapter: FlexibleAdapter>, holder: Holder, - position: Int, payloads: List?) { + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: Holder, + position: Int, + payloads: List? + ) { // Intentionally empty } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SourceAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SourceAdapter.kt index fff85421f..2c7cbcb35 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SourceAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SourceAdapter.kt @@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.ui.migration import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.IFlexible import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.util.getResourceColor +import eu.kanade.tachiyomi.util.system.getResourceColor /** * Adapter that holds the catalogue cards. @@ -11,9 +11,9 @@ import eu.kanade.tachiyomi.util.getResourceColor * @param controller instance of [MigrationController]. */ class SourceAdapter(val controller: MigrationController) : - FlexibleAdapter>(null, controller, true) { + FlexibleAdapter>(null, controller, true) { - val cardBackground = controller.activity!!.getResourceColor(R.attr.background_card) + val cardBackground = controller.activity!!.getResourceColor(R.attr.colorSurface) private var items: List>? = null @@ -53,4 +53,4 @@ class SourceAdapter(val controller: MigrationController) : super.updateDataSet(items) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SourceHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SourceHolder.kt index bcf2ea1cc..96b8620f3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SourceHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SourceHolder.kt @@ -2,16 +2,20 @@ package eu.kanade.tachiyomi.ui.migration import android.view.View import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.source.icon import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder import eu.kanade.tachiyomi.ui.base.holder.SlicedHolder -import eu.kanade.tachiyomi.util.getRound -import eu.kanade.tachiyomi.util.gone +import eu.kanade.tachiyomi.util.view.gone import io.github.mthli.slice.Slice -import kotlinx.android.synthetic.main.catalogue_main_controller_card_item.* +import kotlinx.android.synthetic.main.source_main_controller_card_item.card +import kotlinx.android.synthetic.main.source_main_controller_card_item.image +import kotlinx.android.synthetic.main.source_main_controller_card_item.source_browse +import kotlinx.android.synthetic.main.source_main_controller_card_item.source_latest +import kotlinx.android.synthetic.main.source_main_controller_card_item.title class SourceHolder(view: View, override val adapter: SourceAdapter) : - BaseFlexibleViewHolder(view, adapter), - SlicedHolder { + BaseFlexibleViewHolder(view, adapter), + SlicedHolder { override val slice = Slice(card).apply { setColor(adapter.cardBackground) @@ -27,7 +31,7 @@ class SourceHolder(view: View, override val adapter: SourceAdapter) : } source_browse.setText(R.string.select) source_browse.setOnClickListener { - adapter.selectClickListener?.onSelectClick(adapterPosition) + adapter.selectClickListener?.onSelectClick(bindingAdapterPosition) } } @@ -40,7 +44,10 @@ class SourceHolder(view: View, override val adapter: SourceAdapter) : // Set circle letter image. itemView.post { - image.setImageDrawable(image.getRound(source.name.take(1).toUpperCase(),false)) + val icon = source.icon() + if (icon != null) { + image.setImageDrawable(icon) + } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SourceItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SourceItem.kt index c3067845e..0e24f0633 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SourceItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/SourceItem.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.ui.migration import android.view.View +import androidx.recyclerview.widget.RecyclerView import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractSectionableItem import eu.davidea.flexibleadapter.items.IFlexible @@ -14,29 +15,31 @@ import eu.kanade.tachiyomi.source.Source * @param header The header for this item. */ data class SourceItem(val source: Source, val header: SelectionHeader? = null) : - AbstractSectionableItem(header) { + AbstractSectionableItem(header) { /** * Returns the layout resource of this item. */ override fun getLayoutRes(): Int { - return R.layout.catalogue_main_controller_card_item + return R.layout.source_main_controller_card_item } /** * Creates a new view holder for this item. */ - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): SourceHolder { + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): SourceHolder { return SourceHolder(view, adapter as SourceAdapter) } /** * Binds this item to the given view holder. */ - override fun bindViewHolder(adapter: FlexibleAdapter>, holder: SourceHolder, - position: Int, payloads: List?) { - + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: SourceHolder, + position: Int, + payloads: List? + ) { holder.bind(this) } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/ViewState.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/ViewState.kt index 7caa5e9ec..5fbab9122 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/ViewState.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/ViewState.kt @@ -3,8 +3,7 @@ package eu.kanade.tachiyomi.ui.migration import eu.kanade.tachiyomi.source.Source data class ViewState( - val selectedSource: Source? = null, - val mangaForSource: List = emptyList(), - val sourcesWithManga: List = emptyList(), - val isReplacingManga: Boolean = false -) \ No newline at end of file + val selectedSource: Source? = null, + val mangaForSource: List = emptyList(), + val sourcesWithManga: List = emptyList() +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/more/AboutController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/more/AboutController.kt new file mode 100644 index 000000000..b351f9f8a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/more/AboutController.kt @@ -0,0 +1,234 @@ +package eu.kanade.tachiyomi.ui.more + +import android.app.Dialog +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import androidx.preference.PreferenceScreen +import com.afollestad.materialdialogs.MaterialDialog +import com.google.android.gms.oss.licenses.OssLicensesMenuActivity +import eu.kanade.tachiyomi.BuildConfig +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.data.updater.UpdateChecker +import eu.kanade.tachiyomi.data.updater.UpdateResult +import eu.kanade.tachiyomi.data.updater.UpdaterService +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import eu.kanade.tachiyomi.ui.main.ChangelogDialogController +import eu.kanade.tachiyomi.ui.setting.SettingsController +import eu.kanade.tachiyomi.util.lang.launchNow +import eu.kanade.tachiyomi.util.lang.toDateTimestampString +import eu.kanade.tachiyomi.util.preference.onClick +import eu.kanade.tachiyomi.util.preference.preference +import eu.kanade.tachiyomi.util.preference.preferenceCategory +import eu.kanade.tachiyomi.util.preference.titleRes +import eu.kanade.tachiyomi.util.system.copyToClipboard +import eu.kanade.tachiyomi.util.system.toast +import java.text.DateFormat +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone +import timber.log.Timber +import uy.kohesive.injekt.injectLazy + +class AboutController : SettingsController() { + + /** + * Checks for new releases + */ + private val updateChecker by lazy { UpdateChecker.getUpdateChecker() } + + private val userPreferences: PreferencesHelper by injectLazy() + + private val dateFormat: DateFormat = userPreferences.dateFormat().getOrDefault() + + private val isUpdaterEnabled = BuildConfig.INCLUDE_UPDATER + + override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { + titleRes = R.string.pref_category_about + + preference { + titleRes = R.string.version + summary = if (BuildConfig.DEBUG) { + "Preview r${BuildConfig.COMMIT_COUNT} (${BuildConfig.COMMIT_SHA})" + } else { + "Stable ${BuildConfig.VERSION_NAME}" + } + + onClick { copyDebugInfo() } + } + preference { + titleRes = R.string.build_time + summary = getFormattedBuildTime() + } + if (isUpdaterEnabled) { + preference { + titleRes = R.string.check_for_updates + + onClick { checkVersion() } + } + } + preference { + titleRes = R.string.changelog + + onClick { + if (BuildConfig.DEBUG) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/inorichi/tachiyomi/commits/master")) + startActivity(intent) + } else { + ChangelogDialogController().showDialog(router) + } + } + } + if (BuildConfig.DEBUG) { + preference { + titleRes = R.string.notices + + onClick { + ChangelogDialogController().showDialog(router) + } + } + } + + preferenceCategory { + preference { + titleRes = R.string.website + val url = "https://tachiyomi.org" + summary = url + onClick { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + startActivity(intent) + } + } + preference { + title = "Discord" + val url = "https://discord.gg/tachiyomi" + summary = url + onClick { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + startActivity(intent) + } + } + preference { + title = "GitHub" + val url = "https://github.com/inorichi/tachiyomi" + summary = url + onClick { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + startActivity(intent) + } + } + preference { + titleRes = R.string.label_extensions + val url = "https://github.com/inorichi/tachiyomi-extensions" + summary = url + onClick { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + startActivity(intent) + } + } + preference { + titleRes = R.string.licenses + + onClick { + startActivity(Intent(activity, OssLicensesMenuActivity::class.java)) + } + } + } + } + + /** + * Checks version and shows a user prompt if an update is available. + */ + private fun checkVersion() { + if (activity == null) return + + activity?.toast(R.string.update_check_look_for_updates) + + launchNow { + try { + when (val result = updateChecker.checkForUpdate()) { + is UpdateResult.NewUpdate<*> -> { + val body = result.release.info + val url = result.release.downloadLink + + // Create confirmation window + NewUpdateDialogController(body, url).showDialog(router) + } + is UpdateResult.NoNewUpdate -> { + activity?.toast(R.string.update_check_no_new_updates) + } + } + } catch (error: Exception) { + activity?.toast(error.message) + Timber.e(error) + } + } + } + + class NewUpdateDialogController(bundle: Bundle? = null) : DialogController(bundle) { + + constructor(body: String, url: String) : this( + Bundle().apply { + putString(BODY_KEY, body) + putString(URL_KEY, url) + } + ) + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + return MaterialDialog(activity!!) + .title(res = R.string.update_check_notification_update_available) + .message(text = args.getString(BODY_KEY) ?: "") + .positiveButton(R.string.update_check_confirm) { + val appContext = applicationContext + if (appContext != null) { + // Start download + val url = args.getString(URL_KEY) ?: "" + UpdaterService.downloadUpdate(appContext, url) + } + } + .negativeButton(R.string.update_check_ignore) + } + + private companion object { + const val BODY_KEY = "NewUpdateDialogController.body" + const val URL_KEY = "NewUpdateDialogController.key" + } + } + + private fun copyDebugInfo() { + val deviceInfo = + """ + App version: ${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR}, ${BuildConfig.COMMIT_SHA}, ${BuildConfig.VERSION_CODE}) + Android version: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT}) + Android build ID: ${Build.DISPLAY} + Device brand: ${Build.BRAND} + Device manufacturer: ${Build.MANUFACTURER} + Device name: ${Build.DEVICE} + Device model: ${Build.MODEL} + Device product name: ${Build.PRODUCT} + """.trimIndent() + + activity?.copyToClipboard("Debug information", deviceInfo) + } + + private fun getFormattedBuildTime(): String { + return try { + val inputDf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'", Locale.US) + inputDf.timeZone = TimeZone.getTimeZone("UTC") + val buildTime = inputDf.parse(BuildConfig.BUILD_TIME) + + val outputDf = DateFormat.getDateTimeInstance( + DateFormat.MEDIUM, DateFormat.SHORT, Locale.getDefault() + ) + outputDf.timeZone = TimeZone.getDefault() + + buildTime.toDateTimestampString(dateFormat) + } catch (e: ParseException) { + BuildConfig.BUILD_TIME + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreController.kt new file mode 100644 index 000000000..253ac016a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreController.kt @@ -0,0 +1,146 @@ +package eu.kanade.tachiyomi.ui.more + +import android.content.Context +import android.util.AttributeSet +import androidx.preference.Preference +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.download.DownloadManager +import eu.kanade.tachiyomi.data.download.DownloadService +import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys +import eu.kanade.tachiyomi.ui.base.controller.NoToolbarElevationController +import eu.kanade.tachiyomi.ui.base.controller.RootController +import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction +import eu.kanade.tachiyomi.ui.download.DownloadController +import eu.kanade.tachiyomi.ui.migration.MigrationController +import eu.kanade.tachiyomi.ui.setting.SettingsController +import eu.kanade.tachiyomi.ui.setting.SettingsMainController +import eu.kanade.tachiyomi.util.preference.add +import eu.kanade.tachiyomi.util.preference.iconRes +import eu.kanade.tachiyomi.util.preference.iconTint +import eu.kanade.tachiyomi.util.preference.onClick +import eu.kanade.tachiyomi.util.preference.preference +import eu.kanade.tachiyomi.util.preference.preferenceCategory +import eu.kanade.tachiyomi.util.preference.summaryRes +import eu.kanade.tachiyomi.util.preference.switchPreference +import eu.kanade.tachiyomi.util.preference.titleRes +import eu.kanade.tachiyomi.util.system.getResourceColor +import eu.kanade.tachiyomi.util.system.openInBrowser +import rx.android.schedulers.AndroidSchedulers +import uy.kohesive.injekt.injectLazy + +class MoreController : + SettingsController(), + RootController, + NoToolbarElevationController { + + private val downloadManager: DownloadManager by injectLazy() + private var isDownloading: Boolean = false + private var downloadQueueSize: Int = 0 + + override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { + titleRes = R.string.label_more + + val tintColor = context.getResourceColor(R.attr.colorAccent) + + add(MoreHeaderPreference(context)) + + switchPreference { + key = Keys.downloadedOnly + titleRes = R.string.label_downloaded_only + summaryRes = R.string.downloaded_only_summary + iconRes = R.drawable.ic_cloud_off_24dp + iconTint = tintColor + } + + preferenceCategory { + preference { + titleRes = R.string.label_download_queue + + if (downloadManager.queue.isNotEmpty()) { + initDownloadQueueSummary(this) + } + + iconRes = R.drawable.ic_file_download_black_24dp + iconTint = tintColor + onClick { + router.pushController(DownloadController().withFadeTransaction()) + } + } + preference { + titleRes = R.string.label_migration + iconRes = R.drawable.ic_compare_arrows_black_24dp + iconTint = tintColor + onClick { + router.pushController(MigrationController().withFadeTransaction()) + } + } + } + + preferenceCategory { + preference { + titleRes = R.string.label_settings + iconRes = R.drawable.ic_settings_24dp + iconTint = tintColor + onClick { + router.pushController(SettingsMainController().withFadeTransaction()) + } + } + preference { + iconRes = R.drawable.ic_info_24dp + iconTint = tintColor + titleRes = R.string.pref_category_about + onClick { + router.pushController(AboutController().withFadeTransaction()) + } + } + preference { + titleRes = R.string.label_help + iconRes = R.drawable.ic_help_24dp + iconTint = tintColor + onClick { + activity?.openInBrowser(URL_HELP) + } + } + } + } + + private fun initDownloadQueueSummary(preference: Preference) { + // Handle running/paused status change + DownloadService.runningRelay + .observeOn(AndroidSchedulers.mainThread()) + .subscribeUntilDestroy { isRunning -> + isDownloading = isRunning + updateDownloadQueueSummary(preference) + } + + // Handle queue progress updating + downloadManager.queue.getUpdatedObservable() + .observeOn(AndroidSchedulers.mainThread()) + .subscribeUntilDestroy { + downloadQueueSize = it.size + updateDownloadQueueSummary(preference) + } + } + + private fun updateDownloadQueueSummary(preference: Preference) { + preference.summary = when { + downloadQueueSize == 0 -> null + !isDownloading -> resources?.getString(R.string.paused) + else -> resources?.getQuantityString(R.plurals.download_queue_summary, downloadQueueSize, downloadQueueSize) + } + } + + private class MoreHeaderPreference @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + Preference(context, attrs) { + + init { + layoutResource = R.layout.pref_more_header + isSelectable = false + } + } + + companion object { + private const val URL_HELP = "https://tachiyomi.org/help/" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ChapterLoadStrategy.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ChapterLoadStrategy.kt index e25abc014..b07c803f2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ChapterLoadStrategy.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ChapterLoadStrategy.kt @@ -26,7 +26,8 @@ class ChapterLoadByNumber { // If there is only one chapter for this number, use it chaptersForNumber.size == 1 -> chaptersForNumber.first() // Prefer a chapter of the same scanlator as the selected - else -> chaptersForNumber.find { it.scanlator == selectedChapter.scanlator } + else -> + chaptersForNumber.find { it.scanlator == selectedChapter.scanlator } ?: chaptersForNumber.first() } chapters.add(preferredChapter) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/PageIndicatorTextView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/PageIndicatorTextView.kt index 9be01b494..d3f4db747 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/PageIndicatorTextView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/PageIndicatorTextView.kt @@ -2,37 +2,24 @@ package eu.kanade.tachiyomi.ui.reader import android.annotation.SuppressLint import android.content.Context -import android.graphics.Canvas import android.graphics.Color -import android.graphics.Paint import android.text.Spannable import android.text.SpannableString import android.text.style.ScaleXSpan import android.util.AttributeSet -import android.widget.TextView import androidx.appcompat.widget.AppCompatTextView +import eu.kanade.tachiyomi.widget.OutlineSpan /** * Page indicator found at the bottom of the reader */ class PageIndicatorTextView( - context: Context, - attrs: AttributeSet? = null + context: Context, + attrs: AttributeSet? = null ) : AppCompatTextView(context, attrs) { - private val fillColor = Color.rgb(235, 235, 235) - private val strokeColor = Color.rgb(45, 45, 45) - - override fun onDraw(canvas: Canvas) { - textColorField.set(this, strokeColor) - paint.strokeWidth = 4f - paint.style = Paint.Style.STROKE - super.onDraw(canvas) - - textColorField.set(this, fillColor) - paint.strokeWidth = 0f - paint.style = Paint.Style.FILL - super.onDraw(canvas) + init { + setTextColor(fillColor) } @SuppressLint("SetTextI18n") @@ -42,20 +29,26 @@ class PageIndicatorTextView( val currText = " $text " // Also add a bit of spacing between each character, as the stroke overlaps them - val finalText = SpannableString(currText.asIterable().joinToString("\u00A0")) + val finalText = SpannableString(currText.asIterable().joinToString("\u00A0")).apply { + // Apply text outline + setSpan(spanOutline, 1, length - 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - for (i in 1..finalText.lastIndex step 2) { - finalText.setSpan(ScaleXSpan(0.1f), i, i + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + for (i in 1..lastIndex step 2) { + setSpan(ScaleXSpan(0.2f), i, i + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } } - super.setText(finalText, TextView.BufferType.SPANNABLE) + super.setText(finalText, BufferType.SPANNABLE) } private companion object { - // We need to use reflection to set the text color instead of using [setTextColor], - // otherwise the view is invalidated inside [onDraw] and there's an infinite loop - val textColorField = TextView::class.java.getDeclaredField("mCurTextColor").apply { - isAccessible = true - }!! + private val fillColor = Color.rgb(235, 235, 235) + private val strokeColor = Color.rgb(45, 45, 45) + + // A span object with text outlining properties + val spanOutline = OutlineSpan( + strokeColor = strokeColor, + strokeWidth = 4f + ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt index e0499e4fa..1c94f98d3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.ui.reader import android.annotation.SuppressLint +import android.annotation.TargetApi import android.app.ProgressDialog import android.content.Context import android.content.Intent @@ -10,21 +11,26 @@ import android.graphics.Bitmap import android.graphics.Color import android.os.Build import android.os.Bundle -import android.view.* +import android.view.KeyEvent +import android.view.Menu +import android.view.MenuItem +import android.view.MotionEvent +import android.view.View +import android.view.WindowManager import android.view.animation.Animation import android.view.animation.AnimationUtils import android.widget.SeekBar +import androidx.core.view.ViewCompat import com.afollestad.materialdialogs.MaterialDialog import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.elvishew.xlog.XLog -import com.jakewharton.rxbinding.view.clicks -import com.jakewharton.rxbinding.widget.checkedChanges -import com.jakewharton.rxbinding.widget.textChanges import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.notification.NotificationReceiver +import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.databinding.ReaderActivityBinding import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.online.all.EHentai @@ -37,40 +43,56 @@ import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters import eu.kanade.tachiyomi.ui.reader.viewer.BaseViewer -import eu.kanade.tachiyomi.ui.reader.viewer.pager.* +import eu.kanade.tachiyomi.ui.reader.viewer.pager.L2RPagerViewer +import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerViewer +import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer +import eu.kanade.tachiyomi.ui.reader.viewer.pager.VerticalPagerViewer import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer -import eu.kanade.tachiyomi.util.* +import eu.kanade.tachiyomi.util.lang.plusAssign +import eu.kanade.tachiyomi.util.storage.getUriCompat +import eu.kanade.tachiyomi.util.system.GLUtil +import eu.kanade.tachiyomi.util.system.toast +import eu.kanade.tachiyomi.util.view.defaultBar +import eu.kanade.tachiyomi.util.view.gone +import eu.kanade.tachiyomi.util.view.hideBar +import eu.kanade.tachiyomi.util.view.isDefaultBar +import eu.kanade.tachiyomi.util.view.showBar +import eu.kanade.tachiyomi.util.view.visible import eu.kanade.tachiyomi.widget.SimpleAnimationListener import eu.kanade.tachiyomi.widget.SimpleSeekBarListener -import kotlinx.android.synthetic.main.reader_activity.* -import me.zhanghai.android.systemuihelper.SystemUiHelper +import java.io.File +import java.util.concurrent.TimeUnit +import kotlin.math.abs +import kotlin.math.roundToLong +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.observeOn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.sample import nucleus.factory.RequiresPresenter +import reactivecircus.flowbinding.android.view.clicks +import reactivecircus.flowbinding.android.widget.checkedChanges +import reactivecircus.flowbinding.android.widget.textChanges import rx.Observable import rx.Subscription import rx.android.schedulers.AndroidSchedulers import rx.subscriptions.CompositeSubscription import timber.log.Timber import uy.kohesive.injekt.injectLazy -import java.io.File -import java.util.concurrent.TimeUnit -import kotlin.math.roundToLong /** * Activity containing the reader of Tachiyomi. This activity is mostly a container of the * viewers, to which calls from the presenter or UI events are delegated. */ @RequiresPresenter(ReaderPresenter::class) -class ReaderActivity : BaseRxActivity() { +class ReaderActivity : BaseRxActivity() { - /** - * Preferences helper. - */ private val preferences by injectLazy() /** * The maximum bitmap size supported by the device. */ - val maxBitmapSize by lazy { GLUtil.getMaxTextureSize() } + val maxBitmapSize by lazy { GLUtil.maxTextureSize } /** * Viewer used to display the pages (pager, webtoon, ...). @@ -93,16 +115,9 @@ class ReaderActivity : BaseRxActivity() { private val sourceManager: SourceManager by injectLazy() private val prefs: PreferencesHelper by injectLazy() - val showTransitionPages by lazy { prefs.eh_showTransitionPages().getOrDefault() } - private val logger = XLog.tag("ReaderActivity") // <-- EH - /** - * System UI helper to hide status & navigation bar on all different API levels. - */ - private var systemUi: SystemUiHelper? = null - /** * Configuration at reader level, like background color or forced orientation. */ @@ -120,42 +135,48 @@ class ReaderActivity : BaseRxActivity() { const val RIGHT_TO_LEFT = 2 const val VERTICAL = 3 const val WEBTOON = 4 + const val VERTICAL_PLUS = 5 fun newIntent(context: Context, manga: Manga, chapter: Chapter): Intent { - val intent = Intent(context, ReaderActivity::class.java) - intent.putExtra("manga", manga.id) - intent.putExtra("chapter", chapter.id) - return intent + return Intent(context, ReaderActivity::class.java).apply { + putExtra("manga", manga.id) + putExtra("chapter", chapter.id) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } } } /** * Called when the activity is created. Initializes the presenter and configuration. */ - override fun onCreate(savedState: Bundle?) { - setTheme(when (preferences.readerTheme().getOrDefault()) { - 0 -> R.style.Theme_Reader_Light - else -> R.style.Theme_Reader - }) - super.onCreate(savedState) - setContentView(R.layout.reader_activity) + override fun onCreate(savedInstanceState: Bundle?) { + setTheme( + when (preferences.readerTheme().get()) { + 0 -> R.style.Theme_Reader_Light + 2 -> R.style.Theme_Reader_Dark_Grey + else -> R.style.Theme_Reader_Dark + } + ) + super.onCreate(savedInstanceState) + + binding = ReaderActivityBinding.inflate(layoutInflater) + setContentView(binding.root) if (presenter.needsInit()) { val manga = intent.extras!!.getLong("manga", -1) val chapter = intent.extras!!.getLong("chapter", -1) - if (manga == -1L || chapter == -1L) { finish() return } - + NotificationReceiver.dismissNotification(this, manga.hashCode(), Notifications.ID_NEW_CHAPTERS) presenter.init(manga, chapter) } - if (savedState != null) { - menuVisible = savedState.getBoolean(::menuVisible.name) + if (savedInstanceState != null) { + menuVisible = savedInstanceState.getBoolean(::menuVisible.name) // --> EH - ehUtilsVisible = savedState.getBoolean(::ehUtilsVisible.name) + ehUtilsVisible = savedInstanceState.getBoolean(::ehUtilsVisible.name) // <-- EH } @@ -165,31 +186,33 @@ class ReaderActivity : BaseRxActivity() { // --> EH private fun setEhUtilsVisibility(visible: Boolean) { - if(visible) { - eh_utils.visible() - expand_eh_button.setImageResource(R.drawable.ic_keyboard_arrow_up_white_32dp) + if (visible) { + binding.ehUtils.visible() + binding.expandEhButton.setImageResource(R.drawable.ic_keyboard_arrow_up_white_32dp) } else { - eh_utils.gone() - expand_eh_button.setImageResource(R.drawable.ic_keyboard_arrow_down_white_32dp) + binding.ehUtils.gone() + binding.expandEhButton.setImageResource(R.drawable.ic_keyboard_arrow_down_white_32dp) } } + // <-- EH + // --> EH private fun setupAutoscroll(interval: Float) { exhSubscriptions.remove(autoscrollSubscription) autoscrollSubscription = null - if(interval == -1f) return + if (interval == -1f) return val intervalMs = (interval * 1000).roundToLong() val sub = Observable.interval(intervalMs, intervalMs, TimeUnit.MILLISECONDS) - .onBackpressureDrop() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - viewer.let { v -> - if(v is PagerViewer) v.moveToNext() - else if(v is WebtoonViewer) v.scrollDown() - } + .onBackpressureDrop() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + viewer.let { v -> + if (v is PagerViewer) v.moveToNext() + else if (v is WebtoonViewer) v.scrollDown() } + } autoscrollSubscription = sub exhSubscriptions += sub @@ -224,9 +247,18 @@ class ReaderActivity : BaseRxActivity() { super.onSaveInstanceState(outState) } + /** + * Set menu visibility again on activity resume to apply immersive mode again if needed. + * Helps with rotations. + */ + override fun onResume() { + super.onResume() + setMenuVisibility(menuVisible, animate = false) + } + /** * Called when the window focus changes. It sets the menu visibility to the last known state - * to apply again System UI (for immersive mode). + * to apply immersive mode again if needed. */ override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) @@ -240,6 +272,11 @@ class ReaderActivity : BaseRxActivity() { */ override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.reader, menu) + + val isChapterBookmarked = presenter?.getCurrentChapter()?.chapter?.bookmark ?: false + menu.findItem(R.id.action_bookmark).isVisible = !isChapterBookmarked + menu.findItem(R.id.action_remove_bookmark).isVisible = isChapterBookmarked + return true } @@ -249,11 +286,18 @@ class ReaderActivity : BaseRxActivity() { */ override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { + R.id.action_bookmark -> { + presenter.bookmarkCurrentChapter(true) + invalidateOptionsMenu() + } + R.id.action_remove_bookmark -> { + presenter.bookmarkCurrentChapter(false) + invalidateOptionsMenu() + } R.id.action_settings -> ReaderSettingsSheet(this).show() R.id.action_custom_filter -> ReaderColorFilterSheet(this).show() - else -> return super.onOptionsItemSelected(item) } - return true + return super.onOptionsItemSelected(item) } /** @@ -287,113 +331,143 @@ class ReaderActivity : BaseRxActivity() { */ private fun initializeMenu() { // Set toolbar - setSupportActionBar(toolbar) + setSupportActionBar(binding.toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) - toolbar.setNavigationOnClickListener { + binding.toolbar.setNavigationOnClickListener { onBackPressed() } + ViewCompat.setOnApplyWindowInsetsListener(binding.readerMenu) { _, insets -> + if (!window.isDefaultBar()) { + binding.readerMenu.setPadding( + insets.systemWindowInsetLeft, + insets.systemWindowInsetTop, + insets.systemWindowInsetRight, + insets.systemWindowInsetBottom + ) + } + insets + } + // Init listeners on bottom menu - page_seekbar.setOnSeekBarChangeListener(object : SimpleSeekBarListener() { + binding.pageSeekbar.setOnSeekBarChangeListener(object : SimpleSeekBarListener() { override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) { if (viewer != null && fromUser) { moveToPageIndex(value) } } }) - left_chapter.setOnClickListener { + binding.leftChapter.setOnClickListener { if (viewer != null) { - if (viewer is R2LPagerViewer) + if (viewer is R2LPagerViewer) { loadNextChapter() - else + } else { loadPreviousChapter() + } } } - right_chapter.setOnClickListener { + binding.rightChapter.setOnClickListener { if (viewer != null) { - if (viewer is R2LPagerViewer) + if (viewer is R2LPagerViewer) { loadPreviousChapter() - else + } else { loadNextChapter() + } } } // --> EH - exhSubscriptions += expand_eh_button.clicks().subscribe { - ehUtilsVisible = !ehUtilsVisible - setEhUtilsVisibility(ehUtilsVisible) - } + binding.expandEhButton.clicks() + .onEach { + ehUtilsVisible = !ehUtilsVisible + setEhUtilsVisibility(ehUtilsVisible) + } + .launchIn(scope) - eh_autoscroll_freq.setText(preferences.eh_utilAutoscrollInterval().getOrDefault().let { - if(it == -1f) - "" - else it.toString() - }) - - exhSubscriptions += eh_autoscroll.checkedChanges() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - setupAutoscroll(if(it) - preferences.eh_utilAutoscrollInterval().getOrDefault() - else -1f) + binding.ehAutoscrollFreq.setText( + preferences.eh_utilAutoscrollInterval().get().let { + if (it == -1f) { + "" + } else { + it.toString() } + } + ) - exhSubscriptions += eh_autoscroll_freq.textChanges() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - val parsed = it?.toString()?.toFloatOrNull() - - if (parsed == null || parsed <= 0 || parsed > 9999) { - eh_autoscroll_freq.error = "Invalid frequency" - preferences.eh_utilAutoscrollInterval().set(-1f) - eh_autoscroll.isEnabled = false - setupAutoscroll(-1f) + binding.ehAutoscroll.checkedChanges() + // .observeOn(AndroidSchedulers.mainThread()) + .onEach { + setupAutoscroll( + if (it) { + preferences.eh_utilAutoscrollInterval().get() } else { - eh_autoscroll_freq.error = null - preferences.eh_utilAutoscrollInterval().set(parsed) - eh_autoscroll.isEnabled = true - setupAutoscroll(if(eh_autoscroll.isChecked) parsed else -1f) + -1f } + ) + } + .launchIn(scope) + + binding.ehAutoscrollFreq.textChanges() + // .observeOn(AndroidSchedulers.mainThread()) + .onEach { + val parsed = it.toString().toFloatOrNull() + + if (parsed == null || parsed <= 0 || parsed > 9999) { + binding.ehAutoscrollFreq.error = "Invalid frequency" + preferences.eh_utilAutoscrollInterval().set(-1f) + binding.ehAutoscroll.isEnabled = false + setupAutoscroll(-1f) + } else { + binding.ehAutoscrollFreq.error = null + preferences.eh_utilAutoscrollInterval().set(parsed) + binding.ehAutoscroll.isEnabled = true + setupAutoscroll(if (binding.ehAutoscroll.isChecked) parsed else -1f) } + } + .launchIn(scope) - exhSubscriptions += eh_autoscroll_help.clicks().subscribe { - MaterialDialog.Builder(this) - .title("Autoscroll help") - .content("Automatically scroll to the next page in the specified interval. Interval is specified in seconds.") - .positiveText("Ok") + binding.ehAutoscrollHelp.clicks() + .onEach { + MaterialDialog(this) + .title(R.string.eh_autoscroll_help) + .message(R.string.eh_autoscroll_help_message) + .positiveButton(android.R.string.ok) .show() - } + } + .launchIn(scope) - exhSubscriptions += eh_retry_all.clicks().subscribe { - var retried = 0 + binding.ehRetryAll.clicks() + .onEach { + var retried = 0 - presenter.viewerChaptersRelay.value + presenter.viewerChaptersRelay.value .currChapter .pages - ?.forEachIndexed { index, page -> + ?.forEachIndexed { _, page -> var shouldQueuePage = false - if(page.status == Page.ERROR) { + if (page.status == Page.ERROR) { shouldQueuePage = true - } else if(page.status == Page.LOAD_PAGE - || page.status == Page.DOWNLOAD_IMAGE) { - // Do nothing - } + } /*else if (page.status == Page.LOAD_PAGE || + page.status == Page.DOWNLOAD_IMAGE) { + // Do nothing + }*/ - if(shouldQueuePage) { + if (shouldQueuePage) { page.status = Page.QUEUE } else { return@forEachIndexed } - //If we are using EHentai/ExHentai, get a new image URL + // If we are using EHentai/ExHentai, get a new image URL presenter.manga?.let { m -> val src = sourceManager.get(m.source) - if(src is EHentai) + if (src is EHentai) { page.imageUrl = null + } } val loader = page.chapter.pageLoader - if(page.index == exh_currentPage()?.index && loader is HttpPageLoader) { + if (page.index == exh_currentPage()?.index && loader is HttpPageLoader) { loader.boostPage(page) } else { loader?.retryPage(page) @@ -402,49 +476,56 @@ class ReaderActivity : BaseRxActivity() { retried++ } - toast("Retrying $retried failed pages...") - } + toast("Retrying $retried failed pages...") + } + .launchIn(scope) - exhSubscriptions += eh_retry_all_help.clicks().subscribe { - MaterialDialog.Builder(this) - .title("Retry all help") - .content("Re-add all failed pages to the download queue.") - .positiveText("Ok") + binding.ehRetryAllHelp.clicks() + .onEach { + MaterialDialog(this) + .title(R.string.eh_retry_all_help) + .message(R.string.eh_retry_all_help_message) + .positiveButton(android.R.string.ok) .show() - } + } + .launchIn(scope) - exhSubscriptions += eh_boost_page.clicks().subscribe { - viewer?.let { viewer -> - val curPage = exh_currentPage() ?: run { - toast("This page cannot be boosted (invalid page)!") - return@let - } + binding.ehBoostPage.clicks() + .onEach { + viewer?.let { _ -> + val curPage = exh_currentPage() ?: run { + toast("This page cannot be boosted (invalid page)!") + return@let + } - if(curPage.status == Page.ERROR) { - toast("Page failed to load, press the retry button instead!") - } else if(curPage.status == Page.LOAD_PAGE || curPage.status == Page.DOWNLOAD_IMAGE) { - toast("This page is already downloading!") - } else if(curPage.status == Page.READY) { - toast("This page has already been downloaded!") - } else { - val loader = (presenter.viewerChaptersRelay.value.currChapter.pageLoader as? HttpPageLoader) - if(loader != null) { - loader.boostPage(curPage) - toast("Boosted current page!") + if (curPage.status == Page.ERROR) { + toast("Page failed to load, press the retry button instead!") + } else if (curPage.status == Page.LOAD_PAGE || curPage.status == Page.DOWNLOAD_IMAGE) { + toast("This page is already downloading!") + } else if (curPage.status == Page.READY) { + toast("This page has already been downloaded!") } else { - toast("This page cannot be boosted (invalid page loader)!") + val loader = (presenter.viewerChaptersRelay.value.currChapter.pageLoader as? HttpPageLoader) + if (loader != null) { + loader.boostPage(curPage) + toast("Boosted current page!") + } else { + toast("This page cannot be boosted (invalid page loader)!") + } } } } - } + .launchIn(scope) - exhSubscriptions += eh_boost_page_help.clicks().subscribe { - MaterialDialog.Builder(this) - .title("Boost page help") - .content("Normally the downloader can only download a specific amount of pages at the same time. This means you can be waiting for a page to download but the downloader will not start downloading the page until it has a free download slot. Pressing 'Boost page' will force the downloader to begin downloading the current page, regardless of whether or not there is an available slot.") - .positiveText("Ok") + binding.ehBoostPageHelp.clicks() + .onEach { + MaterialDialog(this) + .title(R.string.eh_boost_page_help) + .message(R.string.eh_boost_page_help_message) + .positiveButton(android.R.string.ok) .show() - } + } + .launchIn(scope) // <-- EH // Set initial visibility @@ -457,8 +538,12 @@ class ReaderActivity : BaseRxActivity() { // EXH --> private fun exh_currentPage(): ReaderPage? { - val currentPage = (((viewer as? PagerViewer)?.currentPage - ?: (viewer as? WebtoonViewer)?.currentPage) as? ReaderPage)?.index + val currentPage = ( + ( + (viewer as? PagerViewer)?.currentPage + ?: (viewer as? WebtoonViewer)?.currentPage + ) as? ReaderPage + )?.index return currentPage?.let { presenter.viewerChaptersRelay.value.currChapter.pages?.getOrNull(it) } } // EXH <-- @@ -470,47 +555,68 @@ class ReaderActivity : BaseRxActivity() { private fun setMenuVisibility(visible: Boolean, animate: Boolean = true) { menuVisible = visible if (visible) { - systemUi?.show() - reader_menu.visibility = View.VISIBLE + if (preferences.fullscreen().get()) { + window.showBar() + } else { + resetDefaultMenuAndBar() + } + binding.readerMenu.visible() if (animate) { val toolbarAnimation = AnimationUtils.loadAnimation(this, R.anim.enter_from_top) toolbarAnimation.setAnimationListener(object : SimpleAnimationListener() { override fun onAnimationStart(animation: Animation) { // Fix status bar being translucent the first time it's opened. - if (Build.VERSION.SDK_INT >= 21) { - window.addFlags( - WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) - } + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) } }) // EXH --> - header.startAnimation(toolbarAnimation) + binding.header.startAnimation(toolbarAnimation) // EXH <-- val bottomAnimation = AnimationUtils.loadAnimation(this, R.anim.enter_from_bottom) - reader_menu_bottom.startAnimation(bottomAnimation) + binding.readerMenuBottom.startAnimation(bottomAnimation) + } + + if (preferences.showPageNumber().get()) { + config?.setPageNumberVisibility(false) } } else { - systemUi?.hide() + if (preferences.fullscreen().get()) { + window.hideBar() + } else { + resetDefaultMenuAndBar() + } if (animate) { val toolbarAnimation = AnimationUtils.loadAnimation(this, R.anim.exit_to_top) toolbarAnimation.setAnimationListener(object : SimpleAnimationListener() { override fun onAnimationEnd(animation: Animation) { - reader_menu.visibility = View.GONE + binding.readerMenu.gone() } }) // EXH --> - header.startAnimation(toolbarAnimation) + binding.header.startAnimation(toolbarAnimation) // EXH <-- val bottomAnimation = AnimationUtils.loadAnimation(this, R.anim.exit_to_bottom) - reader_menu_bottom.startAnimation(bottomAnimation) + binding.readerMenuBottom.startAnimation(bottomAnimation) + } + + if (preferences.showPageNumber().get()) { + config?.setPageNumberVisibility(true) } } } + /** + * Reset menu padding and system bar + */ + private fun resetDefaultMenuAndBar() { + binding.readerMenu.setPadding(0, 0, 0, 0) + window.defaultBar() + } + /** * Called from the presenter when a manga is ready. Used to instantiate the appropriate viewer * and the toolbar title. @@ -521,23 +627,24 @@ class ReaderActivity : BaseRxActivity() { RIGHT_TO_LEFT -> R2LPagerViewer(this) VERTICAL -> VerticalPagerViewer(this) WEBTOON -> WebtoonViewer(this) + VERTICAL_PLUS -> WebtoonViewer(this, isContinuous = false) else -> L2RPagerViewer(this) } // Destroy previous viewer if there was one if (prevViewer != null) { prevViewer.destroy() - viewer_container.removeAllViews() + binding.viewerContainer.removeAllViews() } viewer = newViewer - viewer_container.addView(newViewer.getView()) + binding.viewerContainer.addView(newViewer.getView()) - toolbar.title = manga.title + binding.toolbar.title = manga.title - page_seekbar.isRTL = newViewer is R2LPagerViewer + binding.pageSeekbar.isRTL = newViewer is R2LPagerViewer - please_wait.visible() - please_wait.startAnimation(AnimationUtils.loadAnimation(this, R.anim.fade_in_long)) + binding.pleaseWait.visible() + binding.pleaseWait.startAnimation(AnimationUtils.loadAnimation(this, R.anim.fade_in_long)) } /** @@ -545,9 +652,12 @@ class ReaderActivity : BaseRxActivity() { * method to the current viewer, but also set the subtitle on the toolbar. */ fun setChapters(viewerChapters: ViewerChapters) { - please_wait.gone() + binding.pleaseWait.gone() viewer?.setChapters(viewerChapters) - toolbar.subtitle = viewerChapters.currChapter.chapter.name + binding.toolbar.subtitle = viewerChapters.currChapter.chapter.name + + // Invalidate menu to show proper chapter bookmark state + invalidateOptionsMenu() } /** @@ -613,20 +723,20 @@ class ReaderActivity : BaseRxActivity() { val pages = page.chapter.pages ?: return // Set bottom page number - page_number.text = "${page.number}/${pages.size}" + binding.pageNumber.text = "${page.number}/${pages.size}" // Set seekbar page number if (viewer !is R2LPagerViewer) { - left_page_text.text = "${page.number}" - right_page_text.text = "${pages.size}" + binding.leftPageText.text = "${page.number}" + binding.rightPageText.text = "${pages.size}" } else { - right_page_text.text = "${page.number}" - left_page_text.text = "${pages.size}" + binding.rightPageText.text = "${page.number}" + binding.leftPageText.text = "${pages.size}" } // Set seekbar progress - page_seekbar.max = pages.lastIndex - page_seekbar.progress = page.index + binding.pageSeekbar.max = pages.lastIndex + binding.pageSeekbar.progress = page.index } /** @@ -639,7 +749,7 @@ class ReaderActivity : BaseRxActivity() { // EXH <-- ReaderPageSheet(this, page).show() // EXH --> - } catch(e: WindowManager.BadTokenException) { + } catch (e: WindowManager.BadTokenException) { logger.e("Caught and ignoring reader page sheet launch exception!", e) } // EXH <-- @@ -728,11 +838,28 @@ class ReaderActivity : BaseRxActivity() { * depending on the [result]. */ fun onSetAsCoverResult(result: ReaderPresenter.SetAsCoverResult) { - toast(when (result) { - Success -> R.string.cover_updated - AddToLibraryFirst -> R.string.notification_first_add_to_library - Error -> R.string.notification_cover_update_failed - }) + toast( + when (result) { + Success -> R.string.cover_updated + AddToLibraryFirst -> R.string.notification_first_add_to_library + Error -> R.string.notification_cover_update_failed + } + ) + } + + /** + * Sets notch cutout mode to "NEVER", if mobile is in a landscape view + */ + private fun setNotchCutoutMode() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val currentOrientation = resources.configuration.orientation + + if (currentOrientation == Configuration.ORIENTATION_LANDSCAPE) { + val params = window.attributes + params.layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER + } + } } /** @@ -745,16 +872,6 @@ class ReaderActivity : BaseRxActivity() { */ private val subscriptions = CompositeSubscription() - /** - * Custom brightness subscription. - */ - private var customBrightnessSubscription: Subscription? = null - - /** - * Custom color filter subscription. - */ - private var customFilterColorSubscription: Subscription? = null - /** * Initializes the reader subscriptions. */ @@ -767,30 +884,40 @@ class ReaderActivity : BaseRxActivity() { subscriptions += Observable.merge(initialRotation, rotationUpdates) .subscribe { setOrientation(it) } - subscriptions += preferences.readerTheme().asObservable() - .skip(1) // We only care about updates - .subscribe { recreate() } + preferences.readerTheme().asFlow() + .drop(1) // We only care about updates + .onEach { recreate() } + .launchIn(scope) - subscriptions += preferences.showPageNumber().asObservable() - .subscribe { setPageNumberVisibility(it) } + preferences.showPageNumber().asFlow() + .onEach { setPageNumberVisibility(it) } + .launchIn(scope) - subscriptions += preferences.trueColor().asObservable() - .subscribe { setTrueColor(it) } + preferences.trueColor().asFlow() + .onEach { setTrueColor(it) } + .launchIn(scope) - subscriptions += preferences.fullscreen().asObservable() - .subscribe { setFullscreen(it) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + preferences.cutoutShort().asFlow() + .onEach { setCutoutShort(it) } + .launchIn(scope) + } - subscriptions += preferences.keepScreenOn().asObservable() - .subscribe { setKeepScreenOn(it) } + preferences.keepScreenOn().asFlow() + .onEach { setKeepScreenOn(it) } + .launchIn(scope) - subscriptions += preferences.customBrightness().asObservable() - .subscribe { setCustomBrightness(it) } + preferences.customBrightness().asFlow() + .onEach { setCustomBrightness(it) } + .launchIn(scope) - subscriptions += preferences.colorFilter().asObservable() - .subscribe { setColorFilter(it) } + preferences.colorFilter().asFlow() + .onEach { setColorFilter(it) } + .launchIn(scope) - subscriptions += preferences.colorFilterMode().asObservable() - .subscribe { setColorFilter(preferences.colorFilter().getOrDefault()) } + preferences.colorFilterMode().asFlow() + .onEach { setColorFilter(preferences.colorFilter().get()) } + .launchIn(scope) } /** @@ -798,8 +925,6 @@ class ReaderActivity : BaseRxActivity() { */ fun destroy() { subscriptions.unsubscribe() - customBrightnessSubscription = null - customFilterColorSubscription = null } /** @@ -832,36 +957,26 @@ class ReaderActivity : BaseRxActivity() { /** * Sets the visibility of the bottom page indicator according to [visible]. */ - private fun setPageNumberVisibility(visible: Boolean) { - page_number.visibility = if (visible) View.VISIBLE else View.INVISIBLE + fun setPageNumberVisibility(visible: Boolean) { + binding.pageNumber.visibility = if (visible) View.VISIBLE else View.INVISIBLE } /** * Sets the 32-bit color mode according to [enabled]. */ private fun setTrueColor(enabled: Boolean) { - if (enabled) + if (enabled) { SubsamplingScaleImageView.setPreferredBitmapConfig(Bitmap.Config.ARGB_8888) - else + } else { SubsamplingScaleImageView.setPreferredBitmapConfig(Bitmap.Config.RGB_565) + } } - /** - * Sets the fullscreen reading mode (immersive) according to [enabled]. - */ - private fun setFullscreen(enabled: Boolean) { - systemUi = if (enabled) { - val level = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { - SystemUiHelper.LEVEL_IMMERSIVE - } else { - SystemUiHelper.LEVEL_HIDE_STATUS_BAR - } - val flags = SystemUiHelper.FLAG_IMMERSIVE_STICKY or - SystemUiHelper.FLAG_LAYOUT_IN_SCREEN_OLDER_DEVICES - - SystemUiHelper(this@ReaderActivity, level, flags) - } else { - null + @TargetApi(Build.VERSION_CODES.P) + private fun setCutoutShort(enabled: Boolean) { + window.attributes.layoutInDisplayCutoutMode = when (enabled) { + true -> WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + false -> WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER } } @@ -881,13 +996,11 @@ class ReaderActivity : BaseRxActivity() { */ private fun setCustomBrightness(enabled: Boolean) { if (enabled) { - customBrightnessSubscription = preferences.customBrightnessValue().asObservable() - .sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) - .subscribe { setCustomBrightnessValue(it) } - - subscriptions.add(customBrightnessSubscription) + preferences.customBrightnessValue().asFlow() + .sample(100) + .onEach { setCustomBrightnessValue(it) } + .launchIn(scope) } else { - customBrightnessSubscription?.let { subscriptions.remove(it) } setCustomBrightnessValue(0) } } @@ -897,14 +1010,12 @@ class ReaderActivity : BaseRxActivity() { */ private fun setColorFilter(enabled: Boolean) { if (enabled) { - customFilterColorSubscription = preferences.colorFilterValue().asObservable() - .sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) - .subscribe { setColorFilterValue(it) } - - subscriptions.add(customFilterColorSubscription) + preferences.colorFilterValue().asFlow() + .sample(100) + .onEach { setColorFilterValue(it) } + .launchIn(scope) } else { - customFilterColorSubscription?.let { subscriptions.remove(it) } - color_overlay.visibility = View.GONE + binding.colorOverlay.gone() } } @@ -916,21 +1027,25 @@ class ReaderActivity : BaseRxActivity() { */ private fun setCustomBrightnessValue(value: Int) { // Calculate and set reader brightness. - val readerBrightness = if (value > 0) { - value / 100f - } else if (value < 0) { - 0.01f - } else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + val readerBrightness = when { + value > 0 -> { + value / 100f + } + value < 0 -> { + 0.01f + } + else -> WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + } window.attributes = window.attributes.apply { screenBrightness = readerBrightness } // Set black overlay visibility. if (value < 0) { - brightness_overlay.visibility = View.VISIBLE - val alpha = (Math.abs(value) * 2.56).toInt() - brightness_overlay.setBackgroundColor(Color.argb(alpha, 0, 0, 0)) + binding.brightnessOverlay.visible() + val alpha = (abs(value) * 2.56).toInt() + binding.brightnessOverlay.setBackgroundColor(Color.argb(alpha, 0, 0, 0)) } else { - brightness_overlay.visibility = View.GONE + binding.brightnessOverlay.gone() } } @@ -938,10 +1053,8 @@ class ReaderActivity : BaseRxActivity() { * Sets the color filter [value]. */ private fun setColorFilterValue(value: Int) { - color_overlay.visibility = View.VISIBLE - color_overlay.setFilterColor(value, preferences.colorFilterMode().getOrDefault()) + binding.colorOverlay.visible() + binding.colorOverlay.setFilterColor(value, preferences.colorFilterMode().get()) } - } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderColorFilterSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderColorFilterSheet.kt index 4182bdb76..d4d064eda 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderColorFilterSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderColorFilterSheet.kt @@ -9,61 +9,61 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.util.plusAssign +import eu.kanade.tachiyomi.util.view.gone +import eu.kanade.tachiyomi.util.view.visible import eu.kanade.tachiyomi.widget.IgnoreFirstSpinnerListener import eu.kanade.tachiyomi.widget.SimpleSeekBarListener -import kotlinx.android.synthetic.main.reader_color_filter.* -import kotlinx.android.synthetic.main.reader_color_filter_sheet.* -import rx.Subscription -import rx.android.schedulers.AndroidSchedulers -import rx.subscriptions.CompositeSubscription +import kotlin.math.abs +import kotlinx.android.synthetic.main.reader_color_filter.brightness_seekbar +import kotlinx.android.synthetic.main.reader_color_filter.color_filter_mode +import kotlinx.android.synthetic.main.reader_color_filter.custom_brightness +import kotlinx.android.synthetic.main.reader_color_filter.seekbar_color_filter_alpha +import kotlinx.android.synthetic.main.reader_color_filter.seekbar_color_filter_blue +import kotlinx.android.synthetic.main.reader_color_filter.seekbar_color_filter_green +import kotlinx.android.synthetic.main.reader_color_filter.seekbar_color_filter_red +import kotlinx.android.synthetic.main.reader_color_filter.switch_color_filter +import kotlinx.android.synthetic.main.reader_color_filter.txt_brightness_seekbar_value +import kotlinx.android.synthetic.main.reader_color_filter.txt_color_filter_alpha_value +import kotlinx.android.synthetic.main.reader_color_filter.txt_color_filter_blue_value +import kotlinx.android.synthetic.main.reader_color_filter.txt_color_filter_green_value +import kotlinx.android.synthetic.main.reader_color_filter.txt_color_filter_red_value +import kotlinx.android.synthetic.main.reader_color_filter_sheet.brightness_overlay +import kotlinx.android.synthetic.main.reader_color_filter_sheet.color_overlay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.sample import uy.kohesive.injekt.injectLazy -import java.util.concurrent.TimeUnit /** * Color filter sheet to toggle custom filter and brightness overlay. */ -class ReaderColorFilterSheet(activity: ReaderActivity) : BottomSheetDialog(activity) { +class ReaderColorFilterSheet(private val activity: ReaderActivity) : BottomSheetDialog(activity) { private val preferences by injectLazy() - private var behavior: BottomSheetBehavior<*>? = null - - /** - * Subscriptions used for this dialog - */ - private val subscriptions = CompositeSubscription() - - /** - * Subscription used for custom brightness overlay - */ - private var customBrightnessSubscription: Subscription? = null - - /** - * Subscription used for color filter overlay - */ - private var customFilterColorSubscription: Subscription? = null + private var sheetBehavior: BottomSheetBehavior<*>? = null init { val view = activity.layoutInflater.inflate(R.layout.reader_color_filter_sheet, null) setContentView(view) - behavior = BottomSheetBehavior.from(view.parent as ViewGroup) + sheetBehavior = BottomSheetBehavior.from(view.parent as ViewGroup) - // Initialize subscriptions. - subscriptions += preferences.colorFilter().asObservable() - .subscribe { setColorFilter(it, view) } + preferences.colorFilter().asFlow() + .onEach { setColorFilter(it, view) } + .launchIn(activity.scope) - subscriptions += preferences.colorFilterMode().asObservable() - .subscribe { setColorFilter(preferences.colorFilter().getOrDefault(), view) } + preferences.colorFilterMode().asFlow() + .onEach { setColorFilter(preferences.colorFilter().get(), view) } + .launchIn(activity.scope) - subscriptions += preferences.customBrightness().asObservable() - .subscribe { setCustomBrightness(it, view) } + preferences.customBrightness().asFlow() + .onEach { setCustomBrightness(it, view) } + .launchIn(activity.scope) // Get color and update values - val color = preferences.colorFilterValue().getOrDefault() - val brightness = preferences.customBrightnessValue().getOrDefault() + val color = preferences.colorFilterValue().get() + val brightness = preferences.customBrightnessValue().get() val argb = setValues(color, view) @@ -78,12 +78,12 @@ class ReaderColorFilterSheet(activity: ReaderActivity) : BottomSheetDialog(activ seekbar_color_filter_blue.progress = argb[3] // Set listeners - switch_color_filter.isChecked = preferences.colorFilter().getOrDefault() + switch_color_filter.isChecked = preferences.colorFilter().get() switch_color_filter.setOnCheckedChangeListener { _, isChecked -> preferences.colorFilter().set(isChecked) } - custom_brightness.isChecked = preferences.customBrightness().getOrDefault() + custom_brightness.isChecked = preferences.customBrightness().get() custom_brightness.setOnCheckedChangeListener { _, isChecked -> preferences.customBrightness().set(isChecked) } @@ -91,7 +91,7 @@ class ReaderColorFilterSheet(activity: ReaderActivity) : BottomSheetDialog(activ color_filter_mode.onItemSelectedListener = IgnoreFirstSpinnerListener { position -> preferences.colorFilterMode().set(position) } - color_filter_mode.setSelection(preferences.colorFilterMode().getOrDefault(), false) + color_filter_mode.setSelection(preferences.colorFilterMode().get(), false) seekbar_color_filter_alpha.setOnSeekBarChangeListener(object : SimpleSeekBarListener() { override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) { @@ -136,15 +136,8 @@ class ReaderColorFilterSheet(activity: ReaderActivity) : BottomSheetDialog(activ override fun onStart() { super.onStart() - behavior?.skipCollapsed = true - behavior?.state = BottomSheetBehavior.STATE_EXPANDED - } - - override fun onDetachedFromWindow() { - super.onDetachedFromWindow() - subscriptions.unsubscribe() - customBrightnessSubscription = null - customFilterColorSubscription = null + sheetBehavior?.skipCollapsed = true + sheetBehavior?.state = BottomSheetBehavior.STATE_EXPANDED } /** @@ -179,16 +172,12 @@ class ReaderColorFilterSheet(activity: ReaderActivity) : BottomSheetDialog(activ val green = getGreenFromColor(color) val blue = getBlueFromColor(color) - //Initialize values - with(view) { - txt_color_filter_alpha_value.text = alpha.toString() + // Initialize values + txt_color_filter_alpha_value.text = alpha.toString() + txt_color_filter_red_value.text = red.toString() + txt_color_filter_green_value.text = green.toString() + txt_color_filter_blue_value.text = blue.toString() - txt_color_filter_red_value.text = red.toString() - - txt_color_filter_green_value.text = green.toString() - - txt_color_filter_blue_value.text = blue.toString() - } return arrayOf(alpha, red, green, blue) } @@ -199,13 +188,11 @@ class ReaderColorFilterSheet(activity: ReaderActivity) : BottomSheetDialog(activ */ private fun setCustomBrightness(enabled: Boolean, view: View) { if (enabled) { - customBrightnessSubscription = preferences.customBrightnessValue().asObservable() - .sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) - .subscribe { setCustomBrightnessValue(it, view) } - - subscriptions.add(customBrightnessSubscription) + preferences.customBrightnessValue().asFlow() + .sample(100) + .onEach { setCustomBrightnessValue(it, view) } + .launchIn(activity.scope) } else { - customBrightnessSubscription?.let { subscriptions.remove(it) } setCustomBrightnessValue(0, view, true) } setCustomBrightnessSeekBar(enabled, view) @@ -220,15 +207,16 @@ class ReaderColorFilterSheet(activity: ReaderActivity) : BottomSheetDialog(activ private fun setCustomBrightnessValue(value: Int, view: View, isDisabled: Boolean = false) = with(view) { // Set black overlay visibility. if (value < 0) { - brightness_overlay.visibility = View.VISIBLE - val alpha = (Math.abs(value) * 2.56).toInt() + brightness_overlay.visible() + val alpha = (abs(value) * 2.56).toInt() brightness_overlay.setBackgroundColor(Color.argb(alpha, 0, 0, 0)) } else { - brightness_overlay.visibility = View.GONE + brightness_overlay.gone() } - if (!isDisabled) + if (!isDisabled) { txt_brightness_seekbar_value.text = value.toString() + } } /** @@ -238,14 +226,12 @@ class ReaderColorFilterSheet(activity: ReaderActivity) : BottomSheetDialog(activ */ private fun setColorFilter(enabled: Boolean, view: View) { if (enabled) { - customFilterColorSubscription = preferences.colorFilterValue().asObservable() - .sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) - .subscribe { setColorFilterValue(it, view) } - - subscriptions.add(customFilterColorSubscription) + preferences.colorFilterValue().asFlow() + .sample(100) + .onEach { setColorFilterValue(it, view) } + .launchIn(activity.scope) } else { - customFilterColorSubscription?.let { subscriptions.remove(it) } - color_overlay.visibility = View.GONE + color_overlay.gone() } setColorFilterSeekBar(enabled, view) } @@ -256,8 +242,8 @@ class ReaderColorFilterSheet(activity: ReaderActivity) : BottomSheetDialog(activ * @param view view of the dialog */ private fun setColorFilterValue(@ColorInt color: Int, view: View) = with(view) { - color_overlay.visibility = View.VISIBLE - color_overlay.setFilterColor(color, preferences.colorFilterMode().getOrDefault()) + color_overlay.visible() + color_overlay.setFilterColor(color, preferences.colorFilterMode().get()) setValues(color, view) } @@ -268,7 +254,7 @@ class ReaderColorFilterSheet(activity: ReaderActivity) : BottomSheetDialog(activ * @param bitShift amounts of bits that gets shifted to receive value */ fun setColorValue(color: Int, mask: Long, bitShift: Int) { - val currentColor = preferences.colorFilterValue().getOrDefault() + val currentColor = preferences.colorFilterValue().get() val updatedColor = (color shl bitShift) or (currentColor and mask.inv().toInt()) preferences.colorFilterValue().set(updatedColor) } @@ -322,5 +308,4 @@ class ReaderColorFilterSheet(activity: ReaderActivity) : BottomSheetDialog(activ /** Integer mask of blue value **/ const val BLUE_MASK: Long = 0x000000FF } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderColorFilterView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderColorFilterView.kt index 01d91a3b4..4468ed32d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderColorFilterView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderColorFilterView.kt @@ -1,27 +1,32 @@ package eu.kanade.tachiyomi.ui.reader import android.content.Context -import android.graphics.* +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode import android.util.AttributeSet import android.view.View class ReaderColorFilterView( - context: Context, - attrs: AttributeSet? = null + context: Context, + attrs: AttributeSet? = null ) : View(context, attrs) { private val colorFilterPaint: Paint = Paint() fun setFilterColor(color: Int, filterMode: Int) { - colorFilterPaint.setColor(color) - colorFilterPaint.xfermode = PorterDuffXfermode(when (filterMode) { - 1 -> PorterDuff.Mode.MULTIPLY - 2 -> PorterDuff.Mode.SCREEN - 3 -> PorterDuff.Mode.OVERLAY - 4 -> PorterDuff.Mode.LIGHTEN - 5 -> PorterDuff.Mode.DARKEN - else -> PorterDuff.Mode.SRC_OVER - }) + colorFilterPaint.color = color + colorFilterPaint.xfermode = PorterDuffXfermode( + when (filterMode) { + 1 -> PorterDuff.Mode.MULTIPLY + 2 -> PorterDuff.Mode.SCREEN + 3 -> PorterDuff.Mode.OVERLAY + 4 -> PorterDuff.Mode.LIGHTEN + 5 -> PorterDuff.Mode.DARKEN + else -> PorterDuff.Mode.SRC_OVER + } + ) invalidate() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPageSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPageSheet.kt index f21583aa9..201c7d190 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPageSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPageSheet.kt @@ -7,19 +7,18 @@ import com.google.android.material.bottomsheet.BottomSheetDialog import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.model.ReaderPage -import kotlinx.android.synthetic.main.reader_page_sheet.* +import kotlinx.android.synthetic.main.reader_page_sheet.save_layout +import kotlinx.android.synthetic.main.reader_page_sheet.set_as_cover_layout +import kotlinx.android.synthetic.main.reader_page_sheet.share_layout /** * Sheet to show when a page is long clicked. */ class ReaderPageSheet( - private val activity: ReaderActivity, - private val page: ReaderPage + private val activity: ReaderActivity, + private val page: ReaderPage ) : BottomSheetDialog(activity) { - /** - * View used on this sheet. - */ private val view = activity.layoutInflater.inflate(R.layout.reader_page_sheet, null) init { @@ -44,14 +43,13 @@ class ReaderPageSheet( private fun setAsCover() { if (page.status != Page.READY) return - MaterialDialog.Builder(activity) - .content(activity.getString(R.string.confirm_set_image_as_cover)) - .positiveText(android.R.string.yes) - .negativeText(android.R.string.no) - .onPositive { _, _ -> + MaterialDialog(activity) + .message(R.string.confirm_set_image_as_cover) + .positiveButton(android.R.string.ok) { activity.setAsCover(page) dismiss() } + .negativeButton(android.R.string.cancel) .show() } @@ -70,5 +68,4 @@ class ReaderPageSheet( activity.saveImage(page) dismiss() } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt index 599519b90..261c220b2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt @@ -21,8 +21,13 @@ import eu.kanade.tachiyomi.ui.reader.loader.DownloadPageLoader import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters -import eu.kanade.tachiyomi.util.DiskUtil -import eu.kanade.tachiyomi.util.ImageUtil +import eu.kanade.tachiyomi.util.lang.byteSize +import eu.kanade.tachiyomi.util.lang.takeBytes +import eu.kanade.tachiyomi.util.storage.DiskUtil +import eu.kanade.tachiyomi.util.system.ImageUtil +import java.io.File +import java.util.Date +import java.util.concurrent.TimeUnit import rx.Completable import rx.Observable import rx.Subscription @@ -31,19 +36,16 @@ import rx.schedulers.Schedulers import timber.log.Timber import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.io.File -import java.util.* -import java.util.concurrent.TimeUnit /** * Presenter used by the activity to perform background operations. */ class ReaderPresenter( - private val db: DatabaseHelper = Injekt.get(), - private val sourceManager: SourceManager = Injekt.get(), - private val downloadManager: DownloadManager = Injekt.get(), - private val coverCache: CoverCache = Injekt.get(), - private val preferences: PreferencesHelper = Injekt.get() + private val db: DatabaseHelper = Injekt.get(), + private val sourceManager: SourceManager = Injekt.get(), + private val downloadManager: DownloadManager = Injekt.get(), + private val coverCache: CoverCache = Injekt.get(), + private val preferences: PreferencesHelper = Injekt.get() ) : BasePresenter() { /** @@ -77,10 +79,6 @@ class ReaderPresenter( */ private val isLoadingAdjacentChapterRelay = BehaviorRelay.create() - // EXH --> - private var loadKey: String? = null - // EXH <-- - /** * Chapter list for the active manga. It's retrieved lazily and should be accessed for the first * time in a background thread to avoid blocking the UI. @@ -90,19 +88,40 @@ class ReaderPresenter( val dbChapters = db.getChapters(manga).executeAsBlocking() val selectedChapter = dbChapters.find { it.id == chapterId } - ?: error("Requested chapter of id $chapterId not found in chapter list") + ?: error("Requested chapter of id $chapterId not found in chapter list") val chaptersForReader = - if (preferences.skipRead()) { - var list = dbChapters.filter { it -> !it.read }.toMutableList() - val find = list.find { it.id == chapterId } - if (find == null) { - list.add(selectedChapter) + if (preferences.skipRead() || preferences.skipFiltered()) { + val list = dbChapters + .filter { + if (preferences.skipRead() && it.read) { + return@filter false + } else if (preferences.skipFiltered()) { + if ( + (manga.readFilter == Manga.SHOW_READ && !it.read) || + (manga.readFilter == Manga.SHOW_UNREAD && it.read) || + ( + manga.downloadedFilter == Manga.SHOW_DOWNLOADED && + !downloadManager.isChapterDownloaded(it, manga) + ) || + (manga.bookmarkedFilter == Manga.SHOW_BOOKMARKED && !it.bookmark) + ) { + return@filter false + } + } + + true } - list - } else { - dbChapters + .toMutableList() + + val find = list.find { it.id == chapterId } + if (find == null) { + list.add(selectedChapter) } + list + } else { + dbChapters + } when (manga.sorting) { Manga.SORTING_SOURCE -> ChapterLoadBySource().get(chaptersForReader) @@ -181,12 +200,15 @@ class ReaderPresenter( if (!needsInit()) return db.getManga(mangaId).asRxObservable() - .first() - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext { init(it, initialChapterId) } - .subscribeFirst({ _, _ -> + .first() + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { init(it, initialChapterId) } + .subscribeFirst( + { _, _ -> // Ignore onNext event - }, ReaderActivity::setInitialChapterError) + }, + ReaderActivity::setInitialChapterError + ) } /** @@ -209,13 +231,16 @@ class ReaderPresenter( // Read chapterList from an io thread because it's retrieved lazily and would block main. activeChapterSubscription?.unsubscribe() activeChapterSubscription = Observable - .fromCallable { chapterList.first { chapterId == it.chapter.id } } - .flatMap { getLoadObservable(loader!!, it) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst({ _, _ -> + .fromCallable { chapterList.first { chapterId == it.chapter.id } } + .flatMap { getLoadObservable(loader!!, it) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst( + { _, _ -> // Ignore onNext event - }, ReaderActivity::setInitialChapterError) + }, + ReaderActivity::setInitialChapterError + ) } /** @@ -226,34 +251,30 @@ class ReaderPresenter( * Callers must also handle the onError event. */ private fun getLoadObservable( - loader: ChapterLoader, - chapter: ReaderChapter, - requiredLoadKey: String? = null + loader: ChapterLoader, + chapter: ReaderChapter ): Observable { return loader.loadChapter(chapter) - .andThen(Observable.fromCallable { + .andThen( + Observable.fromCallable { val chapterPos = chapterList.indexOf(chapter) - ViewerChapters(chapter, - chapterList.getOrNull(chapterPos - 1), - chapterList.getOrNull(chapterPos + 1)) - }) - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext { newChapters -> - // Add new references first to avoid unnecessary recycling - newChapters.ref() - - // Ensure that we haven't made another load request in the meantime - if(requiredLoadKey == null || requiredLoadKey == loadKey) { - val oldChapters = viewerChaptersRelay.value - - oldChapters?.unref() - - viewerChaptersRelay.call(newChapters) - } else { - // Another load request has been made, our new chapters are useless :( - newChapters.unref() + ViewerChapters( + chapter, + chapterList.getOrNull(chapterPos - 1), + chapterList.getOrNull(chapterPos + 1) + ) } + ) + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { newChapters -> + val oldChapters = viewerChaptersRelay.value + + // Add new references first to avoid unnecessary recycling + newChapters.ref() + oldChapters?.unref() + + viewerChaptersRelay.call(newChapters) } } @@ -266,15 +287,12 @@ class ReaderPresenter( Timber.d("Loading ${chapter.chapter.url}") - val newLoadKey = UUID.randomUUID().toString() - loadKey = newLoadKey - activeChapterSubscription?.unsubscribe() - activeChapterSubscription = getLoadObservable(loader, chapter, newLoadKey) - .toCompletable() - .onErrorComplete() - .subscribe() - .also(::add) + activeChapterSubscription = getLoadObservable(loader, chapter) + .toCompletable() + .onErrorComplete() + .subscribe() + .also(::add) } /** @@ -289,13 +307,16 @@ class ReaderPresenter( activeChapterSubscription?.unsubscribe() activeChapterSubscription = getLoadObservable(loader, chapter) - .doOnSubscribe { isLoadingAdjacentChapterRelay.call(true) } - .doOnUnsubscribe { isLoadingAdjacentChapterRelay.call(false) } - .subscribeFirst({ view, _ -> + .doOnSubscribe { isLoadingAdjacentChapterRelay.call(true) } + .doOnUnsubscribe { isLoadingAdjacentChapterRelay.call(false) } + .subscribeFirst( + { view, _ -> view.moveToPageIndex(0) - }, { _, _ -> + }, + { _, _ -> // Ignore onError event, viewers handle that state - }) + } + ) } /** @@ -312,12 +333,12 @@ class ReaderPresenter( val loader = loader ?: return loader.loadChapter(chapter) - .observeOn(AndroidSchedulers.mainThread()) - // Update current chapters whenever a chapter is preloaded - .doOnCompleted { viewerChaptersRelay.value?.let(viewerChaptersRelay::call) } - .onErrorComplete() - .subscribe() - .also(::add) + .observeOn(AndroidSchedulers.mainThread()) + // Update current chapters whenever a chapter is preloaded + .doOnCompleted { viewerChaptersRelay.value?.let(viewerChaptersRelay::call) } + .onErrorComplete() + .subscribe() + .also(::add) } /** @@ -334,13 +355,13 @@ class ReaderPresenter( selectedChapter.chapter.last_page_read = page.index if (selectedChapter.pages?.lastIndex == page.index) { selectedChapter.chapter.read = true - updateTrackLastChapterRead() + updateTrackChapterRead(selectedChapter) enqueueDeleteReadChapters(selectedChapter) } if (selectedChapter != currentChapters.currChapter) { Timber.d("Setting ${selectedChapter.chapter.url} as active") - onChapterChanged(currentChapters.currChapter, selectedChapter) + onChapterChanged(currentChapters.currChapter) loadNewChapter(selectedChapter) } } @@ -349,7 +370,7 @@ class ReaderPresenter( * Called when a chapter changed from [fromChapter] to [toChapter]. It updates [fromChapter] * on the database. */ - private fun onChapterChanged(fromChapter: ReaderChapter, toChapter: ReaderChapter) { + private fun onChapterChanged(fromChapter: ReaderChapter) { saveChapterProgress(fromChapter) saveChapterHistory(fromChapter) } @@ -359,9 +380,9 @@ class ReaderPresenter( */ private fun saveChapterProgress(chapter: ReaderChapter) { db.updateChapterProgress(chapter.chapter).asRxCompletable() - .onErrorComplete() - .subscribeOn(Schedulers.io()) - .subscribe() + .onErrorComplete() + .subscribeOn(Schedulers.io()) + .subscribe() } /** @@ -370,9 +391,9 @@ class ReaderPresenter( private fun saveChapterHistory(chapter: ReaderChapter) { val history = History.create(chapter.chapter).apply { last_read = Date().time } db.updateHistoryLastRead(history).asRxCompletable() - .onErrorComplete() - .subscribeOn(Schedulers.io()) - .subscribe() + .onErrorComplete() + .subscribeOn(Schedulers.io()) + .subscribe() } /** @@ -405,6 +426,19 @@ class ReaderPresenter( return viewerChaptersRelay.value?.currChapter } + /** + * Bookmarks the currently active chapter. + */ + fun bookmarkCurrentChapter(bookmarked: Boolean) { + if (getCurrentChapter()?.chapter == null) { + return + } + + val chapter = getCurrentChapter()?.chapter!! + chapter.bookmark = bookmarked + db.updateChapterProgress(chapter).executeAsBlocking() + } + /** * Returns the viewer position used by this manga or the default one. */ @@ -422,18 +456,18 @@ class ReaderPresenter( db.updateMangaViewer(manga).executeAsBlocking() Observable.timer(250, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) - .subscribeFirst({ view, _ -> - val currChapters = viewerChaptersRelay.value - if (currChapters != null) { - // Save current page - val currChapter = currChapters.currChapter - currChapter.requestedPage = currChapter.chapter.last_page_read + .subscribeFirst({ view, _ -> + val currChapters = viewerChaptersRelay.value + if (currChapters != null) { + // Save current page + val currChapter = currChapters.currChapter + currChapter.requestedPage = currChapter.chapter.last_page_read - // Emit manga and chapters to the new viewer - view.setManga(manga) - view.setChapters(currChapters) - } - }) + // Emit manga and chapters to the new viewer + view.setManga(manga) + view.setChapters(currChapters) + } + }) } /** @@ -448,9 +482,10 @@ class ReaderPresenter( val chapter = page.chapter.chapter // Build destination file. + val filenameSuffix = " - ${page.number}.${type.extension}" val filename = DiskUtil.buildValidFilename( - "${manga.title} - ${chapter.name}".take(225) - ) + " - ${page.number}.${type.extension}" + "${manga.title} - ${chapter.name}".takeBytes(MAX_FILE_NAME_BYTES - filenameSuffix.byteSize()) + ) + filenameSuffix val destFile = File(directory, filename) stream().use { input -> @@ -474,23 +509,25 @@ class ReaderPresenter( notifier.onClear() // Pictures directory. - val destDir = File(Environment.getExternalStorageDirectory().absolutePath + + val destDir = File( + Environment.getExternalStorageDirectory().absolutePath + File.separator + Environment.DIRECTORY_PICTURES + - File.separator + context.getString(R.string.app_name)) + File.separator + context.getString(R.string.app_name) + ) // Copy file in background. Observable.fromCallable { saveImage(page, destDir, manga) } - .doOnNext { file -> - DiskUtil.scanMedia(context, file) - notifier.onComplete(file) - } - .doOnError { notifier.onError(it.message) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst( - { view, file -> view.onSaveImageResult(SaveImageResult.Success(file)) }, - { view, error -> view.onSaveImageResult(SaveImageResult.Error(error)) } - ) + .doOnNext { file -> + DiskUtil.scanMedia(context, file) + notifier.onComplete(file) + } + .doOnError { notifier.onError(it.message) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst( + { view, file -> view.onSaveImageResult(SaveImageResult.Success(file)) }, + { view, error -> view.onSaveImageResult(SaveImageResult.Error(error)) } + ) } /** @@ -508,13 +545,13 @@ class ReaderPresenter( val destDir = File(context.cacheDir, "shared_image") Observable.fromCallable { destDir.deleteRecursively() } // Keep only the last shared file - .map { saveImage(page, destDir, manga) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst( - { view, file -> view.onShareImageResult(file) }, - { _, _ -> /* Empty */ } - ) + .map { saveImage(page, destDir, manga) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst( + { view, file -> view.onShareImageResult(file) }, + { _, _ -> /* Empty */ } + ) } /** @@ -526,28 +563,28 @@ class ReaderPresenter( val stream = page.stream ?: return Observable - .fromCallable { - if (manga.source == LocalSource.ID) { - val context = Injekt.get() - LocalSource.updateCover(context, manga, stream()) - R.string.cover_updated + .fromCallable { + if (manga.source == LocalSource.ID) { + val context = Injekt.get() + LocalSource.updateCover(context, manga, stream()) + R.string.cover_updated + SetAsCoverResult.Success + } else { + val thumbUrl = manga.thumbnail_url ?: throw Exception("Image url not found") + if (manga.favorite) { + coverCache.copyToCache(thumbUrl, stream()) SetAsCoverResult.Success } else { - val thumbUrl = manga.thumbnail_url ?: throw Exception("Image url not found") - if (manga.favorite) { - coverCache.copyToCache(thumbUrl, stream()) - SetAsCoverResult.Success - } else { - SetAsCoverResult.AddToLibraryFirst - } + SetAsCoverResult.AddToLibraryFirst } } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst( - { view, result -> view.onSetAsCoverResult(result) }, - { view, _ -> view.onSetAsCoverResult(SetAsCoverResult.Error) } - ) + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst( + { view, result -> view.onSetAsCoverResult(result) }, + { view, _ -> view.onSetAsCoverResult(SetAsCoverResult.Error) } + ) } /** @@ -569,45 +606,37 @@ class ReaderPresenter( * Starts the service that updates the last chapter read in sync services. This operation * will run in a background thread and errors are ignored. */ - private fun updateTrackLastChapterRead() { + private fun updateTrackChapterRead(readerChapter: ReaderChapter) { if (!preferences.autoUpdateTrack()) return - val viewerChapters = viewerChaptersRelay.value ?: return val manga = manga ?: return - val currChapter = viewerChapters.currChapter.chapter - val prevChapter = viewerChapters.prevChapter?.chapter - - // Get the last chapter read from the reader. - val lastChapterRead = if (currChapter.read) - currChapter.chapter_number.toInt() - else if (prevChapter != null && prevChapter.read) - prevChapter.chapter_number.toInt() - else - return + val chapterRead = readerChapter.chapter.chapter_number.toInt() val trackManager = Injekt.get() db.getTracks(manga).asRxSingle() - .flatMapCompletable { trackList -> - Completable.concat(trackList.map { track -> + .flatMapCompletable { trackList -> + Completable.concat( + trackList.map { track -> val service = trackManager.getService(track.sync_id) - if (service != null && service.isLogged && lastChapterRead > track.last_chapter_read) { - track.last_chapter_read = lastChapterRead + if (service != null && service.isLogged && chapterRead > track.last_chapter_read) { + track.last_chapter_read = chapterRead // We wan't these to execute even if the presenter is destroyed and leaks // for a while. The view can still be garbage collected. Observable.defer { service.update(track) } - .map { db.insertTrack(track).executeAsBlocking() } - .toCompletable() - .onErrorComplete() + .map { db.insertTrack(track).executeAsBlocking() } + .toCompletable() + .onErrorComplete() } else { Completable.complete() } - }) - } - .onErrorComplete() - .subscribeOn(Schedulers.io()) - .subscribe() + } + ) + } + .onErrorComplete() + .subscribeOn(Schedulers.io()) + .subscribe() } /** @@ -623,19 +652,19 @@ class ReaderPresenter( if (removeAfterReadSlots == -1) return Completable - .fromCallable { - // Position of the read chapter - val position = chapterList.indexOf(chapter) + .fromCallable { + // Position of the read chapter + val position = chapterList.indexOf(chapter) - // Retrieve chapter to delete according to preference - val chapterToDelete = chapterList.getOrNull(position - removeAfterReadSlots) - if (chapterToDelete != null) { - downloadManager.enqueueDeleteChapters(listOf(chapterToDelete.chapter), manga) - } + // Retrieve chapter to delete according to preference + val chapterToDelete = chapterList.getOrNull(position - removeAfterReadSlots) + if (chapterToDelete != null) { + downloadManager.enqueueDeleteChapters(listOf(chapterToDelete.chapter), manga) } - .onErrorComplete() - .subscribeOn(Schedulers.io()) - .subscribe() + } + .onErrorComplete() + .subscribeOn(Schedulers.io()) + .subscribe() } /** @@ -644,9 +673,13 @@ class ReaderPresenter( */ private fun deletePendingChapters() { Completable.fromCallable { downloadManager.deletePendingChapters() } - .onErrorComplete() - .subscribeOn(Schedulers.io()) - .subscribe() + .onErrorComplete() + .subscribeOn(Schedulers.io()) + .subscribe() } + companion object { + // Safe theoretical max filename size is 255 bytes and 1 char = 2-4 bytes (UTF-8) + private const val MAX_FILE_NAME_BYTES = 250 + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSeekBar.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSeekBar.kt index 473bf0f79..0615c1f4f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSeekBar.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSeekBar.kt @@ -10,8 +10,8 @@ import androidx.appcompat.widget.AppCompatSeekBar * Seekbar to show current chapter progress. */ class ReaderSeekBar @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null + context: Context, + attrs: AttributeSet? = null ) : AppCompatSeekBar(context, attrs) { /** @@ -41,5 +41,4 @@ class ReaderSeekBar @JvmOverloads constructor( } return super.onTouchEvent(event) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSettingsSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSettingsSheet.kt index 3f9e96cc9..1654cf18f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSettingsSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSettingsSheet.kt @@ -1,19 +1,40 @@ package eu.kanade.tachiyomi.ui.reader +import android.os.Build import android.os.Bundle import android.widget.CompoundButton import android.widget.Spinner +import androidx.annotation.ArrayRes import androidx.core.widget.NestedScrollView -import com.f2prateek.rx.preferences.Preference +import com.f2prateek.rx.preferences.Preference as RxPreference import com.google.android.material.bottomsheet.BottomSheetDialog +import com.tfcporciuncula.flow.Preference import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerViewer import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer -import eu.kanade.tachiyomi.util.visible +import eu.kanade.tachiyomi.util.view.invisible +import eu.kanade.tachiyomi.util.view.visible import eu.kanade.tachiyomi.widget.IgnoreFirstSpinnerListener -import kotlinx.android.synthetic.main.reader_settings_sheet.* +import kotlinx.android.synthetic.main.reader_settings_sheet.always_show_chapter_transition +import kotlinx.android.synthetic.main.reader_settings_sheet.background_color +import kotlinx.android.synthetic.main.reader_settings_sheet.crop_borders +import kotlinx.android.synthetic.main.reader_settings_sheet.crop_borders_webtoon +import kotlinx.android.synthetic.main.reader_settings_sheet.cutout_short +import kotlinx.android.synthetic.main.reader_settings_sheet.fullscreen +import kotlinx.android.synthetic.main.reader_settings_sheet.keepscreen +import kotlinx.android.synthetic.main.reader_settings_sheet.long_tap +import kotlinx.android.synthetic.main.reader_settings_sheet.page_transitions +import kotlinx.android.synthetic.main.reader_settings_sheet.pager_prefs_group +import kotlinx.android.synthetic.main.reader_settings_sheet.rotation_mode +import kotlinx.android.synthetic.main.reader_settings_sheet.scale_type +import kotlinx.android.synthetic.main.reader_settings_sheet.show_page_number +import kotlinx.android.synthetic.main.reader_settings_sheet.true_color +import kotlinx.android.synthetic.main.reader_settings_sheet.viewer +import kotlinx.android.synthetic.main.reader_settings_sheet.webtoon_prefs_group +import kotlinx.android.synthetic.main.reader_settings_sheet.webtoon_side_padding +import kotlinx.android.synthetic.main.reader_settings_sheet.zoom_start import uy.kohesive.injekt.injectLazy /** @@ -21,9 +42,6 @@ import uy.kohesive.injekt.injectLazy */ class ReaderSettingsSheet(private val activity: ReaderActivity) : BottomSheetDialog(activity) { - /** - * Preferences helper. - */ private val preferences by injectLazy() init { @@ -54,22 +72,38 @@ class ReaderSettingsSheet(private val activity: ReaderActivity) : BottomSheetDia private fun initGeneralPreferences() { viewer.onItemSelectedListener = IgnoreFirstSpinnerListener { position -> activity.presenter.setMangaViewer(position) + + val mangaViewer = activity.presenter.getMangaViewer() + if (mangaViewer == ReaderActivity.WEBTOON || mangaViewer == ReaderActivity.VERTICAL_PLUS) { + initWebtoonPreferences() + } else { + initPagerPreferences() + } } viewer.setSelection(activity.presenter.manga?.viewer ?: 0, false) rotation_mode.bindToPreference(preferences.rotation(), 1) - background_color.bindToPreference(preferences.readerTheme()) + background_color.bindToIntPreference(preferences.readerTheme(), R.array.reader_themes_values) show_page_number.bindToPreference(preferences.showPageNumber()) fullscreen.bindToPreference(preferences.fullscreen()) + cutout_short.bindToPreference(preferences.cutoutShort()) keepscreen.bindToPreference(preferences.keepScreenOn()) long_tap.bindToPreference(preferences.readWithLongTap()) + always_show_chapter_transition.bindToPreference(preferences.alwaysShowChapterTransition()) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + true_color.visible() + true_color.bindToPreference(preferences.trueColor()) + } } /** * Init the preferences for the pager reader. */ private fun initPagerPreferences() { + webtoon_prefs_group.invisible() pager_prefs_group.visible() + scale_type.bindToPreference(preferences.imageScaleType(), 1) zoom_start.bindToPreference(preferences.zoomStart(), 1) crop_borders.bindToPreference(preferences.cropBorders()) @@ -80,18 +114,31 @@ class ReaderSettingsSheet(private val activity: ReaderActivity) : BottomSheetDia * Init the preferences for the webtoon reader. */ private fun initWebtoonPreferences() { + pager_prefs_group.invisible() webtoon_prefs_group.visible() + crop_borders_webtoon.bindToPreference(preferences.cropBordersWebtoon()) + webtoon_side_padding.bindToIntPreference(preferences.webtoonSidePadding(), R.array.webtoon_side_padding_values) } /** * Binds a checkbox or switch view with a boolean preference. */ private fun CompoundButton.bindToPreference(pref: Preference) { - isChecked = pref.getOrDefault() + isChecked = pref.get() setOnCheckedChangeListener { _, isChecked -> pref.set(isChecked) } } + /** + * Binds a spinner to an int preference with an optional offset for the value. + */ + private fun Spinner.bindToPreference(pref: RxPreference, offset: Int = 0) { + onItemSelectedListener = IgnoreFirstSpinnerListener { position -> + pref.set(position + offset) + } + setSelection(pref.getOrDefault() - offset, false) + } + /** * Binds a spinner to an int preference with an optional offset for the value. */ @@ -99,7 +146,19 @@ class ReaderSettingsSheet(private val activity: ReaderActivity) : BottomSheetDia onItemSelectedListener = IgnoreFirstSpinnerListener { position -> pref.set(position + offset) } - setSelection(pref.getOrDefault() - offset, false) + setSelection(pref.get() - offset, false) } + /** + * Binds a spinner to an int preference. The position of the spinner item must + * correlate with the [intValues] resource item (in arrays.xml), which is a + * of int values that will be parsed here and applied to the preference. + */ + private fun Spinner.bindToIntPreference(pref: Preference, @ArrayRes intValuesResource: Int) { + val intValues = resources.getStringArray(intValuesResource).map { it.toIntOrNull() } + onItemSelectedListener = IgnoreFirstSpinnerListener { position -> + pref.set(intValues[position]!!) + } + setSelection(intValues.indexOf(pref.get()), false) + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/SaveImageNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/SaveImageNotifier.kt index d69ced1e2..2b9fe851d 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/SaveImageNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/SaveImageNotifier.kt @@ -9,7 +9,8 @@ import eu.kanade.tachiyomi.data.glide.GlideApp import eu.kanade.tachiyomi.data.notification.NotificationHandler import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.Notifications -import eu.kanade.tachiyomi.util.notificationManager +import eu.kanade.tachiyomi.util.system.notificationBuilder +import eu.kanade.tachiyomi.util.system.notificationManager import java.io.File /** @@ -20,7 +21,7 @@ class SaveImageNotifier(private val context: Context) { /** * Notification builder. */ - private val notificationBuilder = NotificationCompat.Builder(context, Notifications.CHANNEL_COMMON) + private val notificationBuilder = context.notificationBuilder(Notifications.CHANNEL_COMMON) /** * Id of the notification. @@ -53,25 +54,31 @@ class SaveImageNotifier(private val context: Context) { private fun showCompleteNotification(file: File, image: Bitmap) { with(notificationBuilder) { setContentTitle(context.getString(R.string.picture_saved)) - setSmallIcon(R.drawable.ic_insert_photo_white_24dp) + setSmallIcon(R.drawable.ic_photo_24dp) setStyle(NotificationCompat.BigPictureStyle().bigPicture(image)) setLargeIcon(image) setAutoCancel(true) + // Clear old actions if they exist - if (!mActions.isEmpty()) + if (mActions.isNotEmpty()) { mActions.clear() + } setContentIntent(NotificationHandler.openImagePendingActivity(context, file)) // Share action - addAction(R.drawable.ic_share_grey_24dp, - context.getString(R.string.action_share), - NotificationReceiver.shareImagePendingBroadcast(context, file.absolutePath, notificationId)) + addAction( + R.drawable.ic_share_24dp, + context.getString(R.string.action_share), + NotificationReceiver.shareImagePendingBroadcast(context, file.absolutePath, notificationId) + ) // Delete action - addAction(R.drawable.ic_delete_grey_24dp, - context.getString(R.string.action_delete), - NotificationReceiver.deleteImagePendingBroadcast(context, file.absolutePath, notificationId)) - updateNotification() + addAction( + R.drawable.ic_delete_24dp, + context.getString(R.string.action_delete), + NotificationReceiver.deleteImagePendingBroadcast(context, file.absolutePath, notificationId) + ) + updateNotification() } } @@ -87,7 +94,6 @@ class SaveImageNotifier(private val context: Context) { context.notificationManager.notify(notificationId, notificationBuilder.build()) } - /** * Called on error while downloading image. * @param error string containing error information. @@ -101,5 +107,4 @@ class SaveImageNotifier(private val context: Context) { } updateNotification() } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt index 10f008e66..4315cbe31 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt @@ -19,9 +19,9 @@ import timber.log.Timber * Loader used to retrieve the [PageLoader] for a given chapter. */ class ChapterLoader( - private val downloadManager: DownloadManager, - private val manga: Manga, - private val source: Source + private val downloadManager: DownloadManager, + private val manga: Manga, + private val source: Source ) { /** @@ -29,17 +29,17 @@ class ChapterLoader( * completes if the chapter is already loaded. */ fun loadChapter(chapter: ReaderChapter): Completable { - if (chapter.state is ReaderChapter.State.Loaded) { + if (chapterIsReady(chapter)) { return Completable.complete() } return Observable.just(chapter) .doOnNext { chapter.state = ReaderChapter.State.Loading } .observeOn(Schedulers.io()) - .flatMap { + .flatMap { readerChapter -> Timber.d("Loading pages for ${chapter.chapter.name}") - val loader = getPageLoader(it) + val loader = getPageLoader(readerChapter) loader.getPages().take(1).doOnNext { pages -> pages.forEach { it.chapter = chapter } @@ -63,19 +63,14 @@ class ChapterLoader( } } .toCompletable() - .doOnError { - // [EXH] - XLog.w("> Failed to fetch page list!", it) - XLog.w("> (source.id: %s, source.name: %s, manga.id: %s, manga.url: %s, chapter.id: %s, chapter.url: %s)", - source.id, - source.name, - manga.id, - manga.url, - chapter.chapter.id, - chapter.chapter.url) + .doOnError { chapter.state = ReaderChapter.State.Error(it) } + } - chapter.state = ReaderChapter.State.Error(it) - } + /** + * Checks [chapter] to be loaded based on present pages and loader in addition to state. + */ + private fun chapterIsReady(chapter: ReaderChapter): Boolean { + return chapter.state is ReaderChapter.State.Loaded && chapter.pageLoader != null } /** @@ -97,5 +92,4 @@ class ChapterLoader( else -> error("Loader not implemented") } } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DirectoryPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DirectoryPageLoader.kt index 8d70d4a76..95e7c6e5e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DirectoryPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DirectoryPageLoader.kt @@ -2,11 +2,11 @@ package eu.kanade.tachiyomi.ui.reader.loader import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.model.ReaderPage -import eu.kanade.tachiyomi.util.ComparatorUtil.CaseInsensitiveNaturalComparator -import eu.kanade.tachiyomi.util.ImageUtil -import rx.Observable +import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder +import eu.kanade.tachiyomi.util.system.ImageUtil import java.io.File import java.io.FileInputStream +import rx.Observable /** * Loader used to load a chapter from a directory given on [file]. @@ -20,7 +20,7 @@ class DirectoryPageLoader(val file: File) : PageLoader() { override fun getPages(): Observable> { return file.listFiles() .filter { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } } - .sortedWith(Comparator { f1, f2 -> CaseInsensitiveNaturalComparator.compare(f1.name, f2.name) }) + .sortedWith(Comparator { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }) .mapIndexed { i, file -> val streamFn = { FileInputStream(file) } ReaderPage(i).apply { @@ -37,5 +37,4 @@ class DirectoryPageLoader(val file: File) : PageLoader() { override fun getPage(page: ReaderPage): Observable { return Observable.just(Page.READY) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt index 6a207502a..838242d22 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.ui.reader.loader import android.app.Application +import android.net.Uri import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.source.Source @@ -14,10 +15,10 @@ import uy.kohesive.injekt.injectLazy * Loader used to load a chapter from the downloaded chapters. */ class DownloadPageLoader( - private val chapter: ReaderChapter, - private val manga: Manga, - private val source: Source, - private val downloadManager: DownloadManager + private val chapter: ReaderChapter, + private val manga: Manga, + private val source: Source, + private val downloadManager: DownloadManager ) : PageLoader() { /** @@ -33,7 +34,7 @@ class DownloadPageLoader( .map { pages -> pages.map { page -> ReaderPage(page.index, page.url, page.imageUrl) { - context.contentResolver.openInputStream(page.uri) + context.contentResolver.openInputStream(page.uri ?: Uri.EMPTY)!! }.apply { status = Page.READY } @@ -44,5 +45,4 @@ class DownloadPageLoader( override fun getPage(page: ReaderPage): Observable { return Observable.just(Page.READY) // TODO maybe check if file still exists? } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt index 76965b645..00895e901 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt @@ -2,9 +2,9 @@ package eu.kanade.tachiyomi.ui.reader.loader import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.model.ReaderPage -import eu.kanade.tachiyomi.util.EpubFile -import rx.Observable +import eu.kanade.tachiyomi.util.storage.EpubFile import java.io.File +import rx.Observable /** * Loader used to load a chapter from a .epub file. @@ -44,11 +44,12 @@ class EpubPageLoader(file: File) : PageLoader() { * Returns an observable that emits a ready state unless the loader was recycled. */ override fun getPage(page: ReaderPage): Observable { - return Observable.just(if (isRecycled) { - Page.ERROR - } else { - Page.READY - }) + return Observable.just( + if (isRecycled) { + Page.ERROR + } else { + Page.READY + } + ) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt index f65256c3f..eb02cb457 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt @@ -8,9 +8,10 @@ import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderPage -import eu.kanade.tachiyomi.util.plusAssign -import exh.EH_SOURCE_ID -import exh.EXH_SOURCE_ID +import eu.kanade.tachiyomi.util.lang.plusAssign +import java.util.concurrent.PriorityBlockingQueue +import java.util.concurrent.atomic.AtomicInteger +import kotlin.math.min import rx.Completable import rx.Observable import rx.schedulers.Schedulers @@ -28,9 +29,9 @@ import java.util.concurrent.atomic.AtomicInteger * Loader used to load chapters from an online source. */ class HttpPageLoader( - private val chapter: ReaderChapter, - private val source: HttpSource, - private val chapterCache: ChapterCache = Injekt.get() + private val chapter: ReaderChapter, + private val source: HttpSource, + private val chapterCache: ChapterCache = Injekt.get() ) : PageLoader() { // EXH --> private val prefs: PreferencesHelper by injectLazy() @@ -46,17 +47,15 @@ class HttpPageLoader( */ private val subscriptions = CompositeSubscription() + private val preloadSize = 4 + init { // EXH --> repeat(prefs.eh_readerThreads().getOrDefault()) { // EXH <-- subscriptions += Observable.defer { Observable.just(queue.take().page) } .filter { it.status == Page.QUEUE } - .concatMap { - source.fetchImageFromCacheThenNet(it).doOnNext { - XLog.d("Downloaded page: ${it.number}!") - } - } + .concatMap { source.fetchImageFromCacheThenNet(it) } .repeat() .subscribeOn(Schedulers.io()) .subscribe({ @@ -64,7 +63,8 @@ class HttpPageLoader( if (error !is InterruptedException) { Timber.e(error) } - }) + } + ) // EXH --> } // EXH <-- @@ -102,7 +102,8 @@ class HttpPageLoader( .getPageListFromCache(chapter.chapter) .onErrorResumeNext { source.fetchPageList(chapter.chapter) } .map { pages -> - val rp = pages.mapIndexed { index, page -> // Don't trust sources and use our own indexing + val rp = pages.mapIndexed { index, page -> + // Don't trust sources and use our own indexing ReaderPage(index, page.url, page.imageUrl) } if(prefs.eh_aggressivePageLoading().getOrDefault()) { @@ -137,29 +138,41 @@ class HttpPageLoader( val statusSubject = SerializedSubject(PublishSubject.create()) page.setStatusSubject(statusSubject) + val queuedPages = mutableListOf() if (page.status == Page.QUEUE) { - queue.offer(PriorityPage(page, 1)) + queuedPages += PriorityPage(page, 1).also { queue.offer(it) } } - - preloadNextPages(page, 4) + queuedPages += preloadNextPages(page, preloadSize) statusSubject.startWith(page.status) + .doOnUnsubscribe { + queuedPages.forEach { + if (it.page.status == Page.QUEUE) { + queue.remove(it) + } + } + } } + .subscribeOn(Schedulers.io()) + .unsubscribeOn(Schedulers.io()) } /** * Preloads the given [amount] of pages after the [currentPage] with a lower priority. + * @return a list of [PriorityPage] that were added to the [queue] */ - private fun preloadNextPages(currentPage: ReaderPage, amount: Int) { + private fun preloadNextPages(currentPage: ReaderPage, amount: Int): List { val pageIndex = currentPage.index - val pages = currentPage.chapter.pages ?: return - if (pageIndex == pages.lastIndex) return - val nextPages = pages.subList(pageIndex + 1, Math.min(pageIndex + 1 + amount, pages.size)) - for (nextPage in nextPages) { - if (nextPage.status == Page.QUEUE) { - queue.offer(PriorityPage(nextPage, 0)) + val pages = currentPage.chapter.pages ?: return emptyList() + if (pageIndex == pages.lastIndex) return emptyList() + + return pages + .subList(pageIndex + 1, min(pageIndex + 1 + amount, pages.size)) + .mapNotNull { + if (it.status == Page.QUEUE) { + PriorityPage(it, 0).apply { queue.offer(this) } + } else null } - } } /** @@ -182,11 +195,10 @@ class HttpPageLoader( /** * Data class used to keep ordering of pages in order to maintain priority. */ - private data class PriorityPage( - val page: ReaderPage, - val priority: Int - ): Comparable { - + private class PriorityPage( + val page: ReaderPage, + val priority: Int + ) : Comparable { companion object { private val idGenerator = AtomicInteger() } @@ -197,7 +209,6 @@ class HttpPageLoader( val p = other.priority.compareTo(priority) return if (p != 0) p else identifier.compareTo(other.identifier) } - } /** @@ -206,30 +217,18 @@ class HttpPageLoader( * @param page the page whose source image has to be downloaded. */ private fun HttpSource.fetchImageFromCacheThenNet(page: ReaderPage): Observable { - return if (page.imageUrl.isNullOrEmpty()) + return if (page.imageUrl.isNullOrEmpty()) { getImageUrl(page).flatMap { getCachedImage(it) } - else + } else { getCachedImage(page) + } } private fun HttpSource.getImageUrl(page: ReaderPage): Observable { page.status = Page.LOAD_PAGE return fetchImageUrl(page) .doOnError { page.status = Page.ERROR } - .onErrorReturn { - // [EXH] - XLog.w("> Failed to fetch image URL!", it) - XLog.w("> (source.id: %s, source.name: %s, page.index: %s, page.url: %s, page.imageUrl: %s, chapter.id: %s, chapter.url: %s)", - source.id, - source.name, - page.index, - page.url, - page.imageUrl, - page.chapter.chapter.id, - page.chapter.chapter.url) - - null - } + .onErrorReturn { null } .doOnNext { page.imageUrl = it } .map { page } } @@ -255,20 +254,7 @@ class HttpPageLoader( page.stream = { chapterCache.getImageFile(imageUrl).inputStream() } page.status = Page.READY } - .doOnError { - // [EXH] - XLog.w("> Failed to fetch image!", it) - XLog.w("> (source.id: %s, source.name: %s, page.index: %s, page.url: %s, page.imageUrl: %s, chapter.id: %s, chapter.url: %s)", - source.id, - source.name, - page.index, - page.url, - page.imageUrl, - page.chapter.chapter.id, - page.chapter.chapter.url) - - page.status = Page.ERROR - } + .doOnError { page.status = Page.ERROR } .onErrorReturn { page } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/PageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/PageLoader.kt index b0b872b6d..de7e4e541 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/PageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/PageLoader.kt @@ -42,5 +42,4 @@ abstract class PageLoader { * online source is used. */ open fun retryPage(page: ReaderPage) {} - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/RarPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/RarPageLoader.kt index 4a30378a6..37b35a97c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/RarPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/RarPageLoader.kt @@ -2,16 +2,16 @@ package eu.kanade.tachiyomi.ui.reader.loader import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.model.ReaderPage -import eu.kanade.tachiyomi.util.ComparatorUtil.CaseInsensitiveNaturalComparator -import eu.kanade.tachiyomi.util.ImageUtil -import junrar.Archive -import junrar.rarfile.FileHeader -import rx.Observable +import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder +import eu.kanade.tachiyomi.util.system.ImageUtil import java.io.File import java.io.InputStream import java.io.PipedInputStream import java.io.PipedOutputStream import java.util.concurrent.Executors +import junrar.Archive +import junrar.rarfile.FileHeader +import rx.Observable /** * Loader used to load a chapter from a .rar or .cbr file. @@ -44,7 +44,7 @@ class RarPageLoader(file: File) : PageLoader() { override fun getPages(): Observable> { return archive.fileHeaders .filter { !it.isDirectory && ImageUtil.isImage(it.fileNameString) { archive.getInputStream(it) } } - .sortedWith(Comparator { f1, f2 -> CaseInsensitiveNaturalComparator.compare(f1.fileNameString, f2.fileNameString) }) + .sortedWith(Comparator { f1, f2 -> f1.fileNameString.compareToCaseInsensitiveNaturalOrder(f2.fileNameString) }) .mapIndexed { i, header -> val streamFn = { getStream(header) } @@ -60,11 +60,13 @@ class RarPageLoader(file: File) : PageLoader() { * Returns an observable that emits a ready state unless the loader was recycled. */ override fun getPage(page: ReaderPage): Observable { - return Observable.just(if (isRecycled) { - Page.ERROR - } else { - Page.READY - }) + return Observable.just( + if (isRecycled) { + Page.ERROR + } else { + Page.READY + } + ) } /** @@ -83,5 +85,4 @@ class RarPageLoader(file: File) : PageLoader() { } return pipeIn } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt index 78d748884..e2cca59fe 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt @@ -2,12 +2,12 @@ package eu.kanade.tachiyomi.ui.reader.loader import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.model.ReaderPage -import eu.kanade.tachiyomi.util.ComparatorUtil.CaseInsensitiveNaturalComparator -import eu.kanade.tachiyomi.util.ImageUtil -import rx.Observable +import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder +import eu.kanade.tachiyomi.util.system.ImageUtil import java.io.File import java.util.zip.ZipEntry import java.util.zip.ZipFile +import rx.Observable /** * Loader used to load a chapter from a .zip or .cbz file. @@ -34,7 +34,7 @@ class ZipPageLoader(file: File) : PageLoader() { override fun getPages(): Observable> { return zip.entries().toList() .filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } } - .sortedWith(Comparator { f1, f2 -> CaseInsensitiveNaturalComparator.compare(f1.name, f2.name) }) + .sortedWith(Comparator { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }) .mapIndexed { i, entry -> val streamFn = { zip.getInputStream(entry) } ReaderPage(i).apply { @@ -49,10 +49,12 @@ class ZipPageLoader(file: File) : PageLoader() { * Returns an observable that emits a ready state unless the loader was recycled. */ override fun getPage(page: ReaderPage): Observable { - return Observable.just(if (isRecycled) { - Page.ERROR - } else { - Page.READY - }) + return Observable.just( + if (isRecycled) { + Page.ERROR + } else { + Page.READY + } + ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ChapterTransition.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ChapterTransition.kt index 892eceab0..96105e634 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ChapterTransition.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ChapterTransition.kt @@ -6,10 +6,13 @@ sealed class ChapterTransition { abstract val to: ReaderChapter? class Prev( - override val from: ReaderChapter, override val to: ReaderChapter? + override val from: ReaderChapter, + override val to: ReaderChapter? ) : ChapterTransition() + class Next( - override val from: ReaderChapter, override val to: ReaderChapter? + override val from: ReaderChapter, + override val to: ReaderChapter? ) : ChapterTransition() override fun equals(other: Any?): Boolean { @@ -29,5 +32,4 @@ sealed class ChapterTransition { override fun toString(): String { return "${javaClass.simpleName}(from=${from.chapter.url}, to=${to?.chapter?.url})" } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderChapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderChapter.kt index 06f08b6db..fe3285e9b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderChapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderChapter.kt @@ -8,11 +8,11 @@ import timber.log.Timber data class ReaderChapter(val chapter: Chapter) { var state: State = - State.Wait + State.Wait set(value) { - field = value - stateRelay.call(value) - } + field = value + stateRelay.call(value) + } private val stateRelay by lazy { BehaviorRelay.create(state) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt index 34b415253..35a10331d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt @@ -4,12 +4,11 @@ import eu.kanade.tachiyomi.source.model.Page import java.io.InputStream class ReaderPage( - index: Int, - url: String = "", - imageUrl: String? = null, - var stream: (() -> InputStream)? = null + index: Int, + url: String = "", + imageUrl: String? = null, + var stream: (() -> InputStream)? = null ) : Page(index, url, imageUrl, null) { lateinit var chapter: ReaderChapter - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ViewerChapters.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ViewerChapters.kt index 1e950c6c7..9d4304840 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ViewerChapters.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ViewerChapters.kt @@ -1,9 +1,9 @@ package eu.kanade.tachiyomi.ui.reader.model data class ViewerChapters( - val currChapter: ReaderChapter, - val prevChapter: ReaderChapter?, - val nextChapter: ReaderChapter? + val currChapter: ReaderChapter, + val prevChapter: ReaderChapter?, + val nextChapter: ReaderChapter? ) { fun ref() { @@ -17,5 +17,4 @@ data class ViewerChapters( prevChapter?.unref() nextChapter?.unref() } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/BaseViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/BaseViewer.kt index 7420db518..223cb087f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/BaseViewer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/BaseViewer.kt @@ -42,5 +42,4 @@ interface BaseViewer { * return true if the event was handled, false otherwise. */ fun handleGenericMotionEvent(event: MotionEvent): Boolean - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/GestureDetectorWithLongTap.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/GestureDetectorWithLongTap.kt index d941d3bc4..52e1ab9bc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/GestureDetectorWithLongTap.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/GestureDetectorWithLongTap.kt @@ -5,14 +5,15 @@ import android.os.Handler import android.view.GestureDetector import android.view.MotionEvent import android.view.ViewConfiguration +import kotlin.math.abs /** * A custom gesture detector that also implements an on long tap confirmed, because the built-in * one conflicts with the quick scale feature. */ open class GestureDetectorWithLongTap( - context: Context, - listener: Listener + context: Context, + listener: Listener ) : GestureDetector(context, listener) { private val handler = Handler() @@ -45,7 +46,7 @@ open class GestureDetectorWithLongTap( } } MotionEvent.ACTION_MOVE -> { - if (Math.abs(ev.rawX - downX) > slop || Math.abs(ev.rawY - downY) > slop) { + if (abs(ev.rawX - downX) > slop || abs(ev.rawY - downY) > slop) { handler.removeCallbacks(longTapFn) } } @@ -70,5 +71,4 @@ open class GestureDetectorWithLongTap( open fun onLongTapConfirmed(ev: MotionEvent) { } } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderProgressBar.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderProgressBar.kt index a251b0c8a..9b6eed602 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderProgressBar.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderProgressBar.kt @@ -15,7 +15,9 @@ import android.view.animation.DecelerateInterpolator import android.view.animation.LinearInterpolator import android.view.animation.RotateAnimation import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.util.getResourceColor +import eu.kanade.tachiyomi.util.system.getResourceColor +import eu.kanade.tachiyomi.util.view.gone +import kotlin.math.min /** * A custom progress bar that always rotates while being determinate. By always rotating we give @@ -23,9 +25,9 @@ import eu.kanade.tachiyomi.util.getResourceColor * user also approximately knows how much the operation will take. */ class ReaderProgressBar @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { /** @@ -59,9 +61,10 @@ class ReaderProgressBar @JvmOverloads constructor( * The rotation animation to use while the progress bar is visible. */ private val rotationAnimation by lazy { - RotateAnimation(0f, 360f, - Animation.RELATIVE_TO_SELF, 0.5f, - Animation.RELATIVE_TO_SELF, 0.5f + RotateAnimation( + 0f, 360f, + Animation.RELATIVE_TO_SELF, 0.5f, + Animation.RELATIVE_TO_SELF, 0.5f ).apply { interpolator = LinearInterpolator() repeatCount = Animation.INFINITE @@ -75,7 +78,7 @@ class ReaderProgressBar @JvmOverloads constructor( override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { super.onLayout(changed, left, top, right, bottom) - val diameter = Math.min(width, height) + val diameter = min(width, height) val thickness = diameter / 10f val pad = thickness / 2f ovalRect.set(pad, pad, diameter - pad, diameter - pad) @@ -120,7 +123,7 @@ class ReaderProgressBar @JvmOverloads constructor( */ override fun setVisibility(visibility: Int) { super.setVisibility(visibility) - val isVisible = visibility == View.VISIBLE + val isVisible = visibility == VISIBLE if (isVisible) { startAnimation() } else { @@ -132,7 +135,7 @@ class ReaderProgressBar @JvmOverloads constructor( * Starts the rotation animation if needed. */ private fun startAnimation() { - if (visibility != View.VISIBLE || windowVisibility != View.VISIBLE || animation != null) { + if (visibility != VISIBLE || windowVisibility != VISIBLE || animation != null) { return } @@ -151,17 +154,17 @@ class ReaderProgressBar @JvmOverloads constructor( * Hides this progress bar with an optional fade out if [animate] is true. */ fun hide(animate: Boolean = false) { - if (visibility == View.GONE) return + if (visibility == GONE) return if (!animate) { - visibility = View.GONE + gone() } else { - ObjectAnimator.ofFloat(this, "alpha", 1f, 0f).apply { + ObjectAnimator.ofFloat(this, "alpha", 1f, 0f).apply { interpolator = DecelerateInterpolator() duration = 1000 addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator?) { - visibility = View.GONE + gone() alpha = 1f } @@ -208,5 +211,4 @@ class ReaderProgressBar @JvmOverloads constructor( start() } } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ViewerConfig.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ViewerConfig.kt new file mode 100644 index 000000000..fab295831 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ViewerConfig.kt @@ -0,0 +1,58 @@ +package eu.kanade.tachiyomi.ui.reader.viewer + +import com.tfcporciuncula.flow.Preference +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * Common configuration for all viewers. + */ +abstract class ViewerConfig(preferences: PreferencesHelper) { + + private val scope = CoroutineScope(Job() + Dispatchers.Main) + + var imagePropertyChangedListener: (() -> Unit)? = null + + var tappingEnabled = true + var longTapEnabled = true + var doubleTapAnimDuration = 500 + var volumeKeysEnabled = false + var volumeKeysInverted = false + var alwaysShowChapterTransition = true + + init { + preferences.readWithTapping() + .register({ tappingEnabled = it }) + + preferences.readWithLongTap() + .register({ longTapEnabled = it }) + + preferences.doubleTapAnimSpeed() + .register({ doubleTapAnimDuration = it }) + + preferences.readWithVolumeKeys() + .register({ volumeKeysEnabled = it }) + + preferences.readWithVolumeKeysInverted() + .register({ volumeKeysInverted = it }) + + preferences.alwaysShowChapterTransition() + .register({ alwaysShowChapterTransition = it }) + } + + fun Preference.register( + valueAssignment: (T) -> Unit, + onChanged: (T) -> Unit = {} + ) { + asFlow() + .onEach { + valueAssignment(it) + onChanged(it) + } + .launchIn(scope) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/Pager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/Pager.kt index a256a5c6e..b89bc9ee0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/Pager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/Pager.kt @@ -13,8 +13,8 @@ import eu.kanade.tachiyomi.ui.reader.viewer.GestureDetectorWithLongTap * pager can also be declared to be vertical by creating it with [isHorizontal] to false. */ open class Pager( - context: Context, - isHorizontal: Boolean = true + context: Context, + isHorizontal: Boolean = true ) : DirectionalViewPager(context, isHorizontal) { /** @@ -104,5 +104,4 @@ open class Pager( fun setGestureDetectorEnabled(enabled: Boolean) { isGestureDetectorEnabled = enabled } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerButton.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerButton.kt index f14f328c3..57cef1c8a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerButton.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerButton.kt @@ -21,5 +21,4 @@ class PagerButton(context: Context, viewer: PagerViewer) : AppCompatButton(conte false } } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerConfig.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerConfig.kt index c07c7b65f..5912aa990 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerConfig.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerConfig.kt @@ -1,32 +1,15 @@ package eu.kanade.tachiyomi.ui.reader.viewer.pager -import com.f2prateek.rx.preferences.Preference import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.util.addTo -import rx.subscriptions.CompositeSubscription +import eu.kanade.tachiyomi.ui.reader.viewer.ViewerConfig import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get /** * Configuration used by pager viewers. */ -class PagerConfig(private val viewer: PagerViewer, preferences: PreferencesHelper = Injekt.get()) { - - private val subscriptions = CompositeSubscription() - - var imagePropertyChangedListener: (() -> Unit)? = null - - var tappingEnabled = true - private set - - var longTapEnabled = true - private set - - var volumeKeysEnabled = false - private set - - var volumeKeysInverted = false - private set +class PagerConfig(private val viewer: PagerViewer, preferences: PreferencesHelper = Injekt.get()) : + ViewerConfig(preferences) { var usePageTransitions = false private set @@ -40,16 +23,7 @@ class PagerConfig(private val viewer: PagerViewer, preferences: PreferencesHelpe var imageCropBorders = false private set - var doubleTapAnimDuration = 500 - private set - init { - preferences.readWithTapping() - .register({ tappingEnabled = it }) - - preferences.readWithLongTap() - .register({ longTapEnabled = it }) - preferences.pageTransitions() .register({ usePageTransitions = it }) @@ -61,32 +35,6 @@ class PagerConfig(private val viewer: PagerViewer, preferences: PreferencesHelpe preferences.cropBorders() .register({ imageCropBorders = it }, { imagePropertyChangedListener?.invoke() }) - - preferences.doubleTapAnimSpeed() - .register({ doubleTapAnimDuration = it }) - - preferences.readWithVolumeKeys() - .register({ volumeKeysEnabled = it }) - - preferences.readWithVolumeKeysInverted() - .register({ volumeKeysInverted = it }) - } - - fun unsubscribe() { - subscriptions.unsubscribe() - } - - private fun Preference.register( - valueAssignment: (T) -> Unit, - onChanged: (T) -> Unit = {} - ) { - asObservable() - .doOnNext(valueAssignment) - .skip(1) - .distinctUntilChanged() - .doOnNext(onChanged) - .subscribe() - .addTo(subscriptions) } private fun zoomTypeFromPreference(value: Int) { @@ -109,5 +57,4 @@ class PagerConfig(private val viewer: PagerViewer, preferences: PreferencesHelpe enum class ZoomType { Left, Center, Right } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt index 8ec0ead81..544430a8e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt @@ -1,10 +1,8 @@ package eu.kanade.tachiyomi.ui.reader.viewer.pager import android.annotation.SuppressLint -import android.content.Intent import android.graphics.PointF import android.graphics.drawable.Drawable -import android.net.Uri import android.view.GestureDetector import android.view.Gravity import android.view.MotionEvent @@ -32,25 +30,26 @@ import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressBar import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerConfig.ZoomType -import eu.kanade.tachiyomi.util.ImageUtil -import eu.kanade.tachiyomi.util.dpToPx -import eu.kanade.tachiyomi.util.gone -import eu.kanade.tachiyomi.util.visible +import eu.kanade.tachiyomi.ui.webview.WebViewActivity +import eu.kanade.tachiyomi.util.system.ImageUtil +import eu.kanade.tachiyomi.util.system.dpToPx +import eu.kanade.tachiyomi.util.view.gone +import eu.kanade.tachiyomi.util.view.visible import eu.kanade.tachiyomi.widget.ViewPagerAdapter +import java.io.InputStream +import java.util.concurrent.TimeUnit import rx.Observable import rx.Subscription import rx.android.schedulers.AndroidSchedulers import rx.schedulers.Schedulers -import java.io.InputStream -import java.util.concurrent.TimeUnit /** * View of the ViewPager that contains a page of a chapter. */ @SuppressLint("ViewConstructor") class PagerPageHolder( - val viewer: PagerViewer, - val page: ReaderPage + val viewer: PagerViewer, + val page: ReaderPage ) : FrameLayout(viewer.activity), ViewPagerAdapter.PositionableView { /** @@ -284,9 +283,8 @@ class PagerPageHolder( @SuppressLint("PrivateResource") private fun createProgressBar(): ReaderProgressBar { return ReaderProgressBar(context, null).apply { - val size = 48.dpToPx - layoutParams = FrameLayout.LayoutParams(size, size).apply { + layoutParams = LayoutParams(size, size).apply { gravity = Gravity.CENTER } } @@ -301,9 +299,9 @@ class PagerPageHolder( val config = viewer.config subsamplingImageView = SubsamplingScaleImageView(context).apply { - layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) + layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) setMaxTileSize(viewer.activity.maxBitmapSize) - setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_FIXED) + setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_CENTER) setDoubleTapZoomDuration(config.doubleTapAnimDuration) setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE) setMinimumScaleType(config.imageScaleType) @@ -336,7 +334,7 @@ class PagerPageHolder( if (imageView != null) return imageView!! imageView = PhotoView(context, null).apply { - layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) + layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) adjustViewBounds = true setZoomTransitionDuration(viewer.config.doubleTapAnimDuration) setScaleLevels(1f, 2f, 3f) @@ -363,7 +361,7 @@ class PagerPageHolder( if (retryButton != null) return retryButton!! retryButton = PagerButton(context, viewer).apply { - layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { + layoutParams = LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { gravity = Gravity.CENTER } setText(R.string.action_retry) @@ -400,7 +398,7 @@ class PagerPageHolder( } PagerButton(context, viewer).apply { - layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { + layoutParams = LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { setMargins(margins, margins, margins, margins) } setText(R.string.action_retry) @@ -412,14 +410,14 @@ class PagerPageHolder( } val imageUrl = page.imageUrl - if (imageUrl.orEmpty().startsWith("http")) { + if (imageUrl.orEmpty().startsWith("http", true)) { PagerButton(context, viewer).apply { - layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { + layoutParams = LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { setMargins(margins, margins, margins, margins) } - setText(R.string.action_open_in_browser) + setText(R.string.action_open_in_web_view) setOnClickListener { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(imageUrl)) + val intent = WebViewActivity.newIntent(context, imageUrl!!) context.startActivity(intent) } @@ -442,21 +440,21 @@ class PagerPageHolder( .transition(DrawableTransitionOptions.with(NoTransition.getFactory())) .listener(object : RequestListener { override fun onLoadFailed( - e: GlideException?, - model: Any?, - target: Target?, - isFirstResource: Boolean + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean ): Boolean { onImageDecodeError() return false } override fun onResourceReady( - resource: Drawable?, - model: Any?, - target: Target?, - dataSource: DataSource?, - isFirstResource: Boolean + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean ): Boolean { if (resource is GifDrawable) { resource.setLoopCount(GifDrawable.LOOP_INTRINSIC) @@ -467,5 +465,4 @@ class PagerPageHolder( }) .into(this) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerTransitionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerTransitionHolder.kt index 911c5230b..fe9e2828f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerTransitionHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerTransitionHolder.kt @@ -17,7 +17,7 @@ import androidx.appcompat.widget.AppCompatTextView import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter -import eu.kanade.tachiyomi.util.dpToPx +import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.widget.ViewPagerAdapter import rx.Subscription import rx.android.schedulers.AndroidSchedulers @@ -27,8 +27,8 @@ import rx.android.schedulers.AndroidSchedulers */ @SuppressLint("ViewConstructor") class PagerTransitionHolder( - val viewer: PagerViewer, - val transition: ChapterTransition + val viewer: PagerViewer, + val transition: ChapterTransition ) : LinearLayout(viewer.activity), ViewPagerAdapter.PositionableView { /** @@ -55,7 +55,7 @@ class PagerTransitionHolder( * dynamically. */ private var pagesContainer = LinearLayout(context).apply { - layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) orientation = VERTICAL gravity = Gravity.CENTER } @@ -144,7 +144,8 @@ class PagerTransitionHolder( .subscribe { state -> pagesContainer.removeAllViews() when (state) { - is ReaderChapter.State.Wait -> {} + is ReaderChapter.State.Wait -> { + } is ReaderChapter.State.Loading -> setLoading() is ReaderChapter.State.Error -> setError(state.error) is ReaderChapter.State.Loaded -> setLoaded() @@ -204,5 +205,4 @@ class PagerTransitionHolder( private fun View.wrapContent() { layoutParams = ViewGroup.LayoutParams(WRAP_CONTENT, WRAP_CONTENT) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewer.kt index 133a201c9..dc4155044 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewer.kt @@ -6,14 +6,14 @@ import android.view.MotionEvent import android.view.View import android.view.ViewGroup.LayoutParams import androidx.viewpager.widget.ViewPager -import com.elvishew.xlog.XLog import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition -import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters import eu.kanade.tachiyomi.ui.reader.viewer.BaseViewer +import eu.kanade.tachiyomi.util.view.gone +import eu.kanade.tachiyomi.util.view.visible import timber.log.Timber /** @@ -43,11 +43,6 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer { */ /* [EXH] private */ var currentPage: Any? = null - // EXH --> - private var nextChapter: ReaderChapter? = null - private var prevChapter: ReaderChapter? = null - // EXH <-- - /** * Viewer chapters to set when the pager enters idle mode. Otherwise, if the view was settling * or dragging, there'd be a noticeable and annoying jump. @@ -70,36 +65,29 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer { } init { - pager.visibility = View.GONE // Don't layout the pager yet + pager.gone() // Don't layout the pager yet pager.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) pager.offscreenPageLimit = 1 pager.id = R.id.reader_pager pager.adapter = adapter - pager.addOnPageChangeListener(object : androidx.viewpager.widget.ViewPager.SimpleOnPageChangeListener() { + pager.addOnPageChangeListener(object : ViewPager.SimpleOnPageChangeListener() { override fun onPageSelected(position: Int) { - val page = adapter.items.getOrNull(position) - if (page != null && currentPage != page) { - currentPage = page - when (page) { - is ReaderPage -> onPageSelected(page, position) - is ChapterTransition -> onTransitionSelected(page) - } - } + onPageChange(position) } override fun onPageScrollStateChanged(state: Int) { - isIdle = state == androidx.viewpager.widget.ViewPager.SCROLL_STATE_IDLE + isIdle = state == ViewPager.SCROLL_STATE_IDLE } }) pager.tapListener = { event -> val positionX = event.x when { - positionX < pager.width * 0.33f -> if (config.tappingEnabled) moveLeft() - positionX > pager.width * 0.66f -> if (config.tappingEnabled) moveRight() + positionX < pager.width * 0.33f -> if (config.tappingEnabled) moveLeft() else activity.toggleMenu() + positionX > pager.width * 0.66f -> if (config.tappingEnabled) moveRight() else activity.toggleMenu() else -> activity.toggleMenu() } } - pager.longTapListener = f@ { + pager.longTapListener = f@{ if (activity.menuVisible || config.longTapEnabled) { val item = adapter.items.getOrNull(pager.currentItem) if (item is ReaderPage) { @@ -128,63 +116,60 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer { } /** - * Destroys this viewer. Called when leaving the reader or swapping viewers. + * Called when a new page (either a [ReaderPage] or [ChapterTransition]) is marked as active */ - override fun destroy() { - super.destroy() - config.unsubscribe() + private fun onPageChange(position: Int) { + val page = adapter.items.getOrNull(position) + if (page != null && currentPage != page) { + val allowPreload = checkAllowPreload(page as? ReaderPage) + currentPage = page + when (page) { + is ReaderPage -> onReaderPageSelected(page, allowPreload) + is ChapterTransition -> onTransitionSelected(page) + } + } + } + + private fun checkAllowPreload(page: ReaderPage?): Boolean { + // Page is transition page - preload allowed + page ?: return true + + // Initial opening - preload allowed + currentPage ?: return true + + // Allow preload for + // 1. Going to next chapter from chapter transition + // 2. Going between pages of same chapter + // 3. Next chapter page + return when (page.chapter) { + (currentPage as? ChapterTransition.Next)?.to -> true + (currentPage as? ReaderPage)?.chapter -> true + adapter.nextTransition?.to -> true + else -> false + } } /** - * Called from the ViewPager listener when a [page] is marked as active. It notifies the + * Called when a [ReaderPage] is marked as active. It notifies the * activity of the change and requests the preload of the next chapter if this is the last page. */ - private fun onPageSelected(page: ReaderPage, position: Int) { - val pages = page.chapter.pages // Won't be null because it's the loaded chapter - // EXH --> - if(pages == null) { - XLog.e("Pager reader chapter pages are null (position: %s," + - " page.index: %s," + - " page.url: %s," + - " page.imageUrl: %s," + - " page.chapter.state: %s," + - " page.chapter.pageLoader == null: %s," + - " page.chapter.requestedPage: %s" + - " page.chapter.references: %s)!", - position, - page.index, - page.url, - page.imageUrl, - page.chapter.state::class.simpleName, - page.chapter.pageLoader == null, - page.chapter.requestedPage, - page.chapter.references) - return - } - // EXH <-- - - Timber.d("onPageSelected: ${page.number}/${pages.size}") + private fun onReaderPageSelected(page: ReaderPage, allowPreload: Boolean) { + val pages = page.chapter.pages!! // Won't be null because it's the loaded chapter + Timber.d("onReaderPageSelected: ${page.number}/${pages.size}") activity.onPageSelected(page) - if (page === pages.last() || page === pages.getOrNull(pages.lastIndex - 1)) { - Timber.d("Request preload next chapter because we're at the last page") - // EXH --> - nextChapter?.let { - activity.requestPreloadChapter(it) - } - // EXH <-- - } - // EXH --> - if(page === pages.first() || page === pages.getOrNull(1)) { - prevChapter?.let { + // Preload next chapter once we're within the last 5 pages of the current chapter + val inPreloadRange = pages.size - page.number < 5 + if (inPreloadRange && allowPreload && page.chapter == adapter.currentChapter) { + Timber.d("Request preload next chapter because we're at page ${page.number} of ${pages.size}") + adapter.nextTransition?.to?.let { activity.requestPreloadChapter(it) } } - // EXH <-- } /** - * Called from the ViewPager listener when a [transition] is marked as active. It request the + * Called when a [ChapterTransition] is marked as active. It request the * preload of the destination chapter of the transition. */ private fun onTransitionSelected(transition: ChapterTransition) { @@ -204,11 +189,6 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer { * it sets the chapters immediately, otherwise they are saved and set when it becomes idle. */ override fun setChapters(chapters: ViewerChapters) { - // EXH --> - nextChapter = chapters.nextChapter - prevChapter = chapters.prevChapter - // EXH <-- - if (isIdle) { setChaptersInternal(chapters) } else { @@ -221,20 +201,15 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer { */ private fun setChaptersInternal(chapters: ViewerChapters) { Timber.d("setChaptersInternal") - adapter.setChapters(chapters) + val forceTransition = config.alwaysShowChapterTransition || adapter.items.getOrNull(pager.currentItem) is ChapterTransition + adapter.setChapters(chapters, forceTransition) // Layout the pager once a chapter is being set if (pager.visibility == View.GONE) { Timber.d("Pager first layout") val pages = chapters.currChapter.pages ?: return moveToPage(pages[chapters.currChapter.requestedPage]) - pager.visibility = View.VISIBLE - } else { - // Trigger page change - val page = adapter.items.getOrNull(pager.currentItem) - if(page is ReaderPage) { - onPageSelected(page, pager.currentItem) - } + pager.visible() } } @@ -242,17 +217,15 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer { * Tells this viewer to move to the given [page]. */ override fun moveToPage(page: ReaderPage) { - Timber.d("moveToPage") + Timber.d("moveToPage ${page.number}") val position = adapter.items.indexOf(page) if (position != -1) { - // EXH --> - if(position == pager.currentItem) { - // Invoke anyways to update seekbar - onPageSelected(page, position) - } else { - pager.setCurrentItem(position, true) + val currentPosition = pager.currentItem + pager.setCurrentItem(position, true) + // manually call onPageChange since ViewPager listener is not triggered in this case + if (currentPosition == position) { + onPageChange(position) } - // EXH <-- } else { Timber.d("Page $page not found in adapter") } @@ -367,5 +340,4 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer { } return false } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt index 1da0c61ee..30e568b97 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.reader.viewer.pager import android.view.View import android.view.ViewGroup import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition +import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters import eu.kanade.tachiyomi.widget.ViewPagerAdapter @@ -19,12 +20,16 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() { var items: List = emptyList() private set + var nextTransition: ChapterTransition.Next? = null + private set + + var currentChapter: ReaderChapter? = null /** * Updates this adapter with the given [chapters]. It handles setting a few pages of the * next/previous chapter to allow seamless transitions and inverting the pages if the viewer * has R2L direction. */ - fun setChapters(chapters: ViewerChapters) { + fun setChapters(chapters: ViewerChapters, forceTransition: Boolean) { val newItems = mutableListOf() // Add previous chapter pages and transition. @@ -36,13 +41,11 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() { newItems.addAll(prevPages.takeLast(2)) } } - // EXH --> - if(viewer.activity.showTransitionPages) { - // EXH <-- + + // Skip transition page if the chapter is loaded & current page is not a transition page + if (forceTransition || chapters.prevChapter?.state !is ReaderChapter.State.Loaded) { newItems.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter)) - // EXH --> } - // EXH <-- // Add current chapter. val currPages = chapters.currChapter.pages @@ -50,14 +53,18 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() { newItems.addAll(currPages) } + currentChapter = chapters.currChapter + // Add next chapter transition and pages. - // EXH --> - if(viewer.activity.showTransitionPages) { - // EXH <-- - newItems.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter)) - // EXH --> - } - // EXH <-- + nextTransition = ChapterTransition.Next(chapters.currChapter, chapters.nextChapter) + .also { + if (forceTransition || + chapters.nextChapter?.state !is ReaderChapter.State.Loaded + ) { + newItems.add(it) + } + } + if (chapters.nextChapter != null) { // Add at most two pages, because this chapter will be selected before the user can // swap more pages. @@ -86,8 +93,7 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() { * Creates a new view for the item at the given [position]. */ override fun createView(container: ViewGroup, position: Int): View { - val item = items[position] - return when (item) { + return when (val item = items[position]) { is ReaderPage -> PagerPageHolder(viewer, item) is ChapterTransition -> PagerTransitionHolder(viewer, item) else -> throw NotImplementedError("Holder for ${item.javaClass} not implemented") @@ -106,7 +112,6 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() { Timber.d("Position for ${view.item} not found") } } - return androidx.viewpager.widget.PagerAdapter.POSITION_NONE + return POSITION_NONE } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt index f17c5ee82..92cb5192c 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt @@ -4,14 +4,16 @@ import android.view.ViewGroup import android.widget.FrameLayout import android.widget.LinearLayout import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition +import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters /** * RecyclerView Adapter used by this [viewer] to where [ViewerChapters] updates are posted. */ -class WebtoonAdapter(val viewer: WebtoonViewer) : androidx.recyclerview.widget.RecyclerView.Adapter() { +class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter() { /** * List of currently set items. @@ -19,11 +21,12 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : androidx.recyclerview.widget.R var items: List = emptyList() private set + var currentChapter: ReaderChapter? = null /** * Updates this adapter with the given [chapters]. It handles setting a few pages of the * next/previous chapter to allow seamless transitions. */ - fun setChapters(chapters: ViewerChapters) { + fun setChapters(chapters: ViewerChapters, forceTransition: Boolean) { val newItems = mutableListOf() // Add previous chapter pages and transition. @@ -35,7 +38,11 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : androidx.recyclerview.widget.R newItems.addAll(prevPages.takeLast(2)) } } - newItems.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter)) + + // Skip transition page if the chapter is loaded & current page is not a transition page + if (forceTransition || chapters.prevChapter?.state !is ReaderChapter.State.Loaded) { + newItems.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter)) + } // Add current chapter. val currPages = chapters.currChapter.pages @@ -43,8 +50,13 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : androidx.recyclerview.widget.R newItems.addAll(currPages) } + currentChapter = chapters.currChapter + // Add next chapter transition and pages. - newItems.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter)) + if (forceTransition || chapters.nextChapter?.state !is ReaderChapter.State.Loaded) { + newItems.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter)) + } + if (chapters.nextChapter != null) { // Add at most two pages, because this chapter will be selected before the user can // swap more pages. @@ -70,8 +82,7 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : androidx.recyclerview.widget.R * Returns the view type for the item at the given [position]. */ override fun getItemViewType(position: Int): Int { - val item = items[position] - return when (item) { + return when (val item = items[position]) { is ReaderPage -> PAGE_VIEW is ChapterTransition -> TRANSITION_VIEW else -> error("Unknown view type for ${item.javaClass}") @@ -81,7 +92,7 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : androidx.recyclerview.widget.R /** * Creates a new view holder for an item with the given [viewType]. */ - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): androidx.recyclerview.widget.RecyclerView.ViewHolder { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return when (viewType) { PAGE_VIEW -> { val view = FrameLayout(parent.context) @@ -98,7 +109,7 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : androidx.recyclerview.widget.R /** * Binds an existing view [holder] with the item at the given [position]. */ - override fun onBindViewHolder(holder: androidx.recyclerview.widget.RecyclerView.ViewHolder, position: Int) { + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { val item = items[position] when (holder) { is WebtoonPageHolder -> holder.bind(item as ReaderPage) @@ -109,7 +120,7 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : androidx.recyclerview.widget.R /** * Recycles an existing view [holder] before adding it to the view pool. */ - override fun onViewRecycled(holder: androidx.recyclerview.widget.RecyclerView.ViewHolder) { + override fun onViewRecycled(holder: RecyclerView.ViewHolder) { when (holder) { is WebtoonPageHolder -> holder.recycle() is WebtoonTransitionHolder -> holder.recycle() @@ -120,8 +131,8 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : androidx.recyclerview.widget.R * Diff util callback used to dispatch delta updates instead of full dataset changes. */ private class Callback( - private val oldItems: List, - private val newItems: List + private val oldItems: List, + private val newItems: List ) : DiffUtil.Callback() { /** @@ -167,5 +178,4 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : androidx.recyclerview.widget.R */ const val TRANSITION_VIEW = 1 } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonBaseHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonBaseHolder.kt index 293127cb3..10a01b137 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonBaseHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonBaseHolder.kt @@ -7,8 +7,8 @@ import eu.kanade.tachiyomi.ui.base.holder.BaseViewHolder import rx.Subscription abstract class WebtoonBaseHolder( - view: View, - protected val viewer: WebtoonViewer + view: View, + protected val viewer: WebtoonViewer ) : BaseViewHolder(view) { /** @@ -42,5 +42,4 @@ abstract class WebtoonBaseHolder( protected fun View.wrapContent() { layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonConfig.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonConfig.kt index 7ac8a220a..6f108b892 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonConfig.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonConfig.kt @@ -1,74 +1,26 @@ package eu.kanade.tachiyomi.ui.reader.viewer.webtoon -import com.f2prateek.rx.preferences.Preference import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.util.addTo -import rx.subscriptions.CompositeSubscription +import eu.kanade.tachiyomi.ui.reader.viewer.ViewerConfig import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get /** * Configuration used by webtoon viewers. */ -class WebtoonConfig(preferences: PreferencesHelper = Injekt.get()) { - - private val subscriptions = CompositeSubscription() - - var imagePropertyChangedListener: (() -> Unit)? = null - - var tappingEnabled = true - private set - - var longTapEnabled = true - private set - - var volumeKeysEnabled = false - private set - - var volumeKeysInverted = false - private set +class WebtoonConfig(preferences: PreferencesHelper = Injekt.get()) : ViewerConfig(preferences) { var imageCropBorders = false private set - var doubleTapAnimDuration = 500 + var sidePadding = 0 private set init { - preferences.readWithTapping() - .register({ tappingEnabled = it }) - - preferences.readWithLongTap() - .register({ longTapEnabled = it }) - preferences.cropBordersWebtoon() .register({ imageCropBorders = it }, { imagePropertyChangedListener?.invoke() }) - preferences.doubleTapAnimSpeed() - .register({ doubleTapAnimDuration = it }) - - preferences.readWithVolumeKeys() - .register({ volumeKeysEnabled = it }) - - preferences.readWithVolumeKeysInverted() - .register({ volumeKeysInverted = it }) + preferences.webtoonSidePadding() + .register({ sidePadding = it }, { imagePropertyChangedListener?.invoke() }) } - - fun unsubscribe() { - subscriptions.unsubscribe() - } - - private fun Preference.register( - valueAssignment: (T) -> Unit, - onChanged: (T) -> Unit = {} - ) { - asObservable() - .doOnNext(valueAssignment) - .skip(1) - .distinctUntilChanged() - .doOnNext(onChanged) - .subscribe() - .addTo(subscriptions) - } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonFrame.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonFrame.kt index 955dc898e..3f919569c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonFrame.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonFrame.kt @@ -68,13 +68,12 @@ class WebtoonFrame(context: Context) : FrameLayout(context) { } override fun onFling( - e1: MotionEvent?, - e2: MotionEvent?, - velocityX: Float, - velocityY: Float + e1: MotionEvent?, + e2: MotionEvent?, + velocityX: Float, + velocityY: Float ): Boolean { return recycler?.zoomFling(velocityX.toInt(), velocityY.toInt()) ?: false } } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt index 377b9677e..74ced080c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt @@ -13,7 +13,7 @@ import eu.kanade.tachiyomi.ui.reader.ReaderActivity * This layout manager uses the same package name as the support library in order to use a package * protected method. */ -class WebtoonLayoutManager(activity: ReaderActivity) : androidx.recyclerview.widget.LinearLayoutManager(activity) { +class WebtoonLayoutManager(activity: ReaderActivity) : LinearLayoutManager(activity) { /** * Extra layout space is set to half the screen height. @@ -27,7 +27,7 @@ class WebtoonLayoutManager(activity: ReaderActivity) : androidx.recyclerview.wid /** * Returns the custom extra layout space. */ - override fun getExtraLayoutSpace(state: androidx.recyclerview.widget.RecyclerView.State): Int { + override fun getExtraLayoutSpace(state: RecyclerView.State): Int { return extraLayoutSpace } @@ -36,20 +36,20 @@ class WebtoonLayoutManager(activity: ReaderActivity) : androidx.recyclerview.wid */ fun findLastEndVisibleItemPosition(): Int { ensureLayoutState() - @androidx.recyclerview.widget.ViewBoundsCheck.ViewBounds val preferredBoundsFlag = - (androidx.recyclerview.widget.ViewBoundsCheck.FLAG_CVE_LT_PVE or androidx.recyclerview.widget.ViewBoundsCheck.FLAG_CVE_EQ_PVE) + @ViewBoundsCheck.ViewBounds val preferredBoundsFlag = + (ViewBoundsCheck.FLAG_CVE_LT_PVE or ViewBoundsCheck.FLAG_CVE_EQ_PVE) val fromIndex = childCount - 1 val toIndex = -1 - val child = if (mOrientation == HORIZONTAL) + val child = if (mOrientation == HORIZONTAL) { mHorizontalBoundCheck .findOneViewWithinBoundFlags(fromIndex, toIndex, preferredBoundsFlag, 0) - else + } else { mVerticalBoundCheck .findOneViewWithinBoundFlags(fromIndex, toIndex, preferredBoundsFlag, 0) + } return if (child == null) NO_POSITION else getPosition(child) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt index ae603a3ec..384cc978d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt @@ -1,9 +1,8 @@ package eu.kanade.tachiyomi.ui.reader.viewer.webtoon import android.annotation.SuppressLint -import android.content.Intent +import android.content.res.Resources import android.graphics.drawable.Drawable -import android.net.Uri import android.view.Gravity import android.view.ViewGroup import android.view.ViewGroup.LayoutParams.MATCH_PARENT @@ -29,16 +28,17 @@ import eu.kanade.tachiyomi.data.glide.GlideApp import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressBar -import eu.kanade.tachiyomi.util.ImageUtil -import eu.kanade.tachiyomi.util.dpToPx -import eu.kanade.tachiyomi.util.gone -import eu.kanade.tachiyomi.util.visible +import eu.kanade.tachiyomi.ui.webview.WebViewActivity +import eu.kanade.tachiyomi.util.system.ImageUtil +import eu.kanade.tachiyomi.util.system.dpToPx +import eu.kanade.tachiyomi.util.view.gone +import eu.kanade.tachiyomi.util.view.visible +import java.io.InputStream +import java.util.concurrent.TimeUnit import rx.Observable import rx.Subscription import rx.android.schedulers.AndroidSchedulers import rx.schedulers.Schedulers -import java.io.InputStream -import java.util.concurrent.TimeUnit /** * Holder of the webtoon reader for a single page of a chapter. @@ -48,8 +48,8 @@ import java.util.concurrent.TimeUnit * @constructor creates a new webtoon holder. */ class WebtoonPageHolder( - private val frame: FrameLayout, - viewer: WebtoonViewer + private val frame: FrameLayout, + viewer: WebtoonViewer ) : WebtoonBaseHolder(frame, viewer) { /** @@ -111,7 +111,7 @@ class WebtoonPageHolder( private var readImageHeaderSubscription: Subscription? = null init { - frame.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + refreshLayoutParams() } /** @@ -120,6 +120,19 @@ class WebtoonPageHolder( fun bind(page: ReaderPage) { this.page = page observeStatus() + refreshLayoutParams() + } + + private fun refreshLayoutParams() { + frame.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { + if (!viewer.isContinuous) { + bottomMargin = 15.dpToPx + } + + val margin = Resources.getSystem().displayMetrics.widthPixels * (viewer.config.sidePadding / 100f) + marginEnd = margin.toInt() + marginStart = margin.toInt() + } } /** @@ -328,7 +341,7 @@ class WebtoonPageHolder( val size = 48.dpToPx layoutParams = FrameLayout.LayoutParams(size, size).apply { gravity = Gravity.CENTER_HORIZONTAL - setMargins(0, parentHeight/4, 0, 0) + setMargins(0, parentHeight / 4, 0, 0) } } progressContainer.addView(progress) @@ -389,7 +402,7 @@ class WebtoonPageHolder( AppCompatButton(context).apply { layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { gravity = Gravity.CENTER_HORIZONTAL - setMargins(0, parentHeight/4, 0, 0) + setMargins(0, parentHeight / 4, 0, 0) } setText(R.string.action_retry) setOnClickListener { @@ -411,7 +424,7 @@ class WebtoonPageHolder( val decodeLayout = LinearLayout(context).apply { layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, parentHeight).apply { - setMargins(0, parentHeight/6, 0, 0) + setMargins(0, parentHeight / 6, 0, 0) } gravity = Gravity.CENTER_HORIZONTAL orientation = LinearLayout.VERTICAL @@ -441,14 +454,14 @@ class WebtoonPageHolder( } val imageUrl = page?.imageUrl - if (imageUrl.orEmpty().startsWith("http")) { + if (imageUrl.orEmpty().startsWith("http", true)) { AppCompatButton(context).apply { layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { setMargins(0, margins, 0, margins) } - setText(R.string.action_open_in_browser) + setText(R.string.action_open_in_web_view) setOnClickListener { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(imageUrl)) + val intent = WebViewActivity.newIntent(context, imageUrl!!) context.startActivity(intent) } @@ -482,21 +495,21 @@ class WebtoonPageHolder( .transition(DrawableTransitionOptions.with(NoTransition.getFactory())) .listener(object : RequestListener { override fun onLoadFailed( - e: GlideException?, - model: Any?, - target: Target?, - isFirstResource: Boolean + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean ): Boolean { onImageDecodeError() return false } override fun onResourceReady( - resource: Drawable?, - model: Any?, - target: Target?, - dataSource: DataSource?, - isFirstResource: Boolean + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean ): Boolean { if (resource is GifDrawable) { resource.setLoopCount(GifDrawable.LOOP_INTRINSIC) @@ -507,5 +520,4 @@ class WebtoonPageHolder( }) .into(this) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonRecyclerView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonRecyclerView.kt index 01489d56d..91c839ec8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonRecyclerView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonRecyclerView.kt @@ -3,31 +3,33 @@ package eu.kanade.tachiyomi.ui.reader.viewer.webtoon import android.animation.Animator import android.animation.AnimatorSet import android.animation.ValueAnimator -import android.annotation.TargetApi import android.content.Context -import android.os.Build import android.util.AttributeSet import android.view.HapticFeedbackConstants import android.view.MotionEvent import android.view.ViewConfiguration import android.view.animation.DecelerateInterpolator +import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import eu.kanade.tachiyomi.ui.reader.viewer.GestureDetectorWithLongTap +import kotlin.math.abs /** * Implementation of a [RecyclerView] used by the webtoon reader. */ open class WebtoonRecyclerView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyle: Int = 0 -) : androidx.recyclerview.widget.RecyclerView(context, attrs, defStyle) { + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0 +) : RecyclerView(context, attrs, defStyle) { private var isZooming = false private var atLastPosition = false private var atFirstPosition = false private var halfWidth = 0 private var halfHeight = 0 + private var originalHeight = 0 + private var heightSet = false private var firstVisibleItemPosition = 0 private var lastVisibleItemPosition = 0 private var currentScale = DEFAULT_RATE @@ -41,6 +43,10 @@ open class WebtoonRecyclerView @JvmOverloads constructor( override fun onMeasure(widthSpec: Int, heightSpec: Int) { halfWidth = MeasureSpec.getSize(widthSpec) / 2 halfHeight = MeasureSpec.getSize(heightSpec) / 2 + if (!heightSet) { + originalHeight = MeasureSpec.getSize(heightSpec) + heightSet = true + } super.onMeasure(widthSpec, heightSpec) } @@ -53,11 +59,10 @@ open class WebtoonRecyclerView @JvmOverloads constructor( super.onScrolled(dx, dy) val layoutManager = layoutManager lastVisibleItemPosition = - (layoutManager as androidx.recyclerview.widget.LinearLayoutManager).findLastVisibleItemPosition() + (layoutManager as LinearLayoutManager).findLastVisibleItemPosition() firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition() } - @TargetApi(Build.VERSION_CODES.KITKAT) override fun onScrollStateChanged(state: Int) { super.onScrollStateChanged(state) val layoutManager = layoutManager @@ -68,22 +73,28 @@ open class WebtoonRecyclerView @JvmOverloads constructor( } private fun getPositionX(positionX: Float): Float { + if (currentScale < 1) { + return 0f + } val maxPositionX = halfWidth * (currentScale - 1) return positionX.coerceIn(-maxPositionX, maxPositionX) } private fun getPositionY(positionY: Float): Float { + if (currentScale < 1) { + return (originalHeight / 2 - halfHeight).toFloat() + } val maxPositionY = halfHeight * (currentScale - 1) return positionY.coerceIn(-maxPositionY, maxPositionY) } private fun zoom( - fromRate: Float, - toRate: Float, - fromX: Float, - toX: Float, - fromY: Float, - toY: Float + fromRate: Float, + toRate: Float, + fromX: Float, + toX: Float, + fromY: Float, + toY: Float ) { isZooming = true val animatorSet = AnimatorSet() @@ -103,7 +114,6 @@ open class WebtoonRecyclerView @JvmOverloads constructor( animatorSet.start() animatorSet.addListener(object : Animator.AnimatorListener { override fun onAnimationStart(animation: Animator) { - } override fun onAnimationEnd(animation: Animator) { @@ -112,11 +122,9 @@ open class WebtoonRecyclerView @JvmOverloads constructor( } override fun onAnimationCancel(animation: Animator) { - } override fun onAnimationRepeat(animation: Animator) { - } }) } @@ -166,11 +174,15 @@ open class WebtoonRecyclerView @JvmOverloads constructor( fun onScale(scaleFactor: Float) { currentScale *= scaleFactor currentScale = currentScale.coerceIn( - DEFAULT_RATE, - MAX_SCALE_RATE) + MIN_RATE, + MAX_SCALE_RATE + ) setScaleRate(currentScale) + layoutParams.height = if (currentScale < 1) { (originalHeight / currentScale).toInt() } else { originalHeight } + halfHeight = layoutParams.height / 2 + if (currentScale != DEFAULT_RATE) { x = getPositionX(x) y = getPositionY(y) @@ -178,6 +190,8 @@ open class WebtoonRecyclerView @JvmOverloads constructor( x = 0f y = 0f } + + requestLayout() } fun onScaleBegin() { @@ -187,8 +201,8 @@ open class WebtoonRecyclerView @JvmOverloads constructor( } fun onScaleEnd() { - if (scaleX < DEFAULT_RATE) { - zoom(currentScale, DEFAULT_RATE, x, 0f, y, 0f) + if (scaleX < MIN_RATE) { + zoom(currentScale, MIN_RATE, x, 0f, y, 0f) } } @@ -223,7 +237,6 @@ open class WebtoonRecyclerView @JvmOverloads constructor( performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) } } - } inner class Detector : GestureDetectorWithLongTap(context, listener) { @@ -269,7 +282,7 @@ open class WebtoonRecyclerView @JvmOverloads constructor( if (!isZoomDragging && currentScale > 1f) { var startScroll = false - if (Math.abs(dx) > touchSlop) { + if (abs(dx) > touchSlop) { if (dx < 0) { dx += touchSlop } else { @@ -277,7 +290,7 @@ open class WebtoonRecyclerView @JvmOverloads constructor( } startScroll = true } - if (Math.abs(dy) > touchSlop) { + if (abs(dy) > touchSlop) { if (dy < 0) { dy += touchSlop } else { @@ -311,13 +324,12 @@ open class WebtoonRecyclerView @JvmOverloads constructor( } return super.onTouchEvent(ev) } - } private companion object { const val ANIMATOR_DURATION_TIME = 200 + const val MIN_RATE = 0.5f const val DEFAULT_RATE = 1f const val MAX_SCALE_RATE = 3f } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonSubsamplingImageView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonSubsamplingImageView.kt index 692c0596b..88f916161 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonSubsamplingImageView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonSubsamplingImageView.kt @@ -10,12 +10,11 @@ import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView * webtoon viewer handles all the gestures. */ class WebtoonSubsamplingImageView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null + context: Context, + attrs: AttributeSet? = null ) : SubsamplingScaleImageView(context, attrs) { override fun onTouchEvent(event: MotionEvent): Boolean { return false } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt index 3bbca5c17..e99c8224f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt @@ -15,8 +15,8 @@ import androidx.appcompat.widget.AppCompatTextView import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter -import eu.kanade.tachiyomi.util.dpToPx -import eu.kanade.tachiyomi.util.visibleIf +import eu.kanade.tachiyomi.util.system.dpToPx +import eu.kanade.tachiyomi.util.view.visibleIf import rx.Subscription import rx.android.schedulers.AndroidSchedulers @@ -24,8 +24,8 @@ import rx.android.schedulers.AndroidSchedulers * Holder of the webtoon viewer that contains a chapter transition. */ class WebtoonTransitionHolder( - val layout: LinearLayout, - viewer: WebtoonViewer + val layout: LinearLayout, + viewer: WebtoonViewer ) : WebtoonBaseHolder(layout, viewer) { /** @@ -36,7 +36,10 @@ class WebtoonTransitionHolder( /** * Text view used to display the text of the current and next/prev chapters. */ - private var textView = TextView(context) + private var textView = TextView(context).apply { + textSize = 17.5F + wrapContent() + } /** * View container of the current status of the transition page. Child views will be added @@ -161,7 +164,8 @@ class WebtoonTransitionHolder( .subscribe { state -> pagesContainer.removeAllViews() when (state) { - is ReaderChapter.State.Wait -> {} + is ReaderChapter.State.Wait -> { + } is ReaderChapter.State.Loading -> setLoading() is ReaderChapter.State.Error -> setError(state.error, transition) is ReaderChapter.State.Loaded -> setLoaded() @@ -227,5 +231,4 @@ class WebtoonTransitionHolder( pagesContainer.addView(textView) pagesContainer.addView(retryBtn) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt index fdd629883..204ca2c82 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt @@ -7,19 +7,22 @@ import android.view.ViewGroup import android.view.ViewGroup.LayoutParams.MATCH_PARENT import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.WebtoonLayoutManager -import com.elvishew.xlog.XLog import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters import eu.kanade.tachiyomi.ui.reader.viewer.BaseViewer +import eu.kanade.tachiyomi.util.view.gone +import eu.kanade.tachiyomi.util.view.visible +import kotlin.math.max +import kotlin.math.min import rx.subscriptions.CompositeSubscription import timber.log.Timber /** * Implementation of a [BaseViewer] to display pages with a [RecyclerView]. */ -class WebtoonViewer(val activity: ReaderActivity) : BaseViewer { +class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = true) : BaseViewer { /** * Recycler view used by this viewer. @@ -62,32 +65,21 @@ class WebtoonViewer(val activity: ReaderActivity) : BaseViewer { val subscriptions = CompositeSubscription() init { - recycler.visibility = View.GONE // Don't let the recycler layout yet + recycler.gone() // Don't let the recycler layout yet recycler.layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT) recycler.itemAnimator = null recycler.layoutManager = layoutManager recycler.adapter = adapter - recycler.addOnScrollListener(object : androidx.recyclerview.widget.RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: androidx.recyclerview.widget.RecyclerView, dx: Int, dy: Int) { + recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { val position = layoutManager.findLastEndVisibleItemPosition() val item = adapter.items.getOrNull(position) - if (item != null) { + val allowPreload = checkAllowPreload(item as? ReaderPage) + if (item != null && currentPage != item) { currentPage = item when (item) { - is ReaderPage -> onPageSelected(item, position) - is ChapterTransition -> { - onTransitionSelected(item) - - // If transition pages are invisible select page before transition page - if(!activity.showTransitionPages) { - val lastPosition = position - 1 - val lastItem = adapter.items.getOrNull(lastPosition) - if(lastItem is ReaderPage) { - currentPage = lastItem - onPageSelected(lastItem, lastPosition) - } - } - } + is ReaderPage -> onPageSelected(item, allowPreload) + is ChapterTransition -> onTransitionSelected(item) } } @@ -102,16 +94,13 @@ class WebtoonViewer(val activity: ReaderActivity) : BaseViewer { }) recycler.tapListener = { event -> val positionX = event.rawX - val positionY = event.rawY when { - positionY < recycler.height * 0.25 -> if (config.tappingEnabled) scrollUp() - positionY > recycler.height * 0.75 -> if (config.tappingEnabled) scrollDown() - positionX < recycler.width * 0.33 -> if (config.tappingEnabled) scrollUp() - positionX > recycler.width * 0.66 -> if (config.tappingEnabled) scrollDown() + positionX < recycler.width * 0.33 -> if (config.tappingEnabled) scrollUp() else activity.toggleMenu() + positionX > recycler.width * 0.66 -> if (config.tappingEnabled) scrollDown() else activity.toggleMenu() else -> activity.toggleMenu() } } - recycler.longTapListener = f@ { event -> + recycler.longTapListener = f@{ event -> if (activity.menuVisible || config.longTapEnabled) { val child = recycler.findChildViewUnder(event.x, event.y) if (child != null) { @@ -126,10 +115,34 @@ class WebtoonViewer(val activity: ReaderActivity) : BaseViewer { false } + config.imagePropertyChangedListener = { + refreshAdapter() + } + frame.layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT) frame.addView(recycler) } + private fun checkAllowPreload(page: ReaderPage?): Boolean { + // Page is transition page - preload allowed + page ?: return true + + // Initial opening - preload allowed + currentPage ?: return true + + val nextItem = adapter.items.getOrNull(adapter.items.count() - 1) + val nextChapter = (nextItem as? ChapterTransition.Next)?.to ?: (nextItem as? ReaderPage)?.chapter + + // Allow preload for + // 1. Going between pages of same chapter + // 2. Next chapter page + return when (page.chapter) { + (currentPage as? ReaderPage)?.chapter -> true + nextChapter -> true + else -> false + } + } + /** * Returns the view this viewer uses. */ @@ -142,7 +155,6 @@ class WebtoonViewer(val activity: ReaderActivity) : BaseViewer { */ override fun destroy() { super.destroy() - config.unsubscribe() subscriptions.unsubscribe() } @@ -150,38 +162,20 @@ class WebtoonViewer(val activity: ReaderActivity) : BaseViewer { * Called from the RecyclerView listener when a [page] is marked as active. It notifies the * activity of the change and requests the preload of the next chapter if this is the last page. */ - private fun onPageSelected(page: ReaderPage, position: Int) { - val pages = page.chapter.pages // Won't be null because it's the loaded chapter - // EXH --> - if(pages == null) { - XLog.e("Webtoon reader chapter pages are null (position: %s," + - " page.index: %s," + - " page.url: %s," + - " page.imageUrl: %s," + - " page.chapter.state: %s," + - " page.chapter.pageLoader == null: %s," + - " page.chapter.requestedPage: %s" + - " page.chapter.references: %s)!", - position, - page.index, - page.url, - page.imageUrl, - page.chapter.state::class.simpleName, - page.chapter.pageLoader == null, - page.chapter.requestedPage, - page.chapter.references) - return - } - // EXH <-- - + private fun onPageSelected(page: ReaderPage, allowPreload: Boolean) { + val pages = page.chapter.pages!! // Won't be null because it's the loaded chapter Timber.d("onPageSelected: ${page.number}/${pages.size}") activity.onPageSelected(page) - if (page === pages.last()) { - Timber.d("Request preload next chapter because we're at the last page") - val transition = adapter.items.getOrNull(position + 1) as? ChapterTransition.Next - if (transition?.to != null) { - activity.requestPreloadChapter(transition.to) + // Preload next chapter once we're within the last 5 pages of the current chapter + val inPreloadRange = pages.size - page.number < 5 + if (inPreloadRange && allowPreload && page.chapter == adapter.currentChapter) { + Timber.d("Request preload next chapter because we're at page ${page.number} of ${pages.size}") + val nextItem = adapter.items.getOrNull(adapter.items.size - 1) + val transitionChapter = (nextItem as? ChapterTransition.Next)?.to ?: (nextItem as?ReaderPage)?.chapter + if (transitionChapter != null) { + Timber.d("Requesting to preload chapter ${transitionChapter.chapter.chapter_number}") + activity.requestPreloadChapter(transitionChapter) } } } @@ -207,13 +201,14 @@ class WebtoonViewer(val activity: ReaderActivity) : BaseViewer { */ override fun setChapters(chapters: ViewerChapters) { Timber.d("setChapters") - adapter.setChapters(chapters) + val forceTransition = config.alwaysShowChapterTransition || currentPage is ChapterTransition + adapter.setChapters(chapters, forceTransition) if (recycler.visibility == View.GONE) { Timber.d("Recycler first layout") val pages = chapters.currChapter.pages ?: return moveToPage(pages[chapters.currChapter.requestedPage]) - recycler.visibility = View.VISIBLE + recycler.visible() } } @@ -288,4 +283,15 @@ class WebtoonViewer(val activity: ReaderActivity) : BaseViewer { return false } + /** + * Notifies adapter of changes around the current page to trigger a relayout in the recycler. + * Used when an image configuration is changed. + */ + private fun refreshAdapter() { + val position = layoutManager.findLastEndVisibleItemPosition() + adapter.notifyItemRangeChanged( + max(0, position - 2), + min(position + 2, adapter.itemCount - 1) + ) + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/DateItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/DateSectionItem.kt old mode 100755 new mode 100644 similarity index 83% rename from app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/DateItem.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/recent/DateSectionItem.kt index 56ad55dce..450297a5d --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/DateItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/DateSectionItem.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.ui.recent_updates +package eu.kanade.tachiyomi.ui.recent import android.text.format.DateUtils import android.view.View @@ -8,12 +8,12 @@ import eu.davidea.flexibleadapter.items.AbstractHeaderItem import eu.davidea.flexibleadapter.items.IFlexible import eu.davidea.viewholders.FlexibleViewHolder import eu.kanade.tachiyomi.R -import java.util.* +import java.util.Date -class DateItem(val date: Date) : AbstractHeaderItem() { +class DateSectionItem(val date: Date) : AbstractHeaderItem() { override fun getLayoutRes(): Int { - return R.layout.recent_chapters_section_item + return R.layout.recent_section_item } override fun createViewHolder(view: View, adapter: FlexibleAdapter>): Holder { @@ -26,7 +26,7 @@ class DateItem(val date: Date) : AbstractHeaderItem() { override fun equals(other: Any?): Boolean { if (this === other) return true - if (other is DateItem) { + if (other is DateSectionItem) { return date == other.date } return false @@ -42,7 +42,7 @@ class DateItem(val date: Date) : AbstractHeaderItem() { val section_text: TextView = view.findViewById(R.id.section_text) - fun bind(item: DateItem) { + fun bind(item: DateSectionItem) { section_text.text = DateUtils.getRelativeTimeSpanString(item.date.time, now, DateUtils.DAY_IN_MILLIS) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryAdapter.kt old mode 100755 new mode 100644 similarity index 55% rename from app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadAdapter.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryAdapter.kt index 766ce466d..98f2f4245 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryAdapter.kt @@ -1,38 +1,41 @@ -package eu.kanade.tachiyomi.ui.recently_read +package eu.kanade.tachiyomi.ui.recent.history import eu.davidea.flexibleadapter.FlexibleAdapter import eu.kanade.tachiyomi.source.SourceManager -import uy.kohesive.injekt.injectLazy -import java.text.DateFormat import java.text.DecimalFormat import java.text.DecimalFormatSymbols +import uy.kohesive.injekt.injectLazy /** - * Adapter of RecentlyReadHolder. + * Adapter of HistoryHolder. * Connection between Fragment and Holder * Holder updates should be called from here. * - * @param controller a RecentlyReadController object + * @param controller a HistoryController object * @constructor creates an instance of the adapter. */ -class RecentlyReadAdapter(controller: RecentlyReadController) -: FlexibleAdapter(null, controller, true) { +class HistoryAdapter(controller: HistoryController) : + FlexibleAdapter(null, controller, true) { val sourceManager by injectLazy() val resumeClickListener: OnResumeClickListener = controller - val removeClickListener: OnRemoveClickListener = controller - - val coverClickListener: OnCoverClickListener = controller + val itemClickListener: OnItemClickListener = controller /** * DecimalFormat used to display correct chapter number */ - val decimalFormat = DecimalFormat("#.###", DecimalFormatSymbols() - .apply { decimalSeparator = '.' }) + val decimalFormat = DecimalFormat( + "#.###", + DecimalFormatSymbols() + .apply { decimalSeparator = '.' } + ) - val dateFormat: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) + init { + setDisplayHeadersAtStartUp(true) + setStickyHeaders(true) + } interface OnResumeClickListener { fun onResumeClick(position: Int) @@ -42,7 +45,7 @@ class RecentlyReadAdapter(controller: RecentlyReadController) fun onRemoveClick(position: Int) } - interface OnCoverClickListener { - fun onCoverClick(position: Int) + interface OnItemClickListener { + fun onItemClick(position: Int) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryController.kt similarity index 65% rename from app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadController.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryController.kt index 260b0e313..fa7d248b2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryController.kt @@ -1,47 +1,54 @@ -package eu.kanade.tachiyomi.ui.recently_read +package eu.kanade.tachiyomi.ui.recent.history import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.recyclerview.widget.LinearLayoutManager import eu.davidea.flexibleadapter.FlexibleAdapter import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.History import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.databinding.HistoryControllerBinding +import eu.kanade.tachiyomi.ui.base.controller.NoToolbarElevationController import eu.kanade.tachiyomi.ui.base.controller.NucleusController +import eu.kanade.tachiyomi.ui.base.controller.RootController import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.reader.ReaderActivity -import eu.kanade.tachiyomi.util.toast -import kotlinx.android.synthetic.main.recently_read_controller.* +import eu.kanade.tachiyomi.util.system.toast /** * Fragment that shows recently read manga. - * Uses R.layout.fragment_recently_read. + * Uses [R.layout.history_controller]. * UI related actions should be called from here. */ -class RecentlyReadController : NucleusController(), - FlexibleAdapter.OnUpdateListener, - RecentlyReadAdapter.OnRemoveClickListener, - RecentlyReadAdapter.OnResumeClickListener, - RecentlyReadAdapter.OnCoverClickListener, - RemoveHistoryDialog.Listener { +class HistoryController : + NucleusController(), + RootController, + NoToolbarElevationController, + FlexibleAdapter.OnUpdateListener, + HistoryAdapter.OnRemoveClickListener, + HistoryAdapter.OnResumeClickListener, + HistoryAdapter.OnItemClickListener, + RemoveHistoryDialog.Listener { /** * Adapter containing the recent manga. */ - var adapter: RecentlyReadAdapter? = null + var adapter: HistoryAdapter? = null private set override fun getTitle(): String? { return resources?.getString(R.string.label_recent_manga) } - override fun createPresenter(): RecentlyReadPresenter { - return RecentlyReadPresenter() + override fun createPresenter(): HistoryPresenter { + return HistoryPresenter() } override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.recently_read_controller, container, false) + binding = HistoryControllerBinding.inflate(inflater) + return binding.root } /** @@ -53,10 +60,10 @@ class RecentlyReadController : NucleusController(), super.onViewCreated(view) // Initialize adapter - recycler.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(view.context) - adapter = RecentlyReadAdapter(this@RecentlyReadController) - recycler.setHasFixedSize(true) - recycler.adapter = adapter + binding.recycler.layoutManager = LinearLayoutManager(view.context) + adapter = HistoryAdapter(this@HistoryController) + binding.recycler.setHasFixedSize(true) + binding.recycler.adapter = adapter } override fun onDestroyView(view: View) { @@ -69,15 +76,15 @@ class RecentlyReadController : NucleusController(), * * @param mangaHistory list of manga history */ - fun onNextManga(mangaHistory: List) { + fun onNextManga(mangaHistory: List) { adapter?.updateDataSet(mangaHistory) } override fun onUpdateEmptyView(size: Int) { if (size > 0) { - empty_view.hide() + binding.emptyView.hide() } else { - empty_view.show(R.drawable.ic_glasses_black_128dp, R.string.information_no_recent_manga) + binding.emptyView.show(R.string.information_no_recent_manga) } } @@ -99,7 +106,7 @@ class RecentlyReadController : NucleusController(), RemoveHistoryDialog(this, manga, history).showDialog(router) } - override fun onCoverClick(position: Int) { + override fun onItemClick(position: Int) { val manga = adapter?.getItem(position)?.mch?.manga ?: return router.pushController(MangaController(manga).withFadeTransaction()) } @@ -113,5 +120,4 @@ class RecentlyReadController : NucleusController(), presenter.removeFromHistory(history) } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryHolder.kt old mode 100755 new mode 100644 similarity index 52% rename from app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadHolder.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryHolder.kt index 64eab1a9b..eb922988a --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryHolder.kt @@ -1,13 +1,20 @@ -package eu.kanade.tachiyomi.ui.recently_read +package eu.kanade.tachiyomi.ui.recent.history import android.view.View import com.bumptech.glide.load.engine.DiskCacheStrategy import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.data.glide.toMangaThumbnail import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder -import kotlinx.android.synthetic.main.recently_read_item.* +import eu.kanade.tachiyomi.util.lang.toTimestampString import java.util.Date +import kotlinx.android.synthetic.main.history_item.cover +import kotlinx.android.synthetic.main.history_item.holder +import kotlinx.android.synthetic.main.history_item.manga_subtitle +import kotlinx.android.synthetic.main.history_item.manga_title +import kotlinx.android.synthetic.main.history_item.remove +import kotlinx.android.synthetic.main.history_item.resume /** * Holder that contains recent manga item @@ -18,22 +25,22 @@ import java.util.Date * @param adapter the adapter handling this holder. * @constructor creates a new recent chapter holder. */ -class RecentlyReadHolder( - view: View, - val adapter: RecentlyReadAdapter +class HistoryHolder( + view: View, + val adapter: HistoryAdapter ) : BaseFlexibleViewHolder(view, adapter) { init { + holder.setOnClickListener { + adapter.itemClickListener.onItemClick(bindingAdapterPosition) + } + remove.setOnClickListener { - adapter.removeClickListener.onRemoveClick(adapterPosition) + adapter.removeClickListener.onRemoveClick(bindingAdapterPosition) } resume.setOnClickListener { - adapter.resumeClickListener.onResumeClick(adapterPosition) - } - - cover.setOnClickListener { - adapter.coverClickListener.onCoverClick(adapterPosition) + adapter.resumeClickListener.onResumeClick(bindingAdapterPosition) } } @@ -49,24 +56,19 @@ class RecentlyReadHolder( // Set manga title manga_title.text = manga.title - // Set source + chapter title + // Set chapter number + timestamp val formattedNumber = adapter.decimalFormat.format(chapter.chapter_number.toDouble()) - manga_source.text = itemView.context.getString(R.string.recent_manga_source) - .format(adapter.sourceManager.getOrStub(manga.source).toString(), formattedNumber) - - // Set last read timestamp title - last_read.text = adapter.dateFormat.format(Date(history.last_read)) + manga_subtitle.text = itemView.context.getString(R.string.recent_manga_time) + .format(formattedNumber, Date(history.last_read).toTimestampString()) // Set cover GlideApp.with(itemView.context).clear(cover) if (!manga.thumbnail_url.isNullOrEmpty()) { GlideApp.with(itemView.context) - .load(manga) - .diskCacheStrategy(DiskCacheStrategy.RESOURCE) - .centerCrop() - .into(cover) + .load(manga.toMangaThumbnail()) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .centerCrop() + .into(cover) } } - - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryItem.kt new file mode 100644 index 000000000..9a10529b2 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryItem.kt @@ -0,0 +1,42 @@ +package eu.kanade.tachiyomi.ui.recent.history + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractSectionableItem +import eu.davidea.flexibleadapter.items.IFlexible +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory +import eu.kanade.tachiyomi.ui.recent.DateSectionItem + +class HistoryItem(val mch: MangaChapterHistory, header: DateSectionItem) : + AbstractSectionableItem(header) { + + override fun getLayoutRes(): Int { + return R.layout.history_item + } + + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): HistoryHolder { + return HistoryHolder(view, adapter as HistoryAdapter) + } + + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: HistoryHolder, + position: Int, + payloads: List? + ) { + holder.bind(mch) + } + + override fun equals(other: Any?): Boolean { + if (other is HistoryItem) { + return mch.manga.id == other.mch.manga.id + } + return false + } + + override fun hashCode(): Int { + return mch.manga.id!!.hashCode() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryPresenter.kt old mode 100755 new mode 100644 similarity index 62% rename from app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadPresenter.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryPresenter.kt index 8a5e0ed8f..911b6fabd --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryPresenter.kt @@ -1,24 +1,28 @@ -package eu.kanade.tachiyomi.ui.recently_read +package eu.kanade.tachiyomi.ui.recent.history import android.os.Bundle import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.History import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter -import rx.Observable -import rx.android.schedulers.AndroidSchedulers -import uy.kohesive.injekt.injectLazy +import eu.kanade.tachiyomi.ui.recent.DateSectionItem +import eu.kanade.tachiyomi.util.lang.toDateKey import java.util.Calendar import java.util.Comparator import java.util.Date +import java.util.TreeMap +import rx.Observable +import rx.android.schedulers.AndroidSchedulers +import uy.kohesive.injekt.injectLazy /** - * Presenter of RecentlyReadFragment. + * Presenter of HistoryFragment. * Contains information and data for fragment. * Observable updates should be called from here. */ -class RecentlyReadPresenter : BasePresenter() { +class HistoryPresenter : BasePresenter() { /** * Used to connect to database @@ -30,22 +34,31 @@ class RecentlyReadPresenter : BasePresenter() { // Used to get a list of recently read manga getRecentMangaObservable() - .subscribeLatestCache(RecentlyReadController::onNextManga) + .subscribeLatestCache(HistoryController::onNextManga) } /** * Get recent manga observable * @return list of history */ - fun getRecentMangaObservable(): Observable> { - // Set date for recent manga - val cal = Calendar.getInstance() - cal.time = Date() - cal.add(Calendar.MONTH, -1) + fun getRecentMangaObservable(): Observable> { + // Set date limit for recent manga + val cal = Calendar.getInstance().apply { + time = Date() + add(Calendar.MONTH, -3) + } return db.getRecentManga(cal.time).asRxObservable() - .map { recents -> recents.map(::RecentlyReadItem) } - .observeOn(AndroidSchedulers.mainThread()) + .map { recents -> + val map = TreeMap> { d1, d2 -> d2.compareTo(d1) } + val byDay = recents + .groupByTo(map, { it.history.last_read.toDateKey() }) + byDay.flatMap { entry -> + val dateItem = DateSectionItem(entry.key) + entry.value.map { HistoryItem(it, dateItem) } + } + } + .observeOn(AndroidSchedulers.mainThread()) } /** @@ -55,7 +68,7 @@ class RecentlyReadPresenter : BasePresenter() { fun removeFromHistory(history: History) { history.last_read = 0L db.updateHistoryLastRead(history).asRxObservable() - .subscribe() + .subscribe() } /** @@ -64,11 +77,11 @@ class RecentlyReadPresenter : BasePresenter() { */ fun removeAllFromHistory(mangaId: Long) { db.getHistoryByMangaId(mangaId).asRxSingle() - .map { list -> - list.forEach { it.last_read = 0L } - db.updateHistoryLastRead(list).executeAsBlocking() - } - .subscribe() + .map { list -> + list.forEach { it.last_read = 0L } + db.updateHistoryLastRead(list).executeAsBlocking() + } + .subscribe() } /** @@ -89,7 +102,7 @@ class RecentlyReadPresenter : BasePresenter() { } val chapters = db.getChapters(manga).executeAsBlocking() - .sortedWith(Comparator { c1, c2 -> sortFunction(c1, c2) }) + .sortedWith(Comparator { c1, c2 -> sortFunction(c1, c2) }) val currChapterIndex = chapters.indexOfFirst { chapter.id == it.id } return when (manga.sorting) { @@ -98,13 +111,13 @@ class RecentlyReadPresenter : BasePresenter() { val chapterNumber = chapter.chapter_number ((currChapterIndex + 1) until chapters.size) - .map { chapters[it] } - .firstOrNull { it.chapter_number > chapterNumber && - it.chapter_number <= chapterNumber + 1 - } + .map { chapters[it] } + .firstOrNull { + it.chapter_number > chapterNumber && + it.chapter_number <= chapterNumber + 1 + } } else -> throw NotImplementedError("Unknown sorting method") } } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RemoveHistoryDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/RemoveHistoryDialog.kt similarity index 75% rename from app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RemoveHistoryDialog.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/RemoveHistoryDialog.kt index 8385b4ed4..faeb1373c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RemoveHistoryDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/RemoveHistoryDialog.kt @@ -1,8 +1,9 @@ -package eu.kanade.tachiyomi.ui.recently_read +package eu.kanade.tachiyomi.ui.recent.history import android.app.Dialog import android.os.Bundle import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.customview.customView import com.bluelinelabs.conductor.Controller import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.History @@ -11,7 +12,7 @@ import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.widget.DialogCheckboxView class RemoveHistoryDialog(bundle: Bundle? = null) : DialogController(bundle) - where T : Controller, T: RemoveHistoryDialog.Listener { + where T : Controller, T : RemoveHistoryDialog.Listener { private var manga: Manga? = null @@ -32,13 +33,11 @@ class RemoveHistoryDialog(bundle: Bundle? = null) : DialogController(bundle) setOptionDescription(R.string.dialog_with_checkbox_reset) } - return MaterialDialog.Builder(activity) - .title(R.string.action_remove) - .customView(dialogCheckboxView, true) - .positiveText(R.string.action_remove) - .negativeText(android.R.string.cancel) - .onPositive { _, _ -> onPositive(dialogCheckboxView.isChecked()) } - .build() + return MaterialDialog(activity) + .title(R.string.action_remove) + .customView(view = dialogCheckboxView, horizontalPadding = true) + .positiveButton(R.string.action_remove) { onPositive(dialogCheckboxView.isChecked()) } + .negativeButton(android.R.string.cancel) } private fun onPositive(checked: Boolean) { @@ -52,5 +51,4 @@ class RemoveHistoryDialog(bundle: Bundle? = null) : DialogController(bundle) interface Listener { fun removeHistory(manga: Manga, history: History, all: Boolean) } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/ConfirmDeleteChaptersDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/ConfirmDeleteChaptersDialog.kt new file mode 100644 index 000000000..4d4c04806 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/ConfirmDeleteChaptersDialog.kt @@ -0,0 +1,32 @@ +package eu.kanade.tachiyomi.ui.recent.updates + +import android.app.Dialog +import android.os.Bundle +import com.afollestad.materialdialogs.MaterialDialog +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.base.controller.DialogController + +class ConfirmDeleteChaptersDialog(bundle: Bundle? = null) : DialogController(bundle) + where T : Controller, T : ConfirmDeleteChaptersDialog.Listener { + + private var chaptersToDelete = emptyList() + + constructor(target: T, chaptersToDelete: List) : this() { + this.chaptersToDelete = chaptersToDelete + targetController = target + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + return MaterialDialog(activity!!) + .message(R.string.confirm_delete_chapters) + .positiveButton(android.R.string.ok) { + (targetController as? Listener)?.deleteChapters(chaptersToDelete) + } + .negativeButton(android.R.string.cancel) + } + + interface Listener { + fun deleteChapters(chaptersToDelete: List) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesAdapter.kt old mode 100755 new mode 100644 similarity index 64% rename from app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersAdapter.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesAdapter.kt index 233c90fce..0d0c7d9e8 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesAdapter.kt @@ -1,10 +1,10 @@ -package eu.kanade.tachiyomi.ui.recent_updates +package eu.kanade.tachiyomi.ui.recent.updates import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.IFlexible -class RecentChaptersAdapter(val controller: RecentChaptersController) : - FlexibleAdapter>(null, controller, true) { +class UpdatesAdapter(val controller: UpdatesController) : + FlexibleAdapter>(null, controller, true) { val coverClickListener: OnCoverClickListener = controller @@ -16,4 +16,4 @@ class RecentChaptersAdapter(val controller: RecentChaptersController) : interface OnCoverClickListener { fun onCoverClick(position: Int) } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesController.kt old mode 100755 new mode 100644 similarity index 50% rename from app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersController.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesController.kt index 02d456c1d..8c704b396 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesController.kt @@ -1,39 +1,54 @@ -package eu.kanade.tachiyomi.ui.recent_updates +package eu.kanade.tachiyomi.ui.recent.updates -import android.view.* +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode -import com.jakewharton.rxbinding.support.v4.widget.refreshes -import com.jakewharton.rxbinding.support.v7.widget.scrollStateChanges +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.SelectableAdapter import eu.davidea.flexibleadapter.items.IFlexible import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.library.LibraryUpdateService +import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.databinding.UpdatesControllerBinding import eu.kanade.tachiyomi.ui.base.controller.NoToolbarElevationController import eu.kanade.tachiyomi.ui.base.controller.NucleusController -import eu.kanade.tachiyomi.ui.base.controller.popControllerWithTag +import eu.kanade.tachiyomi.ui.base.controller.RootController import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction +import eu.kanade.tachiyomi.ui.main.MainActivity +import eu.kanade.tachiyomi.ui.main.offsetFabAppbarHeight import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.reader.ReaderActivity -import eu.kanade.tachiyomi.util.toast -import kotlinx.android.synthetic.main.recent_chapters_controller.* +import eu.kanade.tachiyomi.util.system.notificationManager +import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.recyclerview.scrollStateChanges +import reactivecircus.flowbinding.swiperefreshlayout.refreshes import timber.log.Timber /** * Fragment that shows recent chapters. - * Uses [R.layout.recent_chapters_controller]. + * Uses [R.layout.updates_controller]. * UI related actions should be called from here. */ -class RecentChaptersController : NucleusController(), - NoToolbarElevationController, - ActionMode.Callback, - FlexibleAdapter.OnItemClickListener, - FlexibleAdapter.OnItemLongClickListener, - FlexibleAdapter.OnUpdateListener, - ConfirmDeleteChaptersDialog.Listener, - RecentChaptersAdapter.OnCoverClickListener { +class UpdatesController : + NucleusController(), + RootController, + NoToolbarElevationController, + ActionMode.Callback, + FlexibleAdapter.OnItemClickListener, + FlexibleAdapter.OnItemLongClickListener, + FlexibleAdapter.OnUpdateListener, + ConfirmDeleteChaptersDialog.Listener, + UpdatesAdapter.OnCoverClickListener { /** * Action mode for multiple selection. @@ -43,19 +58,24 @@ class RecentChaptersController : NucleusController(), /** * Adapter containing the recent chapters. */ - var adapter: RecentChaptersAdapter? = null + var adapter: UpdatesAdapter? = null private set + init { + setHasOptionsMenu(true) + } + override fun getTitle(): String? { return resources?.getString(R.string.label_recent_updates) } - override fun createPresenter(): RecentChaptersPresenter { - return RecentChaptersPresenter() + override fun createPresenter(): UpdatesPresenter { + return UpdatesPresenter() } override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.recent_chapters_controller, container, false) + binding = UpdatesControllerBinding.inflate(inflater) + return binding.root } /** @@ -64,45 +84,71 @@ class RecentChaptersController : NucleusController(), */ override fun onViewCreated(view: View) { super.onViewCreated(view) + view.context.notificationManager.cancel(Notifications.ID_NEW_CHAPTERS) // Init RecyclerView and adapter - val layoutManager = androidx.recyclerview.widget.LinearLayoutManager(view.context) - recycler.layoutManager = layoutManager - recycler.addItemDecoration(androidx.recyclerview.widget.DividerItemDecoration(view.context, androidx.recyclerview.widget.DividerItemDecoration.VERTICAL)) - recycler.setHasFixedSize(true) - adapter = RecentChaptersAdapter(this@RecentChaptersController) - recycler.adapter = adapter + val layoutManager = LinearLayoutManager(view.context) + binding.recycler.layoutManager = layoutManager + binding.recycler.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) + binding.recycler.setHasFixedSize(true) + adapter = UpdatesAdapter(this@UpdatesController) + binding.recycler.adapter = adapter - recycler.scrollStateChanges().subscribeUntilDestroy { - // Disable swipe refresh when view is not at the top - val firstPos = layoutManager.findFirstCompletelyVisibleItemPosition() - swipe_refresh.isEnabled = firstPos <= 0 - } - - swipe_refresh.setDistanceToTriggerSync((2 * 64 * view.resources.displayMetrics.density).toInt()) - swipe_refresh.refreshes().subscribeUntilDestroy { - if (!LibraryUpdateService.isRunning(view.context)) { - LibraryUpdateService.start(view.context) - view.context.toast(R.string.action_update_library) + binding.recycler.scrollStateChanges() + .onEach { + // Disable swipe refresh when view is not at the top + val firstPos = layoutManager.findFirstCompletelyVisibleItemPosition() + binding.swipeRefresh.isEnabled = firstPos <= 0 } - // It can be a very long operation, so we disable swipe refresh and show a toast. - swipe_refresh.isRefreshing = false - } + .launchIn(scope) + + binding.swipeRefresh.setDistanceToTriggerSync((2 * 64 * view.resources.displayMetrics.density).toInt()) + binding.swipeRefresh.refreshes() + .onEach { + updateLibrary() + + // It can be a very long operation, so we disable swipe refresh and show a toast. + binding.swipeRefresh.isRefreshing = false + } + .launchIn(scope) + + binding.actionToolbar.offsetFabAppbarHeight(activity!!) } override fun onDestroyView(view: View) { + destroyActionModeIfNeeded() + binding.actionToolbar.destroy() adapter = null - actionMode = null super.onDestroyView(view) } + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.updates, menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_update_library -> updateLibrary() + } + + return super.onOptionsItemSelected(item) + } + + private fun updateLibrary() { + activity?.let { + if (LibraryUpdateService.start(it)) { + it.toast(R.string.updating_library) + } + } + } + /** * Returns selected chapters * @return list of selected chapters */ - fun getSelectedChapters(): List { + private fun getSelectedChapters(): List { val adapter = adapter ?: return emptyList() - return adapter.selectedPositions.mapNotNull { adapter.getItem(it) as? RecentChapterItem } + return adapter.selectedPositions.mapNotNull { adapter.getItem(it) as? UpdatesItem } } /** @@ -113,13 +159,13 @@ class RecentChaptersController : NucleusController(), val adapter = adapter ?: return false // Get item from position - val item = adapter.getItem(position) as? RecentChapterItem ?: return false - if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) { + val item = adapter.getItem(position) as? UpdatesItem ?: return false + return if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) { toggleSelection(position) - return true + true } else { openChapter(item) - return false + false } } @@ -128,8 +174,14 @@ class RecentChaptersController : NucleusController(), * @param position position of clicked item */ override fun onItemLongClick(position: Int) { - if (actionMode == null) + if (actionMode == null) { actionMode = (activity as AppCompatActivity).startSupportActionMode(this) + binding.actionToolbar.show( + actionMode!!, + R.menu.updates_chapter_selection + ) { onActionItemClicked(actionMode!!, it!!) } + (activity as? MainActivity)?.showBottomNav(visible = false, collapse = true) + } toggleSelection(position) } @@ -148,7 +200,7 @@ class RecentChaptersController : NucleusController(), * Open chapter in reader * @param chapter selected chapter */ - private fun openChapter(item: RecentChapterItem) { + private fun openChapter(item: UpdatesItem) { val activity = activity ?: return val intent = ReaderActivity.newIntent(activity, item.manga, item.chapter) startActivity(intent) @@ -156,10 +208,9 @@ class RecentChaptersController : NucleusController(), /** * Download selected items - * @param chapters list of selected [RecentChapter]s + * @param chapters list of selected [UpdatesItem]s */ - fun downloadChapters(chapters: List) { - destroyActionModeIfNeeded() + private fun downloadChapters(chapters: List) { presenter.downloadChapters(chapters) } @@ -174,9 +225,9 @@ class RecentChaptersController : NucleusController(), override fun onUpdateEmptyView(size: Int) { if (size > 0) { - empty_view?.hide() + binding.emptyView.hide() } else { - empty_view?.show(R.drawable.ic_update_black_128dp, R.string.information_no_recent) + binding.emptyView.show(R.string.information_no_recent) } } @@ -192,66 +243,43 @@ class RecentChaptersController : NucleusController(), * Returns holder belonging to chapter * @param download [Download] object containing download progress. */ - private fun getHolder(download: Download): RecentChapterHolder? { - return recycler?.findViewHolderForItemId(download.chapter.id!!) as? RecentChapterHolder + private fun getHolder(download: Download): UpdatesHolder? { + return binding.recycler.findViewHolderForItemId(download.chapter.id!!) as? UpdatesHolder } /** * Mark chapter as read * @param chapters list of chapters */ - fun markAsRead(chapters: List) { + private fun markAsRead(chapters: List) { presenter.markChapterRead(chapters, true) if (presenter.preferences.removeAfterMarkedAsRead()) { deleteChapters(chapters) } } - override fun deleteChapters(chaptersToDelete: List) { - destroyActionModeIfNeeded() - DeletingChaptersDialog().showDialog(router) - presenter.deleteChapters(chaptersToDelete) - } - - /** - * Destory [ActionMode] if it's shown - */ - fun destroyActionModeIfNeeded() { - actionMode?.finish() - } - /** * Mark chapter as unread - * @param chapters list of selected [RecentChapter] + * @param chapters list of selected [UpdatesItem] */ - fun markAsUnread(chapters: List) { + private fun markAsUnread(chapters: List) { presenter.markChapterRead(chapters, false) } - /** - * Start downloading chapter - * @param chapter selected chapter with manga - */ - fun downloadChapter(chapter: RecentChapterItem) { - presenter.downloadChapters(listOf(chapter)) + override fun deleteChapters(chaptersToDelete: List) { + presenter.deleteChapters(chaptersToDelete) } - /** - * Start deleting chapter - * @param chapter selected chapter with manga - */ - fun deleteChapter(chapter: RecentChapterItem) { - DeletingChaptersDialog().showDialog(router) - presenter.deleteChapters(listOf(chapter)) + private fun destroyActionModeIfNeeded() { + actionMode?.finish() } override fun onCoverClick(position: Int) { - val chapterClicked = adapter?.getItem(position) as? RecentChapterItem ?: return + val chapterClicked = adapter?.getItem(position) as? UpdatesItem ?: return openManga(chapterClicked) - } - fun openManga(chapter: RecentChapterItem) { + private fun openManga(chapter: UpdatesItem) { router.pushController(MangaController(chapter.manga).withFadeTransaction()) } @@ -259,7 +287,6 @@ class RecentChaptersController : NucleusController(), * Called when chapters are deleted */ fun onChaptersDeleted() { - dismissDeletingDialog() adapter?.notifyDataSetChanged() } @@ -268,24 +295,16 @@ class RecentChaptersController : NucleusController(), * @param error error message */ fun onChaptersDeletedError(error: Throwable) { - dismissDeletingDialog() Timber.e(error) } - /** - * Called to dismiss deleting dialog - */ - fun dismissDeletingDialog() { - router.popControllerWithTag(DeletingChaptersDialog.TAG) - } - /** * Called when ActionMode created. * @param mode the ActionMode object * @param menu menu object of ActionMode */ override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.chapter_recent_selection, menu) + mode.menuInflater.inflate(R.menu.generic_selection, menu) adapter?.mode = SelectableAdapter.Mode.MULTI return true } @@ -296,8 +315,15 @@ class RecentChaptersController : NucleusController(), // Destroy action mode if there are no items selected. destroyActionModeIfNeeded() } else { - mode.title = resources?.getString(R.string.label_selected, count) + mode.title = count.toString() + + val chapters = getSelectedChapters() + binding.actionToolbar.findItem(R.id.action_download)?.isVisible = chapters.any { !it.isDownloaded } + binding.actionToolbar.findItem(R.id.action_delete)?.isVisible = chapters.any { it.isDownloaded } + binding.actionToolbar.findItem(R.id.action_mark_as_read)?.isVisible = chapters.any { !it.chapter.read } + binding.actionToolbar.findItem(R.id.action_mark_as_unread)?.isVisible = chapters.all { it.chapter.read } } + return false } @@ -308,11 +334,14 @@ class RecentChaptersController : NucleusController(), */ override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { when (item.itemId) { + R.id.action_select_all -> selectAll() + R.id.action_select_inverse -> selectInverse() + R.id.action_download -> downloadChapters(getSelectedChapters()) + R.id.action_delete -> + ConfirmDeleteChaptersDialog(this, getSelectedChapters()) + .showDialog(router) R.id.action_mark_as_read -> markAsRead(getSelectedChapters()) R.id.action_mark_as_unread -> markAsUnread(getSelectedChapters()) - R.id.action_download -> downloadChapters(getSelectedChapters()) - R.id.action_delete -> ConfirmDeleteChaptersDialog(this, getSelectedChapters()) - .showDialog(router) else -> return false } return true @@ -325,7 +354,25 @@ class RecentChaptersController : NucleusController(), override fun onDestroyActionMode(mode: ActionMode?) { adapter?.mode = SelectableAdapter.Mode.IDLE adapter?.clearSelection() + + binding.actionToolbar.hide() + (activity as? MainActivity)?.showBottomNav(visible = true, collapse = true) + actionMode = null } + private fun selectAll() { + val adapter = adapter ?: return + adapter.selectAll() + actionMode?.invalidate() + } + + private fun selectInverse() { + val adapter = adapter ?: return + for (i in 0..adapter.itemCount) { + adapter.toggleSelection(i) + } + actionMode?.invalidate() + adapter.notifyDataSetChanged() + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesHolder.kt new file mode 100644 index 000000000..08a4015f1 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesHolder.kt @@ -0,0 +1,94 @@ +package eu.kanade.tachiyomi.ui.recent.updates + +import android.view.View +import com.bumptech.glide.load.engine.DiskCacheStrategy +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.download.model.Download +import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.data.glide.toMangaThumbnail +import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder +import eu.kanade.tachiyomi.util.system.getResourceColor +import kotlinx.android.synthetic.main.updates_item.chapter_title +import kotlinx.android.synthetic.main.updates_item.download_text +import kotlinx.android.synthetic.main.updates_item.manga_cover +import kotlinx.android.synthetic.main.updates_item.manga_title + +/** + * Holder that contains chapter item + * Uses R.layout.item_recent_chapters. + * UI related actions should be called from here. + * + * @param view the inflated view for this holder. + * @param adapter the adapter handling this holder. + * @param listener a listener to react to single tap and long tap events. + * @constructor creates a new recent chapter holder. + */ +class UpdatesHolder(private val view: View, private val adapter: UpdatesAdapter) : + BaseFlexibleViewHolder(view, adapter) { + + private var readColor = view.context.getResourceColor(R.attr.colorOnSurface, 0.38f) + private var unreadColor = view.context.getResourceColor(R.attr.colorOnSurface) + + /** + * Currently bound item. + */ + private var item: UpdatesItem? = null + + init { + manga_cover.setOnClickListener { + adapter.coverClickListener.onCoverClick(bindingAdapterPosition) + } + } + + /** + * Set values of view + * + * @param item item containing chapter information + */ + fun bind(item: UpdatesItem) { + this.item = item + + // Set chapter title + chapter_title.text = item.chapter.name + + // Set manga title + manga_title.text = item.manga.title + + // Set cover + GlideApp.with(itemView.context).clear(manga_cover) + if (!item.manga.thumbnail_url.isNullOrEmpty()) { + GlideApp.with(itemView.context) + .load(item.manga.toMangaThumbnail()) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .circleCrop() + .into(manga_cover) + } + + // Check if chapter is read and set correct color + if (item.chapter.read) { + chapter_title.setTextColor(readColor) + manga_title.setTextColor(readColor) + } else { + chapter_title.setTextColor(unreadColor) + manga_title.setTextColor(unreadColor) + } + + // Set chapter status + notifyStatus(item.status) + } + + /** + * Updates chapter status in view. + * + * @param status download status + */ + fun notifyStatus(status: Int) = with(download_text) { + when (status) { + Download.QUEUE -> setText(R.string.chapter_queued) + Download.DOWNLOADING -> setText(R.string.chapter_downloading) + Download.DOWNLOADED -> setText(R.string.chapter_downloaded) + Download.ERROR -> setText(R.string.chapter_error) + else -> text = "" + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChapterItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesItem.kt old mode 100755 new mode 100644 similarity index 53% rename from app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChapterItem.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesItem.kt index b2e835e8f..ecfc486ec --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChapterItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesItem.kt @@ -1,6 +1,7 @@ -package eu.kanade.tachiyomi.ui.recent_updates +package eu.kanade.tachiyomi.ui.recent.updates import android.view.View +import androidx.recyclerview.widget.RecyclerView import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractSectionableItem import eu.davidea.flexibleadapter.items.IFlexible @@ -8,40 +9,45 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.download.model.Download +import eu.kanade.tachiyomi.ui.recent.DateSectionItem -class RecentChapterItem(val chapter: Chapter, val manga: Manga, header: DateItem) : - AbstractSectionableItem(header) { +class UpdatesItem(val chapter: Chapter, val manga: Manga, header: DateSectionItem) : + AbstractSectionableItem(header) { private var _status: Int = 0 var status: Int get() = download?.status ?: _status - set(value) { _status = value } + set(value) { + _status = value + } - @Transient var download: Download? = null + @Transient + var download: Download? = null val isDownloaded: Boolean get() = status == Download.DOWNLOADED override fun getLayoutRes(): Int { - return R.layout.recent_chapters_item + return R.layout.updates_item } - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): RecentChapterHolder { - return RecentChapterHolder(view , adapter as RecentChaptersAdapter) + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): UpdatesHolder { + return UpdatesHolder(view, adapter as UpdatesAdapter) } - override fun bindViewHolder(adapter: FlexibleAdapter>, - holder: RecentChapterHolder, - position: Int, - payloads: List?) { - + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: UpdatesHolder, + position: Int, + payloads: List? + ) { holder.bind(this) } override fun equals(other: Any?): Boolean { if (this === other) return true - if (other is RecentChapterItem) { + if (other is UpdatesItem) { return chapter.id!! == other.chapter.id!! } return false @@ -50,5 +56,4 @@ class RecentChapterItem(val chapter: Chapter, val manga: Manga, header: DateItem override fun hashCode(): Int { return chapter.id!!.hashCode() } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesPresenter.kt old mode 100755 new mode 100644 similarity index 54% rename from app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersPresenter.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesPresenter.kt index bcf418761..86a75cd2d --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesPresenter.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.ui.recent_updates +package eu.kanade.tachiyomi.ui.recent.updates import android.os.Bundle import eu.kanade.tachiyomi.data.database.DatabaseHelper @@ -8,38 +8,41 @@ import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import eu.kanade.tachiyomi.ui.recent.DateSectionItem +import eu.kanade.tachiyomi.util.lang.toDateKey +import java.util.Calendar +import java.util.Date +import java.util.TreeMap import rx.Observable import rx.android.schedulers.AndroidSchedulers import rx.schedulers.Schedulers import timber.log.Timber import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.util.Calendar -import java.util.Date -import java.util.TreeMap -class RecentChaptersPresenter( - val preferences: PreferencesHelper = Injekt.get(), - private val db: DatabaseHelper = Injekt.get(), - private val downloadManager: DownloadManager = Injekt.get(), - private val sourceManager: SourceManager = Injekt.get() -) : BasePresenter() { +class UpdatesPresenter( + val preferences: PreferencesHelper = Injekt.get(), + private val db: DatabaseHelper = Injekt.get(), + private val downloadManager: DownloadManager = Injekt.get(), + private val sourceManager: SourceManager = Injekt.get() +) : BasePresenter() { /** * List containing chapter and manga information */ - private var chapters: List = emptyList() + private var chapters: List = emptyList() override fun onCreate(savedState: Bundle?) { super.onCreate(savedState) - getRecentChaptersObservable() - .observeOn(AndroidSchedulers.mainThread()) - .subscribeLatestCache(RecentChaptersController::onNextRecentChapters) + getUpdatesObservable() + .observeOn(AndroidSchedulers.mainThread()) + .subscribeLatestCache(UpdatesController::onNextRecentChapters) getChapterStatusObservable() - .subscribeLatestCache(RecentChaptersController::onChapterStatusChange, - { _, error -> Timber.e(error) }) + .subscribeLatestCache(UpdatesController::onChapterStatusChange) { _, error -> + Timber.e(error) + } } /** @@ -47,54 +50,40 @@ class RecentChaptersPresenter( * * @return observable containing recent chapters and date */ - fun getRecentChaptersObservable(): Observable> { + private fun getUpdatesObservable(): Observable> { // Set date limit for recent chapters val cal = Calendar.getInstance().apply { time = Date() - add(Calendar.MONTH, -1) + add(Calendar.MONTH, -3) } return db.getRecentChapters(cal.time).asRxObservable() - // Convert to a list of recent chapters. - .map { mangaChapters -> - val map = TreeMap> { d1, d2 -> d2.compareTo(d1) } - val byDay = mangaChapters - .groupByTo(map, { getMapKey(it.chapter.date_fetch) }) - byDay.flatMap { - val dateItem = DateItem(it.key) - it.value.map { RecentChapterItem(it.chapter, it.manga, dateItem) } + // Convert to a list of recent chapters. + .map { mangaChapters -> + val map = TreeMap> { d1, d2 -> d2.compareTo(d1) } + val byDay = mangaChapters + .groupByTo(map, { it.chapter.date_fetch.toDateKey() }) + byDay.flatMap { entry -> + val dateItem = DateSectionItem(entry.key) + entry.value + .sortedWith(compareBy({ it.chapter.date_fetch }, { it.chapter.chapter_number })).asReversed() + .map { UpdatesItem(it.chapter, it.manga, dateItem) } + } + } + .doOnNext { list -> + list.forEach { item -> + // Find an active download for this chapter. + val download = downloadManager.queue.find { it.chapter.id == item.chapter.id } + + // If there's an active download, assign it, otherwise ask the manager if + // the chapter is downloaded and assign it to the status. + if (download != null) { + item.download = download } } - .doOnNext { - it.forEach { item -> - // Find an active download for this chapter. - val download = downloadManager.queue.find { it.chapter.id == item.chapter.id } - - // If there's an active download, assign it, otherwise ask the manager if - // the chapter is downloaded and assign it to the status. - if (download != null) { - item.download = download - } - } - setDownloadedChapters(it) - chapters = it - } - } - - /** - * Get date as time key - * - * @param date desired date - * @return date as time key - */ - private fun getMapKey(date: Long): Date { - val cal = Calendar.getInstance() - cal.time = Date(date) - cal[Calendar.HOUR_OF_DAY] = 0 - cal[Calendar.MINUTE] = 0 - cal[Calendar.SECOND] = 0 - cal[Calendar.MILLISECOND] = 0 - return cal.time + setDownloadedChapters(list) + chapters = list + } } /** @@ -104,8 +93,8 @@ class RecentChaptersPresenter( */ private fun getChapterStatusObservable(): Observable { return downloadManager.queue.getStatusObservable() - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext { download -> onDownloadStatusChange(download) } + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { download -> onDownloadStatusChange(download) } } /** @@ -113,7 +102,7 @@ class RecentChaptersPresenter( * * @param items the list of chapter from the database. */ - private fun setDownloadedChapters(items: List) { + private fun setDownloadedChapters(items: List) { for (item in items) { val manga = item.manga val chapter = item.chapter @@ -145,7 +134,7 @@ class RecentChaptersPresenter( * @param items list of selected chapters * @param read read status */ - fun markChapterRead(items: List, read: Boolean) { + fun markChapterRead(items: List, read: Boolean) { val chapters = items.map { it.chapter } chapters.forEach { it.read = read @@ -155,8 +144,8 @@ class RecentChaptersPresenter( } Observable.fromCallable { db.updateChaptersProgress(chapters).executeAsBlocking() } - .subscribeOn(Schedulers.io()) - .subscribe() + .subscribeOn(Schedulers.io()) + .subscribe() } /** @@ -164,21 +153,24 @@ class RecentChaptersPresenter( * * @param chapters list of chapters */ - fun deleteChapters(chapters: List) { + fun deleteChapters(chapters: List) { Observable.just(chapters) - .doOnNext { deleteChaptersInternal(it) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst({ view, _ -> + .doOnNext { deleteChaptersInternal(it) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst( + { view, _ -> view.onChaptersDeleted() - }, RecentChaptersController::onChaptersDeletedError) + }, + UpdatesController::onChaptersDeletedError + ) } /** * Download selected chapters * @param items list of recent chapters seleted. */ - fun downloadChapters(items: List) { + fun downloadChapters(items: List) { items.forEach { downloadManager.downloadChapters(it.manga, listOf(it.chapter)) } } @@ -187,7 +179,7 @@ class RecentChaptersPresenter( * * @param items chapters selected */ - private fun deleteChaptersInternal(chapterItems: List) { + private fun deleteChaptersInternal(chapterItems: List) { val itemsByManga = chapterItems.groupBy { it.manga.id } for ((_, items) in itemsByManga) { val manga = items.first().manga @@ -201,5 +193,4 @@ class RecentChaptersPresenter( } } } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/ConfirmDeleteChaptersDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/ConfirmDeleteChaptersDialog.kt deleted file mode 100644 index 1e6c058de..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/ConfirmDeleteChaptersDialog.kt +++ /dev/null @@ -1,34 +0,0 @@ -package eu.kanade.tachiyomi.ui.recent_updates - -import android.app.Dialog -import android.os.Bundle -import com.afollestad.materialdialogs.MaterialDialog -import com.bluelinelabs.conductor.Controller -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.base.controller.DialogController - -class ConfirmDeleteChaptersDialog(bundle: Bundle? = null) : DialogController(bundle) - where T : Controller, T : ConfirmDeleteChaptersDialog.Listener { - - private var chaptersToDelete = emptyList() - - constructor(target: T, chaptersToDelete: List) : this() { - this.chaptersToDelete = chaptersToDelete - targetController = target - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - return MaterialDialog.Builder(activity!!) - .content(R.string.confirm_delete_chapters) - .positiveText(android.R.string.yes) - .negativeText(android.R.string.no) - .onPositive { _, _ -> - (targetController as? Listener)?.deleteChapters(chaptersToDelete) - } - .build() - } - - interface Listener { - fun deleteChapters(chaptersToDelete: List) - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/DeletingChaptersDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/DeletingChaptersDialog.kt deleted file mode 100755 index 8bb9d57b9..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/DeletingChaptersDialog.kt +++ /dev/null @@ -1,27 +0,0 @@ -package eu.kanade.tachiyomi.ui.recent_updates - -import android.app.Dialog -import android.os.Bundle -import com.afollestad.materialdialogs.MaterialDialog -import com.bluelinelabs.conductor.Router -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.base.controller.DialogController - -class DeletingChaptersDialog(bundle: Bundle? = null) : DialogController(bundle) { - - companion object { - const val TAG = "deleting_dialog" - } - - override fun onCreateDialog(savedState: Bundle?): Dialog { - return MaterialDialog.Builder(activity!!) - .progress(true, 0) - .content(R.string.deleting) - .build() - } - - override fun showDialog(router: Router) { - showDialog(router, TAG) - } - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChapterHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChapterHolder.kt deleted file mode 100755 index 4a5f139d9..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChapterHolder.kt +++ /dev/null @@ -1,152 +0,0 @@ -package eu.kanade.tachiyomi.ui.recent_updates - -import android.view.View -import android.widget.PopupMenu -import com.bumptech.glide.load.engine.DiskCacheStrategy -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.download.model.Download -import eu.kanade.tachiyomi.data.glide.GlideApp -import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder -import eu.kanade.tachiyomi.util.getResourceColor -import eu.kanade.tachiyomi.util.setVectorCompat -import kotlinx.android.synthetic.main.recent_chapters_item.* - -/** - * Holder that contains chapter item - * Uses R.layout.item_recent_chapters. - * UI related actions should be called from here. - * - * @param view the inflated view for this holder. - * @param adapter the adapter handling this holder. - * @param listener a listener to react to single tap and long tap events. - * @constructor creates a new recent chapter holder. - */ -class RecentChapterHolder(private val view: View, private val adapter: RecentChaptersAdapter) : - BaseFlexibleViewHolder(view, adapter) { - - /** - * Color of read chapter - */ - private var readColor = view.context.getResourceColor(android.R.attr.textColorHint) - - /** - * Color of unread chapter - */ - private var unreadColor = view.context.getResourceColor(android.R.attr.textColorPrimary) - - /** - * Currently bound item. - */ - private var item: RecentChapterItem? = null - - init { - // We need to post a Runnable to show the popup to make sure that the PopupMenu is - // correctly positioned. The reason being that the view may change position before the - // PopupMenu is shown. - chapter_menu.setOnClickListener { it.post { showPopupMenu(it) } } - manga_cover.setOnClickListener { - adapter.coverClickListener.onCoverClick(adapterPosition) - } - } - - /** - * Set values of view - * - * @param item item containing chapter information - */ - fun bind(item: RecentChapterItem) { - this.item = item - - // Set chapter title - chapter_title.text = item.chapter.name - - // Set manga title - manga_title.text = item.manga.title - - // Set the correct drawable for dropdown and update the tint to match theme. - chapter_menu_icon.setVectorCompat(R.drawable.ic_more_horiz_black_24dp, view.context.getResourceColor(R.attr.icon_color)) - - // Set cover - GlideApp.with(itemView.context).clear(manga_cover) - if (!item.manga.thumbnail_url.isNullOrEmpty()) { - GlideApp.with(itemView.context) - .load(item.manga) - .diskCacheStrategy(DiskCacheStrategy.RESOURCE) - .circleCrop() - .into(manga_cover) - } - - // Check if chapter is read and set correct color - if (item.chapter.read) { - chapter_title.setTextColor(readColor) - manga_title.setTextColor(readColor) - } else { - chapter_title.setTextColor(unreadColor) - manga_title.setTextColor(unreadColor) - } - - // Set chapter status - notifyStatus(item.status) - } - - /** - * Updates chapter status in view. - * - * @param status download status - */ - fun notifyStatus(status: Int) = with(download_text) { - when (status) { - Download.QUEUE -> setText(R.string.chapter_queued) - Download.DOWNLOADING -> setText(R.string.chapter_downloading) - Download.DOWNLOADED -> setText(R.string.chapter_downloaded) - Download.ERROR -> setText(R.string.chapter_error) - else -> text = "" - } - } - - /** - * Show pop up menu - * - * @param view view containing popup menu. - */ - private fun showPopupMenu(view: View) = item?.let { item -> - // Create a PopupMenu, giving it the clicked view for an anchor - val popup = PopupMenu(view.context, view) - - // Inflate our menu resource into the PopupMenu's Menu - popup.menuInflater.inflate(R.menu.chapter_recent, popup.menu) - - // Hide download and show delete if the chapter is downloaded and - if (item.isDownloaded) { - popup.menu.findItem(R.id.action_download).isVisible = false - popup.menu.findItem(R.id.action_delete).isVisible = true - } - - // Hide mark as unread when the chapter is unread - if (!item.chapter.read /*&& mangaChapter.chapter.last_page_read == 0*/) { - popup.menu.findItem(R.id.action_mark_as_unread).isVisible = false - } - - // Hide mark as read when the chapter is read - if (item.chapter.read) { - popup.menu.findItem(R.id.action_mark_as_read).isVisible = false - } - - // Set a listener so we are notified if a menu item is clicked - popup.setOnMenuItemClickListener { menuItem -> - with(adapter.controller) { - when (menuItem.itemId) { - R.id.action_download -> downloadChapter(item) - R.id.action_delete -> deleteChapter(item) - R.id.action_mark_as_read -> markAsRead(listOf(item)) - R.id.action_mark_as_unread -> markAsUnread(listOf(item)) - } - } - - true - } - - // Finally show the PopupMenu - popup.show() - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadItem.kt deleted file mode 100644 index e9346bf6e..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadItem.kt +++ /dev/null @@ -1,38 +0,0 @@ -package eu.kanade.tachiyomi.ui.recently_read - -import android.view.View -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.AbstractFlexibleItem -import eu.davidea.flexibleadapter.items.IFlexible -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory - -class RecentlyReadItem(val mch: MangaChapterHistory) : AbstractFlexibleItem() { - - override fun getLayoutRes(): Int { - return R.layout.recently_read_item - } - - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): RecentlyReadHolder { - return RecentlyReadHolder(view, adapter as RecentlyReadAdapter) - } - - override fun bindViewHolder(adapter: FlexibleAdapter>, - holder: RecentlyReadHolder, - position: Int, - payloads: List?) { - - holder.bind(mch) - } - - override fun equals(other: Any?): Boolean { - if (other is RecentlyReadItem) { - return mch.manga.id == other.mch.manga.id - } - return false - } - - override fun hashCode(): Int { - return mch.manga.id!!.hashCode() - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/security/BiometricUnlockActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/security/BiometricUnlockActivity.kt new file mode 100644 index 000000000..9ab2cb267 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/security/BiometricUnlockActivity.kt @@ -0,0 +1,48 @@ +package eu.kanade.tachiyomi.ui.security + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.biometric.BiometricPrompt +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import java.util.Date +import java.util.concurrent.Executors +import uy.kohesive.injekt.injectLazy + +/** + * Blank activity with a BiometricPrompt. + */ +class BiometricUnlockActivity : AppCompatActivity() { + + private val preferences: PreferencesHelper by injectLazy() + private val executor = Executors.newSingleThreadExecutor() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val biometricPrompt = BiometricPrompt( + this, executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + finishAffinity() + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + SecureActivityDelegate.locked = false + preferences.lastAppUnlock().set(Date().time) + finish() + } + } + ) + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(getString(R.string.unlock_app)) + .setDeviceCredentialAllowed(true) + .setConfirmationRequired(false) + .build() + + biometricPrompt.authenticate(promptInfo) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/security/SecureActivityDelegate.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/security/SecureActivityDelegate.kt new file mode 100644 index 000000000..a34a13819 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/security/SecureActivityDelegate.kt @@ -0,0 +1,54 @@ +package eu.kanade.tachiyomi.ui.security + +import android.content.Intent +import android.view.WindowManager +import androidx.biometric.BiometricManager +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import java.util.Date +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import uy.kohesive.injekt.injectLazy + +class SecureActivityDelegate(private val activity: FragmentActivity) { + + private val preferences by injectLazy() + + fun onCreate() { + preferences.secureScreen().asFlow() + .onEach { + if (it) { + activity.window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) + } else { + activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + .launchIn(activity.lifecycleScope) + } + + fun onResume() { + val lockApp = preferences.useBiometricLock().get() + if (lockApp && BiometricManager.from(activity).canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS) { + if (isAppLocked()) { + val intent = Intent(activity, BiometricUnlockActivity::class.java) + activity.startActivity(intent) + activity.overridePendingTransition(0, 0) + } + } else if (lockApp) { + preferences.useBiometricLock().set(false) + } + } + + private fun isAppLocked(): Boolean { + return locked && + ( + preferences.lockAppAfter().get() <= 0 || + Date().time >= preferences.lastAppUnlock().get() + 60 * 1000 * preferences.lockAppAfter().get() + ) + } + + companion object { + var locked: Boolean = true + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAboutController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAboutController.kt deleted file mode 100644 index 76d60f1be..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAboutController.kt +++ /dev/null @@ -1,172 +0,0 @@ -package eu.kanade.tachiyomi.ui.setting - -import android.app.Dialog -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.view.View -import androidx.preference.PreferenceScreen -import com.afollestad.materialdialogs.MaterialDialog -import eu.kanade.tachiyomi.BuildConfig -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.updater.UpdateChecker -import eu.kanade.tachiyomi.data.updater.UpdateResult -import eu.kanade.tachiyomi.data.updater.UpdaterJob -import eu.kanade.tachiyomi.data.updater.UpdaterService -import eu.kanade.tachiyomi.ui.base.controller.DialogController -import eu.kanade.tachiyomi.util.toast -import rx.Subscription -import rx.android.schedulers.AndroidSchedulers -import rx.schedulers.Schedulers -import timber.log.Timber -import java.text.DateFormat -import java.text.ParseException -import java.text.SimpleDateFormat -import java.util.* -import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys - -class SettingsAboutController : SettingsController() { - - /** - * Checks for new releases - */ - private val updateChecker by lazy { UpdateChecker.getUpdateChecker() } - - /** - * The subscribtion service of the obtained release object - */ - private var releaseSubscription: Subscription? = null - - private val isUpdaterEnabled = BuildConfig.INCLUDE_UPDATER - - override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { - titleRes = R.string.pref_category_about - - switchPreference { - key = Keys.automaticUpdates - titleRes = R.string.pref_enable_automatic_updates - summaryRes = R.string.pref_enable_automatic_updates_summary - defaultValue = false - - if (isUpdaterEnabled) { - onChange { newValue -> - val checked = newValue as Boolean - if (checked) { - UpdaterJob.setupTask() - } else { - UpdaterJob.cancelTask() - } - true - } - } else { - isVisible = false - } - } - preference { - title = "Github" - val url = "https://github.com/az4521/TachiyomiAZ" - summary = url - onClick { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) - startActivity(intent) - } - } - preference { - titleRes = R.string.version - summary = if (BuildConfig.DEBUG) - "r" + BuildConfig.COMMIT_COUNT - else - BuildConfig.VERSION_NAME - - if (isUpdaterEnabled) { - onClick { checkVersion() } - } - } - preference { - titleRes = R.string.build_time - summary = getFormattedBuildTime() - } - } - - override fun onDestroyView(view: View) { - super.onDestroyView(view) - releaseSubscription?.unsubscribe() - releaseSubscription = null - } - - /** - * Checks version and shows a user prompt if an update is available. - */ - private fun checkVersion() { - if (activity == null) return - - activity?.toast(R.string.update_check_look_for_updates) - releaseSubscription?.unsubscribe() - releaseSubscription = updateChecker.checkForUpdate() - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ result -> - when (result) { - is UpdateResult.NewUpdate<*> -> { - val body = result.release.info - val url = result.release.downloadLink - - // Create confirmation window - NewUpdateDialogController(body, url).showDialog(router) - } - is UpdateResult.NoNewUpdate -> { - activity?.toast(R.string.update_check_no_new_updates) - } - } - }, { error -> - activity?.toast(error.message) - Timber.e(error) - }) - } - - class NewUpdateDialogController(bundle: Bundle? = null) : DialogController(bundle) { - - constructor(body: String, url: String) : this(Bundle().apply { - putString(BODY_KEY, body) - putString(URL_KEY, url) - }) - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - return MaterialDialog.Builder(activity!!) - .title(R.string.update_check_title) - .content(args.getString(BODY_KEY) ?: "") - .positiveText(R.string.update_check_confirm) - .negativeText(R.string.update_check_ignore) - .onPositive { _, _ -> - val appContext = applicationContext - if (appContext != null) { - // Start download - val url = args.getString(URL_KEY) ?: "" - UpdaterService.downloadUpdate(appContext, url) - } - } - .build() - } - - private companion object { - const val BODY_KEY = "NewUpdateDialogController.body" - const val URL_KEY = "NewUpdateDialogController.key" - } - } - - private fun getFormattedBuildTime(): String { - try { - val inputDf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'", Locale.US) - inputDf.timeZone = TimeZone.getTimeZone("UTC") - val date = inputDf.parse(BuildConfig.BUILD_TIME) - - val outputDf = DateFormat.getDateTimeInstance( - DateFormat.MEDIUM, DateFormat.SHORT, Locale.getDefault()) - outputDf.timeZone = TimeZone.getDefault() - - return outputDf.format(date) - } catch (e: ParseException) { - return BuildConfig.BUILD_TIME - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt index 8bb17a265..28e83b030 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt @@ -1,7 +1,14 @@ package eu.kanade.tachiyomi.ui.setting +import android.annotation.SuppressLint import android.app.Dialog +import android.content.Context.POWER_SERVICE +import android.content.Intent +import android.net.Uri +import android.os.Build import android.os.Bundle +import android.os.PowerManager +import android.provider.Settings import android.text.Html import android.view.View import androidx.preference.PreferenceScreen @@ -13,16 +20,17 @@ import eu.kanade.tachiyomi.data.cache.ChapterCache import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Target -import eu.kanade.tachiyomi.data.preference.PreferenceKeys import eu.kanade.tachiyomi.network.NetworkHelper -import eu.kanade.tachiyomi.source.SourceManager.Companion.DELEGATED_SOURCES import eu.kanade.tachiyomi.ui.base.controller.DialogController -import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction import eu.kanade.tachiyomi.ui.library.LibraryController -import eu.kanade.tachiyomi.util.toast -import exh.debug.SettingsDebugController +import eu.kanade.tachiyomi.util.preference.defaultValue +import eu.kanade.tachiyomi.util.preference.onClick +import eu.kanade.tachiyomi.util.preference.preference +import eu.kanade.tachiyomi.util.preference.summaryRes +import eu.kanade.tachiyomi.util.preference.switchPreference +import eu.kanade.tachiyomi.util.preference.titleRes +import eu.kanade.tachiyomi.util.system.toast import exh.log.EHLogLevel -import exh.ui.migration.MetadataFetchDialog import rx.Observable import rx.android.schedulers.AndroidSchedulers import rx.schedulers.Schedulers @@ -36,6 +44,7 @@ class SettingsAdvancedController : SettingsController() { private val db: DatabaseHelper by injectLazy() + @SuppressLint("BatteryLife") override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { titleRes = R.string.pref_category_advanced @@ -76,6 +85,26 @@ class SettingsAdvancedController : SettingsController() { onClick { LibraryUpdateService.start(context, target = Target.TRACKING) } } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + preference { + titleRes = R.string.pref_disable_battery_optimization + summaryRes = R.string.pref_disable_battery_optimization_summary + + onClick { + val packageName: String = context.packageName + val pm = context.getSystemService(POWER_SERVICE) as PowerManager? + if (!pm!!.isIgnoringBatteryOptimizations(packageName)) { + val intent = Intent().apply { + action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + data = Uri.parse("package:$packageName") + } + startActivity(intent) + } else { + context.toast(R.string.battery_optimization_disabled) + } + } + } + } // --> EXH preferenceCategory { @@ -158,81 +187,40 @@ class SettingsAdvancedController : SettingsController() { var deletedFiles = 0 - val ctrl = DeletingFilesDialogController() - ctrl.total = files.size - ctrl.showDialog(router) - Observable.defer { Observable.from(files) } - .doOnNext { file -> - if (chapterCache.removeFileFromCache(file.name)) { - deletedFiles++ - } + .doOnNext { file -> + if (chapterCache.removeFileFromCache(file.name)) { + deletedFiles++ } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ - ctrl.setProgress(deletedFiles) - }, { - activity?.toast(R.string.cache_delete_error) - }, { - ctrl.finish() - activity?.toast(resources?.getString(R.string.cache_deleted, deletedFiles)) - findPreference(CLEAR_CACHE_KEY)?.summary = - resources?.getString(R.string.used_cache, chapterCache.readableSize) - }) - } - - class DeletingFilesDialogController : DialogController() { - - var total = 0 - - private var materialDialog: MaterialDialog? = null - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - return MaterialDialog.Builder(activity!!) - .title(R.string.deleting) - .progress(false, total, true) - .cancelable(false) - .build() - .also { materialDialog = it } - } - - override fun onDestroyView(view: View) { - super.onDestroyView(view) - materialDialog = null - } - - override fun onRestoreInstanceState(savedInstanceState: Bundle) { - super.onRestoreInstanceState(savedInstanceState) - finish() - } - - fun setProgress(deletedFiles: Int) { - materialDialog?.setProgress(deletedFiles) - } - - fun finish() { - router.popController(this) - } + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnError { + activity?.toast(R.string.cache_delete_error) + } + .doOnCompleted { + activity?.toast(resources?.getString(R.string.cache_deleted, deletedFiles)) + findPreference(CLEAR_CACHE_KEY)?.summary = + resources?.getString(R.string.used_cache, chapterCache.readableSize) + } + .subscribe() } class ClearDatabaseDialogController : DialogController() { override fun onCreateDialog(savedViewState: Bundle?): Dialog { - return MaterialDialog.Builder(activity!!) - .content(R.string.clear_database_confirmation) - .positiveText(android.R.string.yes) - .negativeText(android.R.string.no) - .onPositive { _, _ -> - (targetController as? SettingsAdvancedController)?.clearDatabase() - } - .build() + return MaterialDialog(activity!!) + .message(R.string.clear_database_confirmation) + .positiveButton(android.R.string.ok) { + (targetController as? SettingsAdvancedController)?.clearDatabase() + } + .negativeButton(android.R.string.cancel) } } private fun clearDatabase() { // Avoid weird behavior by going back to the library. val newBackstack = listOf(RouterTransaction.with(LibraryController())) + - router.backstack.drop(1) + router.backstack.drop(1) router.setBackstack(newBackstack, FadeChangeHandler()) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt index 843e1526f..87c5f9d66 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt @@ -3,28 +3,37 @@ package eu.kanade.tachiyomi.ui.setting import android.Manifest.permission.WRITE_EXTERNAL_STORAGE import android.app.Activity import android.app.Dialog -import android.content.* +import android.content.ActivityNotFoundException +import android.content.Intent import android.net.Uri -import android.os.Build import android.os.Bundle import android.view.View import androidx.preference.PreferenceScreen import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.list.listItemsMultiChoice import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.backup.BackupConst import eu.kanade.tachiyomi.data.backup.BackupCreateService import eu.kanade.tachiyomi.data.backup.BackupCreatorJob import eu.kanade.tachiyomi.data.backup.BackupRestoreService import eu.kanade.tachiyomi.data.backup.models.Backup -import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.ui.base.controller.DialogController -import eu.kanade.tachiyomi.ui.base.controller.popControllerWithTag -import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe -import eu.kanade.tachiyomi.util.* -import java.io.File -import java.util.concurrent.TimeUnit import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys +import eu.kanade.tachiyomi.data.preference.asImmediateFlow +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe +import eu.kanade.tachiyomi.util.preference.defaultValue +import eu.kanade.tachiyomi.util.preference.entriesRes +import eu.kanade.tachiyomi.util.preference.intListPreference +import eu.kanade.tachiyomi.util.preference.onChange +import eu.kanade.tachiyomi.util.preference.onClick +import eu.kanade.tachiyomi.util.preference.preference +import eu.kanade.tachiyomi.util.preference.preferenceCategory +import eu.kanade.tachiyomi.util.preference.summaryRes +import eu.kanade.tachiyomi.util.preference.titleRes +import eu.kanade.tachiyomi.util.system.getFilePicker +import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach class SettingsBackupController : SettingsController() { @@ -33,22 +42,11 @@ class SettingsBackupController : SettingsController() { */ private var backupFlags = 0 - private val receiver = BackupBroadcastReceiver() - - init { - preferences.context.registerLocalReceiver(receiver, IntentFilter(BackupConst.INTENT_FILTER)) - } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) requestPermissionsSafe(arrayOf(WRITE_EXTERNAL_STORAGE), 500) } - override fun onDestroy() { - super.onDestroy() - preferences.context.unregisterLocalReceiver(receiver) - } - override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { titleRes = R.string.backup @@ -57,9 +55,13 @@ class SettingsBackupController : SettingsController() { summaryRes = R.string.pref_create_backup_summ onClick { - val ctrl = CreateBackupDialog() - ctrl.targetController = this@SettingsBackupController - ctrl.showDialog(router) + if (!BackupCreateService.isRunning(context)) { + val ctrl = CreateBackupDialog() + ctrl.targetController = this@SettingsBackupController + ctrl.showDialog(router) + } else { + context.toast(R.string.backup_in_progress) + } } } preference { @@ -67,12 +69,16 @@ class SettingsBackupController : SettingsController() { summaryRes = R.string.pref_restore_backup_summ onClick { - val intent = Intent(Intent.ACTION_GET_CONTENT) - intent.addCategory(Intent.CATEGORY_OPENABLE) - intent.type = "application/*" - val title = resources?.getString(R.string.file_select_backup) - val chooser = Intent.createChooser(intent, title) - startActivityForResult(chooser, CODE_BACKUP_RESTORE) + if (!BackupRestoreService.isRunning(context)) { + val intent = Intent(Intent.ACTION_GET_CONTENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = "application/*" + val title = resources?.getString(R.string.file_select_backup) + val chooser = Intent.createChooser(intent, title) + startActivityForResult(chooser, CODE_BACKUP_RESTORE) + } else { + context.toast(R.string.restore_in_progress) + } } } preferenceCategory { @@ -81,70 +87,58 @@ class SettingsBackupController : SettingsController() { intListPreference { key = Keys.backupInterval titleRes = R.string.pref_backup_interval - entriesRes = arrayOf(R.string.update_never, R.string.update_6hour, - R.string.update_12hour, R.string.update_24hour, - R.string.update_48hour, R.string.update_weekly) + entriesRes = arrayOf( + R.string.update_never, R.string.update_6hour, + R.string.update_12hour, R.string.update_24hour, + R.string.update_48hour, R.string.update_weekly + ) entryValues = arrayOf("0", "6", "12", "24", "48", "168") defaultValue = "0" summary = "%s" onChange { newValue -> - // Always cancel the previous task, it seems that sometimes they are not updated - BackupCreatorJob.cancelTask() - val interval = (newValue as String).toInt() - if (interval > 0) { - BackupCreatorJob.setupTask(interval) - } + BackupCreatorJob.setupTask(context, interval) true } } - val backupDir = preference { + preference { key = Keys.backupDirectory titleRes = R.string.pref_backup_directory onClick { - val currentDir = preferences.backupsDirectory().getOrDefault() - try{ - val intent = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - // Custom dir selected, open directory selector - preferences.context.getFilePicker(currentDir) - } else { - Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) - } - + val currentDir = preferences.backupsDirectory().get() + try { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) startActivityForResult(intent, CODE_BACKUP_DIR) - } catch (e: ActivityNotFoundException){ - //Fall back to custom picker on error - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP){ - startActivityForResult(preferences.context.getFilePicker(currentDir), CODE_BACKUP_DIR) - } + } catch (e: ActivityNotFoundException) { + // Fall back to custom picker on error + startActivityForResult(preferences.context.getFilePicker(currentDir), CODE_BACKUP_DIR) } - } - preferences.backupsDirectory().asObservable() - .subscribeUntilDestroy { path -> - val dir = UniFile.fromUri(context, Uri.parse(path)) - summary = dir.filePath + "/automatic" - } + preferences.backupInterval().asImmediateFlow { isVisible = it > 0 } + .launchIn(scope) + + preferences.backupsDirectory().asFlow() + .onEach { path -> + val dir = UniFile.fromUri(context, Uri.parse(path)) + summary = dir.filePath + "/automatic" + } + .launchIn(scope) } - val backupNumber = intListPreference { + intListPreference { key = Keys.numberOfBackups titleRes = R.string.pref_backup_slots entries = arrayOf("1", "2", "3", "4", "5") entryValues = entries defaultValue = "1" summary = "%s" + + preferences.backupInterval().asImmediateFlow { isVisible = it > 0 } + .launchIn(scope) } - - preferences.backupInterval().asObservable() - .subscribeUntilDestroy { - backupDir.isVisible = it > 0 - backupNumber.isVisible = it > 0 - } } - } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { @@ -154,41 +148,39 @@ class SettingsBackupController : SettingsController() { // Get uri of backup folder. val uri = data.data - // Get UriPermission so it's possible to write files post kitkat. - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) { - val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION + // Get UriPermission so it's possible to write files + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + if (uri != null) { activity.contentResolver.takePersistableUriPermission(uri, flags) } - // Set backup Uri. + // Set backup Uri preferences.backupsDirectory().set(uri.toString()) } CODE_BACKUP_CREATE -> if (data != null && resultCode == Activity.RESULT_OK) { val activity = activity ?: return - val uri = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - val dir = data.data.path - val file = File(dir, Backup.getDefaultFilename()) - Uri.fromFile(file) - } else { - val uri = data.data - val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION + val uri = data.data + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + if (uri != null) { activity.contentResolver.takePersistableUriPermission(uri, flags) - val file = UniFile.fromUri(activity, uri) - - file.uri } - CreatingBackupDialog().showDialog(router, TAG_CREATING_BACKUP_DIALOG) - BackupCreateService.makeBackup(activity, uri, backupFlags) + val file = UniFile.fromUri(activity, uri) + + activity.toast(R.string.creating_backup) + + BackupCreateService.start(activity, file.uri, backupFlags) } CODE_BACKUP_RESTORE -> if (data != null && resultCode == Activity.RESULT_OK) { val uri = data.data - RestoreBackupDialog(uri).showDialog(router) + if (uri != null) { + RestoreBackupDialog(uri).showDialog(router) + } } } } @@ -196,125 +188,74 @@ class SettingsBackupController : SettingsController() { fun createBackup(flags: Int) { backupFlags = flags - // Setup custom file picker intent // Get dirs - val currentDir = preferences.backupsDirectory().getOrDefault() + val currentDir = preferences.backupsDirectory().get() try { - // If API is lower than Lollipop use custom picker - val intent = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - preferences.context.getFilePicker(currentDir) - } else { - // Use Androids build in file creator - Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType("application/*") - .putExtra(Intent.EXTRA_TITLE, Backup.getDefaultFilename()) - } + // Use Android's built-in file creator + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType("application/*") + .putExtra(Intent.EXTRA_TITLE, Backup.getDefaultFilename()) startActivityForResult(intent, CODE_BACKUP_CREATE) } catch (e: ActivityNotFoundException) { // Handle errors where the android ROM doesn't support the built in picker - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP){ - startActivityForResult(preferences.context.getFilePicker(currentDir), CODE_BACKUP_CREATE) - } + startActivityForResult(preferences.context.getFilePicker(currentDir), CODE_BACKUP_CREATE) } - } class CreateBackupDialog : DialogController() { override fun onCreateDialog(savedViewState: Bundle?): Dialog { val activity = activity!! - val options = arrayOf(R.string.manga, R.string.categories, R.string.chapters, - R.string.track, R.string.history) - .map { activity.getString(it) } + val options = arrayOf( + R.string.manga, R.string.categories, R.string.chapters, + R.string.track, R.string.history + ) + .map { activity.getString(it) } - return MaterialDialog.Builder(activity) - .title(R.string.pref_create_backup) - .content(R.string.backup_choice) - .items(options) - .itemsDisabledIndices(0) - .itemsCallbackMultiChoice(arrayOf(0, 1, 2, 3, 4), { _, positions, _ -> - var flags = 0 - for (i in 1 until positions.size) { - when (positions[i]) { - 1 -> flags = flags or BackupCreateService.BACKUP_CATEGORY - 2 -> flags = flags or BackupCreateService.BACKUP_CHAPTER - 3 -> flags = flags or BackupCreateService.BACKUP_TRACK - 4 -> flags = flags or BackupCreateService.BACKUP_HISTORY - } + return MaterialDialog(activity) + .title(R.string.pref_create_backup) + .message(R.string.backup_choice) + .listItemsMultiChoice( + items = options, + disabledIndices = intArrayOf(0), + initialSelection = intArrayOf(0, 1, 2, 3, 4) + ) { _, positions, _ -> + var flags = 0 + for (i in 1 until positions.size) { + when (positions[i]) { + 1 -> flags = flags or BackupCreateService.BACKUP_CATEGORY + 2 -> flags = flags or BackupCreateService.BACKUP_CHAPTER + 3 -> flags = flags or BackupCreateService.BACKUP_TRACK + 4 -> flags = flags or BackupCreateService.BACKUP_HISTORY } - - (targetController as? SettingsBackupController)?.createBackup(flags) - true - }) - .positiveText(R.string.action_create) - .negativeText(android.R.string.cancel) - .build() - } - } - - class CreatingBackupDialog : DialogController() { - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - return MaterialDialog.Builder(activity!!) - .title(R.string.backup) - .content(R.string.creating_backup) - .progress(true, 0) - .cancelable(false) - .build() - } - - override fun onRestoreInstanceState(savedInstanceState: Bundle) { - super.onRestoreInstanceState(savedInstanceState) - router.popController(this) - } - } - - class CreatedBackupDialog(bundle: Bundle? = null) : DialogController(bundle) { - constructor(uri: Uri) : this(Bundle().apply { - putParcelable(KEY_URI, uri) - }) - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val activity = activity!! - val unifile = UniFile.fromUri(activity, args.getParcelable(KEY_URI)) - return MaterialDialog.Builder(activity) - .title(R.string.backup_created) - .content(activity.getString(R.string.file_saved, unifile.filePath)) - .positiveText(R.string.action_close) - .negativeText(R.string.action_export) - .onNegative { _, _ -> - val sendIntent = Intent(Intent.ACTION_SEND) - sendIntent.type = "application/json" - sendIntent.putExtra(Intent.EXTRA_STREAM, unifile.uri) - startActivity(Intent.createChooser(sendIntent, "")) } - .build() - } - private companion object { - const val KEY_URI = "BackupCreatedDialog.uri" + (targetController as? SettingsBackupController)?.createBackup(flags) + } + .positiveButton(R.string.action_create) + .negativeButton(android.R.string.cancel) } } class RestoreBackupDialog(bundle: Bundle? = null) : DialogController(bundle) { - constructor(uri: Uri) : this(Bundle().apply { - putParcelable(KEY_URI, uri) - }) + constructor(uri: Uri) : this( + Bundle().apply { + putParcelable(KEY_URI, uri) + } + ) override fun onCreateDialog(savedViewState: Bundle?): Dialog { - return MaterialDialog.Builder(activity!!) - .title(R.string.pref_restore_backup) - .content(activity!!.getString(R.string.backup_restore_content) + "\n\nThe app will throttle when restoring EHentai/ExHentai backups. This may cause the app to appear frozen when it is actually still working. Report an issue if the app remains frozen for more than 30 minutes however.") - .positiveText(R.string.action_restore) - .onPositive { _, _ -> - val context = applicationContext - if (context != null) { - RestoringBackupDialog().showDialog(router, TAG_RESTORING_BACKUP_DIALOG) - BackupRestoreService.start(context, args.getParcelable(KEY_URI)) - } + return MaterialDialog(activity!!) + .title(R.string.pref_restore_backup) + .message(R.string.backup_restore_content) + .positiveButton(R.string.action_restore) { + val context = applicationContext + if (context != null) { + BackupRestoreService.start(context, args.getParcelable(KEY_URI)!!) } - .build() + } } private companion object { @@ -322,137 +263,9 @@ class SettingsBackupController : SettingsController() { } } - class RestoringBackupDialog : DialogController() { - private var materialDialog: MaterialDialog? = null - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - return MaterialDialog.Builder(activity!!) - .title(R.string.backup) - .content(R.string.restoring_backup) - .progress(false, 100, true) - .cancelable(false) - .negativeText(R.string.action_stop) - .onNegative { _, _ -> - applicationContext?.let { BackupRestoreService.stop(it) } - } - .build() - .also { materialDialog = it } - } - - override fun onDestroyView(view: View) { - super.onDestroyView(view) - materialDialog = null - } - - override fun onRestoreInstanceState(savedInstanceState: Bundle) { - super.onRestoreInstanceState(savedInstanceState) - router.popController(this) - } - - fun updateProgress(content: String?, progress: Int, amount: Int) { - val dialog = materialDialog ?: return - dialog.setContent(content) - dialog.setProgress(progress) - dialog.maxProgress = amount - } - } - - class RestoredBackupDialog(bundle: Bundle? = null) : DialogController(bundle) { - constructor(time: Long, errorCount: Int, path: String, file: String) : this(Bundle().apply { - putLong(KEY_TIME, time) - putInt(KEY_ERROR_COUNT, errorCount) - putString(KEY_PATH, path) - putString(KEY_FILE, file) - }) - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val activity = activity!! - val time = args.getLong(KEY_TIME) - val errors = args.getInt(KEY_ERROR_COUNT) - val path = args.getString(KEY_PATH) - val file = args.getString(KEY_FILE) - val timeString = String.format("%02d min, %02d sec", - TimeUnit.MILLISECONDS.toMinutes(time), - TimeUnit.MILLISECONDS.toSeconds(time) - TimeUnit.MINUTES.toSeconds( - TimeUnit.MILLISECONDS.toMinutes(time)) - ) - - return MaterialDialog.Builder(activity) - .title(R.string.restore_completed) - .content(activity.getString(R.string.restore_completed_content, timeString, - if (errors > 0) "$errors" else activity.getString(android.R.string.no))) - .positiveText(R.string.action_close) - .negativeText(R.string.action_open_log) - .onNegative { _, _ -> - val context = applicationContext ?: return@onNegative - if (!path.isEmpty()) { - val destFile = File(path, file) - val uri = destFile.getUriCompat(context) - val sendIntent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, "text/plain") - flags = Intent.FLAG_ACTIVITY_NEW_TASK or - Intent.FLAG_GRANT_READ_URI_PERMISSION - } - startActivity(sendIntent) - } else { - context.toast(context.getString(R.string.error_opening_log)) - } - } - .build() - } - - private companion object { - const val KEY_TIME = "RestoredBackupDialog.time" - const val KEY_ERROR_COUNT = "RestoredBackupDialog.errors" - const val KEY_PATH = "RestoredBackupDialog.path" - const val KEY_FILE = "RestoredBackupDialog.file" - } - } - - inner class BackupBroadcastReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - when (intent.getStringExtra(BackupConst.ACTION)) { - BackupConst.ACTION_BACKUP_COMPLETED_DIALOG -> { - router.popControllerWithTag(TAG_CREATING_BACKUP_DIALOG) - val uri = Uri.parse(intent.getStringExtra(BackupConst.EXTRA_URI)) - CreatedBackupDialog(uri).showDialog(router) - } - BackupConst.ACTION_SET_PROGRESS_DIALOG -> { - val progress = intent.getIntExtra(BackupConst.EXTRA_PROGRESS, 0) - val amount = intent.getIntExtra(BackupConst.EXTRA_AMOUNT, 0) - val content = intent.getStringExtra(BackupConst.EXTRA_CONTENT) - (router.getControllerWithTag(TAG_RESTORING_BACKUP_DIALOG) - as? RestoringBackupDialog)?.updateProgress(content, progress, amount) - } - BackupConst.ACTION_RESTORE_COMPLETED_DIALOG -> { - router.popControllerWithTag(TAG_RESTORING_BACKUP_DIALOG) - val time = intent.getLongExtra(BackupConst.EXTRA_TIME, 0) - val errors = intent.getIntExtra(BackupConst.EXTRA_ERRORS, 0) - val path = intent.getStringExtra(BackupConst.EXTRA_ERROR_FILE_PATH) - val file = intent.getStringExtra(BackupConst.EXTRA_ERROR_FILE) - if (errors > 0) { - RestoredBackupDialog(time, errors, path, file).showDialog(router) - } - } - BackupConst.ACTION_ERROR_BACKUP_DIALOG -> { - router.popControllerWithTag(TAG_CREATING_BACKUP_DIALOG) - context.toast(intent.getStringExtra(BackupConst.EXTRA_ERROR_MESSAGE)) - } - BackupConst.ACTION_ERROR_RESTORE_DIALOG -> { - router.popControllerWithTag(TAG_RESTORING_BACKUP_DIALOG) - context.toast(intent.getStringExtra(BackupConst.EXTRA_ERROR_MESSAGE)) - } - } - } - } - private companion object { const val CODE_BACKUP_CREATE = 501 const val CODE_BACKUP_RESTORE = 502 const val CODE_BACKUP_DIR = 503 - - const val TAG_CREATING_BACKUP_DIALOG = "CreatingBackupDialog" - const val TAG_RESTORING_BACKUP_DIALOG = "RestoringBackupDialog" } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt new file mode 100644 index 000000000..ab4d0bd7b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt @@ -0,0 +1,44 @@ +package eu.kanade.tachiyomi.ui.setting + +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys +import eu.kanade.tachiyomi.extension.ExtensionUpdateJob +import eu.kanade.tachiyomi.util.preference.defaultValue +import eu.kanade.tachiyomi.util.preference.onChange +import eu.kanade.tachiyomi.util.preference.preferenceCategory +import eu.kanade.tachiyomi.util.preference.switchPreference +import eu.kanade.tachiyomi.util.preference.titleRes + +class SettingsBrowseController : SettingsController() { + + override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { + titleRes = R.string.browse + + preferenceCategory { + titleRes = R.string.label_extensions + + switchPreference { + key = Keys.automaticExtUpdates + titleRes = R.string.pref_enable_automatic_extension_updates + defaultValue = true + + onChange { newValue -> + val checked = newValue as Boolean + ExtensionUpdateJob.setupTask(activity!!, checked) + true + } + } + } + + preferenceCategory { + titleRes = R.string.action_global_search + + switchPreference { + key = Keys.searchPinnedSourcesOnly + titleRes = R.string.pref_search_pinned_sources_only + defaultValue = false + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsController.kt index 7586e8a68..ee9c15366 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsController.kt @@ -15,6 +15,9 @@ import com.bluelinelabs.conductor.ControllerChangeType import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.ui.base.controller.BaseController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import rx.Observable import rx.Subscription import rx.subscriptions.CompositeSubscription @@ -24,6 +27,7 @@ import uy.kohesive.injekt.api.get abstract class SettingsController : PreferenceController() { val preferences: PreferencesHelper = Injekt.get() + val scope = CoroutineScope(Job() + Dispatchers.Main) var untilDestroySubscriptions = CompositeSubscription() private set @@ -61,7 +65,7 @@ abstract class SettingsController : PreferenceController() { fun setTitle() { var parentController = parentController while (parentController != null) { - if (parentController is BaseController && parentController.getTitle() != null) { + if (parentController is BaseController<*> && parentController.getTitle() != null) { return } parentController = parentController.parentController @@ -74,14 +78,11 @@ abstract class SettingsController : PreferenceController() { if (type.isEnter) { setTitle() } + setHasOptionsMenu(type.isEnter) super.onChangeStarted(handler, type) } - fun Observable.subscribeUntilDestroy(): Subscription { - return subscribe().also { untilDestroySubscriptions.add(it) } - } - fun Observable.subscribeUntilDestroy(onNext: (T) -> Unit): Subscription { return subscribe(onNext).also { untilDestroySubscriptions.add(it) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadController.kt index e4531de8d..bb9b9ed06 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadController.kt @@ -5,24 +5,36 @@ import android.app.Dialog import android.content.ActivityNotFoundException import android.content.Intent import android.net.Uri -import android.os.Build import android.os.Bundle import android.os.Environment import androidx.core.content.ContextCompat import androidx.preference.PreferenceScreen import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.list.listItemsSingleChoice import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.data.preference.asImmediateFlow import eu.kanade.tachiyomi.ui.base.controller.DialogController -import eu.kanade.tachiyomi.util.getFilePicker +import eu.kanade.tachiyomi.util.preference.defaultValue +import eu.kanade.tachiyomi.util.preference.entriesRes +import eu.kanade.tachiyomi.util.preference.intListPreference +import eu.kanade.tachiyomi.util.preference.multiSelectListPreference +import eu.kanade.tachiyomi.util.preference.onClick +import eu.kanade.tachiyomi.util.preference.preference +import eu.kanade.tachiyomi.util.preference.preferenceCategory +import eu.kanade.tachiyomi.util.preference.switchPreference +import eu.kanade.tachiyomi.util.preference.titleRes +import eu.kanade.tachiyomi.util.system.getFilePicker +import java.io.File +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy -import java.io.File -import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys class SettingsDownloadController : SettingsController() { @@ -40,11 +52,12 @@ class SettingsDownloadController : SettingsController() { ctrl.showDialog(router) } - preferences.downloadsDirectory().asObservable() - .subscribeUntilDestroy { path -> - val dir = UniFile.fromUri(context, Uri.parse(path)) - summary = dir.filePath ?: path - } + preferences.downloadsDirectory().asFlow() + .onEach { path -> + val dir = UniFile.fromUri(context, Uri.parse(path)) + summary = dir.filePath ?: path + } + .launchIn(scope) } switchPreference { key = Keys.downloadOnlyOverWifi @@ -62,9 +75,11 @@ class SettingsDownloadController : SettingsController() { intListPreference { key = Keys.removeAfterReadSlots titleRes = R.string.pref_remove_after_read - entriesRes = arrayOf(R.string.disabled, R.string.last_read_chapter, - R.string.second_to_last, R.string.third_to_last, R.string.fourth_to_last, - R.string.fifth_to_last) + entriesRes = arrayOf( + R.string.disabled, R.string.last_read_chapter, + R.string.second_to_last, R.string.third_to_last, R.string.fourth_to_last, + R.string.fifth_to_last + ) entryValues = arrayOf("-1", "0", "1", "2", "3", "4") defaultValue = "-1" summary = "%s" @@ -72,6 +87,7 @@ class SettingsDownloadController : SettingsController() { } val dbCategories = db.getCategories().executeAsBlocking() + val categories = listOf(Category.createDefault()) + dbCategories preferenceCategory { titleRes = R.string.pref_download_new @@ -84,41 +100,41 @@ class SettingsDownloadController : SettingsController() { multiSelectListPreference { key = Keys.downloadNewCategories titleRes = R.string.pref_download_new_categories - entries = dbCategories.map { it.name }.toTypedArray() - entryValues = dbCategories.map { it.id.toString() }.toTypedArray() + entries = categories.map { it.name }.toTypedArray() + entryValues = categories.map { it.id.toString() }.toTypedArray() - preferences.downloadNew().asObservable() - .subscribeUntilDestroy { isVisible = it } + preferences.downloadNew().asImmediateFlow { isVisible = it } + .launchIn(scope) - preferences.downloadNewCategories().asObservable() - .subscribeUntilDestroy { - val selectedCategories = it - .mapNotNull { id -> dbCategories.find { it.id == id.toInt() } } - .sortedBy { it.order } + preferences.downloadNewCategories().asFlow() + .onEach { mutableSet -> + val selectedCategories = mutableSet + .mapNotNull { id -> categories.find { it.id == id.toInt() } } + .sortedBy { it.order } - summary = if (selectedCategories.isEmpty()) - resources?.getString(R.string.all) - else - selectedCategories.joinToString { it.name } + summary = if (selectedCategories.isEmpty()) { + resources?.getString(R.string.all) + } else { + selectedCategories.joinToString { it.name } } + } + .launchIn(scope) } } } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { when (requestCode) { - DOWNLOAD_DIR_PRE_L -> if (data != null && resultCode == Activity.RESULT_OK) { - val uri = Uri.fromFile(File(data.data.path)) - preferences.downloadsDirectory().set(uri.toString()) - } - DOWNLOAD_DIR_L -> if (data != null && resultCode == Activity.RESULT_OK) { + DOWNLOAD_DIR -> if (data != null && resultCode == Activity.RESULT_OK) { val context = applicationContext ?: return val uri = data.data val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION + Intent.FLAG_GRANT_WRITE_URI_PERMISSION - @Suppress("NewApi") - context.contentResolver.takePersistableUriPermission(uri, flags) + if (uri != null) { + @Suppress("NewApi") + context.contentResolver.takePersistableUriPermission(uri, flags) + } val file = UniFile.fromUri(context, uri) preferences.downloadsDirectory().set(file.uri.toString()) @@ -132,19 +148,11 @@ class SettingsDownloadController : SettingsController() { } fun customDirectorySelected(currentDir: String) { - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - startActivityForResult(preferences.context.getFilePicker(currentDir), DOWNLOAD_DIR_PRE_L) - } else { - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) - try { - startActivityForResult(intent, DOWNLOAD_DIR_L) - } catch (e: ActivityNotFoundException) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - startActivityForResult(preferences.context.getFilePicker(currentDir), DOWNLOAD_DIR_L) - } - } - + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + try { + startActivityForResult(intent, DOWNLOAD_DIR) + } catch (e: ActivityNotFoundException) { + startActivityForResult(preferences.context.getFilePicker(currentDir), DOWNLOAD_DIR) } } @@ -154,36 +162,35 @@ class SettingsDownloadController : SettingsController() { override fun onCreateDialog(savedViewState: Bundle?): Dialog { val activity = activity!! - val currentDir = preferences.downloadsDirectory().getOrDefault() - val externalDirs = getExternalDirs() + File(activity.getString(R.string.custom_dir)) - val selectedIndex = externalDirs.map(File::toString).indexOfFirst { it in currentDir } + val currentDir = preferences.downloadsDirectory().get() + val externalDirs = (getExternalDirs() + File(activity.getString(R.string.custom_dir))).map(File::toString) + val selectedIndex = externalDirs.indexOfFirst { it in currentDir } - return MaterialDialog.Builder(activity) - .items(externalDirs) - .itemsCallbackSingleChoice(selectedIndex, { _, _, which, text -> - val target = targetController as? SettingsDownloadController - if (which == externalDirs.lastIndex) { - target?.customDirectorySelected(currentDir) - } else { - target?.predefinedDirectorySelected(text.toString()) - } - true - }) - .build() + return MaterialDialog(activity) + .listItemsSingleChoice( + items = externalDirs, + initialSelection = selectedIndex + ) { _, position, text -> + val target = targetController as? SettingsDownloadController + if (position == externalDirs.lastIndex) { + target?.customDirectorySelected(currentDir) + } else { + target?.predefinedDirectorySelected(text.toString()) + } + } } private fun getExternalDirs(): List { val defaultDir = Environment.getExternalStorageDirectory().absolutePath + - File.separator + resources?.getString(R.string.app_name) + - File.separator + "downloads" + File.separator + resources?.getString(R.string.app_name) + + File.separator + "downloads" return mutableListOf(File(defaultDir)) + - ContextCompat.getExternalFilesDirs(activity!!, "").filterNotNull() + ContextCompat.getExternalFilesDirs(activity!!, "").filterNotNull() } } private companion object { - const val DOWNLOAD_DIR_PRE_L = 103 - const val DOWNLOAD_DIR_L = 104 + const val DOWNLOAD_DIR = 104 } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralController.kt index ca0d52128..1b0b39c84 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralController.kt @@ -1,202 +1,193 @@ package eu.kanade.tachiyomi.ui.setting -import android.app.Dialog +import android.content.Intent import android.os.Build -import android.os.Bundle -import android.os.Handler -import android.view.View +import android.provider.Settings import androidx.preference.PreferenceScreen -import com.afollestad.materialdialogs.MaterialDialog import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.DatabaseHelper -import eu.kanade.tachiyomi.data.database.models.Category -import eu.kanade.tachiyomi.data.library.LibraryUpdateJob -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.ui.base.controller.DialogController -import eu.kanade.tachiyomi.util.LocaleHelper +import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys +import eu.kanade.tachiyomi.data.preference.PreferenceValues as Values +import eu.kanade.tachiyomi.data.preference.asImmediateFlow +import eu.kanade.tachiyomi.util.preference.defaultValue +import eu.kanade.tachiyomi.util.preference.entriesRes +import eu.kanade.tachiyomi.util.preference.intListPreference +import eu.kanade.tachiyomi.util.preference.listPreference +import eu.kanade.tachiyomi.util.preference.onChange +import eu.kanade.tachiyomi.util.preference.onClick +import eu.kanade.tachiyomi.util.preference.preference +import eu.kanade.tachiyomi.util.preference.preferenceCategory +import eu.kanade.tachiyomi.util.preference.switchPreference +import eu.kanade.tachiyomi.util.preference.titleRes +import eu.kanade.tachiyomi.util.system.LocaleHelper import exh.ui.lock.FingerLockPreference import exh.ui.lock.LockPreference -import kotlinx.android.synthetic.main.pref_library_columns.view.* -import rx.Observable -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys +import kotlinx.coroutines.flow.launchIn class SettingsGeneralController : SettingsController() { - private val db: DatabaseHelper = Injekt.get() - override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { titleRes = R.string.pref_category_general - listPreference { - key = Keys.lang - titleRes = R.string.pref_language - entryValues = arrayOf("", "ar", "bg", "bn", "ca", "cs", "de", "el", "en-US", "en-GB", - "es", "fr", "hi", "hu", "in", "it", "ja", "ko", "lv", "ms", "nb-rNO", "nl", "pl", "pt", - "pt-BR", "ro", "ru", "sc", "sr", "sv", "th", "tl", "tr", "uk", "vi", "zh-rCN") - entries = entryValues.map { value -> - val locale = LocaleHelper.getLocaleFromString(value.toString()) - locale?.getDisplayName(locale)?.capitalize() ?: - context.getString(R.string.system_default) - }.toTypedArray() - defaultValue = "" - summary = "%s" - - onChange { newValue -> - val activity = activity ?: return@onChange false - val app = activity.application - LocaleHelper.changeLocale(newValue.toString()) - LocaleHelper.updateConfiguration(app, app.resources.configuration) - activity.recreate() - true - } - } - intListPreference { - key = Keys.theme - titleRes = R.string.pref_theme - entriesRes = arrayOf(R.string.light_theme, R.string.dark_theme, - R.string.amoled_theme, R.string.darkblue_theme) - entryValues = arrayOf("1", "2", "3", "4") - defaultValue = "1" - summary = "%s" - - onChange { - activity?.recreate() - true - } - } - preference { - titleRes = R.string.pref_library_columns - onClick { - LibraryColumnsDialog().showDialog(router) - } - - fun getColumnValue(value: Int): String { - return if (value == 0) - context.getString(R.string.default_columns) - else - value.toString() - } - - Observable.combineLatest( - preferences.portraitColumns().asObservable(), - preferences.landscapeColumns().asObservable(), - { portraitCols, landscapeCols -> Pair(portraitCols, landscapeCols) }) - .subscribeUntilDestroy { (portraitCols, landscapeCols) -> - val portrait = getColumnValue(portraitCols) - val landscape = getColumnValue(landscapeCols) - summary = "${context.getString(R.string.portrait)}: $portrait, " + - "${context.getString(R.string.landscape)}: $landscape" - } - } intListPreference { key = Keys.startScreen titleRes = R.string.pref_start_screen - entriesRes = arrayOf(R.string.label_library, R.string.label_recent_manga, - R.string.label_recent_updates) - entryValues = arrayOf("1", "2", "3") + entriesRes = arrayOf( + R.string.label_library, + R.string.label_recent_updates, + R.string.label_recent_manga + ) + entryValues = arrayOf("1", "3", "2") defaultValue = "1" summary = "%s" } - intListPreference { - key = Keys.libraryUpdateInterval - titleRes = R.string.pref_library_update_interval - entriesRes = arrayOf(R.string.update_never, R.string.update_1hour, - R.string.update_2hour, R.string.update_3hour, R.string.update_6hour, - R.string.update_12hour, R.string.update_24hour, R.string.update_48hour) - entryValues = arrayOf("0", "1", "2", "3", "6", "12", "24", "48") - defaultValue = "0" - summary = "%s" - - onChange { newValue -> - // Always cancel the previous task, it seems that sometimes they are not updated. - LibraryUpdateJob.cancelTask() - - val interval = (newValue as String).toInt() - if (interval > 0) { - LibraryUpdateJob.setupTask(interval) - } - true - } - } - multiSelectListPreference { - key = Keys.libraryUpdateRestriction - titleRes = R.string.pref_library_update_restriction - entriesRes = arrayOf(R.string.wifi, R.string.charging) - entryValues = arrayOf("wifi", "ac") - summaryRes = R.string.pref_library_update_restriction_summary - - preferences.libraryUpdateInterval().asObservable() - .subscribeUntilDestroy { isVisible = it > 0 } - - onChange { - // Post to event looper to allow the preference to be updated. - Handler().post { LibraryUpdateJob.setupTask() } - true - } - } switchPreference { - key = Keys.updateOnlyNonCompleted - titleRes = R.string.pref_update_only_non_completed + key = Keys.confirmExit + titleRes = R.string.pref_confirm_exit defaultValue = false } - - val dbCategories = db.getCategories().executeAsBlocking() - - multiSelectListPreference { - key = Keys.libraryUpdateCategories - titleRes = R.string.pref_library_update_categories - entries = dbCategories.map { it.name }.toTypedArray() - entryValues = dbCategories.map { it.id.toString() }.toTypedArray() - - preferences.libraryUpdateCategories().asObservable() - .subscribeUntilDestroy { - val selectedCategories = it - .mapNotNull { id -> dbCategories.find { it.id == id.toInt() } } - .sortedBy { it.order } - - summary = if (selectedCategories.isEmpty()) - context.getString(R.string.all) - else - selectedCategories.joinToString { it.name } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + preference { + titleRes = R.string.pref_manage_notifications + onClick { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) } + startActivity(intent) + } + } } - intListPreference{ - key = Keys.libraryUpdatePrioritization - titleRes = R.string.pref_library_update_prioritization - // The following arrays are to be lined up with the list rankingScheme in: - // ../../data/library/LibraryUpdateRanker.kt - entriesRes = arrayOf( - R.string.action_sort_alpha, - R.string.action_sort_last_updated - ) - entryValues = arrayOf( - "0", - "1" - ) - defaultValue = "0" - summaryRes = R.string.pref_library_update_prioritization_summary - } - intListPreference { - key = Keys.defaultCategory - titleRes = R.string.default_category - val categories = listOf(Category.createDefault()) + dbCategories + preferenceCategory { + titleRes = R.string.pref_category_display - val selectedCategory = categories.find { it.id == preferences.defaultCategory() } - entries = arrayOf(context.getString(R.string.default_category_summary)) + - categories.map { it.name }.toTypedArray() - entryValues = arrayOf("-1") + categories.map { it.id.toString() }.toTypedArray() - defaultValue = "-1" - summary = selectedCategory?.name ?: context.getString(R.string.default_category_summary) + listPreference { + key = Keys.lang + titleRes = R.string.pref_language - onChange { newValue -> - summary = categories.find { - it.id == (newValue as String).toInt() - }?.name ?: context.getString(R.string.default_category_summary) - true + val langs = mutableListOf>() + langs += Pair("", "${context.getString(R.string.system_default)} (${LocaleHelper.getDisplayName("")})") + langs += arrayOf( + "ar", "bg", "bn", "ca", "cs", "de", "el", "en-US", "en-GB", "es", "fr", "he", + "hi", "hu", "in", "it", "ja", "ko", "lv", "ms", "nb-rNO", "nl", "pl", "pt", + "pt-BR", "ro", "ru", "sc", "sr", "sv", "th", "tl", "tr", "uk", "vi", "zh-rCN" + ) + .map { + Pair(it, LocaleHelper.getDisplayName(it)) + } + .sortedBy { it.second } + + entryValues = langs.map { it.first }.toTypedArray() + entries = langs.map { it.second }.toTypedArray() + defaultValue = "" + summary = "%s" + + onChange { newValue -> + val activity = activity ?: return@onChange false + val app = activity.application + LocaleHelper.changeLocale(newValue.toString()) + LocaleHelper.updateConfiguration(app, app.resources.configuration) + activity.recreate() + true + } + } + listPreference { + key = Keys.dateFormat + titleRes = R.string.pref_date_format + entryValues = arrayOf("", "MM/dd/yy", "dd/MM/yy", "yyyy-MM-dd") + entries = entryValues.map { value -> + if (value == "") { + context.getString(R.string.system_default) + } else { + value + } + }.toTypedArray() + defaultValue = "" + summary = "%s" + } + listPreference { + key = Keys.themeMode + titleRes = R.string.pref_theme_mode + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + entriesRes = arrayOf( + R.string.theme_system, + R.string.theme_light, + R.string.theme_dark + ) + entryValues = arrayOf( + Values.THEME_MODE_SYSTEM, + Values.THEME_MODE_LIGHT, + Values.THEME_MODE_DARK + ) + defaultValue = Values.THEME_MODE_SYSTEM + } else { + entriesRes = arrayOf( + R.string.theme_light, + R.string.theme_dark + ) + entryValues = arrayOf( + Values.THEME_MODE_LIGHT, + Values.THEME_MODE_DARK + ) + defaultValue = Values.THEME_MODE_LIGHT + } + + summary = "%s" + + onChange { + activity?.recreate() + true + } + } + listPreference { + key = Keys.themeLight + titleRes = R.string.pref_theme_light + entriesRes = arrayOf( + R.string.theme_light_default, + R.string.theme_light_blue + ) + entryValues = arrayOf( + Values.THEME_LIGHT_DEFAULT, + Values.THEME_LIGHT_BLUE + ) + defaultValue = Values.THEME_LIGHT_DEFAULT + summary = "%s" + + preferences.themeMode().asImmediateFlow { isVisible = it != Values.THEME_MODE_DARK } + .launchIn(scope) + + onChange { + if (preferences.themeMode().get() != Values.THEME_MODE_DARK) { + activity?.recreate() + } + true + } + } + listPreference { + key = Keys.themeDark + titleRes = R.string.pref_theme_dark + entriesRes = arrayOf( + R.string.theme_dark_default, + R.string.theme_dark_blue, + R.string.theme_dark_amoled + ) + entryValues = arrayOf( + Values.THEME_DARK_DEFAULT, + Values.THEME_DARK_BLUE, + Values.THEME_DARK_AMOLED + ) + defaultValue = Values.THEME_DARK_DEFAULT + summary = "%s" + + preferences.themeMode().asImmediateFlow { isVisible = it != Values.THEME_MODE_LIGHT } + .launchIn(scope) + + onChange { + if (preferences.themeMode().get() != Values.THEME_MODE_LIGHT) { + activity?.recreate() + } + true + } } } @@ -210,15 +201,8 @@ class SettingsGeneralController : SettingsController() { switchPreference { key = Keys.eh_autoSolveCaptchas title = "Automatically solve captcha" - summary = "Use HIGHLY EXPERIMENTAL automatic ReCAPTCHA solver. Will be grayed out if unsupported by your device." - defaultValue = false - shouldDisableView = Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP - } - - switchPreference { - key = Keys.eh_incogWebview - title = "Incognito 'Open in browser'" - summary = "Prevent pages viewed from the 'Open in browser' menu option from being placed into Chrome's browsing history. May be buggy, some browser features will be unavailable." + summary = + "Use HIGHLY EXPERIMENTAL automatic ReCAPTCHA solver. Will be grayed out if unsupported by your device." defaultValue = false } @@ -238,7 +222,7 @@ class SettingsGeneralController : SettingsController() { addPreference(this) - //Call after addPreference + // Call after addPreference dependency = "pref_app_lock" } @@ -246,57 +230,21 @@ class SettingsGeneralController : SettingsController() { key = Keys.eh_lock_manually title = "Lock manually only" - summary = "Disable automatic app locking. The app can still be locked manually by long-pressing the three-lines/back button in the top left corner." + summary = + "Disable automatic app locking. The app can still be locked manually by long-pressing the three-lines/back button in the top left corner." + defaultValue = false + } + switchPreference { + key = Keys.secureScreen + title = "Enable Secure Screen" + defaultValue = false + } + switchPreference { + key = Keys.hideNotificationContent + titleRes = R.string.hide_notification_content defaultValue = false } } // <-- EXH } - - class LibraryColumnsDialog : DialogController() { - - private val preferences: PreferencesHelper = Injekt.get() - - private var portrait = preferences.portraitColumns().getOrDefault() - private var landscape = preferences.landscapeColumns().getOrDefault() - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val dialog = MaterialDialog.Builder(activity!!) - .title(R.string.pref_library_columns) - .customView(R.layout.pref_library_columns, false) - .positiveText(android.R.string.ok) - .negativeText(android.R.string.cancel) - .onPositive { _, _ -> - preferences.portraitColumns().set(portrait) - preferences.landscapeColumns().set(landscape) - } - .build() - - onViewCreated(dialog.view) - return dialog - } - - fun onViewCreated(view: View) { - with(view.portrait_columns) { - displayedValues = arrayOf(context.getString(R.string.default_columns)) + - IntRange(1, 10).map(Int::toString) - value = portrait - - setOnValueChangedListener { _, _, newValue -> - portrait = newValue - } - } - with(view.landscape_columns) { - displayedValues = arrayOf(context.getString(R.string.default_columns)) + - IntRange(1, 10).map(Int::toString) - value = landscape - - setOnValueChangedListener { _, _, newValue -> - landscape = newValue - } - } - } - - } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsLibraryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsLibraryController.kt new file mode 100644 index 000000000..abfa286d8 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsLibraryController.kt @@ -0,0 +1,247 @@ +package eu.kanade.tachiyomi.ui.setting + +import android.app.Dialog +import android.os.Bundle +import android.os.Handler +import android.view.View +import androidx.preference.PreferenceScreen +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.customview.customView +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.data.library.LibraryUpdateJob +import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.preference.asImmediateFlow +import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction +import eu.kanade.tachiyomi.ui.category.CategoryController +import eu.kanade.tachiyomi.util.preference.defaultValue +import eu.kanade.tachiyomi.util.preference.entriesRes +import eu.kanade.tachiyomi.util.preference.intListPreference +import eu.kanade.tachiyomi.util.preference.multiSelectListPreference +import eu.kanade.tachiyomi.util.preference.onChange +import eu.kanade.tachiyomi.util.preference.onClick +import eu.kanade.tachiyomi.util.preference.preference +import eu.kanade.tachiyomi.util.preference.preferenceCategory +import eu.kanade.tachiyomi.util.preference.summaryRes +import eu.kanade.tachiyomi.util.preference.switchPreference +import eu.kanade.tachiyomi.util.preference.titleRes +import kotlinx.android.synthetic.main.pref_library_columns.view.landscape_columns +import kotlinx.android.synthetic.main.pref_library_columns.view.portrait_columns +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import rx.Observable +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class SettingsLibraryController : SettingsController() { + + private val db: DatabaseHelper = Injekt.get() + + override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { + titleRes = R.string.pref_category_library + + preferenceCategory { + titleRes = R.string.pref_category_display + + preference { + titleRes = R.string.pref_library_columns + onClick { + LibraryColumnsDialog().showDialog(router) + } + + fun getColumnValue(value: Int): String { + return if (value == 0) { + context.getString(R.string.default_columns) + } else { + value.toString() + } + } + + Observable.combineLatest( + preferences.portraitColumns().asObservable(), + preferences.landscapeColumns().asObservable() + ) { portraitCols, landscapeCols -> Pair(portraitCols, landscapeCols) } + .subscribeUntilDestroy { (portraitCols, landscapeCols) -> + val portrait = getColumnValue(portraitCols) + val landscape = getColumnValue(landscapeCols) + summary = "${context.getString(R.string.portrait)}: $portrait, " + + "${context.getString(R.string.landscape)}: $landscape" + } + } + } + + val dbCategories = db.getCategories().executeAsBlocking() + val categories = listOf(Category.createDefault()) + dbCategories + + preferenceCategory { + titleRes = R.string.pref_category_library_update + + intListPreference { + key = Keys.libraryUpdateInterval + titleRes = R.string.pref_library_update_interval + entriesRes = arrayOf( + R.string.update_never, R.string.update_1hour, + R.string.update_2hour, R.string.update_3hour, R.string.update_6hour, + R.string.update_12hour, R.string.update_24hour, R.string.update_48hour + ) + entryValues = arrayOf("0", "1", "2", "3", "6", "12", "24", "48") + defaultValue = "24" + summary = "%s" + + onChange { newValue -> + val interval = (newValue as String).toInt() + LibraryUpdateJob.setupTask(context, interval) + true + } + } + multiSelectListPreference { + key = Keys.libraryUpdateRestriction + titleRes = R.string.pref_library_update_restriction + entriesRes = arrayOf(R.string.wifi, R.string.charging) + entryValues = arrayOf("wifi", "ac") + summaryRes = R.string.pref_library_update_restriction_summary + defaultValue = setOf("wifi") + + preferences.libraryUpdateInterval().asImmediateFlow { isVisible = it > 0 } + .launchIn(scope) + + onChange { + // Post to event looper to allow the preference to be updated. + Handler().post { LibraryUpdateJob.setupTask(context) } + true + } + } + switchPreference { + key = Keys.updateOnlyNonCompleted + titleRes = R.string.pref_update_only_non_completed + defaultValue = false + } + multiSelectListPreference { + key = Keys.libraryUpdateCategories + titleRes = R.string.pref_library_update_categories + entries = categories.map { it.name }.toTypedArray() + entryValues = categories.map { it.id.toString() }.toTypedArray() + preferences.libraryUpdateCategories().asFlow() + .onEach { mutableSet -> + val selectedCategories = mutableSet + .mapNotNull { id -> categories.find { it.id == id.toInt() } } + .sortedBy { it.order } + + summary = if (selectedCategories.isEmpty()) { + context.getString(R.string.all) + } else { + selectedCategories.joinToString { it.name } + } + } + .launchIn(scope) + } + intListPreference { + key = Keys.libraryUpdatePrioritization + titleRes = R.string.pref_library_update_prioritization + + // The following array lines up with the list rankingScheme in: + // ../../data/library/LibraryUpdateRanker.kt + val priorities = arrayOf( + Pair("0", R.string.action_sort_alpha), + Pair("1", R.string.action_sort_last_checked) + ) + val defaultPriority = priorities[0] + + entriesRes = priorities.map { it.second }.toTypedArray() + entryValues = priorities.map { it.first }.toTypedArray() + defaultValue = defaultPriority.first + + val selectedPriority = priorities.find { it.first.toInt() == preferences.libraryUpdatePrioritization().get() } + summaryRes = selectedPriority?.second ?: defaultPriority.second + onChange { newValue -> + summaryRes = priorities.find { + it.first == (newValue as String) + }?.second ?: defaultPriority.second + true + } + } + } + + preferenceCategory { + titleRes = R.string.pref_category_library_categories + + preference { + titleRes = R.string.action_edit_categories + + val catCount = db.getCategories().executeAsBlocking().size + summary = context.resources.getQuantityString(R.plurals.num_categories, catCount, catCount) + + onClick { + router.pushController(CategoryController().withFadeTransaction()) + } + } + + intListPreference { + key = Keys.defaultCategory + titleRes = R.string.default_category + + entries = arrayOf(context.getString(R.string.default_category_summary)) + + categories.map { it.name }.toTypedArray() + entryValues = arrayOf("-1") + categories.map { it.id.toString() }.toTypedArray() + defaultValue = "-1" + + val selectedCategory = categories.find { it.id == preferences.defaultCategory() } + summary = selectedCategory?.name + ?: context.getString(R.string.default_category_summary) + onChange { newValue -> + summary = categories.find { + it.id == (newValue as String).toInt() + }?.name ?: context.getString(R.string.default_category_summary) + true + } + } + } + } + + class LibraryColumnsDialog : DialogController() { + + private val preferences: PreferencesHelper = Injekt.get() + + private var portrait = preferences.portraitColumns().getOrDefault() + private var landscape = preferences.landscapeColumns().getOrDefault() + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val dialog = MaterialDialog(activity!!) + .title(R.string.pref_library_columns) + .customView(R.layout.pref_library_columns, horizontalPadding = true) + .positiveButton(android.R.string.ok) { + preferences.portraitColumns().set(portrait) + preferences.landscapeColumns().set(landscape) + } + .negativeButton(android.R.string.cancel) + + onViewCreated(dialog.view) + return dialog + } + + fun onViewCreated(view: View) { + with(view.portrait_columns) { + displayedValues = arrayOf(context.getString(R.string.default_columns)) + + IntRange(1, 10).map(Int::toString) + value = portrait + + setOnValueChangedListener { _, _, newValue -> + portrait = newValue + } + } + with(view.landscape_columns) { + displayedValues = arrayOf(context.getString(R.string.default_columns)) + + IntRange(1, 10).map(Int::toString) + value = landscape + + setOnValueChangedListener { _, _, newValue -> + landscape = newValue + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt index ee690552a..e5e15cae3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt @@ -1,24 +1,40 @@ package eu.kanade.tachiyomi.ui.setting +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import androidx.preference.PreferenceScreen import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction -import eu.kanade.tachiyomi.util.getResourceColor +import eu.kanade.tachiyomi.util.preference.iconRes +import eu.kanade.tachiyomi.util.preference.iconTint +import eu.kanade.tachiyomi.util.preference.onClick +import eu.kanade.tachiyomi.util.preference.preference +import eu.kanade.tachiyomi.util.preference.titleRes +import eu.kanade.tachiyomi.util.system.getResourceColor +import eu.kanade.tachiyomi.util.system.openInBrowser class SettingsMainController : SettingsController() { + override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { titleRes = R.string.label_settings val tintColor = context.getResourceColor(R.attr.colorAccent) preference { - iconRes = R.drawable.ic_tune_black_24dp + iconRes = R.drawable.ic_tune_24dp iconTint = tintColor titleRes = R.string.pref_category_general onClick { navigateTo(SettingsGeneralController()) } } preference { - iconRes = R.drawable.ic_chrome_reader_mode_black_24dp + iconRes = R.drawable.ic_collections_bookmark_24dp + iconTint = tintColor + titleRes = R.string.pref_category_library + onClick { navigateTo(SettingsLibraryController()) } + } + preference { + iconRes = R.drawable.ic_chrome_reader_mode_24dp iconTint = tintColor titleRes = R.string.pref_category_reader onClick { navigateTo(SettingsReaderController()) } @@ -30,17 +46,29 @@ class SettingsMainController : SettingsController() { onClick { navigateTo(SettingsDownloadController()) } } preference { - iconRes = R.drawable.ic_sync_black_24dp + iconRes = R.drawable.ic_sync_24dp iconTint = tintColor titleRes = R.string.pref_category_tracking onClick { navigateTo(SettingsTrackingController()) } } preference { - iconRes = R.drawable.ic_backup_black_24dp + iconRes = R.drawable.ic_explore_24dp + iconTint = tintColor + titleRes = R.string.browse + onClick { navigateTo(SettingsBrowseController()) } + } + preference { + iconRes = R.drawable.ic_backup_24dp iconTint = tintColor titleRes = R.string.backup onClick { navigateTo(SettingsBackupController()) } } + preference { + iconRes = R.drawable.ic_security_24dp + iconTint = tintColor + titleRes = R.string.pref_category_security + onClick { navigateTo(SettingsSecurityController()) } + } preference { iconRes = R.drawable.eh_ic_ehlogo_red_24dp iconTint = tintColor @@ -60,13 +88,13 @@ class SettingsMainController : SettingsController() { onClick { navigateTo(SettingsHlController()) } } preference { - iconRes = R.drawable.ic_code_black_24dp + iconRes = R.drawable.ic_code_24dp iconTint = tintColor titleRes = R.string.pref_category_advanced onClick { navigateTo(SettingsAdvancedController()) } } preference { - iconRes = R.drawable.ic_help_black_24dp + iconRes = R.drawable.ic_info_24dp iconTint = tintColor titleRes = R.string.pref_category_about onClick { navigateTo(SettingsAboutController()) } @@ -76,4 +104,4 @@ class SettingsMainController : SettingsController() { private fun navigateTo(controller: SettingsController) { router.pushController(controller.withFadeTransaction()) } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt index 3fc324445..58360846f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt @@ -4,117 +4,177 @@ import android.os.Build import androidx.preference.PreferenceScreen import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys +import eu.kanade.tachiyomi.util.preference.defaultValue +import eu.kanade.tachiyomi.util.preference.entriesRes +import eu.kanade.tachiyomi.util.preference.intListPreference +import eu.kanade.tachiyomi.util.preference.listPreference +import eu.kanade.tachiyomi.util.preference.preferenceCategory +import eu.kanade.tachiyomi.util.preference.summaryRes +import eu.kanade.tachiyomi.util.preference.switchPreference +import eu.kanade.tachiyomi.util.preference.titleRes class SettingsReaderController : SettingsController() { override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { titleRes = R.string.pref_category_reader - intListPreference { - key = Keys.defaultViewer - titleRes = R.string.pref_viewer_type - entriesRes = arrayOf(R.string.left_to_right_viewer, R.string.right_to_left_viewer, - R.string.vertical_viewer, R.string.webtoon_viewer) - entryValues = arrayOf("1", "2", "3", "4") - defaultValue = "1" - summary = "%s" - } - intListPreference { - key = Keys.imageScaleType - titleRes = R.string.pref_image_scale_type - entriesRes = arrayOf(R.string.scale_type_fit_screen, R.string.scale_type_stretch, - R.string.scale_type_fit_width, R.string.scale_type_fit_height, - R.string.scale_type_original_size, R.string.scale_type_smart_fit) - entryValues = arrayOf("1", "2", "3", "4", "5", "6") - defaultValue = "1" - summary = "%s" - } - intListPreference { - key = Keys.zoomStart - titleRes = R.string.pref_zoom_start - entriesRes = arrayOf(R.string.zoom_start_automatic, R.string.zoom_start_left, - R.string.zoom_start_right, R.string.zoom_start_center) - entryValues = arrayOf("1", "2", "3", "4") - defaultValue = "1" - summary = "%s" - } - intListPreference { - key = Keys.rotation - titleRes = R.string.pref_rotation_type - entriesRes = arrayOf(R.string.rotation_free, R.string.rotation_lock, - R.string.rotation_force_portrait, R.string.rotation_force_landscape) - entryValues = arrayOf("1", "2", "3", "4") - defaultValue = "1" - summary = "%s" - } - intListPreference { - key = Keys.readerTheme - titleRes = R.string.pref_reader_theme - entriesRes = arrayOf(R.string.white_background, R.string.black_background) - entryValues = arrayOf("0", "1") - defaultValue = "0" - summary = "%s" - } - intListPreference { - key = Keys.doubleTapAnimationSpeed - titleRes = R.string.pref_double_tap_anim_speed - entries = arrayOf(context.getString(R.string.double_tap_anim_speed_0), context.getString(R.string.double_tap_anim_speed_fast), context.getString(R.string.double_tap_anim_speed_normal)) - entryValues = arrayOf("1", "250", "500") // using a value of 0 breaks the image viewer, so min is 1 - defaultValue = "500" - summary = "%s" - } - switchPreference { - key = Keys.skipRead - titleRes = R.string.pref_skip_read_chapters - defaultValue = false - } - switchPreference { - key = Keys.fullscreen - titleRes = R.string.pref_fullscreen - defaultValue = true - } - switchPreference { - key = Keys.keepScreenOn - titleRes = R.string.pref_keep_screen_on - defaultValue = true - } - switchPreference { - key = Keys.showPageNumber - titleRes = R.string.pref_show_page_number - defaultValue = true - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + preferenceCategory { + titleRes = R.string.pref_category_general + + intListPreference { + key = Keys.defaultViewer + titleRes = R.string.pref_viewer_type + entriesRes = arrayOf( + R.string.left_to_right_viewer, R.string.right_to_left_viewer, + R.string.vertical_viewer, R.string.webtoon_viewer, R.string.vertical_plus_viewer + ) + entryValues = arrayOf("1", "2", "3", "4", "5") + defaultValue = "1" + summary = "%s" + } + intListPreference { + key = Keys.rotation + titleRes = R.string.pref_rotation_type + entriesRes = arrayOf( + R.string.rotation_free, R.string.rotation_lock, + R.string.rotation_force_portrait, R.string.rotation_force_landscape + ) + entryValues = arrayOf("1", "2", "3", "4") + defaultValue = "1" + summary = "%s" + } + intListPreference { + key = Keys.readerTheme + titleRes = R.string.pref_reader_theme + entriesRes = arrayOf(R.string.black_background, R.string.gray_background, R.string.white_background) + entryValues = arrayOf("1", "2", "0") + defaultValue = "1" + summary = "%s" + } + intListPreference { + key = Keys.doubleTapAnimationSpeed + titleRes = R.string.pref_double_tap_anim_speed + entries = arrayOf(context.getString(R.string.double_tap_anim_speed_0), context.getString(R.string.double_tap_anim_speed_fast), context.getString(R.string.double_tap_anim_speed_normal)) + entryValues = arrayOf("1", "250", "500") // using a value of 0 breaks the image viewer, so min is 1 + defaultValue = "500" + summary = "%s" + } switchPreference { - key = Keys.trueColor - titleRes = R.string.pref_true_color - defaultValue = false + key = Keys.fullscreen + titleRes = R.string.pref_fullscreen + defaultValue = true + } + + if (activity?.hasDisplayCutout() == true) { + switchPreference { + key = Keys.cutoutShort + titleRes = R.string.pref_cutout_short + defaultValue = true + } + } + + switchPreference { + key = Keys.keepScreenOn + titleRes = R.string.pref_keep_screen_on + defaultValue = true + } + switchPreference { + key = Keys.showPageNumber + titleRes = R.string.pref_show_page_number + defaultValue = true + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + switchPreference { + key = Keys.trueColor + titleRes = R.string.pref_true_color + summaryRes = R.string.pref_true_color_summary + defaultValue = false + } } } + + preferenceCategory { + titleRes = R.string.pref_category_reading + + switchPreference { + key = Keys.skipRead + titleRes = R.string.pref_skip_read_chapters + defaultValue = false + } + switchPreference { + key = Keys.skipFiltered + titleRes = R.string.pref_skip_filtered_chapters + defaultValue = true + } + switchPreference { + key = Keys.alwaysShowChapterTransition + titleRes = R.string.pref_always_show_chapter_transition + defaultValue = true + } + } + // EXH --> - intListPreference { - key = Keys.eh_readerThreads - title = "Download threads" - entries = arrayOf("1", "2", "3", "4", "5") - entryValues = entries - defaultValue = "2" - summary = "Higher values can speed up image downloading significantly, but can also trigger bans. Recommended value is 2 or 3. Current value is: %s" - } - switchPreference { - key = Keys.eh_aggressivePageLoading - title = "Aggressively load pages" - summary = "Slowly download the entire gallery while reading instead of just loading the pages you are viewing." - defaultValue = false - } - switchPreference { - key = Keys.eh_readerInstantRetry - title = "Skip queue on retry" - summary = "Normally, pressing the retry button on a failed download will wait until the downloader has finished downloading the last page before beginning to re-download the failed page. Enabling this will force the downloader to begin re-downloading the failed page as soon as you press the retry button." - defaultValue = true - } - listPreference { - key = Keys.eh_cacheSize - title = "Reader cache size" - entryValues = arrayOf( + preferenceCategory { + titleRes = R.string.eh_settings_category + + intListPreference { + key = Keys.eh_readerThreads + title = "Download threads" + entries = arrayOf("1", "2", "3", "4", "5") + entryValues = entries + defaultValue = "2" + summary = + "Higher values can speed up image downloading significantly, but can also trigger bans. Recommended value is 2 or 3. Current value is: %s" + } + switchPreference { + key = Keys.eh_aggressivePageLoading + title = "Aggressively load pages" + summary = + "Slowly download the entire gallery while reading instead of just loading the pages you are viewing." + defaultValue = false + } + switchPreference { + key = Keys.eh_readerInstantRetry + title = "Skip queue on retry" + summary = + "Normally, pressing the retry button on a failed download will wait until the downloader has finished downloading the last page before beginning to re-download the failed page. Enabling this will force the downloader to begin re-downloading the failed page as soon as you press the retry button." + defaultValue = true + } + intListPreference { + key = Keys.eh_preload_size + title = "Reader Preload amount" + entryValues = arrayOf( + "1", + "2", + "3", + "4", + "6", + "8", + "10", + "12", + "14", + "16" + ) + entries = arrayOf( + "1 Page", + "2 Pages", + "3 Pages", + "4 Pages", + "6 Pages", + "8 Pages", + "10 Pages", + "12 Pages", + "14 Pages", + "16 Pages" + ) + defaultValue = "4" + summary = + "The amount of pages to preload when reading. Higher values will result in a smoother reading experience, at the cost of higher cache usage, it is recommended to increase the amount of cache you allocate when using larger values" + } + listPreference { + key = Keys.eh_cacheSize + title = "Reader cache size" + entryValues = arrayOf( "50", "75", "100", @@ -131,8 +191,8 @@ class SettingsReaderController : SettingsController() { "4000", "4500", "5000" - ) - entries = arrayOf( + ) + entries = arrayOf( "50 MB", "75 MB", "100 MB", @@ -149,24 +209,44 @@ class SettingsReaderController : SettingsController() { "4 GB", "4.5 GB", "5 GB" - ) - defaultValue = "75" - summary = "The amount of images to save on device while reading. Higher values will result in a smoother reading experience, at the cost of higher disk space usage" + ) + defaultValue = "75" + summary = + "The amount of images to save on device while reading. Higher values will result in a smoother reading experience, at the cost of higher disk space usage" + } + switchPreference { + key = Keys.eh_preserveReadingPosition + title = "Preserve reading position on read manga" + defaultValue = false + } } - switchPreference { - key = Keys.eh_preserveReadingPosition - title = "Preserve reading position on read manga" - defaultValue = false - } - switchPreference { - key = Keys.eh_showTransitionPages - title = "Show transition pages between chapters" - defaultValue = true - } - // EXH <-- + preferenceCategory { titleRes = R.string.pager_viewer + intListPreference { + key = Keys.imageScaleType + titleRes = R.string.pref_image_scale_type + entriesRes = arrayOf( + R.string.scale_type_fit_screen, R.string.scale_type_stretch, + R.string.scale_type_fit_width, R.string.scale_type_fit_height, + R.string.scale_type_original_size, R.string.scale_type_smart_fit + ) + entryValues = arrayOf("1", "2", "3", "4", "5", "6") + defaultValue = "1" + summary = "%s" + } + intListPreference { + key = Keys.zoomStart + titleRes = R.string.pref_zoom_start + entriesRes = arrayOf( + R.string.zoom_start_automatic, R.string.zoom_start_left, + R.string.zoom_start_right, R.string.zoom_start_center + ) + entryValues = arrayOf("1", "2", "3", "4") + defaultValue = "1" + summary = "%s" + } switchPreference { key = Keys.enableTransitions titleRes = R.string.pref_page_transitions @@ -178,6 +258,7 @@ class SettingsReaderController : SettingsController() { defaultValue = false } } + preferenceCategory { titleRes = R.string.webtoon_viewer @@ -186,7 +267,23 @@ class SettingsReaderController : SettingsController() { titleRes = R.string.pref_crop_borders defaultValue = false } + + intListPreference { + key = Keys.webtoonSidePadding + titleRes = R.string.pref_webtoon_side_padding + entriesRes = arrayOf( + R.string.webtoon_side_padding_0, + R.string.webtoon_side_padding_10, + R.string.webtoon_side_padding_15, + R.string.webtoon_side_padding_20, + R.string.webtoon_side_padding_25 + ) + entryValues = arrayOf("0", "10", "15", "20", "25") + defaultValue = "0" + summary = "%s" + } } + preferenceCategory { titleRes = R.string.pref_reader_navigation @@ -212,5 +309,4 @@ class SettingsReaderController : SettingsController() { }.apply { dependency = Keys.readWithVolumeKeys } } } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSecurityController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSecurityController.kt new file mode 100644 index 000000000..6fb3a1e66 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSecurityController.kt @@ -0,0 +1,58 @@ +package eu.kanade.tachiyomi.ui.setting + +import androidx.biometric.BiometricManager +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys +import eu.kanade.tachiyomi.data.preference.asImmediateFlow +import eu.kanade.tachiyomi.util.preference.defaultValue +import eu.kanade.tachiyomi.util.preference.intListPreference +import eu.kanade.tachiyomi.util.preference.summaryRes +import eu.kanade.tachiyomi.util.preference.switchPreference +import eu.kanade.tachiyomi.util.preference.titleRes +import kotlinx.coroutines.flow.launchIn + +class SettingsSecurityController : SettingsController() { + + override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { + titleRes = R.string.pref_category_security + + if (BiometricManager.from(context).canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS) { + switchPreference { + key = Keys.useBiometricLock + titleRes = R.string.lock_with_biometrics + defaultValue = false + } + intListPreference { + key = Keys.lockAppAfter + titleRes = R.string.lock_when_idle + val values = arrayOf("0", "1", "2", "5", "10", "-1") + entries = values.mapNotNull { + when (it) { + "-1" -> context.getString(R.string.lock_never) + "0" -> context.getString(R.string.lock_always) + else -> resources?.getQuantityString(R.plurals.lock_after_mins, it.toInt(), it) + } + }.toTypedArray() + entryValues = values + defaultValue = "0" + summary = "%s" + + preferences.useBiometricLock().asImmediateFlow { isVisible = it } + .launchIn(scope) + } + } + + switchPreference { + key = Keys.secureScreen + titleRes = R.string.secure_screen + summaryRes = R.string.secure_screen_summary + defaultValue = false + } + switchPreference { + key = Keys.hideNotificationContent + titleRes = R.string.hide_notification_content + defaultValue = false + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSourcesController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSourcesController.kt index d7f629c59..4ebfcd593 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSourcesController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSourcesController.kt @@ -1,24 +1,36 @@ package eu.kanade.tachiyomi.ui.setting import android.graphics.drawable.Drawable +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.appcompat.widget.SearchView +import androidx.preference.CheckBoxPreference import androidx.preference.PreferenceGroup import androidx.preference.PreferenceScreen import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.icon import eu.kanade.tachiyomi.source.online.HttpSource -import eu.kanade.tachiyomi.source.online.LoginSource -import eu.kanade.tachiyomi.util.LocaleHelper -import eu.kanade.tachiyomi.widget.preference.LoginCheckBoxPreference -import eu.kanade.tachiyomi.widget.preference.SourceLoginDialog +import eu.kanade.tachiyomi.util.preference.onChange +import eu.kanade.tachiyomi.util.preference.switchPreferenceCategory +import eu.kanade.tachiyomi.util.preference.titleRes +import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.widget.preference.SwitchPreferenceCategory import exh.source.BlacklistedSources +import java.util.TreeMap +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.appcompat.queryTextChanges import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.util.* -class SettingsSourcesController : SettingsController(), - SourceLoginDialog.Listener { +class SettingsSourcesController : SettingsController() { + + init { + setHasOptionsMenu(true) + } private val onlineSources by lazy { Injekt.get().getOnlineSources().filter { @@ -26,45 +38,59 @@ class SettingsSourcesController : SettingsController(), } } - override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { - titleRes = R.string.pref_category_sources + private var query = "" + private var orderedLangs = listOf() + private var langPrefs = mutableListOf>() + private var sourcesByLang: TreeMap> = TreeMap() + private var sorting = SourcesSort.Alpha + + override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { + titleRes = R.string.action_filter + + sorting = SourcesSort.from(preferences.sourceSorting().get()) ?: SourcesSort.Alpha + activity?.invalidateOptionsMenu() // Get the list of active language codes. - val activeLangsCodes = preferences.enabledLanguages().getOrDefault() + val activeLangsCodes = preferences.enabledLanguages().get() // Get a map of sources grouped by language. - val sourcesByLang = onlineSources.groupByTo(TreeMap(), { it.lang }) + sourcesByLang = onlineSources.groupByTo(TreeMap(), { it.lang }) // Order first by active languages, then inactive ones - val orderedLangs = sourcesByLang.keys.filter { it in activeLangsCodes } + - sourcesByLang.keys.filterNot { it in activeLangsCodes } + orderedLangs = sourcesByLang.keys.filter { it in activeLangsCodes } + + sourcesByLang.keys.filterNot { it in activeLangsCodes } orderedLangs.forEach { lang -> val sources = sourcesByLang[lang].orEmpty().sortedBy { it.name } // Create a preference group and set initial state and change listener - SwitchPreferenceCategory(context).apply { - preferenceScreen.addPreference(this) - title = LocaleHelper.getDisplayName(lang, context) - isPersistent = false - if (lang in activeLangsCodes) { - setChecked(true) - addLanguageSources(this, sources) - } + langPrefs.add( + Pair( + lang, + switchPreferenceCategory { + preferenceScreen.addPreference(this) + title = LocaleHelper.getSourceDisplayName(lang, context) + isPersistent = false + if (lang in activeLangsCodes) { + setChecked(true) + addLanguageSources(this, sortedSources(sourcesByLang[lang])) + } - onChange { newValue -> - val checked = newValue as Boolean - val current = preferences.enabledLanguages().getOrDefault() - if (!checked) { - preferences.enabledLanguages().set(current - lang) - removeAll() - } else { - preferences.enabledLanguages().set(current + lang) - addLanguageSources(this, sources) + onChange { newValue -> + val checked = newValue as Boolean + val current = preferences.enabledLanguages().get() + if (!checked) { + preferences.enabledLanguages().set(current - lang) + removeAll() + } else { + preferences.enabledLanguages().set(current + lang) + addLanguageSources(this, sortedSources(sourcesByLang[lang])) + } + true + } } - true - } - } + ) + ) } } @@ -78,46 +104,157 @@ class SettingsSourcesController : SettingsController(), * @param group the language category. */ private fun addLanguageSources(group: PreferenceGroup, sources: List) { - val hiddenCatalogues = preferences.hiddenCatalogues().getOrDefault() + val hiddenCatalogues = preferences.hiddenCatalogues().get() + + val selectAllPreference = CheckBoxPreference(group.context).apply { + title = "\t\t${context.getString(R.string.pref_category_all_sources)}" + key = "all_${sources.first().lang}" + isPersistent = false + isChecked = sources.all { it.id.toString() !in hiddenCatalogues } + isVisible = query.isEmpty() + + onChange { newValue -> + val checked = newValue as Boolean + val current = preferences.hiddenCatalogues().get() ?: mutableSetOf() + if (checked) { + current.minus(sources.map { it.id.toString() }) + } else { + current.plus(sources.map { it.id.toString() }) + } + preferences.hiddenCatalogues().set(current) + group.removeAll() + addLanguageSources(group, sortedSources(sources)) + true + } + } + group.addPreference(selectAllPreference) sources.forEach { source -> - val sourcePreference = LoginCheckBoxPreference(group.context, source).apply { + val sourcePreference = CheckBoxPreference(group.context).apply { val id = source.id.toString() title = source.name key = getSourceKey(source.id) isPersistent = false isChecked = id !in hiddenCatalogues + isVisible = query.isEmpty() || source.name.contains(query, ignoreCase = true) + + val sourceIcon = source.icon() + if (sourceIcon != null) { + icon = sourceIcon + } onChange { newValue -> val checked = newValue as Boolean - val current = preferences.hiddenCatalogues().getOrDefault() + val current = preferences.hiddenCatalogues().get() - preferences.hiddenCatalogues().set(if (checked) - current - id - else - current + id) + preferences.hiddenCatalogues().set( + if (checked) { + current - id + } else { + current + id + } + ) + group.removeAll() + addLanguageSources(group, sortedSources(sources)) true } - - setOnLoginClickListener { - val dialog = SourceLoginDialog(source) - dialog.targetController = this@SettingsSourcesController - dialog.showDialog(router) - } } group.addPreference(sourcePreference) } } - override fun loginDialogClosed(source: LoginSource) { - val pref = findPreference(getSourceKey(source.id)) as? LoginCheckBoxPreference - pref?.notifyChanged() - } - private fun getSourceKey(sourceId: Long): String { return "source_$sourceId" } -} \ No newline at end of file + /** + * Adds items to the options menu. + * + * @param menu menu containing options. + * @param inflater used to load the menu xml. + */ + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.settings_sources, menu) + if (sorting == SourcesSort.Alpha) menu.findItem(R.id.action_sort_alpha).isChecked = true + else menu.findItem(R.id.action_sort_enabled).isChecked = true + + val searchItem = menu.findItem(R.id.action_search) + val searchView = searchItem.actionView as SearchView + searchView.maxWidth = Int.MAX_VALUE + + if (this.query.isNotEmpty()) { + searchItem.expandActionView() + searchView.setQuery(query, true) + searchView.clearFocus() + } + + searchView.queryTextChanges() + .filter { router.backstack.lastOrNull()?.controller() == this } + .onEach { + this.query = it.toString() + drawSources() + } + .launchIn(scope) + + // Fixes problem with the overflow icon showing up in lieu of search + searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { + override fun onMenuItemActionExpand(item: MenuItem): Boolean { + return true + } + + override fun onMenuItemActionCollapse(item: MenuItem): Boolean { + activity?.invalidateOptionsMenu() + return true + } + }) + } + + private fun drawSources() { + val activeLangsCodes = preferences.enabledLanguages().get() + langPrefs.forEach { group -> + if (group.first in activeLangsCodes) { + group.second.removeAll() + addLanguageSources(group.second, sortedSources(sourcesByLang[group.first])) + } + } + } + + private fun sortedSources(sources: List?): List { + val sourceAlpha = sources.orEmpty().sortedBy { it.name } + return if (sorting == SourcesSort.Enabled) { + val hiddenCatalogues = preferences.hiddenCatalogues().get() + sourceAlpha.filter { it.id.toString() !in hiddenCatalogues } + + sourceAlpha.filterNot { it.id.toString() !in hiddenCatalogues } + } else { + sourceAlpha + } + } + + /** + * Called when an option menu item has been selected by the user. + * + * @param item The selected item. + * @return True if this event has been consumed, false if it has not. + */ + override fun onOptionsItemSelected(item: MenuItem): Boolean { + sorting = when (item.itemId) { + R.id.action_sort_alpha -> SourcesSort.Alpha + R.id.action_sort_enabled -> SourcesSort.Enabled + else -> return super.onOptionsItemSelected(item) + } + item.isChecked = true + preferences.sourceSorting().set(sorting.value) + drawSources() + return true + } + + enum class SourcesSort(val value: Int) { + Alpha(0), Enabled(1); + + companion object { + fun from(i: Int): SourcesSort? = values().find { it.value == i } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt index 7bc08c326..1c3ec5f0f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt @@ -5,19 +5,28 @@ import android.content.Intent import androidx.browser.customtabs.CustomTabsIntent import androidx.preference.PreferenceScreen import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.anilist.AnilistApi import eu.kanade.tachiyomi.data.track.bangumi.BangumiApi import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi -import eu.kanade.tachiyomi.util.getResourceColor +import eu.kanade.tachiyomi.util.preference.defaultValue +import eu.kanade.tachiyomi.util.preference.initThenAdd +import eu.kanade.tachiyomi.util.preference.onClick +import eu.kanade.tachiyomi.util.preference.preferenceCategory +import eu.kanade.tachiyomi.util.preference.switchPreference +import eu.kanade.tachiyomi.util.preference.titleRes +import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.widget.preference.LoginPreference import eu.kanade.tachiyomi.widget.preference.TrackLoginDialog +import eu.kanade.tachiyomi.widget.preference.TrackLogoutDialog import uy.kohesive.injekt.injectLazy -import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys -class SettingsTrackingController : SettingsController(), - TrackLoginDialog.Listener { +class SettingsTrackingController : + SettingsController(), + TrackLoginDialog.Listener, + TrackLogoutDialog.Listener { private val trackManager: TrackManager by injectLazy() @@ -33,64 +42,69 @@ class SettingsTrackingController : SettingsController(), titleRes = R.string.services trackPreference(trackManager.myAnimeList) { - onClick { - val dialog = TrackLoginDialog(trackManager.myAnimeList) - dialog.targetController = this@SettingsTrackingController - dialog.showDialog(router) - } + val dialog = TrackLoginDialog(trackManager.myAnimeList) + dialog.targetController = this@SettingsTrackingController + dialog.showDialog(router) } trackPreference(trackManager.aniList) { - onClick { - val tabsIntent = CustomTabsIntent.Builder() - .setToolbarColor(context.getResourceColor(R.attr.colorPrimary)) - .build() - tabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) - tabsIntent.launchUrl(activity!!, AnilistApi.authUrl()) - } + val tabsIntent = CustomTabsIntent.Builder() + .setToolbarColor(context.getResourceColor(R.attr.colorPrimary)) + .build() + tabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) + tabsIntent.launchUrl(activity!!, AnilistApi.authUrl()) } trackPreference(trackManager.kitsu) { - onClick { - val dialog = TrackLoginDialog(trackManager.kitsu) - dialog.targetController = this@SettingsTrackingController - dialog.showDialog(router) - } + val dialog = TrackLoginDialog(trackManager.kitsu, R.string.email) + dialog.targetController = this@SettingsTrackingController + dialog.showDialog(router) } trackPreference(trackManager.shikimori) { - onClick { - val tabsIntent = CustomTabsIntent.Builder() - .setToolbarColor(context.getResourceColor(R.attr.colorPrimary)) - .build() - tabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) - tabsIntent.launchUrl(activity!!, ShikimoriApi.authUrl()) - } + val tabsIntent = CustomTabsIntent.Builder() + .setToolbarColor(context.getResourceColor(R.attr.colorPrimary)) + .build() + tabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) + tabsIntent.launchUrl(activity!!, ShikimoriApi.authUrl()) } trackPreference(trackManager.bangumi) { - onClick { - val tabsIntent = CustomTabsIntent.Builder() - .setToolbarColor(context.getResourceColor(R.attr.colorPrimary)) - .build() - tabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) - tabsIntent.launchUrl(activity!!, BangumiApi.authUrl()) - } + val tabsIntent = CustomTabsIntent.Builder() + .setToolbarColor(context.getResourceColor(R.attr.colorPrimary)) + .build() + tabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) + tabsIntent.launchUrl(activity!!, BangumiApi.authUrl()) } } } - inline fun PreferenceScreen.trackPreference( - service: TrackService, - block: (@DSL LoginPreference).() -> Unit + private inline fun PreferenceScreen.trackPreference( + service: TrackService, + crossinline login: () -> Unit ): LoginPreference { - return initThenAdd(LoginPreference(context).apply { - key = Keys.trackUsername(service.id) - title = service.name - }, block) + return initThenAdd( + LoginPreference(context).apply { + key = Keys.trackUsername(service.id) + title = service.name + }, + { + onClick { + if (service.isLogged) { + val dialog = TrackLogoutDialog(service) + dialog.targetController = this@SettingsTrackingController + dialog.showDialog(router) + } else { + login() + } + } + } + ) } override fun onActivityResumed(activity: Activity) { super.onActivityResumed(activity) - // Manually refresh anilist holder + + // Manually refresh OAuth trackers' holders updatePreference(trackManager.aniList.id) updatePreference(trackManager.shikimori.id) + updatePreference(trackManager.bangumi.id) } private fun updatePreference(id: Int) { @@ -98,8 +112,11 @@ class SettingsTrackingController : SettingsController(), pref?.notifyChanged() } - override fun trackDialogClosed(service: TrackService) { + override fun trackLoginDialogClosed(service: TrackService) { updatePreference(service.id) } + override fun trackLogoutDialogClosed(service: TrackService) { + updatePreference(service.id) + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/AnilistLoginActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/AnilistLoginActivity.kt old mode 100755 new mode 100644 similarity index 83% rename from app/src/main/java/eu/kanade/tachiyomi/ui/setting/AnilistLoginActivity.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/AnilistLoginActivity.kt index 3815b847f..ba2448705 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/AnilistLoginActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/AnilistLoginActivity.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.ui.setting +package eu.kanade.tachiyomi.ui.setting.track import android.content.Intent import android.os.Bundle @@ -27,13 +27,16 @@ class AnilistLoginActivity : AppCompatActivity() { val matchResult = regex.find(intent.data?.fragment.toString()) if (matchResult?.groups?.get(1) != null) { trackManager.aniList.login(matchResult.groups[1]!!.value) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { returnToSettings() - }, { _ -> + }, + { returnToSettings() - }) + } + ) } else { trackManager.aniList.logout() returnToSettings() @@ -47,5 +50,4 @@ class AnilistLoginActivity : AppCompatActivity() { intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) startActivity(intent) } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/BangumiLoginActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/BangumiLoginActivity.kt similarity index 82% rename from app/src/main/java/eu/kanade/tachiyomi/ui/setting/BangumiLoginActivity.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/BangumiLoginActivity.kt index 0d4e180da..76e1ca304 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/BangumiLoginActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/BangumiLoginActivity.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.ui.setting +package eu.kanade.tachiyomi.ui.setting.track import android.content.Intent import android.os.Bundle @@ -26,13 +26,16 @@ class BangumiLoginActivity : AppCompatActivity() { val code = intent.data?.getQueryParameter("code") if (code != null) { trackManager.bangumi.login(code) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { returnToSettings() - }, { + }, + { returnToSettings() - }) + } + ) } else { trackManager.bangumi.logout() returnToSettings() @@ -46,5 +49,4 @@ class BangumiLoginActivity : AppCompatActivity() { intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) startActivity(intent) } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/ShikomoriLoginActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/ShikimoriLoginActivity.kt similarity index 82% rename from app/src/main/java/eu/kanade/tachiyomi/ui/setting/ShikomoriLoginActivity.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/ShikimoriLoginActivity.kt index 208001a29..4cb217d85 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/ShikomoriLoginActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/ShikimoriLoginActivity.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.ui.setting +package eu.kanade.tachiyomi.ui.setting.track import android.content.Intent import android.os.Bundle @@ -26,13 +26,16 @@ class ShikimoriLoginActivity : AppCompatActivity() { val code = intent.data?.getQueryParameter("code") if (code != null) { trackManager.shikimori.login(code) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { returnToSettings() - }, { + }, + { returnToSettings() - }) + } + ) } else { trackManager.shikimori.logout() returnToSettings() @@ -46,5 +49,4 @@ class ShikimoriLoginActivity : AppCompatActivity() { intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) startActivity(intent) } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewActivity.kt new file mode 100644 index 000000000..b47f031c3 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewActivity.kt @@ -0,0 +1,190 @@ +package eu.kanade.tachiyomi.ui.webview + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.graphics.Bitmap +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.webkit.WebChromeClient +import android.webkit.WebView +import androidx.core.graphics.ColorUtils +import eu.kanade.tachiyomi.BuildConfig +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.WebviewActivityBinding +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.ui.base.activity.BaseActivity +import eu.kanade.tachiyomi.util.system.WebViewClientCompat +import eu.kanade.tachiyomi.util.system.getResourceColor +import eu.kanade.tachiyomi.util.system.openInBrowser +import eu.kanade.tachiyomi.util.system.toast +import eu.kanade.tachiyomi.util.view.invisible +import eu.kanade.tachiyomi.util.view.visible +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.appcompat.navigationClicks +import reactivecircus.flowbinding.swiperefreshlayout.refreshes +import uy.kohesive.injekt.injectLazy + +class WebViewActivity : BaseActivity() { + + private val sourceManager by injectLazy() + + private var bundle: Bundle? = null + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = WebviewActivityBinding.inflate(layoutInflater) + setContentView(binding.root) + + title = intent.extras?.getString(TITLE_KEY) + setSupportActionBar(binding.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + binding.toolbar.navigationClicks() + .onEach { super.onBackPressed() } + .launchIn(scope) + + binding.swipeRefresh.isEnabled = false + binding.swipeRefresh.refreshes() + .onEach { refreshPage() } + .launchIn(scope) + + if (bundle == null) { + val url = intent.extras!!.getString(URL_KEY) ?: return + var headers = emptyMap() + + val source = sourceManager.get(intent.extras!!.getLong(SOURCE_KEY)) as? HttpSource + if (source != null) { + headers = source.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" } + binding.webview.settings.userAgentString = source.headers["User-Agent"] + } + + supportActionBar?.subtitle = url + + // Debug mode (chrome://inspect/#devices) + if (BuildConfig.DEBUG && 0 != applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) { + WebView.setWebContentsDebuggingEnabled(true) + } + + binding.webview.settings.javaScriptEnabled = true + + binding.webview.webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + binding.progressBar.visible() + binding.progressBar.progress = newProgress + if (newProgress == 100) { + binding.progressBar.invisible() + } + super.onProgressChanged(view, newProgress) + } + } + + binding.webview.webViewClient = object : WebViewClientCompat() { + override fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean { + view.loadUrl(url) + return true + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + invalidateOptionsMenu() + title = view?.title + supportActionBar?.subtitle = url + binding.swipeRefresh.isEnabled = true + binding.swipeRefresh.isRefreshing = false + } + + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + invalidateOptionsMenu() + } + + override fun onPageCommitVisible(view: WebView?, url: String?) { + super.onPageCommitVisible(view, url) + + // Reset to top when page refreshes + binding.nestedView.scrollTo(0, 0) + } + } + + binding.webview.loadUrl(url, headers) + } else { + binding.webview.restoreState(bundle) + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.webview, menu) + return true + } + + override fun onPrepareOptionsMenu(menu: Menu?): Boolean { + val backItem = menu?.findItem(R.id.action_web_back) + val forwardItem = menu?.findItem(R.id.action_web_forward) + backItem?.isEnabled = binding.webview.canGoBack() + forwardItem?.isEnabled = binding.webview.canGoForward() + + val iconTintColor = getResourceColor(R.attr.colorOnPrimary) + val translucentIconTintColor = ColorUtils.setAlphaComponent(iconTintColor, 127) + backItem?.icon?.setTint(if (binding.webview.canGoBack()) iconTintColor else translucentIconTintColor) + forwardItem?.icon?.setTint(if (binding.webview.canGoForward()) iconTintColor else translucentIconTintColor) + + return super.onPrepareOptionsMenu(menu) + } + + override fun onBackPressed() { + if (binding.webview.canGoBack()) binding.webview.goBack() + else super.onBackPressed() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_web_back -> binding.webview.goBack() + R.id.action_web_forward -> binding.webview.goForward() + R.id.action_web_refresh -> refreshPage() + R.id.action_web_share -> shareWebpage() + R.id.action_web_browser -> openInBrowser() + } + return super.onOptionsItemSelected(item) + } + + private fun refreshPage() { + binding.swipeRefresh.isRefreshing = true + binding.webview.reload() + } + + private fun shareWebpage() { + try { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, binding.webview.url) + } + startActivity(Intent.createChooser(intent, getString(R.string.action_share))) + } catch (e: Exception) { + toast(e.message) + } + } + + private fun openInBrowser() { + openInBrowser(binding.webview.url) + } + + companion object { + private const val URL_KEY = "url_key" + private const val SOURCE_KEY = "source_key" + private const val TITLE_KEY = "title_key" + + fun newIntent(context: Context, url: String, sourceId: Long? = null, title: String? = null): Intent { + val intent = Intent(context, WebViewActivity::class.java) + intent.putExtra(URL_KEY, url) + intent.putExtra(SOURCE_KEY, sourceId) + intent.putExtra(TITLE_KEY, title) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + return intent + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/ComparatorUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/ComparatorUtil.kt deleted file mode 100644 index aff3603b6..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/util/ComparatorUtil.kt +++ /dev/null @@ -1,5 +0,0 @@ -package eu.kanade.tachiyomi.util - -object ComparatorUtil { - val CaseInsensitiveNaturalComparator = compareBy(String.CASE_INSENSITIVE_ORDER) { it }.then(naturalOrder()) -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/CoroutinesExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/CoroutinesExtensions.kt deleted file mode 100644 index 93665ffbe..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/util/CoroutinesExtensions.kt +++ /dev/null @@ -1,10 +0,0 @@ -package eu.kanade.tachiyomi.util - -import kotlinx.coroutines.* - -fun launchUI(block: suspend CoroutineScope.() -> Unit): Job = - GlobalScope.launch(Dispatchers.Main, CoroutineStart.DEFAULT, block) - -@UseExperimental(ExperimentalCoroutinesApi::class) -fun launchNow(block: suspend CoroutineScope.() -> Unit): Job = - GlobalScope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED, block) diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/EpubFile.kt b/app/src/main/java/eu/kanade/tachiyomi/util/EpubFile.kt deleted file mode 100644 index f1c81b5b8..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/util/EpubFile.kt +++ /dev/null @@ -1,117 +0,0 @@ -package eu.kanade.tachiyomi.util - -import org.jsoup.Jsoup -import org.jsoup.nodes.Document -import java.io.Closeable -import java.io.File -import java.io.InputStream -import java.util.zip.ZipEntry -import java.util.zip.ZipFile - -/** - * Wrapper over ZipFile to load files in epub format. - */ -class EpubFile(file: File) : Closeable { - - /** - * Zip file of this epub. - */ - private val zip = ZipFile(file) - - /** - * Closes the underlying zip file. - */ - override fun close() { - zip.close() - } - - /** - * Returns an input stream for reading the contents of the specified zip file entry. - */ - fun getInputStream(entry: ZipEntry): InputStream { - return zip.getInputStream(entry) - } - - /** - * Returns the zip file entry for the specified name, or null if not found. - */ - fun getEntry(name: String): ZipEntry? { - return zip.getEntry(name) - } - - /** - * Returns the path of all the images found in the epub file. - */ - fun getImagesFromPages(): List { - val allEntries = zip.entries().toList() - val ref = getPackageHref() - val doc = getPackageDocument(ref) - val pages = getPagesFromDocument(doc) - val hrefs = getHrefMap(ref, allEntries.map { it.name }) - return getImagesFromPages(pages, hrefs) - } - - /** - * Returns the path to the package document. - */ - private fun getPackageHref(): String { - val meta = zip.getEntry("META-INF/container.xml") - if (meta != null) { - val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") } - val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path") - if (path != null) { - return path - } - } - return "OEBPS/content.opf" - } - - /** - * Returns the package document where all the files are listed. - */ - private fun getPackageDocument(ref: String): Document { - val entry = zip.getEntry(ref) - return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") } - } - - /** - * Returns all the pages from the epub. - */ - private fun getPagesFromDocument(document: Document): List { - val pages = document.select("manifest > item") - .filter { "application/xhtml+xml" == it.attr("media-type") } - .associateBy { it.attr("id") } - - val spine = document.select("spine > itemref").map { it.attr("idref") } - return spine.mapNotNull { pages[it] }.map { it.attr("href") } - } - - /** - * Returns all the images contained in every page from the epub. - */ - private fun getImagesFromPages(pages: List, hrefs: Map): List { - return pages.map { page -> - val entry = zip.getEntry(hrefs[page]) - val document = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") } - document.getElementsByTag("img").mapNotNull { hrefs[it.attr("src")] } - }.flatten() - } - - /** - * Returns a map with a relative url as key and abolute url as path. - */ - private fun getHrefMap(packageHref: String, entries: List): Map { - val lastSlashPos = packageHref.lastIndexOf('/') - if (lastSlashPos < 0) { - return entries.associateBy { it } - } - return entries.associateBy { entry -> - if (entry.isNotBlank() && entry.length > lastSlashPos) { - entry.substring(lastSlashPos + 1) - } else { - entry - } - } - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/GLUtil.java b/app/src/main/java/eu/kanade/tachiyomi/util/GLUtil.java deleted file mode 100755 index f60376524..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/util/GLUtil.java +++ /dev/null @@ -1,54 +0,0 @@ -package eu.kanade.tachiyomi.util; - -import javax.microedition.khronos.egl.EGL10; -import javax.microedition.khronos.egl.EGLConfig; -import javax.microedition.khronos.egl.EGLContext; -import javax.microedition.khronos.egl.EGLDisplay; - -public final class GLUtil { - - private GLUtil() throws InstantiationException { - throw new InstantiationException("This class is not for instantiation"); - } - - public static int getMaxTextureSize() { - // Safe minimum default size - final int IMAGE_MAX_BITMAP_DIMENSION = 2048; - - // Get EGL Display - EGL10 egl = (EGL10) EGLContext.getEGL(); - EGLDisplay display = egl.eglGetDisplay(EGL10.EGL_DEFAULT_DISPLAY); - - // Initialise - int[] version = new int[2]; - egl.eglInitialize(display, version); - - // Query total number of configurations - int[] totalConfigurations = new int[1]; - egl.eglGetConfigs(display, null, 0, totalConfigurations); - - // Query actual list configurations - EGLConfig[] configurationsList = new EGLConfig[totalConfigurations[0]]; - egl.eglGetConfigs(display, configurationsList, totalConfigurations[0], totalConfigurations); - - int[] textureSize = new int[1]; - int maximumTextureSize = 0; - - // Iterate through all the configurations to located the maximum texture size - for (int i = 0; i < totalConfigurations[0]; i++) { - // Only need to check for width since opengl textures are always squared - egl.eglGetConfigAttrib(display, configurationsList[i], EGL10.EGL_MAX_PBUFFER_WIDTH, textureSize); - - // Keep track of the maximum texture size - if (maximumTextureSize < textureSize[0]) - maximumTextureSize = textureSize[0]; - } - - // Release - egl.eglTerminate(display); - - // Return largest texture size found, or default - return Math.max(maximumTextureSize, IMAGE_MAX_BITMAP_DIMENSION); - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/SharedData.kt b/app/src/main/java/eu/kanade/tachiyomi/util/SharedData.kt deleted file mode 100755 index 98d10b2d4..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/util/SharedData.kt +++ /dev/null @@ -1,55 +0,0 @@ -package eu.kanade.tachiyomi.util - -import java.util.* - -/** - * This singleton is used to share some objects within the application, useful to communicate - * different parts of the app. - * - * It stores the objects in a map using the type of the object as key, so that only one object per - * class is stored at once. - */ -object SharedData { - - /** - * Map where the objects are saved. - */ - val map = HashMap, Any>() - - /** - * Publish an object to the shared data. - * - * @param data the object to put. - */ - fun put(data: T) { - map.put(data.javaClass, data) - } - - /** - * Retrieves an object from the shared data. - * - * @param classType the class of the object to retrieve. - * @return an object of type T or null if it's not found. - */ - @Suppress("UNCHECKED_CAST") - fun get(classType: Class) = map[classType] as? T - - /** - * Removes an object from the shared data. - * - * @param classType the class of the object to remove. - * @return the object removed, null otherwise. - */ - fun remove(classType: Class) = get(classType)?.apply { map.remove(classType) } - - /** - * Returns an object from the shared data or introduces a new one with the given function. - * - * @param classType the class of the object to retrieve. - * @param fn the function to execute if it didn't find the object. - * @return an object of type T. - */ - @Suppress("UNCHECKED_CAST") - inline fun getOrPut(classType: Class, fn: () -> T) = map.getOrPut(classType, fn) as T - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/StringExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/StringExtensions.kt deleted file mode 100755 index 862514e96..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/util/StringExtensions.kt +++ /dev/null @@ -1,28 +0,0 @@ -package eu.kanade.tachiyomi.util - -import java.lang.Math.floor - -/** - * Replaces the given string to have at most [count] characters using [replacement] at its end. - * If [replacement] is longer than [count] an exception will be thrown when `length > count`. - */ -fun String.chop(count: Int, replacement: String = "..."): String { - return if (length > count) - take(count - replacement.length) + replacement - else - this - -} - -/** - * Replaces the given string to have at most [count] characters using [replacement] near the center. - * If [replacement] is longer than [count] an exception will be thrown when `length > count`. - */ -fun String.truncateCenter(count: Int, replacement: String = "..."): String{ - if(length <= count) - return this - - val pieceLength:Int = floor((count - replacement.length).div(2.0)).toInt() - - return "${ take(pieceLength) }$replacement${ takeLast(pieceLength) }" -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/ViewExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/ViewExtensions.kt deleted file mode 100755 index 4f03ab40c..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/util/ViewExtensions.kt +++ /dev/null @@ -1,70 +0,0 @@ -@file:Suppress("NOTHING_TO_INLINE") - -package eu.kanade.tachiyomi.util - -import android.graphics.Color -import android.graphics.Point -import android.graphics.Typeface -import android.view.View -import android.widget.TextView -import com.amulyakhare.textdrawable.TextDrawable -import com.amulyakhare.textdrawable.util.ColorGenerator -import com.google.android.material.snackbar.Snackbar - -/** - * Returns coordinates of view. - * Used for animation - * - * @return coordinates of view - */ -fun View.getCoordinates() = Point((left + right) / 2, (top + bottom) / 2) - -/** - * Shows a snackbar in this view. - * - * @param message the message to show. - * @param length the duration of the snack. - * @param f a function to execute in the snack, allowing for example to define a custom action. - */ -inline fun View.snack(message: String, length: Int = Snackbar.LENGTH_LONG, f: Snackbar.() -> Unit): Snackbar { - val snack = Snackbar.make(this, message, length) - val textView: TextView = snack.view.findViewById(com.google.android.material.R.id.snackbar_text) - textView.setTextColor(Color.WHITE) - snack.f() - snack.show() - return snack -} - -inline fun View.visible() { - visibility = View.VISIBLE -} - -inline fun View.invisible() { - visibility = View.INVISIBLE -} - -inline fun View.gone() { - visibility = View.GONE -} - -inline fun View.visibleIf(block: () -> Boolean) { - visibility = if (block()) View.VISIBLE else View.GONE -} - -/** - * Returns a TextDrawable determined by input - * - * @param text text of [TextDrawable] - * @param random random color - */ -fun View.getRound(text: String, random : Boolean = true): TextDrawable { - val size = Math.min(this.width, this.height) - return TextDrawable.builder() - .beginConfig() - .width(size) - .height(size) - .textColor(Color.WHITE) - .useFont(Typeface.DEFAULT) - .endConfig() - .buildRound(text, if (random) ColorGenerator.MATERIAL.randomColor else ColorGenerator.MATERIAL.getColor(text)) -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/ChapterRecognition.kt b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterRecognition.kt old mode 100755 new mode 100644 similarity index 87% rename from app/src/main/java/eu/kanade/tachiyomi/util/ChapterRecognition.kt rename to app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterRecognition.kt index 742fa3e1a..bb1524f0b --- a/app/src/main/java/eu/kanade/tachiyomi/util/ChapterRecognition.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterRecognition.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.util +package eu.kanade.tachiyomi.util.chapter import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga @@ -39,8 +39,9 @@ object ChapterRecognition { fun parseChapterNumber(chapter: SChapter, manga: SManga) { // If chapter number is known return. - if (chapter.chapter_number == -2f || chapter.chapter_number > -1f) + if (chapter.chapter_number == -2f || chapter.chapter_number > -1f) { return + } // Get chapter title with lower case var name = chapter.name.toLowerCase() @@ -59,8 +60,9 @@ object ChapterRecognition { } // Check base case ch.xx - if (updateChapter(basic.find(name), chapter)) + if (updateChapter(basic.find(name), chapter)) { return + } // Check one number occurrence. val occurrences: MutableList = arrayListOf() @@ -69,20 +71,23 @@ object ChapterRecognition { } if (occurrences.size == 1) { - if (updateChapter(occurrences[0], chapter)) + if (updateChapter(occurrences[0], chapter)) { return + } } // Remove manga title from chapter title. val nameWithoutManga = name.replace(manga.title.toLowerCase(), "").trim() // Check if first value is number after title remove. - if (updateChapter(withoutManga.find(nameWithoutManga), chapter)) + if (updateChapter(withoutManga.find(nameWithoutManga), chapter)) { return + } // Take the first number encountered. - if (updateChapter(occurrence.find(nameWithoutManga), chapter)) + if (updateChapter(occurrence.find(nameWithoutManga), chapter)) { return + } } /** @@ -110,24 +115,28 @@ object ChapterRecognition { * @return decimal/alpha float value */ private fun checkForDecimal(decimal: String?, alpha: String?): Float { - if (!decimal.isNullOrEmpty()) + if (!decimal.isNullOrEmpty()) { return decimal.toFloat() + } if (!alpha.isNullOrEmpty()) { - if (alpha.contains("extra")) + if (alpha.contains("extra")) { return .99f + } - if (alpha.contains("omake")) + if (alpha.contains("omake")) { return .98f + } - if (alpha.contains("special")) + if (alpha.contains("special")) { return .97f + } - if (alpha[0] == '.') { + return if (alpha[0] == '.') { // Take value after (.) - return parseAlphaPostFix(alpha[1]) + parseAlphaPostFix(alpha[1]) } else { - return parseAlphaPostFix(alpha[0]) + parseAlphaPostFix(alpha[0]) } } @@ -140,5 +149,4 @@ object ChapterRecognition { private fun parseAlphaPostFix(alpha: Char): Float { return ("0." + (alpha.toInt() - 96).toString()).toFloat() } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/ChapterSourceSync.kt b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSourceSync.kt old mode 100755 new mode 100644 similarity index 86% rename from app/src/main/java/eu/kanade/tachiyomi/util/ChapterSourceSync.kt rename to app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSourceSync.kt index 3ec7ab965..058ec36ab --- a/app/src/main/java/eu/kanade/tachiyomi/util/ChapterSourceSync.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSourceSync.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.util +package eu.kanade.tachiyomi.util.chapter import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Chapter @@ -8,7 +8,8 @@ import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.online.HttpSource import exh.EH_SOURCE_ID import exh.EXH_SOURCE_ID -import java.util.* +import java.util.Date +import java.util.TreeSet /** * Helper method for syncing the list of chapters from the source with the ones from the database. @@ -19,11 +20,12 @@ import java.util.* * @param source the source of the chapters. * @return a pair of new insertions and deletions. */ -fun syncChaptersWithSource(db: DatabaseHelper, - rawSourceChapters: List, - manga: Manga, - source: Source): Pair, List> { - +fun syncChaptersWithSource( + db: DatabaseHelper, + rawSourceChapters: List, + manga: Manga, + source: Source +): Pair, List> { if (rawSourceChapters.isEmpty()) { throw Exception("No chapters found") } @@ -52,7 +54,7 @@ fun syncChaptersWithSource(db: DatabaseHelper, if (dbChapter == null) { toAdd.add(sourceChapter) } else { - //this forces metadata update for the main viewable things in the chapter list + // this forces metadata update for the main viewable things in the chapter list if (source is HttpSource) { source.prepareNewChapter(sourceChapter, manga) } @@ -94,7 +96,7 @@ fun syncChaptersWithSource(db: DatabaseHelper, db.inTransaction { val deletedChapterNumbers = TreeSet() val deletedReadChapterNumbers = TreeSet() - if (!toDelete.isEmpty()) { + if (toDelete.isNotEmpty()) { for (c in toDelete) { if (c.read) { deletedReadChapterNumbers.add(c.chapter_number) @@ -104,7 +106,7 @@ fun syncChaptersWithSource(db: DatabaseHelper, db.deleteChapters(toDelete).executeAsBlocking() } - if (!toAdd.isEmpty()) { + if (toAdd.isNotEmpty()) { // Set the date fetch for new items in reverse order to allow another sorting method. // Sources MUST return the chapters from most to less recent, which is common. var now = Date().time @@ -135,10 +137,13 @@ fun syncChaptersWithSource(db: DatabaseHelper, } // <-- EXH - db.insertChapters(toAdd).executeAsBlocking() + val chapters = db.insertChapters(toAdd).executeAsBlocking() + toAdd.forEach { chapter -> + chapter.id = chapters.results().getValue(chapter).insertedId() + } } - if (!toChange.isEmpty()) { + if (toChange.isNotEmpty()) { db.insertChapters(toChange).executeAsBlocking() } @@ -149,13 +154,13 @@ fun syncChaptersWithSource(db: DatabaseHelper, manga.last_update = Date().time db.updateLastUpdated(manga).executeAsBlocking() } - return Pair(toAdd.subtract(readded).toList(), toDelete.subtract(readded).toList()) + return Pair(toAdd.subtract(readded).toList(), toDelete.subtract(readded).toList()) } -//checks if the chapter in db needs updated +// checks if the chapter in db needs updated private fun shouldUpdateDbChapter(dbChapter: Chapter, sourceChapter: SChapter): Boolean { return dbChapter.scanlator != sourceChapter.scanlator || dbChapter.name != sourceChapter.name || - dbChapter.date_upload != sourceChapter.date_upload || - dbChapter.chapter_number != sourceChapter.chapter_number -} \ No newline at end of file + dbChapter.date_upload != sourceChapter.date_upload || + dbChapter.chapter_number != sourceChapter.chapter_number +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/lang/CoroutinesExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/lang/CoroutinesExtensions.kt new file mode 100644 index 000000000..c126c028a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/lang/CoroutinesExtensions.kt @@ -0,0 +1,19 @@ +package eu.kanade.tachiyomi.util.lang + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +fun launchUI(block: suspend CoroutineScope.() -> Unit): Job = + GlobalScope.launch(Dispatchers.Main, CoroutineStart.DEFAULT, block) + +fun launchIO(block: suspend CoroutineScope.() -> Unit): Job = + GlobalScope.launch(Dispatchers.IO, CoroutineStart.DEFAULT, block) + +@OptIn(ExperimentalCoroutinesApi::class) +fun launchNow(block: suspend CoroutineScope.() -> Unit): Job = + GlobalScope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED, block) diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/lang/DateExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/lang/DateExtensions.kt new file mode 100644 index 000000000..2b4f4d8af --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/lang/DateExtensions.kt @@ -0,0 +1,45 @@ +package eu.kanade.tachiyomi.util.lang + +import java.text.DateFormat +import java.util.Calendar +import java.util.Date + +fun Date.toDateTimestampString(dateFormatter: DateFormat): String { + val date = dateFormatter.format(this) + val time = DateFormat.getTimeInstance(DateFormat.SHORT).format(this) + return "$date $time" +} + +fun Date.toTimestampString(): String { + return DateFormat.getTimeInstance(DateFormat.SHORT).format(this) +} + +/** + * Get date as time key + * + * @param date desired date + * @return date as time key + */ +fun Long.toDateKey(): Date { + val cal = Calendar.getInstance() + cal.time = Date(this) + cal[Calendar.HOUR_OF_DAY] = 0 + cal[Calendar.MINUTE] = 0 + cal[Calendar.SECOND] = 0 + cal[Calendar.MILLISECOND] = 0 + return cal.time +} + +/** + * Convert epoch long to Calendar instance + * + * @return Calendar instance at supplied epoch time. Null if epoch was 0. + */ +fun Long.toCalendar(): Calendar? { + if (this == 0L) { + return null + } + val cal = Calendar.getInstance() + cal.timeInMillis = this + return cal +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/Hash.kt b/app/src/main/java/eu/kanade/tachiyomi/util/lang/Hash.kt similarity index 83% rename from app/src/main/java/eu/kanade/tachiyomi/util/Hash.kt rename to app/src/main/java/eu/kanade/tachiyomi/util/lang/Hash.kt index eb89b0431..a89063208 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/Hash.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/lang/Hash.kt @@ -1,11 +1,13 @@ -package eu.kanade.tachiyomi.util +package eu.kanade.tachiyomi.util.lang import java.security.MessageDigest object Hash { - private val chars = charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', - 'a', 'b', 'c', 'd', 'e', 'f') + private val chars = charArrayOf( + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'a', 'b', 'c', 'd', 'e', 'f' + ) private val MD5 get() = MessageDigest.getInstance("MD5") @@ -39,4 +41,4 @@ object Hash { } return String(out) } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/RetryWithDelay.kt b/app/src/main/java/eu/kanade/tachiyomi/util/lang/RetryWithDelay.kt old mode 100755 new mode 100644 similarity index 74% rename from app/src/main/java/eu/kanade/tachiyomi/util/RetryWithDelay.kt rename to app/src/main/java/eu/kanade/tachiyomi/util/lang/RetryWithDelay.kt index e2ef0d2a1..a14825a4b --- a/app/src/main/java/eu/kanade/tachiyomi/util/RetryWithDelay.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/lang/RetryWithDelay.kt @@ -1,15 +1,15 @@ -package eu.kanade.tachiyomi.util +package eu.kanade.tachiyomi.util.lang +import java.util.concurrent.TimeUnit.MILLISECONDS import rx.Observable import rx.Scheduler import rx.functions.Func1 import rx.schedulers.Schedulers -import java.util.concurrent.TimeUnit.MILLISECONDS class RetryWithDelay( - private val maxRetries: Int = 1, - private val retryStrategy: (Int) -> Int = { 1000 }, - private val scheduler: Scheduler = Schedulers.computation() + private val maxRetries: Int = 1, + private val retryStrategy: (Int) -> Int = { 1000 }, + private val scheduler: Scheduler = Schedulers.computation() ) : Func1, Observable<*>> { private var retryCount = 0 @@ -22,4 +22,4 @@ class RetryWithDelay( Observable.error(error as Throwable) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/RxExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/lang/RxExtensions.kt old mode 100755 new mode 100644 similarity index 93% rename from app/src/main/java/eu/kanade/tachiyomi/util/RxExtensions.kt rename to app/src/main/java/eu/kanade/tachiyomi/util/lang/RxExtensions.kt index 8a0c965ff..b9fb8e7eb --- a/app/src/main/java/eu/kanade/tachiyomi/util/RxExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/lang/RxExtensions.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.util +package eu.kanade.tachiyomi.util.lang import rx.Observable import rx.Subscription diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/lang/StringExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/lang/StringExtensions.kt new file mode 100644 index 000000000..1ae62a62e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/lang/StringExtensions.kt @@ -0,0 +1,59 @@ +package eu.kanade.tachiyomi.util.lang + +import kotlin.math.floor +import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator + +/** + * Replaces the given string to have at most [count] characters using [replacement] at its end. + * If [replacement] is longer than [count] an exception will be thrown when `length > count`. + */ +fun String.chop(count: Int, replacement: String = "..."): String { + return if (length > count) { + take(count - replacement.length) + replacement + } else { + this + } +} + +/** + * Replaces the given string to have at most [count] characters using [replacement] near the center. + * If [replacement] is longer than [count] an exception will be thrown when `length > count`. + */ +fun String.truncateCenter(count: Int, replacement: String = "..."): String { + if (length <= count) { + return this + } + + val pieceLength: Int = floor((count - replacement.length).div(2.0)).toInt() + + return "${take(pieceLength)}$replacement${takeLast(pieceLength)}" +} + +/** + * Case-insensitive natural comparator for strings. + */ +fun String.compareToCaseInsensitiveNaturalOrder(other: String): Int { + val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance() + return comparator.compare(this, other) +} + +/** + * Returns the size of the string as the number of bytes. + */ +fun String.byteSize(): Int { + return toByteArray(Charsets.UTF_8).size +} + +/** + * Returns a string containing the first [n] bytes from this string, or the entire string if this + * string is shorter. + */ +@OptIn(ExperimentalStdlibApi::class) +fun String.takeBytes(n: Int): String { + val bytes = toByteArray(Charsets.UTF_8) + return if (bytes.size <= n) { + this + } else { + bytes.decodeToString(endIndex = n).replace("\uFFFD", "") + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/PreferenceDSL.kt b/app/src/main/java/eu/kanade/tachiyomi/util/preference/PreferenceDSL.kt similarity index 63% rename from app/src/main/java/eu/kanade/tachiyomi/ui/setting/PreferenceDSL.kt rename to app/src/main/java/eu/kanade/tachiyomi/util/preference/PreferenceDSL.kt index 265fc4e53..db7b40e4d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/PreferenceDSL.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/preference/PreferenceDSL.kt @@ -1,9 +1,20 @@ -package eu.kanade.tachiyomi.ui.setting +package eu.kanade.tachiyomi.util.preference import androidx.core.graphics.drawable.DrawableCompat -import androidx.preference.* +import androidx.preference.CheckBoxPreference +import androidx.preference.DialogPreference +import androidx.preference.EditTextPreference +import androidx.preference.ListPreference +import androidx.preference.MultiSelectListPreference +import androidx.preference.Preference +import androidx.preference.PreferenceCategory +import androidx.preference.PreferenceGroup +import androidx.preference.PreferenceManager +import androidx.preference.PreferenceScreen +import androidx.preference.SwitchPreferenceCompat import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat import eu.kanade.tachiyomi.widget.preference.IntListPreference +import eu.kanade.tachiyomi.widget.preference.SwitchPreferenceCategory @DslMarker @Target(AnnotationTarget.TYPE) @@ -21,6 +32,10 @@ inline fun PreferenceGroup.switchPreference(block: (@DSL SwitchPreferenceCompat) return initThenAdd(SwitchPreferenceCompat(context), block) } +inline fun PreferenceGroup.switchPreferenceCategory(block: (@DSL SwitchPreferenceCategory).() -> Unit): SwitchPreferenceCategory { + return initThenAdd(SwitchPreferenceCategory(context), block) +} + inline fun PreferenceGroup.checkBoxPreference(block: (@DSL CheckBoxPreference).() -> Unit): CheckBoxPreference { return initThenAdd(CheckBoxPreference(context), block) } @@ -42,9 +57,7 @@ inline fun PreferenceGroup.multiSelectListPreference(block: (@DSL MultiSelectLis } inline fun PreferenceScreen.preferenceCategory(block: (@DSL PreferenceCategory).() -> Unit): PreferenceCategory { - return addThenInit(PreferenceCategory(context).apply { - isIconSpaceReserved = false - }, block) + return addThenInit(PreferenceCategory(context), block) } inline fun PreferenceScreen.preferenceScreen(block: (@DSL PreferenceScreen).() -> Unit): PreferenceScreen { @@ -59,12 +72,27 @@ fun initDialog(dialogPreference: DialogPreference) { } } +inline fun

PreferenceGroup.add(p: P): P { + return p.apply { + this.isIconSpaceReserved = false + addPreference(this) + } +} + inline fun

PreferenceGroup.initThenAdd(p: P, block: P.() -> Unit): P { - return p.apply { block(); addPreference(this); } + return p.apply { + block() + this.isIconSpaceReserved = false + addPreference(this) + } } inline fun

PreferenceGroup.addThenInit(p: P, block: P.() -> Unit): P { - return p.apply { addPreference(this); block() } + return p.apply { + this.isIconSpaceReserved = false + addPreference(this) + block() + } } inline fun Preference.onClick(crossinline block: () -> Unit) { @@ -77,28 +105,42 @@ inline fun Preference.onChange(crossinline block: (Any?) -> Boolean) { var Preference.defaultValue: Any? get() = null // set only - set(value) { setDefaultValue(value) } + set(value) { + setDefaultValue(value) + } var Preference.titleRes: Int get() = 0 // set only - set(value) { setTitle(value) } + set(value) { + setTitle(value) + } var Preference.iconRes: Int get() = 0 // set only - set(value) { icon = VectorDrawableCompat.create(context.resources, value, context.theme) } + set(value) { + icon = VectorDrawableCompat.create(context.resources, value, context.theme) + } var Preference.summaryRes: Int get() = 0 // set only - set(value) { setSummary(value) } + set(value) { + setSummary(value) + } var Preference.iconTint: Int get() = 0 // set only - set(value) { DrawableCompat.setTint(icon, value) } + set(value) { + DrawableCompat.setTint(icon, value) + } var ListPreference.entriesRes: Array get() = emptyArray() // set only - set(value) { entries = value.map { context.getString(it) }.toTypedArray() } + set(value) { + entries = value.map { context.getString(it) }.toTypedArray() + } var MultiSelectListPreference.entriesRes: Array get() = emptyArray() // set only - set(value) { entries = value.map { context.getString(it) }.toTypedArray() } + set(value) { + entries = value.map { context.getString(it) }.toTypedArray() + } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt old mode 100755 new mode 100644 similarity index 76% rename from app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt rename to app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt index 4f0375e42..d44a63b5d --- a/app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt @@ -1,13 +1,13 @@ -package eu.kanade.tachiyomi.util +package eu.kanade.tachiyomi.util.storage import android.content.Context import android.content.Intent import android.net.Uri -import android.os.Build import android.os.Environment import androidx.core.content.ContextCompat import androidx.core.os.EnvironmentCompat import com.hippo.unifile.UniFile +import eu.kanade.tachiyomi.util.lang.Hash import java.io.File object DiskUtil { @@ -34,23 +34,16 @@ object DiskUtil { fun getExternalStorages(context: Context): Collection { val directories = mutableSetOf() directories += ContextCompat.getExternalFilesDirs(context, null) - .filterNotNull() - .mapNotNull { - val file = File(it.absolutePath.substringBefore("/Android/")) - val state = EnvironmentCompat.getStorageState(file) - if (state == Environment.MEDIA_MOUNTED || state == Environment.MEDIA_MOUNTED_READ_ONLY) { - file - } else { - null - } + .filterNotNull() + .mapNotNull { + val file = File(it.absolutePath.substringBefore("/Android/")) + val state = EnvironmentCompat.getStorageState(file) + if (state == Environment.MEDIA_MOUNTED || state == Environment.MEDIA_MOUNTED_READ_ONLY) { + file + } else { + null } - - if (Build.VERSION.SDK_INT < 21) { - val extStorages = System.getenv("SECONDARY_STORAGE") - if (extStorages != null) { - directories += extStorages.split(":").map(::File) } - } return directories } @@ -79,11 +72,7 @@ object DiskUtil { * Scans the given file so that it can be shown in gallery apps, for example. */ fun scanMedia(context: Context, uri: Uri) { - val action = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { - Intent.ACTION_MEDIA_MOUNTED - } else { - Intent.ACTION_MEDIA_SCANNER_SCAN_FILE - } + val action = Intent.ACTION_MEDIA_SCANNER_SCAN_FILE val mediaScanIntent = Intent(action) mediaScanIntent.data = uri context.sendBroadcast(mediaScanIntent) @@ -96,7 +85,7 @@ object DiskUtil { */ fun buildValidFilename(origName: String): String { val name = origName.trim('.', ' ') - if (name.isNullOrEmpty()) { + if (name.isEmpty()) { return "(invalid)" } val sb = StringBuilder(name.length) @@ -125,4 +114,3 @@ object DiskUtil { } } } - diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/storage/EpubFile.kt b/app/src/main/java/eu/kanade/tachiyomi/util/storage/EpubFile.kt new file mode 100644 index 000000000..7f1e966f6 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/storage/EpubFile.kt @@ -0,0 +1,215 @@ +package eu.kanade.tachiyomi.util.storage + +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import java.io.Closeable +import java.io.File +import java.io.InputStream +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import org.jsoup.Jsoup +import org.jsoup.nodes.Document + +/** + * Wrapper over ZipFile to load files in epub format. + */ +class EpubFile(file: File) : Closeable { + + /** + * Zip file of this epub. + */ + private val zip = ZipFile(file) + + /** + * Path separator used by this epub. + */ + private val pathSeparator = getPathSeparator() + + /** + * Closes the underlying zip file. + */ + override fun close() { + zip.close() + } + + /** + * Returns an input stream for reading the contents of the specified zip file entry. + */ + fun getInputStream(entry: ZipEntry): InputStream { + return zip.getInputStream(entry) + } + + /** + * Returns the zip file entry for the specified name, or null if not found. + */ + fun getEntry(name: String): ZipEntry? { + return zip.getEntry(name) + } + + /** + * Fills manga metadata using this epub file's metadata. + */ + fun fillMangaMetadata(manga: SManga) { + val ref = getPackageHref() + val doc = getPackageDocument(ref) + + val creator = doc.getElementsByTag("dc:creator").first() + val description = doc.getElementsByTag("dc:description").first() + + manga.author = creator?.text() + manga.description = description?.text() + } + + /** + * Fills chapter metadata using this epub file's metadata. + */ + fun fillChapterMetadata(chapter: SChapter) { + val ref = getPackageHref() + val doc = getPackageDocument(ref) + + val title = doc.getElementsByTag("dc:title").first() + val publisher = doc.getElementsByTag("dc:publisher").first() + val creator = doc.getElementsByTag("dc:creator").first() + var date = doc.getElementsByTag("dc:date").first() + if (date == null) { + date = doc.select("meta[property=dcterms:modified]").first() + } + + if (title != null) { + chapter.name = title.text() + } + + if (publisher != null) { + chapter.scanlator = publisher.text() + } else if (creator != null) { + chapter.scanlator = creator.text() + } + + if (date != null) { + val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()) + try { + val parsedDate = dateFormat.parse(date.text()) + if (parsedDate != null) { + chapter.date_upload = parsedDate.time + } + } catch (e: ParseException) { + // Empty + } + } + } + + /** + * Returns the path of all the images found in the epub file. + */ + fun getImagesFromPages(): List { + val ref = getPackageHref() + val doc = getPackageDocument(ref) + val pages = getPagesFromDocument(doc) + return getImagesFromPages(pages, ref) + } + + /** + * Returns the path to the package document. + */ + private fun getPackageHref(): String { + val meta = zip.getEntry(resolveZipPath("META-INF", "container.xml")) + if (meta != null) { + val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") } + val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path") + if (path != null) { + return path + } + } + return resolveZipPath("OEBPS", "content.opf") + } + + /** + * Returns the package document where all the files are listed. + */ + private fun getPackageDocument(ref: String): Document { + val entry = zip.getEntry(ref) + return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") } + } + + /** + * Returns all the pages from the epub. + */ + private fun getPagesFromDocument(document: Document): List { + val pages = document.select("manifest > item") + .filter { "application/xhtml+xml" == it.attr("media-type") } + .associateBy { it.attr("id") } + + val spine = document.select("spine > itemref").map { it.attr("idref") } + return spine.mapNotNull { pages[it] }.map { it.attr("href") } + } + + /** + * Returns all the images contained in every page from the epub. + */ + private fun getImagesFromPages(pages: List, packageHref: String): List { + val result = ArrayList() + val basePath = getParentDirectory(packageHref) + pages.forEach { page -> + val entryPath = resolveZipPath(basePath, page) + val entry = zip.getEntry(entryPath) + val document = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") } + val imageBasePath = getParentDirectory(entryPath) + + document.allElements.forEach { + if (it.tagName() == "img") { + result.add(resolveZipPath(imageBasePath, it.attr("src"))) + } else if (it.tagName() == "image") { + result.add(resolveZipPath(imageBasePath, it.attr("xlink:href"))) + } + } + } + + return result + } + + /** + * Returns the path separator used by the epub file. + */ + private fun getPathSeparator(): String { + val meta = zip.getEntry("META-INF\\container.xml") + if (meta != null) { + return "\\" + } else { + return "/" + } + } + + /** + * Resolves a zip path from base and relative components and a path separator. + */ + private fun resolveZipPath(basePath: String, relativePath: String): String { + if (relativePath.startsWith(pathSeparator)) { + // Path is absolute, so return as-is. + return relativePath + } + + var fixedBasePath = basePath.replace(pathSeparator, File.separator) + if (!fixedBasePath.startsWith(File.separator)) { + fixedBasePath = "${File.separator}$fixedBasePath" + } + + val fixedRelativePath = relativePath.replace(pathSeparator, File.separator) + val resolvedPath = File(fixedBasePath, fixedRelativePath).canonicalPath + return resolvedPath.replace(File.separator, pathSeparator).substring(1) + } + + /** + * Gets the parent directory of a path. + */ + private fun getParentDirectory(path: String): String { + val separatorIndex = path.lastIndexOf(pathSeparator) + if (separatorIndex >= 0) { + return path.substring(0, separatorIndex) + } else { + return "" + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/FileExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/storage/FileExtensions.kt old mode 100755 new mode 100644 similarity index 72% rename from app/src/main/java/eu/kanade/tachiyomi/util/FileExtensions.kt rename to app/src/main/java/eu/kanade/tachiyomi/util/storage/FileExtensions.kt index 98fe9bfbb..6ca1bd646 --- a/app/src/main/java/eu/kanade/tachiyomi/util/FileExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/storage/FileExtensions.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.util +package eu.kanade.tachiyomi.util.storage import android.content.Context import android.net.Uri @@ -13,8 +13,9 @@ import java.io.File * @param context context of application */ fun File.getUriCompat(context: Context): Uri { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", this) - else Uri.fromFile(this) + } else { + Uri.fromFile(this) + } } - diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/OkioExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/storage/OkioExtensions.kt old mode 100755 new mode 100644 similarity index 95% rename from app/src/main/java/eu/kanade/tachiyomi/util/OkioExtensions.kt rename to app/src/main/java/eu/kanade/tachiyomi/util/storage/OkioExtensions.kt index 7556f18df..0eac12d48 --- a/app/src/main/java/eu/kanade/tachiyomi/util/OkioExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/storage/OkioExtensions.kt @@ -1,10 +1,10 @@ -package eu.kanade.tachiyomi.util +package eu.kanade.tachiyomi.util.storage +import java.io.File +import java.io.OutputStream import okio.BufferedSource import okio.buffer import okio.sink -import java.io.File -import java.io.OutputStream /** * Saves the given source to a file and closes it. Directories will be created if needed. diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/ActivityExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/ActivityExtensions.kt new file mode 100644 index 000000000..c654daf4c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/ActivityExtensions.kt @@ -0,0 +1,34 @@ +package eu.kanade.tachiyomi.util.system + +import android.app.Activity +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.lang.truncateCenter + +/** + * Copies a string to clipboard + * + * @param label Label to show to the user describing the content + * @param content the actual text to copy to the board + */ +fun Activity.copyToClipboard(label: String, content: String) { + if (content.isBlank()) return + + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText(label, content)) + + toast(getString(R.string.copied_to_clipboard, content.truncateCenter(50))) +} + +/** + * Checks whether if the device has a display cutout (i.e. notch, camera cutout, etc.). + * + * Only works in Android 9+. + */ +fun Activity.hasDisplayCutout(): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && + window.decorView.rootWindowInsets?.displayCutout != null +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/ContextExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt old mode 100755 new mode 100644 similarity index 67% rename from app/src/main/java/eu/kanade/tachiyomi/util/ContextExtensions.kt rename to app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt index 426c33070..00355e6f5 --- a/app/src/main/java/eu/kanade/tachiyomi/util/ContextExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt @@ -1,34 +1,32 @@ -package eu.kanade.tachiyomi.util +package eu.kanade.tachiyomi.util.system import android.app.ActivityManager import android.app.Notification import android.app.NotificationManager -import android.app.job.JobScheduler -import android.content.* -import android.content.Context.VIBRATOR_SERVICE +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager import android.content.res.Resources +import android.graphics.Color import android.net.ConnectivityManager import android.net.Uri import android.net.wifi.WifiManager import android.os.Build import android.os.PowerManager -import android.os.VibrationEffect -import android.os.Vibrator import android.widget.Toast import androidx.annotation.AttrRes -import androidx.annotation.RequiresApi +import androidx.annotation.ColorInt import androidx.annotation.StringRes import androidx.browser.customtabs.CustomTabsIntent import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat +import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.nononsenseapps.filepicker.FilePickerActivity import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.widget.CustomLayoutPickerActivity -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch - +import kotlin.math.roundToInt /** * Display a toast in this context. @@ -54,16 +52,31 @@ fun Context.toast(text: String?, duration: Int = Toast.LENGTH_SHORT) { } } +/** + * Helper method to create a notification builder. + * + * @param id the channel id. + * @param block the function that will execute inside the builder. + * @return a notification to be displayed or updated. + */ +fun Context.notificationBuilder(channelId: String, block: (NotificationCompat.Builder.() -> Unit)? = null): NotificationCompat.Builder { + val builder = NotificationCompat.Builder(this, channelId) + .setColor(ContextCompat.getColor(this, R.color.colorPrimary)) + if (block != null) { + builder.block() + } + return builder +} + /** * Helper method to create a notification. * * @param id the channel id. - * @param func the function that will execute inside the builder. + * @param block the function that will execute inside the builder. * @return a notification to be displayed or updated. */ -inline fun Context.notification(channelId: String, func: NotificationCompat.Builder.() -> Unit): Notification { - val builder = NotificationCompat.Builder(this, channelId) - builder.func() +fun Context.notification(channelId: String, block: (NotificationCompat.Builder.() -> Unit)?): Notification { + val builder = notificationBuilder(channelId, block) return builder.build() } @@ -74,10 +87,10 @@ inline fun Context.notification(channelId: String, func: NotificationCompat.Buil */ fun Context.getFilePicker(currentDir: String): Intent { return Intent(this, CustomLayoutPickerActivity::class.java) - .putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false) - .putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true) - .putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR) - .putExtra(FilePickerActivity.EXTRA_START_PATH, currentDir) + .putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false) + .putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true) + .putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR) + .putExtra(FilePickerActivity.EXTRA_START_PATH, currentDir) } /** @@ -86,19 +99,28 @@ fun Context.getFilePicker(currentDir: String): Intent { * @param permission the permission to check. * @return true if it has permissions. */ -fun Context.hasPermission(permission: String) - = ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED +fun Context.hasPermission(permission: String) = ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED /** * Returns the color for the given attribute. * * @param resource the attribute. + * @param alphaFactor the alpha number [0,1]. */ -fun Context.getResourceColor(@AttrRes resource: Int): Int { +@ColorInt fun Context.getResourceColor(@AttrRes resource: Int, alphaFactor: Float = 1f): Int { val typedArray = obtainStyledAttributes(intArrayOf(resource)) - val attrValue = typedArray.getColor(0, 0) + val color = typedArray.getColor(0, 0) typedArray.recycle() - return attrValue + + if (alphaFactor < 1f) { + val alpha = (Color.alpha(color) * alphaFactor).roundToInt() + val red = Color.red(color) + val green = Color.green(color) + val blue = Color.blue(color) + return Color.argb(alpha, red, green, blue) + } + + return color } /** @@ -142,7 +164,6 @@ val Context.clipboardManager: ClipboardManager get() = applicationContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val Context.jobScheduler: JobScheduler - @RequiresApi(Build.VERSION_CODES.LOLLIPOP) get() = applicationContext.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler // <-- EH @@ -152,7 +173,7 @@ val Context.jobScheduler: JobScheduler * @param intent intent that contains broadcast information */ fun Context.sendLocalBroadcast(intent: Intent) { - androidx.localbroadcastmanager.content.LocalBroadcastManager.getInstance(this).sendBroadcast(intent) + LocalBroadcastManager.getInstance(this).sendBroadcast(intent) } /** @@ -161,7 +182,7 @@ fun Context.sendLocalBroadcast(intent: Intent) { * @param intent intent that contains broadcast information */ fun Context.sendLocalBroadcastSync(intent: Intent) { - androidx.localbroadcastmanager.content.LocalBroadcastManager.getInstance(this).sendBroadcastSync(intent) + LocalBroadcastManager.getInstance(this).sendBroadcastSync(intent) } /** @@ -170,7 +191,7 @@ fun Context.sendLocalBroadcastSync(intent: Intent) { * @param receiver receiver that gets registered. */ fun Context.registerLocalReceiver(receiver: BroadcastReceiver, filter: IntentFilter) { - androidx.localbroadcastmanager.content.LocalBroadcastManager.getInstance(this).registerReceiver(receiver, filter) + LocalBroadcastManager.getInstance(this).registerReceiver(receiver, filter) } /** @@ -179,7 +200,7 @@ fun Context.registerLocalReceiver(receiver: BroadcastReceiver, filter: IntentFil * @param receiver receiver that gets unregistered. */ fun Context.unregisterLocalReceiver(receiver: BroadcastReceiver) { - androidx.localbroadcastmanager.content.LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver) + LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver) } /** @@ -190,7 +211,7 @@ fun Context.isServiceRunning(serviceClass: Class<*>): Boolean { val manager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager @Suppress("DEPRECATION") return manager.getRunningServices(Integer.MAX_VALUE) - .any { className == it.service.className } + .any { className == it.service.className } } /** @@ -200,19 +221,10 @@ fun Context.openInBrowser(url: String) { try { val parsedUrl = Uri.parse(url) val intent = CustomTabsIntent.Builder() - .setToolbarColor(getResourceColor(R.attr.colorPrimary)) - .build() + .setToolbarColor(getResourceColor(R.attr.colorPrimary)) + .build() intent.launchUrl(this, parsedUrl) } catch (e: Exception) { toast(e.message) } } - -fun Context.vibrate(time: Long) { - val vibeService = getSystemService(VIBRATOR_SERVICE) as Vibrator - if (Build.VERSION.SDK_INT >= 26) { - vibeService.vibrate(VibrationEffect.createOneShot(time, VibrationEffect.DEFAULT_AMPLITUDE)) - } else { - vibeService.vibrate(time) - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/GLUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/GLUtil.kt new file mode 100644 index 000000000..739ce709d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/GLUtil.kt @@ -0,0 +1,54 @@ +package eu.kanade.tachiyomi.util.system + +import javax.microedition.khronos.egl.EGL10 +import javax.microedition.khronos.egl.EGLConfig +import javax.microedition.khronos.egl.EGLContext +import kotlin.math.max + +class GLUtil private constructor() { + companion object { + // Safe minimum default size + private const val IMAGE_MAX_BITMAP_DIMENSION = 2048 + + val maxTextureSize: Int + get() { + // Get EGL Display + val egl = EGLContext.getEGL() as EGL10 + val display = egl.eglGetDisplay(EGL10.EGL_DEFAULT_DISPLAY) + + // Initialise + val version = IntArray(2) + egl.eglInitialize(display, version) + + // Query total number of configurations + val totalConfigurations = IntArray(1) + egl.eglGetConfigs(display, null, 0, totalConfigurations) + + // Query actual list configurations + val configurationsList = arrayOfNulls(totalConfigurations[0]) + egl.eglGetConfigs(display, configurationsList, totalConfigurations[0], totalConfigurations) + + val textureSize = IntArray(1) + var maximumTextureSize = 0 + + // Iterate through all the configurations to located the maximum texture size + for (i in 0 until totalConfigurations[0]) { + // Only need to check for width since opengl textures are always squared + egl.eglGetConfigAttrib(display, configurationsList[i], EGL10.EGL_MAX_PBUFFER_WIDTH, textureSize) + + // Keep track of the maximum texture size + if (maximumTextureSize < textureSize[0]) maximumTextureSize = textureSize[0] + } + + // Release + egl.eglTerminate(display) + + // Return largest texture size found, or default + return max(maximumTextureSize, IMAGE_MAX_BITMAP_DIMENSION) + } + } + + init { + throw InstantiationException("This class is not for instantiation") + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/ImageUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/ImageUtil.kt similarity index 91% rename from app/src/main/java/eu/kanade/tachiyomi/util/ImageUtil.kt rename to app/src/main/java/eu/kanade/tachiyomi/util/system/ImageUtil.kt index fa879d4b4..b519a3361 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/ImageUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/ImageUtil.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.util +package eu.kanade.tachiyomi.util.system import java.io.InputStream import java.net.URLConnection @@ -29,8 +29,9 @@ object ImageUtil { stream.read(bytes, 0, bytes.size) } - if (length == -1) + if (length == -1) { return null + } if (bytes.compareWith(charByteArrayOf(0xFF, 0xD8, 0xFF))) { return ImageType.JPG @@ -44,13 +45,13 @@ object ImageUtil { if (bytes.compareWith("RIFF".toByteArray())) { return ImageType.WEBP } - } catch(e: Exception) { + } catch (e: Exception) { } return null } private fun ByteArray.compareWith(magic: ByteArray): Boolean { - for (i in 0 until magic.size) { + for (i in magic.indices) { if (this[i] != magic[i]) return false } return true @@ -58,7 +59,7 @@ object ImageUtil { private fun charByteArrayOf(vararg bytes: Int): ByteArray { return ByteArray(bytes.size).apply { - for (i in 0 until bytes.size) { + for (i in bytes.indices) { set(i, bytes[i].toByte()) } } @@ -70,5 +71,4 @@ object ImageUtil { GIF("image/gif", "gif"), WEBP("image/webp", "webp") } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/LocaleHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/LocaleHelper.kt old mode 100755 new mode 100644 similarity index 82% rename from app/src/main/java/eu/kanade/tachiyomi/util/LocaleHelper.kt rename to app/src/main/java/eu/kanade/tachiyomi/util/system/LocaleHelper.kt index 14e6726e3..5ae9383fa --- a/app/src/main/java/eu/kanade/tachiyomi/util/LocaleHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/LocaleHelper.kt @@ -1,15 +1,15 @@ -package eu.kanade.tachiyomi.util +package eu.kanade.tachiyomi.util.system import android.app.Application import android.content.Context import android.content.res.Configuration import android.os.Build -import android.os.LocaleList import android.view.ContextThemeWrapper import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.ui.browse.source.SourcePresenter +import java.util.Locale import uy.kohesive.injekt.injectLazy -import java.util.* /** * Utility class to change the application's language in runtime. @@ -17,14 +17,8 @@ import java.util.* @Suppress("DEPRECATION") object LocaleHelper { - /** - * Preferences helper. - */ private val preferences: PreferencesHelper by injectLazy() - /** - * The system's locale. - */ private var systemLocale: Locale? = null /** @@ -43,7 +37,7 @@ object LocaleHelper { * * @param pref the string value stored in preferences. */ - fun getLocaleFromString(pref: String): Locale? { + fun getLocaleFromString(pref: String?): Locale? { if (pref.isNullOrEmpty()) { return null } @@ -53,11 +47,25 @@ object LocaleHelper { /** * Returns Display name of a string language code */ - fun getDisplayName(lang: String?, context: Context): String { + fun getSourceDisplayName(lang: String?, context: Context): String { + return when (lang) { + "" -> context.getString(R.string.other_source) + SourcePresenter.LAST_USED_KEY -> context.getString(R.string.last_used_source) + SourcePresenter.PINNED_KEY -> context.getString(R.string.pinned_sources) + "all" -> context.getString(R.string.all_lang) + else -> getDisplayName(lang) + } + } + + /** + * Returns Display name of a string language code + */ + fun getDisplayName(lang: String?): String { return when (lang) { null -> "" - "" -> context.getString(R.string.other_source) - "all" -> context.getString(R.string.all_lang) + "" -> { + systemLocale!!.getDisplayName(systemLocale).capitalize() + } else -> { val locale = getLocale(lang) locale.getDisplayName(locale).capitalize() @@ -65,8 +73,8 @@ object LocaleHelper { } } - /*Return Locale from string language code - + /** + * Return Locale from string language code */ private fun getLocale(lang: String): Locale { val sp = lang.split("_", "-") @@ -90,7 +98,7 @@ object LocaleHelper { * Updates the app's language to an activity. */ fun updateConfiguration(wrapper: ContextThemeWrapper) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1 && appLocale != null) { + if (appLocale != null) { val config = Configuration(preferences.context.resources.configuration) config.setLocale(appLocale) wrapper.applyOverrideConfiguration(config) @@ -138,9 +146,8 @@ object LocaleHelper { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { newConfig.locale = locale } else { - newConfig.locales = LocaleList(locale) + newConfig.setLocale(locale) } return newConfig } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/WebViewClientCompat.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewClientCompat.kt similarity index 54% rename from app/src/main/java/eu/kanade/tachiyomi/util/WebViewClientCompat.kt rename to app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewClientCompat.kt index 977dca5e6..d32cc1141 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/WebViewClientCompat.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewClientCompat.kt @@ -1,8 +1,12 @@ -package eu.kanade.tachiyomi.util +package eu.kanade.tachiyomi.util.system import android.annotation.TargetApi import android.os.Build -import android.webkit.* +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient @Suppress("OverridingDeprecatedMember") abstract class WebViewClientCompat : WebViewClient() { @@ -16,18 +20,18 @@ abstract class WebViewClientCompat : WebViewClient() { } open fun onReceivedErrorCompat( - view: WebView, - errorCode: Int, - description: String?, - failingUrl: String, - isMainFrame: Boolean) { - + view: WebView, + errorCode: Int, + description: String?, + failingUrl: String, + isMainFrame: Boolean + ) { } @TargetApi(Build.VERSION_CODES.N) final override fun shouldOverrideUrlLoading( - view: WebView, - request: WebResourceRequest + view: WebView, + request: WebResourceRequest ): Boolean { return shouldOverrideUrlCompat(view, request.url.toString()) } @@ -36,48 +40,52 @@ abstract class WebViewClientCompat : WebViewClient() { return shouldOverrideUrlCompat(view, url) } - @TargetApi(Build.VERSION_CODES.LOLLIPOP) final override fun shouldInterceptRequest( - view: WebView, - request: WebResourceRequest + view: WebView, + request: WebResourceRequest ): WebResourceResponse? { return shouldInterceptRequestCompat(view, request.url.toString()) } final override fun shouldInterceptRequest( - view: WebView, - url: String + view: WebView, + url: String ): WebResourceResponse? { return shouldInterceptRequestCompat(view, url) } @TargetApi(Build.VERSION_CODES.M) final override fun onReceivedError( - view: WebView, - request: WebResourceRequest, - error: WebResourceError + view: WebView, + request: WebResourceRequest, + error: WebResourceError ) { - onReceivedErrorCompat(view, error.errorCode, error.description?.toString(), - request.url.toString(), request.isForMainFrame) + onReceivedErrorCompat( + view, error.errorCode, error.description?.toString(), + request.url.toString(), request.isForMainFrame + ) } final override fun onReceivedError( - view: WebView, - errorCode: Int, - description: String?, - failingUrl: String + view: WebView, + errorCode: Int, + description: String?, + failingUrl: String ) { onReceivedErrorCompat(view, errorCode, description, failingUrl, failingUrl == view.url) } @TargetApi(Build.VERSION_CODES.M) final override fun onReceivedHttpError( - view: WebView, - request: WebResourceRequest, - error: WebResourceResponse + view: WebView, + request: WebResourceRequest, + error: WebResourceResponse ) { - onReceivedErrorCompat(view, error.statusCode, error.reasonPhrase, request.url - .toString(), request.isForMainFrame) + onReceivedErrorCompat( + view, error.statusCode, error.reasonPhrase, + request.url + .toString(), + request.isForMainFrame + ) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt new file mode 100644 index 000000000..3eeb60bc3 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt @@ -0,0 +1,41 @@ +package eu.kanade.tachiyomi.util.system + +import android.content.Context +import android.content.pm.PackageManager +import android.webkit.WebView + +object WebViewUtil { + val WEBVIEW_UA_VERSION_REGEX by lazy { + Regex(""".*Chrome/(\d+)\..*""") + } + + const val MINIMUM_WEBVIEW_VERSION = 79 + + fun supportsWebView(context: Context): Boolean { + return context.packageManager.hasSystemFeature(PackageManager.FEATURE_WEBVIEW) + } +} + +fun WebView.isOutdated(): Boolean { + return getWebViewMajorVersion(this) < WebViewUtil.MINIMUM_WEBVIEW_VERSION +} + +// Based on https://stackoverflow.com/a/29218966 +private fun getWebViewMajorVersion(webview: WebView): Int { + val originalUA: String = webview.settings.userAgentString + + // Next call to getUserAgentString() will get us the default + webview.settings.userAgentString = null + + val uaRegexMatch = WebViewUtil.WEBVIEW_UA_VERSION_REGEX.matchEntire(webview.settings.userAgentString) + val webViewVersion: Int = if (uaRegexMatch != null && uaRegexMatch.groupValues.size > 1) { + uaRegexMatch.groupValues[1].toInt() + } else { + 0 + } + + // Revert to original UA string + webview.settings.userAgentString = originalUA + + return webViewVersion +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/ImageViewExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/view/ImageViewExtensions.kt old mode 100755 new mode 100644 similarity index 93% rename from app/src/main/java/eu/kanade/tachiyomi/util/ImageViewExtensions.kt rename to app/src/main/java/eu/kanade/tachiyomi/util/view/ImageViewExtensions.kt index 4c9d5124c..f0b81ff48 --- a/app/src/main/java/eu/kanade/tachiyomi/util/ImageViewExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/view/ImageViewExtensions.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.util +package eu.kanade.tachiyomi.util.view import android.widget.ImageView import androidx.annotation.DrawableRes diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt new file mode 100644 index 000000000..82130b8de --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt @@ -0,0 +1,118 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package eu.kanade.tachiyomi.util.view + +import android.graphics.Color +import android.graphics.Point +import android.view.Gravity +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.widget.TextView +import androidx.annotation.MenuRes +import androidx.appcompat.widget.PopupMenu +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.chip.Chip +import com.google.android.material.chip.ChipGroup +import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton +import com.google.android.material.snackbar.Snackbar +import eu.kanade.tachiyomi.R + +/** + * Returns coordinates of view. + * Used for animation + * + * @return coordinates of view + */ +fun View.getCoordinates() = Point((left + right) / 2, (top + bottom) / 2) + +/** + * Shows a snackbar in this view. + * + * @param message the message to show. + * @param length the duration of the snack. + * @param f a function to execute in the snack, allowing for example to define a custom action. + */ +inline fun View.snack(message: String, length: Int = Snackbar.LENGTH_LONG, f: Snackbar.() -> Unit): Snackbar { + val snack = Snackbar.make(this, message, length) + val textView: TextView = snack.view.findViewById(com.google.android.material.R.id.snackbar_text) + textView.setTextColor(Color.WHITE) + snack.f() + snack.show() + return snack +} + +/** + * Shows a popup menu on top of this view. + * + * @param menuRes menu items to inflate the menu with. + * @param initMenu function to execute when the menu after is inflated. + * @param onMenuItemClick function to execute when a menu item is clicked. + */ +fun View.popupMenu(@MenuRes menuRes: Int, initMenu: (Menu.() -> Unit)? = null, onMenuItemClick: MenuItem.() -> Boolean) { + val popup = PopupMenu(context, this, Gravity.NO_GRAVITY, R.attr.actionOverflowMenuStyle, 0) + popup.menuInflater.inflate(menuRes, popup.menu) + + if (initMenu != null) { + popup.menu.initMenu() + } + popup.setOnMenuItemClickListener { it.onMenuItemClick() } + + popup.show() +} + +inline fun View.visible() { + visibility = View.VISIBLE +} + +inline fun View.invisible() { + visibility = View.INVISIBLE +} + +inline fun View.gone() { + visibility = View.GONE +} + +inline fun View.visibleIf(block: () -> Boolean) { + visibility = if (block()) View.VISIBLE else View.GONE +} + +inline fun View.toggle() { + visibleIf { visibility == View.GONE } +} + +/** + * Shrink an ExtendedFloatingActionButton when the associated RecyclerView is scrolled down. + * + * @param recycler [RecyclerView] that the FAB should shrink/extend in response to. + */ +fun ExtendedFloatingActionButton.shrinkOnScroll(recycler: RecyclerView) { + recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + if (dy <= 0) { + extend() + } else { + shrink() + } + } + }) +} + +/** + * Replaces chips in a ChipGroup. + * + * @param items List of strings that are shown as individual chips. + * @param onClick Optional on click listener for each chip. + */ +fun ChipGroup.setChips(items: List?, onClick: (item: String) -> Unit = {}) { + removeAllViews() + + items?.forEach { item -> + val chip = Chip(context).apply { + text = item + setOnClickListener { onClick(item) } + } + + addView(chip) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/ViewGroupExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewGroupExtensions.kt old mode 100755 new mode 100644 similarity index 92% rename from app/src/main/java/eu/kanade/tachiyomi/util/ViewGroupExtensions.kt rename to app/src/main/java/eu/kanade/tachiyomi/util/view/ViewGroupExtensions.kt index 553900943..df628d445 --- a/app/src/main/java/eu/kanade/tachiyomi/util/ViewGroupExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewGroupExtensions.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.util +package eu.kanade.tachiyomi.util.view import android.view.LayoutInflater import android.view.View diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/view/WindowExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/view/WindowExtensions.kt new file mode 100644 index 000000000..3ae882af3 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/view/WindowExtensions.kt @@ -0,0 +1,26 @@ +package eu.kanade.tachiyomi.util.view + +import android.view.View +import android.view.Window + +fun Window.showBar() { + val uiFlags = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + decorView.systemUiVisibility = uiFlags +} + +fun Window.hideBar() { + val uiFlags = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_FULLSCREEN or + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + decorView.systemUiVisibility = uiFlags +} + +fun Window.defaultBar() { + decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_VISIBLE +} + +fun Window.isDefaultBar() = decorView.systemUiVisibility == View.SYSTEM_UI_FLAG_VISIBLE diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/ActionToolbar.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/ActionToolbar.kt new file mode 100644 index 000000000..c25cb1539 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/ActionToolbar.kt @@ -0,0 +1,60 @@ +package eu.kanade.tachiyomi.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.MenuItem +import android.widget.FrameLayout +import androidx.annotation.IdRes +import androidx.annotation.MenuRes +import androidx.appcompat.view.ActionMode +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.view.gone +import eu.kanade.tachiyomi.util.view.visible +import kotlinx.android.synthetic.main.common_action_toolbar.view.common_action_menu +import kotlinx.android.synthetic.main.common_action_toolbar.view.common_action_toolbar + +/** + * A toolbar holding only menu items. + */ +class ActionToolbar @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + FrameLayout(context, attrs) { + + init { + inflate(context, R.layout.common_action_toolbar, this) + } + + /** + * Remove menu items and remove listener. + */ + fun destroy() { + common_action_menu.menu.clear() + common_action_menu.setOnMenuItemClickListener(null) + } + + /** + * Gets a menu item if found. + */ + fun findItem(@IdRes itemId: Int): MenuItem? { + return common_action_menu.menu.findItem(itemId) + } + + /** + * Show the menu toolbar using the provided ActionMode's context to inflate the items. + */ + fun show(mode: ActionMode, @MenuRes menuRes: Int, listener: (item: MenuItem?) -> Boolean) { + // Avoid re-inflating the menu + if (common_action_menu.menu.size() == 0) { + mode.menuInflater.inflate(menuRes, common_action_menu.menu) + common_action_menu.setOnMenuItemClickListener { listener(it) } + } + + common_action_toolbar.visible() + } + + /** + * Hide the menu toolbar. + */ + fun hide() { + common_action_toolbar.gone() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/AutofitRecyclerView.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/AutofitRecyclerView.kt index f619e13e5..565dfbe3c 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/AutofitRecyclerView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/AutofitRecyclerView.kt @@ -2,11 +2,14 @@ package eu.kanade.tachiyomi.widget import android.content.Context import android.util.AttributeSet +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import kotlin.math.max class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : - androidx.recyclerview.widget.RecyclerView(context, attrs) { + RecyclerView(context, attrs) { - private val manager = androidx.recyclerview.widget.GridLayoutManager(context, 1) + private val manager = GridLayoutManager(context, 1) private var columnWidth = -1 @@ -35,9 +38,8 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att override fun onMeasure(widthSpec: Int, heightSpec: Int) { super.onMeasure(widthSpec, heightSpec) if (spanCount == 0 && columnWidth > 0) { - val count = Math.max(1, measuredWidth / columnWidth) + val count = max(1, measuredWidth / columnWidth) spanCount = count } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/CustomLayoutPicker.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/CustomLayoutPicker.kt index 96befcf4d..40f7b8ba8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/CustomLayoutPicker.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/CustomLayoutPicker.kt @@ -1,32 +1,33 @@ package eu.kanade.tachiyomi.widget import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView import com.nononsenseapps.filepicker.AbstractFilePickerFragment import com.nononsenseapps.filepicker.FilePickerActivity import com.nononsenseapps.filepicker.FilePickerFragment import com.nononsenseapps.filepicker.LogicHandler import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.util.inflate +import eu.kanade.tachiyomi.util.view.inflate import java.io.File class CustomLayoutPickerActivity : FilePickerActivity() { override fun getFragment(startPath: String?, mode: Int, allowMultiple: Boolean, allowCreateDir: Boolean): - AbstractFilePickerFragment { - val fragment = CustomLayoutFilePickerFragment() - fragment.setArgs(startPath, mode, allowMultiple, allowCreateDir) - return fragment - } + AbstractFilePickerFragment { + val fragment = CustomLayoutFilePickerFragment() + fragment.setArgs(startPath, mode, allowMultiple, allowCreateDir) + return fragment + } } class CustomLayoutFilePickerFragment : FilePickerFragment() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): androidx.recyclerview.widget.RecyclerView.ViewHolder { - when (viewType) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { LogicHandler.VIEWTYPE_DIR -> { val view = parent.inflate(R.layout.common_listitem_dir) - return DirViewHolder(view) + DirViewHolder(view) } - else -> return super.onCreateViewHolder(parent, viewType) + else -> super.onCreateViewHolder(parent, viewType) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/DialogCheckboxView.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/DialogCheckboxView.kt index 454885007..4d5fd2569 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/DialogCheckboxView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/DialogCheckboxView.kt @@ -5,21 +5,22 @@ import android.util.AttributeSet import android.widget.LinearLayout import androidx.annotation.StringRes import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.util.inflate -import kotlinx.android.synthetic.main.common_dialog_with_checkbox.view.* +import eu.kanade.tachiyomi.util.view.inflate +import kotlinx.android.synthetic.main.common_dialog_with_checkbox.view.checkbox_option +import kotlinx.android.synthetic.main.common_dialog_with_checkbox.view.description class DialogCheckboxView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : - LinearLayout(context, attrs) { + LinearLayout(context, attrs) { init { addView(inflate(R.layout.common_dialog_with_checkbox)) } - fun setDescription(@StringRes id: Int){ + fun setDescription(@StringRes id: Int) { description.text = context.getString(id) } - fun setOptionDescription(@StringRes id: Int){ + fun setOptionDescription(@StringRes id: Int) { checkbox_option.text = context.getString(id) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/DialogCustomDownloadView.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/DialogCustomDownloadView.kt index 03896b900..60af36113 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/DialogCustomDownloadView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/DialogCustomDownloadView.kt @@ -6,15 +6,27 @@ import android.util.AttributeSet import android.view.View import android.widget.LinearLayout import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.util.inflate -import kotlinx.android.synthetic.main.download_custom_amount.view.* +import eu.kanade.tachiyomi.util.view.inflate +import kotlinx.android.synthetic.main.download_custom_amount.view.btn_decrease +import kotlinx.android.synthetic.main.download_custom_amount.view.btn_decrease_10 +import kotlinx.android.synthetic.main.download_custom_amount.view.btn_increase +import kotlinx.android.synthetic.main.download_custom_amount.view.btn_increase_10 +import kotlinx.android.synthetic.main.download_custom_amount.view.myNumber +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.android.widget.textChanges import timber.log.Timber /** * Custom dialog to select how many chapters to download. */ class DialogCustomDownloadView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : - LinearLayout(context, attrs) { + LinearLayout(context, attrs) { + + private val scope = CoroutineScope(Job() + Dispatchers.Main) /** * Current amount of custom download chooser. @@ -37,7 +49,6 @@ class DialogCustomDownloadView @JvmOverloads constructor(context: Context, attrs addView(inflate(R.layout.download_custom_amount)) } - /** * Called when view is added * @@ -70,16 +81,16 @@ class DialogCustomDownloadView @JvmOverloads constructor(context: Context, attrs } // When user inputs custom number set amount equal to input. - myNumber.addTextChangedListener(object : SimpleTextWatcher() { - override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + myNumber.textChanges() + .onEach { try { - amount = getAmount(Integer.parseInt(s.toString())) + amount = getAmount(Integer.parseInt(it.toString())) } catch (error: NumberFormatException) { // Catch NumberFormatException to prevent parse exception when input is empty. Timber.e(error) } } - }) + .launchIn(scope) } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/DrawerSwipeCloseListener.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/DrawerSwipeCloseListener.kt deleted file mode 100644 index 1c5c6a934..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/DrawerSwipeCloseListener.kt +++ /dev/null @@ -1,23 +0,0 @@ -package eu.kanade.tachiyomi.widget - -import android.view.View -import android.view.ViewGroup -import androidx.drawerlayout.widget.DrawerLayout - -class DrawerSwipeCloseListener( - private val drawer: androidx.drawerlayout.widget.DrawerLayout, - private val navigationView: ViewGroup -) : androidx.drawerlayout.widget.DrawerLayout.SimpleDrawerListener() { - - override fun onDrawerOpened(drawerView: View) { - if (drawerView == navigationView) { - drawer.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED, drawerView) - } - } - - override fun onDrawerClosed(drawerView: View) { - if (drawerView == navigationView) { - drawer.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED, drawerView) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/ElevationAppBarLayout.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/ElevationAppBarLayout.kt index 871a8f7da..838bcab8e 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/ElevationAppBarLayout.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/ElevationAppBarLayout.kt @@ -3,46 +3,40 @@ package eu.kanade.tachiyomi.widget import android.animation.ObjectAnimator import android.animation.StateListAnimator import android.content.Context -import android.os.Build import android.util.AttributeSet import com.google.android.material.R import com.google.android.material.appbar.AppBarLayout class ElevationAppBarLayout @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null + context: Context, + attrs: AttributeSet? = null ) : AppBarLayout(context, attrs) { private var origStateAnimator: StateListAnimator? = null init { - if (Build.VERSION.SDK_INT >= 21) { - origStateAnimator = stateListAnimator - } + origStateAnimator = stateListAnimator } fun enableElevation() { - if (Build.VERSION.SDK_INT >= 21) { - stateListAnimator = origStateAnimator - } + stateListAnimator = origStateAnimator } fun disableElevation() { - if (Build.VERSION.SDK_INT >= 21) { - stateListAnimator = StateListAnimator().apply { - val objAnimator = ObjectAnimator.ofFloat(this, "elevation", 0f) + stateListAnimator = StateListAnimator().apply { + val objAnimator = ObjectAnimator.ofFloat(this, "elevation", 0f) - // Enabled and collapsible, but not collapsed means not elevated - addState(intArrayOf(android.R.attr.enabled, R.attr.state_collapsible, -R.attr.state_collapsed), - objAnimator) + // Enabled and collapsible, but not collapsed means not elevated + addState( + intArrayOf(android.R.attr.enabled, R.attr.state_collapsible, -R.attr.state_collapsed), + objAnimator + ) - // Default enabled state - addState(intArrayOf(android.R.attr.enabled), objAnimator) + // Default enabled state + addState(intArrayOf(android.R.attr.enabled), objAnimator) - // Disabled state - addState(IntArray(0), objAnimator) - } + // Disabled state + addState(IntArray(0), objAnimator) } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/EmptyView.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/EmptyView.kt index 6715bb17d..fbe21cd99 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/EmptyView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/EmptyView.kt @@ -2,15 +2,20 @@ package eu.kanade.tachiyomi.widget import android.content.Context import android.util.AttributeSet -import android.view.View +import android.widget.LinearLayout import android.widget.RelativeLayout +import androidx.annotation.StringRes +import androidx.appcompat.widget.AppCompatButton import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.util.getResourceColor -import eu.kanade.tachiyomi.util.setVectorCompat -import kotlinx.android.synthetic.main.common_view_empty.view.* +import eu.kanade.tachiyomi.util.view.gone +import eu.kanade.tachiyomi.util.view.visible +import kotlin.random.Random +import kotlinx.android.synthetic.main.common_view_empty.view.actions_container +import kotlinx.android.synthetic.main.common_view_empty.view.text_face +import kotlinx.android.synthetic.main.common_view_empty.view.text_label class EmptyView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : - RelativeLayout (context, attrs) { + RelativeLayout(context, attrs) { init { inflate(context, R.layout.common_view_empty, this) @@ -20,17 +25,58 @@ class EmptyView @JvmOverloads constructor(context: Context, attrs: AttributeSet? * Hide the information view */ fun hide() { - this.visibility = View.GONE + this.gone() } /** * Show the information view - * @param drawable icon of information view * @param textResource text of information view */ - fun show(drawable: Int, textResource: Int) { - image_view.setVectorCompat(drawable, context.getResourceColor(android.R.attr.textColorHint)) - text_label.text = context.getString(textResource) - this.visibility = View.VISIBLE + fun show(@StringRes textResource: Int, actions: List? = null) { + show(context.getString(textResource), actions) } + + fun show(message: String, actions: List? = null) { + text_face.text = getRandomErrorFace() + text_label.text = message + + actions_container.removeAllViews() + if (!actions.isNullOrEmpty()) { + actions.forEach { + val button = AppCompatButton(context).apply { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + + setText(it.resId) + setOnClickListener(it.listener) + } + + actions_container.addView(button) + } + } + + this.visible() + } + + companion object { + private val ERROR_FACES = listOf( + "(・o・;)", + "Σ(ಠ_ಠ)", + "ಥ_ಥ", + "(˘・_・˘)", + "(; ̄Д ̄)", + "(・Д・。" + ) + + fun getRandomErrorFace(): String { + return ERROR_FACES[Random.nextInt(ERROR_FACES.size)] + } + } + + data class Action( + @StringRes val resId: Int, + val listener: OnClickListener + ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/ExtendedNavigationView.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/ExtendedNavigationView.kt index 2a5e095bc..b836e053b 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/ExtendedNavigationView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/ExtendedNavigationView.kt @@ -3,23 +3,23 @@ package eu.kanade.tachiyomi.widget import android.content.Context import android.graphics.drawable.Drawable import android.util.AttributeSet -import android.view.View import android.view.ViewGroup import androidx.annotation.CallSuper import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.util.getResourceColor +import eu.kanade.tachiyomi.util.system.getResourceColor /** - * An alternative implementation of [android.support.design.widget.NavigationView], without menu + * An alternative implementation of [com.google.android.material.navigation.NavigationView], without menu * inflation and allowing customizable items (multiple selections, custom views, etc). */ open class ExtendedNavigationView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0) - : SimpleNavigationView(context, attrs, defStyleAttr) { + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : SimpleNavigationView(context, attrs, defStyleAttr) { /** * Every item of the nav view. Generic items must belong to this list, custom items could be @@ -40,20 +40,20 @@ open class ExtendedNavigationView @JvmOverloads constructor( /** * A checkbox. */ - open class Checkbox(val resTitle: Int, var checked: Boolean = false) : Item() + open class Checkbox(val resTitle: Int, var checked: Boolean = false, var enabled: Boolean = true) : Item() /** * A checkbox belonging to a group. The group must handle selections and restrictions. */ - class CheckboxGroup(resTitle: Int, override val group: Group, checked: Boolean = false) - : Checkbox(resTitle, checked), GroupedItem + class CheckboxGroup(resTitle: Int, override val group: Group, checked: Boolean = false) : + Checkbox(resTitle, checked), GroupedItem /** * A radio belonging to a group (a sole radio makes no sense). The group must handle * selections and restrictions. */ - class Radio(val resTitle: Int, override val group: Group, var checked: Boolean = false) - : Item(), GroupedItem + class Radio(val resTitle: Int, override val group: Group, var checked: Boolean = false) : + Item(), GroupedItem /** * An item with which needs more than two states (selected/deselected). @@ -76,25 +76,14 @@ open class ExtendedNavigationView @JvmOverloads constructor( setTint(context.getResourceColor(R.attr.colorAccent)) } } - /** - * Creates a vector tinted with the accent color. - * - * @param context any context. - * @param resId the vector resource to load and tint - */ - fun tintVector(context: Context, resId: Int, colorId: Int): Drawable { - return VectorDrawableCompat.create(context.resources, resId, context.theme)!!.apply { - setTint(context.getResourceColor(colorId)) - } - } } /** * An item with which needs more than two states (selected/deselected) belonging to a group. * The group must handle selections and restrictions. */ - abstract class MultiStateGroup(resTitle: Int, override val group: Group, state: Int = 0) - : MultiState(resTitle, state), GroupedItem + abstract class MultiStateGroup(resTitle: Int, override val group: Group, state: Int = 0) : + MultiState(resTitle, state), GroupedItem /** * A multistate item for sorting lists (unselected, ascending, descending). @@ -115,26 +104,6 @@ open class ExtendedNavigationView @JvmOverloads constructor( else -> null } } - - } - - class TriStateGroup(resId: Int, group: Group) : MultiStateGroup(resId, group) { - - companion object { - const val STATE_IGNORE = 0 - const val STATE_INCLUDE = 1 - const val STATE_EXCLUDE = 2 - } - - override fun getStateDrawable(context: Context): Drawable? { - return when(state) { - STATE_INCLUDE -> tintVector(context, R.drawable.ic_check_box_24dp) - STATE_EXCLUDE -> tintVector(context, R.drawable.ic_check_box_x_24dp, - android.R.attr.textColorSecondary) - else -> tintVector(context, R.drawable.ic_check_box_outline_blank_24dp, - android.R.attr.textColorSecondary) - } - } } } @@ -182,16 +151,15 @@ open class ExtendedNavigationView @JvmOverloads constructor( * selections of its items. */ fun onItemClicked(item: Item) - } /** * Base adapter for the navigation view. It knows how to create and render every subclass of * [Item]. */ - abstract inner class Adapter(private val items: List) : androidx.recyclerview.widget.RecyclerView.Adapter() { + abstract inner class Adapter(private val items: List) : RecyclerView.Adapter() { - private val onClick = View.OnClickListener { + private val onClick = OnClickListener { val pos = recycler.getChildAdapterPosition(it) val item = items[pos] onItemClicked(item) @@ -208,8 +176,7 @@ open class ExtendedNavigationView @JvmOverloads constructor( @CallSuper override fun getItemViewType(position: Int): Int { - val item = items[position] - return when (item) { + return when (items[position]) { is Item.Header -> VIEW_TYPE_HEADER is Item.Separator -> VIEW_TYPE_SEPARATOR is Item.Radio -> VIEW_TYPE_RADIO @@ -251,6 +218,10 @@ open class ExtendedNavigationView @JvmOverloads constructor( val item = items[position] as Item.CheckboxGroup holder.check.setText(item.resTitle) holder.check.isChecked = item.checked + + // Allow disabling the holder + holder.itemView.isClickable = item.enabled + holder.check.isEnabled = item.enabled } is MultiStateHolder -> { val item = items[position] as Item.MultiStateGroup @@ -262,7 +233,5 @@ open class ExtendedNavigationView @JvmOverloads constructor( } abstract fun onItemClicked(item: Item) - } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/FABAnimationBase.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/FABAnimationBase.kt deleted file mode 100755 index a66d8a662..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/FABAnimationBase.kt +++ /dev/null @@ -1,33 +0,0 @@ -package eu.kanade.tachiyomi.widget - -import android.view.View -import androidx.core.view.ViewCompat -import com.google.android.material.floatingactionbutton.FloatingActionButton - -abstract class FABAnimationBase : FloatingActionButton.Behavior() { - - var isAnimatingOut = false - - override fun onStartNestedScroll(coordinatorLayout: androidx.coordinatorlayout.widget.CoordinatorLayout, child: FloatingActionButton, - directTargetChild: View, target: View, axes: Int, type: Int): Boolean { - // Ensure we react to vertical scrolling - return axes == ViewCompat.SCROLL_AXIS_VERTICAL || - super.onStartNestedScroll(coordinatorLayout, child, directTargetChild, target, axes, type) - } - - override fun onNestedScroll(coordinatorLayout: androidx.coordinatorlayout.widget.CoordinatorLayout, child: FloatingActionButton, - target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, - dyUnconsumed: Int, type: Int) { - super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type) - if (dyConsumed > 0 && !isAnimatingOut && child.visibility == View.VISIBLE) { - // User scrolled down and the FAB is currently visible -> hide the FAB - animateOut(child) - } else if (dyConsumed < 0 && child.visibility != View.VISIBLE) { - // User scrolled up and the FAB is currently not visible -> show the FAB - animateIn(child) - } - } - - abstract fun animateOut(button: FloatingActionButton) - abstract fun animateIn(button: FloatingActionButton) -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/FABAnimationUpDown.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/FABAnimationUpDown.kt deleted file mode 100755 index c52d6ad28..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/FABAnimationUpDown.kt +++ /dev/null @@ -1,51 +0,0 @@ -package eu.kanade.tachiyomi.widget - -import android.content.Context -import android.util.AttributeSet -import android.view.View -import android.view.animation.Animation -import android.view.animation.AnimationUtils -import androidx.interpolator.view.animation.FastOutSlowInInterpolator -import com.google.android.material.floatingactionbutton.FloatingActionButton -import eu.kanade.tachiyomi.R - -@Suppress("unused", "UNUSED_PARAMETER") -class FABAnimationUpDown @JvmOverloads constructor(ctx: Context, attrs: AttributeSet? = null) : FABAnimationBase() { - - private val INTERPOLATOR = FastOutSlowInInterpolator() - - private val outAnimation by lazy { - AnimationUtils.loadAnimation(ctx, R.anim.fab_hide_to_bottom).apply { - duration = 200 - interpolator = INTERPOLATOR - } - } - private val inAnimation by lazy { - AnimationUtils.loadAnimation(ctx, R.anim.fab_show_from_bottom).apply { - duration = 200 - interpolator = INTERPOLATOR - } - } - - override fun animateOut(button: FloatingActionButton) { - outAnimation.setAnimationListener(object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation) { - isAnimatingOut = true - } - - override fun onAnimationEnd(animation: Animation) { - isAnimatingOut = false - button.visibility = View.INVISIBLE - } - - override fun onAnimationRepeat(animation: Animation) { - } - }) - button.startAnimation(outAnimation) - } - - override fun animateIn(button: FloatingActionButton) { - button.visibility = View.VISIBLE - button.startAnimation(inAnimation) - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/IgnoreFirstSpinnerListener.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/IgnoreFirstSpinnerListener.kt index eab856562..107e6fa55 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/IgnoreFirstSpinnerListener.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/IgnoreFirstSpinnerListener.kt @@ -4,7 +4,7 @@ import android.view.View import android.widget.AdapterView import android.widget.AdapterView.OnItemSelectedListener -class IgnoreFirstSpinnerListener(private val block: (Int) -> Unit): OnItemSelectedListener { +class IgnoreFirstSpinnerListener(private val block: (Int) -> Unit) : OnItemSelectedListener { private var firstEvent = true @@ -17,6 +17,5 @@ class IgnoreFirstSpinnerListener(private val block: (Int) -> Unit): OnItemSelect } override fun onNothingSelected(parent: AdapterView<*>?) { - } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/MaxHeightViewPager.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/MaxHeightViewPager.kt new file mode 100644 index 000000000..05c9a426f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/MaxHeightViewPager.kt @@ -0,0 +1,30 @@ +package eu.kanade.tachiyomi.widget + +import android.content.Context +import android.util.AttributeSet +import androidx.viewpager.widget.ViewPager + +/** + * A [ViewPager] that sets its height to the maximum height of its children. + * This is a way to mimic WRAP_CONTENT for its height. + */ +class MaxHeightViewPager(context: Context, attrs: AttributeSet?) : ViewPager(context, attrs) { + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + var measuredHeight = heightMeasureSpec + + var height = 0 + for (i in 0 until childCount) { + val child = getChildAt(i) + child.measure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)) + val h = child.measuredHeight + if (h > height) height = h + } + + if (height != 0) { + measuredHeight = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) + } + + super.onMeasure(widthMeasureSpec, measuredHeight) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/MinMaxNumberPicker.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/MinMaxNumberPicker.kt index 07adb8d0c..a041db442 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/MinMaxNumberPicker.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/MinMaxNumberPicker.kt @@ -6,7 +6,7 @@ import android.widget.NumberPicker import eu.kanade.tachiyomi.R class MinMaxNumberPicker @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : - NumberPicker(context, attrs) { + NumberPicker(context, attrs) { init { if (attrs != null) { @@ -20,4 +20,3 @@ class MinMaxNumberPicker @JvmOverloads constructor(context: Context, attrs: Attr } } } - diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/NegativeSeekBar.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/NegativeSeekBar.kt index 807435b4e..10f4f1296 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/NegativeSeekBar.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/NegativeSeekBar.kt @@ -4,11 +4,12 @@ import android.content.Context import android.os.Parcelable import android.util.AttributeSet import android.widget.SeekBar +import androidx.appcompat.widget.AppCompatSeekBar import eu.kanade.tachiyomi.R - +import kotlin.math.abs class NegativeSeekBar @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : - SeekBar(context, attrs) { + AppCompatSeekBar(context, attrs) { private var minValue: Int = 0 private var maxValue: Int = 0 @@ -16,8 +17,9 @@ class NegativeSeekBar @JvmOverloads constructor(context: Context, attrs: Attribu init { val styledAttributes = context.obtainStyledAttributes( - attrs, - R.styleable.NegativeSeekBar, 0, 0) + attrs, + R.styleable.NegativeSeekBar, 0, 0 + ) try { setMinSeek(styledAttributes.getInt(R.styleable.NegativeSeekBar_min_seek, 0)) @@ -28,21 +30,21 @@ class NegativeSeekBar @JvmOverloads constructor(context: Context, attrs: Attribu super.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar?, value: Int, fromUser: Boolean) { - listener?.let { it.onProgressChanged(seekBar, minValue + value, fromUser) } + listener?.onProgressChanged(seekBar, minValue + value, fromUser) } override fun onStartTrackingTouch(p0: SeekBar?) { - listener?.let { it.onStartTrackingTouch(p0) } + listener?.onStartTrackingTouch(p0) } override fun onStopTrackingTouch(p0: SeekBar?) { - listener?.let { it.onStopTrackingTouch(p0) } + listener?.onStopTrackingTouch(p0) } }) } override fun setProgress(progress: Int) { - super.setProgress(Math.abs(minValue) + progress) + super.setProgress(abs(minValue) + progress) } fun setMinSeek(minValue: Int) { @@ -65,5 +67,4 @@ class NegativeSeekBar @JvmOverloads constructor(context: Context, attrs: Attribu super.onRestoreInstanceState(state) super.setProgress(origProgress) } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/OutlineSpan.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/OutlineSpan.kt new file mode 100644 index 000000000..b7ab3a82f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/OutlineSpan.kt @@ -0,0 +1,56 @@ +package eu.kanade.tachiyomi.widget + +import android.graphics.Canvas +import android.graphics.Paint +import android.text.style.ReplacementSpan +import androidx.annotation.ColorInt +import androidx.annotation.Dimension + +/** + * Source: https://github.com/santaevpavel + * + * A class that draws the outlines of a text when given a stroke color and stroke width. + */ +class OutlineSpan( + @ColorInt private val strokeColor: Int, + @Dimension private val strokeWidth: Float +) : ReplacementSpan() { + + override fun getSize( + paint: Paint, + text: CharSequence, + start: Int, + end: Int, + fm: Paint.FontMetricsInt? + ): Int { + return paint.measureText(text.toString().substring(start until end)).toInt() + } + + override fun draw( + canvas: Canvas, + text: CharSequence, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + val originTextColor = paint.color + + paint.apply { + color = strokeColor + style = Paint.Style.STROKE + this.strokeWidth = this@OutlineSpan.strokeWidth + } + canvas.drawText(text, start, end, x, y.toFloat(), paint) + + paint.apply { + color = originTextColor + style = Paint.Style.FILL + } + + canvas.drawText(text, start, end, x, y.toFloat(), paint) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/PTSansTextView.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/PTSansTextView.kt deleted file mode 100755 index 4152f1767..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/PTSansTextView.kt +++ /dev/null @@ -1,47 +0,0 @@ -package eu.kanade.tachiyomi.widget - -import android.content.Context -import android.graphics.Canvas -import android.graphics.Typeface -import android.util.AttributeSet -import android.widget.TextView -import eu.kanade.tachiyomi.R -import java.util.* - - -class PTSansTextView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : - TextView(context, attrs) { - - companion object { - const val PTSANS_NARROW = 0 - const val PTSANS_NARROW_BOLD = 1 - - // Map where typefaces are cached - private val typefaces = HashMap(2) - } - - init { - if (attrs != null) { - val values = context.obtainStyledAttributes(attrs, R.styleable.PTSansTextView) - - val typeface = values.getInt(R.styleable.PTSansTextView_typeface, 0) - - setTypeface(typefaces.getOrPut(typeface) { - Typeface.createFromAsset(context.assets, when (typeface) { - PTSANS_NARROW -> "fonts/PTSans-Narrow.ttf" - PTSANS_NARROW_BOLD -> "fonts/PTSans-NarrowBold.ttf" - else -> throw IllegalArgumentException("Font not found " + typeface) - }) - }) - - values.recycle() - } - } - - override fun onDraw(canvas: Canvas) { - // Draw two times for a more visible shadow around the text - super.onDraw(canvas) - super.onDraw(canvas) - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/PreCachingLayoutManager.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/PreCachingLayoutManager.kt deleted file mode 100755 index bf0121491..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/PreCachingLayoutManager.kt +++ /dev/null @@ -1,24 +0,0 @@ -package eu.kanade.tachiyomi.widget - -import android.content.Context - -class PreCachingLayoutManager(context: Context) : androidx.recyclerview.widget.LinearLayoutManager(context) { - - init { - isItemPrefetchEnabled = false - } - - companion object { - const val DEFAULT_EXTRA_LAYOUT_SPACE = 600 - } - - var extraLayoutSpace = 0 - - override fun getExtraLayoutSpace(state: androidx.recyclerview.widget.RecyclerView.State): Int { - if (extraLayoutSpace > 0) { - return extraLayoutSpace - } - return DEFAULT_EXTRA_LAYOUT_SPACE - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/RecyclerViewPagerAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/RecyclerViewPagerAdapter.kt index f644e3fdd..2e1d51c2f 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/RecyclerViewPagerAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/RecyclerViewPagerAdapter.kt @@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.widget import android.view.View import android.view.ViewGroup import com.nightlynexus.viewstatepageradapter.ViewStatePagerAdapter -import java.util.* +import java.util.Stack abstract class RecyclerViewPagerAdapter : ViewStatePagerAdapter() { @@ -31,6 +31,4 @@ abstract class RecyclerViewPagerAdapter : ViewStatePagerAdapter() { recycleView(view, position) if (recycle) pool.push(view) } - - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/RevealAnimationView.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/RevealAnimationView.kt index f5718e75d..b8aa1764f 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/RevealAnimationView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/RevealAnimationView.kt @@ -2,16 +2,15 @@ package eu.kanade.tachiyomi.widget import android.animation.Animator import android.animation.AnimatorListenerAdapter -import android.annotation.TargetApi import android.content.Context -import android.os.Build import android.util.AttributeSet import android.view.View import android.view.ViewAnimationUtils +import eu.kanade.tachiyomi.util.view.invisible +import eu.kanade.tachiyomi.util.view.visible -@TargetApi(Build.VERSION_CODES.LOLLIPOP) class RevealAnimationView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : - View(context, attrs) { + View(context, attrs) { /** * Hides the animation view with a animation @@ -21,28 +20,26 @@ class RevealAnimationView @JvmOverloads constructor(context: Context, attrs: Att * @param initialRadius size of radius of animation */ fun hideRevealEffect(centerX: Int, centerY: Int, initialRadius: Int) { - if (Build.VERSION.SDK_INT >= 21) { + // Make the view visible. + this.visible() - // Make the view visible. - this.visibility = View.VISIBLE + // Create the animation (the final radius is zero). + val anim = ViewAnimationUtils.createCircularReveal( + this, centerX, centerY, initialRadius.toFloat(), 0f + ) - // Create the animation (the final radius is zero). - val anim = ViewAnimationUtils.createCircularReveal( - this, centerX, centerY, initialRadius.toFloat(), 0f) + // Set duration of animation. + anim.duration = 500 - // Set duration of animation. - anim.duration = 500 + // make the view invisible when the animation is done + anim.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + super.onAnimationEnd(animation) + this@RevealAnimationView.invisible() + } + }) - // make the view invisible when the animation is done - anim.addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - super.onAnimationEnd(animation) - this@RevealAnimationView.visibility = View.INVISIBLE - } - }) - - anim.start() - } + anim.start() } /** @@ -55,25 +52,20 @@ class RevealAnimationView @JvmOverloads constructor(context: Context, attrs: Att * @return sdk version lower then 21 */ fun showRevealEffect(centerX: Int, centerY: Int, listener: Animator.AnimatorListener): Boolean { - if (Build.VERSION.SDK_INT >= 21) { + this.visible() - this.visibility = View.VISIBLE + val height = this.height - val height = this.height + // Create animation + val anim = ViewAnimationUtils.createCircularReveal( + this, centerX, centerY, 0f, height.toFloat() + ) - // Create animation - val anim = ViewAnimationUtils.createCircularReveal( - this, centerX, centerY, 0f, height.toFloat()) + // Set duration of animation + anim.duration = 350 - // Set duration of animation - anim.duration = 350 - - anim.addListener(listener) - anim.start() - return true - } - return false + anim.addListener(listener) + anim.start() + return true } - - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/SimpleAnimationListener.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/SimpleAnimationListener.kt index 12a2b4ef2..853e8ba0b 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/SimpleAnimationListener.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/SimpleAnimationListener.kt @@ -8,4 +8,4 @@ open class SimpleAnimationListener : Animation.AnimationListener { override fun onAnimationEnd(animation: Animation) {} override fun onAnimationStart(animation: Animation) {} -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/SimpleNavigationView.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/SimpleNavigationView.kt index bec8d36ad..1d91b7b76 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/SimpleNavigationView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/SimpleNavigationView.kt @@ -5,98 +5,85 @@ import android.content.Context import android.util.AttributeSet import android.view.View import android.view.ViewGroup -import android.widget.* +import android.widget.CheckBox +import android.widget.CheckedTextView +import android.widget.EditText +import android.widget.RadioButton +import android.widget.Spinner +import android.widget.TextView import androidx.appcompat.widget.TintTypedArray import androidx.core.view.ViewCompat +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import com.google.android.material.R import com.google.android.material.internal.ScrimInsetsFrameLayout import com.google.android.material.textfield.TextInputLayout -import eu.kanade.tachiyomi.util.inflate import eu.kanade.tachiyomi.R as TR +import eu.kanade.tachiyomi.util.view.inflate @Suppress("LeakingThis") @SuppressLint("PrivateResource", "RestrictedApi") open class SimpleNavigationView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0) - : ScrimInsetsFrameLayout(context, attrs, defStyleAttr) { - - /** - * Max width of the navigation view. - */ - private var maxWidth: Int + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ScrimInsetsFrameLayout(context, attrs, defStyleAttr) { /** * Recycler view containing all the items. */ - protected val recycler = androidx.recyclerview.widget.RecyclerView(context) + protected val recycler = RecyclerView(context) init { // Custom attributes - val a = TintTypedArray.obtainStyledAttributes(context, attrs, - R.styleable.NavigationView, defStyleAttr, - R.style.Widget_Design_NavigationView) + val a = TintTypedArray.obtainStyledAttributes( + context, attrs, + R.styleable.NavigationView, defStyleAttr, + R.style.Widget_Design_NavigationView + ) ViewCompat.setBackground( - this, a.getDrawable(R.styleable.NavigationView_android_background)) + this, a.getDrawable(R.styleable.NavigationView_android_background) + ) if (a.hasValue(R.styleable.NavigationView_elevation)) { - ViewCompat.setElevation(this, a.getDimensionPixelSize( - R.styleable.NavigationView_elevation, 0).toFloat()) + ViewCompat.setElevation( + this, + a.getDimensionPixelSize( + R.styleable.NavigationView_elevation, 0 + ).toFloat() + ) } - @Suppress("DEPRECATION") - ViewCompat.setFitsSystemWindows(this, - a.getBoolean(R.styleable.NavigationView_android_fitsSystemWindows, false)) - - maxWidth = a.getDimensionPixelSize(R.styleable.NavigationView_android_maxWidth, 0) - a.recycle() - recycler.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(context) - } - - /** - * Overriden to measure the width of the navigation view. - */ - @SuppressLint("SwitchIntDef") - override fun onMeasure(widthSpec: Int, heightSpec: Int) { - val width = when (MeasureSpec.getMode(widthSpec)) { - MeasureSpec.AT_MOST -> MeasureSpec.makeMeasureSpec( - Math.min(MeasureSpec.getSize(widthSpec), maxWidth), MeasureSpec.EXACTLY) - MeasureSpec.UNSPECIFIED -> MeasureSpec.makeMeasureSpec(maxWidth, MeasureSpec.EXACTLY) - else -> widthSpec - } - // Let super sort out the height - super.onMeasure(width, heightSpec) + recycler.layoutManager = LinearLayoutManager(context) } /** * Base view holder. */ - abstract class Holder(view: View) : androidx.recyclerview.widget.RecyclerView.ViewHolder(view) + abstract class Holder(view: View) : RecyclerView.ViewHolder(view) /** * Separator view holder. */ - class SeparatorHolder(parent: ViewGroup) - : Holder(parent.inflate(R.layout.design_navigation_item_separator)) + class SeparatorHolder(parent: ViewGroup) : + Holder(parent.inflate(R.layout.design_navigation_item_separator)) /** * Header view holder. */ - class HeaderHolder(parent: ViewGroup) - : Holder(parent.inflate(TR.layout.navigation_view_group)){ + class HeaderHolder(parent: ViewGroup) : + Holder(parent.inflate(TR.layout.navigation_view_group)) { val title: TextView = itemView.findViewById(TR.id.title) } - /** * Clickable view holder. */ - abstract class ClickableHolder(view: View, listener: View.OnClickListener?) : Holder(view) { + abstract class ClickableHolder(view: View, listener: OnClickListener?) : Holder(view) { init { itemView.setOnClickListener(listener) } @@ -105,8 +92,8 @@ open class SimpleNavigationView @JvmOverloads constructor( /** * Radio view holder. */ - class RadioHolder(parent: ViewGroup, listener: View.OnClickListener?) - : ClickableHolder(parent.inflate(TR.layout.navigation_view_radio), listener) { + class RadioHolder(parent: ViewGroup, listener: OnClickListener?) : + ClickableHolder(parent.inflate(TR.layout.navigation_view_radio), listener) { val radio: RadioButton = itemView.findViewById(TR.id.nav_view_item) } @@ -114,8 +101,8 @@ open class SimpleNavigationView @JvmOverloads constructor( /** * Checkbox view holder. */ - class CheckboxHolder(parent: ViewGroup, listener: View.OnClickListener?) - : ClickableHolder(parent.inflate(TR.layout.navigation_view_checkbox), listener) { + class CheckboxHolder(parent: ViewGroup, listener: OnClickListener?) : + ClickableHolder(parent.inflate(TR.layout.navigation_view_checkbox), listener) { val check: CheckBox = itemView.findViewById(TR.id.nav_view_item) } @@ -123,21 +110,21 @@ open class SimpleNavigationView @JvmOverloads constructor( /** * Multi state view holder. */ - class MultiStateHolder(parent: ViewGroup, listener: View.OnClickListener?) - : ClickableHolder(parent.inflate(TR.layout.navigation_view_checkedtext), listener) { + class MultiStateHolder(parent: ViewGroup, listener: OnClickListener?) : + ClickableHolder(parent.inflate(TR.layout.navigation_view_checkedtext), listener) { val text: CheckedTextView = itemView.findViewById(TR.id.nav_view_item) } - class SpinnerHolder(parent: ViewGroup, listener: OnClickListener? = null) - : ClickableHolder(parent.inflate(TR.layout.navigation_view_spinner), listener) { + class SpinnerHolder(parent: ViewGroup, listener: OnClickListener? = null) : + ClickableHolder(parent.inflate(TR.layout.navigation_view_spinner), listener) { val text: TextView = itemView.findViewById(TR.id.nav_view_item_text) val spinner: Spinner = itemView.findViewById(TR.id.nav_view_item) } - class EditTextHolder(parent: ViewGroup) - : Holder(parent.inflate(TR.layout.navigation_view_text)) { + class EditTextHolder(parent: ViewGroup) : + Holder(parent.inflate(TR.layout.navigation_view_text)) { val wrapper: TextInputLayout = itemView.findViewById(TR.id.nav_view_item_wrapper) val edit: EditText = itemView.findViewById(TR.id.nav_view_item) @@ -152,5 +139,4 @@ open class SimpleNavigationView @JvmOverloads constructor( const val VIEW_TYPE_TEXT = 105 const val VIEW_TYPE_LIST = 106 } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/SimpleSeekBarListener.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/SimpleSeekBarListener.kt index 77f815bd3..fdade75f1 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/SimpleSeekBarListener.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/SimpleSeekBarListener.kt @@ -1,4 +1,5 @@ package eu.kanade.tachiyomi.widget + import android.widget.SeekBar open class SimpleSeekBarListener : SeekBar.OnSeekBarChangeListener { @@ -10,4 +11,4 @@ open class SimpleSeekBarListener : SeekBar.OnSeekBarChangeListener { override fun onStopTrackingTouch(seekBar: SeekBar) { } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/SimpleTextWatcher.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/SimpleTextWatcher.kt deleted file mode 100755 index ee39d5871..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/SimpleTextWatcher.kt +++ /dev/null @@ -1,12 +0,0 @@ -package eu.kanade.tachiyomi.widget - -import android.text.Editable -import android.text.TextWatcher - -open class SimpleTextWatcher : TextWatcher { - override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} - - override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} - - override fun afterTextChanged(s: Editable) {} -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/StateImageViewTarget.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/StateImageViewTarget.kt index 8570109c9..7228c6d94 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/StateImageViewTarget.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/StateImageViewTarget.kt @@ -8,9 +8,9 @@ import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat import com.bumptech.glide.request.target.ImageViewTarget import com.bumptech.glide.request.transition.Transition import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.util.getResourceColor -import eu.kanade.tachiyomi.util.gone -import eu.kanade.tachiyomi.util.visible +import eu.kanade.tachiyomi.util.system.getResourceColor +import eu.kanade.tachiyomi.util.view.gone +import eu.kanade.tachiyomi.util.view.visible /** * A glide target to display an image with an optional view to show while loading and a configurable @@ -21,12 +21,12 @@ import eu.kanade.tachiyomi.util.visible * @param errorDrawableRes the error drawable resource to show. * @param errorScaleType the scale type for the error drawable, [ScaleType.CENTER] by default. */ -class StateImageViewTarget(view: ImageView, - val progress: View? = null, - val errorDrawableRes: Int = R.drawable.ic_broken_image_grey_24dp, - val errorScaleType: ScaleType = ScaleType.CENTER) : - - ImageViewTarget(view) { +class StateImageViewTarget( + view: ImageView, + val progress: View? = null, + private val errorDrawableRes: Int = R.drawable.ic_broken_image_grey_24dp, + private val errorScaleType: ScaleType = ScaleType.CENTER +) : ImageViewTarget(view) { private var resource: Drawable? = null @@ -46,7 +46,7 @@ class StateImageViewTarget(view: ImageView, view.scaleType = errorScaleType val vector = VectorDrawableCompat.create(view.context.resources, errorDrawableRes, null) - vector?.setTint(view.context.getResourceColor(android.R.attr.textColorSecondary)) + vector?.setTint(view.context.getResourceColor(R.attr.colorOnBackground, 0.38f)) view.setImageDrawable(vector) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/TabbedBottomSheetDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/TabbedBottomSheetDialog.kt new file mode 100644 index 000000000..cc65172c7 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/TabbedBottomSheetDialog.kt @@ -0,0 +1,39 @@ +package eu.kanade.tachiyomi.widget + +import android.app.Activity +import android.view.View +import android.view.ViewGroup +import com.google.android.material.bottomsheet.BottomSheetDialog +import eu.kanade.tachiyomi.databinding.CommonTabbedSheetBinding + +abstract class TabbedBottomSheetDialog(private val activity: Activity) : BottomSheetDialog(activity) { + + init { + val binding: CommonTabbedSheetBinding = CommonTabbedSheetBinding.inflate(activity.layoutInflater) + + val adapter = LibrarySettingsSheetAdapter() + binding.pager.adapter = adapter + binding.tabs.setupWithViewPager(binding.pager) + + setContentView(binding.root) + } + + abstract fun getTabViews(): List + + abstract fun getTabTitles(): List + + private inner class LibrarySettingsSheetAdapter : ViewPagerAdapter() { + + override fun createView(container: ViewGroup, position: Int): View { + return getTabViews()[position] + } + + override fun getCount(): Int { + return getTabViews().size + } + + override fun getPageTitle(position: Int): CharSequence { + return activity.resources!!.getString(getTabTitles()[position]) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/ThemedSwipeRefreshLayout.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/ThemedSwipeRefreshLayout.kt new file mode 100644 index 000000000..45d6d01ca --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/ThemedSwipeRefreshLayout.kt @@ -0,0 +1,25 @@ +package eu.kanade.tachiyomi.widget + +import android.content.Context +import android.util.AttributeSet +import androidx.core.content.ContextCompat +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.system.getResourceColor + +class ThemedSwipeRefreshLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + SwipeRefreshLayout(context, attrs) { + + init { + setColors() + } + + private fun setColors() { + setProgressBackgroundColorSchemeColor(context.getResourceColor(R.attr.colorAccent)) + setColorSchemeColors( + ContextCompat.getColor(context, R.color.md_white_1000), + ContextCompat.getColor(context, R.color.md_white_1000), + ContextCompat.getColor(context, R.color.md_white_1000) + ) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/ViewPagerAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/ViewPagerAdapter.kt index 7274f4032..520721104 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/ViewPagerAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/ViewPagerAdapter.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.widget import android.view.View import android.view.ViewGroup +import androidx.viewpager.widget.PagerAdapter abstract class ViewPagerAdapter : androidx.viewpager.widget.PagerAdapter() { @@ -29,5 +30,4 @@ abstract class ViewPagerAdapter : androidx.viewpager.widget.PagerAdapter() { interface PositionableView { val item: Any } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/IntListPreference.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/IntListPreference.kt index 4d5a9b8c6..66530f50d 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/IntListPreference.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/IntListPreference.kt @@ -5,7 +5,7 @@ import android.util.AttributeSet import androidx.preference.ListPreference class IntListPreference @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : - ListPreference(context, attrs) { + ListPreference(context, attrs) { override fun persistString(value: String?): Boolean { return value != null && persistInt(value.toInt()) diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginCheckBoxPreference.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginCheckBoxPreference.kt deleted file mode 100755 index fb94b211c..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginCheckBoxPreference.kt +++ /dev/null @@ -1,57 +0,0 @@ -package eu.kanade.tachiyomi.widget.preference - -import android.content.Context -import android.graphics.Color -import android.util.AttributeSet -import android.view.View -import androidx.preference.CheckBoxPreference -import androidx.preference.PreferenceViewHolder -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.source.online.HttpSource -import eu.kanade.tachiyomi.source.online.LoginSource -import eu.kanade.tachiyomi.util.getResourceColor -import eu.kanade.tachiyomi.util.setVectorCompat -import kotlinx.android.synthetic.main.pref_item_source.view.* - -class LoginCheckBoxPreference @JvmOverloads constructor( - context: Context, - val source: HttpSource, - attrs: AttributeSet? = null -) : CheckBoxPreference(context, attrs) { - - init { - layoutResource = R.layout.pref_item_source - } - - private var onLoginClick: () -> Unit = {} - - override fun onBindViewHolder(holder: PreferenceViewHolder) { - super.onBindViewHolder(holder) - val loginFrame = holder.itemView.login_frame - if (source is LoginSource) { - val tint = if (source.isLogged()) - Color.argb(255, 76, 175, 80) - else - context.getResourceColor(android.R.attr.textColorSecondary) - - holder.itemView.login.setVectorCompat(R.drawable.ic_account_circle_black_24dp, tint) - - loginFrame.visibility = View.VISIBLE - loginFrame.setOnClickListener { - onLoginClick() - } - } else { - loginFrame.visibility = View.GONE - } - } - - fun setOnLoginClickListener(block: () -> Unit) { - onLoginClick = block - } - - // Make method public - override public fun notifyChanged() { - super.notifyChanged() - } - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginDialogPreference.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginDialogPreference.kt index b27c77c62..3b9c65355 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginDialogPreference.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginDialogPreference.kt @@ -4,19 +4,28 @@ import android.app.Dialog import android.os.Bundle import android.text.method.PasswordTransformationMethod import android.view.View +import androidx.annotation.StringRes import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.customview.customView import com.bluelinelabs.conductor.ControllerChangeHandler import com.bluelinelabs.conductor.ControllerChangeType import com.dd.processbutton.iml.ActionProcessButton import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.ui.base.controller.DialogController -import eu.kanade.tachiyomi.widget.SimpleTextWatcher -import kotlinx.android.synthetic.main.pref_account_login.view.* +import kotlinx.android.synthetic.main.pref_account_login.view.login +import kotlinx.android.synthetic.main.pref_account_login.view.password +import kotlinx.android.synthetic.main.pref_account_login.view.show_password +import kotlinx.android.synthetic.main.pref_account_login.view.username_label import rx.Subscription import uy.kohesive.injekt.injectLazy -abstract class LoginDialogPreference(bundle: Bundle? = null) : DialogController(bundle) { +abstract class LoginDialogPreference( + @StringRes private val titleRes: Int? = null, + private val titleFormatArgs: Any? = null, + @StringRes private val usernameLabelRes: Int? = null, + bundle: Bundle? = null +) : DialogController(bundle) { var v: View? = null private set @@ -25,11 +34,14 @@ abstract class LoginDialogPreference(bundle: Bundle? = null) : DialogController( var requestSubscription: Subscription? = null - override fun onCreateDialog(savedState: Bundle?): Dialog { - val dialog = MaterialDialog.Builder(activity!!) - .customView(R.layout.pref_account_login, false) - .negativeText(android.R.string.cancel) - .build() + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + var dialog = MaterialDialog(activity!!) + .customView(R.layout.pref_account_login) + .negativeButton(android.R.string.cancel) + + if (titleRes != null) { + dialog = dialog.title(text = activity!!.getString(titleRes, titleFormatArgs)) + } onViewCreated(dialog.view) @@ -39,28 +51,22 @@ abstract class LoginDialogPreference(bundle: Bundle? = null) : DialogController( fun onViewCreated(view: View) { v = view.apply { show_password.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) + if (isChecked) { password.transformationMethod = null - else + } else { password.transformationMethod = PasswordTransformationMethod() + } + } + + if (usernameLabelRes != null) { + username_label.hint = context.getString(usernameLabelRes) } login.setMode(ActionProcessButton.Mode.ENDLESS) login.setOnClickListener { checkLogin() } setCredentialsOnView(this) - - show_password.isEnabled = password.text.isNullOrEmpty() - - password.addTextChangedListener(object : SimpleTextWatcher() { - override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { - if (s.isEmpty()) { - show_password.isEnabled = true - } - } - }) } - } override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { @@ -77,5 +83,4 @@ abstract class LoginDialogPreference(bundle: Bundle? = null) : DialogController( protected abstract fun checkLogin() protected abstract fun setCredentialsOnView(view: View) - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginPreference.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginPreference.kt index 2b448c867..32ca1d720 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginPreference.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginPreference.kt @@ -5,10 +5,10 @@ import android.util.AttributeSet import androidx.preference.Preference import androidx.preference.PreferenceViewHolder import eu.kanade.tachiyomi.R -import kotlinx.android.synthetic.main.pref_widget_imageview.view.* +import kotlinx.android.synthetic.main.pref_widget_imageview.view.image_view class LoginPreference @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : - Preference(context, attrs) { + Preference(context, attrs) { init { widgetLayoutResource = R.layout.pref_widget_imageview @@ -17,14 +17,16 @@ class LoginPreference @JvmOverloads constructor(context: Context, attrs: Attribu override fun onBindViewHolder(holder: PreferenceViewHolder) { super.onBindViewHolder(holder) - holder.itemView.image_view.setImageResource(if (getPersistedString("").isNullOrEmpty()) - android.R.color.transparent - else - R.drawable.ic_done_green_24dp) + holder.itemView.image_view.setImageResource( + if (getPersistedString("").isNullOrEmpty()) { + android.R.color.transparent + } else { + R.drawable.ic_done_green_24dp + } + ) } - override public fun notifyChanged() { + public override fun notifyChanged() { super.notifyChanged() } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SourceLoginDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SourceLoginDialog.kt deleted file mode 100755 index 42cf0b18e..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SourceLoginDialog.kt +++ /dev/null @@ -1,69 +0,0 @@ -package eu.kanade.tachiyomi.widget.preference - -import android.os.Bundle -import android.view.View -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.source.Source -import eu.kanade.tachiyomi.source.SourceManager -import eu.kanade.tachiyomi.source.online.LoginSource -import eu.kanade.tachiyomi.util.toast -import kotlinx.android.synthetic.main.pref_account_login.view.* -import rx.android.schedulers.AndroidSchedulers -import rx.schedulers.Schedulers -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get - -class SourceLoginDialog(bundle: Bundle? = null) : LoginDialogPreference(bundle) { - - private val source = Injekt.get().get(args.getLong("key")) as LoginSource - - constructor(source: Source) : this(Bundle().apply { putLong("key", source.id) }) - - override fun setCredentialsOnView(view: View) = with(view) { - dialog_title.text = context.getString(R.string.login_title, source.toString()) - username.setText(preferences.sourceUsername(source)) - password.setText(preferences.sourcePassword(source)) - } - - override fun checkLogin() { - requestSubscription?.unsubscribe() - - v?.apply { - if (username.text.isEmpty() || password.text.isEmpty()) - return - - login.progress = 1 - - requestSubscription = source.login(username.text.toString(), password.text.toString()) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ logged -> - if (logged) { - preferences.setSourceCredentials(source, - username.text.toString(), - password.text.toString()) - - dialog?.dismiss() - context.toast(R.string.login_success) - } else { - preferences.setSourceCredentials(source, "", "") - login.progress = -1 - } - }, { error -> - login.progress = -1 - login.setText(R.string.unknown_error) - error.message?.let { context.toast(it) } - }) - } - } - - override fun onDialogClosed() { - super.onDialogClosed() - (targetController as? Listener)?.loginDialogClosed(source) - } - - interface Listener { - fun loginDialogClosed(source: LoginSource) - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SwitchPreferenceCategory.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SwitchPreferenceCategory.kt index 5bbcb701d..15d64392a 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SwitchPreferenceCategory.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SwitchPreferenceCategory.kt @@ -1,9 +1,7 @@ package eu.kanade.tachiyomi.widget.preference -import android.annotation.TargetApi import android.content.Context import android.content.res.TypedArray -import android.os.Build.VERSION_CODES.ICE_CREAM_SANDWICH import android.util.AttributeSet import android.view.View import android.widget.Checkable @@ -13,16 +11,18 @@ import androidx.appcompat.widget.SwitchCompat import androidx.preference.PreferenceCategory import androidx.preference.PreferenceViewHolder import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.util.getResourceColor +import eu.kanade.tachiyomi.util.system.getResourceColor class SwitchPreferenceCategory @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null) -: PreferenceCategory( + context: Context, + attrs: AttributeSet? = null +) : + PreferenceCategory( context, attrs, - R.attr.switchPreferenceCompatStyle), -CompoundButton.OnCheckedChangeListener { + R.attr.switchPreferenceCompatStyle + ), + CompoundButton.OnCheckedChangeListener { private var mChecked = false @@ -40,7 +40,6 @@ CompoundButton.OnCheckedChangeListener { syncSwitchView(switchView) } - @TargetApi(ICE_CREAM_SANDWICH) private fun syncSwitchView(view: View) { if (view is Checkable) { val isChecked = view.isChecked @@ -116,10 +115,12 @@ CompoundButton.OnCheckedChangeListener { } override fun onSetInitialValue(restoreValue: Boolean, defaultValue: Any?) { - setChecked(if (restoreValue) - getPersistedBoolean(mChecked) - else - defaultValue as Boolean) + setChecked( + if (restoreValue) { + getPersistedBoolean(mChecked) + } else { + defaultValue as Boolean + } + ) } - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLoginDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLoginDialog.kt index 500a9225b..40cce109a 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLoginDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLoginDialog.kt @@ -2,24 +2,34 @@ package eu.kanade.tachiyomi.widget.preference import android.os.Bundle import android.view.View +import androidx.annotation.StringRes import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackService -import eu.kanade.tachiyomi.util.toast -import kotlinx.android.synthetic.main.pref_account_login.view.* +import eu.kanade.tachiyomi.util.system.toast +import kotlinx.android.synthetic.main.pref_account_login.view.login +import kotlinx.android.synthetic.main.pref_account_login.view.password +import kotlinx.android.synthetic.main.pref_account_login.view.username import rx.android.schedulers.AndroidSchedulers import rx.schedulers.Schedulers import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -class TrackLoginDialog(bundle: Bundle? = null) : LoginDialogPreference(bundle) { +class TrackLoginDialog( + @StringRes titleRes: Int? = null, + titleFormatArgs: Any? = null, + @StringRes usernameLabelRes: Int? = null, + bundle: Bundle? = null +) : LoginDialogPreference(titleRes, titleFormatArgs, usernameLabelRes, bundle) { private val service = Injekt.get().getService(args.getInt("key"))!! - constructor(service: TrackService) : this(Bundle().apply { putInt("key", service.id) }) + constructor(service: TrackService) : this(service, null) + + constructor(service: TrackService, @StringRes usernameLabelRes: Int?) : + this(R.string.login_title, service.name, usernameLabelRes, Bundle().apply { putInt("key", service.id) }) override fun setCredentialsOnView(view: View) = with(view) { - dialog_title.text = context.getString(R.string.login_title, service.name) username.setText(service.getUsername()) password.setText(service.getPassword()) } @@ -28,34 +38,37 @@ class TrackLoginDialog(bundle: Bundle? = null) : LoginDialogPreference(bundle) { requestSubscription?.unsubscribe() v?.apply { - if (username.text.isEmpty() || password.text.isEmpty()) + if (username.text.isNullOrEmpty() || password.text.isNullOrEmpty()) { return + } login.progress = 1 val user = username.text.toString() val pass = password.text.toString() requestSubscription = service.login(user, pass) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { dialog?.dismiss() context.toast(R.string.login_success) - }, { error -> + }, + { error -> login.progress = -1 login.setText(R.string.unknown_error) error.message?.let { context.toast(it) } - }) + } + ) } } override fun onDialogClosed() { super.onDialogClosed() - (targetController as? Listener)?.trackDialogClosed(service) + (targetController as? Listener)?.trackLoginDialogClosed(service) } interface Listener { - fun trackDialogClosed(service: TrackService) + fun trackLoginDialogClosed(service: TrackService) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLogoutDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLogoutDialog.kt new file mode 100644 index 000000000..740a3681b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLogoutDialog.kt @@ -0,0 +1,34 @@ +package eu.kanade.tachiyomi.widget.preference + +import android.app.Dialog +import android.os.Bundle +import com.afollestad.materialdialogs.MaterialDialog +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.data.track.TrackService +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import eu.kanade.tachiyomi.util.system.toast +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class TrackLogoutDialog(bundle: Bundle? = null) : DialogController(bundle) { + + private val service = Injekt.get().getService(args.getInt("key"))!! + + constructor(service: TrackService) : this(Bundle().apply { putInt("key", service.id) }) + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + return MaterialDialog(activity!!) + .title(R.string.logout_title, service.name) + .positiveButton(R.string.logout) { + service.logout() + (targetController as? Listener)?.trackLogoutDialogClosed(service) + activity?.toast(R.string.logout_success) + } + .negativeButton(android.R.string.cancel) + } + + interface Listener { + fun trackLogoutDialogClosed(service: TrackService) + } +} diff --git a/app/src/main/res/anim/enter_from_left.xml b/app/src/main/res/anim/enter_from_left.xml deleted file mode 100755 index 67f7b0f76..000000000 --- a/app/src/main/res/anim/enter_from_left.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/anim/enter_from_right.xml b/app/src/main/res/anim/enter_from_right.xml deleted file mode 100755 index e5a91703e..000000000 --- a/app/src/main/res/anim/enter_from_right.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/anim/exit_to_left.xml b/app/src/main/res/anim/exit_to_left.xml deleted file mode 100755 index b5d7eb0fc..000000000 --- a/app/src/main/res/anim/exit_to_left.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/anim/exit_to_right.xml b/app/src/main/res/anim/exit_to_right.xml deleted file mode 100755 index c0f406cb0..000000000 --- a/app/src/main/res/anim/exit_to_right.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/anim/fab_hide_to_bottom.xml b/app/src/main/res/anim/fab_hide_to_bottom.xml deleted file mode 100755 index b5f8d63bd..000000000 --- a/app/src/main/res/anim/fab_hide_to_bottom.xml +++ /dev/null @@ -1,6 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/anim/fab_show_from_bottom.xml b/app/src/main/res/anim/fab_show_from_bottom.xml deleted file mode 100755 index eea12e8c4..000000000 --- a/app/src/main/res/anim/fab_show_from_bottom.xml +++ /dev/null @@ -1,6 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/anim/fade_in.xml b/app/src/main/res/anim/fade_in.xml deleted file mode 100755 index 94282b214..000000000 --- a/app/src/main/res/anim/fade_in.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/anim/fade_in_long.xml b/app/src/main/res/anim/fade_in_long.xml index 0a71c8b84..659c29f20 100755 --- a/app/src/main/res/anim/fade_in_long.xml +++ b/app/src/main/res/anim/fade_in_long.xml @@ -1,9 +1,9 @@ - \ No newline at end of file + android:fillAfter="true" + android:fromAlpha="0.0" + android:interpolator="@android:anim/accelerate_interpolator" + android:toAlpha="1.0" /> + diff --git a/app/src/main/res/color/abc_primary_text_material_dark.xml b/app/src/main/res/color/abc_primary_text_material_dark.xml deleted file mode 100755 index 8e9adbf45..000000000 --- a/app/src/main/res/color/abc_primary_text_material_dark.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/color/text_input_stroke.xml b/app/src/main/res/color/text_input_stroke.xml new file mode 100644 index 000000000..006464ff2 --- /dev/null +++ b/app/src/main/res/color/text_input_stroke.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/app/src/main/res/drawable-hdpi/ic_av_pause_grey_24dp_img.png b/app/src/main/res/drawable-hdpi/ic_av_pause_grey_24dp_img.png deleted file mode 100755 index 074bf7562..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_av_pause_grey_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_av_play_arrow_grey_img.png b/app/src/main/res/drawable-hdpi/ic_av_play_arrow_grey_img.png deleted file mode 100755 index 37a4ca1cd..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_av_play_arrow_grey_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_book_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_book_white_24dp.png deleted file mode 100644 index 0fd780e77..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_book_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_clear_grey_24dp_img.png b/app/src/main/res/drawable-hdpi/ic_clear_grey_24dp_img.png deleted file mode 100755 index 8b288271d..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_clear_grey_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_delete_grey_24dp.png b/app/src/main/res/drawable-hdpi/ic_delete_grey_24dp.png deleted file mode 100755 index 13d2741f3..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_delete_grey_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_insert_photo_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_insert_photo_white_24dp.png deleted file mode 100755 index 25a9576dd..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_insert_photo_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_refresh_grey_24dp_img.png b/app/src/main/res/drawable-hdpi/ic_refresh_grey_24dp_img.png deleted file mode 100755 index 8ee620664..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_refresh_grey_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_refresh_white_24dp_img.png b/app/src/main/res/drawable-hdpi/ic_refresh_white_24dp_img.png deleted file mode 100755 index c68c9fe45..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_refresh_white_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_share_grey_24dp.png b/app/src/main/res/drawable-hdpi/ic_share_grey_24dp.png deleted file mode 100755 index c0b2a80e9..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_share_grey_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_system_update_grey_24dp_img.png b/app/src/main/res/drawable-hdpi/ic_system_update_grey_24dp_img.png deleted file mode 100755 index 51cf94d80..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_system_update_grey_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/splash_icon.webp b/app/src/main/res/drawable-hdpi/splash_icon.webp new file mode 100644 index 000000000..9429d0d06 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/splash_icon.webp differ diff --git a/app/src/main/res/drawable-hdpi/tachiyomi_circle.png b/app/src/main/res/drawable-hdpi/tachiyomi_circle.png deleted file mode 100644 index e5f3ce794..000000000 Binary files a/app/src/main/res/drawable-hdpi/tachiyomi_circle.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_av_pause_grey_24dp_img.png b/app/src/main/res/drawable-mdpi/ic_av_pause_grey_24dp_img.png deleted file mode 100755 index e13456677..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_av_pause_grey_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_av_play_arrow_grey_img.png b/app/src/main/res/drawable-mdpi/ic_av_play_arrow_grey_img.png deleted file mode 100755 index 1c2bd317c..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_av_play_arrow_grey_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_book_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_book_white_24dp.png deleted file mode 100644 index 8b02f9ac2..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_book_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_clear_grey_24dp_img.png b/app/src/main/res/drawable-mdpi/ic_clear_grey_24dp_img.png deleted file mode 100755 index cd8b425e9..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_clear_grey_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_delete_grey_24dp.png b/app/src/main/res/drawable-mdpi/ic_delete_grey_24dp.png deleted file mode 100755 index a0df89686..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_delete_grey_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_insert_photo_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_insert_photo_white_24dp.png deleted file mode 100755 index d474bd577..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_insert_photo_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_refresh_grey_24dp_img.png b/app/src/main/res/drawable-mdpi/ic_refresh_grey_24dp_img.png deleted file mode 100755 index 895d64406..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_refresh_grey_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_refresh_white_24dp_img.png b/app/src/main/res/drawable-mdpi/ic_refresh_white_24dp_img.png deleted file mode 100755 index a2a475f6d..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_refresh_white_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_share_grey_24dp.png b/app/src/main/res/drawable-mdpi/ic_share_grey_24dp.png deleted file mode 100755 index e2814bdc9..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_share_grey_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_system_update_grey_24dp_img.png b/app/src/main/res/drawable-mdpi/ic_system_update_grey_24dp_img.png deleted file mode 100755 index 33cca7074..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_system_update_grey_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/splash_icon.webp b/app/src/main/res/drawable-mdpi/splash_icon.webp new file mode 100644 index 000000000..f78eb6609 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/splash_icon.webp differ diff --git a/app/src/main/res/drawable-mdpi/tachiyomi_circle.png b/app/src/main/res/drawable-mdpi/tachiyomi_circle.png deleted file mode 100644 index 94cf3a1d6..000000000 Binary files a/app/src/main/res/drawable-mdpi/tachiyomi_circle.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/filter_mock.webp b/app/src/main/res/drawable-nodpi/filter_mock.webp new file mode 100644 index 000000000..c65b390cb Binary files /dev/null and b/app/src/main/res/drawable-nodpi/filter_mock.webp differ diff --git a/app/src/main/res/drawable-nodpi/ic_tracker_anilist.webp b/app/src/main/res/drawable-nodpi/ic_tracker_anilist.webp new file mode 100644 index 000000000..b553d6c49 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_tracker_anilist.webp differ diff --git a/app/src/main/res/drawable-nodpi/ic_tracker_bangumi.webp b/app/src/main/res/drawable-nodpi/ic_tracker_bangumi.webp new file mode 100644 index 000000000..26b1d4e96 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_tracker_bangumi.webp differ diff --git a/app/src/main/res/drawable-nodpi/ic_tracker_kitsu.webp b/app/src/main/res/drawable-nodpi/ic_tracker_kitsu.webp new file mode 100644 index 000000000..f34cef52e Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_tracker_kitsu.webp differ diff --git a/app/src/main/res/drawable-nodpi/ic_tracker_mal.webp b/app/src/main/res/drawable-nodpi/ic_tracker_mal.webp new file mode 100644 index 000000000..a9c6f2e92 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_tracker_mal.webp differ diff --git a/app/src/main/res/drawable-nodpi/ic_tracker_shikimori.webp b/app/src/main/res/drawable-nodpi/ic_tracker_shikimori.webp new file mode 100644 index 000000000..f08ce3839 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_tracker_shikimori.webp differ diff --git a/app/src/main/res/drawable-v21/library_item_selector_amoled.xml b/app/src/main/res/drawable-v21/library_item_selector_amoled.xml deleted file mode 100644 index b21f488c2..000000000 --- a/app/src/main/res/drawable-v21/library_item_selector_amoled.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable-v21/library_item_selector_dark.xml b/app/src/main/res/drawable-v21/library_item_selector_dark.xml deleted file mode 100755 index 82a72da4a..000000000 --- a/app/src/main/res/drawable-v21/library_item_selector_dark.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable-v21/library_item_selector_light.xml b/app/src/main/res/drawable-v21/library_item_selector_light.xml deleted file mode 100755 index 1f2e8bf89..000000000 --- a/app/src/main/res/drawable-v21/library_item_selector_light.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable-v21/list_item_selector_amoled.xml b/app/src/main/res/drawable-v21/list_item_selector_amoled.xml deleted file mode 100644 index 0fce81a34..000000000 --- a/app/src/main/res/drawable-v21/list_item_selector_amoled.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable-xhdpi/card_background.9.png b/app/src/main/res/drawable-xhdpi/card_background.9.png deleted file mode 100755 index 8190c3b27..000000000 Binary files a/app/src/main/res/drawable-xhdpi/card_background.9.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_av_pause_grey_24dp_img.png b/app/src/main/res/drawable-xhdpi/ic_av_pause_grey_24dp_img.png deleted file mode 100755 index c218aee56..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_av_pause_grey_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_av_play_arrow_grey_img.png b/app/src/main/res/drawable-xhdpi/ic_av_play_arrow_grey_img.png deleted file mode 100755 index d5d467c42..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_av_play_arrow_grey_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_book_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_book_white_24dp.png deleted file mode 100644 index 5a5ee3041..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_book_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_clear_grey_24dp_img.png b/app/src/main/res/drawable-xhdpi/ic_clear_grey_24dp_img.png deleted file mode 100755 index dd1c49e09..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_clear_grey_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_delete_grey_24dp.png b/app/src/main/res/drawable-xhdpi/ic_delete_grey_24dp.png deleted file mode 100755 index 4ca7f7c9a..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_delete_grey_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_insert_photo_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_insert_photo_white_24dp.png deleted file mode 100755 index 2642b9e09..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_insert_photo_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_refresh_grey_24dp_img.png b/app/src/main/res/drawable-xhdpi/ic_refresh_grey_24dp_img.png deleted file mode 100755 index 848f2fb5a..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_refresh_grey_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_refresh_white_24dp_img.png b/app/src/main/res/drawable-xhdpi/ic_refresh_white_24dp_img.png deleted file mode 100755 index c11b6b948..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_refresh_white_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_share_grey_24dp.png b/app/src/main/res/drawable-xhdpi/ic_share_grey_24dp.png deleted file mode 100755 index f691067f7..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_share_grey_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_system_update_grey_24dp_img.png b/app/src/main/res/drawable-xhdpi/ic_system_update_grey_24dp_img.png deleted file mode 100755 index 37db7d1d4..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_system_update_grey_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/splash_icon.webp b/app/src/main/res/drawable-xhdpi/splash_icon.webp new file mode 100644 index 000000000..319757708 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/splash_icon.webp differ diff --git a/app/src/main/res/drawable-xhdpi/tachiyomi_circle.png b/app/src/main/res/drawable-xhdpi/tachiyomi_circle.png deleted file mode 100644 index 3b62c92b3..000000000 Binary files a/app/src/main/res/drawable-xhdpi/tachiyomi_circle.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_av_pause_grey_24dp_img.png b/app/src/main/res/drawable-xxhdpi/ic_av_pause_grey_24dp_img.png deleted file mode 100755 index 803a258ff..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_av_pause_grey_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_av_play_arrow_grey_img.png b/app/src/main/res/drawable-xxhdpi/ic_av_play_arrow_grey_img.png deleted file mode 100755 index cf825e63e..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_av_play_arrow_grey_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_book_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_book_white_24dp.png deleted file mode 100644 index d181a3215..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_book_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_clear_grey_24dp_img.png b/app/src/main/res/drawable-xxhdpi/ic_clear_grey_24dp_img.png deleted file mode 100755 index bbf521cbc..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_clear_grey_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_delete_grey_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_delete_grey_24dp.png deleted file mode 100755 index 209e555ee..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_delete_grey_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_insert_photo_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_insert_photo_white_24dp.png deleted file mode 100755 index f9f1defa6..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_insert_photo_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_refresh_grey_24dp_img.png b/app/src/main/res/drawable-xxhdpi/ic_refresh_grey_24dp_img.png deleted file mode 100755 index 945fd729d..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_refresh_grey_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_refresh_white_24dp_img.png b/app/src/main/res/drawable-xxhdpi/ic_refresh_white_24dp_img.png deleted file mode 100755 index a2ae1a3ac..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_refresh_white_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_share_grey_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_share_grey_24dp.png deleted file mode 100755 index 9ed5b4d43..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_share_grey_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_system_update_grey_24dp_img.png b/app/src/main/res/drawable-xxhdpi/ic_system_update_grey_24dp_img.png deleted file mode 100755 index d24aba7b2..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_system_update_grey_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/splash_icon.webp b/app/src/main/res/drawable-xxhdpi/splash_icon.webp new file mode 100644 index 000000000..cbfd18675 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/splash_icon.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tachiyomi_circle.png b/app/src/main/res/drawable-xxhdpi/tachiyomi_circle.png deleted file mode 100644 index dd8cad897..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/tachiyomi_circle.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/al.png b/app/src/main/res/drawable-xxxhdpi/al.png deleted file mode 100755 index 6529ad678..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/al.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/bangumi.png b/app/src/main/res/drawable-xxxhdpi/bangumi.png deleted file mode 100644 index 412a28ba1..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/bangumi.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_av_pause_grey_24dp_img.png b/app/src/main/res/drawable-xxxhdpi/ic_av_pause_grey_24dp_img.png deleted file mode 100755 index fdf4261dc..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_av_pause_grey_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_av_play_arrow_grey_img.png b/app/src/main/res/drawable-xxxhdpi/ic_av_play_arrow_grey_img.png deleted file mode 100755 index 7e1eef6c2..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_av_play_arrow_grey_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_book_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_book_white_24dp.png deleted file mode 100644 index 938eff46c..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_book_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_clear_grey_24dp_img.png b/app/src/main/res/drawable-xxxhdpi/ic_clear_grey_24dp_img.png deleted file mode 100755 index d0b537944..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_clear_grey_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_delete_grey_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_delete_grey_24dp.png deleted file mode 100755 index a1b86d06e..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_delete_grey_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_insert_photo_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_insert_photo_white_24dp.png deleted file mode 100755 index 2ffdb55f2..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_insert_photo_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_refresh_grey_24dp_img.png b/app/src/main/res/drawable-xxxhdpi/ic_refresh_grey_24dp_img.png deleted file mode 100755 index 7fc4e4799..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_refresh_grey_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_refresh_white_24dp_img.png b/app/src/main/res/drawable-xxxhdpi/ic_refresh_white_24dp_img.png deleted file mode 100755 index 4a4c70f68..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_refresh_white_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_share_grey_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_share_grey_24dp.png deleted file mode 100755 index 35b1664aa..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_share_grey_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_system_update_grey_24dp_img.png b/app/src/main/res/drawable-xxxhdpi/ic_system_update_grey_24dp_img.png deleted file mode 100755 index 8fa116da7..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_system_update_grey_24dp_img.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/kitsu.png b/app/src/main/res/drawable-xxxhdpi/kitsu.png deleted file mode 100755 index bb9caec08..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/kitsu.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/mal.png b/app/src/main/res/drawable-xxxhdpi/mal.png deleted file mode 100755 index b148f40be..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/mal.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/shikimori.png b/app/src/main/res/drawable-xxxhdpi/shikimori.png deleted file mode 100644 index 9859d16e6..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/shikimori.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/splash_icon.webp b/app/src/main/res/drawable-xxxhdpi/splash_icon.webp new file mode 100644 index 000000000..34e4ace57 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/splash_icon.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/tachiyomi_circle.png b/app/src/main/res/drawable-xxxhdpi/tachiyomi_circle.png deleted file mode 100644 index 1c1b2d8cc..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/tachiyomi_circle.png and /dev/null differ diff --git a/app/src/main/res/drawable/branded_logo.xml b/app/src/main/res/drawable/branded_logo.xml deleted file mode 100755 index 3d0d69d42..000000000 --- a/app/src/main/res/drawable/branded_logo.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/branded_logo_icon.png b/app/src/main/res/drawable/branded_logo_icon.png deleted file mode 100644 index ddcebeafa..000000000 Binary files a/app/src/main/res/drawable/branded_logo_icon.png and /dev/null differ diff --git a/app/src/main/res/drawable/button_bg_transparent.xml b/app/src/main/res/drawable/button_bg_transparent.xml deleted file mode 100644 index 84577aa47..000000000 --- a/app/src/main/res/drawable/button_bg_transparent.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/empty_divider.xml b/app/src/main/res/drawable/empty_divider.xml deleted file mode 100755 index 2d9192e3c..000000000 --- a/app/src/main/res/drawable/empty_divider.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/empty_drawable_32dp.xml b/app/src/main/res/drawable/empty_drawable_32dp.xml index de7699cab..09b50315d 100755 --- a/app/src/main/res/drawable/empty_drawable_32dp.xml +++ b/app/src/main/res/drawable/empty_drawable_32dp.xml @@ -1,8 +1,8 @@ - - - + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/filter_mock.png b/app/src/main/res/drawable/filter_mock.png deleted file mode 100755 index a6d6a0c00..000000000 Binary files a/app/src/main/res/drawable/filter_mock.png and /dev/null differ diff --git a/app/src/main/res/drawable/ic_account_circle_black_24dp.xml b/app/src/main/res/drawable/ic_account_circle_black_24dp.xml deleted file mode 100755 index 76785806d..000000000 --- a/app/src/main/res/drawable/ic_account_circle_black_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_add_24dp.xml b/app/src/main/res/drawable/ic_add_24dp.xml new file mode 100644 index 000000000..757f45033 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_add_to_library_24dp.xml b/app/src/main/res/drawable/ic_add_to_library_24dp.xml deleted file mode 100644 index bba483129..000000000 --- a/app/src/main/res/drawable/ic_add_to_library_24dp.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add_white_24dp.xml b/app/src/main/res/drawable/ic_add_white_24dp.xml deleted file mode 100755 index b9b8eca8b..000000000 --- a/app/src/main/res/drawable/ic_add_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_arrow_back_24dp.xml b/app/src/main/res/drawable/ic_arrow_back_24dp.xml new file mode 100644 index 000000000..676797dc9 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_forward_24dp.xml b/app/src/main/res/drawable/ic_arrow_forward_24dp.xml new file mode 100644 index 000000000..3473f8722 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_forward_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_backup_24dp.xml b/app/src/main/res/drawable/ic_backup_24dp.xml new file mode 100644 index 000000000..fad311c80 --- /dev/null +++ b/app/src/main/res/drawable/ic_backup_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_backup_black_24dp.xml b/app/src/main/res/drawable/ic_backup_black_24dp.xml deleted file mode 100755 index 086281669..000000000 --- a/app/src/main/res/drawable/ic_backup_black_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_book_24dp.xml b/app/src/main/res/drawable/ic_book_24dp.xml new file mode 100644 index 000000000..1f05b6210 --- /dev/null +++ b/app/src/main/res/drawable/ic_book_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_book_black_128dp.xml b/app/src/main/res/drawable/ic_book_black_128dp.xml deleted file mode 100755 index 5226a4ad5..000000000 --- a/app/src/main/res/drawable/ic_book_black_128dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_book_black_24dp.xml b/app/src/main/res/drawable/ic_book_black_24dp.xml deleted file mode 100755 index 811d5ac4b..000000000 --- a/app/src/main/res/drawable/ic_book_black_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_bookmark_24dp.xml b/app/src/main/res/drawable/ic_bookmark_24dp.xml new file mode 100644 index 000000000..50036d140 --- /dev/null +++ b/app/src/main/res/drawable/ic_bookmark_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_bookmark_border_white_24dp.xml b/app/src/main/res/drawable/ic_bookmark_border_24dp.xml old mode 100755 new mode 100644 similarity index 54% rename from app/src/main/res/drawable/ic_bookmark_border_white_24dp.xml rename to app/src/main/res/drawable/ic_bookmark_border_24dp.xml index e6596c2a2..6e530b0d8 --- a/app/src/main/res/drawable/ic_bookmark_border_white_24dp.xml +++ b/app/src/main/res/drawable/ic_bookmark_border_24dp.xml @@ -1,9 +1,9 @@ + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + android:fillColor="#000000" + android:pathData="M17,3L7,3c-1.1,0 -1.99,0.9 -1.99,2L5,21l7,-3 7,3L19,5c0,-1.1 -0.9,-2 -2,-2zM17,18l-5,-2.18L7,18L7,5h10v13z" /> diff --git a/app/src/main/res/drawable/ic_bookmark_white_24dp.xml b/app/src/main/res/drawable/ic_bookmark_white_24dp.xml deleted file mode 100755 index a291197be..000000000 --- a/app/src/main/res/drawable/ic_bookmark_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_brightness_4_24dp.xml b/app/src/main/res/drawable/ic_brightness_4_24dp.xml new file mode 100644 index 000000000..ed172b3d3 --- /dev/null +++ b/app/src/main/res/drawable/ic_brightness_4_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_brightness_4_white_24dp.xml b/app/src/main/res/drawable/ic_brightness_4_white_24dp.xml deleted file mode 100755 index 09f16c1d3..000000000 --- a/app/src/main/res/drawable/ic_brightness_4_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_brightness_5_24dp.xml b/app/src/main/res/drawable/ic_brightness_5_24dp.xml new file mode 100644 index 000000000..9d7b62932 --- /dev/null +++ b/app/src/main/res/drawable/ic_brightness_5_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_brightness_5_black_24dp.xml b/app/src/main/res/drawable/ic_brightness_5_black_24dp.xml deleted file mode 100755 index 54301c0aa..000000000 --- a/app/src/main/res/drawable/ic_brightness_5_black_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_chrome_reader_mode_24dp.xml b/app/src/main/res/drawable/ic_chrome_reader_mode_24dp.xml new file mode 100644 index 000000000..452d795fd --- /dev/null +++ b/app/src/main/res/drawable/ic_chrome_reader_mode_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_chrome_reader_mode_black_24dp.xml b/app/src/main/res/drawable/ic_chrome_reader_mode_black_24dp.xml deleted file mode 100755 index 99b5867b3..000000000 --- a/app/src/main/res/drawable/ic_chrome_reader_mode_black_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_close_24dp.xml b/app/src/main/res/drawable/ic_close_24dp.xml new file mode 100644 index 000000000..f3a79ee73 --- /dev/null +++ b/app/src/main/res/drawable/ic_close_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_cloud_off_24dp.xml b/app/src/main/res/drawable/ic_cloud_off_24dp.xml new file mode 100644 index 000000000..a71185fb7 --- /dev/null +++ b/app/src/main/res/drawable/ic_cloud_off_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_code_24dp.xml b/app/src/main/res/drawable/ic_code_24dp.xml new file mode 100644 index 000000000..47c222bb9 --- /dev/null +++ b/app/src/main/res/drawable/ic_code_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_code_black_24dp.xml b/app/src/main/res/drawable/ic_code_black_24dp.xml deleted file mode 100755 index 6f1ccb6e4..000000000 --- a/app/src/main/res/drawable/ic_code_black_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_collections_bookmark_24dp.xml b/app/src/main/res/drawable/ic_collections_bookmark_24dp.xml new file mode 100644 index 000000000..3a4991201 --- /dev/null +++ b/app/src/main/res/drawable/ic_collections_bookmark_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_collections_bookmark_outline_24dp.xml b/app/src/main/res/drawable/ic_collections_bookmark_outline_24dp.xml new file mode 100644 index 000000000..3c0dac124 --- /dev/null +++ b/app/src/main/res/drawable/ic_collections_bookmark_outline_24dp.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_collections_bookmark_state.xml b/app/src/main/res/drawable/ic_collections_bookmark_state.xml new file mode 100644 index 000000000..573305b57 --- /dev/null +++ b/app/src/main/res/drawable/ic_collections_bookmark_state.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_compare_arrows_black_24dp.xml b/app/src/main/res/drawable/ic_compare_arrows_black_24dp.xml new file mode 100644 index 000000000..261c56592 --- /dev/null +++ b/app/src/main/res/drawable/ic_compare_arrows_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_create_white_24dp.xml b/app/src/main/res/drawable/ic_create_white_24dp.xml deleted file mode 100755 index 35a774a51..000000000 --- a/app/src/main/res/drawable/ic_create_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_delete_24dp.xml b/app/src/main/res/drawable/ic_delete_24dp.xml new file mode 100644 index 000000000..eb3494ac2 --- /dev/null +++ b/app/src/main/res/drawable/ic_delete_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_delete_white_24dp.xml b/app/src/main/res/drawable/ic_delete_white_24dp.xml deleted file mode 100755 index f9213d2b5..000000000 --- a/app/src/main/res/drawable/ic_delete_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_done_24dp.xml b/app/src/main/res/drawable/ic_done_24dp.xml new file mode 100644 index 000000000..ec56a7711 --- /dev/null +++ b/app/src/main/res/drawable/ic_done_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_done_all_grey_24dp.xml b/app/src/main/res/drawable/ic_done_all_grey_24dp.xml deleted file mode 100755 index 9cbc7fdf9..000000000 --- a/app/src/main/res/drawable/ic_done_all_grey_24dp.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_done_all_white_24dp.xml b/app/src/main/res/drawable/ic_done_all_white_24dp.xml deleted file mode 100755 index 3fa4edb58..000000000 --- a/app/src/main/res/drawable/ic_done_all_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_done_outline_24dp.xml b/app/src/main/res/drawable/ic_done_outline_24dp.xml new file mode 100644 index 000000000..604980132 --- /dev/null +++ b/app/src/main/res/drawable/ic_done_outline_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_done_prev_24dp.xml b/app/src/main/res/drawable/ic_done_prev_24dp.xml new file mode 100644 index 000000000..7b8a89a7f --- /dev/null +++ b/app/src/main/res/drawable/ic_done_prev_24dp.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_done_white_18dp.xml b/app/src/main/res/drawable/ic_done_white_18dp.xml index 3bd793040..3e9103eb0 100755 --- a/app/src/main/res/drawable/ic_done_white_18dp.xml +++ b/app/src/main/res/drawable/ic_done_white_18dp.xml @@ -1,9 +1,9 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_drag_pill_24dp.xml b/app/src/main/res/drawable/ic_drag_pill_24dp.xml new file mode 100644 index 000000000..51aca297e --- /dev/null +++ b/app/src/main/res/drawable/ic_drag_pill_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_edit_24dp.xml b/app/src/main/res/drawable/ic_edit_24dp.xml new file mode 100644 index 000000000..c11d0729c --- /dev/null +++ b/app/src/main/res/drawable/ic_edit_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_explore_24dp.xml b/app/src/main/res/drawable/ic_explore_24dp.xml new file mode 100644 index 000000000..ad8ea9c41 --- /dev/null +++ b/app/src/main/res/drawable/ic_explore_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_explore_black_24dp.xml b/app/src/main/res/drawable/ic_explore_black_24dp.xml deleted file mode 100755 index 68aa81479..000000000 --- a/app/src/main/res/drawable/ic_explore_black_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_explore_outline_24dp.xml b/app/src/main/res/drawable/ic_explore_outline_24dp.xml new file mode 100644 index 000000000..f42cbf95e --- /dev/null +++ b/app/src/main/res/drawable/ic_explore_outline_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_explore_state.xml b/app/src/main/res/drawable/ic_explore_state.xml new file mode 100644 index 000000000..52e685bd0 --- /dev/null +++ b/app/src/main/res/drawable/ic_explore_state.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_extension_24dp.xml b/app/src/main/res/drawable/ic_extension_24dp.xml new file mode 100644 index 000000000..0f4323839 --- /dev/null +++ b/app/src/main/res/drawable/ic_extension_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_extension_black_24dp.xml b/app/src/main/res/drawable/ic_extension_black_24dp.xml deleted file mode 100644 index d3dd09481..000000000 --- a/app/src/main/res/drawable/ic_extension_black_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_favorite_24dp.xml b/app/src/main/res/drawable/ic_favorite_24dp.xml new file mode 100644 index 000000000..41f391057 --- /dev/null +++ b/app/src/main/res/drawable/ic_favorite_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_favorite_border_24dp.xml b/app/src/main/res/drawable/ic_favorite_border_24dp.xml new file mode 100644 index 000000000..0811b85db --- /dev/null +++ b/app/src/main/res/drawable/ic_favorite_border_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_file_download_black_128dp.xml b/app/src/main/res/drawable/ic_file_download_black_128dp.xml deleted file mode 100755 index 6edeb1b28..000000000 --- a/app/src/main/res/drawable/ic_file_download_black_128dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_file_download_white_24dp.xml b/app/src/main/res/drawable/ic_file_download_white_24dp.xml deleted file mode 100755 index e43b8645a..000000000 --- a/app/src/main/res/drawable/ic_file_download_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_filter_list_24dp.xml b/app/src/main/res/drawable/ic_filter_list_24dp.xml new file mode 100644 index 000000000..5959f25e2 --- /dev/null +++ b/app/src/main/res/drawable/ic_filter_list_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_filter_list_white_24dp.xml b/app/src/main/res/drawable/ic_filter_list_white_24dp.xml deleted file mode 100755 index 7d435fa2b..000000000 --- a/app/src/main/res/drawable/ic_filter_list_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_flip_to_back_24dp.xml b/app/src/main/res/drawable/ic_flip_to_back_24dp.xml new file mode 100644 index 000000000..30fe85acd --- /dev/null +++ b/app/src/main/res/drawable/ic_flip_to_back_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_glasses_black_128dp.xml b/app/src/main/res/drawable/ic_glasses_black_128dp.xml deleted file mode 100755 index fbf52def5..000000000 --- a/app/src/main/res/drawable/ic_glasses_black_128dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_help_24dp.xml b/app/src/main/res/drawable/ic_help_24dp.xml new file mode 100644 index 000000000..0b44954d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_help_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_help_black_24dp.xml b/app/src/main/res/drawable/ic_help_black_24dp.xml deleted file mode 100755 index 1517747d0..000000000 --- a/app/src/main/res/drawable/ic_help_black_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_history_24dp.xml b/app/src/main/res/drawable/ic_history_24dp.xml new file mode 100644 index 000000000..c1caa0b0d --- /dev/null +++ b/app/src/main/res/drawable/ic_history_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_image_black_24dp.xml b/app/src/main/res/drawable/ic_image_black_24dp.xml deleted file mode 100644 index b2018595e..000000000 --- a/app/src/main/res/drawable/ic_image_black_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_in_library_24dp.xml b/app/src/main/res/drawable/ic_in_library_24dp.xml deleted file mode 100644 index f95fc68ae..000000000 --- a/app/src/main/res/drawable/ic_in_library_24dp.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_info_24dp.xml b/app/src/main/res/drawable/ic_info_24dp.xml new file mode 100644 index 000000000..4202822cc --- /dev/null +++ b/app/src/main/res/drawable/ic_info_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_insert_photo_white_24dp.png b/app/src/main/res/drawable/ic_insert_photo_white_24dp.png deleted file mode 100755 index d474bd577..000000000 Binary files a/app/src/main/res/drawable/ic_insert_photo_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable/ic_keyboard_arrow_down_black_32dp.xml b/app/src/main/res/drawable/ic_keyboard_arrow_down_black_32dp.xml deleted file mode 100755 index 8a9f89914..000000000 --- a/app/src/main/res/drawable/ic_keyboard_arrow_down_black_32dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_keyboard_arrow_up_black_32dp.xml b/app/src/main/res/drawable/ic_keyboard_arrow_up_black_32dp.xml deleted file mode 100755 index 7d7c89294..000000000 --- a/app/src/main/res/drawable/ic_keyboard_arrow_up_black_32dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_label_24dp.xml b/app/src/main/res/drawable/ic_label_24dp.xml new file mode 100644 index 000000000..3a37a2e9d --- /dev/null +++ b/app/src/main/res/drawable/ic_label_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_label_white_24dp.xml b/app/src/main/res/drawable/ic_label_white_24dp.xml deleted file mode 100755 index b3c937144..000000000 --- a/app/src/main/res/drawable/ic_label_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_language_black_24dp.xml b/app/src/main/res/drawable/ic_language_black_24dp.xml deleted file mode 100755 index d07324c87..000000000 --- a/app/src/main/res/drawable/ic_language_black_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_menu_white_24dp.xml b/app/src/main/res/drawable/ic_menu_white_24dp.xml deleted file mode 100755 index cf37e2a39..000000000 --- a/app/src/main/res/drawable/ic_menu_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_more_horiz_24dp.xml b/app/src/main/res/drawable/ic_more_horiz_24dp.xml new file mode 100644 index 000000000..c437098f5 --- /dev/null +++ b/app/src/main/res/drawable/ic_more_horiz_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_more_horiz_black_24dp.xml b/app/src/main/res/drawable/ic_more_horiz_black_24dp.xml deleted file mode 100755 index da83afdb1..000000000 --- a/app/src/main/res/drawable/ic_more_horiz_black_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_more_vert_24dp.xml b/app/src/main/res/drawable/ic_more_vert_24dp.xml new file mode 100644 index 000000000..568cbb4d2 --- /dev/null +++ b/app/src/main/res/drawable/ic_more_vert_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_more_vert_black_24dp.xml b/app/src/main/res/drawable/ic_more_vert_black_24dp.xml deleted file mode 100644 index 0ef23a567..000000000 --- a/app/src/main/res/drawable/ic_more_vert_black_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_new_releases_24dp.xml b/app/src/main/res/drawable/ic_new_releases_24dp.xml new file mode 100644 index 000000000..f401891cb --- /dev/null +++ b/app/src/main/res/drawable/ic_new_releases_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_new_releases_outline_24dp.xml b/app/src/main/res/drawable/ic_new_releases_outline_24dp.xml new file mode 100644 index 000000000..bfc9e87d3 --- /dev/null +++ b/app/src/main/res/drawable/ic_new_releases_outline_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_new_releases_state.xml b/app/src/main/res/drawable/ic_new_releases_state.xml new file mode 100644 index 000000000..411b420d4 --- /dev/null +++ b/app/src/main/res/drawable/ic_new_releases_state.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_no_settings.xml b/app/src/main/res/drawable/ic_no_settings.xml deleted file mode 100644 index 71acd27e8..000000000 --- a/app/src/main/res/drawable/ic_no_settings.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_pause_24dp.xml b/app/src/main/res/drawable/ic_pause_24dp.xml new file mode 100644 index 000000000..ed500a758 --- /dev/null +++ b/app/src/main/res/drawable/ic_pause_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_pause_white_24dp.xml b/app/src/main/res/drawable/ic_pause_white_24dp.xml deleted file mode 100755 index 8356ff57f..000000000 --- a/app/src/main/res/drawable/ic_pause_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_photo_24dp.xml b/app/src/main/res/drawable/ic_photo_24dp.xml new file mode 100644 index 000000000..804484a0d --- /dev/null +++ b/app/src/main/res/drawable/ic_photo_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_play_arrow_24dp.xml b/app/src/main/res/drawable/ic_play_arrow_24dp.xml new file mode 100644 index 000000000..370d19ad2 --- /dev/null +++ b/app/src/main/res/drawable/ic_play_arrow_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_play_arrow_white_24dp.xml b/app/src/main/res/drawable/ic_play_arrow_white_24dp.xml deleted file mode 100755 index 81a8f74f6..000000000 --- a/app/src/main/res/drawable/ic_play_arrow_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_public_24dp.xml b/app/src/main/res/drawable/ic_public_24dp.xml new file mode 100644 index 000000000..70fa66d42 --- /dev/null +++ b/app/src/main/res/drawable/ic_public_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_refresh_24dp.xml b/app/src/main/res/drawable/ic_refresh_24dp.xml new file mode 100644 index 000000000..aaefaea82 --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_refresh_white_24dp.xml b/app/src/main/res/drawable/ic_refresh_white_24dp.xml deleted file mode 100755 index a8175c316..000000000 --- a/app/src/main/res/drawable/ic_refresh_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_search_24dp.xml b/app/src/main/res/drawable/ic_search_24dp.xml new file mode 100644 index 000000000..da931c27d --- /dev/null +++ b/app/src/main/res/drawable/ic_search_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_search_white_24dp.xml b/app/src/main/res/drawable/ic_search_white_24dp.xml deleted file mode 100755 index 47432c174..000000000 --- a/app/src/main/res/drawable/ic_search_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_security_24dp.xml b/app/src/main/res/drawable/ic_security_24dp.xml new file mode 100644 index 000000000..0cde5e26b --- /dev/null +++ b/app/src/main/res/drawable/ic_security_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_select_all_24dp.xml b/app/src/main/res/drawable/ic_select_all_24dp.xml new file mode 100644 index 000000000..2f59cda50 --- /dev/null +++ b/app/src/main/res/drawable/ic_select_all_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_select_all_white_24dp.xml b/app/src/main/res/drawable/ic_select_all_white_24dp.xml deleted file mode 100755 index 0fc49c923..000000000 --- a/app/src/main/res/drawable/ic_select_all_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_settings_24dp.xml b/app/src/main/res/drawable/ic_settings_24dp.xml new file mode 100644 index 000000000..282c986f1 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings_black_24dp.xml b/app/src/main/res/drawable/ic_settings_black_24dp.xml deleted file mode 100755 index ace746c40..000000000 --- a/app/src/main/res/drawable/ic_settings_black_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_settings_white_24dp.xml b/app/src/main/res/drawable/ic_settings_white_24dp.xml deleted file mode 100755 index ce997a727..000000000 --- a/app/src/main/res/drawable/ic_settings_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_shape_black_128dp.xml b/app/src/main/res/drawable/ic_shape_black_128dp.xml deleted file mode 100644 index 98a101f5e..000000000 --- a/app/src/main/res/drawable/ic_shape_black_128dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_share_24dp.xml b/app/src/main/res/drawable/ic_share_24dp.xml new file mode 100644 index 000000000..2e1444ec0 --- /dev/null +++ b/app/src/main/res/drawable/ic_share_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_share_white_24dp.xml b/app/src/main/res/drawable/ic_share_white_24dp.xml deleted file mode 100755 index c5027c659..000000000 --- a/app/src/main/res/drawable/ic_share_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_skip_next_24dp.xml b/app/src/main/res/drawable/ic_skip_next_24dp.xml new file mode 100644 index 000000000..8e7d7496d --- /dev/null +++ b/app/src/main/res/drawable/ic_skip_next_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_skip_next_white_24dp.xml b/app/src/main/res/drawable/ic_skip_next_white_24dp.xml deleted file mode 100755 index 5d1017b1c..000000000 --- a/app/src/main/res/drawable/ic_skip_next_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_skip_previous_24dp.xml b/app/src/main/res/drawable/ic_skip_previous_24dp.xml new file mode 100644 index 000000000..5fb38c803 --- /dev/null +++ b/app/src/main/res/drawable/ic_skip_previous_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_skip_previous_white_24dp.xml b/app/src/main/res/drawable/ic_skip_previous_white_24dp.xml deleted file mode 100755 index 752878575..000000000 --- a/app/src/main/res/drawable/ic_skip_previous_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_sort_white_24dp.xml b/app/src/main/res/drawable/ic_sort_white_24dp.xml deleted file mode 100755 index a0c153ad0..000000000 --- a/app/src/main/res/drawable/ic_sort_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_sync_24dp.xml b/app/src/main/res/drawable/ic_sync_24dp.xml new file mode 100644 index 000000000..4a0ebe92e --- /dev/null +++ b/app/src/main/res/drawable/ic_sync_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_sync_black_24dp.xml b/app/src/main/res/drawable/ic_sync_black_24dp.xml deleted file mode 100755 index ce8796cb7..000000000 --- a/app/src/main/res/drawable/ic_sync_black_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_system_update_alt_white_24dp.xml b/app/src/main/res/drawable/ic_system_update_alt_white_24dp.xml new file mode 100644 index 000000000..39f00e288 --- /dev/null +++ b/app/src/main/res/drawable/ic_system_update_alt_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_tachi.xml b/app/src/main/res/drawable/ic_tachi.xml new file mode 100644 index 000000000..0c988fbf1 --- /dev/null +++ b/app/src/main/res/drawable/ic_tachi.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_tune_24dp.xml b/app/src/main/res/drawable/ic_tune_24dp.xml new file mode 100644 index 000000000..c5e7c355a --- /dev/null +++ b/app/src/main/res/drawable/ic_tune_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_tune_black_24dp.xml b/app/src/main/res/drawable/ic_tune_black_24dp.xml deleted file mode 100755 index a15149da2..000000000 --- a/app/src/main/res/drawable/ic_tune_black_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_update_black_128dp.xml b/app/src/main/res/drawable/ic_update_black_128dp.xml deleted file mode 100755 index 5b89fc3c3..000000000 --- a/app/src/main/res/drawable/ic_update_black_128dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_update_black_24dp.xml b/app/src/main/res/drawable/ic_update_black_24dp.xml deleted file mode 100755 index a1a7cbdfd..000000000 --- a/app/src/main/res/drawable/ic_update_black_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_view_list_24dp.xml b/app/src/main/res/drawable/ic_view_list_24dp.xml new file mode 100644 index 000000000..fee886818 --- /dev/null +++ b/app/src/main/res/drawable/ic_view_list_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_view_list_white_24dp.xml b/app/src/main/res/drawable/ic_view_list_white_24dp.xml deleted file mode 100755 index 222dc3b35..000000000 --- a/app/src/main/res/drawable/ic_view_list_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_view_module_24dp.xml b/app/src/main/res/drawable/ic_view_module_24dp.xml new file mode 100644 index 000000000..1a67bbf97 --- /dev/null +++ b/app/src/main/res/drawable/ic_view_module_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_view_module_white_24dp.xml b/app/src/main/res/drawable/ic_view_module_white_24dp.xml deleted file mode 100755 index aeb72e7b4..000000000 --- a/app/src/main/res/drawable/ic_view_module_white_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_watch_later_black_24dp.xml b/app/src/main/res/drawable/ic_watch_later_black_24dp.xml deleted file mode 100755 index 6032098bd..000000000 --- a/app/src/main/res/drawable/ic_watch_later_black_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-v21/list_item_selector_light.xml b/app/src/main/res/drawable/library_item_selector.xml old mode 100755 new mode 100644 similarity index 55% rename from app/src/main/res/drawable-v21/list_item_selector_light.xml rename to app/src/main/res/drawable/library_item_selector.xml index 942446ef0..8dc63c317 --- a/app/src/main/res/drawable-v21/list_item_selector_light.xml +++ b/app/src/main/res/drawable/library_item_selector.xml @@ -1,19 +1,19 @@ + android:color="?attr/colorLibrarySelection"> - + - + - + - \ No newline at end of file + diff --git a/app/src/main/res/drawable/library_item_selector_amoled.xml b/app/src/main/res/drawable/library_item_selector_amoled.xml deleted file mode 100644 index 1cf05bdc9..000000000 --- a/app/src/main/res/drawable/library_item_selector_amoled.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/library_item_selector_dark.xml b/app/src/main/res/drawable/library_item_selector_dark.xml deleted file mode 100755 index 9880c4b38..000000000 --- a/app/src/main/res/drawable/library_item_selector_dark.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/library_item_selector_light.xml b/app/src/main/res/drawable/library_item_selector_light.xml deleted file mode 100755 index 70f7b85b4..000000000 --- a/app/src/main/res/drawable/library_item_selector_light.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/line_divider_dark.xml b/app/src/main/res/drawable/line_divider.xml old mode 100755 new mode 100644 similarity index 77% rename from app/src/main/res/drawable/line_divider_dark.xml rename to app/src/main/res/drawable/line_divider.xml index 3d8da577a..454cad4bd --- a/app/src/main/res/drawable/line_divider_dark.xml +++ b/app/src/main/res/drawable/line_divider.xml @@ -6,6 +6,6 @@ android:width="1dp" android:height="1dp" /> - + - \ No newline at end of file + diff --git a/app/src/main/res/drawable/line_divider_light.xml b/app/src/main/res/drawable/line_divider_light.xml deleted file mode 100755 index eca76daba..000000000 --- a/app/src/main/res/drawable/line_divider_light.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable-v21/list_item_selector_dark.xml b/app/src/main/res/drawable/list_item_selector.xml old mode 100755 new mode 100644 similarity index 59% rename from app/src/main/res/drawable-v21/list_item_selector_dark.xml rename to app/src/main/res/drawable/list_item_selector.xml index 07b9ef6d5..1e2592665 --- a/app/src/main/res/drawable-v21/list_item_selector_dark.xml +++ b/app/src/main/res/drawable/list_item_selector.xml @@ -1,19 +1,19 @@ + android:color="?attr/rippleColor"> - + - + - + - \ No newline at end of file + diff --git a/app/src/main/res/drawable/list_item_selector_amoled.xml b/app/src/main/res/drawable/list_item_selector_amoled.xml deleted file mode 100644 index 9bbf56578..000000000 --- a/app/src/main/res/drawable/list_item_selector_amoled.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/list_item_selector_dark.xml b/app/src/main/res/drawable/list_item_selector_dark.xml deleted file mode 100755 index 60034f818..000000000 --- a/app/src/main/res/drawable/list_item_selector_dark.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/list_item_selector_light.xml b/app/src/main/res/drawable/list_item_selector_light.xml deleted file mode 100755 index 92bed9fc9..000000000 --- a/app/src/main/res/drawable/list_item_selector_light.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/mask_star.png b/app/src/main/res/drawable/mask_star.png deleted file mode 100755 index 5d3c11953..000000000 Binary files a/app/src/main/res/drawable/mask_star.png and /dev/null differ diff --git a/app/src/main/res/drawable/sc_update_48dp.xml b/app/src/main/res/drawable/sc_collections_bookmark_48dp.xml similarity index 52% rename from app/src/main/res/drawable/sc_update_48dp.xml rename to app/src/main/res/drawable/sc_collections_bookmark_48dp.xml index 3e6ba0cb9..c16085854 100644 --- a/app/src/main/res/drawable/sc_update_48dp.xml +++ b/app/src/main/res/drawable/sc_collections_bookmark_48dp.xml @@ -6,7 +6,7 @@ android:viewportWidth="48"> + android:pathData="M4,6H2v14c0,1.1 0.9,2 2,2h14v-2H4V6z"/> + - \ No newline at end of file + diff --git a/app/src/main/res/drawable/sc_explore_48dp.xml b/app/src/main/res/drawable/sc_explore_48dp.xml index 1489806c8..911db2d81 100644 --- a/app/src/main/res/drawable/sc_explore_48dp.xml +++ b/app/src/main/res/drawable/sc_explore_48dp.xml @@ -6,7 +6,7 @@ android:viewportWidth="48"> - \ No newline at end of file + diff --git a/app/src/main/res/drawable/sc_glasses_48dp.xml b/app/src/main/res/drawable/sc_glasses_48dp.xml deleted file mode 100644 index 9fa1b0e15..000000000 --- a/app/src/main/res/drawable/sc_glasses_48dp.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/sc_history_48dp.xml b/app/src/main/res/drawable/sc_history_48dp.xml new file mode 100644 index 000000000..76d656f30 --- /dev/null +++ b/app/src/main/res/drawable/sc_history_48dp.xml @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/sc_book_48dp.xml b/app/src/main/res/drawable/sc_new_releases_48dp.xml similarity index 58% rename from app/src/main/res/drawable/sc_book_48dp.xml rename to app/src/main/res/drawable/sc_new_releases_48dp.xml index 35c7a7c2f..82ce2ac6f 100644 --- a/app/src/main/res/drawable/sc_book_48dp.xml +++ b/app/src/main/res/drawable/sc_new_releases_48dp.xml @@ -6,7 +6,7 @@ android:viewportWidth="48"> + android:pathData="M23,12l-2.44,-2.78 0.34,-3.68 -3.61,-0.82 -1.89,-3.18L12,3 8.6,1.54 6.71,4.72l-3.61,0.81 0.34,3.68L1,12l2.44,2.78 -0.34,3.69 3.61,0.82 1.89,3.18L12,21l3.4,1.46 1.89,-3.18 3.61,-0.82 -0.34,-3.68L23,12zM13,17h-2v-2h2v2zM13,13h-2L11,7h2v6z" /> - \ No newline at end of file + diff --git a/app/src/main/res/drawable/snackbar_bg.xml b/app/src/main/res/drawable/snackbar_bg.xml new file mode 100644 index 000000000..b26126c71 --- /dev/null +++ b/app/src/main/res/drawable/snackbar_bg.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/splash_background.xml b/app/src/main/res/drawable/splash_background.xml new file mode 100644 index 000000000..2829543da --- /dev/null +++ b/app/src/main/res/drawable/splash_background.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/tab_indicator.xml b/app/src/main/res/drawable/tab_indicator.xml new file mode 100644 index 000000000..10b03769d --- /dev/null +++ b/app/src/main/res/drawable/tab_indicator.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/app/src/main/res/drawable/text_button.xml b/app/src/main/res/drawable/text_button.xml deleted file mode 100644 index ef5c24c56..000000000 --- a/app/src/main/res/drawable/text_button.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/assets/fonts/PTSans-NarrowBold.ttf b/app/src/main/res/font/ptsans_narrow_bold.ttf old mode 100755 new mode 100644 similarity index 100% rename from app/src/main/assets/fonts/PTSans-NarrowBold.ttf rename to app/src/main/res/font/ptsans_narrow_bold.ttf diff --git a/app/src/main/res/layout-land/manga_info_controller.xml b/app/src/main/res/layout-land/manga_info_controller.xml index 80eece804..2326b83f8 100644 --- a/app/src/main/res/layout-land/manga_info_controller.xml +++ b/app/src/main/res/layout-land/manga_info_controller.xml @@ -1,78 +1,70 @@ - + android:layout_height="match_parent" + tools:context=".ui.browse.source.browse.BrowseSourceController"> + android:layout_height="match_parent"> - + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintVertical_bias="0.0"> - + + + + android:layout_marginTop="16dp" + android:layout_marginEnd="16dp" + android:layout_marginBottom="16dp" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toEndOf="@+id/manga_cover_card" + app:layout_constraintTop_toTopOf="parent"> + app:autoSizeMinTextSize="12sp" + app:autoSizeStepGranularity="2sp" + app:autoSizeTextType="uniform" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/manga_full_title" /> + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toEndOf="@+id/manga_author_label" /> + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/manga_author_label" /> + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toEndOf="@+id/manga_artist_label" /> + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/manga_artist_label" /> + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toEndOf="@+id/manga_chapters_label" /> + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/manga_chapters_label" /> + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toEndOf="@+id/manga_last_update_label" /> + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/manga_last_update_label" /> + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toEndOf="@+id/manga_status_label" /> + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/manga_status_label" /> + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toEndOf="@+id/manga_source_label" /> + + + + + + + + + + + + + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/actions_bar" /> + app:layout_constraintBottom_toTopOf="@id/manga_genres_tags_wrapper" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/manga_summary_label" /> - - - + android:layout_marginBottom="8dp" + app:layout_constrainedHeight="true" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/manga_summary"> -