Compare commits
No commits in common. 'b57069c55f64d145f50cf9c0f0cea1c3eb767a20' and 'fe5534b006322188080f6a8fa1d3f04bddb3b6c1' have entirely different histories.
b57069c55f
...
fe5534b006
@ -1,29 +0,0 @@
|
|||||||
**PLEASE READ THIS**
|
|
||||||
|
|
||||||
I acknowledge that:
|
|
||||||
|
|
||||||
- I have updated to the latest version of the app (https://github.com/KotatsuApp/Kotatsu/releases/latest)
|
|
||||||
- If this is an issue with a parser, that I should be opening an issue in https://github.com/KotatsuApp/kotatsu-parsers
|
|
||||||
- I have searched the existing issues and this is new ticket **NOT** a duplicate or related to another open or closed issue
|
|
||||||
- I will fill out the title and the information in this template
|
|
||||||
|
|
||||||
Note that the issue will be automatically closed if you do not fill out the title or requested information.
|
|
||||||
|
|
||||||
**DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Device information
|
|
||||||
* Kotatsu version: ?
|
|
||||||
* Android version: ?
|
|
||||||
* Device: ?
|
|
||||||
|
|
||||||
## Steps to reproduce
|
|
||||||
1. First step
|
|
||||||
2. Second step
|
|
||||||
|
|
||||||
## Issue/Request
|
|
||||||
?
|
|
||||||
|
|
||||||
## Other details
|
|
||||||
Additional details and attachments.
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: ⚠️ Source issue
|
- name: ⚠️ Application issue
|
||||||
url: https://github.com/KotatsuApp/kotatsu-parsers/issues/new
|
url: https://github.com/KotatsuApp/Kotatsu/issues/new/choose
|
||||||
about: If you have troubles with a manga parser or want to propose new manga source, please open an issue in the kotatsu-parsers repository instead
|
about: Issues and requests about the app itself should be opened in the Kotatsu repository instead
|
||||||
@ -1,66 +0,0 @@
|
|||||||
name: 🐞 Bug report
|
|
||||||
description: Report a bug in Kotatsu
|
|
||||||
labels: [bug]
|
|
||||||
body:
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: summary
|
|
||||||
attributes:
|
|
||||||
label: Brief summary
|
|
||||||
description: Please describe, what went wrong
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: reproduce-steps
|
|
||||||
attributes:
|
|
||||||
label: Steps to reproduce
|
|
||||||
description: Please provide a way to reproduce this issue. Screenshots or videos can be very helpful
|
|
||||||
placeholder: |
|
|
||||||
Example:
|
|
||||||
1. First step
|
|
||||||
2. Second step
|
|
||||||
3. Issue here
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: kotatsu-version
|
|
||||||
attributes:
|
|
||||||
label: Kotatsu version
|
|
||||||
description: You can find your Kotatsu version in **Settings → About**.
|
|
||||||
placeholder: |
|
|
||||||
Example: "3.3"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: android-version
|
|
||||||
attributes:
|
|
||||||
label: Android version
|
|
||||||
description: You can find this somewhere in your Android settings.
|
|
||||||
placeholder: |
|
|
||||||
Example: "12.0"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: device
|
|
||||||
attributes:
|
|
||||||
label: Device
|
|
||||||
description: List your device and model.
|
|
||||||
placeholder: |
|
|
||||||
Example: "LG Nexus 5X"
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: checkboxes
|
|
||||||
id: acknowledgements
|
|
||||||
attributes:
|
|
||||||
label: Acknowledgements
|
|
||||||
options:
|
|
||||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
|
||||||
required: true
|
|
||||||
- label: If this is an issue with a parser, I should be opening an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose).
|
|
||||||
required: true
|
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
name: 🐞 Issue report
|
||||||
|
description: Report a source issue with a source
|
||||||
|
labels: [ bug ]
|
||||||
|
body:
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: source
|
||||||
|
attributes:
|
||||||
|
label: Source information
|
||||||
|
description: |
|
||||||
|
You can find the source name in navigation drawer.
|
||||||
|
placeholder: |
|
||||||
|
Example: "MangaDex"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: reproduce-steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
description: Provide an example of the issue.
|
||||||
|
placeholder: |
|
||||||
|
Example:
|
||||||
|
1. First step
|
||||||
|
2. Second step
|
||||||
|
3. Issue here
|
||||||
|
Please use English language
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: kotatsu-version
|
||||||
|
attributes:
|
||||||
|
label: Kotatsu version
|
||||||
|
description: |
|
||||||
|
You can find your Kotatsu version in **Settings → About**.
|
||||||
|
placeholder: |
|
||||||
|
Example: "3.3"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: android-version
|
||||||
|
attributes:
|
||||||
|
label: Android version
|
||||||
|
description: |
|
||||||
|
You can find this somewhere in your Android settings.
|
||||||
|
placeholder: |
|
||||||
|
Example: "Android 12"
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: other-details
|
||||||
|
attributes:
|
||||||
|
label: Other details
|
||||||
|
placeholder: |
|
||||||
|
Additional details and attachments.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: acknowledgements
|
||||||
|
attributes:
|
||||||
|
label: Acknowledgements
|
||||||
|
options:
|
||||||
|
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||||
|
required: true
|
||||||
@ -1,24 +1,31 @@
|
|||||||
name: ⭐ Feature request
|
name: ⭐ Feature request
|
||||||
description: Suggest a new idea how to improve Kotatsu
|
description: Suggest a feature to improve a source
|
||||||
labels: [feature request]
|
labels: [ feature request ]
|
||||||
body:
|
body:
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: feature-description
|
id: feature-description
|
||||||
attributes:
|
attributes:
|
||||||
label: Describe your suggested feature
|
label: Describe your suggested feature
|
||||||
description: How can Kotatsu be improved?
|
description: How can an existing source be improved?
|
||||||
placeholder: |
|
placeholder: |
|
||||||
Example:
|
Example:
|
||||||
"It should work like this..."
|
"It should work like this..."
|
||||||
validations:
|
Please use English language
|
||||||
required: true
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
- type: checkboxes
|
- type: textarea
|
||||||
id: acknowledgements
|
id: other-details
|
||||||
attributes:
|
attributes:
|
||||||
label: Acknowledgements
|
label: Other details
|
||||||
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
|
placeholder: |
|
||||||
options:
|
Additional details and attachments.
|
||||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
|
||||||
required: true
|
- type: checkboxes
|
||||||
|
id: acknowledgements
|
||||||
|
attributes:
|
||||||
|
label: Acknowledgements
|
||||||
|
options:
|
||||||
|
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||||
|
required: true
|
||||||
|
|||||||
@ -0,0 +1,31 @@
|
|||||||
|
name: 🗑 Source removal request
|
||||||
|
description: Scanlators can request their site to be removed
|
||||||
|
labels: [ source removal ]
|
||||||
|
body:
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: link
|
||||||
|
attributes:
|
||||||
|
label: Source link
|
||||||
|
placeholder: |
|
||||||
|
Example: "https://example.org"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: other-details
|
||||||
|
attributes:
|
||||||
|
label: Other details (reason for removal, etc)
|
||||||
|
placeholder: |
|
||||||
|
Additional details and attachments.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: requirements
|
||||||
|
attributes:
|
||||||
|
label: Requirements
|
||||||
|
description: Your request will be denied if you don't meet these requirements.
|
||||||
|
options:
|
||||||
|
- label: Proof of ownership of the website is sent to a Kotatsu [Discord server](https://discord.gg/NNJ5RgVBC5) or [Telegram community](https://t.me/kotatsuapp)
|
||||||
|
required: true
|
||||||
|
- label: Site only hosts content scanlated by the group and not stolen from other scanlators or official releases (i.e., not an aggregator site)
|
||||||
|
required: true
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
name: 🌐 Source request
|
||||||
|
description: Suggest a new source for Kotatsu
|
||||||
|
labels: [ source request ]
|
||||||
|
body:
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: Please specify source **name** and **language** in the issue title
|
||||||
|
- type: input
|
||||||
|
id: name
|
||||||
|
attributes:
|
||||||
|
label: Source name
|
||||||
|
placeholder: |
|
||||||
|
Example: "Example Scans"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: link
|
||||||
|
attributes:
|
||||||
|
label: Source link
|
||||||
|
placeholder: |
|
||||||
|
Example: "https://example.org"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: language
|
||||||
|
attributes:
|
||||||
|
label: Language
|
||||||
|
placeholder: |
|
||||||
|
Example: "English"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: other-details
|
||||||
|
attributes:
|
||||||
|
label: Other details
|
||||||
|
placeholder: |
|
||||||
|
Additional details and attachments.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: acknowledgements
|
||||||
|
attributes:
|
||||||
|
label: Acknowledgements
|
||||||
|
options:
|
||||||
|
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||||
|
required: true
|
||||||
|
- label: I have checked that the source does not already exist on the app.
|
||||||
|
required: true
|
||||||
|
- label: I have checked that the source does not already exist by searching the [GitHub repository](https://github.com/KotatsuApp/kotatsu-parsers) and verified it does not appear in the code base.
|
||||||
|
required: true
|
||||||
@ -0,0 +1 @@
|
|||||||
|
total: 1251
|
||||||
@ -1,29 +0,0 @@
|
|||||||
name: Issue moderator
|
|
||||||
|
|
||||||
on:
|
|
||||||
issues:
|
|
||||||
types: [opened, edited, reopened]
|
|
||||||
issue_comment:
|
|
||||||
types: [created]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
moderate:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Moderate issues
|
|
||||||
uses: tachiyomiorg/issue-moderator-action@v1
|
|
||||||
with:
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
auto-close-rules: |
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"type": "body",
|
|
||||||
"regex": ".*DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT.*",
|
|
||||||
"message": "The acknowledgment section was not removed."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "body",
|
|
||||||
"regex": ".*\\* (Kotatsu version|Android version|Device): \\?.*",
|
|
||||||
"message": "Requested information in the template was not filled out."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
name: Check & Test latest parsers
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-and-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository 🌏
|
||||||
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
|
- name: Set up enviroment 🔧
|
||||||
|
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||||
|
with:
|
||||||
|
java-version: '17'
|
||||||
|
distribution: 'temurin'
|
||||||
|
|
||||||
|
- name: Set up Gradle 📦
|
||||||
|
uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
|
||||||
|
with:
|
||||||
|
cache-read-only: true
|
||||||
|
|
||||||
|
- name: Compile parsers 🚀
|
||||||
|
run: ./gradlew compileKotlin
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
name: Parsers test for PRs
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'src/main/kotlin/org/koitharu/kotatsu/parsers/**'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository 🌏
|
||||||
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
|
- name: Set up enviroment 🔧
|
||||||
|
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||||
|
with:
|
||||||
|
java-version: '17'
|
||||||
|
distribution: 'temurin'
|
||||||
|
|
||||||
|
- name: Set up Gradle 📦
|
||||||
|
uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
|
||||||
|
with:
|
||||||
|
cache-read-only: true
|
||||||
|
|
||||||
|
- name: Compile parsers 🚀
|
||||||
|
run: ./gradlew compileKotlin
|
||||||
@ -1,26 +1,94 @@
|
|||||||
|
# User-specific stuff
|
||||||
|
.idea/**/workspace.xml
|
||||||
|
.idea/**/tasks.xml
|
||||||
|
.idea/**/usage.statistics.xml
|
||||||
|
.idea/**/dictionaries
|
||||||
|
.idea/**/shelf
|
||||||
|
.idea/**/copilot
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
.idea/**/contentModel.xml
|
||||||
|
|
||||||
|
# Sensitive or high-churn files
|
||||||
|
.idea/**/dataSources/
|
||||||
|
.idea/**/dataSources.ids
|
||||||
|
.idea/**/dataSources.local.xml
|
||||||
|
.idea/**/sqlDataSources.xml
|
||||||
|
.idea/**/dynamic.xml
|
||||||
|
.idea/**/uiDesigner.xml
|
||||||
|
.idea/**/dbnavigator.xml
|
||||||
|
.idea/**/Project_Default.xml
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
.idea/**/gradle.xml
|
||||||
|
.idea/**/libraries
|
||||||
|
|
||||||
|
# Gradle and Maven with auto-import
|
||||||
|
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||||
|
# since they will be recreated, and may cause churn. Uncomment if using
|
||||||
|
# auto-import.
|
||||||
|
.idea/deviceManager.xml
|
||||||
|
.idea/.name
|
||||||
|
.idea/artifacts
|
||||||
|
.idea/compiler.xml
|
||||||
|
.idea/jarRepositories.xml
|
||||||
|
.idea/modules.xml
|
||||||
|
.idea/ktlint-plugin.xml
|
||||||
|
.idea/*.iml
|
||||||
|
.idea/modules
|
||||||
*.iml
|
*.iml
|
||||||
.gradle
|
*.ipr
|
||||||
/local.properties
|
|
||||||
/.idea/caches
|
# CMake
|
||||||
/.idea/libraries
|
cmake-build-*/
|
||||||
/.idea/dictionaries
|
|
||||||
/.idea/modules.xml
|
# Mongo Explorer plugin
|
||||||
/.idea/misc.xml
|
.idea/**/mongoSettings.xml
|
||||||
/.idea/discord.xml
|
|
||||||
/.idea/compiler.xml
|
# File-based project format
|
||||||
/.idea/workspace.xml
|
*.iws
|
||||||
/.idea/navEditor.xml
|
|
||||||
/.idea/ktlint-plugin.xml
|
# IntelliJ
|
||||||
/.idea/assetWizardSettings.xml
|
out/
|
||||||
/.idea/kotlinScripting.xml
|
|
||||||
/.idea/kotlinc.xml
|
# mpeltonen/sbt-idea plugin
|
||||||
/.idea/deploymentTargetDropDown.xml
|
.idea_modules/
|
||||||
/.idea/androidTestResultsUserPreferences.xml
|
|
||||||
/.idea/deploymentTargetSelector.xml
|
# JIRA plugin
|
||||||
/.idea/render.experimental.xml
|
atlassian-ide-plugin.xml
|
||||||
/.idea/inspectionProfiles/
|
|
||||||
.DS_Store
|
# Cursive Clojure plugin
|
||||||
/build
|
.idea/replstate.xml
|
||||||
/captures
|
|
||||||
.externalNativeBuild
|
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||||
.cxx
|
com_crashlytics_export_strings.xml
|
||||||
|
crashlytics.properties
|
||||||
|
crashlytics-build.properties
|
||||||
|
fabric.properties
|
||||||
|
|
||||||
|
# Editor-based Rest Client
|
||||||
|
.idea/httpRequests
|
||||||
|
|
||||||
|
# Android studio 3.1+ serialized cache file
|
||||||
|
.idea/caches/build_file_checksums.ser
|
||||||
|
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
bin/
|
||||||
|
|
||||||
|
.idea/**/misc.xml
|
||||||
|
.idea/**/vcs.xml
|
||||||
|
.idea/**/ktlint.xml
|
||||||
|
.idea/codeStyles/
|
||||||
|
.idea/kotlinc.xml
|
||||||
|
|
||||||
|
src/test/resources/cookies.txt
|
||||||
|
local.properties
|
||||||
|
.kotlin/
|
||||||
|
!/.idea/kotlin-statistics.xml
|
||||||
|
|
||||||
|
.idea/**/discord.xml
|
||||||
|
.idea/**/migrations.xml
|
||||||
|
.idea/**/runConfigurations.xml
|
||||||
|
.idea/**/AndroidProjectSystem.xml
|
||||||
|
.idea/caches/deviceStreaming.xml
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
# Default ignored files
|
# Default ignored files
|
||||||
/shelf/
|
/shelf/
|
||||||
/workspace.xml
|
/workspace.xml
|
||||||
/migrations.xml
|
# GitHub Copilot persisted chat sessions
|
||||||
|
/copilot/chatSessions
|
||||||
|
|
||||||
|
.name
|
||||||
|
deviceManager.xml
|
||||||
|
|||||||
@ -1,187 +0,0 @@
|
|||||||
<component name="ProjectCodeStyleConfiguration">
|
|
||||||
<code_scheme name="Project" version="173">
|
|
||||||
<option name="OTHER_INDENT_OPTIONS">
|
|
||||||
<value>
|
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
|
||||||
</value>
|
|
||||||
</option>
|
|
||||||
<AndroidXmlCodeStyleSettings>
|
|
||||||
<option name="LAYOUT_SETTINGS">
|
|
||||||
<value>
|
|
||||||
<option name="INSERT_LINE_BREAK_BEFORE_NAMESPACE_DECLARATION" value="true" />
|
|
||||||
</value>
|
|
||||||
</option>
|
|
||||||
<option name="MANIFEST_SETTINGS">
|
|
||||||
<value>
|
|
||||||
<option name="INSERT_LINE_BREAK_BEFORE_NAMESPACE_DECLARATION" value="true" />
|
|
||||||
</value>
|
|
||||||
</option>
|
|
||||||
<option name="OTHER_SETTINGS">
|
|
||||||
<value>
|
|
||||||
<option name="INSERT_LINE_BREAK_BEFORE_NAMESPACE_DECLARATION" value="true" />
|
|
||||||
</value>
|
|
||||||
</option>
|
|
||||||
</AndroidXmlCodeStyleSettings>
|
|
||||||
<JetCodeStyleSettings>
|
|
||||||
<option name="ALLOW_TRAILING_COMMA" value="true" />
|
|
||||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
|
||||||
</JetCodeStyleSettings>
|
|
||||||
<codeStyleSettings language="CMake">
|
|
||||||
<indentOptions>
|
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
|
||||||
</indentOptions>
|
|
||||||
</codeStyleSettings>
|
|
||||||
<codeStyleSettings language="Groovy">
|
|
||||||
<indentOptions>
|
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
|
||||||
</indentOptions>
|
|
||||||
</codeStyleSettings>
|
|
||||||
<codeStyleSettings language="HTML">
|
|
||||||
<indentOptions>
|
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
|
||||||
</indentOptions>
|
|
||||||
</codeStyleSettings>
|
|
||||||
<codeStyleSettings language="JAVA">
|
|
||||||
<indentOptions>
|
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
|
||||||
</indentOptions>
|
|
||||||
</codeStyleSettings>
|
|
||||||
<codeStyleSettings language="JSON">
|
|
||||||
<indentOptions>
|
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
|
||||||
</indentOptions>
|
|
||||||
</codeStyleSettings>
|
|
||||||
<codeStyleSettings language="ObjectiveC">
|
|
||||||
<indentOptions>
|
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
|
||||||
</indentOptions>
|
|
||||||
</codeStyleSettings>
|
|
||||||
<codeStyleSettings language="Shell Script">
|
|
||||||
<indentOptions>
|
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
|
||||||
</indentOptions>
|
|
||||||
</codeStyleSettings>
|
|
||||||
<codeStyleSettings language="XML">
|
|
||||||
<indentOptions>
|
|
||||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
|
||||||
</indentOptions>
|
|
||||||
<arrangement>
|
|
||||||
<rules>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>xmlns:android</NAME>
|
|
||||||
<XML_ATTRIBUTE />
|
|
||||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>xmlns:.*</NAME>
|
|
||||||
<XML_ATTRIBUTE />
|
|
||||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
<order>BY_NAME</order>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>.*:id</NAME>
|
|
||||||
<XML_ATTRIBUTE />
|
|
||||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>.*:name</NAME>
|
|
||||||
<XML_ATTRIBUTE />
|
|
||||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>name</NAME>
|
|
||||||
<XML_ATTRIBUTE />
|
|
||||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>style</NAME>
|
|
||||||
<XML_ATTRIBUTE />
|
|
||||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>.*</NAME>
|
|
||||||
<XML_ATTRIBUTE />
|
|
||||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
<order>BY_NAME</order>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>.*</NAME>
|
|
||||||
<XML_ATTRIBUTE />
|
|
||||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
<order>ANDROID_ATTRIBUTE_ORDER</order>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>.*</NAME>
|
|
||||||
<XML_ATTRIBUTE />
|
|
||||||
<XML_NAMESPACE>.*</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
<order>BY_NAME</order>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
</rules>
|
|
||||||
</arrangement>
|
|
||||||
</codeStyleSettings>
|
|
||||||
<codeStyleSettings language="kotlin">
|
|
||||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
|
||||||
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
|
|
||||||
<option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" />
|
|
||||||
<option name="LINE_COMMENT_ADD_SPACE" value="true" />
|
|
||||||
<indentOptions>
|
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
|
||||||
</indentOptions>
|
|
||||||
</codeStyleSettings>
|
|
||||||
</code_scheme>
|
|
||||||
</component>
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
<component name="ProjectCodeStyleConfiguration">
|
|
||||||
<state>
|
|
||||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
|
||||||
</state>
|
|
||||||
</component>
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
|
||||||
<component name="GradleSettings">
|
|
||||||
<option name="linkedExternalProjectsSettings">
|
|
||||||
<GradleProjectSettings>
|
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
|
||||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
|
||||||
<option name="modules">
|
|
||||||
<set>
|
|
||||||
<option value="$PROJECT_DIR$" />
|
|
||||||
<option value="$PROJECT_DIR$/app" />
|
|
||||||
</set>
|
|
||||||
</option>
|
|
||||||
<option name="resolveExternalAnnotations" value="false" />
|
|
||||||
</GradleProjectSettings>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@ -1,287 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<svg
|
|
||||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
|
||||||
xmlns:cc="http://creativecommons.org/ns#"
|
|
||||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
|
||||||
xmlns:svg="http://www.w3.org/2000/svg"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
||||||
inkscape:export-ydpi="39.689999"
|
|
||||||
inkscape:export-xdpi="39.689999"
|
|
||||||
inkscape:export-filename="/home/admin/Documents/projects/graphics/k/icon4.png"
|
|
||||||
width="512mm"
|
|
||||||
height="512mm"
|
|
||||||
viewBox="0 0 512 512.00002"
|
|
||||||
version="1.1"
|
|
||||||
id="svg8"
|
|
||||||
inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
|
|
||||||
sodipodi:docname="icon4.svg">
|
|
||||||
<defs
|
|
||||||
id="defs2">
|
|
||||||
<filter
|
|
||||||
style="color-interpolation-filters:sRGB;"
|
|
||||||
inkscape:label="Drop Shadow"
|
|
||||||
id="filter1266">
|
|
||||||
<feFlood
|
|
||||||
flood-opacity="0.498039"
|
|
||||||
flood-color="rgb(0,0,0)"
|
|
||||||
result="flood"
|
|
||||||
id="feFlood1256" />
|
|
||||||
<feComposite
|
|
||||||
in="flood"
|
|
||||||
in2="SourceGraphic"
|
|
||||||
operator="in"
|
|
||||||
result="composite1"
|
|
||||||
id="feComposite1258" />
|
|
||||||
<feGaussianBlur
|
|
||||||
in="composite1"
|
|
||||||
stdDeviation="3"
|
|
||||||
result="blur"
|
|
||||||
id="feGaussianBlur1260" />
|
|
||||||
<feOffset
|
|
||||||
dx="6"
|
|
||||||
dy="6"
|
|
||||||
result="offset"
|
|
||||||
id="feOffset1262" />
|
|
||||||
<feComposite
|
|
||||||
in="SourceGraphic"
|
|
||||||
in2="offset"
|
|
||||||
operator="over"
|
|
||||||
result="composite2"
|
|
||||||
id="feComposite1264" />
|
|
||||||
</filter>
|
|
||||||
<filter
|
|
||||||
style="color-interpolation-filters:sRGB;"
|
|
||||||
inkscape:label="Drop Shadow"
|
|
||||||
id="filter1059">
|
|
||||||
<feFlood
|
|
||||||
flood-opacity="0.498039"
|
|
||||||
flood-color="rgb(0,0,0)"
|
|
||||||
result="flood"
|
|
||||||
id="feFlood1049" />
|
|
||||||
<feComposite
|
|
||||||
in="flood"
|
|
||||||
in2="SourceGraphic"
|
|
||||||
operator="in"
|
|
||||||
result="composite1"
|
|
||||||
id="feComposite1051" />
|
|
||||||
<feGaussianBlur
|
|
||||||
in="composite1"
|
|
||||||
stdDeviation="3"
|
|
||||||
result="blur"
|
|
||||||
id="feGaussianBlur1053" />
|
|
||||||
<feOffset
|
|
||||||
dx="6"
|
|
||||||
dy="6"
|
|
||||||
result="offset"
|
|
||||||
id="feOffset1055" />
|
|
||||||
<feComposite
|
|
||||||
in="SourceGraphic"
|
|
||||||
in2="offset"
|
|
||||||
operator="over"
|
|
||||||
result="composite2"
|
|
||||||
id="feComposite1057" />
|
|
||||||
</filter>
|
|
||||||
<filter
|
|
||||||
style="color-interpolation-filters:sRGB;"
|
|
||||||
inkscape:label="Drop Shadow"
|
|
||||||
id="filter1071">
|
|
||||||
<feFlood
|
|
||||||
flood-opacity="0.498039"
|
|
||||||
flood-color="rgb(0,0,0)"
|
|
||||||
result="flood"
|
|
||||||
id="feFlood1061" />
|
|
||||||
<feComposite
|
|
||||||
in="flood"
|
|
||||||
in2="SourceGraphic"
|
|
||||||
operator="in"
|
|
||||||
result="composite1"
|
|
||||||
id="feComposite1063" />
|
|
||||||
<feGaussianBlur
|
|
||||||
in="composite1"
|
|
||||||
stdDeviation="3"
|
|
||||||
result="blur"
|
|
||||||
id="feGaussianBlur1065" />
|
|
||||||
<feOffset
|
|
||||||
dx="6"
|
|
||||||
dy="6"
|
|
||||||
result="offset"
|
|
||||||
id="feOffset1067" />
|
|
||||||
<feComposite
|
|
||||||
in="SourceGraphic"
|
|
||||||
in2="offset"
|
|
||||||
operator="over"
|
|
||||||
result="composite2"
|
|
||||||
id="feComposite1069" />
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
<sodipodi:namedview
|
|
||||||
id="base"
|
|
||||||
pagecolor="#0d47a1"
|
|
||||||
bordercolor="#666666"
|
|
||||||
borderopacity="1.0"
|
|
||||||
inkscape:pageopacity="0"
|
|
||||||
inkscape:pageshadow="2"
|
|
||||||
inkscape:zoom="0.175"
|
|
||||||
inkscape:cx="-361.03654"
|
|
||||||
inkscape:cy="630.78782"
|
|
||||||
inkscape:document-units="mm"
|
|
||||||
inkscape:current-layer="layer1"
|
|
||||||
inkscape:document-rotation="0"
|
|
||||||
showgrid="false"
|
|
||||||
inkscape:window-width="1600"
|
|
||||||
inkscape:window-height="838"
|
|
||||||
inkscape:window-x="0"
|
|
||||||
inkscape:window-y="0"
|
|
||||||
inkscape:window-maximized="1"
|
|
||||||
fit-margin-top="20"
|
|
||||||
fit-margin-left="20"
|
|
||||||
fit-margin-right="20"
|
|
||||||
fit-margin-bottom="20" />
|
|
||||||
<metadata
|
|
||||||
id="metadata5">
|
|
||||||
<rdf:RDF>
|
|
||||||
<cc:Work
|
|
||||||
rdf:about="">
|
|
||||||
<dc:format>image/svg+xml</dc:format>
|
|
||||||
<dc:type
|
|
||||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
|
||||||
<dc:title />
|
|
||||||
</cc:Work>
|
|
||||||
</rdf:RDF>
|
|
||||||
</metadata>
|
|
||||||
<g
|
|
||||||
inkscape:label="Слой 1"
|
|
||||||
inkscape:groupmode="layer"
|
|
||||||
id="layer1"
|
|
||||||
transform="translate(-51.12025,-104.74797)">
|
|
||||||
<g
|
|
||||||
id="g1028"
|
|
||||||
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
|
||||||
<g
|
|
||||||
id="g1030"
|
|
||||||
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
|
||||||
<g
|
|
||||||
id="g1032"
|
|
||||||
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
|
||||||
<g
|
|
||||||
id="g1034"
|
|
||||||
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
|
||||||
<g
|
|
||||||
id="g1036"
|
|
||||||
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
|
||||||
<g
|
|
||||||
id="g1038"
|
|
||||||
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
|
||||||
<g
|
|
||||||
id="g1040"
|
|
||||||
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
|
||||||
<g
|
|
||||||
id="g1042"
|
|
||||||
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
|
||||||
<g
|
|
||||||
id="g1044"
|
|
||||||
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
|
||||||
<g
|
|
||||||
id="g1046"
|
|
||||||
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
|
||||||
<g
|
|
||||||
id="g1048"
|
|
||||||
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
|
||||||
<g
|
|
||||||
id="g1050"
|
|
||||||
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
|
||||||
<g
|
|
||||||
id="g1052"
|
|
||||||
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
|
||||||
<g
|
|
||||||
id="g1054"
|
|
||||||
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
|
||||||
<g
|
|
||||||
id="g1056"
|
|
||||||
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
|
||||||
<path
|
|
||||||
id="path1128"
|
|
||||||
d="m 307.12025,310.74755 c -50.53732,0 -91.66608,44.85688 -91.66608,99.99965 0,55.14277 41.12954,99.99964 91.66608,99.99964 50.53653,0 91.66607,-44.85687 91.66607,-99.99964 0,-55.14277 -41.12875,-99.99965 -91.66607,-99.99965 z m -34.21238,78.72707 c -1.46484,2.91327 -4.41092,4.60623 -7.45466,4.60623 -1.25312,0 -2.52265,-0.27656 -3.72733,-0.8789 l -12.9398,-6.4781 -12.9398,6.4781 c -4.13436,2.06718 -9.11481,0.37421 -11.18199,-3.72733 -2.05077,-4.11796 -0.39062,-9.11481 3.72733,-11.18199 l 16.66635,-8.33357 c 2.34374,-1.17187 5.11092,-1.17187 7.45466,0 l 16.66635,8.33357 c 4.11951,2.06718 5.77966,7.06403 3.72889,11.18199 z m 58.33338,-24.99991 c -1.46484,2.91327 -4.41092,4.60623 -7.45466,4.60623 -1.25312,0 -2.52264,-0.27656 -3.72733,-0.8789 l -12.93901,-6.47811 -12.9398,6.47811 c -4.13436,2.06718 -9.11481,0.37421 -11.18199,-3.72733 -2.05078,-4.11796 -0.39063,-9.11482 3.72733,-11.182 l 16.66634,-8.33356 c 2.34375,-1.17187 5.11092,-1.17187 7.45466,0 l 16.66635,8.33356 c 4.11874,2.06718 5.77889,7.06404 3.72811,11.182 z m 54.60606,13.81792 c 4.11795,2.06718 5.7781,7.06403 3.72733,11.18199 -1.46484,2.91327 -4.41092,4.60623 -7.45466,4.60623 -1.25312,0 -2.52265,-0.27656 -3.72733,-0.8789 l -12.9398,-6.4781 -12.9398,6.4781 c -4.11795,2.06718 -9.11481,0.37421 -11.18199,-3.72733 -2.05077,-4.11796 -0.39062,-9.11481 3.72733,-11.18199 l 16.66635,-8.33357 c 2.34374,-1.17187 5.11092,-1.17187 7.45466,0 z"
|
|
||||||
style="fill:#ffffff;stroke-width:0.781247" />
|
|
||||||
<path
|
|
||||||
id="path1130"
|
|
||||||
d="m 415.36283,274.00237 c -3.48202,-6.90544 -6.92029,-13.41714 -10.20934,-19.37102 l -8.26716,-14.47964 c -6.79607,-11.51871 -12.25699,-19.90305 -14.78354,-23.66554 -0.7164,-43.32797 -19.12415,-53.79356 -21.25617,-54.86777 -3.20624,-1.5789 -7.09607,-0.97656 -9.6195,1.56249 -12.25621,12.25621 -20.23118,24.4632 -24.00695,30.89286 h -40.20141 c -3.77577,-6.42888 -11.75153,-18.63665 -24.00695,-30.89286 -2.52265,-2.53905 -6.39685,-3.14139 -9.6195,-1.56249 -2.13202,1.07421 -20.54055,11.5398 -21.25617,54.86777 -2.52655,3.76327 -7.98669,12.14605 -14.78276,23.66476 l -8.27341,14.49214 c -3.28983,5.95701 -6.73044,12.47105 -10.21403,19.3804 l -7.4445,15.32572 c -17.72572,38.05377 -34.30066,85.4286 -34.30066,129.72766 0,69.32085 58.26776,128.42064 60.75838,130.89485 0.91171,0.91172 2.03437,1.61171 3.25546,2.01796 1.07421,0.35859 26.78974,8.757 69.31928,8.757 2.21327,0 4.32967,-0.8789 5.89217,-2.4414 l 5.89216,-5.89216 h 9.76559 l 5.89217,5.89216 c 1.56249,1.5625 3.67811,2.4414 5.89216,2.4414 42.52954,0 68.24507,-8.39841 69.31929,-8.757 1.22109,-0.40703 2.34374,-1.10703 3.25546,-2.01796 2.48905,-2.47421 60.75681,-61.57322 60.75681,-130.89485 0,-44.29906 -16.57494,-91.67389 -34.30066,-129.72766 z M 348.7865,227.41426 c 4.60624,0 4.41171,7.35466 4.41171,11.96089 4.60623,0 12.25464,0.1 12.25464,4.70624 0,9.19606 -7.47107,16.66634 -16.66635,16.66634 -9.19528,0 -16.66634,-7.47028 -16.66634,-16.66634 0,-9.19606 7.47106,-16.66713 16.66634,-16.66713 z m -57.69823,30.14364 c 1.28593,-3.10858 4.32967,-5.14295 7.69841,-5.14295 h 16.66635 c 3.36952,0 6.41248,2.03437 7.69841,5.14295 1.28593,3.10858 0.56953,6.70545 -1.80703,9.082 l -8.33356,8.33356 c -1.62734,1.62734 -3.76014,2.4414 -5.89217,2.4414 -2.13202,0 -4.26404,-0.81406 -5.89216,-2.4414 l -8.33357,-8.33356 c -2.37421,-2.37655 -3.09061,-5.97342 -1.80468,-9.082 z m -25.63428,-30.14364 c 4.60623,0 4.4117,7.35466 4.4117,11.96089 4.60623,0 12.25465,0.1 12.25465,4.70624 0,9.19606 -7.47107,16.66634 -16.66635,16.66634 -9.19606,0 -16.66635,-7.47106 -16.66635,-16.66634 -7.8e-4,-9.19606 7.47029,-16.66713 16.66635,-16.66713 z m 41.66626,299.99893 c -59.7326,0 -108.33321,-52.34357 -108.33321,-116.66599 0,-64.32243 48.60061,-116.66599 108.33321,-116.66599 59.73259,0 108.3332,52.34356 108.3332,116.66599 0,64.32242 -48.60061,116.66599 -108.3332,116.66599 z"
|
|
||||||
style="fill:#ffffff;stroke-width:0.781247;filter:url(#filter1059)"
|
|
||||||
sodipodi:nodetypes="cccccccccccccccsccssccssccsccscsssssssssssccsscsscssssss" />
|
|
||||||
<g
|
|
||||||
style="fill:#ffffff"
|
|
||||||
id="g1138"
|
|
||||||
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)"
|
|
||||||
inkscape:groupmode="layer" />
|
|
||||||
<g
|
|
||||||
style="fill:#ffffff"
|
|
||||||
id="g1140"
|
|
||||||
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
|
||||||
<g
|
|
||||||
style="fill:#ffffff"
|
|
||||||
id="g1142"
|
|
||||||
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
|
||||||
<g
|
|
||||||
style="fill:#ffffff"
|
|
||||||
id="g1144"
|
|
||||||
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
|
||||||
<g
|
|
||||||
style="fill:#ffffff"
|
|
||||||
id="g1146"
|
|
||||||
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
|
||||||
<g
|
|
||||||
style="fill:#ffffff"
|
|
||||||
id="g1148"
|
|
||||||
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
|
||||||
<g
|
|
||||||
style="fill:#ffffff"
|
|
||||||
id="g1150"
|
|
||||||
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
|
||||||
<g
|
|
||||||
style="fill:#ffffff"
|
|
||||||
id="g1152"
|
|
||||||
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
|
||||||
<g
|
|
||||||
style="fill:#ffffff"
|
|
||||||
id="g1154"
|
|
||||||
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
|
||||||
<g
|
|
||||||
style="fill:#ffffff"
|
|
||||||
id="g1156"
|
|
||||||
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
|
||||||
<g
|
|
||||||
style="fill:#ffffff"
|
|
||||||
id="g1158"
|
|
||||||
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
|
||||||
<g
|
|
||||||
style="fill:#ffffff"
|
|
||||||
id="g1160"
|
|
||||||
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
|
||||||
<g
|
|
||||||
style="fill:#ffffff"
|
|
||||||
id="g1162"
|
|
||||||
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
|
||||||
<g
|
|
||||||
style="fill:#ffffff"
|
|
||||||
id="g1164"
|
|
||||||
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
|
||||||
<g
|
|
||||||
style="fill:#ffffff"
|
|
||||||
id="g1166"
|
|
||||||
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
|
||||||
<path
|
|
||||||
style="fill:#ef5350;fill-opacity:1;stroke:none;stroke-width:5.18208;stroke-linecap:round;stroke-linejoin:round"
|
|
||||||
d="m 344.3189,392.83707 c -4.60362,-2.75958 -5.36974,-9.69605 -1.45595,-13.18226 0.54459,-0.48508 5.34567,-3.07035 10.66909,-5.74503 7.5498,-3.79328 10.16725,-4.86303 11.89884,-4.86303 1.73503,0 4.42542,1.10391 12.3172,5.05396 11.72559,5.86898 12.60994,6.68326 12.60994,11.61118 0,3.40408 -0.99553,5.20819 -4.00363,7.25549 -3.08358,2.09867 -5.44113,1.68547 -13.60905,-2.38528 l -7.19926,-3.58796 -7.37198,3.59617 c -8.3911,4.09331 -10.26721,4.39753 -13.8552,2.24676 z"
|
|
||||||
id="path944" />
|
|
||||||
<path
|
|
||||||
style="fill:#ef5350;fill-opacity:1;stroke:none;stroke-width:5.18208;stroke-linecap:round;stroke-linejoin:round"
|
|
||||||
d="m 285.98437,367.98056 c -3.86343,-2.35557 -5.1524,-8.06518 -2.66781,-11.81734 1.64304,-2.48125 20.719,-12.23981 23.92632,-12.23981 1.56364,0 4.61398,1.26582 12.2153,5.06905 8.53551,4.27064 10.3157,5.36752 11.30239,6.96403 1.75651,2.84207 1.95178,5.62136 0.58856,8.37633 -1.52635,3.08463 -3.36973,4.32306 -6.86644,4.61304 -2.68142,0.22236 -3.36743,-0.003 -10.22731,-3.35873 l -7.35311,-3.59707 -7.04119,3.52834 c -7.90523,3.96133 -10.62609,4.44409 -13.87671,2.46216 z"
|
|
||||||
id="path946" />
|
|
||||||
<path
|
|
||||||
style="fill:#ef5350;fill-opacity:1;stroke:none;stroke-width:5.18208;stroke-linecap:round;stroke-linejoin:round"
|
|
||||||
d="m 228.11707,393.18031 c -1.0244,-0.54435 -2.42484,-1.80721 -3.11209,-2.80633 -1.05812,-1.53828 -1.2181,-2.32693 -1.04433,-5.14815 0.29039,-4.71472 1.41139,-5.70783 12.90113,-11.42937 7.71258,-3.84061 9.99443,-4.74971 11.92193,-4.74971 1.94819,0 4.22735,0.92952 12.47354,5.08716 8.66324,4.3679 10.26522,5.3693 11.33052,7.08263 3.53608,5.68714 -0.55313,12.95355 -7.28968,12.95355 -1.25225,0 -4.29453,-1.20187 -9.08226,-3.58799 l -7.19927,-3.58796 -7.37197,3.59617 c -8.07507,3.93914 -10.21699,4.34922 -13.52752,2.59 z"
|
|
||||||
id="path948" />
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 13 KiB |
@ -1,45 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="RemoteRepositoriesConfiguration">
|
|
||||||
<remote-repository>
|
|
||||||
<option name="id" value="central" />
|
|
||||||
<option name="name" value="Maven Central repository" />
|
|
||||||
<option name="url" value="https://repo1.maven.org/maven2" />
|
|
||||||
</remote-repository>
|
|
||||||
<remote-repository>
|
|
||||||
<option name="id" value="jboss.community" />
|
|
||||||
<option name="name" value="JBoss Community repository" />
|
|
||||||
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
|
|
||||||
</remote-repository>
|
|
||||||
<remote-repository>
|
|
||||||
<option name="id" value="BintrayJCenter" />
|
|
||||||
<option name="name" value="BintrayJCenter" />
|
|
||||||
<option name="url" value="https://jcenter.bintray.com/" />
|
|
||||||
</remote-repository>
|
|
||||||
<remote-repository>
|
|
||||||
<option name="id" value="Google" />
|
|
||||||
<option name="name" value="Google" />
|
|
||||||
<option name="url" value="https://dl.google.com/dl/android/maven2/" />
|
|
||||||
</remote-repository>
|
|
||||||
<remote-repository>
|
|
||||||
<option name="id" value="maven" />
|
|
||||||
<option name="name" value="maven" />
|
|
||||||
<option name="url" value="https://jitpack.io" />
|
|
||||||
</remote-repository>
|
|
||||||
<remote-repository>
|
|
||||||
<option name="id" value="maven2" />
|
|
||||||
<option name="name" value="maven2" />
|
|
||||||
<option name="url" value="https://dl.bintray.com/kotlin/kotlin-eap" />
|
|
||||||
</remote-repository>
|
|
||||||
<remote-repository>
|
|
||||||
<option name="id" value="MavenRepo" />
|
|
||||||
<option name="name" value="MavenRepo" />
|
|
||||||
<option name="url" value="https://repo.maven.apache.org/maven2/" />
|
|
||||||
</remote-repository>
|
|
||||||
<remote-repository>
|
|
||||||
<option name="id" value="maven2" />
|
|
||||||
<option name="name" value="maven2" />
|
|
||||||
<option name="url" value="https://maven.pkg.github.com/nv95/kotatsu-parsers" />
|
|
||||||
</remote-repository>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="KotlinCodeInsightWorkspaceSettings">
|
|
||||||
<option name="addUnambiguousImportsOnTheFly" value="true" />
|
|
||||||
<option name="optimizeImportsOnTheFly" value="true" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="KtlintProjectConfiguration">
|
|
||||||
<enableKtlint>false</enableKtlint>
|
|
||||||
<androidMode>true</androidMode>
|
|
||||||
<treatAsErrors>false</treatAsErrors>
|
|
||||||
<disabledRules>
|
|
||||||
<list>
|
|
||||||
<option value="no-empty-first-line-in-method-block" />
|
|
||||||
</list>
|
|
||||||
</disabledRules>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="GitSharedSettings">
|
|
||||||
<option name="FORCE_PUSH_PROHIBITED_PATTERNS">
|
|
||||||
<list>
|
|
||||||
<option value="master" />
|
|
||||||
<option value="devel" />
|
|
||||||
<option value="legacy" />
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
[weblate]
|
|
||||||
url = https://hosted.weblate.org/api/
|
|
||||||
translation = kotatsu/strings
|
|
||||||
@ -1,12 +1,96 @@
|
|||||||
## Kotatsu contribution guidelines
|
# Contributing
|
||||||
|
|
||||||
+ If you want to **fix bugs** or **implement new features** that **already have an [issue card](https://github.com/KotatsuApp/Kotatsu/issues):** please assign this issue to you and/or comment about it.
|
The following is a guide for creating Kotatsu parsers. Thanks for taking the time to contribute!
|
||||||
+ If you want to **implement a new feature:** open an issue or discussion regarding it to ensure it will be accepted.
|
|
||||||
+ **Translations** have to be managed using the [Weblate](https://hosted.weblate.org/engage/kotatsu/) platform.
|
|
||||||
+ In case you want to **add a new manga source,** refer to the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers).
|
|
||||||
|
|
||||||
**Refactoring** or some **dev-faces improvements** might also be accepted. However, please stick to the following principles:
|
## Prerequisites
|
||||||
|
|
||||||
+ **Performance matters.** In the case of choosing between source code beauty and performance, performance should be a priority.
|
Before you start, please note that the ability to use the following technologies is **required**.
|
||||||
+ Please, **do not modify readme and other information files** (except for typos).
|
|
||||||
+ **Avoid adding new dependencies** unless required. APK size is important.
|
- Basic [Android development](https://developer.android.com/)
|
||||||
|
- [Kotlin](https://kotlinlang.org/)
|
||||||
|
- Web scraping ([JSoup](https://jsoup.org/)) or JSON API
|
||||||
|
|
||||||
|
### Tools
|
||||||
|
|
||||||
|
- [Android Studio](https://developer.android.com/studio)
|
||||||
|
- [IntelliJ IDEA](https://www.jetbrains.com/idea/) (Community edition is enough)
|
||||||
|
- Android device (or emulator)
|
||||||
|
|
||||||
|
Kotatsu parsers are not a part of the Android application, but you can easily develop and test it directly inside an
|
||||||
|
Android application project and relocate it to the library project when done.
|
||||||
|
|
||||||
|
### Before you start
|
||||||
|
|
||||||
|
First, take a look at the `kotatsu-parsers` project structure. Each parser is a single class that
|
||||||
|
extends the `MangaParser` class and has a `MangaSourceParser` annotation.
|
||||||
|
Also, pay attention to extensions in the `util` package. For example, extensions from the `Jsoup` file
|
||||||
|
should be used instead of existing JSoup functions because they have better nullability support
|
||||||
|
and improved error messages.
|
||||||
|
|
||||||
|
## Writing your parser
|
||||||
|
|
||||||
|
So, you want to create a parser, that will provide access to manga from a website.
|
||||||
|
First, you should explore a website to learn about API availability.
|
||||||
|
If it does not contain any documentation about
|
||||||
|
API, [explore network requests](https://firefox-source-docs.mozilla.org/devtools-user/):
|
||||||
|
some websites use AJAX.
|
||||||
|
|
||||||
|
- [Example](https://github.com/KotatsuApp/kotatsu-parsers/blob/master/src/main/kotlin/org/koitharu/kotatsu/parsers/site/ru/DesuMeParser.kt)
|
||||||
|
of Json API usage.
|
||||||
|
- [Example](https://github.com/KotatsuApp/kotatsu-parsers/blob/master/src/main/kotlin/org/koitharu/kotatsu/parsers/site/be/AnibelParser.kt)
|
||||||
|
of GraphQL API usage
|
||||||
|
- [Example](https://github.com/KotatsuApp/kotatsu-parsers/blob/master/src/main/kotlin/org/koitharu/kotatsu/parsers/site/en/MangaTownParser.kt)
|
||||||
|
of pure HTML parsing.
|
||||||
|
|
||||||
|
If the website is based on some engine it is rationally to use a common base class for this one (for example, Madara
|
||||||
|
Wordpress theme and the `MadaraParser` class)
|
||||||
|
|
||||||
|
### Parser class skeleton
|
||||||
|
|
||||||
|
The parser class must have exactly one primary constructor parameter of type `MangaLoaderContext` and have an
|
||||||
|
`MangaSourceParser` annotation that provides the internal name, title, and language of a manga source.
|
||||||
|
|
||||||
|
All members of the `MangaParser` class are documented. Pay attention to some peculiarities:
|
||||||
|
|
||||||
|
- Never hardcode domain. Specify the default domain in the `configKeyDomain` field and obtain an actual one using
|
||||||
|
`domain`.
|
||||||
|
- All IDs must be unique and domain-independent. Use `generateUid` functions with a relative URL or some internal id
|
||||||
|
that is unique across the manga source.
|
||||||
|
- The `availableSortOrders` set should not be empty. If your source does not support sorting, specify one most relevant
|
||||||
|
value.
|
||||||
|
- If you cannot obtain direct links to page images inside the `getPages` method, it is ok to use an intermediate URL
|
||||||
|
as `Page.url` and fetch a direct link in the `getPageUrl` function.
|
||||||
|
- You can use _asserts_ to check some optional fields. For example, the `Manga.author` field is not required, but if
|
||||||
|
your source provides this information, add `assert(it != null)`. This will not have any effect on production but help
|
||||||
|
to find issues during unit testing.
|
||||||
|
- Your parser may also implement the `Interceptor` interface for additional manipulation of all network requests and
|
||||||
|
responses, including image loading.
|
||||||
|
- If your source website (or its API) uses pages for pagination instead of offset you should extend `PagedMangaParser`
|
||||||
|
instead of `MangaParser`.
|
||||||
|
- If your source website (or its API) does not provide pagination (has only one page of content) you should extend
|
||||||
|
`SinglePageMangaParser` instead of `MangaParser` or `PagedMangaParser`.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Development process
|
||||||
|
|
||||||
|
During the development, it is recommended (but not necessary) to write it directly
|
||||||
|
in the Kotatsu Android application project. You can use the `core.parser.DummyParser` class as a sandbox. The `Dummy`
|
||||||
|
manga source is available in the debug Kotatsu build.
|
||||||
|
|
||||||
|
Once the parser is ready you can relocate your code into the `kotatsu-parsers` library project in a `site` package and
|
||||||
|
create a Pull Request.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
It is recommended that unit tests be run before submitting a PR.
|
||||||
|
|
||||||
|
- Temporary modify the `MangaSources` annotation class: specify your parser(s) name(s) and change mode
|
||||||
|
to `EnumSource.Mode.INCLUDE`
|
||||||
|
- Run the `MangaParserTest` (`gradlew :test --tests "org.koitharu.kotatsu.parsers.MangaParserTest"`)
|
||||||
|
- Optionally, you can run the `generateTestsReport` gradle task to get a pretty readable html report from test results.
|
||||||
|
|
||||||
|
## Help
|
||||||
|
|
||||||
|
If you need help or have some questions, ask a community in our [Telegram chat](https://t.me/kotatsuapp)
|
||||||
|
or [Discord server](https://discord.gg/NNJ5RgVBC5).
|
||||||
|
|||||||
@ -1,57 +1,74 @@
|
|||||||
# Kotatsu
|
# Kotatsu parsers
|
||||||
|
|
||||||
Kotatsu is a free and open source manga reader for Android.
|
This library provides a collection of manga parsers for convenient access manga available on the web. It can be used in
|
||||||
|
JVM and Android applications.
|
||||||
|
|
||||||
   [](https://hosted.weblate.org/engage/kotatsu/) [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5)
|
 [](https://jitpack.io/#KotatsuApp/kotatsu-parsers)  [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5)
|
||||||
|
|
||||||
### Download
|
## Usage
|
||||||
|
|
||||||
- **Recommended:** Download and install APK from **[GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest)**. Application has a built-in self-updating feature.
|
1. Add it to your root build.gradle at the end of repositories:
|
||||||
- Get it on **[F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu)**. The F-Droid build may be a bit outdated and some fixes might be missing.
|
|
||||||
|
|
||||||
### Main Features
|
```groovy
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
...
|
||||||
|
maven { url 'https://jitpack.io' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers)
|
2. Add the dependency
|
||||||
* Search manga by name and genres
|
|
||||||
* Reading history and bookmarks
|
|
||||||
* Favourites organized by user-defined categories
|
|
||||||
* Downloading manga and reading it offline. Third-party CBZ archives also supported
|
|
||||||
* Tablet-optimized Material You UI
|
|
||||||
* Standard and Webtoon-optimized reader
|
|
||||||
* Notifications about new chapters with updates feed
|
|
||||||
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
|
||||||
* Password/fingerprint protect access to the app
|
|
||||||
* History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices
|
|
||||||
|
|
||||||
### Screenshots
|
For Java/Kotlin project:
|
||||||
|
```groovy
|
||||||
|
dependencies {
|
||||||
|
implementation("com.github.KotatsuApp:kotatsu-parsers:$parsers_version")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|  |  |  |
|
For Android project:
|
||||||
|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|
|
```groovy
|
||||||
|  |  |  |
|
dependencies {
|
||||||
|
implementation("com.github.KotatsuApp:kotatsu-parsers:$parsers_version") {
|
||||||
|
exclude group: 'org.json', module: 'json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|  |  |
|
Versions are available on [JitPack](https://jitpack.io/#KotatsuApp/kotatsu-parsers)
|
||||||
|-----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------|
|
|
||||||
|
|
||||||
### Localization
|
When used in Android
|
||||||
|
projects, [core library desugaring](https://developer.android.com/studio/write/java8-support#library-desugaring) with
|
||||||
|
the [NIO specification](https://developer.android.com/studio/write/java11-nio-support-table) should be enabled to
|
||||||
|
support Java 8+ features.
|
||||||
|
|
||||||
[<img src="https://hosted.weblate.org/widgets/kotatsu/-/287x66-white.png" alt="Translation status">](https://hosted.weblate.org/engage/kotatsu/)
|
|
||||||
|
|
||||||
Kotatsu is localized in a number of different languages, if you would like to help improve these or add new languages,
|
3. Usage in code
|
||||||
please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)
|
|
||||||
|
|
||||||
### Contributing
|
```kotlin
|
||||||
|
val parser = mangaLoaderContext.newParserInstance(MangaParserSource.MANGADEX)
|
||||||
|
```
|
||||||
|
|
||||||
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the guidelines.
|
`mangaLoaderContext` is an implementation of the `MangaLoaderContext` class.
|
||||||
|
See examples
|
||||||
|
of [Android](https://github.com/KotatsuApp/Kotatsu/blob/devel/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt)
|
||||||
|
and [Non-Android](https://github.com/KotatsuApp/kotatsu-dl/blob/master/src/main/kotlin/org/koitharu/kotatsu/dl/parsers/MangaLoaderContextImpl.kt)
|
||||||
|
implementation.
|
||||||
|
|
||||||
|
## Projects that use the library
|
||||||
|
|
||||||
### License
|
- [Kotatsu](https://github.com/KotatsuApp/Kotatsu)
|
||||||
|
- [Doki](https://github.com/DokiTeam/Doki)
|
||||||
|
- [kotatsu-dl](https://github.com/KotatsuApp/kotatsu-dl)
|
||||||
|
- [Shirizu (WIP)](https://github.com/ztimms73/shirizu)
|
||||||
|
- [OtakuWorld](https://github.com/jakepurple13/OtakuWorld)
|
||||||
|
|
||||||
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
|
## Contribution
|
||||||
|
|
||||||
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications
|
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the guidelines.
|
||||||
to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build &
|
|
||||||
install instructions.
|
|
||||||
|
|
||||||
### DMCA disclaimer
|
## DMCA disclaimer
|
||||||
|
|
||||||
The developers of this application does not have any affiliation with the content available in the app.
|
The developers of this application have no affiliation with the content available in the app. It is collected from
|
||||||
It is collecting from the sources freely available through any web browser.
|
sources freely available through any web browser.
|
||||||
|
|||||||
@ -1,2 +0,0 @@
|
|||||||
/build
|
|
||||||
/schemas/
|
|
||||||
@ -1,166 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id 'com.android.application'
|
|
||||||
id 'kotlin-android'
|
|
||||||
id 'kotlin-kapt'
|
|
||||||
id 'com.google.devtools.ksp'
|
|
||||||
id 'kotlin-parcelize'
|
|
||||||
id 'dagger.hilt.android.plugin'
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
compileSdk = 34
|
|
||||||
buildToolsVersion = '34.0.0'
|
|
||||||
namespace = 'org.koitharu.kotatsu'
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId 'org.koitharu.kotatsu'
|
|
||||||
minSdk = 21
|
|
||||||
targetSdk = 34
|
|
||||||
versionCode = 632
|
|
||||||
versionName = '6.8.2'
|
|
||||||
generatedDensities = []
|
|
||||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
|
||||||
ksp {
|
|
||||||
arg('room.generateKotlin', 'true')
|
|
||||||
arg('room.schemaLocation', "$projectDir/schemas")
|
|
||||||
}
|
|
||||||
androidResources {
|
|
||||||
generateLocaleConfig true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buildTypes {
|
|
||||||
debug {
|
|
||||||
applicationIdSuffix = '.debug'
|
|
||||||
}
|
|
||||||
release {
|
|
||||||
minifyEnabled true
|
|
||||||
shrinkResources true
|
|
||||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buildFeatures {
|
|
||||||
viewBinding true
|
|
||||||
buildConfig true
|
|
||||||
}
|
|
||||||
sourceSets {
|
|
||||||
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
|
||||||
main.java.srcDirs += 'src/main/kotlin/'
|
|
||||||
}
|
|
||||||
compileOptions {
|
|
||||||
coreLibraryDesugaringEnabled true
|
|
||||||
sourceCompatibility JavaVersion.VERSION_1_8
|
|
||||||
targetCompatibility JavaVersion.VERSION_1_8
|
|
||||||
}
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
|
||||||
freeCompilerArgs += [
|
|
||||||
'-opt-in=kotlin.ExperimentalStdlibApi',
|
|
||||||
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
|
||||||
'-opt-in=kotlinx.coroutines.FlowPreview',
|
|
||||||
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
|
||||||
'-opt-in=coil.annotation.ExperimentalCoilApi',
|
|
||||||
]
|
|
||||||
}
|
|
||||||
lint {
|
|
||||||
abortOnError true
|
|
||||||
disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged', 'SetJavaScriptEnabled'
|
|
||||||
}
|
|
||||||
testOptions {
|
|
||||||
unitTests.includeAndroidResources true
|
|
||||||
unitTests.returnDefaultValues false
|
|
||||||
kotlinOptions {
|
|
||||||
freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
afterEvaluate {
|
|
||||||
compileDebugKotlin {
|
|
||||||
kotlinOptions {
|
|
||||||
freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dependencies {
|
|
||||||
//noinspection GradleDependency
|
|
||||||
implementation('com.github.KotatsuApp:kotatsu-parsers:44ea9fe709') {
|
|
||||||
exclude group: 'org.json', module: 'json'
|
|
||||||
}
|
|
||||||
|
|
||||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
|
|
||||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.23'
|
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0'
|
|
||||||
|
|
||||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
|
||||||
implementation 'androidx.core:core-ktx:1.12.0'
|
|
||||||
implementation 'androidx.activity:activity-ktx:1.8.2'
|
|
||||||
implementation 'androidx.fragment:fragment-ktx:1.6.2'
|
|
||||||
implementation 'androidx.collection:collection-ktx:1.4.0'
|
|
||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
|
|
||||||
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
|
|
||||||
implementation 'androidx.lifecycle:lifecycle-process:2.7.0'
|
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
|
||||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
|
||||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
|
||||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
|
|
||||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
|
||||||
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
|
|
||||||
implementation 'com.google.android.material:material:1.12.0-beta01'
|
|
||||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.7.0'
|
|
||||||
implementation 'androidx.webkit:webkit:1.10.0'
|
|
||||||
|
|
||||||
implementation 'androidx.work:work-runtime:2.9.0'
|
|
||||||
//noinspection GradleDependency
|
|
||||||
implementation('com.google.guava:guava:32.0.1-android') {
|
|
||||||
exclude group: 'com.google.guava', module: 'failureaccess'
|
|
||||||
exclude group: 'org.checkerframework', module: 'checker-qual'
|
|
||||||
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
|
|
||||||
}
|
|
||||||
|
|
||||||
implementation 'androidx.room:room-runtime:2.6.1'
|
|
||||||
implementation 'androidx.room:room-ktx:2.6.1'
|
|
||||||
ksp 'androidx.room:room-compiler:2.6.1'
|
|
||||||
|
|
||||||
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
|
||||||
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
|
|
||||||
implementation 'com.squareup.okio:okio:3.9.0'
|
|
||||||
|
|
||||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
|
|
||||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
|
||||||
|
|
||||||
implementation 'com.google.dagger:hilt-android:2.51.1'
|
|
||||||
kapt 'com.google.dagger:hilt-compiler:2.51.1'
|
|
||||||
implementation 'androidx.hilt:hilt-work:1.2.0'
|
|
||||||
kapt 'androidx.hilt:hilt-compiler:1.2.0'
|
|
||||||
|
|
||||||
implementation 'io.coil-kt:coil-base:2.6.0'
|
|
||||||
implementation 'io.coil-kt:coil-svg:2.6.0'
|
|
||||||
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:02e6d6cfe9'
|
|
||||||
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
|
||||||
implementation 'io.noties.markwon:core:4.6.2'
|
|
||||||
|
|
||||||
implementation 'ch.acra:acra-http:5.11.3'
|
|
||||||
implementation 'ch.acra:acra-dialog:5.11.3'
|
|
||||||
compileOnly 'com.google.auto.service:auto-service-annotations:1.1.1'
|
|
||||||
ksp 'dev.zacsweers.autoservice:auto-service-ksp:1.1.0'
|
|
||||||
|
|
||||||
implementation 'org.conscrypt:conscrypt-android:2.5.2'
|
|
||||||
|
|
||||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.13'
|
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
|
||||||
testImplementation 'org.json:json:20240303'
|
|
||||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0'
|
|
||||||
|
|
||||||
androidTestImplementation 'androidx.test:runner:1.5.2'
|
|
||||||
androidTestImplementation 'androidx.test:rules:1.5.0'
|
|
||||||
androidTestImplementation 'androidx.test:core-ktx:1.5.0'
|
|
||||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
|
|
||||||
|
|
||||||
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0'
|
|
||||||
|
|
||||||
androidTestImplementation 'androidx.room:room-testing:2.6.1'
|
|
||||||
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1'
|
|
||||||
|
|
||||||
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.51.1'
|
|
||||||
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.51.1'
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
-optimizationpasses 8
|
|
||||||
-dontobfuscate
|
|
||||||
-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
|
|
||||||
public static void checkExpressionValueIsNotNull(...);
|
|
||||||
public static void checkNotNullExpressionValue(...);
|
|
||||||
public static void checkReturnedValueIsNotNull(...);
|
|
||||||
public static void checkFieldIsNotNull(...);
|
|
||||||
public static void checkParameterIsNotNull(...);
|
|
||||||
public static void checkNotNullParameter(...);
|
|
||||||
}
|
|
||||||
-keep public class ** extends org.koitharu.kotatsu.core.ui.BaseFragment
|
|
||||||
-keep class org.koitharu.kotatsu.core.db.entity.* { *; }
|
|
||||||
-dontwarn okhttp3.internal.platform.**
|
|
||||||
-dontwarn org.conscrypt.**
|
|
||||||
-dontwarn org.bouncycastle.**
|
|
||||||
-dontwarn org.openjsse.**
|
|
||||||
|
|
||||||
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
|
||||||
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
|
|
||||||
-keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; }
|
|
||||||
-keep class org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment { *; }
|
|
||||||
-keep class org.jsoup.parser.Tag
|
|
||||||
-keep class org.jsoup.internal.StringUtil
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"id": 4,
|
|
||||||
"title": "Read later",
|
|
||||||
"sortKey": 1,
|
|
||||||
"order": "NEWEST",
|
|
||||||
"createdAt": 1335906000000,
|
|
||||||
"isTrackingEnabled": true,
|
|
||||||
"isVisibleInLibrary": true
|
|
||||||
}
|
|
||||||
Binary file not shown.
@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"id": -2096681732556647985,
|
|
||||||
"title": "Странствия Эманон",
|
|
||||||
"url": "/stranstviia_emanon",
|
|
||||||
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
|
||||||
"rating": 0.9400894,
|
|
||||||
"isNsfw": true,
|
|
||||||
"coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
|
|
||||||
"tags": [
|
|
||||||
{
|
|
||||||
"title": "Сверхъестественное",
|
|
||||||
"key": "supernatural",
|
|
||||||
"source": "READMANGA_RU"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Сэйнэн",
|
|
||||||
"key": "seinen",
|
|
||||||
"source": "READMANGA_RU"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Повседневность",
|
|
||||||
"key": "slice_of_life",
|
|
||||||
"source": "READMANGA_RU"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Приключения",
|
|
||||||
"key": "adventure",
|
|
||||||
"source": "READMANGA_RU"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"state": "FINISHED",
|
|
||||||
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
|
||||||
"description": null,
|
|
||||||
"source": "READMANGA_RU"
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
package org.koitharu.kotatsu
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.test.runner.AndroidJUnitRunner
|
|
||||||
import dagger.hilt.android.testing.HiltTestApplication
|
|
||||||
|
|
||||||
class HiltTestRunner : AndroidJUnitRunner() {
|
|
||||||
|
|
||||||
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
|
|
||||||
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
package org.koitharu.kotatsu
|
|
||||||
|
|
||||||
import android.app.Instrumentation
|
|
||||||
import kotlin.coroutines.resume
|
|
||||||
import kotlin.coroutines.suspendCoroutine
|
|
||||||
|
|
||||||
suspend fun Instrumentation.awaitForIdle() = suspendCoroutine<Unit> { cont ->
|
|
||||||
waitForIdle { cont.resume(Unit) }
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
package org.koitharu.kotatsu
|
|
||||||
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
|
||||||
import com.squareup.moshi.*
|
|
||||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
|
||||||
import okio.buffer
|
|
||||||
import okio.source
|
|
||||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import java.util.*
|
|
||||||
import kotlin.reflect.KClass
|
|
||||||
|
|
||||||
object SampleData {
|
|
||||||
|
|
||||||
private val moshi = Moshi.Builder()
|
|
||||||
.add(DateAdapter())
|
|
||||||
.add(KotlinJsonAdapterFactory())
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val manga: Manga = loadAsset("manga/header.json", Manga::class)
|
|
||||||
|
|
||||||
val mangaDetails: Manga = loadAsset("manga/full.json", Manga::class)
|
|
||||||
|
|
||||||
val tag = mangaDetails.tags.elementAt(2)
|
|
||||||
|
|
||||||
val chapter = checkNotNull(mangaDetails.chapters)[2]
|
|
||||||
|
|
||||||
val favouriteCategory: FavouriteCategory = loadAsset("categories/simple.json", FavouriteCategory::class)
|
|
||||||
|
|
||||||
fun <T : Any> loadAsset(name: String, cls: KClass<T>): T {
|
|
||||||
val assets = InstrumentationRegistry.getInstrumentation().context.assets
|
|
||||||
return assets.open(name).use {
|
|
||||||
moshi.adapter(cls.java).fromJson(it.source().buffer())
|
|
||||||
} ?: throw RuntimeException("Cannot read asset from json \"$name\"")
|
|
||||||
}
|
|
||||||
|
|
||||||
private class DateAdapter : JsonAdapter<Date>() {
|
|
||||||
|
|
||||||
@FromJson
|
|
||||||
override fun fromJson(reader: JsonReader): Date? {
|
|
||||||
val ms = reader.nextLong()
|
|
||||||
return if (ms == 0L) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
Date(ms)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ToJson
|
|
||||||
override fun toJson(writer: JsonWriter, value: Date?) {
|
|
||||||
writer.value(value?.time ?: 0L)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.db
|
|
||||||
|
|
||||||
import androidx.room.testing.MigrationTestHelper
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class MangaDatabaseTest {
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
val helper: MigrationTestHelper = MigrationTestHelper(
|
|
||||||
InstrumentationRegistry.getInstrumentation(),
|
|
||||||
MangaDatabase::class.java,
|
|
||||||
)
|
|
||||||
|
|
||||||
private val migrations = getDatabaseMigrations(InstrumentationRegistry.getInstrumentation().targetContext)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun versions() {
|
|
||||||
assertEquals(1, migrations.first().startVersion)
|
|
||||||
repeat(migrations.size) { i ->
|
|
||||||
assertEquals(i + 1, migrations[i].startVersion)
|
|
||||||
assertEquals(i + 2, migrations[i].endVersion)
|
|
||||||
}
|
|
||||||
assertEquals(DATABASE_VERSION, migrations.last().endVersion)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun migrateAll() {
|
|
||||||
helper.createDatabase(TEST_DB, 1).close()
|
|
||||||
for (migration in migrations) {
|
|
||||||
helper.runMigrationsAndValidate(
|
|
||||||
TEST_DB,
|
|
||||||
migration.endVersion,
|
|
||||||
true,
|
|
||||||
migration,
|
|
||||||
).close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun prePopulate() {
|
|
||||||
val resources = InstrumentationRegistry.getInstrumentation().targetContext.resources
|
|
||||||
helper.createDatabase(TEST_DB, DATABASE_VERSION).use {
|
|
||||||
DatabasePrePopulateCallback(resources).onCreate(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
|
|
||||||
const val TEST_DB = "test-db"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.os
|
|
||||||
|
|
||||||
import android.content.pm.ShortcutInfo
|
|
||||||
import android.content.pm.ShortcutManager
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.core.content.getSystemService
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
|
||||||
import dagger.hilt.android.testing.HiltAndroidRule
|
|
||||||
import dagger.hilt.android.testing.HiltAndroidTest
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.koitharu.kotatsu.SampleData
|
|
||||||
import org.koitharu.kotatsu.awaitForIdle
|
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
|
||||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltAndroidTest
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class AppShortcutManagerTest {
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
var hiltRule = HiltAndroidRule(this)
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var historyRepository: HistoryRepository
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var appShortcutManager: AppShortcutManager
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var database: MangaDatabase
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setUp() {
|
|
||||||
hiltRule.inject()
|
|
||||||
database.clearAllTables()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testUpdateShortcuts() = runTest {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) {
|
|
||||||
return@runTest
|
|
||||||
}
|
|
||||||
database.invalidationTracker.addObserver(appShortcutManager)
|
|
||||||
awaitUpdate()
|
|
||||||
assertTrue(getShortcuts().isEmpty())
|
|
||||||
historyRepository.addOrUpdate(
|
|
||||||
manga = SampleData.manga,
|
|
||||||
chapterId = SampleData.chapter.id,
|
|
||||||
page = 4,
|
|
||||||
scroll = 2,
|
|
||||||
percent = 0.3f,
|
|
||||||
force = false,
|
|
||||||
)
|
|
||||||
awaitUpdate()
|
|
||||||
|
|
||||||
val shortcuts = getShortcuts()
|
|
||||||
assertEquals(1, shortcuts.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getShortcuts(): List<ShortcutInfo> {
|
|
||||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
|
||||||
val manager = checkNotNull(context.getSystemService<ShortcutManager>())
|
|
||||||
return manager.dynamicShortcuts.filterNot { it.id == "com.squareup.leakcanary.dynamic_shortcut" }
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun awaitUpdate() {
|
|
||||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
|
||||||
instrumentation.awaitForIdle()
|
|
||||||
appShortcutManager.await()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.settings.backup
|
|
||||||
|
|
||||||
import android.content.res.AssetManager
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
|
||||||
import dagger.hilt.android.testing.HiltAndroidRule
|
|
||||||
import dagger.hilt.android.testing.HiltAndroidTest
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.koitharu.kotatsu.SampleData
|
|
||||||
import org.koitharu.kotatsu.core.backup.BackupRepository
|
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
|
||||||
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
|
||||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
|
||||||
import java.io.File
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltAndroidTest
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class AppBackupAgentTest {
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
var hiltRule = HiltAndroidRule(this)
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var historyRepository: HistoryRepository
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var favouritesRepository: FavouritesRepository
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var backupRepository: BackupRepository
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var database: MangaDatabase
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setUp() {
|
|
||||||
hiltRule.inject()
|
|
||||||
database.clearAllTables()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun backupAndRestore() = runTest {
|
|
||||||
val category = favouritesRepository.createCategory(
|
|
||||||
title = SampleData.favouriteCategory.title,
|
|
||||||
sortOrder = SampleData.favouriteCategory.order,
|
|
||||||
isTrackerEnabled = SampleData.favouriteCategory.isTrackingEnabled,
|
|
||||||
isVisibleOnShelf = SampleData.favouriteCategory.isVisibleInLibrary,
|
|
||||||
)
|
|
||||||
favouritesRepository.addToCategory(categoryId = category.id, mangas = listOf(SampleData.manga))
|
|
||||||
historyRepository.addOrUpdate(
|
|
||||||
manga = SampleData.mangaDetails,
|
|
||||||
chapterId = SampleData.mangaDetails.chapters!![2].id,
|
|
||||||
page = 3,
|
|
||||||
scroll = 40,
|
|
||||||
percent = 0.2f,
|
|
||||||
force = false,
|
|
||||||
)
|
|
||||||
val history = checkNotNull(historyRepository.getOne(SampleData.manga))
|
|
||||||
|
|
||||||
val agent = AppBackupAgent()
|
|
||||||
val backup = agent.createBackupFile(
|
|
||||||
context = InstrumentationRegistry.getInstrumentation().targetContext,
|
|
||||||
repository = backupRepository,
|
|
||||||
)
|
|
||||||
|
|
||||||
database.clearAllTables()
|
|
||||||
assertTrue(favouritesRepository.getAllManga().isEmpty())
|
|
||||||
assertNull(historyRepository.getLastOrNull())
|
|
||||||
|
|
||||||
backup.inputStream().use {
|
|
||||||
agent.restoreBackupFile(it.fd, backup.length(), backupRepository)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals(category, favouritesRepository.getCategory(category.id))
|
|
||||||
assertEquals(history, historyRepository.getOne(SampleData.manga))
|
|
||||||
assertEquals(listOf(SampleData.manga), favouritesRepository.getManga(category.id))
|
|
||||||
|
|
||||||
val allTags = database.getTagsDao().findTags(SampleData.tag.source.name).toMangaTags()
|
|
||||||
assertTrue(SampleData.tag in allTags)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun restoreOldBackup() {
|
|
||||||
val agent = AppBackupAgent()
|
|
||||||
val backup = File.createTempFile("backup_", ".tmp")
|
|
||||||
InstrumentationRegistry.getInstrumentation().context.assets
|
|
||||||
.open("kotatsu_test.bak", AssetManager.ACCESS_STREAMING)
|
|
||||||
.use { input ->
|
|
||||||
backup.outputStream().use { output ->
|
|
||||||
input.copyTo(output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
backup.inputStream().use {
|
|
||||||
agent.restoreBackupFile(it.fd, backup.length(), backupRepository)
|
|
||||||
}
|
|
||||||
runTest {
|
|
||||||
assertEquals(6, historyRepository.observeAll().first().size)
|
|
||||||
assertEquals(2, favouritesRepository.observeCategories().first().size)
|
|
||||||
assertEquals(15, favouritesRepository.getAllManga().size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,198 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.tracker.domain
|
|
||||||
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import dagger.hilt.android.testing.HiltAndroidRule
|
|
||||||
import dagger.hilt.android.testing.HiltAndroidTest
|
|
||||||
import junit.framework.TestCase.*
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.koitharu.kotatsu.SampleData
|
|
||||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltAndroidTest
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class TrackerTest {
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
var hiltRule = HiltAndroidRule(this)
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var repository: TrackingRepository
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var dataRepository: MangaDataRepository
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var tracker: Tracker
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setUp() {
|
|
||||||
hiltRule.inject()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun noUpdates() = runTest {
|
|
||||||
val manga = loadManga("full.json")
|
|
||||||
tracker.deleteTrack(manga.id)
|
|
||||||
|
|
||||||
tracker.checkUpdates(manga, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(manga.id))
|
|
||||||
tracker.checkUpdates(manga, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(manga.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun hasUpdates() = runTest {
|
|
||||||
val mangaFirst = loadManga("first_chapters.json")
|
|
||||||
val mangaFull = loadManga("full.json")
|
|
||||||
tracker.deleteTrack(mangaFirst.id)
|
|
||||||
|
|
||||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assertEquals(3, newChapters.size)
|
|
||||||
}
|
|
||||||
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun badIds() = runTest {
|
|
||||||
val mangaFirst = loadManga("first_chapters.json")
|
|
||||||
val mangaBad = loadManga("bad_ids.json")
|
|
||||||
tracker.deleteTrack(mangaFirst.id)
|
|
||||||
|
|
||||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
tracker.checkUpdates(mangaBad, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun badIds2() = runTest {
|
|
||||||
val mangaFirst = loadManga("first_chapters.json")
|
|
||||||
val mangaBad = loadManga("bad_ids.json")
|
|
||||||
val mangaFull = loadManga("full.json")
|
|
||||||
tracker.deleteTrack(mangaFirst.id)
|
|
||||||
|
|
||||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assertEquals(3, newChapters.size)
|
|
||||||
}
|
|
||||||
assertEquals(3, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
tracker.checkUpdates(mangaBad, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fullReset() = runTest {
|
|
||||||
val mangaFull = loadManga("full.json")
|
|
||||||
val mangaFirst = loadManga("first_chapters.json")
|
|
||||||
val mangaEmpty = loadManga("empty.json")
|
|
||||||
tracker.deleteTrack(mangaFull.id)
|
|
||||||
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
tracker.checkUpdates(mangaEmpty, commit = true).apply {
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assertEquals(3, newChapters.size)
|
|
||||||
}
|
|
||||||
assertEquals(3, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
tracker.checkUpdates(mangaEmpty, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun syncWithHistory() = runTest {
|
|
||||||
val mangaFull = loadManga("full.json")
|
|
||||||
val mangaFirst = loadManga("first_chapters.json")
|
|
||||||
tracker.deleteTrack(mangaFull.id)
|
|
||||||
|
|
||||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assertEquals(3, newChapters.size)
|
|
||||||
}
|
|
||||||
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
|
|
||||||
var chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex - 1) }
|
|
||||||
repository.syncWithHistory(mangaFull, chapter.id)
|
|
||||||
|
|
||||||
assertEquals(1, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
|
|
||||||
chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex) }
|
|
||||||
repository.syncWithHistory(mangaFull, chapter.id)
|
|
||||||
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun loadManga(name: String): Manga {
|
|
||||||
val manga = SampleData.loadAsset("manga/$name", Manga::class)
|
|
||||||
dataRepository.storeManga(manga)
|
|
||||||
return manga
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
package org.koitharu.kotatsu
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.StrictMode
|
|
||||||
import androidx.fragment.app.strictmode.FragmentStrictMode
|
|
||||||
import org.koitharu.kotatsu.core.BaseApp
|
|
||||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
|
||||||
import org.koitharu.kotatsu.local.data.PagesCache
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
|
||||||
|
|
||||||
class KotatsuApp : BaseApp() {
|
|
||||||
|
|
||||||
override fun attachBaseContext(base: Context?) {
|
|
||||||
super.attachBaseContext(base)
|
|
||||||
enableStrictMode()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun enableStrictMode() {
|
|
||||||
StrictMode.setThreadPolicy(
|
|
||||||
StrictMode.ThreadPolicy.Builder()
|
|
||||||
.detectAll()
|
|
||||||
.penaltyLog()
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
StrictMode.setVmPolicy(
|
|
||||||
StrictMode.VmPolicy.Builder()
|
|
||||||
.detectAll()
|
|
||||||
.setClassInstanceLimit(LocalMangaRepository::class.java, 1)
|
|
||||||
.setClassInstanceLimit(PagesCache::class.java, 1)
|
|
||||||
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
|
|
||||||
.setClassInstanceLimit(PageLoader::class.java, 1)
|
|
||||||
.penaltyLog()
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
|
|
||||||
.penaltyDeath()
|
|
||||||
.detectFragmentReuse()
|
|
||||||
.detectWrongFragmentContainer()
|
|
||||||
.detectRetainInstanceUsage()
|
|
||||||
.detectSetUserVisibleHint()
|
|
||||||
.detectFragmentTagUsage()
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.network
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import okhttp3.Interceptor
|
|
||||||
import okhttp3.Response
|
|
||||||
import okio.Buffer
|
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders.ACCEPT_ENCODING
|
|
||||||
|
|
||||||
class CurlLoggingInterceptor(
|
|
||||||
private val curlOptions: String? = null
|
|
||||||
) : Interceptor {
|
|
||||||
|
|
||||||
private val escapeRegex = Regex("([\\[\\]\"])")
|
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
|
||||||
val request = chain.request()
|
|
||||||
var isCompressed = false
|
|
||||||
|
|
||||||
val curlCmd = StringBuilder()
|
|
||||||
curlCmd.append("curl")
|
|
||||||
if (curlOptions != null) {
|
|
||||||
curlCmd.append(' ').append(curlOptions)
|
|
||||||
}
|
|
||||||
curlCmd.append(" -X ").append(request.method)
|
|
||||||
|
|
||||||
for ((name, value) in request.headers) {
|
|
||||||
if (name.equals(ACCEPT_ENCODING, ignoreCase = true) && value.equals("gzip", ignoreCase = true)) {
|
|
||||||
isCompressed = true
|
|
||||||
}
|
|
||||||
curlCmd.append(" -H \"").append(name).append(": ").append(value.escape()).append('\"')
|
|
||||||
}
|
|
||||||
|
|
||||||
val body = request.body
|
|
||||||
if (body != null) {
|
|
||||||
val buffer = Buffer()
|
|
||||||
body.writeTo(buffer)
|
|
||||||
val charset = body.contentType()?.charset() ?: Charsets.UTF_8
|
|
||||||
curlCmd.append(" --data-raw '")
|
|
||||||
.append(buffer.readString(charset).replace("\n", "\\n"))
|
|
||||||
.append("'")
|
|
||||||
}
|
|
||||||
if (isCompressed) {
|
|
||||||
curlCmd.append(" --compressed")
|
|
||||||
}
|
|
||||||
curlCmd.append(" \"").append(request.url.toString().escape()).append('"')
|
|
||||||
|
|
||||||
log("---cURL (" + request.url + ")")
|
|
||||||
log(curlCmd.toString())
|
|
||||||
|
|
||||||
return chain.proceed(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.escape() = replace(escapeRegex) { match ->
|
|
||||||
"\\" + match.value
|
|
||||||
}
|
|
||||||
// .replace("\"", "\\\"")
|
|
||||||
// .replace("[", "\\[")
|
|
||||||
// .replace("]", "\\]")
|
|
||||||
|
|
||||||
private fun log(msg: String) {
|
|
||||||
Log.d("CURL", msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.util.ext
|
|
||||||
|
|
||||||
fun Throwable.printStackTraceDebug() = printStackTrace()
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<menu
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
|
||||||
|
|
||||||
<item
|
|
||||||
android:id="@id/action_leaks"
|
|
||||||
android:title="@string/leak_canary_display_activity_label"
|
|
||||||
app:showAsAction="never" />
|
|
||||||
|
|
||||||
</menu>
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
|
||||||
<bool name="leak_canary_add_launcher_icon" tools:node="replace">false</bool>
|
|
||||||
</resources>
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="account_type_sync" translatable="false">org.kotatsu.debug.sync</string>
|
|
||||||
<string name="sync_authority_history" translatable="false">org.koitharu.kotatsu.debug.history</string>
|
|
||||||
<string name="sync_authority_favourites" translatable="false">org.koitharu.kotatsu.debug.favourites</string>
|
|
||||||
</resources>
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
<resources>
|
|
||||||
<string name="app_name" translatable="false">Kotatsu Dev</string>
|
|
||||||
</resources>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,85 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.alternatives.domain
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.channelFlow
|
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
|
||||||
import kotlinx.coroutines.sync.withPermit
|
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.almostEquals
|
|
||||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
private const val MAX_PARALLELISM = 4
|
|
||||||
private const val MATCH_THRESHOLD = 0.2f
|
|
||||||
|
|
||||||
class AlternativesUseCase @Inject constructor(
|
|
||||||
private val sourcesRepository: MangaSourcesRepository,
|
|
||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
|
||||||
) {
|
|
||||||
|
|
||||||
suspend operator fun invoke(manga: Manga): Flow<Manga> {
|
|
||||||
val sources = getSources(manga.source)
|
|
||||||
if (sources.isEmpty()) {
|
|
||||||
return emptyFlow()
|
|
||||||
}
|
|
||||||
val semaphore = Semaphore(MAX_PARALLELISM)
|
|
||||||
return channelFlow {
|
|
||||||
for (source in sources) {
|
|
||||||
val repository = mangaRepositoryFactory.create(source)
|
|
||||||
if (!repository.isSearchSupported) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
launch {
|
|
||||||
val list = runCatchingCancellable {
|
|
||||||
semaphore.withPermit {
|
|
||||||
repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title))
|
|
||||||
}
|
|
||||||
}.getOrDefault(emptyList())
|
|
||||||
for (item in list) {
|
|
||||||
if (item.matches(manga)) {
|
|
||||||
send(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.map {
|
|
||||||
runCatchingCancellable {
|
|
||||||
mangaRepositoryFactory.create(it.source).getDetails(it)
|
|
||||||
}.getOrDefault(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getSources(ref: MangaSource): List<MangaSource> {
|
|
||||||
val result = ArrayList<MangaSource>(MangaSource.entries.size - 2)
|
|
||||||
result.addAll(sourcesRepository.getEnabledSources())
|
|
||||||
result.sortByDescending { it.priority(ref) }
|
|
||||||
result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) })
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Manga.matches(ref: Manga): Boolean {
|
|
||||||
return matchesTitles(title, ref.title) ||
|
|
||||||
matchesTitles(title, ref.altTitle) ||
|
|
||||||
matchesTitles(altTitle, ref.title) ||
|
|
||||||
matchesTitles(altTitle, ref.altTitle)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun matchesTitles(a: String?, b: String?): Boolean {
|
|
||||||
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, MATCH_THRESHOLD)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun MangaSource.priority(ref: MangaSource): Int {
|
|
||||||
var res = 0
|
|
||||||
if (locale == ref.locale) res += 2
|
|
||||||
if (contentType == ref.contentType) res++
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,129 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.alternatives.domain
|
|
||||||
|
|
||||||
import androidx.room.withTransaction
|
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
|
||||||
import org.koitharu.kotatsu.core.model.getPreferredBranch
|
|
||||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
|
||||||
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
|
|
||||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
|
||||||
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
|
|
||||||
import org.koitharu.kotatsu.history.data.toMangaHistory
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class MigrateUseCase @Inject constructor(
|
|
||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
|
||||||
private val mangaDataRepository: MangaDataRepository,
|
|
||||||
private val database: MangaDatabase,
|
|
||||||
private val progressUpdateUseCase: ProgressUpdateUseCase,
|
|
||||||
) {
|
|
||||||
|
|
||||||
suspend operator fun invoke(oldManga: Manga, newManga: Manga) {
|
|
||||||
val oldDetails = if (oldManga.chapters.isNullOrEmpty()) {
|
|
||||||
runCatchingCancellable {
|
|
||||||
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
|
||||||
}.getOrDefault(oldManga)
|
|
||||||
} else {
|
|
||||||
oldManga
|
|
||||||
}
|
|
||||||
val newDetails = if (newManga.chapters.isNullOrEmpty()) {
|
|
||||||
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
|
||||||
} else {
|
|
||||||
newManga
|
|
||||||
}
|
|
||||||
mangaDataRepository.storeManga(newDetails)
|
|
||||||
database.withTransaction {
|
|
||||||
// replace favorites
|
|
||||||
val favoritesDao = database.getFavouritesDao()
|
|
||||||
val oldFavourites = favoritesDao.findAllRaw(oldDetails.id)
|
|
||||||
if (oldFavourites.isNotEmpty()) {
|
|
||||||
favoritesDao.delete(oldManga.id)
|
|
||||||
for (f in oldFavourites) {
|
|
||||||
val e = f.copy(
|
|
||||||
mangaId = newManga.id,
|
|
||||||
)
|
|
||||||
favoritesDao.upsert(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// replace history
|
|
||||||
val historyDao = database.getHistoryDao()
|
|
||||||
val oldHistory = historyDao.find(oldDetails.id)
|
|
||||||
if (oldHistory != null) {
|
|
||||||
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
|
|
||||||
historyDao.delete(oldDetails.id)
|
|
||||||
historyDao.upsert(newHistory)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
progressUpdateUseCase(newManga)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun makeNewHistory(
|
|
||||||
oldManga: Manga,
|
|
||||||
newManga: Manga,
|
|
||||||
history: HistoryEntity,
|
|
||||||
): HistoryEntity {
|
|
||||||
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
|
|
||||||
val branch = newManga.getPreferredBranch(null)
|
|
||||||
val chapters = checkNotNull(newManga.getChapters(branch))
|
|
||||||
val currentChapter = if (history.percent in 0f..1f) {
|
|
||||||
chapters[(chapters.lastIndex * history.percent).toInt()]
|
|
||||||
} else {
|
|
||||||
chapters.first()
|
|
||||||
}
|
|
||||||
return HistoryEntity(
|
|
||||||
mangaId = newManga.id,
|
|
||||||
createdAt = history.createdAt,
|
|
||||||
updatedAt = System.currentTimeMillis(),
|
|
||||||
chapterId = currentChapter.id,
|
|
||||||
page = history.page,
|
|
||||||
scroll = history.scroll,
|
|
||||||
percent = history.percent,
|
|
||||||
deletedAt = 0,
|
|
||||||
chaptersCount = chapters.size,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
|
|
||||||
val oldChapters = checkNotNull(oldManga.getChapters(branch))
|
|
||||||
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
|
|
||||||
if (index < 0) {
|
|
||||||
index = if (history.percent in 0f..1f) {
|
|
||||||
(oldChapters.lastIndex * history.percent).toInt()
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
|
|
||||||
val newBranch = if (newChapters.containsKey(branch)) {
|
|
||||||
branch
|
|
||||||
} else {
|
|
||||||
newManga.getPreferredBranch(null)
|
|
||||||
}
|
|
||||||
val newChapterId = checkNotNull(newChapters[newBranch]).let {
|
|
||||||
val oldChapter = oldChapters[index]
|
|
||||||
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
|
|
||||||
}.id
|
|
||||||
|
|
||||||
return HistoryEntity(
|
|
||||||
mangaId = newManga.id,
|
|
||||||
createdAt = history.createdAt,
|
|
||||||
updatedAt = System.currentTimeMillis(),
|
|
||||||
chapterId = newChapterId,
|
|
||||||
page = history.page,
|
|
||||||
scroll = history.scroll,
|
|
||||||
percent = PROGRESS_NONE,
|
|
||||||
deletedAt = 0,
|
|
||||||
chaptersCount = checkNotNull(newChapters[newBranch]).size,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun List<MangaChapter>.findByNumber(volume: Int, number: Float): MangaChapter? {
|
|
||||||
return if (number <= 0f) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
firstOrNull { it.volume == volume && it.number == number }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.alternatives.ui
|
|
||||||
|
|
||||||
import android.text.style.ForegroundColorSpan
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.text.buildSpannedString
|
|
||||||
import androidx.core.text.inSpans
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import coil.ImageLoader
|
|
||||||
import coil.request.ImageRequest
|
|
||||||
import coil.transform.CircleCropTransformation
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
|
||||||
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
|
|
||||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
|
||||||
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.source
|
|
||||||
import org.koitharu.kotatsu.databinding.ItemMangaAlternativeBinding
|
|
||||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
import kotlin.math.sign
|
|
||||||
import com.google.android.material.R as materialR
|
|
||||||
|
|
||||||
fun alternativeAD(
|
|
||||||
coil: ImageLoader,
|
|
||||||
lifecycleOwner: LifecycleOwner,
|
|
||||||
listener: OnListItemClickListener<MangaAlternativeModel>,
|
|
||||||
) = adapterDelegateViewBinding<MangaAlternativeModel, ListModel, ItemMangaAlternativeBinding>(
|
|
||||||
{ inflater, parent -> ItemMangaAlternativeBinding.inflate(inflater, parent, false) },
|
|
||||||
) {
|
|
||||||
|
|
||||||
val colorGreen = ContextCompat.getColor(context, R.color.common_green)
|
|
||||||
val colorRed = ContextCompat.getColor(context, R.color.common_red)
|
|
||||||
val clickListener = AdapterDelegateClickListenerAdapter(this, listener)
|
|
||||||
itemView.setOnClickListener(clickListener)
|
|
||||||
binding.buttonMigrate.setOnClickListener(clickListener)
|
|
||||||
binding.chipSource.setOnClickListener(clickListener)
|
|
||||||
|
|
||||||
bind { payloads ->
|
|
||||||
binding.textViewTitle.text = item.manga.title
|
|
||||||
binding.textViewSubtitle.text = buildSpannedString {
|
|
||||||
if (item.chaptersCount > 0) {
|
|
||||||
append(context.resources.getQuantityString(R.plurals.chapters, item.chaptersCount, item.chaptersCount))
|
|
||||||
} else {
|
|
||||||
append(context.getString(R.string.no_chapters))
|
|
||||||
}
|
|
||||||
when (item.chaptersDiff.sign) {
|
|
||||||
-1 -> inSpans(ForegroundColorSpan(colorRed)) {
|
|
||||||
append(" ▼ ")
|
|
||||||
append(item.chaptersDiff.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
1 -> inSpans(ForegroundColorSpan(colorGreen)) {
|
|
||||||
append(" ▲ +")
|
|
||||||
append(item.chaptersDiff.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
binding.progressView.setPercent(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
|
|
||||||
binding.chipSource.also { chip ->
|
|
||||||
chip.text = item.manga.source.title
|
|
||||||
ImageRequest.Builder(context)
|
|
||||||
.data(item.manga.source.faviconUri())
|
|
||||||
.lifecycle(lifecycleOwner)
|
|
||||||
.crossfade(false)
|
|
||||||
.size(context.resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size))
|
|
||||||
.target(ChipIconTarget(chip))
|
|
||||||
.placeholder(R.drawable.ic_web)
|
|
||||||
.fallback(R.drawable.ic_web)
|
|
||||||
.error(R.drawable.ic_web)
|
|
||||||
.source(item.manga.source)
|
|
||||||
.transformations(CircleCropTransformation())
|
|
||||||
.allowRgb565(true)
|
|
||||||
.enqueueWith(coil)
|
|
||||||
}
|
|
||||||
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
|
|
||||||
size(CoverSizeResolver(binding.imageViewCover))
|
|
||||||
placeholder(R.drawable.ic_placeholder)
|
|
||||||
fallback(R.drawable.ic_placeholder)
|
|
||||||
error(R.drawable.ic_error_placeholder)
|
|
||||||
transformations(TrimTransformation())
|
|
||||||
allowRgb565(true)
|
|
||||||
tag(item.manga)
|
|
||||||
source(item.manga.source)
|
|
||||||
enqueueWith(coil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,114 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.alternatives.ui
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.View
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.viewModels
|
|
||||||
import androidx.core.graphics.Insets
|
|
||||||
import androidx.core.view.updatePadding
|
|
||||||
import coil.ImageLoader
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
|
||||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
|
||||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
|
||||||
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
|
|
||||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.search.ui.SearchActivity
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
|
||||||
OnListItemClickListener<MangaAlternativeModel> {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var coil: ImageLoader
|
|
||||||
|
|
||||||
private val viewModel by viewModels<AlternativesViewModel>()
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContentView(ActivityAlternativesBinding.inflate(layoutInflater))
|
|
||||||
supportActionBar?.run {
|
|
||||||
setDisplayHomeAsUpEnabled(true)
|
|
||||||
subtitle = viewModel.manga.title
|
|
||||||
}
|
|
||||||
val listAdapter = BaseListAdapter<ListModel>()
|
|
||||||
.addDelegate(ListItemType.MANGA_LIST_DETAILED, alternativeAD(coil, this, this))
|
|
||||||
.addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, this, null))
|
|
||||||
.addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
|
||||||
.addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
|
||||||
with(viewBinding.recyclerView) {
|
|
||||||
setHasFixedSize(true)
|
|
||||||
addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false))
|
|
||||||
adapter = listAdapter
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
|
|
||||||
viewModel.content.observe(this, listAdapter)
|
|
||||||
viewModel.onMigrated.observeEvent(this) {
|
|
||||||
Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show()
|
|
||||||
startActivity(DetailsActivity.newIntent(this, it))
|
|
||||||
finishAfterTransition()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onWindowInsetsChanged(insets: Insets) {
|
|
||||||
viewBinding.root.updatePadding(
|
|
||||||
left = insets.left,
|
|
||||||
right = insets.right,
|
|
||||||
)
|
|
||||||
viewBinding.recyclerView.updatePadding(
|
|
||||||
bottom = insets.bottom + viewBinding.recyclerView.paddingTop,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onItemClick(item: MangaAlternativeModel, view: View) {
|
|
||||||
when (view.id) {
|
|
||||||
R.id.chip_source -> startActivity(SearchActivity.newIntent(this, item.manga.source, viewModel.manga.title))
|
|
||||||
R.id.button_migrate -> confirmMigration(item.manga)
|
|
||||||
else -> startActivity(DetailsActivity.newIntent(this, item.manga))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun confirmMigration(target: Manga) {
|
|
||||||
MaterialAlertDialogBuilder(this, DIALOG_THEME_CENTERED)
|
|
||||||
.setIcon(R.drawable.ic_replace)
|
|
||||||
.setTitle(R.string.manga_migration)
|
|
||||||
.setMessage(
|
|
||||||
getString(
|
|
||||||
R.string.migrate_confirmation,
|
|
||||||
viewModel.manga.title,
|
|
||||||
viewModel.manga.source.title,
|
|
||||||
target.title,
|
|
||||||
target.source.title,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
|
||||||
.setPositiveButton(R.string.migrate) { _, _ ->
|
|
||||||
viewModel.migrate(target)
|
|
||||||
}.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
fun newIntent(context: Context, manga: Manga) = Intent(context, AlternativesActivity::class.java)
|
|
||||||
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.alternatives.ui
|
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.onEmpty
|
|
||||||
import kotlinx.coroutines.flow.runningFold
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.alternatives.domain.AlternativesUseCase
|
|
||||||
import org.koitharu.kotatsu.alternatives.domain.MigrateUseCase
|
|
||||||
import org.koitharu.kotatsu.core.model.chaptersCount
|
|
||||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
|
||||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.call
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.require
|
|
||||||
import org.koitharu.kotatsu.list.domain.ListExtraProvider
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class AlternativesViewModel @Inject constructor(
|
|
||||||
savedStateHandle: SavedStateHandle,
|
|
||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
|
||||||
private val alternativesUseCase: AlternativesUseCase,
|
|
||||||
private val migrateUseCase: MigrateUseCase,
|
|
||||||
private val extraProvider: ListExtraProvider,
|
|
||||||
) : BaseViewModel() {
|
|
||||||
|
|
||||||
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
|
|
||||||
|
|
||||||
val onMigrated = MutableEventFlow<Manga>()
|
|
||||||
val content = MutableStateFlow<List<ListModel>>(listOf(LoadingState))
|
|
||||||
private var migrationJob: Job? = null
|
|
||||||
|
|
||||||
init {
|
|
||||||
launchJob(Dispatchers.Default) {
|
|
||||||
val ref = runCatchingCancellable {
|
|
||||||
mangaRepositoryFactory.create(manga.source).getDetails(manga)
|
|
||||||
}.getOrDefault(manga)
|
|
||||||
val refCount = ref.chaptersCount()
|
|
||||||
alternativesUseCase(ref)
|
|
||||||
.map {
|
|
||||||
MangaAlternativeModel(
|
|
||||||
manga = it,
|
|
||||||
progress = extraProvider.getProgress(it.id),
|
|
||||||
referenceChapters = refCount,
|
|
||||||
)
|
|
||||||
}.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item ->
|
|
||||||
acc.filterIsInstance<MangaAlternativeModel>() + item + LoadingFooter()
|
|
||||||
}.onEmpty {
|
|
||||||
emit(
|
|
||||||
listOf(
|
|
||||||
EmptyState(
|
|
||||||
icon = R.drawable.ic_empty_common,
|
|
||||||
textPrimary = R.string.nothing_found,
|
|
||||||
textSecondary = R.string.text_search_holder_secondary,
|
|
||||||
actionStringRes = 0,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}.collect {
|
|
||||||
content.value = it
|
|
||||||
}
|
|
||||||
content.value = content.value.filterNot { it is LoadingFooter }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun migrate(target: Manga) {
|
|
||||||
if (migrationJob?.isActive == true) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
migrationJob = launchLoadingJob(Dispatchers.Default) {
|
|
||||||
migrateUseCase(manga, target)
|
|
||||||
onMigrated.call(target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun mapList(list: List<Manga>, refCount: Int): List<MangaAlternativeModel> {
|
|
||||||
return list.map {
|
|
||||||
MangaAlternativeModel(
|
|
||||||
manga = it,
|
|
||||||
progress = extraProvider.getProgress(it.id),
|
|
||||||
referenceChapters = refCount,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.alternatives.ui
|
|
||||||
|
|
||||||
import org.koitharu.kotatsu.core.model.chaptersCount
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
|
|
||||||
data class MangaAlternativeModel(
|
|
||||||
val manga: Manga,
|
|
||||||
val progress: Float,
|
|
||||||
private val referenceChapters: Int,
|
|
||||||
) : ListModel {
|
|
||||||
|
|
||||||
val chaptersCount = manga.chaptersCount()
|
|
||||||
|
|
||||||
val chaptersDiff: Int
|
|
||||||
get() = if (referenceChapters == 0 || chaptersCount == 0) 0 else chaptersCount - referenceChapters
|
|
||||||
|
|
||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
|
||||||
return other is MangaAlternativeModel && other.manga.id == manga.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.data
|
|
||||||
|
|
||||||
import androidx.room.ColumnInfo
|
|
||||||
import androidx.room.Entity
|
|
||||||
import androidx.room.ForeignKey
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
|
||||||
|
|
||||||
@Entity(
|
|
||||||
tableName = "bookmarks",
|
|
||||||
primaryKeys = ["manga_id", "page_id"],
|
|
||||||
foreignKeys = [
|
|
||||||
ForeignKey(
|
|
||||||
entity = MangaEntity::class,
|
|
||||||
parentColumns = ["manga_id"],
|
|
||||||
childColumns = ["manga_id"],
|
|
||||||
onDelete = ForeignKey.CASCADE
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
data class BookmarkEntity(
|
|
||||||
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
|
||||||
@ColumnInfo(name = "page_id", index = true) val pageId: Long,
|
|
||||||
@ColumnInfo(name = "chapter_id") val chapterId: Long,
|
|
||||||
@ColumnInfo(name = "page") val page: Int,
|
|
||||||
@ColumnInfo(name = "scroll") val scroll: Int,
|
|
||||||
@ColumnInfo(name = "image") val imageUrl: String,
|
|
||||||
@ColumnInfo(name = "created_at") val createdAt: Long,
|
|
||||||
@ColumnInfo(name = "percent") val percent: Float,
|
|
||||||
)
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.data
|
|
||||||
|
|
||||||
import androidx.room.Dao
|
|
||||||
import androidx.room.Delete
|
|
||||||
import androidx.room.Insert
|
|
||||||
import androidx.room.Query
|
|
||||||
import androidx.room.Transaction
|
|
||||||
import androidx.room.Upsert
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
abstract class BookmarksDao {
|
|
||||||
|
|
||||||
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
|
|
||||||
abstract suspend fun find(mangaId: Long, pageId: Long): BookmarkEntity?
|
|
||||||
|
|
||||||
@Query("SELECT * FROM bookmarks WHERE page_id = :pageId")
|
|
||||||
abstract suspend fun find(pageId: Long): BookmarkEntity?
|
|
||||||
|
|
||||||
@Transaction
|
|
||||||
@Query(
|
|
||||||
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent",
|
|
||||||
)
|
|
||||||
abstract suspend fun findAll(): Map<MangaWithTags, List<BookmarkEntity>>
|
|
||||||
|
|
||||||
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page ORDER BY percent")
|
|
||||||
abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow<BookmarkEntity?>
|
|
||||||
|
|
||||||
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId ORDER BY percent")
|
|
||||||
abstract fun observe(mangaId: Long): Flow<List<BookmarkEntity>>
|
|
||||||
|
|
||||||
@Transaction
|
|
||||||
@Query(
|
|
||||||
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent",
|
|
||||||
)
|
|
||||||
abstract fun observe(): Flow<Map<MangaWithTags, List<BookmarkEntity>>>
|
|
||||||
|
|
||||||
@Insert
|
|
||||||
abstract suspend fun insert(entity: BookmarkEntity)
|
|
||||||
|
|
||||||
@Delete
|
|
||||||
abstract suspend fun delete(entity: BookmarkEntity)
|
|
||||||
|
|
||||||
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
|
|
||||||
abstract suspend fun delete(mangaId: Long, pageId: Long): Int
|
|
||||||
|
|
||||||
@Query("DELETE FROM bookmarks WHERE page_id = :pageId")
|
|
||||||
abstract suspend fun delete(pageId: Long): Int
|
|
||||||
|
|
||||||
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page")
|
|
||||||
abstract suspend fun delete(mangaId: Long, chapterId: Long, page: Int): Int
|
|
||||||
|
|
||||||
@Upsert
|
|
||||||
abstract suspend fun upsert(bookmarks: Collection<BookmarkEntity>)
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.data
|
|
||||||
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import java.time.Instant
|
|
||||||
|
|
||||||
fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
|
|
||||||
manga = manga,
|
|
||||||
pageId = pageId,
|
|
||||||
chapterId = chapterId,
|
|
||||||
page = page,
|
|
||||||
scroll = scroll,
|
|
||||||
imageUrl = imageUrl,
|
|
||||||
createdAt = Instant.ofEpochMilli(createdAt),
|
|
||||||
percent = percent,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun Bookmark.toEntity() = BookmarkEntity(
|
|
||||||
mangaId = manga.id,
|
|
||||||
pageId = pageId,
|
|
||||||
chapterId = chapterId,
|
|
||||||
page = page,
|
|
||||||
scroll = scroll,
|
|
||||||
imageUrl = imageUrl,
|
|
||||||
createdAt = createdAt.toEpochMilli(),
|
|
||||||
percent = percent,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun Collection<BookmarkEntity>.toBookmarks(manga: Manga) = map {
|
|
||||||
it.toBookmark(manga)
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmName("bookmarksIds")
|
|
||||||
fun Collection<Bookmark>.ids() = map { it.pageId }
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.domain
|
|
||||||
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
import org.koitharu.kotatsu.local.data.hasImageExtension
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
|
||||||
import java.time.Instant
|
|
||||||
|
|
||||||
data class Bookmark(
|
|
||||||
val manga: Manga,
|
|
||||||
val pageId: Long,
|
|
||||||
val chapterId: Long,
|
|
||||||
val page: Int,
|
|
||||||
val scroll: Int,
|
|
||||||
val imageUrl: String,
|
|
||||||
val createdAt: Instant,
|
|
||||||
val percent: Float,
|
|
||||||
) : ListModel {
|
|
||||||
|
|
||||||
val directImageUrl: String?
|
|
||||||
get() = if (isImageUrlDirect()) imageUrl else null
|
|
||||||
|
|
||||||
val imageLoadData: Any
|
|
||||||
get() = if (isImageUrlDirect()) imageUrl else toMangaPage()
|
|
||||||
|
|
||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
|
||||||
return other is Bookmark &&
|
|
||||||
manga.id == other.manga.id &&
|
|
||||||
chapterId == other.chapterId &&
|
|
||||||
page == other.page
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toMangaPage() = MangaPage(
|
|
||||||
id = pageId,
|
|
||||||
url = imageUrl,
|
|
||||||
preview = null,
|
|
||||||
source = manga.source,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun isImageUrlDirect(): Boolean {
|
|
||||||
return hasImageExtension(imageUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.domain
|
|
||||||
|
|
||||||
import android.database.SQLException
|
|
||||||
import androidx.room.withTransaction
|
|
||||||
import dagger.Reusable
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
|
||||||
import org.koitharu.kotatsu.bookmarks.data.toBookmark
|
|
||||||
import org.koitharu.kotatsu.bookmarks.data.toBookmarks
|
|
||||||
import org.koitharu.kotatsu.bookmarks.data.toEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.toEntities
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.toEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.toManga
|
|
||||||
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.mapItems
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@Reusable
|
|
||||||
class BookmarksRepository @Inject constructor(
|
|
||||||
private val db: MangaDatabase,
|
|
||||||
) {
|
|
||||||
|
|
||||||
fun observeBookmark(manga: Manga, chapterId: Long, page: Int): Flow<Bookmark?> {
|
|
||||||
return db.getBookmarksDao().observe(manga.id, chapterId, page).map { it?.toBookmark(manga) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun observeBookmarks(manga: Manga): Flow<List<Bookmark>> {
|
|
||||||
return db.getBookmarksDao().observe(manga.id).mapItems { it.toBookmark(manga) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun observeBookmarks(): Flow<Map<Manga, List<Bookmark>>> {
|
|
||||||
return db.getBookmarksDao().observe().map { map ->
|
|
||||||
val res = LinkedHashMap<Manga, List<Bookmark>>(map.size)
|
|
||||||
for ((k, v) in map) {
|
|
||||||
val manga = k.toManga()
|
|
||||||
res[manga] = v.toBookmarks(manga)
|
|
||||||
}
|
|
||||||
res
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun addBookmark(bookmark: Bookmark) {
|
|
||||||
db.withTransaction {
|
|
||||||
val tags = bookmark.manga.tags.toEntities()
|
|
||||||
db.getTagsDao().upsert(tags)
|
|
||||||
db.getMangaDao().upsert(bookmark.manga.toEntity(), tags)
|
|
||||||
db.getBookmarksDao().insert(bookmark.toEntity())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updateBookmark(bookmark: Bookmark, imageUrl: String) {
|
|
||||||
val entity = bookmark.toEntity().copy(
|
|
||||||
imageUrl = imageUrl,
|
|
||||||
)
|
|
||||||
db.getBookmarksDao().upsert(listOf(entity))
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun removeBookmark(mangaId: Long, chapterId: Long, page: Int) {
|
|
||||||
check(db.getBookmarksDao().delete(mangaId, chapterId, page) != 0) {
|
|
||||||
"Bookmark not found"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun removeBookmark(bookmark: Bookmark) {
|
|
||||||
removeBookmark(bookmark.manga.id, bookmark.chapterId, bookmark.page)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun removeBookmarks(ids: Set<Long>): ReversibleHandle {
|
|
||||||
val entities = ArrayList<BookmarkEntity>(ids.size)
|
|
||||||
db.withTransaction {
|
|
||||||
val dao = db.getBookmarksDao()
|
|
||||||
for (pageId in ids) {
|
|
||||||
val e = dao.find(pageId)
|
|
||||||
if (e != null) {
|
|
||||||
entities.add(e)
|
|
||||||
}
|
|
||||||
dao.delete(pageId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return BookmarksRestorer(entities)
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class BookmarksRestorer(
|
|
||||||
private val entities: Collection<BookmarkEntity>,
|
|
||||||
) : ReversibleHandle {
|
|
||||||
|
|
||||||
override suspend fun reverse() {
|
|
||||||
db.withTransaction {
|
|
||||||
for (e in entities) {
|
|
||||||
try {
|
|
||||||
db.getBookmarksDao().insert(e)
|
|
||||||
} catch (e: SQLException) {
|
|
||||||
e.printStackTraceDebug()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
|
||||||
import androidx.core.graphics.Insets
|
|
||||||
import androidx.core.view.updatePadding
|
|
||||||
import androidx.fragment.app.commit
|
|
||||||
import com.google.android.material.appbar.AppBarLayout
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
|
||||||
import org.koitharu.kotatsu.databinding.ActivityContainerBinding
|
|
||||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
|
||||||
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class BookmarksActivity :
|
|
||||||
BaseActivity<ActivityContainerBinding>(),
|
|
||||||
AppBarOwner,
|
|
||||||
SnackbarOwner {
|
|
||||||
|
|
||||||
override val appBar: AppBarLayout
|
|
||||||
get() = viewBinding.appbar
|
|
||||||
|
|
||||||
override val snackbarHost: CoordinatorLayout
|
|
||||||
get() = viewBinding.root
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContentView(ActivityContainerBinding.inflate(layoutInflater))
|
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
|
||||||
val fm = supportFragmentManager
|
|
||||||
if (fm.findFragmentById(R.id.container) == null) {
|
|
||||||
fm.commit {
|
|
||||||
setReorderingAllowed(true)
|
|
||||||
replace(R.id.container, BookmarksFragment::class.java, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onWindowInsetsChanged(insets: Insets) {
|
|
||||||
viewBinding.root.updatePadding(
|
|
||||||
left = insets.left,
|
|
||||||
right = insets.right,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
fun newIntent(context: Context) = Intent(context, BookmarksActivity::class.java)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,216 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.Menu
|
|
||||||
import android.view.MenuItem
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.appcompat.view.ActionMode
|
|
||||||
import androidx.core.graphics.Insets
|
|
||||||
import androidx.core.view.updateLayoutParams
|
|
||||||
import androidx.core.view.updatePadding
|
|
||||||
import androidx.fragment.app.viewModels
|
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
|
||||||
import coil.ImageLoader
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
|
||||||
import org.koitharu.kotatsu.bookmarks.ui.sheet.BookmarksAdapter
|
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
|
||||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
|
||||||
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
|
|
||||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
|
||||||
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
|
||||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class BookmarksFragment :
|
|
||||||
BaseFragment<FragmentListSimpleBinding>(),
|
|
||||||
ListStateHolderListener,
|
|
||||||
OnListItemClickListener<Bookmark>,
|
|
||||||
ListSelectionController.Callback2,
|
|
||||||
FastScroller.FastScrollListener, ListHeaderClickListener {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var coil: ImageLoader
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var settings: AppSettings
|
|
||||||
|
|
||||||
private val viewModel by viewModels<BookmarksViewModel>()
|
|
||||||
private var bookmarksAdapter: BookmarksAdapter? = null
|
|
||||||
private var selectionController: ListSelectionController? = null
|
|
||||||
|
|
||||||
override fun onCreateViewBinding(
|
|
||||||
inflater: LayoutInflater,
|
|
||||||
container: ViewGroup?,
|
|
||||||
): FragmentListSimpleBinding {
|
|
||||||
return FragmentListSimpleBinding.inflate(inflater, container, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewBindingCreated(
|
|
||||||
binding: FragmentListSimpleBinding,
|
|
||||||
savedInstanceState: Bundle?,
|
|
||||||
) {
|
|
||||||
super.onViewBindingCreated(binding, savedInstanceState)
|
|
||||||
selectionController = ListSelectionController(
|
|
||||||
activity = requireActivity(),
|
|
||||||
decoration = BookmarksSelectionDecoration(binding.root.context),
|
|
||||||
registryOwner = this,
|
|
||||||
callback = this,
|
|
||||||
)
|
|
||||||
bookmarksAdapter = BookmarksAdapter(
|
|
||||||
lifecycleOwner = viewLifecycleOwner,
|
|
||||||
coil = coil,
|
|
||||||
clickListener = this,
|
|
||||||
headerClickListener = this,
|
|
||||||
)
|
|
||||||
val spanSizeLookup = SpanSizeLookup()
|
|
||||||
with(binding.recyclerView) {
|
|
||||||
setHasFixedSize(true)
|
|
||||||
val spanResolver = MangaListSpanResolver(resources)
|
|
||||||
addItemDecoration(TypedListSpacingDecoration(context, false))
|
|
||||||
adapter = bookmarksAdapter
|
|
||||||
addOnLayoutChangeListener(spanResolver)
|
|
||||||
spanResolver.setGridSize(settings.gridSize / 100f, this)
|
|
||||||
val lm = GridLayoutManager(context, spanResolver.spanCount)
|
|
||||||
lm.spanSizeLookup = spanSizeLookup
|
|
||||||
layoutManager = lm
|
|
||||||
selectionController?.attachToRecyclerView(this)
|
|
||||||
}
|
|
||||||
viewModel.content.observe(viewLifecycleOwner) {
|
|
||||||
bookmarksAdapter?.setItems(it, spanSizeLookup)
|
|
||||||
}
|
|
||||||
viewModel.onError.observeEvent(
|
|
||||||
viewLifecycleOwner,
|
|
||||||
SnackbarErrorObserver(binding.recyclerView, this)
|
|
||||||
)
|
|
||||||
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyView() {
|
|
||||||
super.onDestroyView()
|
|
||||||
bookmarksAdapter = null
|
|
||||||
selectionController = null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onItemClick(item: Bookmark, view: View) {
|
|
||||||
if (selectionController?.onItemClick(item.pageId) != true) {
|
|
||||||
val intent = ReaderActivity.IntentBuilder(view.context)
|
|
||||||
.bookmark(item)
|
|
||||||
.incognito(true)
|
|
||||||
.build()
|
|
||||||
startActivity(intent)
|
|
||||||
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onListHeaderClick(item: ListHeader, view: View) {
|
|
||||||
val manga = item.payload as? Manga ?: return
|
|
||||||
startActivity(DetailsActivity.newIntent(view.context, manga))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
|
|
||||||
return selectionController?.onItemLongClick(item.pageId) ?: false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRetryClick(error: Throwable) = Unit
|
|
||||||
|
|
||||||
override fun onEmptyActionClick() = Unit
|
|
||||||
|
|
||||||
override fun onFastScrollStart(fastScroller: FastScroller) {
|
|
||||||
(activity as? AppBarOwner)?.appBar?.setExpanded(false, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFastScrollStop(fastScroller: FastScroller) = Unit
|
|
||||||
|
|
||||||
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
|
|
||||||
requireViewBinding().recyclerView.invalidateItemDecorations()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateActionMode(
|
|
||||||
controller: ListSelectionController,
|
|
||||||
mode: ActionMode,
|
|
||||||
menu: Menu,
|
|
||||||
): Boolean {
|
|
||||||
mode.menuInflater.inflate(R.menu.mode_bookmarks, menu)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActionItemClicked(
|
|
||||||
controller: ListSelectionController,
|
|
||||||
mode: ActionMode,
|
|
||||||
item: MenuItem,
|
|
||||||
): Boolean {
|
|
||||||
return when (item.itemId) {
|
|
||||||
R.id.action_remove -> {
|
|
||||||
val ids = selectionController?.snapshot() ?: return false
|
|
||||||
viewModel.removeBookmarks(ids)
|
|
||||||
mode.finish()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onWindowInsetsChanged(insets: Insets) {
|
|
||||||
val rv = requireViewBinding().recyclerView
|
|
||||||
rv.updatePadding(
|
|
||||||
bottom = insets.bottom + rv.paddingTop,
|
|
||||||
)
|
|
||||||
rv.fastScroller.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
|
||||||
bottomMargin = insets.bottom
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup(), Runnable {
|
|
||||||
|
|
||||||
init {
|
|
||||||
isSpanIndexCacheEnabled = true
|
|
||||||
isSpanGroupIndexCacheEnabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getSpanSize(position: Int): Int {
|
|
||||||
val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount
|
|
||||||
?: return 1
|
|
||||||
return when (bookmarksAdapter?.getItemViewType(position)) {
|
|
||||||
ListItemType.PAGE_THUMB.ordinal -> 1
|
|
||||||
else -> total
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun run() {
|
|
||||||
invalidateSpanGroupIndexCache()
|
|
||||||
invalidateSpanIndexCache()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
@Deprecated(
|
|
||||||
"", ReplaceWith(
|
|
||||||
"BookmarksFragment()",
|
|
||||||
"org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
fun newInstance() = BookmarksFragment()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.view.View
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.getItem
|
|
||||||
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
|
|
||||||
|
|
||||||
class BookmarksSelectionDecoration(context: Context) : MangaSelectionDecoration(context) {
|
|
||||||
|
|
||||||
override fun getItemId(parent: RecyclerView, child: View): Long {
|
|
||||||
val holder = parent.getChildViewHolder(child) ?: return RecyclerView.NO_ID
|
|
||||||
val item = holder.getItem(Bookmark::class.java) ?: return RecyclerView.NO_ID
|
|
||||||
return item.pageId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui
|
|
||||||
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.plus
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
|
||||||
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.call
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class BookmarksViewModel @Inject constructor(
|
|
||||||
private val repository: BookmarksRepository,
|
|
||||||
) : BaseViewModel() {
|
|
||||||
|
|
||||||
val onActionDone = MutableEventFlow<ReversibleAction>()
|
|
||||||
|
|
||||||
val content: StateFlow<List<ListModel>> = repository.observeBookmarks()
|
|
||||||
.map { list ->
|
|
||||||
if (list.isEmpty()) {
|
|
||||||
listOf(
|
|
||||||
EmptyState(
|
|
||||||
icon = R.drawable.ic_empty_favourites,
|
|
||||||
textPrimary = R.string.no_bookmarks_yet,
|
|
||||||
textSecondary = R.string.no_bookmarks_summary,
|
|
||||||
actionStringRes = 0,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
mapList(list)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.catch { e -> emit(listOf(e.toErrorState(canRetry = false))) }
|
|
||||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
|
|
||||||
|
|
||||||
fun removeBookmarks(ids: Set<Long>) {
|
|
||||||
launchJob(Dispatchers.Default) {
|
|
||||||
val handle = repository.removeBookmarks(ids)
|
|
||||||
onActionDone.call(ReversibleAction(R.string.bookmarks_removed, handle))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mapList(data: Map<Manga, List<Bookmark>>): List<ListModel> {
|
|
||||||
val result = ArrayList<ListModel>(data.values.sumOf { it.size + 1 })
|
|
||||||
for ((manga, bookmarks) in data) {
|
|
||||||
result.add(ListHeader(manga.title, R.string.more, manga))
|
|
||||||
result.addAll(bookmarks)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
|
||||||
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import coil.ImageLoader
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
|
||||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.source
|
|
||||||
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
|
|
||||||
|
|
||||||
fun bookmarkListAD(
|
|
||||||
coil: ImageLoader,
|
|
||||||
lifecycleOwner: LifecycleOwner,
|
|
||||||
clickListener: OnListItemClickListener<Bookmark>,
|
|
||||||
) = adapterDelegateViewBinding<Bookmark, Bookmark, ItemBookmarkBinding>(
|
|
||||||
{ inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) },
|
|
||||||
) {
|
|
||||||
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
|
|
||||||
|
|
||||||
binding.root.setOnClickListener(listener)
|
|
||||||
binding.root.setOnLongClickListener(listener)
|
|
||||||
|
|
||||||
bind {
|
|
||||||
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
|
||||||
size(CoverSizeResolver(binding.imageViewThumb))
|
|
||||||
placeholder(R.drawable.ic_placeholder)
|
|
||||||
fallback(R.drawable.ic_placeholder)
|
|
||||||
error(R.drawable.ic_error_placeholder)
|
|
||||||
allowRgb565(true)
|
|
||||||
tag(item)
|
|
||||||
decodeRegion(item.scroll)
|
|
||||||
source(item.manga.source)
|
|
||||||
enqueueWith(coil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
|
||||||
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import coil.ImageLoader
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
|
||||||
|
|
||||||
class BookmarksAdapter(
|
|
||||||
coil: ImageLoader,
|
|
||||||
lifecycleOwner: LifecycleOwner,
|
|
||||||
clickListener: OnListItemClickListener<Bookmark>,
|
|
||||||
) : BaseListAdapter<Bookmark>() {
|
|
||||||
|
|
||||||
init {
|
|
||||||
addDelegate(ListItemType.PAGE_THUMB, bookmarkListAD(coil, lifecycleOwner, clickListener))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.sheet
|
|
||||||
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import coil.ImageLoader
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
|
||||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.source
|
|
||||||
import org.koitharu.kotatsu.databinding.ItemBookmarkLargeBinding
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
|
|
||||||
fun bookmarkLargeAD(
|
|
||||||
coil: ImageLoader,
|
|
||||||
lifecycleOwner: LifecycleOwner,
|
|
||||||
clickListener: OnListItemClickListener<Bookmark>,
|
|
||||||
) = adapterDelegateViewBinding<Bookmark, ListModel, ItemBookmarkLargeBinding>(
|
|
||||||
{ inflater, parent -> ItemBookmarkLargeBinding.inflate(inflater, parent, false) },
|
|
||||||
) {
|
|
||||||
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
|
|
||||||
|
|
||||||
binding.root.setOnClickListener(listener)
|
|
||||||
binding.root.setOnLongClickListener(listener)
|
|
||||||
|
|
||||||
bind {
|
|
||||||
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
|
||||||
size(CoverSizeResolver(binding.imageViewThumb))
|
|
||||||
placeholder(R.drawable.ic_placeholder)
|
|
||||||
fallback(R.drawable.ic_placeholder)
|
|
||||||
error(R.drawable.ic_error_placeholder)
|
|
||||||
allowRgb565(true)
|
|
||||||
tag(item)
|
|
||||||
decodeRegion(item.scroll)
|
|
||||||
source(item.manga.source)
|
|
||||||
enqueueWith(coil)
|
|
||||||
}
|
|
||||||
binding.progressView.percent = item.percent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.sheet
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import coil.ImageLoader
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
|
|
||||||
class BookmarksAdapter(
|
|
||||||
coil: ImageLoader,
|
|
||||||
lifecycleOwner: LifecycleOwner,
|
|
||||||
clickListener: OnListItemClickListener<Bookmark>,
|
|
||||||
headerClickListener: ListHeaderClickListener?,
|
|
||||||
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
|
|
||||||
|
|
||||||
init {
|
|
||||||
addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(coil, lifecycleOwner, clickListener))
|
|
||||||
addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener))
|
|
||||||
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
|
||||||
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
|
||||||
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, null))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getSectionText(context: Context, position: Int): CharSequence? {
|
|
||||||
return findHeader(position)?.getText(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,169 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.sheet
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.fragment.app.FragmentManager
|
|
||||||
import androidx.fragment.app.viewModels
|
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
|
||||||
import coil.ImageLoader
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
|
||||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
|
||||||
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior
|
|
||||||
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback
|
|
||||||
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
|
||||||
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.plus
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
|
||||||
import org.koitharu.kotatsu.databinding.SheetPagesBinding
|
|
||||||
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
|
|
||||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
|
||||||
import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
|
|
||||||
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
|
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class BookmarksSheet :
|
|
||||||
BaseAdaptiveSheet<SheetPagesBinding>(),
|
|
||||||
AdaptiveSheetCallback,
|
|
||||||
OnListItemClickListener<Bookmark> {
|
|
||||||
|
|
||||||
private val viewModel by viewModels<BookmarksSheetViewModel>()
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var coil: ImageLoader
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var settings: AppSettings
|
|
||||||
|
|
||||||
private var bookmarksAdapter: BookmarksAdapter? = null
|
|
||||||
private var spanResolver: MangaListSpanResolver? = null
|
|
||||||
|
|
||||||
private val spanSizeLookup = SpanSizeLookup()
|
|
||||||
private val listCommitCallback = Runnable {
|
|
||||||
spanSizeLookup.invalidateCache()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetPagesBinding {
|
|
||||||
return SheetPagesBinding.inflate(inflater, container, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewBindingCreated(binding: SheetPagesBinding, savedInstanceState: Bundle?) {
|
|
||||||
super.onViewBindingCreated(binding, savedInstanceState)
|
|
||||||
addSheetCallback(this)
|
|
||||||
spanResolver = MangaListSpanResolver(binding.root.resources)
|
|
||||||
bookmarksAdapter = BookmarksAdapter(
|
|
||||||
coil = coil,
|
|
||||||
lifecycleOwner = viewLifecycleOwner,
|
|
||||||
clickListener = this@BookmarksSheet,
|
|
||||||
headerClickListener = null,
|
|
||||||
)
|
|
||||||
viewBinding?.headerBar?.setTitle(R.string.bookmarks)
|
|
||||||
with(binding.recyclerView) {
|
|
||||||
addItemDecoration(TypedListSpacingDecoration(context, false))
|
|
||||||
adapter = bookmarksAdapter
|
|
||||||
addOnLayoutChangeListener(spanResolver)
|
|
||||||
spanResolver?.setGridSize(settings.gridSize / 100f, this)
|
|
||||||
(layoutManager as GridLayoutManager).spanSizeLookup = spanSizeLookup
|
|
||||||
}
|
|
||||||
viewModel.content.observe(viewLifecycleOwner, ::onThumbnailsChanged)
|
|
||||||
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyView() {
|
|
||||||
spanResolver = null
|
|
||||||
bookmarksAdapter = null
|
|
||||||
spanSizeLookup.invalidateCache()
|
|
||||||
super.onDestroyView()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onItemClick(item: Bookmark, view: View) {
|
|
||||||
val listener = (parentFragment as? OnPageSelectListener) ?: (activity as? OnPageSelectListener)
|
|
||||||
if (listener != null) {
|
|
||||||
listener.onPageSelected(ReaderPage(item.toMangaPage(), item.page, item.chapterId))
|
|
||||||
} else {
|
|
||||||
val intent = IntentBuilder(view.context)
|
|
||||||
.manga(viewModel.manga)
|
|
||||||
.bookmark(item)
|
|
||||||
.incognito(true)
|
|
||||||
.build()
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStateChanged(sheet: View, newState: Int) {
|
|
||||||
viewBinding?.recyclerView?.isFastScrollerEnabled = newState == AdaptiveSheetBehavior.STATE_EXPANDED
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onThumbnailsChanged(list: List<ListModel>) {
|
|
||||||
val adapter = bookmarksAdapter ?: return
|
|
||||||
if (adapter.itemCount == 0) {
|
|
||||||
var position = list.indexOfFirst { it is PageThumbnail && it.isCurrent }
|
|
||||||
if (position > 0) {
|
|
||||||
val spanCount = spanResolver?.spanCount ?: 0
|
|
||||||
val offset = if (position > spanCount + 1) {
|
|
||||||
(resources.getDimensionPixelSize(R.dimen.manga_list_details_item_height) * 0.6).roundToInt()
|
|
||||||
} else {
|
|
||||||
position = 0
|
|
||||||
0
|
|
||||||
}
|
|
||||||
val scrollCallback = RecyclerViewScrollCallback(requireViewBinding().recyclerView, position, offset)
|
|
||||||
adapter.setItems(list, listCommitCallback + scrollCallback)
|
|
||||||
} else {
|
|
||||||
adapter.setItems(list, listCommitCallback)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
adapter.setItems(list, listCommitCallback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() {
|
|
||||||
|
|
||||||
init {
|
|
||||||
isSpanIndexCacheEnabled = true
|
|
||||||
isSpanGroupIndexCacheEnabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getSpanSize(position: Int): Int {
|
|
||||||
val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1
|
|
||||||
return when (bookmarksAdapter?.getItemViewType(position)) {
|
|
||||||
ListItemType.PAGE_THUMB.ordinal -> 1
|
|
||||||
else -> total
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun invalidateCache() {
|
|
||||||
invalidateSpanGroupIndexCache()
|
|
||||||
invalidateSpanIndexCache()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
const val ARG_MANGA = "manga"
|
|
||||||
|
|
||||||
private const val TAG = "BookmarksSheet"
|
|
||||||
|
|
||||||
fun show(fm: FragmentManager, manga: Manga) {
|
|
||||||
BookmarksSheet().withArgs(1) {
|
|
||||||
putParcelable(ARG_MANGA, ParcelableManga(manga))
|
|
||||||
}.showDistinct(fm, TAG)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.sheet
|
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.plus
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
|
||||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.require
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
|
||||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class BookmarksSheetViewModel @Inject constructor(
|
|
||||||
savedStateHandle: SavedStateHandle,
|
|
||||||
mangaRepositoryFactory: MangaRepository.Factory,
|
|
||||||
bookmarksRepository: BookmarksRepository,
|
|
||||||
) : BaseViewModel() {
|
|
||||||
|
|
||||||
val manga = savedStateHandle.require<ParcelableManga>(BookmarksSheet.ARG_MANGA).manga
|
|
||||||
private val chaptersLazy = SuspendLazy {
|
|
||||||
requireNotNull(manga.chapters ?: mangaRepositoryFactory.create(manga.source).getDetails(manga).chapters)
|
|
||||||
}
|
|
||||||
|
|
||||||
val content: StateFlow<List<ListModel>> = bookmarksRepository.observeBookmarks(manga)
|
|
||||||
.map { mapList(it) }
|
|
||||||
.withErrorHandling()
|
|
||||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingFooter()))
|
|
||||||
|
|
||||||
private suspend fun mapList(bookmarks: List<Bookmark>): List<ListModel> {
|
|
||||||
val chapters = chaptersLazy.get()
|
|
||||||
val bookmarksMap = bookmarks.groupBy { it.chapterId }
|
|
||||||
val result = ArrayList<ListModel>(bookmarks.size + bookmarksMap.size)
|
|
||||||
for (chapter in chapters) {
|
|
||||||
val b = bookmarksMap[chapter.id]
|
|
||||||
if (b.isNullOrEmpty()) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result += ListHeader(chapter.name)
|
|
||||||
result.addAll(b)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,149 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.browser
|
|
||||||
|
|
||||||
import android.content.ActivityNotFoundException
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.Menu
|
|
||||||
import android.view.MenuItem
|
|
||||||
import android.webkit.CookieManager
|
|
||||||
import androidx.core.graphics.Insets
|
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import androidx.core.view.updatePadding
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
|
||||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
|
||||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
import javax.inject.Inject
|
|
||||||
import com.google.android.material.R as materialR
|
|
||||||
|
|
||||||
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
|
|
||||||
|
|
||||||
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var mangaRepositoryFactory: MangaRepository.Factory
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
supportActionBar?.run {
|
|
||||||
setDisplayHomeAsUpEnabled(true)
|
|
||||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
|
||||||
}
|
|
||||||
val userAgent = intent?.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE)?.let { source ->
|
|
||||||
val repository = mangaRepositoryFactory.create(source) as? RemoteMangaRepository
|
|
||||||
repository?.headers?.get(CommonHeaders.USER_AGENT)
|
|
||||||
}
|
|
||||||
viewBinding.webView.configureForParser(userAgent)
|
|
||||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
|
||||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
|
||||||
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
|
||||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
|
|
||||||
onBackPressedDispatcher.addCallback(onBackPressedCallback)
|
|
||||||
if (savedInstanceState != null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val url = intent?.dataString
|
|
||||||
if (url.isNullOrEmpty()) {
|
|
||||||
finishAfterTransition()
|
|
||||||
} else {
|
|
||||||
onTitleChanged(
|
|
||||||
intent?.getStringExtra(EXTRA_TITLE) ?: getString(R.string.loading_),
|
|
||||||
url,
|
|
||||||
)
|
|
||||||
viewBinding.webView.loadUrl(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
|
||||||
super.onCreateOptionsMenu(menu)
|
|
||||||
menuInflater.inflate(R.menu.opt_browser, menu)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
|
||||||
android.R.id.home -> {
|
|
||||||
viewBinding.webView.stopLoading()
|
|
||||||
finishAfterTransition()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_browser -> {
|
|
||||||
val url = viewBinding.webView.url?.toUriOrNull()
|
|
||||||
if (url != null) {
|
|
||||||
val intent = Intent(Intent.ACTION_VIEW)
|
|
||||||
intent.data = url
|
|
||||||
try {
|
|
||||||
startActivity(Intent.createChooser(intent, item.title))
|
|
||||||
} catch (_: ActivityNotFoundException) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> super.onOptionsItemSelected(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPause() {
|
|
||||||
viewBinding.webView.onPause()
|
|
||||||
super.onPause()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
viewBinding.webView.onResume()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
viewBinding.webView.stopLoading()
|
|
||||||
viewBinding.webView.destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLoadingStateChanged(isLoading: Boolean) {
|
|
||||||
viewBinding.progressBar.isVisible = isLoading
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
|
|
||||||
this.title = title
|
|
||||||
supportActionBar?.subtitle = subtitle
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onHistoryChanged() {
|
|
||||||
onBackPressedCallback.onHistoryChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onWindowInsetsChanged(insets: Insets) {
|
|
||||||
viewBinding.appbar.updatePadding(
|
|
||||||
top = insets.top,
|
|
||||||
)
|
|
||||||
viewBinding.root.updatePadding(
|
|
||||||
left = insets.left,
|
|
||||||
right = insets.right,
|
|
||||||
bottom = insets.bottom,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
private const val EXTRA_TITLE = "title"
|
|
||||||
private const val EXTRA_SOURCE = "source"
|
|
||||||
|
|
||||||
fun newIntent(context: Context, url: String, source: MangaSource?, title: String?): Intent {
|
|
||||||
return Intent(context, BrowserActivity::class.java)
|
|
||||||
.setData(Uri.parse(url))
|
|
||||||
.putExtra(EXTRA_TITLE, title)
|
|
||||||
.putExtra(EXTRA_SOURCE, source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.browser
|
|
||||||
|
|
||||||
interface BrowserCallback : OnHistoryChangedListener {
|
|
||||||
|
|
||||||
fun onLoadingStateChanged(isLoading: Boolean)
|
|
||||||
|
|
||||||
fun onTitleChanged(title: CharSequence, subtitle: CharSequence?)
|
|
||||||
}
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.browser
|
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.webkit.WebView
|
|
||||||
import android.webkit.WebViewClient
|
|
||||||
|
|
||||||
open class BrowserClient(private val callback: BrowserCallback) : WebViewClient() {
|
|
||||||
|
|
||||||
override fun onPageFinished(webView: WebView, url: String) {
|
|
||||||
super.onPageFinished(webView, url)
|
|
||||||
callback.onLoadingStateChanged(isLoading = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
|
||||||
super.onPageStarted(view, url, favicon)
|
|
||||||
callback.onLoadingStateChanged(isLoading = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPageCommitVisible(view: WebView, url: String?) {
|
|
||||||
super.onPageCommitVisible(view, url)
|
|
||||||
callback.onTitleChanged(view.title.orEmpty(), url)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) {
|
|
||||||
super.doUpdateVisitedHistory(view, url, isReload)
|
|
||||||
callback.onHistoryChanged()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.browser
|
|
||||||
|
|
||||||
fun interface OnHistoryChangedListener {
|
|
||||||
|
|
||||||
fun onHistoryChanged()
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.browser
|
|
||||||
|
|
||||||
import android.webkit.WebChromeClient
|
|
||||||
import android.webkit.WebView
|
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import com.google.android.material.progressindicator.BaseProgressIndicator
|
|
||||||
|
|
||||||
private const val PROGRESS_MAX = 100
|
|
||||||
|
|
||||||
class ProgressChromeClient(
|
|
||||||
private val progressIndicator: BaseProgressIndicator<*>,
|
|
||||||
) : WebChromeClient() {
|
|
||||||
|
|
||||||
init {
|
|
||||||
progressIndicator.max = PROGRESS_MAX
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
|
||||||
super.onProgressChanged(view, newProgress)
|
|
||||||
if (!progressIndicator.isVisible) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (newProgress in 1 until PROGRESS_MAX) {
|
|
||||||
progressIndicator.isIndeterminate = false
|
|
||||||
progressIndicator.setProgressCompat(newProgress.coerceAtMost(PROGRESS_MAX), true)
|
|
||||||
} else {
|
|
||||||
progressIndicator.isIndeterminate = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.browser
|
|
||||||
|
|
||||||
import android.webkit.WebView
|
|
||||||
import androidx.activity.OnBackPressedCallback
|
|
||||||
|
|
||||||
class WebViewBackPressedCallback(
|
|
||||||
private val webView: WebView,
|
|
||||||
) : OnBackPressedCallback(false), OnHistoryChangedListener {
|
|
||||||
|
|
||||||
init {
|
|
||||||
onHistoryChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun handleOnBackPressed() {
|
|
||||||
webView.goBack()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onHistoryChanged() {
|
|
||||||
isEnabled = webView.canGoBack()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.browser.cloudflare
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.core.app.NotificationChannelCompat
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import androidx.core.app.PendingIntentCompat
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import coil.EventListener
|
|
||||||
import coil.request.ErrorResult
|
|
||||||
import coil.request.ImageRequest
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
|
|
||||||
class CaptchaNotifier(
|
|
||||||
private val context: Context,
|
|
||||||
) : EventListener {
|
|
||||||
|
|
||||||
fun notify(exception: CloudFlareProtectedException) {
|
|
||||||
if (!context.checkNotificationPermission(CHANNEL_ID)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val manager = NotificationManagerCompat.from(context)
|
|
||||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
|
|
||||||
.setName(context.getString(R.string.captcha_required))
|
|
||||||
.setShowBadge(true)
|
|
||||||
.setVibrationEnabled(false)
|
|
||||||
.setSound(null, null)
|
|
||||||
.setLightsEnabled(false)
|
|
||||||
.build()
|
|
||||||
manager.createNotificationChannel(channel)
|
|
||||||
|
|
||||||
val intent = CloudFlareActivity.newIntent(context, exception.url, exception.headers)
|
|
||||||
.setData(exception.url.toUri())
|
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
|
||||||
.setContentTitle(channel.name)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
|
||||||
.setDefaults(NotificationCompat.DEFAULT_SOUND)
|
|
||||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.setVisibility(
|
|
||||||
if (exception.source?.contentType == ContentType.HENTAI) {
|
|
||||||
NotificationCompat.VISIBILITY_SECRET
|
|
||||||
} else {
|
|
||||||
NotificationCompat.VISIBILITY_PUBLIC
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.setContentText(
|
|
||||||
context.getString(
|
|
||||||
R.string.captcha_required_summary,
|
|
||||||
exception.source?.title ?: context.getString(R.string.app_name),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false))
|
|
||||||
.build()
|
|
||||||
manager.notify(TAG, exception.source.hashCode(), notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismiss(source: MangaSource) {
|
|
||||||
NotificationManagerCompat.from(context).cancel(TAG, source.hashCode())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onError(request: ImageRequest, result: ErrorResult) {
|
|
||||||
super.onError(request, result)
|
|
||||||
val e = result.throwable
|
|
||||||
if (e is CloudFlareProtectedException && request.parameters.value<Boolean>(PARAM_IGNORE_CAPTCHA) != true) {
|
|
||||||
notify(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
fun ImageRequest.Builder.ignoreCaptchaErrors() = setParameter(
|
|
||||||
key = PARAM_IGNORE_CAPTCHA,
|
|
||||||
value = true,
|
|
||||||
memoryCacheKey = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
private const val PARAM_IGNORE_CAPTCHA = "ignore_captcha"
|
|
||||||
private const val CHANNEL_ID = "captcha"
|
|
||||||
private const val TAG = CHANNEL_ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,203 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.browser.cloudflare
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.Menu
|
|
||||||
import android.view.MenuItem
|
|
||||||
import android.webkit.CookieManager
|
|
||||||
import androidx.activity.result.contract.ActivityResultContract
|
|
||||||
import androidx.core.graphics.Insets
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.core.view.isInvisible
|
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import androidx.core.view.updatePadding
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import kotlinx.coroutines.yield
|
|
||||||
import okhttp3.Headers
|
|
||||||
import okhttp3.HttpUrl
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
|
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
|
||||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
|
||||||
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
|
||||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
|
||||||
import javax.inject.Inject
|
|
||||||
import com.google.android.material.R as materialR
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCallback {
|
|
||||||
|
|
||||||
private var pendingResult = RESULT_CANCELED
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var cookieJar: MutableCookieJar
|
|
||||||
|
|
||||||
private lateinit var cfClient: CloudFlareClient
|
|
||||||
private var onBackPressedCallback: WebViewBackPressedCallback? = null
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
supportActionBar?.run {
|
|
||||||
setDisplayHomeAsUpEnabled(true)
|
|
||||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
|
||||||
}
|
|
||||||
val url = intent?.dataString.orEmpty()
|
|
||||||
cfClient = CloudFlareClient(cookieJar, this, url)
|
|
||||||
viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA))
|
|
||||||
viewBinding.webView.webViewClient = cfClient
|
|
||||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
|
|
||||||
onBackPressedDispatcher.addCallback(it)
|
|
||||||
}
|
|
||||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
|
||||||
if (savedInstanceState != null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (url.isEmpty()) {
|
|
||||||
finishAfterTransition()
|
|
||||||
} else {
|
|
||||||
onTitleChanged(getString(R.string.loading_), url)
|
|
||||||
viewBinding.webView.loadUrl(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
runCatching {
|
|
||||||
viewBinding.webView
|
|
||||||
}.onSuccess {
|
|
||||||
it.stopLoading()
|
|
||||||
it.destroy()
|
|
||||||
}
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
|
||||||
menuInflater.inflate(R.menu.opt_captcha, menu)
|
|
||||||
return super.onCreateOptionsMenu(menu)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onWindowInsetsChanged(insets: Insets) {
|
|
||||||
viewBinding.appbar.updatePadding(
|
|
||||||
top = insets.top,
|
|
||||||
)
|
|
||||||
viewBinding.root.updatePadding(
|
|
||||||
left = insets.left,
|
|
||||||
right = insets.right,
|
|
||||||
bottom = insets.bottom,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
|
||||||
android.R.id.home -> {
|
|
||||||
viewBinding.webView.stopLoading()
|
|
||||||
finishAfterTransition()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_retry -> {
|
|
||||||
restartCheck()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> super.onOptionsItemSelected(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
viewBinding.webView.onResume()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPause() {
|
|
||||||
viewBinding.webView.onPause()
|
|
||||||
super.onPause()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun finish() {
|
|
||||||
setResult(pendingResult)
|
|
||||||
super.finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPageLoaded() {
|
|
||||||
viewBinding.progressBar.isInvisible = true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLoopDetected() {
|
|
||||||
restartCheck()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCheckPassed() {
|
|
||||||
pendingResult = RESULT_OK
|
|
||||||
finishAfterTransition()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLoadingStateChanged(isLoading: Boolean) {
|
|
||||||
viewBinding.progressBar.isVisible = isLoading
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onHistoryChanged() {
|
|
||||||
onBackPressedCallback?.onHistoryChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
|
|
||||||
setTitle(title)
|
|
||||||
supportActionBar?.subtitle =
|
|
||||||
subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun restartCheck() {
|
|
||||||
lifecycleScope.launch {
|
|
||||||
viewBinding.webView.stopLoading()
|
|
||||||
yield()
|
|
||||||
cfClient.reset()
|
|
||||||
val targetUrl = intent?.dataString?.toHttpUrlOrNull()
|
|
||||||
if (targetUrl != null) {
|
|
||||||
clearCfCookies(targetUrl)
|
|
||||||
viewBinding.webView.loadUrl(targetUrl.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) {
|
|
||||||
cookieJar.removeCookies(url) { cookie ->
|
|
||||||
val name = cookie.name
|
|
||||||
name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf") || name == "csrftoken"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Contract : ActivityResultContract<Pair<String, Headers?>, TaggedActivityResult>() {
|
|
||||||
override fun createIntent(context: Context, input: Pair<String, Headers?>): Intent {
|
|
||||||
return newIntent(context, input.first, input.second)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult {
|
|
||||||
return TaggedActivityResult(TAG, resultCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
const val TAG = "CloudFlareActivity"
|
|
||||||
private const val ARG_UA = "ua"
|
|
||||||
|
|
||||||
fun newIntent(
|
|
||||||
context: Context,
|
|
||||||
url: String,
|
|
||||||
headers: Headers?,
|
|
||||||
) = Intent(context, CloudFlareActivity::class.java).apply {
|
|
||||||
data = url.toUri()
|
|
||||||
headers?.get(CommonHeaders.USER_AGENT)?.let {
|
|
||||||
putExtra(ARG_UA, it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.browser.cloudflare
|
|
||||||
|
|
||||||
import org.koitharu.kotatsu.browser.BrowserCallback
|
|
||||||
|
|
||||||
interface CloudFlareCallback : BrowserCallback {
|
|
||||||
|
|
||||||
override fun onLoadingStateChanged(isLoading: Boolean) = Unit
|
|
||||||
|
|
||||||
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) = Unit
|
|
||||||
|
|
||||||
fun onPageLoaded()
|
|
||||||
|
|
||||||
fun onCheckPassed()
|
|
||||||
|
|
||||||
fun onLoopDetected()
|
|
||||||
}
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.browser.cloudflare
|
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.webkit.WebView
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
|
||||||
import org.koitharu.kotatsu.browser.BrowserClient
|
|
||||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
|
||||||
|
|
||||||
private const val CF_CLEARANCE = "cf_clearance"
|
|
||||||
private const val LOOP_COUNTER = 3
|
|
||||||
|
|
||||||
class CloudFlareClient(
|
|
||||||
private val cookieJar: MutableCookieJar,
|
|
||||||
private val callback: CloudFlareCallback,
|
|
||||||
private val targetUrl: String,
|
|
||||||
) : BrowserClient(callback) {
|
|
||||||
|
|
||||||
private val oldClearance = getClearance()
|
|
||||||
private var counter = 0
|
|
||||||
|
|
||||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
|
||||||
super.onPageStarted(view, url, favicon)
|
|
||||||
checkClearance()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPageCommitVisible(view: WebView, url: String?) {
|
|
||||||
super.onPageCommitVisible(view, url)
|
|
||||||
callback.onPageLoaded()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPageFinished(webView: WebView, url: String) {
|
|
||||||
super.onPageFinished(webView, url)
|
|
||||||
callback.onPageLoaded()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reset() {
|
|
||||||
counter = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkClearance() {
|
|
||||||
val clearance = getClearance()
|
|
||||||
if (clearance != null && clearance != oldClearance) {
|
|
||||||
callback.onCheckPassed()
|
|
||||||
} else {
|
|
||||||
counter++
|
|
||||||
if (counter >= LOOP_COUNTER) {
|
|
||||||
reset()
|
|
||||||
callback.onLoopDetected()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getClearance(): String? {
|
|
||||||
return cookieJar.loadForRequest(targetUrl.toHttpUrl())
|
|
||||||
.find { it.name == CF_CLEARANCE }?.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,187 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.content.Context
|
|
||||||
import android.provider.SearchRecentSuggestions
|
|
||||||
import android.text.Html
|
|
||||||
import androidx.collection.arraySetOf
|
|
||||||
import androidx.room.InvalidationTracker
|
|
||||||
import androidx.work.WorkManager
|
|
||||||
import coil.ComponentRegistry
|
|
||||||
import coil.ImageLoader
|
|
||||||
import coil.decode.SvgDecoder
|
|
||||||
import coil.disk.DiskCache
|
|
||||||
import coil.util.DebugLogger
|
|
||||||
import dagger.Binds
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.Provides
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import dagger.multibindings.ElementsIntoSet
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
|
||||||
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
|
|
||||||
import org.koitharu.kotatsu.core.cache.ContentCache
|
|
||||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
|
||||||
import org.koitharu.kotatsu.core.cache.StubContentCache
|
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
|
||||||
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
|
|
||||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
|
||||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
|
||||||
import org.koitharu.kotatsu.core.os.NetworkState
|
|
||||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
|
||||||
import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
|
|
||||||
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
|
|
||||||
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
|
|
||||||
import org.koitharu.kotatsu.core.util.AcraScreenLogger
|
|
||||||
import org.koitharu.kotatsu.core.util.IncognitoModeIndicator
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
|
|
||||||
import org.koitharu.kotatsu.local.data.CacheDir
|
|
||||||
import org.koitharu.kotatsu.local.data.CbzFetcher
|
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
|
||||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
|
||||||
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
|
|
||||||
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.reader.ui.thumbnails.MangaPageFetcher
|
|
||||||
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
|
|
||||||
import org.koitharu.kotatsu.settings.backup.BackupObserver
|
|
||||||
import org.koitharu.kotatsu.sync.domain.SyncController
|
|
||||||
import org.koitharu.kotatsu.widget.WidgetUpdater
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
interface AppModule {
|
|
||||||
|
|
||||||
@Binds
|
|
||||||
fun bindMangaLoaderContext(mangaLoaderContextImpl: MangaLoaderContextImpl): MangaLoaderContext
|
|
||||||
|
|
||||||
@Binds
|
|
||||||
fun bindImageGetter(coilImageGetter: CoilImageGetter): Html.ImageGetter
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideNetworkState(
|
|
||||||
@ApplicationContext context: Context
|
|
||||||
) = NetworkState(context.connectivityManager)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideMangaDatabase(
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
): MangaDatabase {
|
|
||||||
return MangaDatabase(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideCoil(
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
@MangaHttpClient okHttpClient: OkHttpClient,
|
|
||||||
mangaRepositoryFactory: MangaRepository.Factory,
|
|
||||||
imageProxyInterceptor: ImageProxyInterceptor,
|
|
||||||
pageFetcherFactory: MangaPageFetcher.Factory,
|
|
||||||
coverRestoreInterceptor: CoverRestoreInterceptor,
|
|
||||||
): ImageLoader {
|
|
||||||
val diskCacheFactory = {
|
|
||||||
val rootDir = context.externalCacheDir ?: context.cacheDir
|
|
||||||
DiskCache.Builder()
|
|
||||||
.directory(rootDir.resolve(CacheDir.THUMBS.dir))
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
return ImageLoader.Builder(context)
|
|
||||||
.okHttpClient(okHttpClient.newBuilder().cache(null).build())
|
|
||||||
.interceptorDispatcher(Dispatchers.Default)
|
|
||||||
.fetcherDispatcher(Dispatchers.IO)
|
|
||||||
.decoderDispatcher(Dispatchers.Default)
|
|
||||||
.transformationDispatcher(Dispatchers.Default)
|
|
||||||
.diskCache(diskCacheFactory)
|
|
||||||
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
|
||||||
.allowRgb565(context.isLowRamDevice())
|
|
||||||
.eventListener(CaptchaNotifier(context))
|
|
||||||
.components(
|
|
||||||
ComponentRegistry.Builder()
|
|
||||||
.add(SvgDecoder.Factory())
|
|
||||||
.add(CbzFetcher.Factory())
|
|
||||||
.add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory))
|
|
||||||
.add(pageFetcherFactory)
|
|
||||||
.add(imageProxyInterceptor)
|
|
||||||
.add(coverRestoreInterceptor)
|
|
||||||
.build(),
|
|
||||||
).build()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
fun provideSearchSuggestions(
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
): SearchRecentSuggestions {
|
|
||||||
return MangaSuggestionsProvider.createSuggestions(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@ElementsIntoSet
|
|
||||||
fun provideDatabaseObservers(
|
|
||||||
widgetUpdater: WidgetUpdater,
|
|
||||||
appShortcutManager: AppShortcutManager,
|
|
||||||
backupObserver: BackupObserver,
|
|
||||||
syncController: SyncController,
|
|
||||||
): Set<@JvmSuppressWildcards InvalidationTracker.Observer> = arraySetOf(
|
|
||||||
widgetUpdater,
|
|
||||||
appShortcutManager,
|
|
||||||
backupObserver,
|
|
||||||
syncController,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@ElementsIntoSet
|
|
||||||
fun provideActivityLifecycleCallbacks(
|
|
||||||
appProtectHelper: AppProtectHelper,
|
|
||||||
activityRecreationHandle: ActivityRecreationHandle,
|
|
||||||
incognitoModeIndicator: IncognitoModeIndicator,
|
|
||||||
acraScreenLogger: AcraScreenLogger,
|
|
||||||
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
|
|
||||||
appProtectHelper,
|
|
||||||
activityRecreationHandle,
|
|
||||||
incognitoModeIndicator,
|
|
||||||
acraScreenLogger,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideContentCache(
|
|
||||||
application: Application,
|
|
||||||
): ContentCache {
|
|
||||||
return if (application.isLowRamDevice()) {
|
|
||||||
StubContentCache()
|
|
||||||
} else {
|
|
||||||
MemoryContentCache(application)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
@LocalStorageChanges
|
|
||||||
fun provideMutableLocalStorageChangesFlow(): MutableSharedFlow<LocalManga?> = MutableSharedFlow()
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@LocalStorageChanges
|
|
||||||
fun provideLocalStorageChangesFlow(
|
|
||||||
@LocalStorageChanges flow: MutableSharedFlow<LocalManga?>,
|
|
||||||
): SharedFlow<LocalManga?> = flow.asSharedFlow()
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
fun provideWorkManager(
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
): WorkManager = WorkManager.getInstance(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,136 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
|
||||||
import androidx.hilt.work.HiltWorkerFactory
|
|
||||||
import androidx.room.InvalidationTracker
|
|
||||||
import androidx.work.Configuration
|
|
||||||
import androidx.work.WorkManager
|
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.acra.ACRA
|
|
||||||
import org.acra.ReportField
|
|
||||||
import org.acra.config.dialog
|
|
||||||
import org.acra.config.httpSender
|
|
||||||
import org.acra.data.StringFormat
|
|
||||||
import org.acra.ktx.initAcra
|
|
||||||
import org.acra.sender.HttpSender
|
|
||||||
import org.conscrypt.Conscrypt
|
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
|
||||||
import org.koitharu.kotatsu.core.os.AppValidator
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
|
||||||
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
|
|
||||||
import java.security.Security
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Provider
|
|
||||||
|
|
||||||
@HiltAndroidApp
|
|
||||||
open class BaseApp : Application(), Configuration.Provider {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var databaseObservers: Set<@JvmSuppressWildcards InvalidationTracker.Observer>
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var activityLifecycleCallbacks: Set<@JvmSuppressWildcards ActivityLifecycleCallbacks>
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var database: Provider<MangaDatabase>
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var settings: AppSettings
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var workerFactory: HiltWorkerFactory
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var appValidator: AppValidator
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var workScheduleManager: WorkScheduleManager
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var workManagerProvider: Provider<WorkManager>
|
|
||||||
|
|
||||||
override val workManagerConfiguration: Configuration
|
|
||||||
get() = Configuration.Builder()
|
|
||||||
.setWorkerFactory(workerFactory)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
|
||||||
AppCompatDelegate.setApplicationLocales(settings.appLocales)
|
|
||||||
// TLS 1.3 support for Android < 10
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
|
||||||
Security.insertProviderAt(Conscrypt.newProvider(), 1)
|
|
||||||
}
|
|
||||||
setupActivityLifecycleCallbacks()
|
|
||||||
processLifecycleScope.launch {
|
|
||||||
val isOriginalApp = withContext(Dispatchers.Default) {
|
|
||||||
appValidator.isOriginalApp
|
|
||||||
}
|
|
||||||
ACRA.errorReporter.putCustomData("isOriginalApp", isOriginalApp.toString())
|
|
||||||
}
|
|
||||||
processLifecycleScope.launch(Dispatchers.Default) {
|
|
||||||
setupDatabaseObservers()
|
|
||||||
}
|
|
||||||
workScheduleManager.init()
|
|
||||||
WorkServiceStopHelper(workManagerProvider).setup()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun attachBaseContext(base: Context?) {
|
|
||||||
super.attachBaseContext(base)
|
|
||||||
initAcra {
|
|
||||||
buildConfigClass = BuildConfig::class.java
|
|
||||||
reportFormat = StringFormat.JSON
|
|
||||||
httpSender {
|
|
||||||
uri = getString(R.string.url_error_report)
|
|
||||||
basicAuthLogin = getString(R.string.acra_login)
|
|
||||||
basicAuthPassword = getString(R.string.acra_password)
|
|
||||||
httpMethod = HttpSender.Method.POST
|
|
||||||
}
|
|
||||||
reportContent = listOf(
|
|
||||||
ReportField.PACKAGE_NAME,
|
|
||||||
ReportField.INSTALLATION_ID,
|
|
||||||
ReportField.APP_VERSION_CODE,
|
|
||||||
ReportField.APP_VERSION_NAME,
|
|
||||||
ReportField.ANDROID_VERSION,
|
|
||||||
ReportField.PHONE_MODEL,
|
|
||||||
ReportField.STACK_TRACE,
|
|
||||||
ReportField.CRASH_CONFIGURATION,
|
|
||||||
ReportField.CUSTOM_DATA,
|
|
||||||
)
|
|
||||||
|
|
||||||
dialog {
|
|
||||||
text = getString(R.string.crash_text)
|
|
||||||
title = getString(R.string.error_occurred)
|
|
||||||
positiveButtonText = getString(R.string.send)
|
|
||||||
resIcon = R.drawable.ic_alert_outline
|
|
||||||
resTheme = android.R.style.Theme_Material_Light_Dialog_Alert
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@WorkerThread
|
|
||||||
private fun setupDatabaseObservers() {
|
|
||||||
val tracker = database.get().invalidationTracker
|
|
||||||
databaseObservers.forEach {
|
|
||||||
tracker.addObserver(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupActivityLifecycleCallbacks() {
|
|
||||||
activityLifecycleCallbacks.forEach {
|
|
||||||
registerActivityLifecycleCallbacks(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core
|
|
||||||
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.core.app.PendingIntentCompat
|
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.report
|
|
||||||
|
|
||||||
class ErrorReporterReceiver : BroadcastReceiver() {
|
|
||||||
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
|
||||||
val e = intent?.getSerializableExtraCompat<Throwable>(EXTRA_ERROR) ?: return
|
|
||||||
e.report()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
private const val EXTRA_ERROR = "err"
|
|
||||||
private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR"
|
|
||||||
|
|
||||||
fun getPendingIntent(context: Context, e: Throwable): PendingIntent {
|
|
||||||
val intent = Intent(context, ErrorReporterReceiver::class.java)
|
|
||||||
intent.setAction(ACTION_REPORT)
|
|
||||||
intent.setData(Uri.parse("err://${e.hashCode()}"))
|
|
||||||
intent.putExtra(EXTRA_ERROR, e)
|
|
||||||
return checkNotNull(PendingIntentCompat.getBroadcast(context, 0, intent, 0, false))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.google.auto.service.AutoService
|
|
||||||
import org.acra.builder.ReportBuilder
|
|
||||||
import org.acra.config.CoreConfiguration
|
|
||||||
import org.acra.config.ReportingAdministrator
|
|
||||||
|
|
||||||
@AutoService(ReportingAdministrator::class)
|
|
||||||
class ErrorReportingAdmin : ReportingAdministrator {
|
|
||||||
|
|
||||||
override fun shouldStartCollecting(
|
|
||||||
context: Context,
|
|
||||||
config: CoreConfiguration,
|
|
||||||
reportBuilder: ReportBuilder
|
|
||||||
): Boolean {
|
|
||||||
return reportBuilder.exception?.isDeadOs() != true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Throwable.isDeadOs(): Boolean {
|
|
||||||
val className = javaClass.simpleName
|
|
||||||
return className == "DeadSystemException" || className == "DeadSystemRuntimeException" || cause?.isDeadOs() == true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import org.json.JSONArray
|
|
||||||
|
|
||||||
class BackupEntry(
|
|
||||||
val name: Name,
|
|
||||||
val data: JSONArray
|
|
||||||
) {
|
|
||||||
|
|
||||||
enum class Name(
|
|
||||||
val key: String,
|
|
||||||
) {
|
|
||||||
|
|
||||||
INDEX("index"),
|
|
||||||
HISTORY("history"),
|
|
||||||
CATEGORIES("categories"),
|
|
||||||
FAVOURITES("favourites"),
|
|
||||||
SETTINGS("settings"),
|
|
||||||
BOOKMARKS("bookmarks"),
|
|
||||||
SOURCES("sources"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,224 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import androidx.room.withTransaction
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.JSONIterator
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
|
||||||
import java.util.Date
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
private const val PAGE_SIZE = 10
|
|
||||||
|
|
||||||
class BackupRepository @Inject constructor(
|
|
||||||
private val db: MangaDatabase,
|
|
||||||
private val settings: AppSettings,
|
|
||||||
) {
|
|
||||||
|
|
||||||
suspend fun dumpHistory(): BackupEntry {
|
|
||||||
var offset = 0
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.HISTORY, JSONArray())
|
|
||||||
while (true) {
|
|
||||||
val history = db.getHistoryDao().findAll(offset, PAGE_SIZE)
|
|
||||||
if (history.isEmpty()) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
offset += history.size
|
|
||||||
for (item in history) {
|
|
||||||
val manga = JsonSerializer(item.manga).toJson()
|
|
||||||
val tags = JSONArray()
|
|
||||||
item.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
|
|
||||||
manga.put("tags", tags)
|
|
||||||
val json = JsonSerializer(item.history).toJson()
|
|
||||||
json.put("manga", manga)
|
|
||||||
entry.data.put(json)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun dumpCategories(): BackupEntry {
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.CATEGORIES, JSONArray())
|
|
||||||
val categories = db.getFavouriteCategoriesDao().findAll()
|
|
||||||
for (item in categories) {
|
|
||||||
entry.data.put(JsonSerializer(item).toJson())
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun dumpFavourites(): BackupEntry {
|
|
||||||
var offset = 0
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray())
|
|
||||||
while (true) {
|
|
||||||
val favourites = db.getFavouritesDao().findAllRaw(offset, PAGE_SIZE)
|
|
||||||
if (favourites.isEmpty()) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
offset += favourites.size
|
|
||||||
for (item in favourites) {
|
|
||||||
val manga = JsonSerializer(item.manga).toJson()
|
|
||||||
val tags = JSONArray()
|
|
||||||
item.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
|
|
||||||
manga.put("tags", tags)
|
|
||||||
val json = JsonSerializer(item.favourite).toJson()
|
|
||||||
json.put("manga", manga)
|
|
||||||
entry.data.put(json)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun dumpBookmarks(): BackupEntry {
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.BOOKMARKS, JSONArray())
|
|
||||||
val all = db.getBookmarksDao().findAll()
|
|
||||||
for ((m, b) in all) {
|
|
||||||
val json = JSONObject()
|
|
||||||
val manga = JsonSerializer(m.manga).toJson()
|
|
||||||
json.put("manga", manga)
|
|
||||||
val tags = JSONArray()
|
|
||||||
m.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
|
|
||||||
json.put("tags", tags)
|
|
||||||
val bookmarks = JSONArray()
|
|
||||||
b.forEach { bookmarks.put(JsonSerializer(it).toJson()) }
|
|
||||||
json.put("bookmarks", bookmarks)
|
|
||||||
entry.data.put(json)
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dumpSettings(): BackupEntry {
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.SETTINGS, JSONArray())
|
|
||||||
val settingsDump = settings.getAllValues().toMutableMap()
|
|
||||||
settingsDump.remove(AppSettings.KEY_APP_PASSWORD)
|
|
||||||
settingsDump.remove(AppSettings.KEY_PROXY_PASSWORD)
|
|
||||||
settingsDump.remove(AppSettings.KEY_PROXY_LOGIN)
|
|
||||||
settingsDump.remove(AppSettings.KEY_INCOGNITO_MODE)
|
|
||||||
val json = JsonSerializer(settingsDump).toJson()
|
|
||||||
entry.data.put(json)
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun dumpSources(): BackupEntry {
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray())
|
|
||||||
val all = db.getSourcesDao().findAll()
|
|
||||||
for (source in all) {
|
|
||||||
val json = JsonSerializer(source).toJson()
|
|
||||||
entry.data.put(json)
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createIndex(): BackupEntry {
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.INDEX, JSONArray())
|
|
||||||
val json = JSONObject()
|
|
||||||
json.put("app_id", BuildConfig.APPLICATION_ID)
|
|
||||||
json.put("app_version", BuildConfig.VERSION_CODE)
|
|
||||||
json.put("created_at", System.currentTimeMillis())
|
|
||||||
entry.data.put(json)
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getBackupDate(entry: BackupEntry?): Date? {
|
|
||||||
val timestamp = entry?.data?.optJSONObject(0)?.getLongOrDefault("created_at", 0) ?: 0
|
|
||||||
return if (timestamp == 0L) null else Date(timestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
for (item in entry.data.JSONIterator()) {
|
|
||||||
val mangaJson = item.getJSONObject("manga")
|
|
||||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
|
||||||
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
|
||||||
JsonDeserializer(it).toTagEntity()
|
|
||||||
}
|
|
||||||
val history = JsonDeserializer(item).toHistoryEntity()
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
db.withTransaction {
|
|
||||||
db.getTagsDao().upsert(tags)
|
|
||||||
db.getMangaDao().upsert(manga, tags)
|
|
||||||
db.getHistoryDao().upsert(history)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun restoreCategories(entry: BackupEntry): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
for (item in entry.data.JSONIterator()) {
|
|
||||||
val category = JsonDeserializer(item).toFavouriteCategoryEntity()
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
db.getFavouriteCategoriesDao().upsert(category)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun restoreFavourites(entry: BackupEntry): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
for (item in entry.data.JSONIterator()) {
|
|
||||||
val mangaJson = item.getJSONObject("manga")
|
|
||||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
|
||||||
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
|
||||||
JsonDeserializer(it).toTagEntity()
|
|
||||||
}
|
|
||||||
val favourite = JsonDeserializer(item).toFavouriteEntity()
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
db.withTransaction {
|
|
||||||
db.getTagsDao().upsert(tags)
|
|
||||||
db.getMangaDao().upsert(manga, tags)
|
|
||||||
db.getFavouritesDao().upsert(favourite)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun restoreBookmarks(entry: BackupEntry): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
for (item in entry.data.JSONIterator()) {
|
|
||||||
val mangaJson = item.getJSONObject("manga")
|
|
||||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
|
||||||
val tags = item.getJSONArray("tags").mapJSON {
|
|
||||||
JsonDeserializer(it).toTagEntity()
|
|
||||||
}
|
|
||||||
val bookmarks = item.getJSONArray("bookmarks").mapJSON {
|
|
||||||
JsonDeserializer(it).toBookmarkEntity()
|
|
||||||
}
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
db.withTransaction {
|
|
||||||
db.getTagsDao().upsert(tags)
|
|
||||||
db.getMangaDao().upsert(manga, tags)
|
|
||||||
db.getBookmarksDao().upsert(bookmarks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun restoreSources(entry: BackupEntry): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
for (item in entry.data.JSONIterator()) {
|
|
||||||
val source = JsonDeserializer(item).toMangaSourceEntity()
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
db.getSourcesDao().upsert(source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun restoreSettings(entry: BackupEntry): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
for (item in entry.data.JSONIterator()) {
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
settings.upsertAll(JsonDeserializer(item).toMap())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineStart
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import okio.Closeable
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
|
||||||
import java.io.File
|
|
||||||
import java.util.EnumSet
|
|
||||||
import java.util.zip.ZipFile
|
|
||||||
|
|
||||||
class BackupZipInput(val file: File) : Closeable {
|
|
||||||
|
|
||||||
private val zipFile = ZipFile(file)
|
|
||||||
|
|
||||||
suspend fun getEntry(name: BackupEntry.Name): BackupEntry? = runInterruptible(Dispatchers.IO) {
|
|
||||||
val entry = zipFile.getEntry(name.key) ?: return@runInterruptible null
|
|
||||||
val json = zipFile.getInputStream(entry).use {
|
|
||||||
JSONArray(it.bufferedReader().readText())
|
|
||||||
}
|
|
||||||
BackupEntry(name, json)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun entries(): Set<BackupEntry.Name> = runInterruptible(Dispatchers.IO) {
|
|
||||||
zipFile.entries().toList().mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { ze ->
|
|
||||||
BackupEntry.Name.entries.find { it.key == ze.name }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() {
|
|
||||||
zipFile.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cleanupAsync() {
|
|
||||||
processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) {
|
|
||||||
runCatching {
|
|
||||||
close()
|
|
||||||
file.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import okio.Closeable
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.zip.ZipOutput
|
|
||||||
import java.io.File
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.zip.Deflater
|
|
||||||
|
|
||||||
class BackupZipOutput(val file: File) : Closeable {
|
|
||||||
|
|
||||||
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
|
|
||||||
|
|
||||||
suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) {
|
|
||||||
output.put(entry.name.key, entry.data.toString(2))
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun finish() = runInterruptible(Dispatchers.IO) {
|
|
||||||
output.finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() {
|
|
||||||
output.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const val DIR_BACKUPS = "backups"
|
|
||||||
|
|
||||||
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
|
|
||||||
val dir = context.run {
|
|
||||||
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
|
||||||
}
|
|
||||||
dir.mkdirs()
|
|
||||||
val filename = buildString {
|
|
||||||
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
|
||||||
append('_')
|
|
||||||
append(LocalDate.now().format(DateTimeFormatter.ofPattern("ddMMyyyy")))
|
|
||||||
append(".bk.zip")
|
|
||||||
}
|
|
||||||
BackupZipOutput(File(dir, filename))
|
|
||||||
}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
class CompositeResult {
|
|
||||||
|
|
||||||
private var successCount: Int = 0
|
|
||||||
private val errors = ArrayList<Throwable?>()
|
|
||||||
|
|
||||||
val size: Int
|
|
||||||
get() = successCount + errors.size
|
|
||||||
|
|
||||||
val failures: List<Throwable>
|
|
||||||
get() = errors.filterNotNull()
|
|
||||||
|
|
||||||
val isEmpty: Boolean
|
|
||||||
get() = errors.isEmpty() && successCount == 0
|
|
||||||
|
|
||||||
val isAllSuccess: Boolean
|
|
||||||
get() = errors.none { it != null }
|
|
||||||
|
|
||||||
val isAllFailed: Boolean
|
|
||||||
get() = successCount == 0 && errors.isNotEmpty()
|
|
||||||
|
|
||||||
operator fun plusAssign(result: Result<*>) {
|
|
||||||
when {
|
|
||||||
result.isSuccess -> successCount++
|
|
||||||
result.isFailure -> errors.add(result.exceptionOrNull())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
operator fun plusAssign(other: CompositeResult) {
|
|
||||||
this.successCount += other.successCount
|
|
||||||
this.errors += other.errors
|
|
||||||
}
|
|
||||||
|
|
||||||
operator fun plus(other: CompositeResult): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
result.successCount = this.successCount + other.successCount
|
|
||||||
result.errors.addAll(this.errors)
|
|
||||||
result.errors.addAll(other.errors)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
|
||||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
|
||||||
|
|
||||||
class JsonDeserializer(private val json: JSONObject) {
|
|
||||||
|
|
||||||
fun toFavouriteEntity() = FavouriteEntity(
|
|
||||||
mangaId = json.getLong("manga_id"),
|
|
||||||
categoryId = json.getLong("category_id"),
|
|
||||||
sortKey = json.getIntOrDefault("sort_key", 0),
|
|
||||||
createdAt = json.getLong("created_at"),
|
|
||||||
deletedAt = 0L,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toMangaEntity() = MangaEntity(
|
|
||||||
id = json.getLong("id"),
|
|
||||||
title = json.getString("title"),
|
|
||||||
altTitle = json.getStringOrNull("alt_title"),
|
|
||||||
url = json.getString("url"),
|
|
||||||
publicUrl = json.getStringOrNull("public_url").orEmpty(),
|
|
||||||
rating = json.getDouble("rating").toFloat(),
|
|
||||||
isNsfw = json.getBooleanOrDefault("nsfw", false),
|
|
||||||
coverUrl = json.getString("cover_url"),
|
|
||||||
largeCoverUrl = json.getStringOrNull("large_cover_url"),
|
|
||||||
state = json.getStringOrNull("state"),
|
|
||||||
author = json.getStringOrNull("author"),
|
|
||||||
source = json.getString("source"),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toTagEntity() = TagEntity(
|
|
||||||
id = json.getLong("id"),
|
|
||||||
title = json.getString("title"),
|
|
||||||
key = json.getString("key"),
|
|
||||||
source = json.getString("source"),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toHistoryEntity() = HistoryEntity(
|
|
||||||
mangaId = json.getLong("manga_id"),
|
|
||||||
createdAt = json.getLong("created_at"),
|
|
||||||
updatedAt = json.getLong("updated_at"),
|
|
||||||
chapterId = json.getLong("chapter_id"),
|
|
||||||
page = json.getInt("page"),
|
|
||||||
scroll = json.getDouble("scroll").toFloat(),
|
|
||||||
percent = json.getFloatOrDefault("percent", -1f),
|
|
||||||
chaptersCount = json.getIntOrDefault("chapters", -1),
|
|
||||||
deletedAt = 0L,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toFavouriteCategoryEntity() = FavouriteCategoryEntity(
|
|
||||||
categoryId = json.getInt("category_id"),
|
|
||||||
createdAt = json.getLong("created_at"),
|
|
||||||
sortKey = json.getInt("sort_key"),
|
|
||||||
title = json.getString("title"),
|
|
||||||
order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name,
|
|
||||||
track = json.getBooleanOrDefault("track", true),
|
|
||||||
isVisibleInLibrary = json.getBooleanOrDefault("show_in_lib", true),
|
|
||||||
deletedAt = 0L,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toBookmarkEntity() = BookmarkEntity(
|
|
||||||
mangaId = json.getLong("manga_id"),
|
|
||||||
pageId = json.getLong("page_id"),
|
|
||||||
chapterId = json.getLong("chapter_id"),
|
|
||||||
page = json.getInt("page"),
|
|
||||||
scroll = json.getInt("scroll"),
|
|
||||||
imageUrl = json.getString("image_url"),
|
|
||||||
createdAt = json.getLong("created_at"),
|
|
||||||
percent = json.getDouble("percent").toFloat(),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toMangaSourceEntity() = MangaSourceEntity(
|
|
||||||
source = json.getString("source"),
|
|
||||||
isEnabled = json.getBoolean("enabled"),
|
|
||||||
sortKey = json.getInt("sort_key"),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toMap(): Map<String, Any?> {
|
|
||||||
val map = mutableMapOf<String, Any?>()
|
|
||||||
val keys = json.keys()
|
|
||||||
|
|
||||||
while (keys.hasNext()) {
|
|
||||||
val key = keys.next()
|
|
||||||
val value = json.get(key)
|
|
||||||
map[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
return map
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
|
||||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
|
||||||
|
|
||||||
class JsonSerializer private constructor(private val json: JSONObject) {
|
|
||||||
|
|
||||||
constructor(e: FavouriteEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("manga_id", e.mangaId)
|
|
||||||
put("category_id", e.categoryId)
|
|
||||||
put("sort_key", e.sortKey)
|
|
||||||
put("created_at", e.createdAt)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(e: FavouriteCategoryEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("category_id", e.categoryId)
|
|
||||||
put("created_at", e.createdAt)
|
|
||||||
put("sort_key", e.sortKey)
|
|
||||||
put("title", e.title)
|
|
||||||
put("order", e.order)
|
|
||||||
put("track", e.track)
|
|
||||||
put("show_in_lib", e.isVisibleInLibrary)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(e: HistoryEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("manga_id", e.mangaId)
|
|
||||||
put("created_at", e.createdAt)
|
|
||||||
put("updated_at", e.updatedAt)
|
|
||||||
put("chapter_id", e.chapterId)
|
|
||||||
put("page", e.page)
|
|
||||||
put("scroll", e.scroll)
|
|
||||||
put("percent", e.percent)
|
|
||||||
put("chapters", e.chaptersCount)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(e: TagEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("id", e.id)
|
|
||||||
put("title", e.title)
|
|
||||||
put("key", e.key)
|
|
||||||
put("source", e.source)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(e: MangaEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("id", e.id)
|
|
||||||
put("title", e.title)
|
|
||||||
put("alt_title", e.altTitle)
|
|
||||||
put("url", e.url)
|
|
||||||
put("public_url", e.publicUrl)
|
|
||||||
put("rating", e.rating)
|
|
||||||
put("nsfw", e.isNsfw)
|
|
||||||
put("cover_url", e.coverUrl)
|
|
||||||
put("large_cover_url", e.largeCoverUrl)
|
|
||||||
put("state", e.state)
|
|
||||||
put("author", e.author)
|
|
||||||
put("source", e.source)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(e: BookmarkEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("manga_id", e.mangaId)
|
|
||||||
put("page_id", e.pageId)
|
|
||||||
put("chapter_id", e.chapterId)
|
|
||||||
put("page", e.page)
|
|
||||||
put("scroll", e.scroll)
|
|
||||||
put("image_url", e.imageUrl)
|
|
||||||
put("created_at", e.createdAt)
|
|
||||||
put("percent", e.percent)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(e: MangaSourceEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("source", e.source)
|
|
||||||
put("enabled", e.isEnabled)
|
|
||||||
put("sort_key", e.sortKey)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(m: Map<String, *>) : this(
|
|
||||||
JSONObject(m),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toJson(): JSONObject = json
|
|
||||||
}
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.cache
|
|
||||||
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
|
|
||||||
interface ContentCache {
|
|
||||||
|
|
||||||
val isCachingEnabled: Boolean
|
|
||||||
|
|
||||||
suspend fun getDetails(source: MangaSource, url: String): Manga?
|
|
||||||
|
|
||||||
fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>)
|
|
||||||
|
|
||||||
suspend fun getPages(source: MangaSource, url: String): List<MangaPage>?
|
|
||||||
|
|
||||||
fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>)
|
|
||||||
|
|
||||||
suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>?
|
|
||||||
|
|
||||||
fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>)
|
|
||||||
|
|
||||||
fun clear(source: MangaSource)
|
|
||||||
|
|
||||||
data class Key(
|
|
||||||
val source: MangaSource,
|
|
||||||
val url: String,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.cache
|
|
||||||
|
|
||||||
import androidx.collection.LruCache
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
class ExpiringLruCache<T>(
|
|
||||||
val maxSize: Int,
|
|
||||||
private val lifetime: Long,
|
|
||||||
private val timeUnit: TimeUnit,
|
|
||||||
) : Iterable<ContentCache.Key> {
|
|
||||||
|
|
||||||
private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize)
|
|
||||||
|
|
||||||
override fun iterator(): Iterator<ContentCache.Key> = cache.snapshot().keys.iterator()
|
|
||||||
|
|
||||||
operator fun get(key: ContentCache.Key): T? {
|
|
||||||
val value = cache[key] ?: return null
|
|
||||||
if (value.isExpired) {
|
|
||||||
cache.remove(key)
|
|
||||||
}
|
|
||||||
return value.get()
|
|
||||||
}
|
|
||||||
|
|
||||||
operator fun set(key: ContentCache.Key, value: T) {
|
|
||||||
cache.put(key, ExpiringValue(value, lifetime, timeUnit))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clear() {
|
|
||||||
cache.evictAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun trimToSize(size: Int) {
|
|
||||||
cache.trimToSize(size)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun remove(key: ContentCache.Key) {
|
|
||||||
cache.remove(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.cache
|
|
||||||
|
|
||||||
import android.os.SystemClock
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
class ExpiringValue<T>(
|
|
||||||
private val value: T,
|
|
||||||
lifetime: Long,
|
|
||||||
timeUnit: TimeUnit,
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val expiresAt = SystemClock.elapsedRealtime() + timeUnit.toMillis(lifetime)
|
|
||||||
|
|
||||||
val isExpired: Boolean
|
|
||||||
get() = SystemClock.elapsedRealtime() >= expiresAt
|
|
||||||
|
|
||||||
fun get(): T? = if (isExpired) null else value
|
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
|
||||||
if (this === other) return true
|
|
||||||
if (javaClass != other?.javaClass) return false
|
|
||||||
|
|
||||||
other as ExpiringValue<*>
|
|
||||||
|
|
||||||
if (value != other.value) return false
|
|
||||||
return expiresAt == other.expiresAt
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
|
||||||
var result = value?.hashCode() ?: 0
|
|
||||||
result = 31 * result + expiresAt.hashCode()
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.cache
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.content.ComponentCallbacks2
|
|
||||||
import android.content.res.Configuration
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
class MemoryContentCache(application: Application) : ContentCache, ComponentCallbacks2 {
|
|
||||||
|
|
||||||
init {
|
|
||||||
application.registerComponentCallbacks(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(4, 5, TimeUnit.MINUTES)
|
|
||||||
private val pagesCache = ExpiringLruCache<SafeDeferred<List<MangaPage>>>(4, 10, TimeUnit.MINUTES)
|
|
||||||
private val relatedMangaCache = ExpiringLruCache<SafeDeferred<List<Manga>>>(4, 10, TimeUnit.MINUTES)
|
|
||||||
|
|
||||||
override val isCachingEnabled: Boolean = true
|
|
||||||
|
|
||||||
override suspend fun getDetails(source: MangaSource, url: String): Manga? {
|
|
||||||
return detailsCache[ContentCache.Key(source, url)]?.awaitOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) {
|
|
||||||
detailsCache[ContentCache.Key(source, url)] = details
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? {
|
|
||||||
return pagesCache[ContentCache.Key(source, url)]?.awaitOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) {
|
|
||||||
pagesCache[ContentCache.Key(source, url)] = pages
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? {
|
|
||||||
return relatedMangaCache[ContentCache.Key(source, url)]?.awaitOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) {
|
|
||||||
relatedMangaCache[ContentCache.Key(source, url)] = related
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun clear(source: MangaSource) {
|
|
||||||
clearCache(detailsCache, source)
|
|
||||||
clearCache(pagesCache, source)
|
|
||||||
clearCache(relatedMangaCache, source)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onConfigurationChanged(newConfig: Configuration) = Unit
|
|
||||||
|
|
||||||
override fun onLowMemory() = Unit
|
|
||||||
|
|
||||||
override fun onTrimMemory(level: Int) {
|
|
||||||
trimCache(detailsCache, level)
|
|
||||||
trimCache(pagesCache, level)
|
|
||||||
trimCache(relatedMangaCache, level)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun trimCache(cache: ExpiringLruCache<*>, level: Int) {
|
|
||||||
when (level) {
|
|
||||||
ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL,
|
|
||||||
ComponentCallbacks2.TRIM_MEMORY_COMPLETE,
|
|
||||||
ComponentCallbacks2.TRIM_MEMORY_MODERATE -> cache.clear()
|
|
||||||
|
|
||||||
ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN,
|
|
||||||
ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW,
|
|
||||||
ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> cache.trimToSize(1)
|
|
||||||
|
|
||||||
else -> cache.trimToSize(cache.maxSize / 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearCache(cache: ExpiringLruCache<*>, source: MangaSource) {
|
|
||||||
cache.forEach { key ->
|
|
||||||
if (key.source == source) {
|
|
||||||
cache.remove(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.cache
|
|
||||||
|
|
||||||
import kotlinx.coroutines.Deferred
|
|
||||||
|
|
||||||
class SafeDeferred<T>(
|
|
||||||
private val delegate: Deferred<Result<T>>,
|
|
||||||
) {
|
|
||||||
|
|
||||||
suspend fun await(): T {
|
|
||||||
return delegate.await().getOrThrow()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun awaitOrNull(): T? {
|
|
||||||
return delegate.await().getOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancel() {
|
|
||||||
delegate.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue