Compare commits
No commits in common. 'fe5534b006322188080f6a8fa1d3f04bddb3b6c1' and 'f7a461a9d81d9b5f7e1bdf21e362ff7705e63d22' have entirely different histories.
fe5534b006
...
f7a461a9d8
@ -0,0 +1,29 @@
|
|||||||
|
**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: ⚠️ Application issue
|
- name: ⚠️ Source issue
|
||||||
url: https://github.com/KotatsuApp/Kotatsu/issues/new/choose
|
url: https://github.com/KotatsuApp/kotatsu-parsers/issues/new
|
||||||
about: Issues and requests about the app itself should be opened in the Kotatsu repository instead
|
about: If you have a problem with a specific **manga source** or want to propose a new one, please open an issue in the kotatsu-parsers repository instead
|
||||||
|
|||||||
@ -0,0 +1,66 @@
|
|||||||
|
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: This is not a duplicate of an existing issue. Please look through the list of [open issues](https://github.com/KotatsuApp/Kotatsu/issues) before creating a new one.
|
||||||
|
required: true
|
||||||
|
- label: This is not an issue with a specific manga source. Otherwise, you have to open an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose).
|
||||||
|
required: true
|
||||||
@ -1,66 +0,0 @@
|
|||||||
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,31 +1,24 @@
|
|||||||
name: ⭐ Feature request
|
name: ⭐ Feature request
|
||||||
description: Suggest a feature to improve a source
|
description: Suggest a new idea how to improve Kotatsu
|
||||||
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 an existing source be improved?
|
description: How can Kotatsu be improved?
|
||||||
placeholder: |
|
placeholder: |
|
||||||
Example:
|
Example:
|
||||||
"It should work like this..."
|
"It should work like this..."
|
||||||
Please use English language
|
validations:
|
||||||
validations:
|
required: true
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
- type: checkboxes
|
||||||
id: other-details
|
id: acknowledgements
|
||||||
attributes:
|
attributes:
|
||||||
label: Other details
|
label: Acknowledgements
|
||||||
placeholder: |
|
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
|
||||||
Additional details and attachments.
|
options:
|
||||||
|
- label: This is not a duplicate of an existing issue. Please look through the list of [open issues](https://github.com/KotatsuApp/Kotatsu/issues) before creating a new one.
|
||||||
- type: checkboxes
|
required: true
|
||||||
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,31 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
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
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
@ -1 +0,0 @@
|
|||||||
total: 1251
|
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
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."
|
||||||
|
}
|
||||||
|
]
|
||||||
@ -1,27 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
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
|
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
name: Trigger Site Update
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
trigger-site:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Send repository_dispatch to site-repo
|
||||||
|
uses: peter-evans/repository-dispatch@v3
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.SITE_REPO_TOKEN }}
|
||||||
|
repository: KotatsuApp/website
|
||||||
|
event-type: app-release
|
||||||
@ -1,94 +1,29 @@
|
|||||||
# 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
|
||||||
*.ipr
|
.gradle
|
||||||
|
/local.properties
|
||||||
# CMake
|
/.idea/caches
|
||||||
cmake-build-*/
|
/.idea/libraries
|
||||||
|
/.idea/dictionaries
|
||||||
# Mongo Explorer plugin
|
/.idea/modules.xml
|
||||||
.idea/**/mongoSettings.xml
|
/.idea/misc.xml
|
||||||
|
/.idea/discord.xml
|
||||||
# File-based project format
|
/.idea/compiler.xml
|
||||||
*.iws
|
/.idea/workspace.xml
|
||||||
|
/.idea/navEditor.xml
|
||||||
# IntelliJ
|
/.idea/ktlint-plugin.xml
|
||||||
out/
|
/.idea/assetWizardSettings.xml
|
||||||
|
/.idea/kotlinScripting.xml
|
||||||
# mpeltonen/sbt-idea plugin
|
/.idea/kotlinc.xml
|
||||||
.idea_modules/
|
/.idea/deploymentTargetDropDown.xml
|
||||||
|
/.idea/androidTestResultsUserPreferences.xml
|
||||||
# JIRA plugin
|
/.idea/deploymentTargetSelector.xml
|
||||||
atlassian-ide-plugin.xml
|
/.idea/render.experimental.xml
|
||||||
|
/.idea/inspectionProfiles/
|
||||||
# Cursive Clojure plugin
|
.DS_Store
|
||||||
.idea/replstate.xml
|
/build
|
||||||
|
/captures
|
||||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
.externalNativeBuild
|
||||||
com_crashlytics_export_strings.xml
|
.cxx
|
||||||
crashlytics.properties
|
/.idea/deviceManager.xml
|
||||||
crashlytics-build.properties
|
/.kotlin/
|
||||||
fabric.properties
|
/.idea/AndroidProjectSystem.xml
|
||||||
|
|
||||||
# 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,8 +1,5 @@
|
|||||||
# Default ignored files
|
# Default ignored files
|
||||||
/shelf/
|
/shelf/
|
||||||
/workspace.xml
|
/workspace.xml
|
||||||
# GitHub Copilot persisted chat sessions
|
/migrations.xml
|
||||||
/copilot/chatSessions
|
/runConfigurations.xml
|
||||||
|
|
||||||
.name
|
|
||||||
deviceManager.xml
|
|
||||||
|
|||||||
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AndroidProjectSystem">
|
||||||
|
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@ -0,0 +1,187 @@
|
|||||||
|
<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>
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<state>
|
||||||
|
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||||
|
</state>
|
||||||
|
</component>
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||||
|
<component name="GradleSettings">
|
||||||
|
<option name="linkedExternalProjectsSettings">
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="gradleJvm" value="jbr-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
<option value="$PROJECT_DIR$/app" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@ -0,0 +1,287 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
inkscape:export-ydpi="39.689999"
|
||||||
|
inkscape:export-xdpi="39.689999"
|
||||||
|
inkscape:export-filename="/home/admin/Documents/projects/graphics/k/icon4.png"
|
||||||
|
width="512mm"
|
||||||
|
height="512mm"
|
||||||
|
viewBox="0 0 512 512.00002"
|
||||||
|
version="1.1"
|
||||||
|
id="svg8"
|
||||||
|
inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
|
||||||
|
sodipodi:docname="icon4.svg">
|
||||||
|
<defs
|
||||||
|
id="defs2">
|
||||||
|
<filter
|
||||||
|
style="color-interpolation-filters:sRGB;"
|
||||||
|
inkscape:label="Drop Shadow"
|
||||||
|
id="filter1266">
|
||||||
|
<feFlood
|
||||||
|
flood-opacity="0.498039"
|
||||||
|
flood-color="rgb(0,0,0)"
|
||||||
|
result="flood"
|
||||||
|
id="feFlood1256" />
|
||||||
|
<feComposite
|
||||||
|
in="flood"
|
||||||
|
in2="SourceGraphic"
|
||||||
|
operator="in"
|
||||||
|
result="composite1"
|
||||||
|
id="feComposite1258" />
|
||||||
|
<feGaussianBlur
|
||||||
|
in="composite1"
|
||||||
|
stdDeviation="3"
|
||||||
|
result="blur"
|
||||||
|
id="feGaussianBlur1260" />
|
||||||
|
<feOffset
|
||||||
|
dx="6"
|
||||||
|
dy="6"
|
||||||
|
result="offset"
|
||||||
|
id="feOffset1262" />
|
||||||
|
<feComposite
|
||||||
|
in="SourceGraphic"
|
||||||
|
in2="offset"
|
||||||
|
operator="over"
|
||||||
|
result="composite2"
|
||||||
|
id="feComposite1264" />
|
||||||
|
</filter>
|
||||||
|
<filter
|
||||||
|
style="color-interpolation-filters:sRGB;"
|
||||||
|
inkscape:label="Drop Shadow"
|
||||||
|
id="filter1059">
|
||||||
|
<feFlood
|
||||||
|
flood-opacity="0.498039"
|
||||||
|
flood-color="rgb(0,0,0)"
|
||||||
|
result="flood"
|
||||||
|
id="feFlood1049" />
|
||||||
|
<feComposite
|
||||||
|
in="flood"
|
||||||
|
in2="SourceGraphic"
|
||||||
|
operator="in"
|
||||||
|
result="composite1"
|
||||||
|
id="feComposite1051" />
|
||||||
|
<feGaussianBlur
|
||||||
|
in="composite1"
|
||||||
|
stdDeviation="3"
|
||||||
|
result="blur"
|
||||||
|
id="feGaussianBlur1053" />
|
||||||
|
<feOffset
|
||||||
|
dx="6"
|
||||||
|
dy="6"
|
||||||
|
result="offset"
|
||||||
|
id="feOffset1055" />
|
||||||
|
<feComposite
|
||||||
|
in="SourceGraphic"
|
||||||
|
in2="offset"
|
||||||
|
operator="over"
|
||||||
|
result="composite2"
|
||||||
|
id="feComposite1057" />
|
||||||
|
</filter>
|
||||||
|
<filter
|
||||||
|
style="color-interpolation-filters:sRGB;"
|
||||||
|
inkscape:label="Drop Shadow"
|
||||||
|
id="filter1071">
|
||||||
|
<feFlood
|
||||||
|
flood-opacity="0.498039"
|
||||||
|
flood-color="rgb(0,0,0)"
|
||||||
|
result="flood"
|
||||||
|
id="feFlood1061" />
|
||||||
|
<feComposite
|
||||||
|
in="flood"
|
||||||
|
in2="SourceGraphic"
|
||||||
|
operator="in"
|
||||||
|
result="composite1"
|
||||||
|
id="feComposite1063" />
|
||||||
|
<feGaussianBlur
|
||||||
|
in="composite1"
|
||||||
|
stdDeviation="3"
|
||||||
|
result="blur"
|
||||||
|
id="feGaussianBlur1065" />
|
||||||
|
<feOffset
|
||||||
|
dx="6"
|
||||||
|
dy="6"
|
||||||
|
result="offset"
|
||||||
|
id="feOffset1067" />
|
||||||
|
<feComposite
|
||||||
|
in="SourceGraphic"
|
||||||
|
in2="offset"
|
||||||
|
operator="over"
|
||||||
|
result="composite2"
|
||||||
|
id="feComposite1069" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="base"
|
||||||
|
pagecolor="#0d47a1"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1.0"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:zoom="0.175"
|
||||||
|
inkscape:cx="-361.03654"
|
||||||
|
inkscape:cy="630.78782"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
inkscape:current-layer="layer1"
|
||||||
|
inkscape:document-rotation="0"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:window-width="1600"
|
||||||
|
inkscape:window-height="838"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
fit-margin-top="20"
|
||||||
|
fit-margin-left="20"
|
||||||
|
fit-margin-right="20"
|
||||||
|
fit-margin-bottom="20" />
|
||||||
|
<metadata
|
||||||
|
id="metadata5">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
<dc:title />
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<g
|
||||||
|
inkscape:label="Слой 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-51.12025,-104.74797)">
|
||||||
|
<g
|
||||||
|
id="g1028"
|
||||||
|
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
||||||
|
<g
|
||||||
|
id="g1030"
|
||||||
|
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
||||||
|
<g
|
||||||
|
id="g1032"
|
||||||
|
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
||||||
|
<g
|
||||||
|
id="g1034"
|
||||||
|
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
||||||
|
<g
|
||||||
|
id="g1036"
|
||||||
|
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
||||||
|
<g
|
||||||
|
id="g1038"
|
||||||
|
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
||||||
|
<g
|
||||||
|
id="g1040"
|
||||||
|
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
||||||
|
<g
|
||||||
|
id="g1042"
|
||||||
|
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
||||||
|
<g
|
||||||
|
id="g1044"
|
||||||
|
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
||||||
|
<g
|
||||||
|
id="g1046"
|
||||||
|
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
||||||
|
<g
|
||||||
|
id="g1048"
|
||||||
|
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
||||||
|
<g
|
||||||
|
id="g1050"
|
||||||
|
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
||||||
|
<g
|
||||||
|
id="g1052"
|
||||||
|
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
||||||
|
<g
|
||||||
|
id="g1054"
|
||||||
|
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
||||||
|
<g
|
||||||
|
id="g1056"
|
||||||
|
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
|
||||||
|
<path
|
||||||
|
id="path1128"
|
||||||
|
d="m 307.12025,310.74755 c -50.53732,0 -91.66608,44.85688 -91.66608,99.99965 0,55.14277 41.12954,99.99964 91.66608,99.99964 50.53653,0 91.66607,-44.85687 91.66607,-99.99964 0,-55.14277 -41.12875,-99.99965 -91.66607,-99.99965 z m -34.21238,78.72707 c -1.46484,2.91327 -4.41092,4.60623 -7.45466,4.60623 -1.25312,0 -2.52265,-0.27656 -3.72733,-0.8789 l -12.9398,-6.4781 -12.9398,6.4781 c -4.13436,2.06718 -9.11481,0.37421 -11.18199,-3.72733 -2.05077,-4.11796 -0.39062,-9.11481 3.72733,-11.18199 l 16.66635,-8.33357 c 2.34374,-1.17187 5.11092,-1.17187 7.45466,0 l 16.66635,8.33357 c 4.11951,2.06718 5.77966,7.06403 3.72889,11.18199 z m 58.33338,-24.99991 c -1.46484,2.91327 -4.41092,4.60623 -7.45466,4.60623 -1.25312,0 -2.52264,-0.27656 -3.72733,-0.8789 l -12.93901,-6.47811 -12.9398,6.47811 c -4.13436,2.06718 -9.11481,0.37421 -11.18199,-3.72733 -2.05078,-4.11796 -0.39063,-9.11482 3.72733,-11.182 l 16.66634,-8.33356 c 2.34375,-1.17187 5.11092,-1.17187 7.45466,0 l 16.66635,8.33356 c 4.11874,2.06718 5.77889,7.06404 3.72811,11.182 z m 54.60606,13.81792 c 4.11795,2.06718 5.7781,7.06403 3.72733,11.18199 -1.46484,2.91327 -4.41092,4.60623 -7.45466,4.60623 -1.25312,0 -2.52265,-0.27656 -3.72733,-0.8789 l -12.9398,-6.4781 -12.9398,6.4781 c -4.11795,2.06718 -9.11481,0.37421 -11.18199,-3.72733 -2.05077,-4.11796 -0.39062,-9.11481 3.72733,-11.18199 l 16.66635,-8.33357 c 2.34374,-1.17187 5.11092,-1.17187 7.45466,0 z"
|
||||||
|
style="fill:#ffffff;stroke-width:0.781247" />
|
||||||
|
<path
|
||||||
|
id="path1130"
|
||||||
|
d="m 415.36283,274.00237 c -3.48202,-6.90544 -6.92029,-13.41714 -10.20934,-19.37102 l -8.26716,-14.47964 c -6.79607,-11.51871 -12.25699,-19.90305 -14.78354,-23.66554 -0.7164,-43.32797 -19.12415,-53.79356 -21.25617,-54.86777 -3.20624,-1.5789 -7.09607,-0.97656 -9.6195,1.56249 -12.25621,12.25621 -20.23118,24.4632 -24.00695,30.89286 h -40.20141 c -3.77577,-6.42888 -11.75153,-18.63665 -24.00695,-30.89286 -2.52265,-2.53905 -6.39685,-3.14139 -9.6195,-1.56249 -2.13202,1.07421 -20.54055,11.5398 -21.25617,54.86777 -2.52655,3.76327 -7.98669,12.14605 -14.78276,23.66476 l -8.27341,14.49214 c -3.28983,5.95701 -6.73044,12.47105 -10.21403,19.3804 l -7.4445,15.32572 c -17.72572,38.05377 -34.30066,85.4286 -34.30066,129.72766 0,69.32085 58.26776,128.42064 60.75838,130.89485 0.91171,0.91172 2.03437,1.61171 3.25546,2.01796 1.07421,0.35859 26.78974,8.757 69.31928,8.757 2.21327,0 4.32967,-0.8789 5.89217,-2.4414 l 5.89216,-5.89216 h 9.76559 l 5.89217,5.89216 c 1.56249,1.5625 3.67811,2.4414 5.89216,2.4414 42.52954,0 68.24507,-8.39841 69.31929,-8.757 1.22109,-0.40703 2.34374,-1.10703 3.25546,-2.01796 2.48905,-2.47421 60.75681,-61.57322 60.75681,-130.89485 0,-44.29906 -16.57494,-91.67389 -34.30066,-129.72766 z M 348.7865,227.41426 c 4.60624,0 4.41171,7.35466 4.41171,11.96089 4.60623,0 12.25464,0.1 12.25464,4.70624 0,9.19606 -7.47107,16.66634 -16.66635,16.66634 -9.19528,0 -16.66634,-7.47028 -16.66634,-16.66634 0,-9.19606 7.47106,-16.66713 16.66634,-16.66713 z m -57.69823,30.14364 c 1.28593,-3.10858 4.32967,-5.14295 7.69841,-5.14295 h 16.66635 c 3.36952,0 6.41248,2.03437 7.69841,5.14295 1.28593,3.10858 0.56953,6.70545 -1.80703,9.082 l -8.33356,8.33356 c -1.62734,1.62734 -3.76014,2.4414 -5.89217,2.4414 -2.13202,0 -4.26404,-0.81406 -5.89216,-2.4414 l -8.33357,-8.33356 c -2.37421,-2.37655 -3.09061,-5.97342 -1.80468,-9.082 z m -25.63428,-30.14364 c 4.60623,0 4.4117,7.35466 4.4117,11.96089 4.60623,0 12.25465,0.1 12.25465,4.70624 0,9.19606 -7.47107,16.66634 -16.66635,16.66634 -9.19606,0 -16.66635,-7.47106 -16.66635,-16.66634 -7.8e-4,-9.19606 7.47029,-16.66713 16.66635,-16.66713 z m 41.66626,299.99893 c -59.7326,0 -108.33321,-52.34357 -108.33321,-116.66599 0,-64.32243 48.60061,-116.66599 108.33321,-116.66599 59.73259,0 108.3332,52.34356 108.3332,116.66599 0,64.32242 -48.60061,116.66599 -108.3332,116.66599 z"
|
||||||
|
style="fill:#ffffff;stroke-width:0.781247;filter:url(#filter1059)"
|
||||||
|
sodipodi:nodetypes="cccccccccccccccsccssccssccsccscsssssssssssccsscsscssssss" />
|
||||||
|
<g
|
||||||
|
style="fill:#ffffff"
|
||||||
|
id="g1138"
|
||||||
|
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)"
|
||||||
|
inkscape:groupmode="layer" />
|
||||||
|
<g
|
||||||
|
style="fill:#ffffff"
|
||||||
|
id="g1140"
|
||||||
|
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
||||||
|
<g
|
||||||
|
style="fill:#ffffff"
|
||||||
|
id="g1142"
|
||||||
|
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
||||||
|
<g
|
||||||
|
style="fill:#ffffff"
|
||||||
|
id="g1144"
|
||||||
|
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
||||||
|
<g
|
||||||
|
style="fill:#ffffff"
|
||||||
|
id="g1146"
|
||||||
|
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
||||||
|
<g
|
||||||
|
style="fill:#ffffff"
|
||||||
|
id="g1148"
|
||||||
|
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
||||||
|
<g
|
||||||
|
style="fill:#ffffff"
|
||||||
|
id="g1150"
|
||||||
|
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
||||||
|
<g
|
||||||
|
style="fill:#ffffff"
|
||||||
|
id="g1152"
|
||||||
|
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
||||||
|
<g
|
||||||
|
style="fill:#ffffff"
|
||||||
|
id="g1154"
|
||||||
|
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
||||||
|
<g
|
||||||
|
style="fill:#ffffff"
|
||||||
|
id="g1156"
|
||||||
|
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
||||||
|
<g
|
||||||
|
style="fill:#ffffff"
|
||||||
|
id="g1158"
|
||||||
|
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
||||||
|
<g
|
||||||
|
style="fill:#ffffff"
|
||||||
|
id="g1160"
|
||||||
|
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
||||||
|
<g
|
||||||
|
style="fill:#ffffff"
|
||||||
|
id="g1162"
|
||||||
|
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
||||||
|
<g
|
||||||
|
style="fill:#ffffff"
|
||||||
|
id="g1164"
|
||||||
|
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
||||||
|
<g
|
||||||
|
style="fill:#ffffff"
|
||||||
|
id="g1166"
|
||||||
|
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
|
||||||
|
<path
|
||||||
|
style="fill:#ef5350;fill-opacity:1;stroke:none;stroke-width:5.18208;stroke-linecap:round;stroke-linejoin:round"
|
||||||
|
d="m 344.3189,392.83707 c -4.60362,-2.75958 -5.36974,-9.69605 -1.45595,-13.18226 0.54459,-0.48508 5.34567,-3.07035 10.66909,-5.74503 7.5498,-3.79328 10.16725,-4.86303 11.89884,-4.86303 1.73503,0 4.42542,1.10391 12.3172,5.05396 11.72559,5.86898 12.60994,6.68326 12.60994,11.61118 0,3.40408 -0.99553,5.20819 -4.00363,7.25549 -3.08358,2.09867 -5.44113,1.68547 -13.60905,-2.38528 l -7.19926,-3.58796 -7.37198,3.59617 c -8.3911,4.09331 -10.26721,4.39753 -13.8552,2.24676 z"
|
||||||
|
id="path944" />
|
||||||
|
<path
|
||||||
|
style="fill:#ef5350;fill-opacity:1;stroke:none;stroke-width:5.18208;stroke-linecap:round;stroke-linejoin:round"
|
||||||
|
d="m 285.98437,367.98056 c -3.86343,-2.35557 -5.1524,-8.06518 -2.66781,-11.81734 1.64304,-2.48125 20.719,-12.23981 23.92632,-12.23981 1.56364,0 4.61398,1.26582 12.2153,5.06905 8.53551,4.27064 10.3157,5.36752 11.30239,6.96403 1.75651,2.84207 1.95178,5.62136 0.58856,8.37633 -1.52635,3.08463 -3.36973,4.32306 -6.86644,4.61304 -2.68142,0.22236 -3.36743,-0.003 -10.22731,-3.35873 l -7.35311,-3.59707 -7.04119,3.52834 c -7.90523,3.96133 -10.62609,4.44409 -13.87671,2.46216 z"
|
||||||
|
id="path946" />
|
||||||
|
<path
|
||||||
|
style="fill:#ef5350;fill-opacity:1;stroke:none;stroke-width:5.18208;stroke-linecap:round;stroke-linejoin:round"
|
||||||
|
d="m 228.11707,393.18031 c -1.0244,-0.54435 -2.42484,-1.80721 -3.11209,-2.80633 -1.05812,-1.53828 -1.2181,-2.32693 -1.04433,-5.14815 0.29039,-4.71472 1.41139,-5.70783 12.90113,-11.42937 7.71258,-3.84061 9.99443,-4.74971 11.92193,-4.74971 1.94819,0 4.22735,0.92952 12.47354,5.08716 8.66324,4.3679 10.26522,5.3693 11.33052,7.08263 3.53608,5.68714 -0.55313,12.95355 -7.28968,12.95355 -1.25225,0 -4.29453,-1.20187 -9.08226,-3.58799 l -7.19927,-3.58796 -7.37197,3.59617 c -8.07507,3.93914 -10.21699,4.34922 -13.52752,2.59 z"
|
||||||
|
id="path948" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 13 KiB |
@ -0,0 +1,45 @@
|
|||||||
|
<?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>
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
<?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>
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
<?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>
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
<?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="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
[weblate]
|
||||||
|
url = https://hosted.weblate.org/api/
|
||||||
|
translation = kotatsu/strings
|
||||||
@ -1,96 +1,12 @@
|
|||||||
# Contributing
|
## Kotatsu contribution guidelines
|
||||||
|
|
||||||
The following is a guide for creating Kotatsu parsers. Thanks for taking the time to contribute!
|
+ 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.
|
||||||
|
+ 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).
|
||||||
|
|
||||||
## Prerequisites
|
**Refactoring** or some **dev-faces improvements** might also be accepted. However, please stick to the following principles:
|
||||||
|
|
||||||
Before you start, please note that the ability to use the following technologies is **required**.
|
+ **Performance matters.** In the case of choosing between source code beauty and performance, performance should be a priority.
|
||||||
|
+ Please, **do not modify readme and other information files** (except for typos).
|
||||||
- Basic [Android development](https://developer.android.com/)
|
+ **Avoid adding new dependencies** unless required. APK size is important.
|
||||||
- [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,74 +1,117 @@
|
|||||||
# Kotatsu parsers
|
<div align="center">
|
||||||
|
|
||||||
This library provides a collection of manga parsers for convenient access manga available on the web. It can be used in
|
<a href="https://kotatsu.app">
|
||||||
JVM and Android applications.
|
<img src="./.github/assets/vtuber.png" alt="Kotatsu Logo" title="Kotatsu" width="600"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
 [](https://jitpack.io/#KotatsuApp/kotatsu-parsers)  [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5)
|
# [Kotatsu](https://kotatsu.app)
|
||||||
|
|
||||||
## Usage
|
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in online content sources.**
|
||||||
|
|
||||||
1. Add it to your root build.gradle at the end of repositories:
|
   [](https://github.com/KotatsuApp/kotatsu-parsers) [](https://hosted.weblate.org/engage/kotatsu/) [](https://discord.gg/NNJ5RgVBC5) [](https://t.me/kotatsuapp) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
||||||
|
|
||||||
```groovy
|
### Download
|
||||||
allprojects {
|
|
||||||
repositories {
|
|
||||||
...
|
|
||||||
maven { url 'https://jitpack.io' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Add the dependency
|
<div align="left">
|
||||||
|
|
||||||
For Java/Kotlin project:
|
* **Recommended:** Download and install APK from [GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest). Application has a built-in self-updating feature.
|
||||||
```groovy
|
* 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.
|
||||||
dependencies {
|
* Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (Unstable, use at your own risk). Application has a built-in self-updating feature.
|
||||||
implementation("com.github.KotatsuApp:kotatsu-parsers:$parsers_version")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
For Android project:
|
</div>
|
||||||
```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)
|
### Main Features
|
||||||
|
|
||||||
When used in Android
|
<div align="left">
|
||||||
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.
|
|
||||||
|
|
||||||
|
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers) (with 1200+ manga sources)
|
||||||
|
* Search manga by name, genres and more filters
|
||||||
|
* Favorites organized by user-defined categories
|
||||||
|
* Reading history, bookmarks and incognito mode support
|
||||||
|
* Download manga and read it offline. Third-party CBZ archives are also supported
|
||||||
|
* Clean and convenient Material You UI, optimized for phones, tablets and desktop
|
||||||
|
* Standard and Webtoon-optimized customizable reader, gesture support on reading interface
|
||||||
|
* Notifications about new chapters with updates feed, manga recommendations (with filters)
|
||||||
|
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
||||||
|
* Password / fingerprint-protected access to the app
|
||||||
|
* Automatically sync app data with other devices on the same account
|
||||||
|
* Support for older devices running Android 5.0+
|
||||||
|
|
||||||
3. Usage in code
|
</div>
|
||||||
|
|
||||||
```kotlin
|
### In-App Screenshots
|
||||||
val parser = mangaLoaderContext.newParserInstance(MangaParserSource.MANGADEX)
|
|
||||||
```
|
|
||||||
|
|
||||||
`mangaLoaderContext` is an implementation of the `MangaLoaderContext` class.
|
<div align="center">
|
||||||
See examples
|
<img src="./metadata/en-US/images/phoneScreenshots/1.png" alt="Mobile view" width="250"/>
|
||||||
of [Android](https://github.com/KotatsuApp/Kotatsu/blob/devel/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt)
|
<img src="./metadata/en-US/images/phoneScreenshots/2.png" alt="Mobile view" width="250"/>
|
||||||
and [Non-Android](https://github.com/KotatsuApp/kotatsu-dl/blob/master/src/main/kotlin/org/koitharu/kotatsu/dl/parsers/MangaLoaderContextImpl.kt)
|
<img src="./metadata/en-US/images/phoneScreenshots/3.png" alt="Mobile view" width="250"/>
|
||||||
implementation.
|
<img src="./metadata/en-US/images/phoneScreenshots/4.png" alt="Mobile view" width="250"/>
|
||||||
|
<img src="./metadata/en-US/images/phoneScreenshots/5.png" alt="Mobile view" width="250"/>
|
||||||
|
<img src="./metadata/en-US/images/phoneScreenshots/6.png" alt="Mobile view" width="250"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
## Projects that use the library
|
<br>
|
||||||
|
|
||||||
- [Kotatsu](https://github.com/KotatsuApp/Kotatsu)
|
<div align="center">
|
||||||
- [Doki](https://github.com/DokiTeam/Doki)
|
<img src="./metadata/en-US/images/tenInchScreenshots/1.png" alt="Tablet view" width="400"/>
|
||||||
- [kotatsu-dl](https://github.com/KotatsuApp/kotatsu-dl)
|
<img src="./metadata/en-US/images/tenInchScreenshots/2.png" alt="Tablet view" width="400"/>
|
||||||
- [Shirizu (WIP)](https://github.com/ztimms73/shirizu)
|
</div>
|
||||||
- [OtakuWorld](https://github.com/jakepurple13/OtakuWorld)
|
|
||||||
|
|
||||||
## Contribution
|
### Localization
|
||||||
|
|
||||||
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the guidelines.
|
<a href="https://hosted.weblate.org/engage/kotatsu/">
|
||||||
|
<img src="https://hosted.weblate.org/widget/kotatsu/horizontal-auto.png" alt="Translation status" />
|
||||||
|
</a>
|
||||||
|
|
||||||
## DMCA disclaimer
|
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is localized in a number of different languages.**<br>
|
||||||
|
**📌 If you would like to help improve these or add new languages,
|
||||||
|
please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)**
|
||||||
|
|
||||||
The developers of this application have no affiliation with the content available in the app. It is collected from
|
### Contributing
|
||||||
sources freely available through any web browser.
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<a href="https://github.com/KotatsuApp/Kotatsu">
|
||||||
|
<picture>
|
||||||
|
<source srcset="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu&bg_color=0d1117&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" media="(prefers-color-scheme: dark)">
|
||||||
|
<img src="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" alt="Kotatsu GitHub Repository">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/KotatsuApp/Kotatsu-parsers">
|
||||||
|
<picture>
|
||||||
|
<source srcset="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu-parsers&bg_color=0d1117&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" media="(prefers-color-scheme: dark)">
|
||||||
|
<img src="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu-parsers&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" alt="Kotatsu-parsers GitHub Repository">
|
||||||
|
</picture>
|
||||||
|
</a><br></br>
|
||||||
|
|
||||||
|
</br>
|
||||||
|
|
||||||
|
**📌 Pull requests are welcome, if you want: See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines**
|
||||||
|
|
||||||
|
### Certificate fingerprints
|
||||||
|
|
||||||
|
```plaintext
|
||||||
|
2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE
|
||||||
|
```
|
||||||
|
|
||||||
|
```plaintext
|
||||||
|
67:E1:51:00:BB:80:93:01:78:3E:DC:B6:34:8F:A3:BB:F8:30:34:D9:1E:62:86:8A:91:05:3D:BD:70:DB:3F:18
|
||||||
|
```
|
||||||
|
|
||||||
|
### License
|
||||||
|
|
||||||
|
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||||
|
|
||||||
|
<div align="left">
|
||||||
|
|
||||||
|
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
### DMCA disclaimer
|
||||||
|
|
||||||
|
<div align="left">
|
||||||
|
|
||||||
|
The developers of this application do not have any affiliation with the content available in the app. It collects content from sources that are freely available through any web browser.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
/build
|
||||||
|
/schemas/
|
||||||
@ -0,0 +1,210 @@
|
|||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id 'com.android.application'
|
||||||
|
id 'kotlin-android'
|
||||||
|
id 'com.google.devtools.ksp'
|
||||||
|
id 'kotlin-parcelize'
|
||||||
|
id 'dagger.hilt.android.plugin'
|
||||||
|
id 'androidx.room'
|
||||||
|
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||||
|
// enable if needed
|
||||||
|
// id 'dev.reformator.stacktracedecoroutinator'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdk = 36
|
||||||
|
buildToolsVersion = '35.0.0'
|
||||||
|
namespace = 'org.koitharu.kotatsu'
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId 'org.koitharu.kotatsu'
|
||||||
|
minSdk = 21
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 1028
|
||||||
|
versionName = '9.1.4'
|
||||||
|
generatedDensities = []
|
||||||
|
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||||
|
ksp {
|
||||||
|
arg('room.generateKotlin', 'true')
|
||||||
|
}
|
||||||
|
androidResources {
|
||||||
|
// https://issuetracker.google.com/issues/408030127
|
||||||
|
generateLocaleConfig false
|
||||||
|
}
|
||||||
|
def localProperties = new Properties()
|
||||||
|
def localPropertiesFile = rootProject.file('local.properties')
|
||||||
|
if (localPropertiesFile.exists()) {
|
||||||
|
localProperties.load(new FileInputStream(localPropertiesFile))
|
||||||
|
}
|
||||||
|
resValue 'string', 'tg_backup_bot_token', localProperties.getProperty('tg_backup_bot_token', '')
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
applicationIdSuffix = '.debug'
|
||||||
|
}
|
||||||
|
release {
|
||||||
|
minifyEnabled true
|
||||||
|
shrinkResources true
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
nightly {
|
||||||
|
initWith release
|
||||||
|
applicationIdSuffix = '.nightly'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
viewBinding true
|
||||||
|
buildConfig true
|
||||||
|
}
|
||||||
|
packagingOptions {
|
||||||
|
resources {
|
||||||
|
excludes += [
|
||||||
|
'META-INF/README.md',
|
||||||
|
'META-INF/NOTICE.md'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sourceSets {
|
||||||
|
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
||||||
|
main.java.srcDirs += 'src/main/kotlin/'
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
coreLibraryDesugaringEnabled true
|
||||||
|
sourceCompatibility JavaVersion.VERSION_11
|
||||||
|
targetCompatibility JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||||
|
freeCompilerArgs += [
|
||||||
|
'-opt-in=kotlin.ExperimentalStdlibApi',
|
||||||
|
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||||
|
'-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi',
|
||||||
|
'-opt-in=kotlinx.coroutines.InternalForInheritanceCoroutinesApi',
|
||||||
|
'-opt-in=kotlinx.coroutines.FlowPreview',
|
||||||
|
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
||||||
|
'-opt-in=coil3.annotation.ExperimentalCoilApi',
|
||||||
|
'-opt-in=coil3.annotation.InternalCoilApi',
|
||||||
|
'-opt-in=kotlinx.serialization.ExperimentalSerializationApi',
|
||||||
|
'-Xjspecify-annotations=strict',
|
||||||
|
'-Xtype-enhancement-improvements-strict-mode'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
room {
|
||||||
|
schemaDirectory "$projectDir/schemas"
|
||||||
|
}
|
||||||
|
lint {
|
||||||
|
abortOnError true
|
||||||
|
disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled', 'SimpleDateFormat'
|
||||||
|
}
|
||||||
|
testOptions {
|
||||||
|
unitTests.includeAndroidResources true
|
||||||
|
unitTests.returnDefaultValues false
|
||||||
|
kotlinOptions {
|
||||||
|
freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applicationVariants.configureEach { variant ->
|
||||||
|
if (variant.name == 'nightly') {
|
||||||
|
variant.outputs.each { output ->
|
||||||
|
def now = LocalDateTime.now()
|
||||||
|
output.versionCodeOverride = now.format("yyMMdd").toInteger()
|
||||||
|
output.versionNameOverride = 'N' + now.format("yyyyMMdd")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
def parsersVersion = libs.versions.parsers.get()
|
||||||
|
if (System.properties.containsKey('parsersVersionOverride')) {
|
||||||
|
// usage:
|
||||||
|
// -DparsersVersionOverride=$(curl -s https://api.github.com/repos/kotatsuapp/kotatsu-parsers/commits/master -H "Accept: application/vnd.github.sha" | cut -c -10)
|
||||||
|
parsersVersion = System.getProperty('parsersVersionOverride')
|
||||||
|
}
|
||||||
|
//noinspection UseTomlInstead
|
||||||
|
implementation("com.github.KotatsuApp:kotatsu-parsers:$parsersVersion") {
|
||||||
|
exclude group: 'org.json', module: 'json'
|
||||||
|
}
|
||||||
|
|
||||||
|
coreLibraryDesugaring libs.desugar.jdk.libs
|
||||||
|
implementation libs.kotlin.stdlib
|
||||||
|
implementation libs.kotlinx.coroutines.android
|
||||||
|
implementation libs.kotlinx.coroutines.guava
|
||||||
|
|
||||||
|
implementation libs.androidx.appcompat
|
||||||
|
implementation libs.androidx.core
|
||||||
|
implementation libs.androidx.activity
|
||||||
|
implementation libs.androidx.fragment
|
||||||
|
implementation libs.androidx.transition
|
||||||
|
implementation libs.androidx.collection
|
||||||
|
implementation libs.lifecycle.viewmodel
|
||||||
|
implementation libs.lifecycle.service
|
||||||
|
implementation libs.lifecycle.process
|
||||||
|
implementation libs.androidx.constraintlayout
|
||||||
|
implementation libs.androidx.documentfile
|
||||||
|
implementation libs.androidx.swiperefreshlayout
|
||||||
|
implementation libs.androidx.recyclerview
|
||||||
|
implementation libs.androidx.viewpager2
|
||||||
|
implementation libs.androidx.preference
|
||||||
|
implementation libs.androidx.biometric
|
||||||
|
implementation libs.material
|
||||||
|
implementation libs.androidx.lifecycle.common.java8
|
||||||
|
implementation libs.androidx.webkit
|
||||||
|
|
||||||
|
implementation libs.androidx.work.runtime
|
||||||
|
implementation libs.guava
|
||||||
|
|
||||||
|
implementation libs.androidx.room.runtime
|
||||||
|
implementation libs.androidx.room.ktx
|
||||||
|
ksp libs.androidx.room.compiler
|
||||||
|
|
||||||
|
implementation libs.okhttp
|
||||||
|
implementation libs.okhttp.tls
|
||||||
|
implementation libs.okhttp.dnsoverhttps
|
||||||
|
implementation libs.okio
|
||||||
|
implementation libs.kotlinx.serialization.json
|
||||||
|
|
||||||
|
implementation libs.adapterdelegates
|
||||||
|
implementation libs.adapterdelegates.viewbinding
|
||||||
|
|
||||||
|
implementation libs.hilt.android
|
||||||
|
ksp libs.hilt.compiler
|
||||||
|
implementation libs.androidx.hilt.work
|
||||||
|
ksp libs.androidx.hilt.compiler
|
||||||
|
|
||||||
|
implementation libs.coil.core
|
||||||
|
implementation libs.coil.network
|
||||||
|
implementation libs.coil.gif
|
||||||
|
implementation libs.coil.svg
|
||||||
|
implementation libs.avif.decoder
|
||||||
|
implementation libs.ssiv
|
||||||
|
implementation libs.disk.lru.cache
|
||||||
|
implementation libs.markwon
|
||||||
|
implementation libs.kizzyrpc
|
||||||
|
|
||||||
|
implementation libs.acra.http
|
||||||
|
implementation libs.acra.dialog
|
||||||
|
|
||||||
|
implementation libs.conscrypt.android
|
||||||
|
|
||||||
|
debugImplementation libs.leakcanary.android
|
||||||
|
nightlyImplementation libs.leakcanary.android
|
||||||
|
debugImplementation libs.workinspector
|
||||||
|
|
||||||
|
testImplementation libs.junit
|
||||||
|
testImplementation libs.json
|
||||||
|
testImplementation libs.kotlinx.coroutines.test
|
||||||
|
|
||||||
|
androidTestImplementation libs.androidx.runner
|
||||||
|
androidTestImplementation libs.androidx.rules
|
||||||
|
androidTestImplementation libs.androidx.test.core
|
||||||
|
androidTestImplementation libs.androidx.junit
|
||||||
|
|
||||||
|
androidTestImplementation libs.kotlinx.coroutines.test
|
||||||
|
|
||||||
|
androidTestImplementation libs.androidx.room.testing
|
||||||
|
androidTestImplementation libs.moshi.kotlin
|
||||||
|
|
||||||
|
androidTestImplementation libs.hilt.android.testing
|
||||||
|
kspAndroidTest libs.hilt.android.compiler
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
-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.**
|
||||||
|
-dontwarn com.google.j2objc.annotations.**
|
||||||
|
-dontwarn coil3.PlatformContext
|
||||||
|
|
||||||
|
-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.backups.ui.periodical.PeriodicalBackupSettingsFragment { *; }
|
||||||
|
-keep class org.jsoup.parser.Tag
|
||||||
|
-keep class org.jsoup.internal.StringUtil
|
||||||
|
|
||||||
|
-keep class org.acra.security.NoKeyStoreFactory { *; }
|
||||||
|
-keep class org.acra.config.DefaultRetryPolicy { *; }
|
||||||
|
-keep class org.acra.attachment.DefaultAttachmentProvider { *; }
|
||||||
|
-keep class org.acra.sender.JobSenderService
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"title": "Read later",
|
||||||
|
"sortKey": 1,
|
||||||
|
"order": "NEWEST",
|
||||||
|
"createdAt": 1335906000000,
|
||||||
|
"isTrackingEnabled": true,
|
||||||
|
"isVisibleInLibrary": true
|
||||||
|
}
|
||||||
Binary file not shown.
@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"id": -2096681732556647985,
|
||||||
|
"title": "Странствия Эманон",
|
||||||
|
"altTitles": [],
|
||||||
|
"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",
|
||||||
|
"authors": [],
|
||||||
|
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
||||||
|
"description": null,
|
||||||
|
"source": "READMANGA_RU"
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
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) }
|
||||||
|
}
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
package org.koitharu.kotatsu
|
||||||
|
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.squareup.moshi.FromJson
|
||||||
|
import com.squareup.moshi.JsonAdapter
|
||||||
|
import com.squareup.moshi.JsonReader
|
||||||
|
import com.squareup.moshi.JsonWriter
|
||||||
|
import com.squareup.moshi.Moshi
|
||||||
|
import com.squareup.moshi.ToJson
|
||||||
|
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||||
|
import okio.buffer
|
||||||
|
import okio.source
|
||||||
|
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.Date
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
object SampleData {
|
||||||
|
|
||||||
|
private val moshi = Moshi.Builder()
|
||||||
|
.add(DateAdapter())
|
||||||
|
.add(InstantAdapter())
|
||||||
|
.add(MangaSourceAdapter())
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MangaSourceAdapter : JsonAdapter<MangaSource>() {
|
||||||
|
|
||||||
|
@FromJson
|
||||||
|
override fun fromJson(reader: JsonReader): MangaSource? {
|
||||||
|
val name = reader.nextString() ?: return null
|
||||||
|
return MangaSource(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ToJson
|
||||||
|
override fun toJson(writer: JsonWriter, value: MangaSource?) {
|
||||||
|
writer.value(value?.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class InstantAdapter : JsonAdapter<Instant>() {
|
||||||
|
|
||||||
|
@FromJson
|
||||||
|
override fun fromJson(reader: JsonReader): Instant? {
|
||||||
|
val ms = reader.nextLong()
|
||||||
|
return if (ms == 0L) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
Instant.ofEpochMilli(ms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ToJson
|
||||||
|
override fun toJson(writer: JsonWriter, value: Instant?) {
|
||||||
|
writer.value(value?.toEpochMilli() ?: 0L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,113 @@
|
|||||||
|
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.backups.data.BackupRepository
|
||||||
|
import org.koitharu.kotatsu.backups.domain.AppBackupAgent
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
package org.koitharu.kotatsu
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.StrictMode
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||||
|
import leakcanary.LeakCanary
|
||||||
|
import org.koitharu.kotatsu.core.BaseApp
|
||||||
|
|
||||||
|
class KotatsuApp : BaseApp() {
|
||||||
|
|
||||||
|
var isLeakCanaryEnabled: Boolean
|
||||||
|
get() = getDebugPreferences(this).getBoolean(KEY_LEAK_CANARY, true)
|
||||||
|
set(value) {
|
||||||
|
getDebugPreferences(this).edit { putBoolean(KEY_LEAK_CANARY, value) }
|
||||||
|
configureLeakCanary()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun attachBaseContext(base: Context) {
|
||||||
|
super.attachBaseContext(base)
|
||||||
|
enableStrictMode()
|
||||||
|
configureLeakCanary()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun configureLeakCanary() {
|
||||||
|
LeakCanary.config = LeakCanary.config.copy(
|
||||||
|
dumpHeap = isLeakCanaryEnabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enableStrictMode() {
|
||||||
|
val notifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
StrictModeNotifier(this)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
StrictMode.setThreadPolicy(
|
||||||
|
StrictMode.ThreadPolicy.Builder().apply {
|
||||||
|
detectNetwork()
|
||||||
|
detectDiskWrites()
|
||||||
|
detectCustomSlowCalls()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectUnbufferedIo()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) detectResourceMismatches()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) detectExplicitGc()
|
||||||
|
penaltyLog()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
||||||
|
penaltyListener(notifier.executor, notifier)
|
||||||
|
}
|
||||||
|
}.build(),
|
||||||
|
)
|
||||||
|
StrictMode.setVmPolicy(
|
||||||
|
StrictMode.VmPolicy.Builder().apply {
|
||||||
|
detectActivityLeaks()
|
||||||
|
detectLeakedSqlLiteObjects()
|
||||||
|
detectLeakedClosableObjects()
|
||||||
|
detectLeakedRegistrationObjects()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
detectContentUriWithoutPermission()
|
||||||
|
}
|
||||||
|
detectFileUriExposure()
|
||||||
|
penaltyLog()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
||||||
|
penaltyListener(notifier.executor, notifier)
|
||||||
|
}
|
||||||
|
}.build(),
|
||||||
|
)
|
||||||
|
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder().apply {
|
||||||
|
detectWrongFragmentContainer()
|
||||||
|
detectFragmentTagUsage()
|
||||||
|
detectRetainInstanceUsage()
|
||||||
|
detectSetUserVisibleHint()
|
||||||
|
detectWrongNestedHierarchy()
|
||||||
|
detectFragmentReuse()
|
||||||
|
penaltyLog()
|
||||||
|
if (notifier != null) {
|
||||||
|
penaltyListener(notifier)
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
|
||||||
|
const val PREFS_DEBUG = "_debug"
|
||||||
|
const val KEY_LEAK_CANARY = "leak_canary"
|
||||||
|
|
||||||
|
fun getDebugPreferences(context: Context): SharedPreferences =
|
||||||
|
context.getSharedPreferences(PREFS_DEBUG, MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
package org.koitharu.kotatsu
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.Notification.BigTextStyle
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.StrictMode
|
||||||
|
import android.os.strictmode.Violation
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.core.app.PendingIntentCompat
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.asExecutor
|
||||||
|
import org.koitharu.kotatsu.core.util.ShareHelper
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
import androidx.fragment.app.strictmode.Violation as FragmentViolation
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.P)
|
||||||
|
class StrictModeNotifier(
|
||||||
|
private val context: Context,
|
||||||
|
) : StrictMode.OnVmViolationListener, StrictMode.OnThreadViolationListener, FragmentStrictMode.OnViolationListener {
|
||||||
|
|
||||||
|
val executor = Dispatchers.Default.asExecutor()
|
||||||
|
|
||||||
|
private val notificationManager by lazy {
|
||||||
|
val nm = checkNotNull(context.getSystemService<NotificationManager>())
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
context.getString(R.string.strict_mode),
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
)
|
||||||
|
nm.createNotificationChannel(channel)
|
||||||
|
nm
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onVmViolation(v: Violation) = showNotification(v)
|
||||||
|
|
||||||
|
override fun onThreadViolation(v: Violation) = showNotification(v)
|
||||||
|
|
||||||
|
override fun onViolation(violation: FragmentViolation) = showNotification(violation)
|
||||||
|
|
||||||
|
private fun showNotification(violation: Throwable) = Notification.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_bug)
|
||||||
|
.setContentTitle(context.getString(R.string.strict_mode))
|
||||||
|
.setContentText(violation.message)
|
||||||
|
.setStyle(
|
||||||
|
BigTextStyle()
|
||||||
|
.setBigContentTitle(context.getString(R.string.strict_mode))
|
||||||
|
.setSummaryText(violation.message)
|
||||||
|
.bigText(violation.stackTraceToString()),
|
||||||
|
).setShowWhen(true)
|
||||||
|
.setContentIntent(
|
||||||
|
PendingIntentCompat.getActivity(
|
||||||
|
context,
|
||||||
|
violation.hashCode(),
|
||||||
|
ShareHelper(context).getShareTextIntent(violation.stackTraceToString()),
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setGroup(CHANNEL_ID)
|
||||||
|
.build()
|
||||||
|
.let { notificationManager.notify(CHANNEL_ID, violation.hashCode().absoluteValue, it) }
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
|
||||||
|
const val CHANNEL_ID = "strict_mode"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
package org.koitharu.kotatsu.core.network
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Request
|
||||||
|
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 = chain.proceed(chain.request()).also {
|
||||||
|
logRequest(it.networkResponse?.request ?: it.request)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logRequest(request: 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.escape() = replace(escapeRegex) { match ->
|
||||||
|
"\\" + match.value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun log(msg: String) {
|
||||||
|
Log.d("CURL", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.LifecycleService
|
||||||
|
import leakcanary.AppWatcher
|
||||||
|
|
||||||
|
abstract class BaseService : LifecycleService() {
|
||||||
|
|
||||||
|
override fun attachBaseContext(newBase: Context) {
|
||||||
|
super.attachBaseContext(ContextCompat.getContextForLanguage(newBase))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
AppWatcher.objectWatcher.watch(
|
||||||
|
watchedObject = this,
|
||||||
|
description = "${javaClass.simpleName} service received Service#onDestroy() callback",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package org.koitharu.kotatsu.core.util.ext
|
||||||
|
|
||||||
|
import android.os.Looper
|
||||||
|
|
||||||
|
fun Throwable.printStackTraceDebug() = printStackTrace()
|
||||||
|
|
||||||
|
fun assertNotInMainThread() = check(Looper.myLooper() != Looper.getMainLooper()) {
|
||||||
|
"Calling this from the main thread is prohibited"
|
||||||
|
}
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
package org.koitharu.kotatsu.settings
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.preference.Preference
|
||||||
|
import leakcanary.LeakCanary
|
||||||
|
import org.koitharu.kotatsu.KotatsuApp
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||||
|
import org.koitharu.kotatsu.settings.utils.SplitSwitchPreference
|
||||||
|
import org.koitharu.workinspector.WorkInspector
|
||||||
|
|
||||||
|
class DebugSettingsFragment : BasePreferenceFragment(R.string.debug), Preference.OnPreferenceChangeListener,
|
||||||
|
Preference.OnPreferenceClickListener {
|
||||||
|
|
||||||
|
private val application
|
||||||
|
get() = requireContext().applicationContext as KotatsuApp
|
||||||
|
|
||||||
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
|
addPreferencesFromResource(R.xml.pref_debug)
|
||||||
|
findPreference<SplitSwitchPreference>(KEY_LEAK_CANARY)?.let { pref ->
|
||||||
|
pref.isChecked = application.isLeakCanaryEnabled
|
||||||
|
pref.onPreferenceChangeListener = this
|
||||||
|
pref.onContainerClickListener = this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
findPreference<SplitSwitchPreference>(KEY_LEAK_CANARY)?.isChecked = application.isLeakCanaryEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) {
|
||||||
|
KEY_WORK_INSPECTOR -> {
|
||||||
|
startActivity(WorkInspector.getIntent(preference.context))
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> super.onPreferenceTreeClick(preference)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPreferenceClick(preference: Preference): Boolean = when (preference.key) {
|
||||||
|
KEY_LEAK_CANARY -> {
|
||||||
|
startActivity(LeakCanary.newLeakDisplayActivityIntent())
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> super.onPreferenceTreeClick(preference)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPreferenceChange(preference: Preference, newValue: Any?): Boolean = when (preference.key) {
|
||||||
|
KEY_LEAK_CANARY -> {
|
||||||
|
application.isLeakCanaryEnabled = newValue as Boolean
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
|
||||||
|
const val KEY_LEAK_CANARY = "leak_canary"
|
||||||
|
const val KEY_WORK_INSPECTOR = "work_inspector"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="#FFFFFF">
|
||||||
|
<group android:scaleX="0.98150784"
|
||||||
|
android:scaleY="0.98150784"
|
||||||
|
android:translateX="0.22190611"
|
||||||
|
android:translateY="-0.2688478">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M20,8h-2.81c-0.45,-0.78 -1.07,-1.45 -1.82,-1.96L17,4.41 15.59,3l-2.17,2.17C12.96,5.06 12.49,5 12,5c-0.49,0 -0.96,0.06 -1.41,0.17L8.41,3 7,4.41l1.62,1.63C7.88,6.55 7.26,7.22 6.81,8L4,8v2h2.09c-0.05,0.33 -0.09,0.66 -0.09,1v1L4,12v2h2v1c0,0.34 0.04,0.67 0.09,1L4,16v2h2.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3L20,18v-2h-2.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h2v-2h-2v-1c0,-0.34 -0.04,-0.67 -0.09,-1L20,10L20,8zM14,16h-4v-2h4v2zM14,12h-4v-2h4v2z"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 417 B |
Binary file not shown.
|
After Width: | Height: | Size: 308 B |
Binary file not shown.
|
After Width: | Height: | Size: 480 B |
Binary file not shown.
|
After Width: | Height: | Size: 792 B |
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:tint="?attr/colorControlNormal"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000"
|
||||||
|
android:pathData="M20,8H17.19C16.74,7.2 16.12,6.5 15.37,6L17,4.41L15.59,3L13.42,5.17C12.96,5.06 12.5,5 12,5C11.5,5 11.05,5.06 10.59,5.17L8.41,3L7,4.41L8.62,6C7.87,6.5 7.26,7.21 6.81,8H4V10H6.09C6.03,10.33 6,10.66 6,11V12H4V14H6V15C6,15.34 6.03,15.67 6.09,16H4V18H6.81C8.47,20.87 12.14,21.84 15,20.18C15.91,19.66 16.67,18.9 17.19,18H20V16H17.91C17.97,15.67 18,15.34 18,15V14H20V12H18V11C18,10.66 17.97,10.33 17.91,10H20V8M16,15A4,4 0 0,1 12,19A4,4 0 0,1 8,15V11A4,4 0 0,1 12,7A4,4 0 0,1 16,11V15M14,10V12H10V10H14M10,14H14V16H10V14Z" />
|
||||||
|
</vector>
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
<?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>
|
||||||
|
<bool name="wi_launcher_icon_enabled" tools:node="replace">false</bool>
|
||||||
|
</resources>
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
<?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>
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name" translatable="false">Kotatsu Dev</string>
|
||||||
|
<string name="strict_mode">Strict mode</string>
|
||||||
|
</resources>
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.preference.PreferenceScreen
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<org.koitharu.kotatsu.settings.utils.SplitSwitchPreference
|
||||||
|
android:id="@+id/action_leakcanary"
|
||||||
|
android:key="leak_canary"
|
||||||
|
android:persistent="false"
|
||||||
|
android:title="LeakCanary" />
|
||||||
|
|
||||||
|
<Preference
|
||||||
|
android:id="@+id/action_works"
|
||||||
|
android:key="work_inspector"
|
||||||
|
android:persistent="false"
|
||||||
|
android:title="@string/wi_lib_name" />
|
||||||
|
|
||||||
|
</androidx.preference.PreferenceScreen>
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.preference.PreferenceScreen
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<PreferenceScreen
|
||||||
|
android:fragment="org.koitharu.kotatsu.settings.DebugSettingsFragment"
|
||||||
|
android:icon="@drawable/ic_debug"
|
||||||
|
android:key="debug"
|
||||||
|
android:title="@string/debug" />
|
||||||
|
|
||||||
|
</androidx.preference.PreferenceScreen>
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,31 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
|
||||||
|
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
||||||
|
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
|
||||||
|
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
|
||||||
|
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
|
||||||
|
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
|
||||||
|
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
|
||||||
|
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
|
||||||
|
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
|
||||||
|
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
|
||||||
|
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
|
||||||
|
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
|
||||||
|
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
|
||||||
|
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
|
||||||
|
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
|
||||||
|
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
|
||||||
|
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
|
||||||
|
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
|
||||||
|
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
|
||||||
|
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
|
||||||
|
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
|
||||||
|
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
|
||||||
|
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
|
||||||
|
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
|
||||||
|
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
|
||||||
|
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
|
||||||
|
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
|
||||||
|
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
|
||||||
|
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
|
||||||
|
-----END CERTIFICATE-----
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
package org.koitharu.kotatsu.alternatives.domain
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
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.toLocale
|
||||||
|
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import org.koitharu.kotatsu.search.domain.SearchKind
|
||||||
|
import org.koitharu.kotatsu.search.domain.SearchV2Helper
|
||||||
|
import java.util.Locale
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val MAX_PARALLELISM = 4
|
||||||
|
|
||||||
|
class AlternativesUseCase @Inject constructor(
|
||||||
|
private val sourcesRepository: MangaSourcesRepository,
|
||||||
|
private val searchHelperFactory: SearchV2Helper.Factory,
|
||||||
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend operator fun invoke(manga: Manga, throughDisabledSources: Boolean): Flow<Manga> {
|
||||||
|
val sources = getSources(manga.source, throughDisabledSources)
|
||||||
|
if (sources.isEmpty()) {
|
||||||
|
return emptyFlow()
|
||||||
|
}
|
||||||
|
val semaphore = Semaphore(MAX_PARALLELISM)
|
||||||
|
return channelFlow {
|
||||||
|
for (source in sources) {
|
||||||
|
launch {
|
||||||
|
val searchHelper = searchHelperFactory.create(source)
|
||||||
|
val list = runCatchingCancellable {
|
||||||
|
semaphore.withPermit {
|
||||||
|
searchHelper(manga.title, SearchKind.TITLE)?.manga
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
list?.forEach { m ->
|
||||||
|
if (m.id != manga.id) {
|
||||||
|
launch {
|
||||||
|
val details = runCatchingCancellable {
|
||||||
|
mangaRepositoryFactory.create(m.source).getDetails(m)
|
||||||
|
}.getOrDefault(m)
|
||||||
|
send(details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getSources(ref: MangaSource, disabled: Boolean): List<MangaSource> = if (disabled) {
|
||||||
|
sourcesRepository.getDisabledSources()
|
||||||
|
} else {
|
||||||
|
sourcesRepository.getEnabledSources()
|
||||||
|
}.sortedByDescending { it.priority(ref) }
|
||||||
|
|
||||||
|
private fun MangaSource.priority(ref: MangaSource): Int {
|
||||||
|
var res = 0
|
||||||
|
if (this is MangaParserSource && ref is MangaParserSource) {
|
||||||
|
if (locale == ref.locale) {
|
||||||
|
res += 4
|
||||||
|
} else if (locale.toLocale() == Locale.getDefault()) {
|
||||||
|
res += 2
|
||||||
|
}
|
||||||
|
if (contentType == ref.contentType) {
|
||||||
|
res++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
package org.koitharu.kotatsu.alternatives.domain
|
||||||
|
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.lastOrNull
|
||||||
|
import kotlinx.coroutines.flow.runningFold
|
||||||
|
import kotlinx.coroutines.flow.transformWhile
|
||||||
|
import kotlinx.coroutines.flow.withIndex
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import org.koitharu.kotatsu.core.model.chaptersCount
|
||||||
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.concat
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
|
class AutoFixUseCase @Inject constructor(
|
||||||
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
private val alternativesUseCase: AlternativesUseCase,
|
||||||
|
private val migrateUseCase: MigrateUseCase,
|
||||||
|
private val mangaDataRepository: MangaDataRepository,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend operator fun invoke(mangaId: Long): Pair<Manga, Manga?> {
|
||||||
|
val seed = checkNotNull(
|
||||||
|
mangaDataRepository.findMangaById(mangaId, withChapters = true),
|
||||||
|
) { "Manga $mangaId not found" }.getDetailsSafe()
|
||||||
|
if (seed.isHealthy()) {
|
||||||
|
return seed to null // no fix required
|
||||||
|
}
|
||||||
|
val replacement = alternativesUseCase(seed, throughDisabledSources = false)
|
||||||
|
.concat(alternativesUseCase(seed, throughDisabledSources = true))
|
||||||
|
.filter { it.isHealthy() }
|
||||||
|
.runningFold<Manga, Manga?>(null) { best, candidate ->
|
||||||
|
if (best == null || best < candidate) {
|
||||||
|
candidate
|
||||||
|
} else {
|
||||||
|
best
|
||||||
|
}
|
||||||
|
}.selectLastWithTimeout(4, 40, TimeUnit.SECONDS)
|
||||||
|
migrateUseCase(seed, replacement ?: throw NoAlternativesException(ParcelableManga(seed)))
|
||||||
|
return seed to replacement
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun Manga.isHealthy(): Boolean = runCatchingCancellable {
|
||||||
|
val repo = mangaRepositoryFactory.create(source)
|
||||||
|
val details = if (this.chapters != null) this else repo.getDetails(this)
|
||||||
|
val firstChapter = details.chapters?.firstOrNull() ?: return@runCatchingCancellable false
|
||||||
|
val pageUrl = repo.getPageUrl(repo.getPages(firstChapter).first())
|
||||||
|
pageUrl.toHttpUrlOrNull() != null
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
private suspend fun Manga.getDetailsSafe() = runCatchingCancellable {
|
||||||
|
mangaRepositoryFactory.create(source).getDetails(this)
|
||||||
|
}.getOrDefault(this)
|
||||||
|
|
||||||
|
private operator fun Manga.compareTo(other: Manga) = chaptersCount().compareTo(other.chaptersCount())
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST", "OPT_IN_USAGE")
|
||||||
|
private suspend fun <T> Flow<T>.selectLastWithTimeout(
|
||||||
|
minCount: Int,
|
||||||
|
timeout: Long,
|
||||||
|
timeUnit: TimeUnit
|
||||||
|
): T? = channelFlow<T?> {
|
||||||
|
var lastValue: T? = null
|
||||||
|
launch {
|
||||||
|
delay(timeUnit.toMillis(timeout))
|
||||||
|
close(InternalTimeoutException(lastValue))
|
||||||
|
}
|
||||||
|
withIndex().transformWhile { (index, value) ->
|
||||||
|
lastValue = value
|
||||||
|
emit(value)
|
||||||
|
index < minCount && !isClosedForSend
|
||||||
|
}.collect {
|
||||||
|
send(it)
|
||||||
|
}
|
||||||
|
}.catch { e ->
|
||||||
|
if (e is InternalTimeoutException) {
|
||||||
|
emit(e.value as T?)
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}.lastOrNull()
|
||||||
|
|
||||||
|
class NoAlternativesException(val seed: ParcelableManga) : NoSuchElementException()
|
||||||
|
|
||||||
|
private class InternalTimeoutException(val value: Any?) : CancellationException()
|
||||||
|
}
|
||||||
@ -0,0 +1,193 @@
|
|||||||
|
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.toMangaHistory
|
||||||
|
import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
|
||||||
|
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
|
||||||
|
import org.koitharu.kotatsu.tracker.data.TrackEntity
|
||||||
|
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,
|
||||||
|
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
|
||||||
|
) {
|
||||||
|
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, replaceExisting = true)
|
||||||
|
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)
|
||||||
|
val newHistory =
|
||||||
|
if (oldHistory != null) {
|
||||||
|
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
|
||||||
|
historyDao.delete(oldDetails.id)
|
||||||
|
historyDao.upsert(newHistory)
|
||||||
|
newHistory
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
// track
|
||||||
|
val tracksDao = database.getTracksDao()
|
||||||
|
val oldTrack = tracksDao.find(oldDetails.id)
|
||||||
|
if (oldTrack != null) {
|
||||||
|
val lastChapter = newDetails.chapters?.lastOrNull()
|
||||||
|
val newTrack =
|
||||||
|
TrackEntity(
|
||||||
|
mangaId = newDetails.id,
|
||||||
|
lastChapterId = lastChapter?.id ?: 0L,
|
||||||
|
newChapters = 0,
|
||||||
|
lastCheckTime = System.currentTimeMillis(),
|
||||||
|
lastChapterDate = lastChapter?.uploadDate ?: 0L,
|
||||||
|
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
|
||||||
|
lastError = null,
|
||||||
|
)
|
||||||
|
tracksDao.delete(oldDetails.id)
|
||||||
|
tracksDao.upsert(newTrack)
|
||||||
|
}
|
||||||
|
// scrobbling
|
||||||
|
for (scrobbler in scrobblers) {
|
||||||
|
if (!scrobbler.isEnabled) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val prevInfo = scrobbler.getScrobblingInfoOrNull(oldDetails.id) ?: continue
|
||||||
|
scrobbler.unregisterScrobbling(oldDetails.id)
|
||||||
|
scrobbler.linkManga(newDetails.id, prevInfo.targetId)
|
||||||
|
scrobbler.updateScrobblingInfo(
|
||||||
|
mangaId = newDetails.id,
|
||||||
|
rating = prevInfo.rating,
|
||||||
|
status =
|
||||||
|
prevInfo.status ?: when {
|
||||||
|
newHistory == null -> ScrobblingStatus.PLANNED
|
||||||
|
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
|
||||||
|
else -> ScrobblingStatus.READING
|
||||||
|
},
|
||||||
|
comment = prevInfo.comment,
|
||||||
|
)
|
||||||
|
if (newHistory != null) {
|
||||||
|
scrobbler.scrobble(
|
||||||
|
manga = newDetails,
|
||||||
|
chapterId = newHistory.chapterId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 = history.updatedAt,
|
||||||
|
chapterId = currentChapter.id,
|
||||||
|
page = history.page,
|
||||||
|
scroll = history.scroll,
|
||||||
|
percent = history.percent,
|
||||||
|
deletedAt = 0,
|
||||||
|
chaptersCount = chapters.count { it.branch == currentChapter.branch },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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 = history.updatedAt,
|
||||||
|
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? =
|
||||||
|
if (number <= 0f) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
firstOrNull { it.volume == volume && it.number == number }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,104 @@
|
|||||||
|
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.core.view.isVisible
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import coil3.request.allowRgb565
|
||||||
|
import coil3.request.crossfade
|
||||||
|
import coil3.request.error
|
||||||
|
import coil3.request.fallback
|
||||||
|
import coil3.request.lifecycle
|
||||||
|
import coil3.request.placeholder
|
||||||
|
import coil3.request.transformations
|
||||||
|
import coil3.transform.RoundedCornersTransformation
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.model.getTitle
|
||||||
|
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||||
|
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
|
||||||
|
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.getQuantityStringSafe
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||||
|
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.mangaModel.title
|
||||||
|
with(binding.iconsView) {
|
||||||
|
clearIcons()
|
||||||
|
if (item.mangaModel.isSaved) addIcon(R.drawable.ic_storage)
|
||||||
|
if (item.mangaModel.isFavorite) addIcon(R.drawable.ic_heart_outline)
|
||||||
|
isVisible = iconsCount > 0
|
||||||
|
}
|
||||||
|
binding.textViewSubtitle.text = buildSpannedString {
|
||||||
|
if (item.chaptersCount > 0) {
|
||||||
|
append(
|
||||||
|
context.resources.getQuantityStringSafe(
|
||||||
|
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.setProgress(
|
||||||
|
item.mangaModel.progress,
|
||||||
|
ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads,
|
||||||
|
)
|
||||||
|
binding.chipSource.also { chip ->
|
||||||
|
chip.text = item.manga.source.getTitle(chip.context)
|
||||||
|
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)
|
||||||
|
.mangaSourceExtra(item.manga.source)
|
||||||
|
.transformations(RoundedCornersTransformation(context.resources.getDimension(R.dimen.chip_icon_corner)))
|
||||||
|
.allowRgb565(true)
|
||||||
|
.enqueueWith(coil)
|
||||||
|
}
|
||||||
|
binding.imageViewCover.setImageAsync(item.manga.coverUrl, item.manga)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,124 @@
|
|||||||
|
package org.koitharu.kotatsu.alternatives.ui
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||||
|
import org.koitharu.kotatsu.core.model.getTitle
|
||||||
|
import org.koitharu.kotatsu.core.nav.router
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||||
|
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||||
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.systemBarsInsets
|
||||||
|
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
|
||||||
|
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.adapter.buttonFooterAD
|
||||||
|
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 javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
||||||
|
ListStateHolderListener,
|
||||||
|
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(null))
|
||||||
|
.addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
||||||
|
.addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||||
|
.addDelegate(ListItemType.FOOTER_BUTTON, buttonFooterAD(this))
|
||||||
|
with(viewBinding.recyclerView) {
|
||||||
|
setHasFixedSize(true)
|
||||||
|
addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false))
|
||||||
|
adapter = listAdapter
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
|
||||||
|
viewModel.list.observe(this, listAdapter)
|
||||||
|
viewModel.onMigrated.observeEvent(this) {
|
||||||
|
Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show()
|
||||||
|
router.openDetails(it)
|
||||||
|
finishAfterTransition()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onApplyWindowInsets(
|
||||||
|
v: View,
|
||||||
|
insets: WindowInsetsCompat
|
||||||
|
): WindowInsetsCompat {
|
||||||
|
val barsInsets = insets.systemBarsInsets
|
||||||
|
viewBinding.recyclerView.updatePadding(
|
||||||
|
left = barsInsets.left,
|
||||||
|
right = barsInsets.right,
|
||||||
|
bottom = barsInsets.bottom,
|
||||||
|
)
|
||||||
|
viewBinding.appbar.updatePadding(
|
||||||
|
left = barsInsets.left,
|
||||||
|
right = barsInsets.right,
|
||||||
|
top = barsInsets.top,
|
||||||
|
)
|
||||||
|
return insets.consumeAllSystemBarsInsets()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onItemClick(item: MangaAlternativeModel, view: View) {
|
||||||
|
when (view.id) {
|
||||||
|
R.id.chip_source -> router.openSearch(item.manga.source, viewModel.manga.title)
|
||||||
|
R.id.button_migrate -> confirmMigration(item.manga)
|
||||||
|
else -> router.openDetails(item.manga)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRetryClick(error: Throwable) = viewModel.retry()
|
||||||
|
|
||||||
|
override fun onEmptyActionClick() = Unit
|
||||||
|
|
||||||
|
override fun onFooterButtonClick() = viewModel.continueSearch()
|
||||||
|
|
||||||
|
private fun confirmMigration(target: Manga) {
|
||||||
|
buildAlertDialog(this, isCentered = true) {
|
||||||
|
setIcon(R.drawable.ic_replace)
|
||||||
|
setTitle(R.string.manga_migration)
|
||||||
|
setMessage(
|
||||||
|
getString(
|
||||||
|
R.string.migrate_confirmation,
|
||||||
|
viewModel.manga.title,
|
||||||
|
viewModel.manga.source.getTitle(context),
|
||||||
|
target.title,
|
||||||
|
target.source.getTitle(context),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
setNegativeButton(android.R.string.cancel, null)
|
||||||
|
setPositiveButton(R.string.migrate) { _, _ ->
|
||||||
|
viewModel.migrate(target)
|
||||||
|
}
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,136 @@
|
|||||||
|
package org.koitharu.kotatsu.alternatives.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.plus
|
||||||
|
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.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
|
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.append
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.call
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.require
|
||||||
|
import org.koitharu.kotatsu.list.domain.MangaListMapper
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ButtonFooter
|
||||||
|
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.list.ui.model.MangaGridModel
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrDefault
|
||||||
|
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
|
||||||
|
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 mangaListMapper: MangaListMapper,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
val manga = savedStateHandle.require<ParcelableManga>(AppRouter.KEY_MANGA).manga
|
||||||
|
|
||||||
|
private var includeDisabledSources = MutableStateFlow(false)
|
||||||
|
private val results = MutableStateFlow<List<MangaAlternativeModel>>(emptyList())
|
||||||
|
|
||||||
|
private var migrationJob: Job? = null
|
||||||
|
private var searchJob: Job? = null
|
||||||
|
|
||||||
|
private val mangaDetails = suspendLazy {
|
||||||
|
mangaRepositoryFactory.create(manga.source).getDetails(manga)
|
||||||
|
}
|
||||||
|
|
||||||
|
val onMigrated = MutableEventFlow<Manga>()
|
||||||
|
|
||||||
|
val list: StateFlow<List<ListModel>> = combine(
|
||||||
|
results,
|
||||||
|
isLoading,
|
||||||
|
includeDisabledSources,
|
||||||
|
) { list, loading, includeDisabled ->
|
||||||
|
when {
|
||||||
|
list.isEmpty() -> listOf(
|
||||||
|
when {
|
||||||
|
loading -> LoadingState
|
||||||
|
else -> EmptyState(
|
||||||
|
icon = R.drawable.ic_empty_common,
|
||||||
|
textPrimary = R.string.nothing_found,
|
||||||
|
textSecondary = R.string.text_search_holder_secondary,
|
||||||
|
actionStringRes = 0,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
loading -> list + LoadingFooter()
|
||||||
|
includeDisabled -> list
|
||||||
|
else -> list + ButtonFooter(R.string.search_disabled_sources)
|
||||||
|
}
|
||||||
|
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
|
||||||
|
|
||||||
|
init {
|
||||||
|
doSearch(throughDisabledSources = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun retry() {
|
||||||
|
searchJob?.cancel()
|
||||||
|
results.value = emptyList()
|
||||||
|
includeDisabledSources.value = false
|
||||||
|
doSearch(throughDisabledSources = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun continueSearch() {
|
||||||
|
if (includeDisabledSources.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val prevJob = searchJob
|
||||||
|
searchJob = launchLoadingJob(Dispatchers.Default) {
|
||||||
|
includeDisabledSources.value = true
|
||||||
|
prevJob?.join()
|
||||||
|
doSearch(throughDisabledSources = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun migrate(target: Manga) {
|
||||||
|
if (migrationJob?.isActive == true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
migrationJob = launchLoadingJob(Dispatchers.Default) {
|
||||||
|
migrateUseCase(manga, target)
|
||||||
|
onMigrated.call(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun doSearch(throughDisabledSources: Boolean) {
|
||||||
|
val prevJob = searchJob
|
||||||
|
searchJob = launchLoadingJob(Dispatchers.Default) {
|
||||||
|
prevJob?.cancelAndJoin()
|
||||||
|
val ref = mangaDetails.getOrDefault(manga)
|
||||||
|
val refCount = ref.chaptersCount()
|
||||||
|
alternativesUseCase.invoke(ref, throughDisabledSources)
|
||||||
|
.collect {
|
||||||
|
val model = MangaAlternativeModel(
|
||||||
|
mangaModel = mangaListMapper.toListModel(it, ListMode.GRID) as MangaGridModel,
|
||||||
|
referenceChapters = refCount,
|
||||||
|
)
|
||||||
|
results.append(model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,203 @@
|
|||||||
|
package org.koitharu.kotatsu.alternatives.ui
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import androidx.core.app.NotificationChannelCompat
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.app.PendingIntentCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase
|
||||||
|
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase.NoAlternativesException
|
||||||
|
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||||
|
import org.koitharu.kotatsu.core.model.getTitle
|
||||||
|
import org.koitharu.kotatsu.core.model.isNsfw
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.powerManager
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import javax.inject.Inject
|
||||||
|
import androidx.appcompat.R as appcompatR
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class AutoFixService : CoroutineIntentService() {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var autoFixUseCase: AutoFixUseCase
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var coil: ImageLoader
|
||||||
|
|
||||||
|
private lateinit var notificationManager: NotificationManagerCompat
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
notificationManager = NotificationManagerCompat.from(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
||||||
|
val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS))
|
||||||
|
startForeground(this)
|
||||||
|
for (mangaId in ids) {
|
||||||
|
powerManager.withPartialWakeLock(TAG) {
|
||||||
|
val result = runCatchingCancellable {
|
||||||
|
autoFixUseCase.invoke(mangaId)
|
||||||
|
}
|
||||||
|
if (checkNotificationPermission(CHANNEL_ID)) {
|
||||||
|
val notification = buildNotification(startId, result)
|
||||||
|
notificationManager.notify(TAG, startId, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun IntentJobContext.onError(error: Throwable) {
|
||||||
|
if (checkNotificationPermission(CHANNEL_ID)) {
|
||||||
|
val notification = runBlocking { buildNotification(startId, Result.failure(error)) }
|
||||||
|
notificationManager.notify(TAG, startId, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("InlinedApi")
|
||||||
|
private fun startForeground(jobContext: IntentJobContext) {
|
||||||
|
val title = getString(R.string.fixing_manga)
|
||||||
|
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN)
|
||||||
|
.setName(title)
|
||||||
|
.setShowBadge(false)
|
||||||
|
.setVibrationEnabled(false)
|
||||||
|
.setSound(null, null)
|
||||||
|
.setLightsEnabled(false)
|
||||||
|
.build()
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setSilent(true)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setProgress(0, 0, true)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_auto_fix)
|
||||||
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
|
.addAction(
|
||||||
|
appcompatR.drawable.abc_ic_clear_material,
|
||||||
|
getString(android.R.string.cancel),
|
||||||
|
jobContext.getCancelIntent(),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
jobContext.setForeground(
|
||||||
|
FOREGROUND_NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun buildNotification(startId: Int, result: Result<Pair<Manga, Manga?>>): Notification {
|
||||||
|
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setSilent(true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
result.onSuccess { (seed, replacement) ->
|
||||||
|
if (replacement != null) {
|
||||||
|
notification.setLargeIcon(
|
||||||
|
coil.execute(
|
||||||
|
ImageRequest.Builder(this)
|
||||||
|
.data(replacement.coverUrl)
|
||||||
|
.mangaSourceExtra(replacement.source)
|
||||||
|
.build(),
|
||||||
|
).toBitmapOrNull(),
|
||||||
|
)
|
||||||
|
notification.setSubText(replacement.title)
|
||||||
|
val intent = AppRouter.detailsIntent(this, replacement)
|
||||||
|
notification.setContentIntent(
|
||||||
|
PendingIntentCompat.getActivity(
|
||||||
|
this,
|
||||||
|
replacement.id.toInt(),
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
).setVisibility(
|
||||||
|
if (replacement.isNsfw()) {
|
||||||
|
NotificationCompat.VISIBILITY_SECRET
|
||||||
|
} else {
|
||||||
|
NotificationCompat.VISIBILITY_PUBLIC
|
||||||
|
},
|
||||||
|
)
|
||||||
|
notification
|
||||||
|
.setContentTitle(getString(R.string.fixed))
|
||||||
|
.setContentText(
|
||||||
|
getString(
|
||||||
|
R.string.manga_replaced,
|
||||||
|
seed.title,
|
||||||
|
seed.source.getTitle(this),
|
||||||
|
replacement.title,
|
||||||
|
replacement.source.getTitle(this),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_done)
|
||||||
|
} else {
|
||||||
|
notification
|
||||||
|
.setContentTitle(getString(R.string.fixing_manga))
|
||||||
|
.setContentText(getString(R.string.no_fix_required, seed.title))
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
notification
|
||||||
|
.setContentTitle(getString(R.string.error_occurred))
|
||||||
|
.setContentText(
|
||||||
|
if (error is NoAlternativesException) {
|
||||||
|
getString(R.string.no_alternatives_found, error.seed.manga.title)
|
||||||
|
} else {
|
||||||
|
error.getDisplayMessage(resources)
|
||||||
|
},
|
||||||
|
).setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
|
ErrorReporterReceiver.getNotificationAction(
|
||||||
|
context = this,
|
||||||
|
e = error,
|
||||||
|
notificationId = startId,
|
||||||
|
notificationTag = TAG,
|
||||||
|
)?.let { action ->
|
||||||
|
notification.addAction(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return notification.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val DATA_IDS = "ids"
|
||||||
|
private const val TAG = "auto_fix"
|
||||||
|
private const val CHANNEL_ID = "auto_fix"
|
||||||
|
private const val FOREGROUND_NOTIFICATION_ID = 38
|
||||||
|
|
||||||
|
fun start(context: Context, mangaIds: Collection<Long>): Boolean = try {
|
||||||
|
val intent = Intent(context, AutoFixService::class.java)
|
||||||
|
intent.putExtra(DATA_IDS, mangaIds.toLongArray())
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
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.list.ui.model.MangaGridModel
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
|
||||||
|
data class MangaAlternativeModel(
|
||||||
|
val mangaModel: MangaGridModel,
|
||||||
|
private val referenceChapters: Int,
|
||||||
|
) : ListModel {
|
||||||
|
|
||||||
|
val manga: Manga
|
||||||
|
get() = mangaModel.manga
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getChangePayload(previousState: ListModel): Any? = if (previousState is MangaAlternativeModel) {
|
||||||
|
mangaModel.getChangePayload(previousState.mangaModel)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,262 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data
|
||||||
|
|
||||||
|
import androidx.collection.ArrayMap
|
||||||
|
import androidx.room.withTransaction
|
||||||
|
import dagger.Reusable
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.FlowCollector
|
||||||
|
import kotlinx.coroutines.flow.asFlow
|
||||||
|
import kotlinx.coroutines.flow.collectIndexed
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onCompletion
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
|
import kotlinx.serialization.DeserializationStrategy
|
||||||
|
import kotlinx.serialization.SerializationStrategy
|
||||||
|
import kotlinx.serialization.json.DecodeSequenceMode
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.decodeToSequence
|
||||||
|
import kotlinx.serialization.json.encodeToStream
|
||||||
|
import kotlinx.serialization.serializer
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.BackupIndex
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.BookmarkBackup
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.CategoryBackup
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.FavouriteBackup
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.HistoryBackup
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.MangaBackup
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.SourceBackup
|
||||||
|
import org.koitharu.kotatsu.backups.domain.BackupSection
|
||||||
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.util.CompositeResult
|
||||||
|
import org.koitharu.kotatsu.core.util.progress.Progress
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import org.koitharu.kotatsu.reader.data.TapGridSettings
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@Reusable
|
||||||
|
class BackupRepository @Inject constructor(
|
||||||
|
private val database: MangaDatabase,
|
||||||
|
private val settings: AppSettings,
|
||||||
|
private val tapGridSettings: TapGridSettings,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
allowSpecialFloatingPointValues = true
|
||||||
|
coerceInputValues = true
|
||||||
|
encodeDefaults = true
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
useAlternativeNames = false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createBackup(
|
||||||
|
output: ZipOutputStream,
|
||||||
|
progress: FlowCollector<Progress>?,
|
||||||
|
) {
|
||||||
|
progress?.emit(Progress.INDETERMINATE)
|
||||||
|
var commonProgress = Progress(0, BackupSection.entries.size)
|
||||||
|
for (section in BackupSection.entries) {
|
||||||
|
when (section) {
|
||||||
|
BackupSection.INDEX -> output.writeJsonArray(
|
||||||
|
section = BackupSection.INDEX,
|
||||||
|
data = flowOf(BackupIndex()),
|
||||||
|
serializer = serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupSection.HISTORY -> output.writeJsonArray(
|
||||||
|
section = BackupSection.HISTORY,
|
||||||
|
data = database.getHistoryDao().dump().map { HistoryBackup(it) },
|
||||||
|
serializer = serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupSection.CATEGORIES -> output.writeJsonArray(
|
||||||
|
section = BackupSection.CATEGORIES,
|
||||||
|
data = database.getFavouriteCategoriesDao().findAll().asFlow().map { CategoryBackup(it) },
|
||||||
|
serializer = serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupSection.FAVOURITES -> output.writeJsonArray(
|
||||||
|
section = BackupSection.FAVOURITES,
|
||||||
|
data = database.getFavouritesDao().dump().map { FavouriteBackup(it) },
|
||||||
|
serializer = serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupSection.SETTINGS -> output.writeString(
|
||||||
|
section = BackupSection.SETTINGS,
|
||||||
|
data = dumpSettings(),
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupSection.SETTINGS_READER_GRID -> output.writeString(
|
||||||
|
section = BackupSection.SETTINGS_READER_GRID,
|
||||||
|
data = dumpReaderGridSettings(),
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupSection.BOOKMARKS -> output.writeJsonArray(
|
||||||
|
section = BackupSection.BOOKMARKS,
|
||||||
|
data = database.getBookmarksDao().dump().map { BookmarkBackup(it.first, it.second) },
|
||||||
|
serializer = serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupSection.SOURCES -> output.writeJsonArray(
|
||||||
|
section = BackupSection.SOURCES,
|
||||||
|
data = database.getSourcesDao().dumpEnabled().map { SourceBackup(it) },
|
||||||
|
serializer = serializer(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
progress?.emit(commonProgress)
|
||||||
|
commonProgress++
|
||||||
|
}
|
||||||
|
progress?.emit(commonProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun restoreBackup(
|
||||||
|
input: ZipInputStream,
|
||||||
|
sections: Set<BackupSection>,
|
||||||
|
progress: FlowCollector<Progress>?,
|
||||||
|
): CompositeResult {
|
||||||
|
progress?.emit(Progress.INDETERMINATE)
|
||||||
|
var commonProgress = Progress(0, sections.size)
|
||||||
|
var entry = input.nextEntry
|
||||||
|
var result = CompositeResult.EMPTY
|
||||||
|
while (entry != null) {
|
||||||
|
val section = BackupSection.of(entry)
|
||||||
|
if (section in sections) {
|
||||||
|
result = result + when (section) {
|
||||||
|
BackupSection.INDEX -> CompositeResult.EMPTY // useless in our case
|
||||||
|
BackupSection.HISTORY -> input.readJsonArray<HistoryBackup>(serializer()).restoreToDb {
|
||||||
|
upsertManga(it.manga)
|
||||||
|
getHistoryDao().upsert(it.toEntity())
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupSection.CATEGORIES -> input.readJsonArray<CategoryBackup>(serializer()).restoreToDb {
|
||||||
|
getFavouriteCategoriesDao().upsert(it.toEntity())
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupSection.FAVOURITES -> input.readJsonArray<FavouriteBackup>(serializer()).restoreToDb {
|
||||||
|
upsertManga(it.manga)
|
||||||
|
getFavouritesDao().upsert(it.toEntity())
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupSection.SETTINGS -> input.readMap().let {
|
||||||
|
settings.upsertAll(it)
|
||||||
|
CompositeResult.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupSection.SETTINGS_READER_GRID -> input.readMap().let {
|
||||||
|
tapGridSettings.upsertAll(it)
|
||||||
|
CompositeResult.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupSection.BOOKMARKS -> input.readJsonArray<BookmarkBackup>(serializer()).restoreToDb {
|
||||||
|
upsertManga(it.manga)
|
||||||
|
getBookmarksDao().upsert(it.bookmarks.map { b -> b.toEntity() })
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupSection.SOURCES -> input.readJsonArray<SourceBackup>(serializer()).restoreToDb {
|
||||||
|
getSourcesDao().upsert(it.toEntity())
|
||||||
|
}
|
||||||
|
|
||||||
|
null -> CompositeResult.EMPTY // skip unknown entries
|
||||||
|
}
|
||||||
|
progress?.emit(commonProgress)
|
||||||
|
commonProgress++
|
||||||
|
}
|
||||||
|
input.closeEntry()
|
||||||
|
entry = input.nextEntry
|
||||||
|
}
|
||||||
|
progress?.emit(commonProgress)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> ZipOutputStream.writeJsonArray(
|
||||||
|
section: BackupSection,
|
||||||
|
data: Flow<T>,
|
||||||
|
serializer: SerializationStrategy<T>,
|
||||||
|
) {
|
||||||
|
data.onStart {
|
||||||
|
putNextEntry(ZipEntry(section.entryName))
|
||||||
|
write("[")
|
||||||
|
}.onCompletion { error ->
|
||||||
|
if (error == null) {
|
||||||
|
write("]")
|
||||||
|
}
|
||||||
|
closeEntry()
|
||||||
|
flush()
|
||||||
|
}.collectIndexed { index, value ->
|
||||||
|
if (index > 0) {
|
||||||
|
write(",")
|
||||||
|
}
|
||||||
|
json.encodeToStream(serializer, value, this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> InputStream.readJsonArray(
|
||||||
|
serializer: DeserializationStrategy<T>,
|
||||||
|
): Sequence<T> = json.decodeToSequence(this, serializer, DecodeSequenceMode.ARRAY_WRAPPED)
|
||||||
|
|
||||||
|
private fun InputStream.readMap(): Map<String, Any?> {
|
||||||
|
val jo = JSONArray(readString()).getJSONObject(0)
|
||||||
|
val map = ArrayMap<String, Any?>(jo.length())
|
||||||
|
val keys = jo.keys()
|
||||||
|
while (keys.hasNext()) {
|
||||||
|
val key = keys.next()
|
||||||
|
map[key] = jo.get(key)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ZipOutputStream.writeString(
|
||||||
|
section: BackupSection,
|
||||||
|
data: String,
|
||||||
|
) {
|
||||||
|
putNextEntry(ZipEntry(section.entryName))
|
||||||
|
try {
|
||||||
|
write("[")
|
||||||
|
write(data)
|
||||||
|
write("]")
|
||||||
|
} finally {
|
||||||
|
closeEntry()
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun OutputStream.write(str: String) = write(str.toByteArray())
|
||||||
|
|
||||||
|
private fun InputStream.readString(): String = readBytes().decodeToString()
|
||||||
|
|
||||||
|
private fun dumpSettings(): String {
|
||||||
|
val map = settings.getAllValues().toMutableMap()
|
||||||
|
map.remove(AppSettings.KEY_APP_PASSWORD)
|
||||||
|
map.remove(AppSettings.KEY_PROXY_PASSWORD)
|
||||||
|
map.remove(AppSettings.KEY_PROXY_LOGIN)
|
||||||
|
map.remove(AppSettings.KEY_INCOGNITO_MODE)
|
||||||
|
return JSONObject(map).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dumpReaderGridSettings(): String {
|
||||||
|
return JSONObject(tapGridSettings.getAllValues()).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun MangaDatabase.upsertManga(manga: MangaBackup) {
|
||||||
|
val tags = manga.tags.map { it.toEntity() }
|
||||||
|
getTagsDao().upsert(tags)
|
||||||
|
getMangaDao().upsert(manga.toEntity(), tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun <T> Sequence<T>.restoreToDb(crossinline block: suspend MangaDatabase.(T) -> Unit): CompositeResult {
|
||||||
|
return fold(CompositeResult.EMPTY) { result, item ->
|
||||||
|
result + runCatchingCancellable {
|
||||||
|
database.withTransaction {
|
||||||
|
database.block(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class BackupIndex(
|
||||||
|
@SerialName("app_id") val appId: String,
|
||||||
|
@SerialName("app_version") val appVersion: Int,
|
||||||
|
@SerialName("created_at") val createdAt: Long,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor() : this(
|
||||||
|
appId = BuildConfig.APPLICATION_ID,
|
||||||
|
appVersion = BuildConfig.VERSION_CODE,
|
||||||
|
createdAt = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class BookmarkBackup(
|
||||||
|
@SerialName("manga") val manga: MangaBackup,
|
||||||
|
@SerialName("tags") val tags: Set<TagBackup>,
|
||||||
|
@SerialName("bookmarks") val bookmarks: List<Bookmark>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class Bookmark(
|
||||||
|
@SerialName("manga_id") val mangaId: Long,
|
||||||
|
@SerialName("page_id") val pageId: Long,
|
||||||
|
@SerialName("chapter_id") val chapterId: Long,
|
||||||
|
@SerialName("page") val page: Int,
|
||||||
|
@SerialName("scroll") val scroll: Int,
|
||||||
|
@SerialName("image_url") val imageUrl: String,
|
||||||
|
@SerialName("created_at") val createdAt: Long,
|
||||||
|
@SerialName("percent") val percent: Float,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun toEntity() = BookmarkEntity(
|
||||||
|
mangaId = mangaId,
|
||||||
|
pageId = pageId,
|
||||||
|
chapterId = chapterId,
|
||||||
|
page = page,
|
||||||
|
scroll = scroll,
|
||||||
|
imageUrl = imageUrl,
|
||||||
|
createdAt = createdAt,
|
||||||
|
percent = percent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(manga: MangaWithTags, entities: List<BookmarkEntity>) : this(
|
||||||
|
manga = MangaBackup(manga.copy(tags = emptyList())),
|
||||||
|
tags = manga.tags.mapToSet { TagBackup(it) },
|
||||||
|
bookmarks = entities.map {
|
||||||
|
Bookmark(
|
||||||
|
mangaId = it.mangaId,
|
||||||
|
pageId = it.pageId,
|
||||||
|
chapterId = it.chapterId,
|
||||||
|
page = it.page,
|
||||||
|
scroll = it.scroll,
|
||||||
|
imageUrl = it.imageUrl,
|
||||||
|
createdAt = it.createdAt,
|
||||||
|
percent = it.percent,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
||||||
|
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class CategoryBackup(
|
||||||
|
@SerialName("category_id") val categoryId: Int,
|
||||||
|
@SerialName("created_at") val createdAt: Long,
|
||||||
|
@SerialName("sort_key") val sortKey: Int,
|
||||||
|
@SerialName("title") val title: String,
|
||||||
|
@SerialName("order") val order: String = ListSortOrder.NEWEST.name,
|
||||||
|
@SerialName("track") val track: Boolean = true,
|
||||||
|
@SerialName("show_in_lib") val isVisibleInLibrary: Boolean = true,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(entity: FavouriteCategoryEntity) : this(
|
||||||
|
categoryId = entity.categoryId,
|
||||||
|
createdAt = entity.createdAt,
|
||||||
|
sortKey = entity.sortKey,
|
||||||
|
title = entity.title,
|
||||||
|
order = entity.order,
|
||||||
|
track = entity.track,
|
||||||
|
isVisibleInLibrary = entity.isVisibleInLibrary,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toEntity() = FavouriteCategoryEntity(
|
||||||
|
categoryId = categoryId,
|
||||||
|
createdAt = createdAt,
|
||||||
|
sortKey = sortKey,
|
||||||
|
title = title,
|
||||||
|
order = order,
|
||||||
|
track = track,
|
||||||
|
isVisibleInLibrary = isVisibleInLibrary,
|
||||||
|
deletedAt = 0L,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
||||||
|
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
||||||
|
import org.koitharu.kotatsu.favourites.data.FavouriteManga
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class FavouriteBackup(
|
||||||
|
@SerialName("manga_id") val mangaId: Long,
|
||||||
|
@SerialName("category_id") val categoryId: Long,
|
||||||
|
@SerialName("sort_key") val sortKey: Int = 0,
|
||||||
|
@SerialName("pinned") val isPinned: Boolean = false,
|
||||||
|
@SerialName("created_at") val createdAt: Long,
|
||||||
|
@SerialName("manga") val manga: MangaBackup,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(entity: FavouriteManga) : this(
|
||||||
|
mangaId = entity.manga.id,
|
||||||
|
categoryId = entity.favourite.categoryId,
|
||||||
|
sortKey = entity.favourite.sortKey,
|
||||||
|
isPinned = entity.favourite.isPinned,
|
||||||
|
createdAt = entity.favourite.createdAt,
|
||||||
|
manga = MangaBackup(MangaWithTags(entity.manga, entity.tags)),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toEntity() = FavouriteEntity(
|
||||||
|
mangaId = mangaId,
|
||||||
|
categoryId = categoryId,
|
||||||
|
sortKey = sortKey,
|
||||||
|
isPinned = isPinned,
|
||||||
|
createdAt = createdAt,
|
||||||
|
deletedAt = 0L,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
||||||
|
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||||
|
import org.koitharu.kotatsu.history.data.HistoryWithManga
|
||||||
|
import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class HistoryBackup(
|
||||||
|
@SerialName("manga_id") val mangaId: Long,
|
||||||
|
@SerialName("created_at") val createdAt: Long,
|
||||||
|
@SerialName("updated_at") val updatedAt: Long,
|
||||||
|
@SerialName("chapter_id") val chapterId: Long,
|
||||||
|
@SerialName("page") val page: Int,
|
||||||
|
@SerialName("scroll") val scroll: Float,
|
||||||
|
@SerialName("percent") val percent: Float = PROGRESS_NONE,
|
||||||
|
@SerialName("chapters") val chaptersCount: Int = 0,
|
||||||
|
@SerialName("manga") val manga: MangaBackup,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(entity: HistoryWithManga) : this(
|
||||||
|
mangaId = entity.manga.id,
|
||||||
|
createdAt = entity.history.createdAt,
|
||||||
|
updatedAt = entity.history.updatedAt,
|
||||||
|
chapterId = entity.history.chapterId,
|
||||||
|
page = entity.history.page,
|
||||||
|
scroll = entity.history.scroll,
|
||||||
|
percent = entity.history.percent,
|
||||||
|
chaptersCount = entity.history.chaptersCount,
|
||||||
|
manga = MangaBackup(MangaWithTags(entity.manga, entity.tags)),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toEntity() = HistoryEntity(
|
||||||
|
mangaId = mangaId,
|
||||||
|
createdAt = createdAt,
|
||||||
|
updatedAt = updatedAt,
|
||||||
|
chapterId = chapterId,
|
||||||
|
page = page,
|
||||||
|
scroll = scroll,
|
||||||
|
percent = percent,
|
||||||
|
deletedAt = 0L,
|
||||||
|
chaptersCount = chaptersCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
||||||
|
import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class MangaBackup(
|
||||||
|
@SerialName("id") val id: Long,
|
||||||
|
@SerialName("title") val title: String,
|
||||||
|
@SerialName("alt_title") val altTitles: String? = null,
|
||||||
|
@SerialName("url") val url: String,
|
||||||
|
@SerialName("public_url") val publicUrl: String,
|
||||||
|
@SerialName("rating") val rating: Float = RATING_UNKNOWN,
|
||||||
|
@SerialName("nsfw") val isNsfw: Boolean = false,
|
||||||
|
@SerialName("content_rating") val contentRating: String? = null,
|
||||||
|
@SerialName("cover_url") val coverUrl: String,
|
||||||
|
@SerialName("large_cover_url") val largeCoverUrl: String? = null,
|
||||||
|
@SerialName("state") val state: String? = null,
|
||||||
|
@SerialName("author") val authors: String? = null,
|
||||||
|
@SerialName("source") val source: String,
|
||||||
|
@SerialName("tags") val tags: Set<TagBackup> = emptySet(),
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(entity: MangaWithTags) : this(
|
||||||
|
id = entity.manga.id,
|
||||||
|
title = entity.manga.title,
|
||||||
|
altTitles = entity.manga.altTitles,
|
||||||
|
url = entity.manga.url,
|
||||||
|
publicUrl = entity.manga.publicUrl,
|
||||||
|
rating = entity.manga.rating,
|
||||||
|
isNsfw = entity.manga.isNsfw,
|
||||||
|
contentRating = entity.manga.contentRating,
|
||||||
|
coverUrl = entity.manga.coverUrl,
|
||||||
|
largeCoverUrl = entity.manga.largeCoverUrl,
|
||||||
|
state = entity.manga.state,
|
||||||
|
authors = entity.manga.authors,
|
||||||
|
source = entity.manga.source,
|
||||||
|
tags = entity.tags.mapToSet { TagBackup(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toEntity() = MangaEntity(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
altTitles = altTitles,
|
||||||
|
url = url,
|
||||||
|
publicUrl = publicUrl,
|
||||||
|
rating = rating,
|
||||||
|
isNsfw = isNsfw,
|
||||||
|
contentRating = contentRating,
|
||||||
|
coverUrl = coverUrl,
|
||||||
|
largeCoverUrl = largeCoverUrl,
|
||||||
|
state = state,
|
||||||
|
authors = authors,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class SourceBackup(
|
||||||
|
@SerialName("source") val source: String,
|
||||||
|
@SerialName("sort_key") val sortKey: Int,
|
||||||
|
@SerialName("used_at") val lastUsedAt: Long,
|
||||||
|
@SerialName("added_in") val addedIn: Int,
|
||||||
|
@SerialName("pinned") val isPinned: Boolean = false,
|
||||||
|
@SerialName("enabled") val isEnabled: Boolean = true, // for compatibility purposes, should be only true
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(entity: MangaSourceEntity) : this(
|
||||||
|
source = entity.source,
|
||||||
|
sortKey = entity.sortKey,
|
||||||
|
lastUsedAt = entity.lastUsedAt,
|
||||||
|
addedIn = entity.addedIn,
|
||||||
|
isPinned = entity.isPinned,
|
||||||
|
isEnabled = entity.isEnabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toEntity() = MangaSourceEntity(
|
||||||
|
source = source,
|
||||||
|
isEnabled = isEnabled,
|
||||||
|
sortKey = sortKey,
|
||||||
|
addedIn = addedIn,
|
||||||
|
lastUsedAt = lastUsedAt,
|
||||||
|
isPinned = isPinned,
|
||||||
|
cfState = 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class TagBackup(
|
||||||
|
@SerialName("id") val id: Long,
|
||||||
|
@SerialName("title") val title: String,
|
||||||
|
@SerialName("key") val key: String,
|
||||||
|
@SerialName("source") val source: String,
|
||||||
|
@SerialName("pinned") val isPinned: Boolean = false,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(entity: TagEntity) : this(
|
||||||
|
id = entity.id,
|
||||||
|
title = entity.title,
|
||||||
|
key = entity.key,
|
||||||
|
source = entity.source,
|
||||||
|
isPinned = entity.isPinned,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toEntity() = TagEntity(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
key = key,
|
||||||
|
source = source,
|
||||||
|
isPinned = isPinned,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.domain
|
||||||
|
|
||||||
|
import android.app.backup.BackupAgent
|
||||||
|
import android.app.backup.BackupDataInput
|
||||||
|
import android.app.backup.BackupDataOutput
|
||||||
|
import android.app.backup.FullBackupDataOutput
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import com.google.common.io.ByteStreams
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.koitharu.kotatsu.backups.data.BackupRepository
|
||||||
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.reader.data.TapGridSettings
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileDescriptor
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.util.EnumSet
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
|
class AppBackupAgent : BackupAgent() {
|
||||||
|
|
||||||
|
override fun onBackup(
|
||||||
|
oldState: ParcelFileDescriptor?,
|
||||||
|
data: BackupDataOutput?,
|
||||||
|
newState: ParcelFileDescriptor?
|
||||||
|
) = Unit
|
||||||
|
|
||||||
|
override fun onRestore(
|
||||||
|
data: BackupDataInput?,
|
||||||
|
appVersionCode: Int,
|
||||||
|
newState: ParcelFileDescriptor?
|
||||||
|
) = Unit
|
||||||
|
|
||||||
|
override fun onFullBackup(data: FullBackupDataOutput) {
|
||||||
|
super.onFullBackup(data)
|
||||||
|
val file =
|
||||||
|
createBackupFile(
|
||||||
|
this,
|
||||||
|
BackupRepository(
|
||||||
|
MangaDatabase(context = applicationContext),
|
||||||
|
AppSettings(applicationContext),
|
||||||
|
TapGridSettings(applicationContext),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
fullBackupFile(file, data)
|
||||||
|
} finally {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRestoreFile(
|
||||||
|
data: ParcelFileDescriptor,
|
||||||
|
size: Long,
|
||||||
|
destination: File?,
|
||||||
|
type: Int,
|
||||||
|
mode: Long,
|
||||||
|
mtime: Long
|
||||||
|
) {
|
||||||
|
if (destination?.name?.endsWith(".bk.zip") == true) {
|
||||||
|
restoreBackupFile(
|
||||||
|
data.fileDescriptor,
|
||||||
|
size,
|
||||||
|
BackupRepository(
|
||||||
|
database = MangaDatabase(applicationContext),
|
||||||
|
settings = AppSettings(applicationContext),
|
||||||
|
tapGridSettings = TapGridSettings(applicationContext),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
destination.delete()
|
||||||
|
} else {
|
||||||
|
super.onRestoreFile(data, size, destination, type, mode, mtime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
fun createBackupFile(context: Context, repository: BackupRepository): File {
|
||||||
|
val file = BackupUtils.createTempFile(context)
|
||||||
|
ZipOutputStream(file.outputStream()).use { output ->
|
||||||
|
runBlocking {
|
||||||
|
repository.createBackup(output, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
fun restoreBackupFile(fd: FileDescriptor, size: Long, repository: BackupRepository) {
|
||||||
|
ZipInputStream(ByteStreams.limit(FileInputStream(fd), size)).use { input ->
|
||||||
|
runBlocking {
|
||||||
|
repository.restoreBackup(input, EnumSet.allOf(BackupSection::class.java), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.domain
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
data class BackupFile(
|
||||||
|
val uri: Uri,
|
||||||
|
val dateTime: Date,
|
||||||
|
) : Comparable<BackupFile> {
|
||||||
|
|
||||||
|
override fun compareTo(other: BackupFile): Int = compareValues(dateTime, other.dateTime)
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.domain
|
||||||
|
|
||||||
|
import android.app.backup.BackupManager
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.InvalidationTracker
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
|
||||||
|
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES
|
||||||
|
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class BackupObserver @Inject constructor(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
) : InvalidationTracker.Observer(
|
||||||
|
arrayOf(
|
||||||
|
TABLE_HISTORY,
|
||||||
|
TABLE_FAVOURITES,
|
||||||
|
TABLE_FAVOURITE_CATEGORIES,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val backupManager = BackupManager(context)
|
||||||
|
|
||||||
|
override fun onInvalidated(tables: Set<String>) {
|
||||||
|
backupManager.dataChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.domain
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
|
||||||
|
enum class BackupSection(
|
||||||
|
val entryName: String,
|
||||||
|
) {
|
||||||
|
|
||||||
|
INDEX("index"),
|
||||||
|
HISTORY("history"),
|
||||||
|
CATEGORIES("categories"),
|
||||||
|
FAVOURITES("favourites"),
|
||||||
|
SETTINGS("settings"),
|
||||||
|
SETTINGS_READER_GRID("reader_grid"),
|
||||||
|
BOOKMARKS("bookmarks"),
|
||||||
|
SOURCES("sources"),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun of(entry: ZipEntry): BackupSection? {
|
||||||
|
val name = entry.name.lowercase(Locale.ROOT)
|
||||||
|
return entries.first { x -> x.entryName == name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.domain
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.CheckResult
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import java.io.File
|
||||||
|
import java.text.ParseException
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
object BackupUtils {
|
||||||
|
|
||||||
|
private const val DIR_BACKUPS = "backups"
|
||||||
|
private val dateTimeFormat = SimpleDateFormat("yyyyMMdd-HHmm")
|
||||||
|
|
||||||
|
@CheckResult
|
||||||
|
fun createTempFile(context: Context): File {
|
||||||
|
val dir = getAppBackupDir(context)
|
||||||
|
dir.mkdirs()
|
||||||
|
return File(dir, generateFileName(context))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAppBackupDir(context: Context) = context.run {
|
||||||
|
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseBackupDateTime(fileName: String): Date? = try {
|
||||||
|
dateTimeFormat.parse(fileName.substringAfterLast('_').substringBefore('.'))
|
||||||
|
} catch (e: ParseException) {
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun generateFileName(context: Context) = buildString {
|
||||||
|
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
||||||
|
append('_')
|
||||||
|
append(dateTimeFormat.format(Date()))
|
||||||
|
append(".bk.zip")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.domain
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.annotation.CheckResult
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import okio.buffer
|
||||||
|
import okio.sink
|
||||||
|
import okio.source
|
||||||
|
import org.jetbrains.annotations.Blocking
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class ExternalBackupStorage @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val settings: AppSettings,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun list(): List<BackupFile> = runInterruptible(Dispatchers.IO) {
|
||||||
|
getRootOrThrow().listFiles().mapNotNull {
|
||||||
|
if (it.isFile && it.canRead()) {
|
||||||
|
BackupFile(
|
||||||
|
uri = it.uri,
|
||||||
|
dateTime = it.name?.let { fileName ->
|
||||||
|
BackupUtils.parseBackupDateTime(fileName)
|
||||||
|
} ?: return@mapNotNull null,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.sortedDescending()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun listOrNull() = runCatchingCancellable {
|
||||||
|
list()
|
||||||
|
}.onFailure { e ->
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
suspend fun put(file: File): Uri = runInterruptible(Dispatchers.IO) {
|
||||||
|
val out = checkNotNull(
|
||||||
|
getRootOrThrow().createFile(
|
||||||
|
"application/zip",
|
||||||
|
file.nameWithoutExtension,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
"Cannot create target backup file"
|
||||||
|
}
|
||||||
|
checkNotNull(context.contentResolver.openOutputStream(out.uri, "wt")).sink().use { sink ->
|
||||||
|
file.source().buffer().use { src ->
|
||||||
|
src.readAll(sink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.uri
|
||||||
|
}
|
||||||
|
|
||||||
|
@CheckResult
|
||||||
|
suspend fun delete(victim: BackupFile) = runInterruptible(Dispatchers.IO) {
|
||||||
|
val df = DocumentFile.fromSingleUri(context, victim.uri)
|
||||||
|
df != null && df.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getLastBackupDate() = listOrNull()?.maxOfOrNull { it.dateTime }
|
||||||
|
|
||||||
|
suspend fun trim(maxCount: Int): Boolean {
|
||||||
|
if (maxCount == Int.MAX_VALUE) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val list = listOrNull()
|
||||||
|
if (list == null || list.size <= maxCount) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var result = false
|
||||||
|
for (i in maxCount until list.size) {
|
||||||
|
if (delete(list[i])) {
|
||||||
|
result = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
@Blocking
|
||||||
|
private fun getRootOrThrow(): DocumentFile {
|
||||||
|
val uri = checkNotNull(settings.periodicalBackupDirectory) {
|
||||||
|
"Backup directory is not specified"
|
||||||
|
}
|
||||||
|
val root = DocumentFile.fromTreeUri(context, uri)
|
||||||
|
return checkNotNull(root) { "Cannot obtain DocumentFile from $uri" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,136 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.app.NotificationChannelCompat
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.app.PendingIntentCompat
|
||||||
|
import androidx.core.app.ShareCompat
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
||||||
|
import org.koitharu.kotatsu.core.util.CompositeResult
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getFileDisplayName
|
||||||
|
import androidx.appcompat.R as appcompatR
|
||||||
|
|
||||||
|
abstract class BaseBackupRestoreService : CoroutineIntentService() {
|
||||||
|
|
||||||
|
protected abstract val notificationTag: String
|
||||||
|
protected abstract val isRestoreService: Boolean
|
||||||
|
|
||||||
|
protected lateinit var notificationManager: NotificationManagerCompat
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||||
|
createNotificationChannel(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun IntentJobContext.onError(error: Throwable) {
|
||||||
|
showResultNotification(null, CompositeResult.failure(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun IntentJobContext.showResultNotification(
|
||||||
|
fileUri: Uri?,
|
||||||
|
result: CompositeResult,
|
||||||
|
) {
|
||||||
|
if (!applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setSilent(true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setSubText(fileUri?.let { contentResolver.getFileDisplayName(it) })
|
||||||
|
when {
|
||||||
|
result.isAllSuccess -> {
|
||||||
|
if (isRestoreService) {
|
||||||
|
notification
|
||||||
|
.setContentTitle(getString(R.string.restoring_backup))
|
||||||
|
.setContentText(getString(R.string.data_restored_success))
|
||||||
|
} else {
|
||||||
|
notification
|
||||||
|
.setContentTitle(getString(R.string.backup_saved))
|
||||||
|
.setContentText(fileUri?.let { contentResolver.getFileDisplayName(it) })
|
||||||
|
.setSubText(null)
|
||||||
|
|
||||||
|
}
|
||||||
|
notification.setSmallIcon(R.drawable.ic_stat_done)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.isAllFailed || !isRestoreService -> {
|
||||||
|
val title = getString(if (isRestoreService) R.string.data_not_restored else R.string.error_occurred)
|
||||||
|
val message = result.failures.joinToString("\n") {
|
||||||
|
it.getDisplayMessage(applicationContext.resources)
|
||||||
|
}
|
||||||
|
notification
|
||||||
|
.setContentText(if (isRestoreService) getString(R.string.data_not_restored_text) else message)
|
||||||
|
.setBigText(title, message)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
|
result.failures.firstNotNullOfOrNull { error ->
|
||||||
|
ErrorReporterReceiver.getNotificationAction(applicationContext, error, startId, notificationTag)
|
||||||
|
}?.let { action ->
|
||||||
|
notification.addAction(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
notification
|
||||||
|
.setContentTitle(getString(R.string.restoring_backup))
|
||||||
|
.setContentText(getString(R.string.data_restored_with_errors))
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notification.setContentIntent(
|
||||||
|
PendingIntentCompat.getActivity(
|
||||||
|
applicationContext,
|
||||||
|
0,
|
||||||
|
AppRouter.homeIntent(this@BaseBackupRestoreService),
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!isRestoreService && fileUri != null) {
|
||||||
|
val shareIntent = ShareCompat.IntentBuilder(this@BaseBackupRestoreService)
|
||||||
|
.setStream(fileUri)
|
||||||
|
.setType("application/zip")
|
||||||
|
.setChooserTitle(R.string.share_backup)
|
||||||
|
.createChooserIntent()
|
||||||
|
notification.addAction(
|
||||||
|
appcompatR.drawable.abc_ic_menu_share_mtrl_alpha,
|
||||||
|
getString(R.string.share),
|
||||||
|
PendingIntentCompat.getActivity(this@BaseBackupRestoreService, 0, shareIntent, 0, false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
notificationManager.notify(notificationTag, startId, notification.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun NotificationCompat.Builder.setBigText(title: String, text: CharSequence) = setStyle(
|
||||||
|
NotificationCompat.BigTextStyle()
|
||||||
|
.bigText(text)
|
||||||
|
.setSummaryText(text)
|
||||||
|
.setBigContentTitle(title),
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
const val CHANNEL_ID = "backup_restore"
|
||||||
|
|
||||||
|
fun createNotificationChannel(context: Context) {
|
||||||
|
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_HIGH)
|
||||||
|
.setName(context.getString(R.string.backup_restore))
|
||||||
|
.setShowBadge(true)
|
||||||
|
.setVibrationEnabled(false)
|
||||||
|
.setSound(null, null)
|
||||||
|
.setLightsEnabled(false)
|
||||||
|
.build()
|
||||||
|
NotificationManagerCompat.from(context).createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.backup
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||||
|
import org.koitharu.kotatsu.core.util.progress.Progress
|
||||||
|
import org.koitharu.kotatsu.databinding.DialogProgressBinding
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
|
||||||
|
|
||||||
|
private val viewModel by viewModels<BackupViewModel>()
|
||||||
|
|
||||||
|
override fun onCreateViewBinding(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
) = DialogProgressBinding.inflate(inflater, container, false)
|
||||||
|
|
||||||
|
override fun onViewBindingCreated(binding: DialogProgressBinding, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewBindingCreated(binding, savedInstanceState)
|
||||||
|
binding.textViewTitle.setText(R.string.create_backup)
|
||||||
|
binding.textViewSubtitle.setText(R.string.processing_)
|
||||||
|
|
||||||
|
viewModel.progress.observe(viewLifecycleOwner, this::onProgressChanged)
|
||||||
|
viewModel.onBackupDone.observeEvent(viewLifecycleOwner, this::onBackupDone)
|
||||||
|
viewModel.onError.observeEvent(viewLifecycleOwner, this::onError)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
|
||||||
|
return super.onBuildDialog(builder)
|
||||||
|
.setCancelable(false)
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onError(e: Throwable) {
|
||||||
|
MaterialAlertDialogBuilder(context ?: return)
|
||||||
|
.setNegativeButton(R.string.close, null)
|
||||||
|
.setTitle(R.string.error)
|
||||||
|
.setMessage(e.getDisplayMessage(resources))
|
||||||
|
.show()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onProgressChanged(value: Progress) {
|
||||||
|
with(requireViewBinding().progressBar) {
|
||||||
|
isVisible = true
|
||||||
|
val wasIndeterminate = isIndeterminate
|
||||||
|
isIndeterminate = value.isIndeterminate
|
||||||
|
if (!value.isIndeterminate) {
|
||||||
|
max = value.total
|
||||||
|
setProgressCompat(value.progress, !wasIndeterminate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onBackupDone(uri: Uri) {
|
||||||
|
Toast.makeText(requireContext(), R.string.backup_saved, Toast.LENGTH_SHORT).show()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,131 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.backup
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Notification
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.net.Uri
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.annotation.CheckResult
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.backups.data.BackupRepository
|
||||||
|
import org.koitharu.kotatsu.backups.ui.BaseBackupRestoreService
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.util.CompositeResult
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.powerManager
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock
|
||||||
|
import org.koitharu.kotatsu.core.util.progress.Progress
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
import androidx.appcompat.R as appcompatR
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
@SuppressLint("InlinedApi")
|
||||||
|
class BackupService : BaseBackupRestoreService() {
|
||||||
|
|
||||||
|
override val notificationTag = TAG
|
||||||
|
override val isRestoreService = false
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var repository: BackupRepository
|
||||||
|
|
||||||
|
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
||||||
|
val notification = buildNotification(Progress.INDETERMINATE)
|
||||||
|
setForeground(
|
||||||
|
FOREGROUND_NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||||
|
)
|
||||||
|
val destination = intent.getStringExtra(AppRouter.KEY_DATA)?.toUriOrNull() ?: throw FileNotFoundException()
|
||||||
|
powerManager.withPartialWakeLock(TAG) {
|
||||||
|
val progress = MutableStateFlow(Progress.INDETERMINATE)
|
||||||
|
val progressUpdateJob = if (checkNotificationPermission(CHANNEL_ID)) {
|
||||||
|
launch {
|
||||||
|
progress.collect {
|
||||||
|
notificationManager.notify(FOREGROUND_NOTIFICATION_ID, buildNotification(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ZipOutputStream(contentResolver.openOutputStream(destination)).use { output ->
|
||||||
|
repository.createBackup(output, progress)
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
try {
|
||||||
|
DocumentFile.fromSingleUri(applicationContext, destination)?.delete()
|
||||||
|
} catch (e2: Throwable) {
|
||||||
|
e.addSuppressed(e2)
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
progressUpdateJob?.cancelAndJoin()
|
||||||
|
contentResolver.notifyChange(destination, null)
|
||||||
|
showResultNotification(destination, CompositeResult.success())
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(this@BackupService, R.string.backup_saved, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun IntentJobContext.buildNotification(progress: Progress): Notification {
|
||||||
|
return NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setContentTitle(getString(R.string.creating_backup))
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setSilent(true)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setProgress(
|
||||||
|
progress.total.coerceAtLeast(0),
|
||||||
|
progress.progress.coerceAtLeast(0),
|
||||||
|
progress.isIndeterminate,
|
||||||
|
)
|
||||||
|
.setContentText(
|
||||||
|
if (progress.isIndeterminate) {
|
||||||
|
getString(R.string.processing_)
|
||||||
|
} else {
|
||||||
|
getString(R.string.fraction_pattern, progress.progress, progress.total)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_upload)
|
||||||
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
|
.addAction(
|
||||||
|
appcompatR.drawable.abc_ic_clear_material,
|
||||||
|
applicationContext.getString(android.R.string.cancel),
|
||||||
|
getCancelIntent(),
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val TAG = "BACKUP"
|
||||||
|
private const val FOREGROUND_NOTIFICATION_ID = 33
|
||||||
|
|
||||||
|
@CheckResult
|
||||||
|
fun start(context: Context, uri: Uri): Boolean = try {
|
||||||
|
val intent = Intent(context, BackupService::class.java)
|
||||||
|
intent.putExtra(AppRouter.KEY_DATA, uri.toString())
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.backup
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import org.koitharu.kotatsu.backups.data.BackupRepository
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
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.core.util.progress.Progress
|
||||||
|
import java.util.zip.Deflater
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class BackupViewModel @Inject constructor(
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
private val repository: BackupRepository,
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
val progress = MutableStateFlow(Progress.INDETERMINATE)
|
||||||
|
val onBackupDone = MutableEventFlow<Uri>()
|
||||||
|
|
||||||
|
private val destination = savedStateHandle.require<Uri>(AppRouter.KEY_DATA)
|
||||||
|
private val contentResolver: ContentResolver = context.contentResolver
|
||||||
|
|
||||||
|
init {
|
||||||
|
launchLoadingJob(Dispatchers.Default) {
|
||||||
|
ZipOutputStream(checkNotNull(contentResolver.openOutputStream(destination))).use {
|
||||||
|
it.setLevel(Deflater.BEST_COMPRESSION)
|
||||||
|
repository.createBackup(it, progress)
|
||||||
|
}
|
||||||
|
onBackupDone.call(destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,105 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.periodical
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.app.PendingIntentCompat
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.backups.data.BackupRepository
|
||||||
|
import org.koitharu.kotatsu.backups.domain.BackupUtils
|
||||||
|
import org.koitharu.kotatsu.backups.domain.ExternalBackupStorage
|
||||||
|
import org.koitharu.kotatsu.backups.ui.BaseBackupRestoreService
|
||||||
|
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class PeriodicalBackupService : CoroutineIntentService() {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var externalBackupStorage: ExternalBackupStorage
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var telegramBackupUploader: TelegramBackupUploader
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var repository: BackupRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var settings: AppSettings
|
||||||
|
|
||||||
|
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
||||||
|
if (!settings.isPeriodicalBackupEnabled || settings.periodicalBackupDirectory == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val lastBackupDate = externalBackupStorage.getLastBackupDate()
|
||||||
|
if (lastBackupDate != null && lastBackupDate.time + settings.periodicalBackupFrequencyMillis > System.currentTimeMillis()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val output = BackupUtils.createTempFile(applicationContext)
|
||||||
|
try {
|
||||||
|
ZipOutputStream(output.outputStream()).use {
|
||||||
|
repository.createBackup(it, null)
|
||||||
|
}
|
||||||
|
externalBackupStorage.put(output)
|
||||||
|
externalBackupStorage.trim(settings.periodicalBackupMaxCount)
|
||||||
|
if (settings.isBackupTelegramUploadEnabled && telegramBackupUploader.isAvailable) {
|
||||||
|
telegramBackupUploader.uploadBackup(output)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
output.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun IntentJobContext.onError(error: Throwable) {
|
||||||
|
if (!applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
BaseBackupRestoreService.createNotificationChannel(applicationContext)
|
||||||
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setSilent(true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
val title = getString(R.string.periodic_backups)
|
||||||
|
val message = getString(
|
||||||
|
R.string.inline_preference_pattern,
|
||||||
|
getString(R.string.packup_creation_failed),
|
||||||
|
error.getDisplayMessage(resources),
|
||||||
|
)
|
||||||
|
notification
|
||||||
|
.setContentText(message)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
|
.setStyle(
|
||||||
|
NotificationCompat.BigTextStyle()
|
||||||
|
.bigText(message)
|
||||||
|
.setSummaryText(getString(R.string.packup_creation_failed))
|
||||||
|
.setBigContentTitle(title),
|
||||||
|
)
|
||||||
|
ErrorReporterReceiver.getNotificationAction(applicationContext, error, startId, TAG)?.let { action ->
|
||||||
|
notification.addAction(action)
|
||||||
|
}
|
||||||
|
notification.setContentIntent(
|
||||||
|
PendingIntentCompat.getActivity(
|
||||||
|
applicationContext,
|
||||||
|
0,
|
||||||
|
AppRouter.periodicBackupSettingsIntent(applicationContext),
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
NotificationManagerCompat.from(applicationContext).notify(TAG, startId, notification.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
|
||||||
|
const val CHANNEL_ID = BaseBackupRestoreService.CHANNEL_ID
|
||||||
|
const val TAG = "periodical_backup"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,107 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.periodical
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import android.view.View
|
||||||
|
import androidx.activity.result.ActivityResultCallback
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.preference.EditTextPreference
|
||||||
|
import androidx.preference.Preference
|
||||||
|
import androidx.preference.PreferenceCategory
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||||
|
import org.koitharu.kotatsu.core.nav.router
|
||||||
|
import org.koitharu.kotatsu.core.os.OpenDocumentTreeHelper
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.tryLaunch
|
||||||
|
import org.koitharu.kotatsu.settings.utils.EditTextFallbackSummaryProvider
|
||||||
|
import java.util.Date
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodic_backups),
|
||||||
|
ActivityResultCallback<Uri?> {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var telegramBackupUploader: TelegramBackupUploader
|
||||||
|
|
||||||
|
private val viewModel by viewModels<PeriodicalBackupSettingsViewModel>()
|
||||||
|
|
||||||
|
private val outputSelectCall = OpenDocumentTreeHelper(this, this)
|
||||||
|
|
||||||
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
|
addPreferencesFromResource(R.xml.pref_backup_periodic)
|
||||||
|
findPreference<PreferenceCategory>(AppSettings.KEY_BACKUP_TG)?.isVisible = viewModel.isTelegramAvailable
|
||||||
|
findPreference<EditTextPreference>(AppSettings.KEY_BACKUP_TG_CHAT)?.summaryProvider =
|
||||||
|
EditTextFallbackSummaryProvider(R.string.telegram_chat_id_summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
viewModel.lastBackupDate.observe(viewLifecycleOwner, ::bindLastBackupInfo)
|
||||||
|
viewModel.backupsDirectory.observe(viewLifecycleOwner, ::bindOutputSummary)
|
||||||
|
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this))
|
||||||
|
viewModel.isTelegramCheckLoading.observe(viewLifecycleOwner) {
|
||||||
|
findPreference<Preference>(AppSettings.KEY_BACKUP_TG_TEST)?.isEnabled = !it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||||
|
val result = when (preference.key) {
|
||||||
|
AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT -> outputSelectCall.tryLaunch(null)
|
||||||
|
AppSettings.KEY_BACKUP_TG_OPEN -> telegramBackupUploader.openBotInApp(router)
|
||||||
|
AppSettings.KEY_BACKUP_TG_TEST -> {
|
||||||
|
viewModel.checkTelegram()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> return super.onPreferenceTreeClick(preference)
|
||||||
|
}
|
||||||
|
if (!result) {
|
||||||
|
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityResult(result: Uri?) {
|
||||||
|
if (result != null) {
|
||||||
|
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
|
context?.contentResolver?.takePersistableUriPermission(result, takeFlags)
|
||||||
|
settings.periodicalBackupDirectory = result
|
||||||
|
viewModel.updateSummaryData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindOutputSummary(path: String?) {
|
||||||
|
val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT) ?: return
|
||||||
|
preference.summary = when (path) {
|
||||||
|
null -> getString(R.string.invalid_value_message)
|
||||||
|
"" -> null
|
||||||
|
else -> path
|
||||||
|
}
|
||||||
|
preference.icon = if (path == null) {
|
||||||
|
getWarningIcon()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindLastBackupInfo(lastBackupDate: Date?) {
|
||||||
|
val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_LAST) ?: return
|
||||||
|
preference.summary = lastBackupDate?.let {
|
||||||
|
preference.context.getString(
|
||||||
|
R.string.last_successful_backup,
|
||||||
|
DateUtils.getRelativeTimeSpanString(it.time),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
preference.isVisible = lastBackupDate != null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.periodical
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.backups.domain.BackupUtils
|
||||||
|
import org.koitharu.kotatsu.backups.domain.ExternalBackupStorage
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
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.core.util.ext.resolveFile
|
||||||
|
import java.util.Date
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class PeriodicalBackupSettingsViewModel @Inject constructor(
|
||||||
|
private val settings: AppSettings,
|
||||||
|
private val telegramUploader: TelegramBackupUploader,
|
||||||
|
private val backupStorage: ExternalBackupStorage,
|
||||||
|
@ApplicationContext private val appContext: Context,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
val isTelegramAvailable
|
||||||
|
get() = telegramUploader.isAvailable
|
||||||
|
|
||||||
|
val lastBackupDate = MutableStateFlow<Date?>(null)
|
||||||
|
val backupsDirectory = MutableStateFlow<String?>("")
|
||||||
|
val isTelegramCheckLoading = MutableStateFlow(false)
|
||||||
|
val onActionDone = MutableEventFlow<ReversibleAction>()
|
||||||
|
|
||||||
|
init {
|
||||||
|
updateSummaryData()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun checkTelegram() {
|
||||||
|
launchJob(Dispatchers.Default) {
|
||||||
|
try {
|
||||||
|
isTelegramCheckLoading.value = true
|
||||||
|
telegramUploader.sendTestMessage()
|
||||||
|
onActionDone.call(ReversibleAction(R.string.connection_ok, null))
|
||||||
|
} finally {
|
||||||
|
isTelegramCheckLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSummaryData() {
|
||||||
|
updateBackupsDirectory()
|
||||||
|
updateLastBackupDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateBackupsDirectory() = launchJob(Dispatchers.Default) {
|
||||||
|
val dir = settings.periodicalBackupDirectory
|
||||||
|
backupsDirectory.value = if (dir != null) {
|
||||||
|
dir.toUserFriendlyString()
|
||||||
|
} else {
|
||||||
|
BackupUtils.getAppBackupDir(appContext).path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateLastBackupDate() = launchJob(Dispatchers.Default) {
|
||||||
|
lastBackupDate.value = backupStorage.getLastBackupDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Uri.toUserFriendlyString(): String? {
|
||||||
|
val df = DocumentFile.fromTreeUri(appContext, this)
|
||||||
|
if (df?.canWrite() != true) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return resolveFile(appContext)?.path ?: toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.periodical
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.CheckResult
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.internal.closeQuietly
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.network.BaseHttpClient
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.parsers.util.await
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
||||||
|
import org.koitharu.kotatsu.parsers.util.parseJson
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class TelegramBackupUploader @Inject constructor(
|
||||||
|
private val settings: AppSettings,
|
||||||
|
@BaseHttpClient private val client: OkHttpClient,
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val botToken = context.getString(R.string.tg_backup_bot_token)
|
||||||
|
|
||||||
|
val isAvailable: Boolean
|
||||||
|
get() = botToken.isNotEmpty()
|
||||||
|
|
||||||
|
suspend fun uploadBackup(file: File) {
|
||||||
|
val requestBody = file.asRequestBody("application/zip".toMediaTypeOrNull())
|
||||||
|
val multipartBody = MultipartBody.Builder()
|
||||||
|
.setType(MultipartBody.Companion.FORM)
|
||||||
|
.addFormDataPart("chat_id", requireChatId())
|
||||||
|
.addFormDataPart("document", file.name, requestBody)
|
||||||
|
.build()
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(urlOf("sendDocument").build())
|
||||||
|
.post(multipartBody)
|
||||||
|
.build()
|
||||||
|
client.newCall(request).await().consume()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendTestMessage() {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(urlOf("getMe").build())
|
||||||
|
.build()
|
||||||
|
client.newCall(request).await().consume()
|
||||||
|
sendMessage(context.getString(R.string.backup_tg_echo))
|
||||||
|
}
|
||||||
|
|
||||||
|
@CheckResult
|
||||||
|
fun openBotInApp(router: AppRouter): Boolean {
|
||||||
|
val botUsername = context.getString(R.string.tg_backup_bot_name)
|
||||||
|
return router.openExternalBrowser("tg://resolve?domain=$botUsername") ||
|
||||||
|
router.openExternalBrowser("https://t.me/$botUsername")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendMessage(message: String) {
|
||||||
|
val url = urlOf("sendMessage")
|
||||||
|
.addQueryParameter("chat_id", requireChatId())
|
||||||
|
.addQueryParameter("text", message)
|
||||||
|
.build()
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.build()
|
||||||
|
client.newCall(request).await().consume()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireChatId() = checkNotNull(settings.backupTelegramChatId) {
|
||||||
|
"Telegram chat ID not set in settings"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Response.consume() {
|
||||||
|
if (isSuccessful) {
|
||||||
|
closeQuietly()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val jo = parseJson()
|
||||||
|
if (!jo.getBooleanOrDefault("ok", true)) {
|
||||||
|
throw RuntimeException(jo.getStringOrNull("description"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun urlOf(method: String) = HttpUrl.Builder()
|
||||||
|
.scheme("https")
|
||||||
|
.host("api.telegram.org")
|
||||||
|
.addPathSegment("bot$botToken")
|
||||||
|
.addPathSegment(method)
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.restore
|
||||||
|
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||||
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.setChecked
|
||||||
|
import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
|
||||||
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_CHECKED_CHANGED
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
|
|
||||||
|
class BackupSectionsAdapter(
|
||||||
|
clickListener: OnListItemClickListener<BackupSectionModel>,
|
||||||
|
) : BaseListAdapter<BackupSectionModel>() {
|
||||||
|
|
||||||
|
init {
|
||||||
|
addDelegate(ListItemType.NAV_ITEM, backupSectionAD(clickListener))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun backupSectionAD(
|
||||||
|
clickListener: OnListItemClickListener<BackupSectionModel>,
|
||||||
|
) = adapterDelegateViewBinding<BackupSectionModel, BackupSectionModel, ItemCheckableMultipleBinding>(
|
||||||
|
{ layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
|
||||||
|
) {
|
||||||
|
|
||||||
|
binding.root.setOnClickListener { v ->
|
||||||
|
clickListener.onItemClick(item, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
bind { payloads ->
|
||||||
|
with(binding.root) {
|
||||||
|
setText(item.titleResId)
|
||||||
|
setChecked(item.isChecked, PAYLOAD_CHECKED_CHANGED in payloads)
|
||||||
|
isEnabled = item.isEnabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.restore
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.backups.domain.BackupSection
|
||||||
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
|
||||||
|
data class BackupSectionModel(
|
||||||
|
val section: BackupSection,
|
||||||
|
val isChecked: Boolean,
|
||||||
|
val isEnabled: Boolean,
|
||||||
|
) : ListModel {
|
||||||
|
|
||||||
|
@get:StringRes
|
||||||
|
val titleResId: Int
|
||||||
|
get() = when (section) {
|
||||||
|
BackupSection.INDEX -> 0 // should not appear here
|
||||||
|
BackupSection.HISTORY -> R.string.history
|
||||||
|
BackupSection.CATEGORIES -> R.string.favourites_categories
|
||||||
|
BackupSection.FAVOURITES -> R.string.favourites
|
||||||
|
BackupSection.SETTINGS -> R.string.settings
|
||||||
|
BackupSection.SETTINGS_READER_GRID -> R.string.reader_actions
|
||||||
|
BackupSection.BOOKMARKS -> R.string.bookmarks
|
||||||
|
BackupSection.SOURCES -> R.string.remote_sources
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||||
|
return other is BackupSectionModel && other.section == section
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getChangePayload(previousState: ListModel): Any? {
|
||||||
|
if (previousState !is BackupSectionModel) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return if (previousState.isEnabled != isEnabled) {
|
||||||
|
ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED
|
||||||
|
} else if (previousState.isChecked != isChecked) {
|
||||||
|
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
|
||||||
|
} else {
|
||||||
|
super.getChangePayload(previousState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,118 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.restore
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.view.isGone
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.nav.router
|
||||||
|
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||||
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||||
|
import org.koitharu.kotatsu.databinding.DialogRestoreBinding
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnListItemClickListener<BackupSectionModel>,
|
||||||
|
View.OnClickListener {
|
||||||
|
|
||||||
|
private val viewModel: RestoreViewModel by viewModels()
|
||||||
|
|
||||||
|
override fun onCreateViewBinding(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
) = DialogRestoreBinding.inflate(inflater, container, false)
|
||||||
|
|
||||||
|
override fun onViewBindingCreated(binding: DialogRestoreBinding, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewBindingCreated(binding, savedInstanceState)
|
||||||
|
val adapter = BackupSectionsAdapter(this)
|
||||||
|
binding.recyclerView.adapter = adapter
|
||||||
|
binding.buttonCancel.setOnClickListener(this)
|
||||||
|
binding.buttonRestore.setOnClickListener(this)
|
||||||
|
viewModel.availableEntries.observe(viewLifecycleOwner, adapter)
|
||||||
|
viewModel.onError.observeEvent(viewLifecycleOwner, this::onError)
|
||||||
|
combine(
|
||||||
|
viewModel.isLoading,
|
||||||
|
viewModel.availableEntries,
|
||||||
|
viewModel.backupDate,
|
||||||
|
::Triple,
|
||||||
|
).observe(viewLifecycleOwner, this::onLoadingChanged)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
|
||||||
|
return super.onBuildDialog(builder)
|
||||||
|
.setTitle(R.string.restore_backup)
|
||||||
|
.setCancelable(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick(v: View) {
|
||||||
|
when (v.id) {
|
||||||
|
R.id.button_cancel -> dismiss()
|
||||||
|
R.id.button_restore -> {
|
||||||
|
if (startRestoreService()) {
|
||||||
|
Toast.makeText(v.context, R.string.backup_restored_background, Toast.LENGTH_SHORT).show()
|
||||||
|
router.closeWelcomeSheet()
|
||||||
|
dismiss()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(v.context, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onItemClick(item: BackupSectionModel, view: View) {
|
||||||
|
viewModel.onItemClick(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onLoadingChanged(value: Triple<Boolean, List<BackupSectionModel>, Date?>) {
|
||||||
|
val (isLoading, entries, backupDate) = value
|
||||||
|
val hasEntries = entries.isNotEmpty()
|
||||||
|
with(requireViewBinding()) {
|
||||||
|
progressBar.isVisible = isLoading
|
||||||
|
recyclerView.isGone = isLoading
|
||||||
|
textViewSubtitle.textAndVisible =
|
||||||
|
when {
|
||||||
|
!isLoading -> backupDate?.formatBackupDate()
|
||||||
|
hasEntries -> getString(R.string.processing_)
|
||||||
|
else -> getString(R.string.loading_)
|
||||||
|
}
|
||||||
|
buttonRestore.isEnabled = !isLoading && entries.any { it.isChecked }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startRestoreService(): Boolean {
|
||||||
|
return RestoreService.start(
|
||||||
|
context ?: return false,
|
||||||
|
viewModel.uri ?: return false,
|
||||||
|
viewModel.getCheckedSections(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Date.formatBackupDate(): String {
|
||||||
|
return getString(
|
||||||
|
R.string.backup_date_,
|
||||||
|
SimpleDateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(this),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onError(e: Throwable) {
|
||||||
|
MaterialAlertDialogBuilder(context ?: return)
|
||||||
|
.setNegativeButton(R.string.close, null)
|
||||||
|
.setTitle(R.string.error)
|
||||||
|
.setMessage(e.getDisplayMessage(resources))
|
||||||
|
.show()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue