Compare commits

..

4 Commits

Author SHA1 Message Date
Koitharu
0153e90bf0 Fix build 2025-01-22 20:35:51 +02:00
Koitharu
d4f8fe83f5 Release signing config 2025-01-22 20:27:30 +02:00
Koitharu
d28b1e4094 Release build workflow 2025-01-22 20:22:03 +02:00
Koitharu
cd2de0136a Support for dynamic version 2025-01-22 20:22:02 +02:00
956 changed files with 14086 additions and 28213 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

114
.github/workflows/auto_release.yml vendored Normal file
View File

@@ -0,0 +1,114 @@
name: Build automatic release
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-24.04
outputs:
should_build: ${{ steps.check-updates.outputs.has_changes }}
steps:
- name: Check for updates 🌏
id: check-updates
run: |
last_run=$(curl -s "https://api.github.com/repos/${{ github.repository }}/releases/latest" | jq -r '.created_at')
kotatsu_updated=$(curl -s "https://api.github.com/repos/KotatsuApp/Kotatsu/commits?since=$last_run" | jq '. | length')
parsers_updated=$(curl -s "https://api.github.com/repos/KotatsuApp/kotatsu-parsers/commits?since=$last_run" | jq '. | length')
if [ "$kotatsu_updated" -gt "0" ] || [ "$parsers_updated" -gt "0" ]; then
echo "has_changes=true" >> $GITHUB_OUTPUT
else
echo "has_changes=false" >> $GITHUB_OUTPUT
fi
build:
needs: check
if: needs.check.outputs.should_build == 'true'
runs-on: ubuntu-24.04
outputs:
new_tag: ${{ steps.tagger.outputs.new_tag }}
steps:
- uses: actions/checkout@v3
with:
ref: autobuild
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
cache: 'gradle'
- name: Setup Android SDK 💻
uses: android-actions/setup-android@v3
- name: Grant permissions 💻
run: chmod a+x gradlew
- name: Generate build number 📆
id: tagger
run: |
echo "new_tag=$(./gradlew -q versionInfo -DbuildNumberIncrement=true)" >> $GITHUB_OUTPUT
echo "formatted_date=$(date +'%Y/%m/%d')" >> $GITHUB_OUTPUT
- name: Decode Keystore
id: decode_keystore
uses: timheuer/base64-to-file@v1
with:
fileName: 'keystore/kotatsu.jks'
encodedString: ${{ secrets.ANDROID_SIGNING_KEY }}
- name: Building new APK 💻
run: >-
./gradlew assembleRelease
-DparsersVersionOverride=$(curl -s https://api.github.com/repos/kotatsuapp/kotatsu-parsers/commits/master -H "Accept: application/vnd.github.sha" | cut -c -10)
- name: Prepare to Upload 🌏
run: |
mv ${{steps.sign_app.outputs.signedFile}} app/build/outputs/apk/release/release.apk
echo "SIGNED_APK=app/build/outputs/apk/release/release.apk" >> $GITHUB_ENV
- name: Get latest changes 📑
id: changelog
run: |
CHANGELOG=$(cat CHANGELOG.txt)
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CHANGELOG" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create new GH Release + Uploading 🌏
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.tagger.outputs.new_tag }}
name: "Build ${{ steps.tagger.outputs.new_tag }}"
body: |
Automated build generated on ${{ steps.tagger.outputs.formatted_date }}
${{ steps.changelog.outputs.content }}
files: ${{ env.SIGNED_APK }}
prerelease: false
update:
needs: build
if: needs.check.outputs.should_build == 'true'
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@v3
with:
ref: autobuild
fetch-depth: 0
- name: Commit 🌏
run: |
git config --local user.email "autorelease@users.noreply.github.com"
git config --local user.name "autorelease"
if [[ -n $(git status -s) ]]; then
git add README.md
git commit -m "Automatic release v${{ needs.build.outputs.new_tag }}"
git push origin autobuild
else
echo "No changes to push!"
fi

View File

@@ -1,16 +0,0 @@
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

2
.gitignore vendored
View File

@@ -26,4 +26,4 @@
.cxx .cxx
/.idea/deviceManager.xml /.idea/deviceManager.xml
/.kotlin/ /.kotlin/
/.idea/AndroidProjectSystem.xml /.idea/AndroidProjectSystem.xml

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

3
.idea/gradle.xml generated
View File

@@ -6,13 +6,14 @@
<GradleProjectSettings> <GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" /> <option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="jbr-21" /> <option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" /> <option value="$PROJECT_DIR$/app" />
</set> </set>
</option> </option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings> </GradleProjectSettings>
</option> </option>
</component> </component>

2
.idea/vcs.xml generated
View File

@@ -10,6 +10,6 @@
</option> </option>
</component> </component>
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" /> <mapping directory="$PROJECT_DIR$" vcs="Git" />
</component> </component>
</project> </project>

118
README.md
View File

@@ -1,117 +1,57 @@
<div align="center"> # Kotatsu
<a href="https://kotatsu.app"> Kotatsu is a free and open-source manga reader for Android with built-in online content sources.
<img src="./.github/assets/vtuber.png" alt="Kotatsu Logo" title="Kotatsu" width="600"/>
</a>
# [Kotatsu](https://kotatsu.app) [![Sources count](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2FKotatsuApp%2Fkotatsu-parsers%2Frefs%2Fheads%2Fmaster%2F.github%2Fsummary.yaml&query=total&label=manga%20sources&color=%23E9321C)](https://github.com/KotatsuApp/kotatsu-parsers) ![F-Droid Version](https://img.shields.io/f-droid/v/org.koitharu.kotatsu) ![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![Telegram](https://img.shields.io/badge/chat-telegram-60ACFF)](https://t.me/kotatsuapp) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) [![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu)](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in online content sources.**
![Downloads count](https://img.shields.io/github/downloads/KotatsuApp/Kotatsu/total?color=1976d2) ![Latest Stable version](https://img.shields.io/github/v/release/KotatsuApp/Kotatsu?color=2596be&label=latest) ![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) [![Sources count](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2FKotatsuApp%2Fkotatsu-parsers%2Frefs%2Fheads%2Fmaster%2F.github%2Fsummary.yaml&query=total&label=manga%20sources&color=%23E9321C)](https://github.com/KotatsuApp/kotatsu-parsers) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) [![Telegram](https://img.shields.io/badge/chat-telegram-60ACFF?)](https://t.me/kotatsuapp) [![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu)](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
### Download ### Download
<div align="left"> - **Recommended:** Download and install APK from **[GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest)**. Application has a built-in self-updating feature.
- 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.
* **Recommended:** Download and install APK from [GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest). Application has a built-in self-updating feature. - Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (very unstable, use at your own risk).
* 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.
* 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.
</div>
### Main Features ### Main Features
<div align="left"> * Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers)
* Search manga by name, genres, and more filters
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers) (with 1200+ manga sources) * Reading history and bookmarks
* Search manga by name, genres and more filters
* Favorites organized by user-defined categories * Favorites organized by user-defined categories
* Reading history, bookmarks and incognito mode support * Downloading manga and reading it offline. Third-party CBZ archives also supported
* Download manga and read it offline. Third-party CBZ archives are also supported * Tablet-optimized Material You UI
* Clean and convenient Material You UI, optimized for phones, tablets and desktop * Standard and Webtoon-optimized customizable reader
* Standard and Webtoon-optimized customizable reader, gesture support on reading interface * Notifications about new chapters with updates feed
* Notifications about new chapters with updates feed, manga recommendations (with filters)
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu * Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
* Password / fingerprint-protected access to the app * 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+
</div> ### Screenshots
### In-App Screenshots | ![Screenshot_20200226-210337](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/1.png) | ![](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/2.png) | ![Screenshot_20200226-210232](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/3.png) |
|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|
| ![Screenshot_20200226-210405](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/4.png) | ![Screenshot_20200226-210151](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/5.png) | ![Screenshot_20200226-210223](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/6.png) |
<div align="center"> | ![](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/tenInchScreenshots/1.png) | ![](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/tenInchScreenshots/2.png) |
<img src="./metadata/en-US/images/phoneScreenshots/1.png" alt="Mobile view" width="250"/> |-----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------|
<img src="./metadata/en-US/images/phoneScreenshots/2.png" alt="Mobile view" width="250"/>
<img src="./metadata/en-US/images/phoneScreenshots/3.png" alt="Mobile view" width="250"/>
<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>
<br>
<div align="center">
<img src="./metadata/en-US/images/tenInchScreenshots/1.png" alt="Tablet view" width="400"/>
<img src="./metadata/en-US/images/tenInchScreenshots/2.png" alt="Tablet view" width="400"/>
</div>
### Localization ### Localization
<a href="https://hosted.weblate.org/engage/kotatsu/"> [<img src="https://hosted.weblate.org/widgets/kotatsu/-/287x66-white.png" alt="Translation status">](https://hosted.weblate.org/engage/kotatsu/)
<img src="https://hosted.weblate.org/widget/kotatsu/horizontal-auto.png" alt="Translation status" />
</a>
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is localized in a number of different languages.**<br> Kotatsu is localized in a number of different languages, if you would like to help improve these or add new languages,
**📌 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/)
please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)**
### Contributing ### Contributing
<br> See [CONTRIBUTING.md](./CONTRIBUTING.md) for the guidelines.
<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 ### License
[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html) [![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html)
<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 &
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. install instructions.
</div>
### DMCA disclaimer ### 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
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>

View File

@@ -3,41 +3,55 @@ import java.time.LocalDateTime
plugins { plugins {
id 'com.android.application' id 'com.android.application'
id 'kotlin-android' id 'kotlin-android'
id 'kotlin-kapt'
id 'com.google.devtools.ksp' id 'com.google.devtools.ksp'
id 'kotlin-parcelize' id 'kotlin-parcelize'
id 'dagger.hilt.android.plugin' id 'dagger.hilt.android.plugin'
id 'androidx.room'
id 'org.jetbrains.kotlin.plugin.serialization'
// enable if needed
// id 'dev.reformator.stacktracedecoroutinator'
} }
def Properties versionProps = getVersionProps()
android { android {
compileSdk = 36 compileSdk = 35
buildToolsVersion = '35.0.0' buildToolsVersion = '35.0.0'
namespace = 'org.koitharu.kotatsu' namespace = 'org.koitharu.kotatsu'
defaultConfig { defaultConfig {
def code = versionProps['code'].toInteger()
def base = versionProps['base'].trim()
def build = versionProps['build'].toInteger()
def variant = versionProps['variant'].trim()
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 36 targetSdk = 35
versionCode = 1028 versionCode = code * 1000 + build
versionName = '9.1.4' versionName = base + (build == 0 ? '' : '.' + build) + (variant == '' ? '' : '-') + variant
generatedDensities = [] generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp { ksp {
arg('room.generateKotlin', 'true') arg('room.generateKotlin', 'true')
arg('room.schemaLocation', "$projectDir/schemas")
} }
androidResources { androidResources {
// https://issuetracker.google.com/issues/408030127 generateLocaleConfig true
generateLocaleConfig false
} }
def localProperties = new Properties() }
def localPropertiesFile = rootProject.file('local.properties') signingConfigs {
if (localPropertiesFile.exists()) { release {
localProperties.load(new FileInputStream(localPropertiesFile)) def tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/"
def allFilesFromDir = new File(tmpFilePath).listFiles()
if (allFilesFromDir != null) {
def keystoreFile = allFilesFromDir.first()
keystoreFile.renameTo("keystore/kotatsu.jks")
}
storeFile = file("keystore/kotatsu.jks")
storePassword System.getenv("SIGNING_STORE_PASSWORD")
keyAlias System.getenv("SIGNING_KEY_ALIAS")
keyPassword System.getenv("SIGNING_KEY_PASSWORD")
} }
resValue 'string', 'tg_backup_bot_token', localProperties.getProperty('tg_backup_bot_token', '')
} }
buildTypes { buildTypes {
debug { debug {
@@ -47,6 +61,7 @@ android {
minifyEnabled true minifyEnabled true
shrinkResources true shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
} }
nightly { nightly {
initWith release initWith release
@@ -80,19 +95,11 @@ android {
'-opt-in=kotlin.ExperimentalStdlibApi', '-opt-in=kotlin.ExperimentalStdlibApi',
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', '-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi', '-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi',
'-opt-in=kotlinx.coroutines.InternalForInheritanceCoroutinesApi',
'-opt-in=kotlinx.coroutines.FlowPreview', '-opt-in=kotlinx.coroutines.FlowPreview',
'-opt-in=kotlin.contracts.ExperimentalContracts', '-opt-in=kotlin.contracts.ExperimentalContracts',
'-opt-in=coil3.annotation.ExperimentalCoilApi', '-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 { lint {
abortOnError true abortOnError true
disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled', 'SimpleDateFormat' disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled', 'SimpleDateFormat'
@@ -114,6 +121,13 @@ android {
} }
} }
} }
afterEvaluate {
compileDebugKotlin {
kotlinOptions {
freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
}
}
}
dependencies { dependencies {
def parsersVersion = libs.versions.parsers.get() def parsersVersion = libs.versions.parsers.get()
if (System.properties.containsKey('parsersVersionOverride')) { if (System.properties.containsKey('parsersVersionOverride')) {
@@ -141,7 +155,6 @@ dependencies {
implementation libs.lifecycle.service implementation libs.lifecycle.service
implementation libs.lifecycle.process implementation libs.lifecycle.process
implementation libs.androidx.constraintlayout implementation libs.androidx.constraintlayout
implementation libs.androidx.documentfile
implementation libs.androidx.swiperefreshlayout implementation libs.androidx.swiperefreshlayout
implementation libs.androidx.recyclerview implementation libs.androidx.recyclerview
implementation libs.androidx.viewpager2 implementation libs.androidx.viewpager2
@@ -162,15 +175,14 @@ dependencies {
implementation libs.okhttp.tls implementation libs.okhttp.tls
implementation libs.okhttp.dnsoverhttps implementation libs.okhttp.dnsoverhttps
implementation libs.okio implementation libs.okio
implementation libs.kotlinx.serialization.json
implementation libs.adapterdelegates implementation libs.adapterdelegates
implementation libs.adapterdelegates.viewbinding implementation libs.adapterdelegates.viewbinding
implementation libs.hilt.android implementation libs.hilt.android
ksp libs.hilt.compiler kapt libs.hilt.compiler
implementation libs.androidx.hilt.work implementation libs.androidx.hilt.work
ksp libs.androidx.hilt.compiler kapt libs.androidx.hilt.compiler
implementation libs.coil.core implementation libs.coil.core
implementation libs.coil.network implementation libs.coil.network
@@ -180,7 +192,6 @@ dependencies {
implementation libs.ssiv implementation libs.ssiv
implementation libs.disk.lru.cache implementation libs.disk.lru.cache
implementation libs.markwon implementation libs.markwon
implementation libs.kizzyrpc
implementation libs.acra.http implementation libs.acra.http
implementation libs.acra.dialog implementation libs.acra.dialog
@@ -188,7 +199,6 @@ dependencies {
implementation libs.conscrypt.android implementation libs.conscrypt.android
debugImplementation libs.leakcanary.android debugImplementation libs.leakcanary.android
nightlyImplementation libs.leakcanary.android
debugImplementation libs.workinspector debugImplementation libs.workinspector
testImplementation libs.junit testImplementation libs.junit
@@ -206,5 +216,24 @@ dependencies {
androidTestImplementation libs.moshi.kotlin androidTestImplementation libs.moshi.kotlin
androidTestImplementation libs.hilt.android.testing androidTestImplementation libs.hilt.android.testing
kspAndroidTest libs.hilt.android.compiler kaptAndroidTest libs.hilt.android.compiler
}
tasks.register('versionInfo') {
def base = versionProps['base'].trim()
def build = versionProps['build'].toInteger()
def variant = versionProps['variant'].trim()
println base + (build == 0 ? '' : '.' + build) + (variant == '' ? '' : '-') + variant
}
def getVersionProps() {
def versionPropsFile = file('version.properties')
def Properties versionProps = new Properties()
versionProps.load(new FileInputStream(versionPropsFile))
if (System.getProperty('buildNumberIncrement') == 'true') {
def code = versionProps['build'].toInteger() + 1
versionProps['build'] = code.toString()
versionProps.store(versionPropsFile.newWriter(), null)
}
return versionProps
} }

View File

@@ -20,7 +20,7 @@
-keep class org.koitharu.kotatsu.core.exceptions.* { *; } -keep class org.koitharu.kotatsu.core.exceptions.* { *; }
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment -keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
-keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; } -keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; }
-keep class org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragment { *; } -keep class org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment { *; }
-keep class org.jsoup.parser.Tag -keep class org.jsoup.parser.Tag
-keep class org.jsoup.internal.StringUtil -keep class org.jsoup.internal.StringUtil

View File

@@ -1,7 +1,6 @@
{ {
"id": -2096681732556647985, "id": -2096681732556647985,
"title": "Странствия Эманон", "title": "Странствия Эманон",
"altTitles": [],
"url": "/stranstviia_emanon", "url": "/stranstviia_emanon",
"publicUrl": "https://readmanga.io/stranstviia_emanon", "publicUrl": "https://readmanga.io/stranstviia_emanon",
"rating": 0.9400894, "rating": 0.9400894,
@@ -30,15 +29,13 @@
} }
], ],
"state": "FINISHED", "state": "FINISHED",
"authors": [],
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg", "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>", "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
"chapters": [ "chapters": [
{ {
"id": 1552943969433540704, "id": 1552943969433540704,
"title": "1 - 1", "name": "1 - 1",
"number": 1, "number": 1,
"volume": 0,
"url": "/stranstviia_emanon/vol1/1", "url": "/stranstviia_emanon/vol1/1",
"scanlator": "Sad-Robot", "scanlator": "Sad-Robot",
"uploadDate": 1342731600000, "uploadDate": 1342731600000,
@@ -46,9 +43,8 @@
}, },
{ {
"id": 1552943969433540705, "id": 1552943969433540705,
"title": "1 - 2", "name": "1 - 2",
"number": 2, "number": 2,
"volume": 0,
"url": "/stranstviia_emanon/vol1/2", "url": "/stranstviia_emanon/vol1/2",
"scanlator": "Sad-Robot", "scanlator": "Sad-Robot",
"uploadDate": 1342731600000, "uploadDate": 1342731600000,
@@ -56,9 +52,8 @@
}, },
{ {
"id": 1552943969433540706, "id": 1552943969433540706,
"title": "1 - 3", "name": "1 - 3",
"number": 3, "number": 3,
"volume": 0,
"url": "/stranstviia_emanon/vol1/3", "url": "/stranstviia_emanon/vol1/3",
"scanlator": "Sad-Robot", "scanlator": "Sad-Robot",
"uploadDate": 1342731600000, "uploadDate": 1342731600000,
@@ -66,9 +61,8 @@
}, },
{ {
"id": 1552943969433540707, "id": 1552943969433540707,
"title": "1 - 4", "name": "1 - 4",
"number": 4, "number": 4,
"volume": 0,
"url": "/stranstviia_emanon/vol1/4", "url": "/stranstviia_emanon/vol1/4",
"scanlator": "Sad-Robot", "scanlator": "Sad-Robot",
"uploadDate": 1342731600000, "uploadDate": 1342731600000,
@@ -76,9 +70,8 @@
}, },
{ {
"id": 1552943969433540708, "id": 1552943969433540708,
"title": "1 - 5", "name": "1 - 5",
"number": 5, "number": 5,
"volume": 0,
"url": "/stranstviia_emanon/vol1/5", "url": "/stranstviia_emanon/vol1/5",
"scanlator": "Sad-Robot", "scanlator": "Sad-Robot",
"uploadDate": 1342731600000, "uploadDate": 1342731600000,
@@ -86,9 +79,8 @@
}, },
{ {
"id": 1552943969433541665, "id": 1552943969433541665,
"title": "2 - 1", "name": "2 - 1",
"number": 6, "number": 6,
"volume": 0,
"url": "/stranstviia_emanon/vol2/1", "url": "/stranstviia_emanon/vol2/1",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1415570400000, "uploadDate": 1415570400000,
@@ -96,9 +88,8 @@
}, },
{ {
"id": 1552943969433541666, "id": 1552943969433541666,
"title": "2 - 2", "name": "2 - 2",
"number": 7, "number": 7,
"volume": 0,
"url": "/stranstviia_emanon/vol2/2", "url": "/stranstviia_emanon/vol2/2",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1419976800000, "uploadDate": 1419976800000,
@@ -106,9 +97,8 @@
}, },
{ {
"id": 1552943969433541667, "id": 1552943969433541667,
"title": "2 - 3", "name": "2 - 3",
"number": 8, "number": 8,
"volume": 0,
"url": "/stranstviia_emanon/vol2/3", "url": "/stranstviia_emanon/vol2/3",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1427922000000, "uploadDate": 1427922000000,
@@ -116,9 +106,8 @@
}, },
{ {
"id": 1552943969433541668, "id": 1552943969433541668,
"title": "2 - 4", "name": "2 - 4",
"number": 9, "number": 9,
"volume": 0,
"url": "/stranstviia_emanon/vol2/4", "url": "/stranstviia_emanon/vol2/4",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1436907600000, "uploadDate": 1436907600000,
@@ -126,9 +115,8 @@
}, },
{ {
"id": 1552943969433541669, "id": 1552943969433541669,
"title": "2 - 5", "name": "2 - 5",
"number": 10, "number": 10,
"volume": 0,
"url": "/stranstviia_emanon/vol2/5", "url": "/stranstviia_emanon/vol2/5",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1446674400000, "uploadDate": 1446674400000,
@@ -136,9 +124,8 @@
}, },
{ {
"id": 1552943969433541670, "id": 1552943969433541670,
"title": "2 - 6", "name": "2 - 6",
"number": 11, "number": 11,
"volume": 0,
"url": "/stranstviia_emanon/vol2/6", "url": "/stranstviia_emanon/vol2/6",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1451512800000, "uploadDate": 1451512800000,
@@ -146,9 +133,8 @@
}, },
{ {
"id": 1552943969433542626, "id": 1552943969433542626,
"title": "3 - 1", "name": "3 - 1",
"number": 12, "number": 12,
"volume": 0,
"url": "/stranstviia_emanon/vol3/1", "url": "/stranstviia_emanon/vol3/1",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1461618000000, "uploadDate": 1461618000000,
@@ -156,9 +142,8 @@
}, },
{ {
"id": 1552943969433542627, "id": 1552943969433542627,
"title": "3 - 2", "name": "3 - 2",
"number": 13, "number": 13,
"volume": 0,
"url": "/stranstviia_emanon/vol3/2", "url": "/stranstviia_emanon/vol3/2",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1461618000000, "uploadDate": 1461618000000,
@@ -166,9 +151,8 @@
}, },
{ {
"id": 1552943969433542628, "id": 1552943969433542628,
"title": "3 - 3", "name": "3 - 3",
"number": 14, "number": 14,
"volume": 0,
"url": "/stranstviia_emanon/vol3/3", "url": "/stranstviia_emanon/vol3/3",
"scanlator": "", "scanlator": "",
"uploadDate": 1465851600000, "uploadDate": 1465851600000,
@@ -176,4 +160,4 @@
} }
], ],
"source": "READMANGA_RU" "source": "READMANGA_RU"
} }

View File

@@ -1,7 +1,6 @@
{ {
"id": -2096681732556647985, "id": -2096681732556647985,
"title": "Странствия Эманон", "title": "Странствия Эманон",
"altTitles": [],
"url": "/stranstviia_emanon", "url": "/stranstviia_emanon",
"publicUrl": "https://readmanga.io/stranstviia_emanon", "publicUrl": "https://readmanga.io/stranstviia_emanon",
"rating": 0.9400894, "rating": 0.9400894,
@@ -30,9 +29,8 @@
} }
], ],
"state": "FINISHED", "state": "FINISHED",
"authors": [],
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg", "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>", "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
"chapters": [], "chapters": [],
"source": "READMANGA_RU" "source": "READMANGA_RU"
} }

View File

@@ -1,7 +1,6 @@
{ {
"id": -2096681732556647985, "id": -2096681732556647985,
"title": "Странствия Эманон", "title": "Странствия Эманон",
"altTitles": [],
"url": "/stranstviia_emanon", "url": "/stranstviia_emanon",
"publicUrl": "https://readmanga.io/stranstviia_emanon", "publicUrl": "https://readmanga.io/stranstviia_emanon",
"rating": 0.9400894, "rating": 0.9400894,
@@ -30,15 +29,13 @@
} }
], ],
"state": "FINISHED", "state": "FINISHED",
"authors": [],
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg", "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>", "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
"chapters": [ "chapters": [
{ {
"id": 3552943969433540704, "id": 3552943969433540704,
"title": "1 - 1", "name": "1 - 1",
"number": 1, "number": 1,
"volume": 0,
"url": "/stranstviia_emanon/vol1/1", "url": "/stranstviia_emanon/vol1/1",
"scanlator": "Sad-Robot", "scanlator": "Sad-Robot",
"uploadDate": 1342731600000, "uploadDate": 1342731600000,
@@ -46,9 +43,8 @@
}, },
{ {
"id": 3552943969433540705, "id": 3552943969433540705,
"title": "1 - 2", "name": "1 - 2",
"number": 2, "number": 2,
"volume": 0,
"url": "/stranstviia_emanon/vol1/2", "url": "/stranstviia_emanon/vol1/2",
"scanlator": "Sad-Robot", "scanlator": "Sad-Robot",
"uploadDate": 1342731600000, "uploadDate": 1342731600000,
@@ -56,9 +52,8 @@
}, },
{ {
"id": 3552943969433540706, "id": 3552943969433540706,
"title": "1 - 3", "name": "1 - 3",
"number": 3, "number": 3,
"volume": 0,
"url": "/stranstviia_emanon/vol1/3", "url": "/stranstviia_emanon/vol1/3",
"scanlator": "Sad-Robot", "scanlator": "Sad-Robot",
"uploadDate": 1342731600000, "uploadDate": 1342731600000,
@@ -66,9 +61,8 @@
}, },
{ {
"id": 3552943969433540707, "id": 3552943969433540707,
"title": "1 - 4", "name": "1 - 4",
"number": 4, "number": 4,
"volume": 0,
"url": "/stranstviia_emanon/vol1/4", "url": "/stranstviia_emanon/vol1/4",
"scanlator": "Sad-Robot", "scanlator": "Sad-Robot",
"uploadDate": 1342731600000, "uploadDate": 1342731600000,
@@ -76,9 +70,8 @@
}, },
{ {
"id": 3552943969433540708, "id": 3552943969433540708,
"title": "1 - 5", "name": "1 - 5",
"number": 5, "number": 5,
"volume": 0,
"url": "/stranstviia_emanon/vol1/5", "url": "/stranstviia_emanon/vol1/5",
"scanlator": "Sad-Robot", "scanlator": "Sad-Robot",
"uploadDate": 1342731600000, "uploadDate": 1342731600000,
@@ -86,9 +79,8 @@
}, },
{ {
"id": 3552943969433541665, "id": 3552943969433541665,
"title": "2 - 1", "name": "2 - 1",
"number": 6, "number": 6,
"volume": 0,
"url": "/stranstviia_emanon/vol2/1", "url": "/stranstviia_emanon/vol2/1",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1415570400000, "uploadDate": 1415570400000,
@@ -96,9 +88,8 @@
}, },
{ {
"id": 3552943969433541666, "id": 3552943969433541666,
"title": "2 - 2", "name": "2 - 2",
"number": 7, "number": 7,
"volume": 0,
"url": "/stranstviia_emanon/vol2/2", "url": "/stranstviia_emanon/vol2/2",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1419976800000, "uploadDate": 1419976800000,
@@ -106,9 +97,8 @@
}, },
{ {
"id": 3552943969433541667, "id": 3552943969433541667,
"title": "2 - 3", "name": "2 - 3",
"number": 8, "number": 8,
"volume": 0,
"url": "/stranstviia_emanon/vol2/3", "url": "/stranstviia_emanon/vol2/3",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1427922000000, "uploadDate": 1427922000000,
@@ -116,9 +106,8 @@
}, },
{ {
"id": 3552943969433541668, "id": 3552943969433541668,
"title": "2 - 4", "name": "2 - 4",
"number": 9, "number": 9,
"volume": 0,
"url": "/stranstviia_emanon/vol2/4", "url": "/stranstviia_emanon/vol2/4",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1436907600000, "uploadDate": 1436907600000,
@@ -126,9 +115,8 @@
}, },
{ {
"id": 3552943969433541669, "id": 3552943969433541669,
"title": "2 - 5", "name": "2 - 5",
"number": 10, "number": 10,
"volume": 0,
"url": "/stranstviia_emanon/vol2/5", "url": "/stranstviia_emanon/vol2/5",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1446674400000, "uploadDate": 1446674400000,
@@ -136,9 +124,8 @@
}, },
{ {
"id": 3552943969433541670, "id": 3552943969433541670,
"title": "2 - 6", "name": "2 - 6",
"number": 11, "number": 11,
"volume": 0,
"url": "/stranstviia_emanon/vol2/6", "url": "/stranstviia_emanon/vol2/6",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1451512800000, "uploadDate": 1451512800000,
@@ -146,4 +133,4 @@
} }
], ],
"source": "READMANGA_RU" "source": "READMANGA_RU"
} }

View File

@@ -1,7 +1,6 @@
{ {
"id": -2096681732556647985, "id": -2096681732556647985,
"title": "Странствия Эманон", "title": "Странствия Эманон",
"altTitles": [],
"url": "/stranstviia_emanon", "url": "/stranstviia_emanon",
"publicUrl": "https://readmanga.io/stranstviia_emanon", "publicUrl": "https://readmanga.io/stranstviia_emanon",
"rating": 0.9400894, "rating": 0.9400894,
@@ -30,15 +29,13 @@
} }
], ],
"state": "FINISHED", "state": "FINISHED",
"authors": [],
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg", "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>", "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
"chapters": [ "chapters": [
{ {
"id": 3552943969433540704, "id": 3552943969433540704,
"title": "1 - 1", "name": "1 - 1",
"number": 1, "number": 1,
"volume": 0,
"url": "/stranstviia_emanon/vol1/1", "url": "/stranstviia_emanon/vol1/1",
"scanlator": "Sad-Robot", "scanlator": "Sad-Robot",
"uploadDate": 1342731600000, "uploadDate": 1342731600000,
@@ -46,9 +43,8 @@
}, },
{ {
"id": 3552943969433540705, "id": 3552943969433540705,
"title": "1 - 2", "name": "1 - 2",
"number": 2, "number": 2,
"volume": 0,
"url": "/stranstviia_emanon/vol1/2", "url": "/stranstviia_emanon/vol1/2",
"scanlator": "Sad-Robot", "scanlator": "Sad-Robot",
"uploadDate": 1342731600000, "uploadDate": 1342731600000,
@@ -56,9 +52,8 @@
}, },
{ {
"id": 3552943969433540706, "id": 3552943969433540706,
"title": "1 - 3", "name": "1 - 3",
"number": 3, "number": 3,
"volume": 0,
"url": "/stranstviia_emanon/vol1/3", "url": "/stranstviia_emanon/vol1/3",
"scanlator": "Sad-Robot", "scanlator": "Sad-Robot",
"uploadDate": 1342731600000, "uploadDate": 1342731600000,
@@ -66,9 +61,8 @@
}, },
{ {
"id": 3552943969433540707, "id": 3552943969433540707,
"title": "1 - 4", "name": "1 - 4",
"number": 4, "number": 4,
"volume": 0,
"url": "/stranstviia_emanon/vol1/4", "url": "/stranstviia_emanon/vol1/4",
"scanlator": "Sad-Robot", "scanlator": "Sad-Robot",
"uploadDate": 1342731600000, "uploadDate": 1342731600000,
@@ -76,9 +70,8 @@
}, },
{ {
"id": 3552943969433540708, "id": 3552943969433540708,
"title": "1 - 5", "name": "1 - 5",
"number": 5, "number": 5,
"volume": 0,
"url": "/stranstviia_emanon/vol1/5", "url": "/stranstviia_emanon/vol1/5",
"scanlator": "Sad-Robot", "scanlator": "Sad-Robot",
"uploadDate": 1342731600000, "uploadDate": 1342731600000,
@@ -86,9 +79,8 @@
}, },
{ {
"id": 3552943969433541665, "id": 3552943969433541665,
"title": "2 - 1", "name": "2 - 1",
"number": 6, "number": 6,
"volume": 0,
"url": "/stranstviia_emanon/vol2/1", "url": "/stranstviia_emanon/vol2/1",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1415570400000, "uploadDate": 1415570400000,
@@ -96,9 +88,8 @@
}, },
{ {
"id": 3552943969433541666, "id": 3552943969433541666,
"title": "2 - 2", "name": "2 - 2",
"number": 7, "number": 7,
"volume": 0,
"url": "/stranstviia_emanon/vol2/2", "url": "/stranstviia_emanon/vol2/2",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1419976800000, "uploadDate": 1419976800000,
@@ -106,9 +97,8 @@
}, },
{ {
"id": 3552943969433541667, "id": 3552943969433541667,
"title": "2 - 3", "name": "2 - 3",
"number": 8, "number": 8,
"volume": 0,
"url": "/stranstviia_emanon/vol2/3", "url": "/stranstviia_emanon/vol2/3",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1427922000000, "uploadDate": 1427922000000,
@@ -116,9 +106,8 @@
}, },
{ {
"id": 3552943969433541668, "id": 3552943969433541668,
"title": "2 - 4", "name": "2 - 4",
"number": 9, "number": 9,
"volume": 0,
"url": "/stranstviia_emanon/vol2/4", "url": "/stranstviia_emanon/vol2/4",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1436907600000, "uploadDate": 1436907600000,
@@ -126,9 +115,8 @@
}, },
{ {
"id": 3552943969433541669, "id": 3552943969433541669,
"title": "2 - 5", "name": "2 - 5",
"number": 10, "number": 10,
"volume": 0,
"url": "/stranstviia_emanon/vol2/5", "url": "/stranstviia_emanon/vol2/5",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1446674400000, "uploadDate": 1446674400000,
@@ -136,9 +124,8 @@
}, },
{ {
"id": 3552943969433541670, "id": 3552943969433541670,
"title": "2 - 6", "name": "2 - 6",
"number": 11, "number": 11,
"volume": 0,
"url": "/stranstviia_emanon/vol2/6", "url": "/stranstviia_emanon/vol2/6",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1451512800000, "uploadDate": 1451512800000,
@@ -146,9 +133,8 @@
}, },
{ {
"id": 3552943969433542626, "id": 3552943969433542626,
"title": "3 - 1", "name": "3 - 1",
"number": 12, "number": 12,
"volume": 0,
"url": "/stranstviia_emanon/vol3/1", "url": "/stranstviia_emanon/vol3/1",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1461618000000, "uploadDate": 1461618000000,
@@ -156,9 +142,8 @@
}, },
{ {
"id": 3552943969433542627, "id": 3552943969433542627,
"title": "3 - 2", "name": "3 - 2",
"number": 13, "number": 13,
"volume": 0,
"url": "/stranstviia_emanon/vol3/2", "url": "/stranstviia_emanon/vol3/2",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1461618000000, "uploadDate": 1461618000000,
@@ -166,9 +151,8 @@
}, },
{ {
"id": 3552943969433542628, "id": 3552943969433542628,
"title": "3 - 3", "name": "3 - 3",
"number": 14, "number": 14,
"volume": 0,
"url": "/stranstviia_emanon/vol3/3", "url": "/stranstviia_emanon/vol3/3",
"scanlator": "", "scanlator": "",
"uploadDate": 1465851600000, "uploadDate": 1465851600000,
@@ -176,4 +160,4 @@
} }
], ],
"source": "READMANGA_RU" "source": "READMANGA_RU"
} }

View File

@@ -1,7 +1,6 @@
{ {
"id": -2096681732556647985, "id": -2096681732556647985,
"title": "Странствия Эманон", "title": "Странствия Эманон",
"altTitles": [],
"url": "/stranstviia_emanon", "url": "/stranstviia_emanon",
"publicUrl": "https://readmanga.io/stranstviia_emanon", "publicUrl": "https://readmanga.io/stranstviia_emanon",
"rating": 0.9400894, "rating": 0.9400894,
@@ -30,8 +29,7 @@
} }
], ],
"state": "FINISHED", "state": "FINISHED",
"authors": [],
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg", "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
"description": null, "description": null,
"source": "READMANGA_RU" "source": "READMANGA_RU"
} }

View File

@@ -1,7 +1,6 @@
{ {
"id": -2096681732556647985, "id": -2096681732556647985,
"title": "Странствия Эманон", "title": "Странствия Эманон",
"altTitles": [],
"url": "/stranstviia_emanon", "url": "/stranstviia_emanon",
"publicUrl": "https://readmanga.io/stranstviia_emanon", "publicUrl": "https://readmanga.io/stranstviia_emanon",
"rating": 0.9400894, "rating": 0.9400894,
@@ -30,15 +29,13 @@
} }
], ],
"state": "FINISHED", "state": "FINISHED",
"authors": [],
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg", "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>", "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
"chapters": [ "chapters": [
{ {
"id": 3552943969433540704, "id": 3552943969433540704,
"title": "1 - 1", "name": "1 - 1",
"number": 1, "number": 1,
"volume": 0,
"url": "/stranstviia_emanon/vol1/1", "url": "/stranstviia_emanon/vol1/1",
"scanlator": "Sad-Robot", "scanlator": "Sad-Robot",
"uploadDate": 1342731600000, "uploadDate": 1342731600000,
@@ -46,9 +43,8 @@
}, },
{ {
"id": 3552943969433540705, "id": 3552943969433540705,
"title": "1 - 2", "name": "1 - 2",
"number": 2, "number": 2,
"volume": 0,
"url": "/stranstviia_emanon/vol1/2", "url": "/stranstviia_emanon/vol1/2",
"scanlator": "Sad-Robot", "scanlator": "Sad-Robot",
"uploadDate": 1342731600000, "uploadDate": 1342731600000,
@@ -56,9 +52,8 @@
}, },
{ {
"id": 3552943969433540706, "id": 3552943969433540706,
"title": "1 - 3", "name": "1 - 3",
"number": 3, "number": 3,
"volume": 0,
"url": "/stranstviia_emanon/vol1/3", "url": "/stranstviia_emanon/vol1/3",
"scanlator": "Sad-Robot", "scanlator": "Sad-Robot",
"uploadDate": 1342731600000, "uploadDate": 1342731600000,
@@ -66,9 +61,8 @@
}, },
{ {
"id": 3552943969433540707, "id": 3552943969433540707,
"title": "1 - 4", "name": "1 - 4",
"number": 4, "number": 4,
"volume": 0,
"url": "/stranstviia_emanon/vol1/4", "url": "/stranstviia_emanon/vol1/4",
"scanlator": "Sad-Robot", "scanlator": "Sad-Robot",
"uploadDate": 1342731600000, "uploadDate": 1342731600000,
@@ -76,9 +70,8 @@
}, },
{ {
"id": 3552943969433540708, "id": 3552943969433540708,
"title": "1 - 5", "name": "1 - 5",
"number": 5, "number": 5,
"volume": 0,
"url": "/stranstviia_emanon/vol1/5", "url": "/stranstviia_emanon/vol1/5",
"scanlator": "Sad-Robot", "scanlator": "Sad-Robot",
"uploadDate": 1342731600000, "uploadDate": 1342731600000,
@@ -86,9 +79,8 @@
}, },
{ {
"id": 3552943969433541666, "id": 3552943969433541666,
"title": "2 - 2", "name": "2 - 2",
"number": 7, "number": 7,
"volume": 0,
"url": "/stranstviia_emanon/vol2/2", "url": "/stranstviia_emanon/vol2/2",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1419976800000, "uploadDate": 1419976800000,
@@ -96,9 +88,8 @@
}, },
{ {
"id": 3552943969433541667, "id": 3552943969433541667,
"title": "2 - 3", "name": "2 - 3",
"number": 8, "number": 8,
"volume": 0,
"url": "/stranstviia_emanon/vol2/3", "url": "/stranstviia_emanon/vol2/3",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1427922000000, "uploadDate": 1427922000000,
@@ -106,9 +97,8 @@
}, },
{ {
"id": 3552943969433541668, "id": 3552943969433541668,
"title": "2 - 4", "name": "2 - 4",
"number": 9, "number": 9,
"volume": 0,
"url": "/stranstviia_emanon/vol2/4", "url": "/stranstviia_emanon/vol2/4",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1436907600000, "uploadDate": 1436907600000,
@@ -116,9 +106,8 @@
}, },
{ {
"id": 3552943969433541669, "id": 3552943969433541669,
"title": "2 - 5", "name": "2 - 5",
"number": 10, "number": 10,
"volume": 0,
"url": "/stranstviia_emanon/vol2/5", "url": "/stranstviia_emanon/vol2/5",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1446674400000, "uploadDate": 1446674400000,
@@ -126,9 +115,8 @@
}, },
{ {
"id": 3552943969433541670, "id": 3552943969433541670,
"title": "2 - 6", "name": "2 - 6",
"number": 11, "number": 11,
"volume": 0,
"url": "/stranstviia_emanon/vol2/6", "url": "/stranstviia_emanon/vol2/6",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1451512800000, "uploadDate": 1451512800000,
@@ -136,9 +124,8 @@
}, },
{ {
"id": 3552943969433542626, "id": 3552943969433542626,
"title": "3 - 1", "name": "3 - 1",
"number": 12, "number": 12,
"volume": 0,
"url": "/stranstviia_emanon/vol3/1", "url": "/stranstviia_emanon/vol3/1",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1461618000000, "uploadDate": 1461618000000,
@@ -146,9 +133,8 @@
}, },
{ {
"id": 3552943969433542627, "id": 3552943969433542627,
"title": "3 - 2", "name": "3 - 2",
"number": 13, "number": 13,
"volume": 0,
"url": "/stranstviia_emanon/vol3/2", "url": "/stranstviia_emanon/vol3/2",
"scanlator": "Sup!", "scanlator": "Sup!",
"uploadDate": 1461618000000, "uploadDate": 1461618000000,
@@ -156,9 +142,8 @@
}, },
{ {
"id": 3552943969433542628, "id": 3552943969433542628,
"title": "3 - 3", "name": "3 - 3",
"number": 14, "number": 14,
"volume": 0,
"url": "/stranstviia_emanon/vol3/3", "url": "/stranstviia_emanon/vol3/3",
"scanlator": "", "scanlator": "",
"uploadDate": 1465851600000, "uploadDate": 1465851600000,
@@ -166,4 +151,4 @@
} }
], ],
"source": "READMANGA_RU" "source": "READMANGA_RU"
} }

View File

@@ -1,29 +1,19 @@
package org.koitharu.kotatsu package org.koitharu.kotatsu
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.squareup.moshi.FromJson import com.squareup.moshi.*
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 com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okio.buffer import okio.buffer
import okio.source import okio.source
import org.koitharu.kotatsu.core.model.FavouriteCategory 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.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import java.util.*
import java.time.Instant
import java.util.Date
import kotlin.reflect.KClass import kotlin.reflect.KClass
object SampleData { object SampleData {
private val moshi = Moshi.Builder() private val moshi = Moshi.Builder()
.add(DateAdapter()) .add(DateAdapter())
.add(InstantAdapter())
.add(MangaSourceAdapter())
.add(KotlinJsonAdapterFactory()) .add(KotlinJsonAdapterFactory())
.build() .build()
@@ -61,36 +51,4 @@ object SampleData {
writer.value(value?.time ?: 0L) 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)
}
}
}

View File

@@ -15,8 +15,7 @@ import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.koitharu.kotatsu.SampleData import org.koitharu.kotatsu.SampleData
import org.koitharu.kotatsu.backups.data.BackupRepository import org.koitharu.kotatsu.core.backup.BackupRepository
import org.koitharu.kotatsu.backups.domain.AppBackupAgent
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository

View File

@@ -1,33 +1,21 @@
package org.koitharu.kotatsu package org.koitharu.kotatsu
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.os.Build import android.os.Build
import android.os.StrictMode import android.os.StrictMode
import androidx.core.content.edit
import androidx.fragment.app.strictmode.FragmentStrictMode import androidx.fragment.app.strictmode.FragmentStrictMode
import leakcanary.LeakCanary
import org.koitharu.kotatsu.core.BaseApp import org.koitharu.kotatsu.core.BaseApp
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
class KotatsuApp : 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) { override fun attachBaseContext(base: Context) {
super.attachBaseContext(base) super.attachBaseContext(base)
enableStrictMode() enableStrictMode()
configureLeakCanary()
}
private fun configureLeakCanary() {
LeakCanary.config = LeakCanary.config.copy(
dumpHeap = isLeakCanaryEnabled,
)
} }
private fun enableStrictMode() { private fun enableStrictMode() {
@@ -56,15 +44,18 @@ class KotatsuApp : BaseApp() {
detectLeakedSqlLiteObjects() detectLeakedSqlLiteObjects()
detectLeakedClosableObjects() detectLeakedClosableObjects()
detectLeakedRegistrationObjects() detectLeakedRegistrationObjects()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectContentUriWithoutPermission()
detectContentUriWithoutPermission()
}
detectFileUriExposure() detectFileUriExposure()
setClassInstanceLimit(LocalMangaRepository::class.java, 1)
setClassInstanceLimit(PagesCache::class.java, 1)
setClassInstanceLimit(MangaLoaderContext::class.java, 1)
setClassInstanceLimit(PageLoader::class.java, 1)
setClassInstanceLimit(ReaderViewModel::class.java, 1)
penaltyLog() penaltyLog()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
penaltyListener(notifier.executor, notifier) penaltyListener(notifier.executor, notifier)
} }
}.build(), }.build()
) )
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder().apply { FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder().apply {
detectWrongFragmentContainer() detectWrongFragmentContainer()
@@ -79,13 +70,4 @@ class KotatsuApp : BaseApp() {
} }
}.build() }.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)
}
} }

View File

@@ -55,7 +55,7 @@ class StrictModeNotifier(
.setContentIntent( .setContentIntent(
PendingIntentCompat.getActivity( PendingIntentCompat.getActivity(
context, context,
violation.hashCode(), 0,
ShareHelper(context).getShareTextIntent(violation.stackTraceToString()), ShareHelper(context).getShareTextIntent(violation.stackTraceToString()),
0, 0,
false, false,

View File

@@ -1,21 +0,0 @@
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",
)
}
}

View File

@@ -1,64 +0,0 @@
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"
}
}

View File

@@ -0,0 +1,33 @@
package org.koitharu.kotatsu.settings
import android.content.Context
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import leakcanary.LeakCanary
import org.koitharu.kotatsu.R
import org.koitharu.workinspector.WorkInspector
class SettingsMenuProvider(
private val context: Context,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_settings, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_leaks -> {
context.startActivity(LeakCanary.newLeakDisplayActivityIntent())
true
}
R.id.action_works -> {
context.startActivity(WorkInspector.getIntent(context))
true
}
else -> false
}
}

View File

@@ -1,12 +0,0 @@
<?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>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@id/action_leaks"
android:title="@string/leak_canary_display_activity_label"
app:showAsAction="never" />
<item
android:id="@id/action_works"
android:title="@string/wi_lib_name"
app:showAsAction="never" />
</menu>

View File

@@ -1,17 +0,0 @@
<?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>

View File

@@ -1,11 +0,0 @@
<?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>

View File

@@ -5,9 +5,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
android:name="android.permission.FOREGROUND_SERVICE"
tools:ignore="ForegroundServicesPolicy" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -21,19 +19,17 @@
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" /> <uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
android:name="android.permission.REQUEST_INSTALL_PACKAGES"
tools:ignore="RequestInstallPackagesPolicy" />
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
<uses-permission <uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES" android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" /> tools:ignore="QueryAllPackagesPermission" />
<uses-permission <uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" /> android:maxSdkVersion="29" />
<uses-permission <uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE" android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="AllFilesAccessPolicy,ScopedStorage" /> tools:ignore="ScopedStorage" />
<queries> <queries>
<intent> <intent>
@@ -48,16 +44,14 @@
<application <application
android:name="org.koitharu.kotatsu.KotatsuApp" android:name="org.koitharu.kotatsu.KotatsuApp"
android:allowBackup="true" android:allowBackup="true"
android:backupAgent="org.koitharu.kotatsu.backups.domain.AppBackupAgent" android:backupAgent="org.koitharu.kotatsu.settings.backup.AppBackupAgent"
android:dataExtractionRules="@xml/backup_rules" android:dataExtractionRules="@xml/backup_rules"
android:enableOnBackInvokedCallback="@bool/is_predictive_back_enabled" android:enableOnBackInvokedCallback="@bool/is_predictive_back_enabled"
android:fullBackupContent="@xml/backup_content" android:fullBackupContent="@xml/backup_content"
android:fullBackupOnly="true" android:fullBackupOnly="true"
android:hasFragileUserData="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:largeHeap="true" android:largeHeap="true"
android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true" android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
@@ -213,9 +207,6 @@
android:launchMode="singleTop" /> android:launchMode="singleTop" />
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity" /> <activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity" />
<activity android:name="org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity" /> <activity android:name="org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity" />
<activity
android:name="org.koitharu.kotatsu.settings.override.OverrideConfigActivity"
android:label="@string/edit" />
<activity <activity
android:name="org.koitharu.kotatsu.sync.ui.SyncAuthActivity" android:name="org.koitharu.kotatsu.sync.ui.SyncAuthActivity"
android:exported="true" android:exported="true"
@@ -269,27 +260,6 @@
<activity <activity
android:name="org.koitharu.kotatsu.tracker.ui.debug.TrackerDebugActivity" android:name="org.koitharu.kotatsu.tracker.ui.debug.TrackerDebugActivity"
android:label="@string/tracker_debug_info" /> android:label="@string/tracker_debug_info" />
<activity
android:name="org.koitharu.kotatsu.picker.ui.PageImagePickActivity"
android:exported="true"
android:label="@string/pick_manga_page">
<intent-filter>
<action android:name="android.intent.action.GET_CONTENT" />
<category android:name="android.intent.category.OPENABLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.PICK" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
<activity
android:name="org.koitharu.kotatsu.scrobbling.discord.ui.DiscordAuthActivity"
android:label="@string/discord" />
<service <service
android:name="androidx.work.impl.foreground.SystemForegroundService" android:name="androidx.work.impl.foreground.SystemForegroundService"
@@ -300,7 +270,7 @@
android:foregroundServiceType="dataSync" android:foregroundServiceType="dataSync"
android:label="@string/local_manga_processing" /> android:label="@string/local_manga_processing" />
<service <service
android:name="org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupService" android:name="org.koitharu.kotatsu.settings.backup.PeriodicalBackupService"
android:foregroundServiceType="dataSync" android:foregroundServiceType="dataSync"
android:label="@string/periodic_backups" /> android:label="@string/periodic_backups" />
<service <service
@@ -310,14 +280,6 @@
<service <service
android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService" android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService"
android:label="@string/local_manga_processing" /> android:label="@string/local_manga_processing" />
<service
android:name="org.koitharu.kotatsu.backups.ui.backup.BackupService"
android:foregroundServiceType="dataSync"
android:label="@string/creating_backup" />
<service
android:name="org.koitharu.kotatsu.backups.ui.restore.RestoreService"
android:foregroundServiceType="dataSync"
android:label="@string/restoring_backup" />
<service <service
android:name="org.koitharu.kotatsu.local.ui.ImportService" android:name="org.koitharu.kotatsu.local.ui.ImportService"
android:foregroundServiceType="dataSync" android:foregroundServiceType="dataSync"
@@ -367,9 +329,6 @@
android:name="org.koitharu.kotatsu.details.service.MangaPrefetchService" android:name="org.koitharu.kotatsu.details.service.MangaPrefetchService"
android:exported="false" android:exported="false"
android:label="@string/prefetch_content" /> android:label="@string/prefetch_content" />
<service
android:name="org.koitharu.kotatsu.browser.AdListUpdateService"
android:exported="false" />
<provider <provider
android:name="org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider" android:name="org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider"
@@ -407,13 +366,6 @@
tools:node="remove" /> tools:node="remove" />
</provider> </provider>
<receiver
android:name="org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler$DiscardReceiver"
android:exported="false">
<intent-filter>
<action android:name="org.koitharu.kotatsu.CAPTCHA_DISCARD" />
</intent-filter>
</receiver>
<receiver <receiver
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider" android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider"
android:exported="true" android:exported="true"

View File

@@ -3,76 +3,88 @@ package org.koitharu.kotatsu.alternatives.domain
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import org.koitharu.kotatsu.core.parser.MangaRepository 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.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.almostEquals
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable 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 import javax.inject.Inject
private const val MAX_PARALLELISM = 4 private const val MAX_PARALLELISM = 4
private const val MATCH_THRESHOLD_DEFAULT = 0.2f
class AlternativesUseCase @Inject constructor( class AlternativesUseCase @Inject constructor(
private val sourcesRepository: MangaSourcesRepository, private val sourcesRepository: MangaSourcesRepository,
private val searchHelperFactory: SearchV2Helper.Factory,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
) { ) {
suspend operator fun invoke(manga: Manga, throughDisabledSources: Boolean): Flow<Manga> { suspend operator fun invoke(manga: Manga): Flow<Manga> = invoke(manga, MATCH_THRESHOLD_DEFAULT)
val sources = getSources(manga.source, throughDisabledSources)
suspend operator fun invoke(manga: Manga, matchThreshold: Float): Flow<Manga> {
val sources = getSources(manga.source)
if (sources.isEmpty()) { if (sources.isEmpty()) {
return emptyFlow() return emptyFlow()
} }
val semaphore = Semaphore(MAX_PARALLELISM) val semaphore = Semaphore(MAX_PARALLELISM)
return channelFlow { return channelFlow {
for (source in sources) { for (source in sources) {
val repository = mangaRepositoryFactory.create(source)
if (!repository.filterCapabilities.isSearchSupported) {
continue
}
launch { launch {
val searchHelper = searchHelperFactory.create(source)
val list = runCatchingCancellable { val list = runCatchingCancellable {
semaphore.withPermit { semaphore.withPermit {
searchHelper(manga.title, SearchKind.TITLE)?.manga repository.getList(offset = 0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title))
} }
}.getOrNull() }.getOrDefault(emptyList())
list?.forEach { m -> for (item in list) {
if (m.id != manga.id) { if (item.matches(manga, matchThreshold)) {
launch { send(item)
val details = runCatchingCancellable {
mangaRepositoryFactory.create(m.source).getDetails(m)
}.getOrDefault(m)
send(details)
}
} }
} }
} }
} }
}.map {
runCatchingCancellable {
mangaRepositoryFactory.create(it.source).getDetails(it)
}.getOrDefault(it)
} }
} }
private suspend fun getSources(ref: MangaSource, disabled: Boolean): List<MangaSource> = if (disabled) { private suspend fun getSources(ref: MangaSource): List<MangaSource> {
sourcesRepository.getDisabledSources() val result = ArrayList<MangaSource>(MangaParserSource.entries.size - 2)
} else { result.addAll(sourcesRepository.getEnabledSources())
sourcesRepository.getEnabledSources() result.sortByDescending { it.priority(ref) }
}.sortedByDescending { it.priority(ref) } result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) })
return result
}
private fun Manga.matches(ref: Manga, threshold: Float): Boolean {
return matchesTitles(title, ref.title, threshold) ||
matchesTitles(title, ref.altTitle, threshold) ||
matchesTitles(altTitle, ref.title, threshold) ||
matchesTitles(altTitle, ref.altTitle, threshold)
}
private fun matchesTitles(a: String?, b: String?, threshold: Float): Boolean {
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, threshold)
}
private fun MangaSource.priority(ref: MangaSource): Int { private fun MangaSource.priority(ref: MangaSource): Int {
var res = 0 var res = 0
if (this is MangaParserSource && ref is MangaParserSource) { if (this is MangaParserSource && ref is MangaParserSource) {
if (locale == ref.locale) { if (locale == ref.locale) res += 2
res += 4 if (contentType == ref.contentType) res++
} else if (locale.toLocale() == Locale.getDefault()) {
res += 2
}
if (contentType == ref.contentType) {
res++
}
} }
return res return res
} }

View File

@@ -15,7 +15,6 @@ import org.koitharu.kotatsu.core.model.chaptersCount
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository 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.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -30,14 +29,12 @@ class AutoFixUseCase @Inject constructor(
) { ) {
suspend operator fun invoke(mangaId: Long): Pair<Manga, Manga?> { suspend operator fun invoke(mangaId: Long): Pair<Manga, Manga?> {
val seed = checkNotNull( val seed = checkNotNull(mangaDataRepository.findMangaById(mangaId)) { "Manga $mangaId not found" }
mangaDataRepository.findMangaById(mangaId, withChapters = true), .getDetailsSafe()
) { "Manga $mangaId not found" }.getDetailsSafe()
if (seed.isHealthy()) { if (seed.isHealthy()) {
return seed to null // no fix required return seed to null // no fix required
} }
val replacement = alternativesUseCase(seed, throughDisabledSources = false) val replacement = alternativesUseCase(seed, matchThreshold = 0.02f)
.concat(alternativesUseCase(seed, throughDisabledSources = true))
.filter { it.isHealthy() } .filter { it.isHealthy() }
.runningFold<Manga, Manga?>(null) { best, candidate -> .runningFold<Manga, Manga?>(null) { best, candidate ->
if (best == null || best < candidate) { if (best == null || best < candidate) {

View File

@@ -30,19 +30,21 @@ constructor(
oldManga: Manga, oldManga: Manga,
newManga: Manga, newManga: Manga,
) { ) {
val oldDetails = if (oldManga.chapters.isNullOrEmpty()) { val oldDetails =
runCatchingCancellable { if (oldManga.chapters.isNullOrEmpty()) {
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga) runCatchingCancellable {
}.getOrDefault(oldManga) mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
} else { }.getOrDefault(oldManga)
oldManga } else {
} oldManga
val newDetails = if (newManga.chapters.isNullOrEmpty()) { }
mangaRepositoryFactory.create(newManga.source).getDetails(newManga) val newDetails =
} else { if (newManga.chapters.isNullOrEmpty()) {
newManga mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
} } else {
mangaDataRepository.storeManga(newDetails, replaceExisting = true) newManga
}
mangaDataRepository.storeManga(newDetails)
database.withTransaction { database.withTransaction {
// replace favorites // replace favorites
val favoritesDao = database.getFavouritesDao() val favoritesDao = database.getFavouritesDao()
@@ -99,11 +101,11 @@ constructor(
mangaId = newDetails.id, mangaId = newDetails.id,
rating = prevInfo.rating, rating = prevInfo.rating,
status = status =
prevInfo.status ?: when { prevInfo.status ?: when {
newHistory == null -> ScrobblingStatus.PLANNED newHistory == null -> ScrobblingStatus.PLANNED
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
else -> ScrobblingStatus.READING else -> ScrobblingStatus.READING
}, },
comment = prevInfo.comment, comment = prevInfo.comment,
) )
if (newHistory != null) { if (newHistory != null) {

View File

@@ -4,7 +4,6 @@ import android.text.style.ForegroundColorSpan
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.text.buildSpannedString import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans import androidx.core.text.inSpans
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil3.ImageLoader import coil3.ImageLoader
import coil3.request.ImageRequest import coil3.request.ImageRequest
@@ -21,11 +20,15 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.mangaExtra
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.databinding.ItemMangaAlternativeBinding import org.koitharu.kotatsu.databinding.ItemMangaAlternativeBinding
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
@@ -48,22 +51,10 @@ fun alternativeAD(
binding.chipSource.setOnClickListener(clickListener) binding.chipSource.setOnClickListener(clickListener)
bind { payloads -> bind { payloads ->
binding.textViewTitle.text = item.mangaModel.title binding.textViewTitle.text = item.manga.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 { binding.textViewSubtitle.text = buildSpannedString {
if (item.chaptersCount > 0) { if (item.chaptersCount > 0) {
append( append(context.resources.getQuantityString(R.plurals.chapters, item.chaptersCount, item.chaptersCount))
context.resources.getQuantityStringSafe(
R.plurals.chapters,
item.chaptersCount,
item.chaptersCount,
),
)
} else { } else {
append(context.getString(R.string.no_chapters)) append(context.getString(R.string.no_chapters))
} }
@@ -79,10 +70,7 @@ fun alternativeAD(
} }
} }
} }
binding.progressView.setProgress( binding.progressView.setProgress(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
item.mangaModel.progress,
ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads,
)
binding.chipSource.also { chip -> binding.chipSource.also { chip ->
chip.text = item.manga.source.getTitle(chip.context) chip.text = item.manga.source.getTitle(chip.context)
ImageRequest.Builder(context) ImageRequest.Builder(context)
@@ -99,6 +87,13 @@ fun alternativeAD(
.allowRgb565(true) .allowRgb565(true)
.enqueueWith(coil) .enqueueWith(coil)
} }
binding.imageViewCover.setImageAsync(item.manga.coverUrl, item.manga) binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
size(CoverSizeResolver(binding.imageViewCover))
defaultPlaceholders(context)
transformations(TrimTransformation())
allowRgb565(true)
mangaExtra(item.manga)
enqueueWith(coil)
}
} }
} }

View File

@@ -1,40 +1,41 @@
package org.koitharu.kotatsu.alternatives.ui package org.koitharu.kotatsu.alternatives.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.widget.Toast import android.widget.Toast
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.view.WindowInsetsCompat import androidx.core.graphics.Insets
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import coil3.ImageLoader import coil3.ImageLoader
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener 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.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent 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.databinding.ActivityAlternativesBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.ListItemType 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.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.adapter.buttonFooterAD
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.search.ui.MangaListActivity
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(), class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
ListStateHolderListener,
OnListItemClickListener<MangaAlternativeModel> { OnListItemClickListener<MangaAlternativeModel> {
@Inject @Inject
@@ -51,10 +52,9 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
} }
val listAdapter = BaseListAdapter<ListModel>() val listAdapter = BaseListAdapter<ListModel>()
.addDelegate(ListItemType.MANGA_LIST_DETAILED, alternativeAD(coil, this, this)) .addDelegate(ListItemType.MANGA_LIST_DETAILED, alternativeAD(coil, this, this))
.addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(null)) .addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, this, null))
.addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD()) .addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
.addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) .addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
.addDelegate(ListItemType.FOOTER_BUTTON, buttonFooterAD(this))
with(viewBinding.recyclerView) { with(viewBinding.recyclerView) {
setHasFixedSize(true) setHasFixedSize(true)
addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false)) addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false))
@@ -62,46 +62,39 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
} }
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
viewModel.list.observe(this, listAdapter) viewModel.content.observe(this, listAdapter)
viewModel.onMigrated.observeEvent(this) { viewModel.onMigrated.observeEvent(this) {
Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show()
router.openDetails(it) startActivity(DetailsActivity.newIntent(this, it))
finishAfterTransition() finishAfterTransition()
} }
} }
override fun onApplyWindowInsets( override fun onWindowInsetsChanged(insets: Insets) {
v: View, viewBinding.root.updatePadding(
insets: WindowInsetsCompat left = insets.left,
): WindowInsetsCompat { right = insets.right,
val barsInsets = insets.systemBarsInsets )
viewBinding.recyclerView.updatePadding( viewBinding.recyclerView.updatePadding(
left = barsInsets.left, bottom = insets.bottom + viewBinding.recyclerView.paddingTop,
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) { override fun onItemClick(item: MangaAlternativeModel, view: View) {
when (view.id) { when (view.id) {
R.id.chip_source -> router.openSearch(item.manga.source, viewModel.manga.title) R.id.chip_source -> startActivity(
MangaListActivity.newIntent(
this,
item.manga.source,
MangaListFilter(query = viewModel.manga.title),
),
)
R.id.button_migrate -> confirmMigration(item.manga) R.id.button_migrate -> confirmMigration(item.manga)
else -> router.openDetails(item.manga) else -> startActivity(DetailsActivity.newIntent(this, item.manga))
} }
} }
override fun onRetryClick(error: Throwable) = viewModel.retry()
override fun onEmptyActionClick() = Unit
override fun onFooterButtonClick() = viewModel.continueSearch()
private fun confirmMigration(target: Manga) { private fun confirmMigration(target: Manga) {
buildAlertDialog(this, isCentered = true) { buildAlertDialog(this, isCentered = true) {
setIcon(R.drawable.ic_replace) setIcon(R.drawable.ic_replace)
@@ -121,4 +114,10 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
} }
}.show() }.show()
} }
companion object {
fun newIntent(context: Context, manga: Manga) = Intent(context, AlternativesActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
}
} }

View File

@@ -1,40 +1,33 @@
package org.koitharu.kotatsu.alternatives.ui package org.koitharu.kotatsu.alternatives.ui
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.onEmpty
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.runningFold
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.domain.AlternativesUseCase import org.koitharu.kotatsu.alternatives.domain.AlternativesUseCase
import org.koitharu.kotatsu.alternatives.domain.MigrateUseCase import org.koitharu.kotatsu.alternatives.domain.MigrateUseCase
import org.koitharu.kotatsu.core.model.chaptersCount import org.koitharu.kotatsu.core.model.chaptersCount
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow 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.call
import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.list.ui.model.ButtonFooter import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState 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.model.Manga
import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrDefault import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -43,67 +36,46 @@ class AlternativesViewModel @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
private val alternativesUseCase: AlternativesUseCase, private val alternativesUseCase: AlternativesUseCase,
private val migrateUseCase: MigrateUseCase, private val migrateUseCase: MigrateUseCase,
private val mangaListMapper: MangaListMapper, private val historyRepository: HistoryRepository,
private val settings: AppSettings,
) : BaseViewModel() { ) : BaseViewModel() {
val manga = savedStateHandle.require<ParcelableManga>(AppRouter.KEY_MANGA).manga val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.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 onMigrated = MutableEventFlow<Manga>()
val content = MutableStateFlow<List<ListModel>>(listOf(LoadingState))
val list: StateFlow<List<ListModel>> = combine( private var migrationJob: Job? = null
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 { init {
doSearch(throughDisabledSources = false) launchJob(Dispatchers.Default) {
} val ref = runCatchingCancellable {
mangaRepositoryFactory.create(manga.source).getDetails(manga)
fun retry() { }.getOrDefault(manga)
searchJob?.cancel() val refCount = ref.chaptersCount()
results.value = emptyList() alternativesUseCase(ref)
includeDisabledSources.value = false .map {
doSearch(throughDisabledSources = false) MangaAlternativeModel(
} manga = it,
progress = getProgress(it.id),
fun continueSearch() { referenceChapters = refCount,
if (includeDisabledSources.value) { )
return }.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item ->
} acc.filterIsInstance<MangaAlternativeModel>() + item + LoadingFooter()
val prevJob = searchJob }.onEmpty {
searchJob = launchLoadingJob(Dispatchers.Default) { emit(
includeDisabledSources.value = true listOf(
prevJob?.join() EmptyState(
doSearch(throughDisabledSources = true) icon = R.drawable.ic_empty_common,
textPrimary = R.string.nothing_found,
textSecondary = R.string.text_search_holder_secondary,
actionStringRes = 0,
),
),
)
}.collect {
content.value = it
}
content.value = content.value.filterNot { it is LoadingFooter }
} }
} }
@@ -117,20 +89,7 @@ class AlternativesViewModel @Inject constructor(
} }
} }
private fun doSearch(throughDisabledSources: Boolean) { private suspend fun getProgress(mangaId: Long): ReadingProgress? {
val prevJob = searchJob return historyRepository.getProgress(mangaId, settings.progressIndicatorMode)
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)
}
}
} }
} }

View File

@@ -10,6 +10,7 @@ import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat import androidx.core.app.PendingIntentCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import coil3.ImageLoader import coil3.ImageLoader
import coil3.request.ImageRequest import coil3.request.ImageRequest
@@ -17,23 +18,19 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase 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.ErrorReporterReceiver
import org.koitharu.kotatsu.core.model.getTitle 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.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra 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.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject import javax.inject.Inject
import androidx.appcompat.R as appcompatR import com.google.android.material.R as materialR
@AndroidEntryPoint @AndroidEntryPoint
class AutoFixService : CoroutineIntentService() { class AutoFixService : CoroutineIntentService() {
@@ -48,35 +45,33 @@ class AutoFixService : CoroutineIntentService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
notificationManager = NotificationManagerCompat.from(this) notificationManager = NotificationManagerCompat.from(applicationContext)
} }
override suspend fun IntentJobContext.processIntent(intent: Intent) { override suspend fun IntentJobContext.processIntent(intent: Intent) {
val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS)) val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS))
startForeground(this) startForeground(this)
for (mangaId in ids) { for (mangaId in ids) {
powerManager.withPartialWakeLock(TAG) { val result = runCatchingCancellable {
val result = runCatchingCancellable { autoFixUseCase.invoke(mangaId)
autoFixUseCase.invoke(mangaId) }
} if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
if (checkNotificationPermission(CHANNEL_ID)) { val notification = buildNotification(result)
val notification = buildNotification(startId, result) notificationManager.notify(TAG, startId, notification)
notificationManager.notify(TAG, startId, notification)
}
} }
} }
} }
override fun IntentJobContext.onError(error: Throwable) { override fun IntentJobContext.onError(error: Throwable) {
if (checkNotificationPermission(CHANNEL_ID)) { if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
val notification = runBlocking { buildNotification(startId, Result.failure(error)) } val notification = runBlocking { buildNotification(Result.failure(error)) }
notificationManager.notify(TAG, startId, notification) notificationManager.notify(TAG, startId, notification)
} }
} }
@SuppressLint("InlinedApi") @SuppressLint("InlinedApi")
private fun startForeground(jobContext: IntentJobContext) { private fun startForeground(jobContext: IntentJobContext) {
val title = getString(R.string.fixing_manga) val title = applicationContext.getString(R.string.fixing_manga)
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN) val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN)
.setName(title) .setName(title)
.setShowBadge(false) .setShowBadge(false)
@@ -86,7 +81,7 @@ class AutoFixService : CoroutineIntentService() {
.build() .build()
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
val notification = NotificationCompat.Builder(this, CHANNEL_ID) val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setContentTitle(title) .setContentTitle(title)
.setPriority(NotificationCompat.PRIORITY_MIN) .setPriority(NotificationCompat.PRIORITY_MIN)
.setDefaults(0) .setDefaults(0)
@@ -97,8 +92,8 @@ class AutoFixService : CoroutineIntentService() {
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setCategory(NotificationCompat.CATEGORY_PROGRESS) .setCategory(NotificationCompat.CATEGORY_PROGRESS)
.addAction( .addAction(
appcompatR.drawable.abc_ic_clear_material, materialR.drawable.material_ic_clear_black_24dp,
getString(android.R.string.cancel), applicationContext.getString(android.R.string.cancel),
jobContext.getCancelIntent(), jobContext.getCancelIntent(),
) )
.build() .build()
@@ -110,8 +105,8 @@ class AutoFixService : CoroutineIntentService() {
) )
} }
private suspend fun buildNotification(startId: Int, result: Result<Pair<Manga, Manga?>>): Notification { private suspend fun buildNotification(result: Result<Pair<Manga, Manga?>>): Notification {
val notification = NotificationCompat.Builder(this, CHANNEL_ID) val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setDefaults(0) .setDefaults(0)
.setSilent(true) .setSilent(true)
@@ -120,64 +115,59 @@ class AutoFixService : CoroutineIntentService() {
if (replacement != null) { if (replacement != null) {
notification.setLargeIcon( notification.setLargeIcon(
coil.execute( coil.execute(
ImageRequest.Builder(this) ImageRequest.Builder(applicationContext)
.data(replacement.coverUrl) .data(replacement.coverUrl)
.mangaSourceExtra(replacement.source) .mangaSourceExtra(replacement.source)
.build(), .build(),
).toBitmapOrNull(), ).toBitmapOrNull(),
) )
notification.setSubText(replacement.title) notification.setSubText(replacement.title)
val intent = AppRouter.detailsIntent(this, replacement) val intent = DetailsActivity.newIntent(applicationContext, replacement)
notification.setContentIntent( notification.setContentIntent(
PendingIntentCompat.getActivity( PendingIntentCompat.getActivity(
this, applicationContext,
replacement.id.toInt(), replacement.id.toInt(),
intent, intent,
PendingIntent.FLAG_UPDATE_CURRENT, PendingIntent.FLAG_UPDATE_CURRENT,
false, false,
), ),
).setVisibility( ).setVisibility(
if (replacement.isNsfw()) { if (replacement.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC,
NotificationCompat.VISIBILITY_SECRET
} else {
NotificationCompat.VISIBILITY_PUBLIC
},
) )
notification notification
.setContentTitle(getString(R.string.fixed)) .setContentTitle(applicationContext.getString(R.string.fixed))
.setContentText( .setContentText(
getString( applicationContext.getString(
R.string.manga_replaced, R.string.manga_replaced,
seed.title, seed.title,
seed.source.getTitle(this), seed.source.getTitle(applicationContext),
replacement.title, replacement.title,
replacement.source.getTitle(this), replacement.source.getTitle(applicationContext),
), ),
) )
.setSmallIcon(R.drawable.ic_stat_done) .setSmallIcon(R.drawable.ic_stat_done)
} else { } else {
notification notification
.setContentTitle(getString(R.string.fixing_manga)) .setContentTitle(applicationContext.getString(R.string.fixing_manga))
.setContentText(getString(R.string.no_fix_required, seed.title)) .setContentText(applicationContext.getString(R.string.no_fix_required, seed.title))
.setSmallIcon(android.R.drawable.stat_sys_warning) .setSmallIcon(android.R.drawable.stat_sys_warning)
} }
}.onFailure { error -> }.onFailure { error ->
notification notification
.setContentTitle(getString(R.string.error_occurred)) .setContentTitle(applicationContext.getString(R.string.error_occurred))
.setContentText( .setContentText(
if (error is NoAlternativesException) { if (error is AutoFixUseCase.NoAlternativesException) {
getString(R.string.no_alternatives_found, error.seed.manga.title) applicationContext.getString(R.string.no_alternatives_found, error.seed.manga.title)
} else { } else {
error.getDisplayMessage(resources) error.getDisplayMessage(applicationContext.resources)
}, },
).setSmallIcon(android.R.drawable.stat_notify_error) ).setSmallIcon(android.R.drawable.stat_notify_error)
ErrorReporterReceiver.getNotificationAction( ErrorReporterReceiver.getPendingIntent(applicationContext, error)?.let { reportIntent ->
context = this, notification.addAction(
e = error, R.drawable.ic_alert_outline,
notificationId = startId, applicationContext.getString(R.string.report),
notificationTag = TAG, reportIntent,
)?.let { action -> )
notification.addAction(action)
} }
} }
return notification.build() return notification.build()

View File

@@ -1,18 +1,16 @@
package org.koitharu.kotatsu.alternatives.ui package org.koitharu.kotatsu.alternatives.ui
import org.koitharu.kotatsu.core.model.chaptersCount import org.koitharu.kotatsu.core.model.chaptersCount
import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
data class MangaAlternativeModel( data class MangaAlternativeModel(
val mangaModel: MangaGridModel, val manga: Manga,
val progress: ReadingProgress?,
private val referenceChapters: Int, private val referenceChapters: Int,
) : ListModel { ) : ListModel {
val manga: Manga
get() = mangaModel.manga
val chaptersCount = manga.chaptersCount() val chaptersCount = manga.chaptersCount()
val chaptersDiff: Int val chaptersDiff: Int
@@ -21,10 +19,4 @@ data class MangaAlternativeModel(
override fun areItemsTheSame(other: ListModel): Boolean { override fun areItemsTheSame(other: ListModel): Boolean {
return other is MangaAlternativeModel && other.manga.id == manga.id 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
}
} }

View File

@@ -1,262 +0,0 @@
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)
}
}
}
}
}

View File

@@ -1,19 +0,0 @@
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(),
)
}

View File

@@ -1,56 +0,0 @@
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,
)
},
)
}

View File

@@ -1,39 +0,0 @@
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,
)
}

View File

@@ -1,36 +0,0 @@
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,
)
}

View File

@@ -1,46 +0,0 @@
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,
)
}

View File

@@ -1,60 +0,0 @@
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,
)
}

View File

@@ -1,35 +0,0 @@
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,
)
}

View File

@@ -1,31 +0,0 @@
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,
)
}

View File

@@ -1,98 +0,0 @@
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)
}
}
}
}

View File

@@ -1,27 +0,0 @@
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 }
}
}
}

View File

@@ -1,42 +0,0 @@
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")
}
}

View File

@@ -1,136 +0,0 @@
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)
}
}
}

View File

@@ -1,131 +0,0 @@
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
}
}
}

View File

@@ -1,44 +0,0 @@
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)
}
}
}

View File

@@ -1,105 +0,0 @@
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"
}
}

View File

@@ -1,107 +0,0 @@
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
}
}

View File

@@ -1,79 +0,0 @@
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()
}
}

View File

@@ -1,96 +0,0 @@
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)
}

View File

@@ -1,44 +0,0 @@
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)
}
}
}

View File

@@ -1,118 +0,0 @@
package org.koitharu.kotatsu.backups.ui.restore
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 androidx.annotation.CheckResult
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.backups.data.BackupRepository
import org.koitharu.kotatsu.backups.domain.BackupSection
import org.koitharu.kotatsu.backups.ui.BaseBackupRestoreService
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
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.ZipInputStream
import javax.inject.Inject
import androidx.appcompat.R as appcompatR
@AndroidEntryPoint
@SuppressLint("InlinedApi")
class RestoreService : BaseBackupRestoreService() {
override val notificationTag = TAG
override val isRestoreService = true
@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 source = intent.getStringExtra(AppRouter.KEY_DATA)?.toUriOrNull() ?: throw FileNotFoundException()
val sections =
requireNotNull(intent.getSerializableExtraCompat<Array<BackupSection>>(AppRouter.KEY_ENTRIES)?.toSet())
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
}
val result = ZipInputStream(contentResolver.openInputStream(source)).use { input ->
repository.restoreBackup(input, sections, progress)
}
progressUpdateJob?.cancelAndJoin()
showResultNotification(source, result)
}
}
private fun IntentJobContext.buildNotification(progress: Progress): Notification {
return NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setContentTitle(getString(R.string.restoring_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 = "RESTORE"
private const val FOREGROUND_NOTIFICATION_ID = 39
@CheckResult
fun start(context: Context, uri: Uri, sections: Set<BackupSection>): Boolean = try {
val intent = Intent(context, RestoreService::class.java)
intent.putExtra(AppRouter.KEY_DATA, uri.toString())
intent.putExtra(AppRouter.KEY_ENTRIES, sections.toTypedArray())
ContextCompat.startForegroundService(context, intent)
true
} catch (e: Exception) {
e.printStackTraceDebug()
false
}
}
}

View File

@@ -1,112 +0,0 @@
package org.koitharu.kotatsu.backups.ui.restore
import android.content.Context
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 kotlinx.coroutines.runInterruptible
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import org.koitharu.kotatsu.backups.data.model.BackupIndex
import org.koitharu.kotatsu.backups.domain.BackupSection
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import java.io.FileNotFoundException
import java.io.InputStream
import java.util.Date
import java.util.EnumMap
import java.util.EnumSet
import java.util.zip.ZipInputStream
import javax.inject.Inject
@HiltViewModel
class RestoreViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
@ApplicationContext context: Context,
) : BaseViewModel() {
val uri = savedStateHandle.get<String>(AppRouter.KEY_FILE)?.toUriOrNull()
private val contentResolver = context.contentResolver
val availableEntries = MutableStateFlow<List<BackupSectionModel>>(emptyList())
val backupDate = MutableStateFlow<Date?>(null)
init {
launchLoadingJob(Dispatchers.Default) {
loadBackupInfo()
}
}
private suspend fun loadBackupInfo() {
val sections = runInterruptible(Dispatchers.IO) {
if (uri == null) throw FileNotFoundException()
ZipInputStream(contentResolver.openInputStream(uri)).use { stream ->
val result = EnumSet.noneOf(BackupSection::class.java)
var entry = stream.nextEntry
while (entry != null) {
val s = BackupSection.of(entry)
if (s != null) {
result.add(s)
if (s == BackupSection.INDEX) {
backupDate.value = stream.readDate()
}
}
stream.closeEntry()
entry = stream.nextEntry
}
result
}
}
availableEntries.value = BackupSection.entries.mapNotNull { entry ->
if (entry == BackupSection.INDEX || entry !in sections) {
return@mapNotNull null
}
BackupSectionModel(
section = entry,
isChecked = true,
isEnabled = true,
)
}
}
fun onItemClick(item: BackupSectionModel) {
val map = availableEntries.value.associateByTo(EnumMap(BackupSection::class.java)) { it.section }
map[item.section] = item.copy(isChecked = !item.isChecked)
map.validate()
availableEntries.value = map.values.sortedBy { it.section.ordinal }
}
fun getCheckedSections(): Set<BackupSection> = availableEntries.value
.mapNotNullTo(EnumSet.noneOf(BackupSection::class.java)) {
if (it.isChecked) it.section else null
}
/**
* Check for inconsistent user selection
* Favorites cannot be restored without categories
*/
private fun MutableMap<BackupSection, BackupSectionModel>.validate() {
val favorites = this[BackupSection.FAVOURITES] ?: return
val categories = this[BackupSection.CATEGORIES]
if (categories?.isChecked == true) {
if (!favorites.isEnabled) {
this[BackupSection.FAVOURITES] = favorites.copy(isEnabled = true)
}
} else {
if (favorites.isEnabled) {
this[BackupSection.FAVOURITES] = favorites.copy(isEnabled = false, isChecked = false)
}
}
}
private fun InputStream.readDate(): Date? = runCatching {
val index = Json.decodeFromStream<List<BackupIndex>>(this)
Date(index.single().createdAt)
}.onFailure { e ->
e.printStackTraceDebug()
}.getOrNull()
}

View File

@@ -6,10 +6,7 @@ import androidx.room.Insert
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction import androidx.room.Transaction
import androidx.room.Upsert import androidx.room.Upsert
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive
import org.koitharu.kotatsu.core.db.entity.MangaWithTags import org.koitharu.kotatsu.core.db.entity.MangaWithTags
@Dao @Dao
@@ -20,9 +17,9 @@ abstract class BookmarksDao {
@Transaction @Transaction
@Query( @Query(
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent LIMIT :limit OFFSET :offset", "SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent",
) )
abstract suspend fun findAll(offset: Int, limit: Int): Map<MangaWithTags, List<BookmarkEntity>> abstract suspend fun findAll(): Map<MangaWithTags, List<BookmarkEntity>>
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page ORDER BY percent") @Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page ORDER BY percent")
abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow<BookmarkEntity?> abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow<BookmarkEntity?>
@@ -50,17 +47,4 @@ abstract class BookmarksDao {
@Upsert @Upsert
abstract suspend fun upsert(bookmarks: Collection<BookmarkEntity>) abstract suspend fun upsert(bookmarks: Collection<BookmarkEntity>)
fun dump(): Flow<Pair<MangaWithTags, List<BookmarkEntity>>> = flow {
val window = 4
var offset = 0
while (currentCoroutineContext().isActive) {
val list = findAll(offset, window)
if (list.isEmpty()) {
break
}
offset += window
list.forEach { emit(it.key to it.value) }
}
}
} }

View File

@@ -1,8 +1,7 @@
package org.koitharu.kotatsu.bookmarks.domain package org.koitharu.kotatsu.bookmarks.domain
import org.koitharu.kotatsu.core.util.MimeTypes
import org.koitharu.kotatsu.core.util.ext.isImage
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.local.data.hasImageExtension
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import java.time.Instant import java.time.Instant
@@ -18,6 +17,9 @@ data class Bookmark(
val percent: Float, val percent: Float,
) : ListModel { ) : ListModel {
val imageLoadData: Any
get() = if (isImageUrlDirect()) imageUrl else toMangaPage()
override fun areItemsTheSame(other: ListModel): Boolean { override fun areItemsTheSame(other: ListModel): Boolean {
return other is Bookmark && return other is Bookmark &&
manga.id == other.manga.id && manga.id == other.manga.id &&
@@ -28,9 +30,11 @@ data class Bookmark(
fun toMangaPage() = MangaPage( fun toMangaPage() = MangaPage(
id = pageId, id = pageId,
url = imageUrl, url = imageUrl,
preview = imageUrl.takeIf { preview = null,
MimeTypes.getMimeTypeFromUrl(it)?.isImage == true
},
source = manga.source, source = manga.source,
) )
private fun isImageUrlDirect(): Boolean {
return hasImageExtension(imageUrl)
}
} }

View File

@@ -1,5 +1,54 @@
package org.koitharu.kotatsu.bookmarks.ui package org.koitharu.kotatsu.bookmarks.ui
import org.koitharu.kotatsu.core.ui.FragmentContainerActivity import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.fragment.app.commit
import com.google.android.material.appbar.AppBarLayout
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityContainerBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
class AllBookmarksActivity : FragmentContainerActivity(AllBookmarksFragment::class.java) @AndroidEntryPoint
class AllBookmarksActivity :
BaseActivity<ActivityContainerBinding>(),
AppBarOwner,
SnackbarOwner {
override val appBar: AppBarLayout
get() = viewBinding.appbar
override val snackbarHost: CoordinatorLayout
get() = viewBinding.root
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityContainerBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val fm = supportFragmentManager
if (fm.findFragmentById(R.id.container) == null) {
fm.commit {
setReorderingAllowed(true)
replace(R.id.container, AllBookmarksFragment::class.java, null)
}
}
}
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.root.updatePadding(
left = insets.left,
right = insets.right,
)
}
companion object {
fun newIntent(context: Context) = Intent(context, AllBookmarksActivity::class.java)
}
}

View File

@@ -9,28 +9,28 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.core.view.WindowInsetsCompat import androidx.core.graphics.Insets
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import coil3.ImageLoader
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.nav.ReaderIntent
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.systemBarsInsets
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.GridSpanResolver import org.koitharu.kotatsu.list.ui.GridSpanResolver
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.ListItemType
@@ -39,7 +39,7 @@ import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.PageSaveHelper import org.koitharu.kotatsu.reader.ui.ReaderActivity
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -51,21 +51,15 @@ class AllBookmarksFragment :
FastScroller.FastScrollListener, ListHeaderClickListener { FastScroller.FastScrollListener, ListHeaderClickListener {
@Inject @Inject
lateinit var settings: AppSettings lateinit var coil: ImageLoader
@Inject @Inject
lateinit var pageSaveHelperFactory: PageSaveHelper.Factory lateinit var settings: AppSettings
private lateinit var pageSaveHelper: PageSaveHelper
private val viewModel by viewModels<AllBookmarksViewModel>() private val viewModel by viewModels<AllBookmarksViewModel>()
private var bookmarksAdapter: BookmarksAdapter? = null private var bookmarksAdapter: BookmarksAdapter? = null
private var selectionController: ListSelectionController? = null private var selectionController: ListSelectionController? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
pageSaveHelper = pageSaveHelperFactory.create(this)
}
override fun onCreateViewBinding( override fun onCreateViewBinding(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@@ -85,6 +79,8 @@ class AllBookmarksFragment :
callback = this, callback = this,
) )
bookmarksAdapter = BookmarksAdapter( bookmarksAdapter = BookmarksAdapter(
lifecycleOwner = viewLifecycleOwner,
coil = coil,
clickListener = this, clickListener = this,
headerClickListener = this, headerClickListener = this,
) )
@@ -111,18 +107,6 @@ class AllBookmarksFragment :
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
} }
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val barsInsets = insets.systemBarsInsets
val basePadding = resources.getDimensionPixelOffset(R.dimen.list_spacing_normal)
viewBinding?.recyclerView?.setPadding(
barsInsets.left + basePadding,
barsInsets.top + basePadding,
barsInsets.right + basePadding,
barsInsets.bottom + basePadding,
)
return insets.consumeAllSystemBarsInsets()
}
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
bookmarksAdapter = null bookmarksAdapter = null
@@ -131,26 +115,26 @@ class AllBookmarksFragment :
override fun onItemClick(item: Bookmark, view: View) { override fun onItemClick(item: Bookmark, view: View) {
if (selectionController?.onItemClick(item.pageId) != true) { if (selectionController?.onItemClick(item.pageId) != true) {
val intent = ReaderIntent.Builder(view.context) val intent = ReaderActivity.IntentBuilder(view.context)
.bookmark(item) .bookmark(item)
.incognito() .incognito(true)
.build() .build()
router.openReader(intent) startActivity(intent)
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show() Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
} }
} }
override fun onListHeaderClick(item: ListHeader, view: View) { override fun onListHeaderClick(item: ListHeader, view: View) {
val manga = item.payload as? Manga ?: return val manga = item.payload as? Manga ?: return
router.openDetails(manga) startActivity(DetailsActivity.newIntent(view.context, manga))
} }
override fun onItemLongClick(item: Bookmark, view: View): Boolean { override fun onItemLongClick(item: Bookmark, view: View): Boolean {
return selectionController?.onItemLongClick(view, item.pageId) == true return selectionController?.onItemLongClick(view, item.pageId) ?: false
} }
override fun onItemContextClick(item: Bookmark, view: View): Boolean { override fun onItemContextClick(item: Bookmark, view: View): Boolean {
return selectionController?.onItemContextClick(view, item.pageId) == true return selectionController?.onItemContextClick(view, item.pageId) ?: false
} }
override fun onRetryClick(error: Throwable) = Unit override fun onRetryClick(error: Throwable) = Unit
@@ -189,16 +173,20 @@ class AllBookmarksFragment :
true true
} }
R.id.action_save -> {
viewModel.savePages(pageSaveHelper, selectionController?.snapshot() ?: return false)
mode?.finish()
true
}
else -> false else -> false
} }
} }
override fun onWindowInsetsChanged(insets: Insets) {
val rv = requireViewBinding().recyclerView
rv.updatePadding(
bottom = insets.bottom + rv.paddingTop,
)
rv.fastScroller.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = insets.bottom
}
}
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup(), Runnable { private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup(), Runnable {
init { init {
@@ -220,4 +208,16 @@ class AllBookmarksFragment :
invalidateSpanIndexCache() invalidateSpanIndexCache()
} }
} }
companion object {
@Deprecated(
"",
ReplaceWith(
"BookmarksFragment()",
"org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment",
),
)
fun newInstance() = AllBookmarksFragment()
}
} }

View File

@@ -22,7 +22,6 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.PageSaveHelper
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -57,23 +56,6 @@ class AllBookmarksViewModel @Inject constructor(
} }
} }
fun savePages(pageSaveHelper: PageSaveHelper, ids: Set<Long>) {
launchLoadingJob(Dispatchers.Default) {
val tasks = content.value.mapNotNull {
if (it !is Bookmark || it.pageId !in ids) return@mapNotNull null
PageSaveHelper.Task(
manga = it.manga,
chapterId = it.chapterId,
pageNumber = it.page + 1,
page = it.toMangaPage(),
)
}
val dest = pageSaveHelper.save(tasks)
val msg = if (dest.size == 1) R.string.page_saved else R.string.pages_saved
onActionDone.call(ReversibleAction(msg, null))
}
}
private fun mapList(data: Map<Manga, List<Bookmark>>): List<ListModel> { private fun mapList(data: Map<Manga, List<Bookmark>>): List<ListModel> {
val result = ArrayList<ListModel>(data.values.sumOf { it.size + 1 }) val result = ArrayList<ListModel>(data.values.sumOf { it.size + 1 })
for ((manga, bookmarks) in data) { for ((manga, bookmarks) in data) {

View File

@@ -1,13 +1,24 @@
package org.koitharu.kotatsu.bookmarks.ui.adapter package org.koitharu.kotatsu.bookmarks.ui.adapter
import androidx.lifecycle.LifecycleOwner
import coil3.ImageLoader
import coil3.request.allowRgb565
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.bookmarkExtra
import org.koitharu.kotatsu.core.util.ext.decodeRegion
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.databinding.ItemBookmarkLargeBinding import org.koitharu.kotatsu.databinding.ItemBookmarkLargeBinding
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
fun bookmarkLargeAD( fun bookmarkLargeAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Bookmark>, clickListener: OnListItemClickListener<Bookmark>,
) = adapterDelegateViewBinding<Bookmark, ListModel, ItemBookmarkLargeBinding>( ) = adapterDelegateViewBinding<Bookmark, ListModel, ItemBookmarkLargeBinding>(
{ inflater, parent -> ItemBookmarkLargeBinding.inflate(inflater, parent, false) }, { inflater, parent -> ItemBookmarkLargeBinding.inflate(inflater, parent, false) },
@@ -15,7 +26,14 @@ fun bookmarkLargeAD(
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView) AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
bind { bind {
binding.imageViewThumb.setImageAsync(item) binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
size(CoverSizeResolver(binding.imageViewThumb))
defaultPlaceholders(context)
allowRgb565(true)
bookmarkExtra(item)
decodeRegion(item.scroll)
enqueueWith(coil)
}
binding.progressView.setProgress(item.percent, false) binding.progressView.setProgress(item.percent, false)
} }
} }

View File

@@ -0,0 +1,38 @@
package org.koitharu.kotatsu.bookmarks.ui.adapter
import androidx.lifecycle.LifecycleOwner
import coil3.ImageLoader
import coil3.request.allowRgb565
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.bookmarkExtra
import org.koitharu.kotatsu.core.util.ext.decodeRegion
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
// TODO check usages
fun bookmarkListAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Bookmark>,
) = adapterDelegateViewBinding<Bookmark, Bookmark, ItemBookmarkBinding>(
{ inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) },
) {
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
bind {
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
size(CoverSizeResolver(binding.imageViewThumb))
defaultPlaceholders(context)
allowRgb565(true)
bookmarkExtra(item)
decodeRegion(item.scroll)
enqueueWith(coil)
}
}
}

View File

@@ -1,6 +1,8 @@
package org.koitharu.kotatsu.bookmarks.ui.adapter package org.koitharu.kotatsu.bookmarks.ui.adapter
import android.content.Context import android.content.Context
import androidx.lifecycle.LifecycleOwner
import coil3.ImageLoader
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
@@ -8,24 +10,24 @@ import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
class BookmarksAdapter( class BookmarksAdapter(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Bookmark>, clickListener: OnListItemClickListener<Bookmark>,
headerClickListener: ListHeaderClickListener?, headerClickListener: ListHeaderClickListener?,
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer { ) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
init { init {
addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(clickListener)) addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(coil, lifecycleOwner, clickListener))
addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener)) addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener))
addDelegate(ListItemType.STATE_ERROR, errorStateListAD(null))
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD()) addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(null)) addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, null))
} }
override fun getSectionText(context: Context, position: Int): CharSequence? { override fun getSectionText(context: Context, position: Int): CharSequence? {

View File

@@ -1,20 +0,0 @@
package org.koitharu.kotatsu.browser
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.core.network.webview.adblock.AdBlock
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import javax.inject.Inject
@AndroidEntryPoint
class AdListUpdateService : CoroutineIntentService() {
@Inject
lateinit var updater: AdBlock.Updater
override suspend fun IntentJobContext.processIntent(intent: Intent) {
updater.updateList()
}
override fun IntentJobContext.onError(error: Throwable) = Unit
}

View File

@@ -1,111 +0,0 @@
package org.koitharu.kotatsu.browser
import android.os.Bundle
import android.view.View
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
import org.koitharu.kotatsu.core.network.webview.adblock.AdBlock
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.consumeAll
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import javax.inject.Inject
@AndroidEntryPoint
abstract class BaseBrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
@Inject
lateinit var proxyProvider: ProxyProvider
@Inject
lateinit var mangaRepositoryFactory: MangaRepository.Factory
@Inject
lateinit var adBlock: AdBlock
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
return
}
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
onBackPressedDispatcher.addCallback(onBackPressedCallback)
val mangaSource = MangaSource(intent?.getStringExtra(AppRouter.KEY_SOURCE))
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
val userAgent = intent?.getStringExtra(AppRouter.KEY_USER_AGENT)?.nullIfEmpty()
?: repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
viewBinding.webView.configureForParser(userAgent)
onCreate2(savedInstanceState, mangaSource, repository)
}
protected abstract fun onCreate2(
savedInstanceState: Bundle?,
source: MangaSource,
repository: ParserMangaRepository?
)
override fun onApplyWindowInsets(
v: View,
insets: WindowInsetsCompat
): WindowInsetsCompat {
val type = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime()
val barsInsets = insets.getInsets(type)
viewBinding.webView.updatePadding(
left = barsInsets.left,
right = barsInsets.right,
bottom = barsInsets.bottom,
)
viewBinding.appbar.updatePadding(
left = barsInsets.left,
right = barsInsets.right,
top = barsInsets.top,
)
return insets.consumeAll(type)
}
override fun onPause() {
viewBinding.webView.onPause()
super.onPause()
}
override fun onResume() {
super.onResume()
viewBinding.webView.onResume()
}
override fun onDestroy() {
super.onDestroy()
if (hasViewBinding()) {
viewBinding.webView.stopLoading()
viewBinding.webView.destroy()
}
}
override fun onLoadingStateChanged(isLoading: Boolean) {
viewBinding.progressBar.isVisible = isLoading
}
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
this.title = title
supportActionBar?.subtitle = subtitle
}
override fun onHistoryChanged() {
onBackPressedCallback.onHistoryChanged()
}
}

View File

@@ -1,49 +1,68 @@
package org.koitharu.kotatsu.browser package org.koitharu.kotatsu.browser
import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.result.contract.ActivityResultContract import android.webkit.CookieManager
import androidx.lifecycle.lifecycleScope import androidx.core.graphics.Insets
import com.google.android.material.snackbar.Snackbar import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import javax.inject.Inject
import com.google.android.material.R as materialR
@AndroidEntryPoint @AndroidEntryPoint
class BrowserActivity : BaseBrowserActivity() { class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) { private lateinit var onBackPressedCallback: WebViewBackPressedCallback
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true)
viewBinding.webView.webViewClient = BrowserClient(this, adBlock) @Inject
lifecycleScope.launch { lateinit var mangaRepositoryFactory: MangaRepository.Factory
try {
proxyProvider.applyWebViewConfig() override fun onCreate(savedInstanceState: Bundle?) {
} catch (e: Exception) { super.onCreate(savedInstanceState)
e.printStackTraceDebug() if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
Snackbar.make(viewBinding.webView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show() return
} }
if (savedInstanceState == null) { supportActionBar?.run {
val url = intent?.dataString setDisplayHomeAsUpEnabled(true)
if (url.isNullOrEmpty()) { setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
finishAfterTransition() }
} else { val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
onTitleChanged( val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
intent?.getStringExtra(AppRouter.KEY_TITLE) ?: getString(R.string.loading_), val userAgent = repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
url, viewBinding.webView.configureForParser(userAgent)
) CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
viewBinding.webView.loadUrl(url) viewBinding.webView.webViewClient = BrowserClient(this)
} viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
} onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
onBackPressedDispatcher.addCallback(onBackPressedCallback)
if (savedInstanceState != null) {
return
}
val url = intent?.dataString
if (url.isNullOrEmpty()) {
finishAfterTransition()
} else {
onTitleChanged(
intent?.getStringExtra(EXTRA_TITLE) ?: getString(R.string.loading_),
url,
)
viewBinding.webView.loadUrl(url)
} }
} }
@@ -61,8 +80,14 @@ class BrowserActivity : BaseBrowserActivity() {
} }
R.id.action_browser -> { R.id.action_browser -> {
if (!router.openExternalBrowser(viewBinding.webView.url.orEmpty(), item.title)) { val url = viewBinding.webView.url?.toUriOrNull()
Snackbar.make(viewBinding.webView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() if (url != null) {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = url
try {
startActivity(Intent.createChooser(intent, item.title))
} catch (_: ActivityNotFoundException) {
}
} }
true true
} }
@@ -70,22 +95,58 @@ class BrowserActivity : BaseBrowserActivity() {
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
class Contract : ActivityResultContract<InteractiveActionRequiredException, Unit>() { override fun onPause() {
override fun createIntent( viewBinding.webView.onPause()
context: Context, super.onPause()
input: InteractiveActionRequiredException }
): Intent = AppRouter.browserIntent(
context = context,
url = input.url,
source = input.source,
title = null,
)
override fun parseResult(resultCode: Int, intent: Intent?): Unit = Unit override fun onResume() {
super.onResume()
viewBinding.webView.onResume()
}
override fun onDestroy() {
super.onDestroy()
if (hasViewBinding()) {
viewBinding.webView.stopLoading()
viewBinding.webView.destroy()
}
}
override fun onLoadingStateChanged(isLoading: Boolean) {
viewBinding.progressBar.isVisible = isLoading
}
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
this.title = title
supportActionBar?.subtitle = subtitle
}
override fun onHistoryChanged() {
onBackPressedCallback.onHistoryChanged()
}
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.appbar.updatePadding(
top = insets.top,
)
viewBinding.root.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom,
)
} }
companion object { companion object {
const val TAG = "BrowserActivity" private const val EXTRA_TITLE = "title"
private const val EXTRA_SOURCE = "source"
fun newIntent(context: Context, url: String, source: MangaSource?, title: String?): Intent {
return Intent(context, BrowserActivity::class.java)
.setData(Uri.parse(url))
.putExtra(EXTRA_TITLE, title)
.putExtra(EXTRA_SOURCE, source?.name)
}
} }
} }

View File

@@ -1,27 +1,10 @@
package org.koitharu.kotatsu.browser package org.koitharu.kotatsu.browser
import android.annotation.SuppressLint
import android.graphics.Bitmap import android.graphics.Bitmap
import android.os.Looper
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import androidx.annotation.AnyThread
import androidx.annotation.WorkerThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.core.network.webview.adblock.AdBlock
import java.io.ByteArrayInputStream
open class BrowserClient( open class BrowserClient(private val callback: BrowserCallback) : WebViewClient() {
private val callback: BrowserCallback,
private val adBlock: AdBlock?,
) : WebViewClient() {
/**
* https://stackoverflow.com/questions/57414530/illegalstateexception-reasonphrase-cant-be-empty-with-android-webview
*/
override fun onPageFinished(webView: WebView, url: String) { override fun onPageFinished(webView: WebView, url: String) {
super.onPageFinished(webView, url) super.onPageFinished(webView, url)
@@ -33,7 +16,7 @@ open class BrowserClient(
callback.onLoadingStateChanged(isLoading = true) callback.onLoadingStateChanged(isLoading = true)
} }
override fun onPageCommitVisible(view: WebView, url: String) { override fun onPageCommitVisible(view: WebView, url: String?) {
super.onPageCommitVisible(view, url) super.onPageCommitVisible(view, url)
callback.onTitleChanged(view.title.orEmpty(), url) callback.onTitleChanged(view.title.orEmpty(), url)
} }
@@ -42,39 +25,4 @@ open class BrowserClient(
super.doUpdateVisitedHistory(view, url, isReload) super.doUpdateVisitedHistory(view, url, isReload)
callback.onHistoryChanged() callback.onHistoryChanged()
} }
@WorkerThread
@Deprecated("Deprecated in Java")
override fun shouldInterceptRequest(
view: WebView?,
url: String?
): WebResourceResponse? = if (url.isNullOrEmpty() || adBlock?.shouldLoadUrl(url, view?.getUrlSafe()) ?: true) {
super.shouldInterceptRequest(view, url)
} else {
emptyResponse()
}
@WorkerThread
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?
): WebResourceResponse? =
if (request == null || adBlock?.shouldLoadUrl(request.url.toString(), view?.getUrlSafe()) ?: true) {
super.shouldInterceptRequest(view, request)
} else {
emptyResponse()
}
private fun emptyResponse(): WebResourceResponse =
WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream(byteArrayOf()))
@SuppressLint("WrongThread")
@AnyThread
private fun WebView.getUrlSafe(): String? = if (Looper.myLooper() == Looper.getMainLooper()) {
url
} else {
runBlocking(Dispatchers.Main.immediate) {
url
}
}
} }

View File

@@ -0,0 +1,106 @@
package org.koitharu.kotatsu.browser.cloudflare
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.net.toUri
import coil3.EventListener
import coil3.Extras
import coil3.request.ErrorResult
import coil3.request.ImageRequest
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.parsers.model.MangaSource
class CaptchaNotifier(
private val context: Context,
) : EventListener() {
fun notify(exception: CloudFlareProtectedException) {
if (!context.checkNotificationPermission(CHANNEL_ID)) {
return
}
val manager = NotificationManagerCompat.from(context)
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
.setName(context.getString(R.string.captcha_required))
.setShowBadge(true)
.setVibrationEnabled(false)
.setSound(null, null)
.setLightsEnabled(false)
.build()
manager.createNotificationChannel(channel)
val intent = CloudFlareActivity.newIntent(context, exception)
.setData(exception.url.toUri())
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(channel.name)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setDefaults(0)
.setSmallIcon(R.drawable.ic_bot)
.setGroup(GROUP_CAPTCHA)
.setAutoCancel(true)
.setVisibility(
if (exception.source?.isNsfw() == true) {
NotificationCompat.VISIBILITY_SECRET
} else {
NotificationCompat.VISIBILITY_PUBLIC
},
)
.setContentText(
context.getString(
R.string.captcha_required_summary,
exception.source?.getTitle(context) ?: context.getString(R.string.app_name),
),
)
.setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val actionIntent = PendingIntentCompat.getActivity(
context, SETTINGS_ACTION_CODE,
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
.putExtra(Settings.EXTRA_CHANNEL_ID, CHANNEL_ID),
0, false,
)
notification.addAction(
R.drawable.ic_settings,
context.getString(R.string.notifications_settings),
actionIntent,
)
}
manager.notify(TAG, exception.source.hashCode(), notification.build())
}
fun dismiss(source: MangaSource) {
NotificationManagerCompat.from(context).cancel(TAG, source.hashCode())
}
override fun onError(request: ImageRequest, result: ErrorResult) {
super.onError(request, result)
val e = result.throwable
if (e is CloudFlareProtectedException && request.extras[ignoreCaptchaKey] != true) {
notify(e)
}
}
companion object {
fun ImageRequest.Builder.ignoreCaptchaErrors() = apply {
extras[ignoreCaptchaKey] = true
}
val ignoreCaptchaKey = Extras.Key(false)
private const val CHANNEL_ID = "captcha"
private const val TAG = CHANNEL_ID
private const val GROUP_CAPTCHA = "org.koitharu.kotatsu.CAPTCHA"
private const val SETTINGS_ACTION_CODE = 3
}
}

View File

@@ -1,70 +1,87 @@
package org.koitharu.kotatsu.browser.cloudflare package org.koitharu.kotatsu.browser.cloudflare
import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.webkit.CookieManager
import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContract
import androidx.core.graphics.Insets
import androidx.core.net.toUri
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import okhttp3.Headers
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BaseBrowserActivity import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR
@AndroidEntryPoint @AndroidEntryPoint
class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback { class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCallback {
private var pendingResult = RESULT_CANCELED private var pendingResult = RESULT_CANCELED
@Inject @Inject
lateinit var cookieJar: MutableCookieJar lateinit var cookieJar: MutableCookieJar
@Inject
lateinit var captchaHandler: CaptchaHandler
private lateinit var cfClient: CloudFlareClient private lateinit var cfClient: CloudFlareClient
private var onBackPressedCallback: WebViewBackPressedCallback? = null
override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) { override fun onCreate(savedInstanceState: Bundle?) {
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true) super.onCreate(savedInstanceState)
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
return
}
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
}
val url = intent?.dataString val url = intent?.dataString
if (url.isNullOrEmpty()) { if (url.isNullOrEmpty()) {
finishAfterTransition() finishAfterTransition()
return return
} }
cfClient = CloudFlareClient(cookieJar, this, adBlock, url) cfClient = CloudFlareClient(cookieJar, this, url)
viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA))
viewBinding.webView.webViewClient = cfClient viewBinding.webView.webViewClient = cfClient
lifecycleScope.launch { onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
try { onBackPressedDispatcher.addCallback(it)
proxyProvider.applyWebViewConfig()
} catch (e: Exception) {
Snackbar.make(viewBinding.webView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
}
if (savedInstanceState == null) {
onTitleChanged(getString(R.string.loading_), url)
viewBinding.webView.loadUrl(url)
}
} }
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
if (savedInstanceState == null) {
onTitleChanged(getString(R.string.loading_), url)
viewBinding.webView.loadUrl(url)
}
}
override fun onDestroy() {
runCatching {
viewBinding.webView
}.onSuccess {
it.stopLoading()
it.destroy()
}
super.onDestroy()
} }
override fun onCreateOptionsMenu(menu: Menu?): Boolean { override fun onCreateOptionsMenu(menu: Menu?): Boolean {
@@ -72,6 +89,17 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
return super.onCreateOptionsMenu(menu) return super.onCreateOptionsMenu(menu)
} }
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.appbar.updatePadding(
top = insets.top,
)
viewBinding.root.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom,
)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
android.R.id.home -> { android.R.id.home -> {
viewBinding.webView.stopLoading() viewBinding.webView.stopLoading()
@@ -87,13 +115,21 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
override fun onResume() {
super.onResume()
viewBinding.webView.onResume()
}
override fun onPause() {
viewBinding.webView.onPause()
super.onPause()
}
override fun finish() { override fun finish() {
setResult(pendingResult) setResult(pendingResult)
super.finish() super.finish()
} }
override fun onLoadingStateChanged(isLoading: Boolean) = Unit
override fun onPageLoaded() { override fun onPageLoaded() {
viewBinding.progressBar.isInvisible = true viewBinding.progressBar.isInvisible = true
} }
@@ -104,22 +140,25 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
override fun onCheckPassed() { override fun onCheckPassed() {
pendingResult = RESULT_OK pendingResult = RESULT_OK
lifecycleScope.launch { val source = intent?.getStringExtra(ARG_SOURCE)
val source = intent?.getStringExtra(AppRouter.KEY_SOURCE) if (source != null) {
if (source != null) { CaptchaNotifier(this).dismiss(MangaSource(source))
runCatchingCancellable {
captchaHandler.discard(MangaSource(source))
}.onFailure {
it.printStackTraceDebug()
}
}
finishAfterTransition()
} }
finishAfterTransition()
}
override fun onLoadingStateChanged(isLoading: Boolean) {
viewBinding.progressBar.isVisible = isLoading
}
override fun onHistoryChanged() {
onBackPressedCallback?.onHistoryChanged()
} }
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) { override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
setTitle(title) setTitle(title)
supportActionBar?.subtitle = subtitle?.toString()?.toHttpUrlOrNull()?.host.ifNullOrEmpty { subtitle } supportActionBar?.subtitle =
subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
} }
private fun restartCheck() { private fun restartCheck() {
@@ -143,16 +182,38 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
class Contract : ActivityResultContract<CloudFlareProtectedException, Boolean>() { class Contract : ActivityResultContract<CloudFlareProtectedException, Boolean>() {
override fun createIntent(context: Context, input: CloudFlareProtectedException): Intent { override fun createIntent(context: Context, input: CloudFlareProtectedException): Intent {
return AppRouter.cloudFlareResolveIntent(context, input) return newIntent(context, input)
} }
override fun parseResult(resultCode: Int, intent: Intent?): Boolean { override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
return resultCode == RESULT_OK return resultCode == Activity.RESULT_OK
} }
} }
companion object { companion object {
const val TAG = "CloudFlareActivity" const val TAG = "CloudFlareActivity"
private const val ARG_UA = "ua"
private const val ARG_SOURCE = "_source"
fun newIntent(context: Context, exception: CloudFlareProtectedException) = newIntent(
context = context,
url = exception.url,
source = exception.source,
headers = exception.headers,
)
private fun newIntent(
context: Context,
url: String,
source: MangaSource?,
headers: Headers?,
) = Intent(context, CloudFlareActivity::class.java).apply {
data = url.toUri()
putExtra(ARG_SOURCE, source?.name)
headers?.get(CommonHeaders.USER_AGENT)?.let {
putExtra(ARG_UA, it)
}
}
} }
} }

View File

@@ -4,6 +4,8 @@ import org.koitharu.kotatsu.browser.BrowserCallback
interface CloudFlareCallback : BrowserCallback { interface CloudFlareCallback : BrowserCallback {
override fun onLoadingStateChanged(isLoading: Boolean) = Unit
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) = Unit override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) = Unit
fun onPageLoaded() fun onPageLoaded()

View File

@@ -4,7 +4,6 @@ import android.graphics.Bitmap
import android.webkit.WebView import android.webkit.WebView
import org.koitharu.kotatsu.browser.BrowserClient import org.koitharu.kotatsu.browser.BrowserClient
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.network.webview.adblock.AdBlock
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
private const val LOOP_COUNTER = 3 private const val LOOP_COUNTER = 3
@@ -12,9 +11,8 @@ private const val LOOP_COUNTER = 3
class CloudFlareClient( class CloudFlareClient(
private val cookieJar: MutableCookieJar, private val cookieJar: MutableCookieJar,
private val callback: CloudFlareCallback, private val callback: CloudFlareCallback,
adBlock: AdBlock,
private val targetUrl: String, private val targetUrl: String,
) : BrowserClient(callback, adBlock) { ) : BrowserClient(callback) {
private val oldClearance = getClearance() private val oldClearance = getClearance()
private var counter = 0 private var counter = 0
@@ -24,7 +22,7 @@ class CloudFlareClient(
checkClearance() checkClearance()
} }
override fun onPageCommitVisible(view: WebView, url: String) { override fun onPageCommitVisible(view: WebView, url: String?) {
super.onPageCommitVisible(view, url) super.onPageCommitVisible(view, url)
callback.onPageLoaded() callback.onPageLoaded()
} }

View File

@@ -6,7 +6,6 @@ import android.os.Build
import android.provider.SearchRecentSuggestions import android.provider.SearchRecentSuggestions
import android.text.Html import android.text.Html
import androidx.collection.arraySetOf import androidx.collection.arraySetOf
import androidx.core.content.ContextCompat
import androidx.room.InvalidationTracker import androidx.room.InvalidationTracker
import androidx.work.WorkManager import androidx.work.WorkManager
import coil3.ImageLoader import coil3.ImageLoader
@@ -31,9 +30,8 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.backups.domain.BackupObserver import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler
import org.koitharu.kotatsu.core.image.AvifImageDecoder import org.koitharu.kotatsu.core.image.AvifImageDecoder
import org.koitharu.kotatsu.core.image.CbzFetcher import org.koitharu.kotatsu.core.image.CbzFetcher
import org.koitharu.kotatsu.core.image.MangaSourceHeaderInterceptor import org.koitharu.kotatsu.core.image.MangaSourceHeaderInterceptor
@@ -42,27 +40,25 @@ import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.util.AcraScreenLogger import org.koitharu.kotatsu.core.util.AcraScreenLogger
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.connectivityManager import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageFetcher import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageFetcher
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageKeyer import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageKeyer
import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.FaviconCache
import org.koitharu.kotatsu.local.data.LocalStorageCache
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.PageCache
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
import org.koitharu.kotatsu.settings.backup.BackupObserver
import org.koitharu.kotatsu.sync.domain.SyncController import org.koitharu.kotatsu.sync.domain.SyncController
import org.koitharu.kotatsu.widget.WidgetUpdater import org.koitharu.kotatsu.widget.WidgetUpdater
import javax.inject.Provider import javax.inject.Provider
@@ -80,12 +76,6 @@ interface AppModule {
companion object { companion object {
@Provides
@LocalizedAppContext
fun provideLocalizedContext(
@ApplicationContext context: Context,
): Context = ContextCompat.getContextForLanguage(context)
@Provides @Provides
@Singleton @Singleton
fun provideNetworkState( fun provideNetworkState(
@@ -102,14 +92,13 @@ interface AppModule {
@Provides @Provides
@Singleton @Singleton
fun provideCoil( fun provideCoil(
@LocalizedAppContext context: Context, @ApplicationContext context: Context,
@MangaHttpClient okHttpClientProvider: Provider<OkHttpClient>, @MangaHttpClient okHttpClientProvider: Provider<OkHttpClient>,
faviconFetcherFactory: FaviconFetcher.Factory, mangaRepositoryFactory: MangaRepository.Factory,
imageProxyInterceptor: ImageProxyInterceptor, imageProxyInterceptor: ImageProxyInterceptor,
pageFetcherFactory: MangaPageFetcher.Factory, pageFetcherFactory: MangaPageFetcher.Factory,
coverRestoreInterceptor: CoverRestoreInterceptor, coverRestoreInterceptor: CoverRestoreInterceptor,
networkStateProvider: Provider<NetworkState>, networkStateProvider: Provider<NetworkState>,
captchaHandler: CaptchaHandler,
): ImageLoader { ): ImageLoader {
val diskCacheFactory = { val diskCacheFactory = {
val rootDir = context.externalCacheDir ?: context.cacheDir val rootDir = context.externalCacheDir ?: context.cacheDir
@@ -125,7 +114,7 @@ interface AppModule {
.diskCache(diskCacheFactory) .diskCache(diskCacheFactory)
.logger(if (BuildConfig.DEBUG) DebugLogger() else null) .logger(if (BuildConfig.DEBUG) DebugLogger() else null)
.allowRgb565(context.isLowRamDevice()) .allowRgb565(context.isLowRamDevice())
.eventListener(captchaHandler) .eventListener(CaptchaNotifier(context))
.components { .components {
add( add(
OkHttpNetworkFetcherFactory( OkHttpNetworkFetcherFactory(
@@ -141,7 +130,7 @@ interface AppModule {
add(SvgDecoder.Factory()) add(SvgDecoder.Factory())
add(CbzFetcher.Factory()) add(CbzFetcher.Factory())
add(AvifImageDecoder.Factory()) add(AvifImageDecoder.Factory())
add(faviconFetcherFactory) add(FaviconFetcher.Factory(mangaRepositoryFactory))
add(MangaPageKeyer()) add(MangaPageKeyer())
add(pageFetcherFactory) add(pageFetcherFactory)
add(imageProxyInterceptor) add(imageProxyInterceptor)
@@ -198,29 +187,5 @@ interface AppModule {
fun provideWorkManager( fun provideWorkManager(
@ApplicationContext context: Context, @ApplicationContext context: Context,
): WorkManager = WorkManager.getInstance(context) ): WorkManager = WorkManager.getInstance(context)
@Provides
@Singleton
@PageCache
fun providePageCache(
@ApplicationContext context: Context,
) = LocalStorageCache(
context = context,
dir = CacheDir.PAGES,
defaultSize = FileSize.MEGABYTES.convert(200, FileSize.BYTES),
minSize = FileSize.MEGABYTES.convert(20, FileSize.BYTES),
)
@Provides
@Singleton
@FaviconCache
fun provideFaviconCache(
@ApplicationContext context: Context,
) = LocalStorageCache(
context = context,
dir = CacheDir.FAVICONS,
defaultSize = FileSize.MEGABYTES.convert(8, FileSize.BYTES),
minSize = FileSize.MEGABYTES.convert(2, FileSize.BYTES),
)
} }
} }

View File

@@ -13,6 +13,7 @@ import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.acra.ACRA import org.acra.ACRA
import org.acra.ReportField import org.acra.ReportField
import org.acra.config.dialog import org.acra.config.dialog
@@ -25,14 +26,12 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.os.AppValidator import org.koitharu.kotatsu.core.os.AppValidator
import org.koitharu.kotatsu.core.os.RomCompat
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrNull
import org.koitharu.kotatsu.settings.work.WorkScheduleManager import org.koitharu.kotatsu.settings.work.WorkScheduleManager
import java.security.Security import java.security.Security
import javax.inject.Inject import javax.inject.Inject
@@ -83,14 +82,17 @@ open class BaseApp : Application(), Configuration.Provider {
return return
} }
AppCompatDelegate.setDefaultNightMode(settings.theme) AppCompatDelegate.setDefaultNightMode(settings.theme)
AppCompatDelegate.setApplicationLocales(settings.appLocales)
// TLS 1.3 support for Android < 10 // TLS 1.3 support for Android < 10
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
Security.insertProviderAt(Conscrypt.newProvider(), 1) Security.insertProviderAt(Conscrypt.newProvider(), 1)
} }
setupActivityLifecycleCallbacks() setupActivityLifecycleCallbacks()
processLifecycleScope.launch { processLifecycleScope.launch {
ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.getOrNull().toString()) val isOriginalApp = withContext(Dispatchers.Default) {
ACRA.errorReporter.putCustomData("isMiui", RomCompat.isMiui.getOrNull().toString()) appValidator.isOriginalApp
}
ACRA.errorReporter.putCustomData("isOriginalApp", isOriginalApp.toString())
} }
processLifecycleScope.launch(Dispatchers.Default) { processLifecycleScope.launch(Dispatchers.Default) {
setupDatabaseObservers() setupDatabaseObservers()
@@ -102,9 +104,6 @@ open class BaseApp : Application(), Configuration.Provider {
override fun attachBaseContext(base: Context) { override fun attachBaseContext(base: Context) {
super.attachBaseContext(base) super.attachBaseContext(base)
if (ACRA.isACRASenderServiceProcess()) {
return
}
initAcra { initAcra {
buildConfigClass = BuildConfig::class.java buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON reportFormat = StringFormat.JSON

View File

@@ -4,13 +4,10 @@ import android.app.PendingIntent
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.core.app.NotificationCompat import android.net.Uri
import androidx.core.app.NotificationManagerCompat import android.os.BadParcelableException
import androidx.core.app.PendingIntentCompat import androidx.core.app.PendingIntentCompat
import androidx.core.net.toUri
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.report import org.koitharu.kotatsu.core.util.ext.report
@@ -18,63 +15,24 @@ import org.koitharu.kotatsu.core.util.ext.report
class ErrorReporterReceiver : BroadcastReceiver() { class ErrorReporterReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
val e = intent?.getSerializableExtraCompat<Throwable>(AppRouter.KEY_ERROR) ?: return val e = intent?.getSerializableExtraCompat<Throwable>(EXTRA_ERROR) ?: return
val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, 0)
if (notificationId != 0 && context != null) {
val notificationTag = intent.getStringExtra(EXTRA_NOTIFICATION_TAG)
NotificationManagerCompat.from(context).cancel(notificationTag, notificationId)
}
e.report() e.report()
} }
companion object { companion object {
private const val EXTRA_ERROR = "err"
private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR" private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR"
private const val EXTRA_NOTIFICATION_ID = "notify.id"
private const val EXTRA_NOTIFICATION_TAG = "notify.tag"
fun getPendingIntent(context: Context, e: Throwable): PendingIntent? = getPendingIntentInternal( fun getPendingIntent(context: Context, e: Throwable): PendingIntent? = try {
context = context,
e = e,
notificationId = 0,
notificationTag = null,
)
fun getNotificationAction(
context: Context,
e: Throwable,
notificationId: Int,
notificationTag: String?,
): NotificationCompat.Action? {
val intent = getPendingIntentInternal(
context = context,
e = e,
notificationId = notificationId,
notificationTag = notificationTag,
) ?: return null
return NotificationCompat.Action(
R.drawable.ic_alert_outline,
context.getString(R.string.report),
intent,
)
}
private fun getPendingIntentInternal(
context: Context,
e: Throwable,
notificationId: Int,
notificationTag: String?,
): PendingIntent? = runCatching {
val intent = Intent(context, ErrorReporterReceiver::class.java) val intent = Intent(context, ErrorReporterReceiver::class.java)
intent.setAction(ACTION_REPORT) intent.setAction(ACTION_REPORT)
intent.setData("err://${e.hashCode()}".toUri()) intent.setData(Uri.parse("err://${e.hashCode()}"))
intent.putExtra(AppRouter.KEY_ERROR, e) intent.putExtra(EXTRA_ERROR, e)
intent.putExtra(EXTRA_NOTIFICATION_ID, notificationId)
intent.putExtra(EXTRA_NOTIFICATION_TAG, notificationTag)
PendingIntentCompat.getBroadcast(context, 0, intent, 0, false) PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)
}.onFailure { e -> } catch (e: BadParcelableException) {
// probably cannot write exception as serializable
e.printStackTraceDebug() e.printStackTraceDebug()
}.getOrNull() null
}
} }
} }

View File

@@ -1,13 +0,0 @@
package org.koitharu.kotatsu.core
import javax.inject.Qualifier
@Qualifier
@Target(
AnnotationTarget.FUNCTION,
AnnotationTarget.PROPERTY_GETTER,
AnnotationTarget.PROPERTY_SETTER,
AnnotationTarget.VALUE_PARAMETER,
AnnotationTarget.FIELD,
)
annotation class LocalizedAppContext

View File

@@ -0,0 +1,22 @@
package org.koitharu.kotatsu.core.backup
import org.json.JSONArray
class BackupEntry(
val name: Name,
val data: JSONArray
) {
enum class Name(
val key: String,
) {
INDEX("index"),
HISTORY("history"),
CATEGORIES("categories"),
FAVOURITES("favourites"),
SETTINGS("settings"),
BOOKMARKS("bookmarks"),
SOURCES("sources"),
}
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.backups.domain package org.koitharu.kotatsu.core.backup
import android.net.Uri import android.net.Uri
import java.util.Date import java.util.Date
@@ -6,7 +6,7 @@ import java.util.Date
data class BackupFile( data class BackupFile(
val uri: Uri, val uri: Uri,
val dateTime: Date, val dateTime: Date,
) : Comparable<BackupFile> { ): Comparable<BackupFile> {
override fun compareTo(other: BackupFile): Int = compareValues(dateTime, other.dateTime) override fun compareTo(other: BackupFile): Int = compareValues(dateTime, other.dateTime)
} }

View File

@@ -0,0 +1,224 @@
package org.koitharu.kotatsu.core.backup
import androidx.room.withTransaction
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.util.json.asTypedList
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Date
import javax.inject.Inject
private const val PAGE_SIZE = 10
class BackupRepository @Inject constructor(
private val db: MangaDatabase,
private val settings: AppSettings,
) {
suspend fun dumpHistory(): BackupEntry {
var offset = 0
val entry = BackupEntry(BackupEntry.Name.HISTORY, JSONArray())
while (true) {
val history = db.getHistoryDao().findAll(offset, PAGE_SIZE)
if (history.isEmpty()) {
break
}
offset += history.size
for (item in history) {
val manga = JsonSerializer(item.manga).toJson()
val tags = JSONArray()
item.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
manga.put("tags", tags)
val json = JsonSerializer(item.history).toJson()
json.put("manga", manga)
entry.data.put(json)
}
}
return entry
}
suspend fun dumpCategories(): BackupEntry {
val entry = BackupEntry(BackupEntry.Name.CATEGORIES, JSONArray())
val categories = db.getFavouriteCategoriesDao().findAll()
for (item in categories) {
entry.data.put(JsonSerializer(item).toJson())
}
return entry
}
suspend fun dumpFavourites(): BackupEntry {
var offset = 0
val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray())
while (true) {
val favourites = db.getFavouritesDao().findAllRaw(offset, PAGE_SIZE)
if (favourites.isEmpty()) {
break
}
offset += favourites.size
for (item in favourites) {
val manga = JsonSerializer(item.manga).toJson()
val tags = JSONArray()
item.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
manga.put("tags", tags)
val json = JsonSerializer(item.favourite).toJson()
json.put("manga", manga)
entry.data.put(json)
}
}
return entry
}
suspend fun dumpBookmarks(): BackupEntry {
val entry = BackupEntry(BackupEntry.Name.BOOKMARKS, JSONArray())
val all = db.getBookmarksDao().findAll()
for ((m, b) in all) {
val json = JSONObject()
val manga = JsonSerializer(m.manga).toJson()
json.put("manga", manga)
val tags = JSONArray()
m.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
json.put("tags", tags)
val bookmarks = JSONArray()
b.forEach { bookmarks.put(JsonSerializer(it).toJson()) }
json.put("bookmarks", bookmarks)
entry.data.put(json)
}
return entry
}
fun dumpSettings(): BackupEntry {
val entry = BackupEntry(BackupEntry.Name.SETTINGS, JSONArray())
val settingsDump = settings.getAllValues().toMutableMap()
settingsDump.remove(AppSettings.KEY_APP_PASSWORD)
settingsDump.remove(AppSettings.KEY_PROXY_PASSWORD)
settingsDump.remove(AppSettings.KEY_PROXY_LOGIN)
settingsDump.remove(AppSettings.KEY_INCOGNITO_MODE)
val json = JsonSerializer(settingsDump).toJson()
entry.data.put(json)
return entry
}
suspend fun dumpSources(): BackupEntry {
val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray())
val all = db.getSourcesDao().findAll()
for (source in all) {
val json = JsonSerializer(source).toJson()
entry.data.put(json)
}
return entry
}
fun createIndex(): BackupEntry {
val entry = BackupEntry(BackupEntry.Name.INDEX, JSONArray())
val json = JSONObject()
json.put("app_id", BuildConfig.APPLICATION_ID)
json.put("app_version", BuildConfig.VERSION_CODE)
json.put("created_at", System.currentTimeMillis())
entry.data.put(json)
return entry
}
fun getBackupDate(entry: BackupEntry?): Date? {
val timestamp = entry?.data?.optJSONObject(0)?.getLongOrDefault("created_at", 0) ?: 0
return if (timestamp == 0L) null else Date(timestamp)
}
suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.asTypedList<JSONObject>()) {
val mangaJson = item.getJSONObject("manga")
val manga = JsonDeserializer(mangaJson).toMangaEntity()
val tags = mangaJson.getJSONArray("tags").mapJSON {
JsonDeserializer(it).toTagEntity()
}
val history = JsonDeserializer(item).toHistoryEntity()
result += runCatchingCancellable {
db.withTransaction {
db.getTagsDao().upsert(tags)
db.getMangaDao().upsert(manga, tags)
db.getHistoryDao().upsert(history)
}
}
}
return result
}
suspend fun restoreCategories(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.asTypedList<JSONObject>()) {
val category = JsonDeserializer(item).toFavouriteCategoryEntity()
result += runCatchingCancellable {
db.getFavouriteCategoriesDao().upsert(category)
}
}
return result
}
suspend fun restoreFavourites(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.asTypedList<JSONObject>()) {
val mangaJson = item.getJSONObject("manga")
val manga = JsonDeserializer(mangaJson).toMangaEntity()
val tags = mangaJson.getJSONArray("tags").mapJSON {
JsonDeserializer(it).toTagEntity()
}
val favourite = JsonDeserializer(item).toFavouriteEntity()
result += runCatchingCancellable {
db.withTransaction {
db.getTagsDao().upsert(tags)
db.getMangaDao().upsert(manga, tags)
db.getFavouritesDao().upsert(favourite)
}
}
}
return result
}
suspend fun restoreBookmarks(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.asTypedList<JSONObject>()) {
val mangaJson = item.getJSONObject("manga")
val manga = JsonDeserializer(mangaJson).toMangaEntity()
val tags = item.getJSONArray("tags").mapJSON {
JsonDeserializer(it).toTagEntity()
}
val bookmarks = item.getJSONArray("bookmarks").mapJSON {
JsonDeserializer(it).toBookmarkEntity()
}
result += runCatchingCancellable {
db.withTransaction {
db.getTagsDao().upsert(tags)
db.getMangaDao().upsert(manga, tags)
db.getBookmarksDao().upsert(bookmarks)
}
}
}
return result
}
suspend fun restoreSources(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.asTypedList<JSONObject>()) {
val source = JsonDeserializer(item).toMangaSourceEntity()
result += runCatchingCancellable {
db.getSourcesDao().upsert(source)
}
}
return result
}
fun restoreSettings(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.asTypedList<JSONObject>()) {
result += runCatchingCancellable {
settings.upsertAll(JsonDeserializer(item).toMap())
}
}
return result
}
}

View File

@@ -0,0 +1,61 @@
package org.koitharu.kotatsu.core.backup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okhttp3.internal.closeQuietly
import okio.Closeable
import org.json.JSONArray
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
import java.io.File
import java.util.EnumSet
import java.util.zip.ZipException
import java.util.zip.ZipFile
class BackupZipInput private constructor(val file: File) : Closeable {
private val zipFile = ZipFile(file)
suspend fun getEntry(name: BackupEntry.Name): BackupEntry? = runInterruptible(Dispatchers.IO) {
val entry = zipFile.getEntry(name.key) ?: return@runInterruptible null
val json = zipFile.getInputStream(entry).use {
JSONArray(it.bufferedReader().readText())
}
BackupEntry(name, json)
}
suspend fun entries(): Set<BackupEntry.Name> = runInterruptible(Dispatchers.IO) {
zipFile.entries().toList().mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { ze ->
BackupEntry.Name.entries.find { it.key == ze.name }
}
}
override fun close() {
zipFile.close()
}
fun closeAndDelete() {
closeQuietly()
file.delete()
}
companion object {
fun from(file: File): BackupZipInput {
var res: BackupZipInput? = null
return try {
res = BackupZipInput(file)
if (res.zipFile.getEntry("index") == null) {
throw BadBackupFormatException(null)
}
res
} catch (exception: Throwable) {
res?.closeQuietly()
throw if (exception is ZipException) {
BadBackupFormatException(exception)
} else {
exception
}
}
}
}
}

View File

@@ -0,0 +1,60 @@
package org.koitharu.kotatsu.core.backup
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.Closeable
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.zip.ZipOutput
import java.io.File
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.zip.Deflater
class BackupZipOutput(val file: File) : Closeable {
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) {
output.put(entry.name.key, entry.data.toString(2))
}
suspend fun finish() = runInterruptible(Dispatchers.IO) {
output.finish()
}
override fun close() {
output.close()
}
companion object {
const val DIR_BACKUPS = "backups"
private val dateTimeFormat = SimpleDateFormat("yyyyMMdd-HHmm")
fun generateFileName(context: Context) = buildString {
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
append('_')
append(dateTimeFormat.format(Date()))
append(".bk.zip")
}
fun parseBackupDateTime(fileName: String): Date? = try {
dateTimeFormat.parse(fileName.substringAfterLast('_').substringBefore('.'))
} catch (e: ParseException) {
e.printStackTraceDebug()
null
}
suspend fun createTemp(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
val dir = context.run {
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
}
dir.mkdirs()
BackupZipOutput(File(dir, generateFileName(context)))
}
}
}

View File

@@ -0,0 +1,42 @@
package org.koitharu.kotatsu.core.backup
class CompositeResult {
private var successCount: Int = 0
private val errors = ArrayList<Throwable?>()
val size: Int
get() = successCount + errors.size
val failures: List<Throwable>
get() = errors.filterNotNull()
val isEmpty: Boolean
get() = errors.isEmpty() && successCount == 0
val isAllSuccess: Boolean
get() = errors.none { it != null }
val isAllFailed: Boolean
get() = successCount == 0 && errors.isNotEmpty()
operator fun plusAssign(result: Result<*>) {
when {
result.isSuccess -> successCount++
result.isFailure -> errors.add(result.exceptionOrNull())
}
}
operator fun plusAssign(other: CompositeResult) {
this.successCount += other.successCount
this.errors += other.errors
}
operator fun plus(other: CompositeResult): CompositeResult {
val result = CompositeResult()
result.successCount = this.successCount + other.successCount
result.errors.addAll(this.errors)
result.errors.addAll(other.errors)
return result
}
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.backups.domain package org.koitharu.kotatsu.core.backup
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
@@ -28,7 +28,7 @@ class ExternalBackupStorage @Inject constructor(
BackupFile( BackupFile(
uri = it.uri, uri = it.uri,
dateTime = it.name?.let { fileName -> dateTime = it.name?.let { fileName ->
BackupUtils.parseBackupDateTime(fileName) BackupZipOutput.parseBackupDateTime(fileName)
} ?: return@mapNotNull null, } ?: return@mapNotNull null,
) )
} else { } else {
@@ -44,12 +44,7 @@ class ExternalBackupStorage @Inject constructor(
}.getOrNull() }.getOrNull()
suspend fun put(file: File): Uri = runInterruptible(Dispatchers.IO) { suspend fun put(file: File): Uri = runInterruptible(Dispatchers.IO) {
val out = checkNotNull( val out = checkNotNull(getRootOrThrow().createFile("application/zip", file.nameWithoutExtension)) {
getRootOrThrow().createFile(
"application/zip",
file.nameWithoutExtension,
),
) {
"Cannot create target backup file" "Cannot create target backup file"
} }
checkNotNull(context.contentResolver.openOutputStream(out.uri, "wt")).sink().use { sink -> checkNotNull(context.contentResolver.openOutputStream(out.uri, "wt")).sink().use { sink ->

View File

@@ -0,0 +1,105 @@
package org.koitharu.kotatsu.core.backup
import org.json.JSONObject
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
class JsonDeserializer(private val json: JSONObject) {
fun toFavouriteEntity() = FavouriteEntity(
mangaId = json.getLong("manga_id"),
categoryId = json.getLong("category_id"),
sortKey = json.getIntOrDefault("sort_key", 0),
createdAt = json.getLong("created_at"),
deletedAt = 0L,
)
fun toMangaEntity() = MangaEntity(
id = json.getLong("id"),
title = json.getString("title"),
altTitle = json.getStringOrNull("alt_title"),
url = json.getString("url"),
publicUrl = json.getStringOrNull("public_url").orEmpty(),
rating = json.getDouble("rating").toFloat(),
isNsfw = json.getBooleanOrDefault("nsfw", false),
coverUrl = json.getString("cover_url"),
largeCoverUrl = json.getStringOrNull("large_cover_url"),
state = json.getStringOrNull("state"),
author = json.getStringOrNull("author"),
source = json.getString("source"),
)
fun toTagEntity() = TagEntity(
id = json.getLong("id"),
title = json.getString("title"),
key = json.getString("key"),
source = json.getString("source"),
)
fun toHistoryEntity() = HistoryEntity(
mangaId = json.getLong("manga_id"),
createdAt = json.getLong("created_at"),
updatedAt = json.getLong("updated_at"),
chapterId = json.getLong("chapter_id"),
page = json.getInt("page"),
scroll = json.getDouble("scroll").toFloat(),
percent = json.getFloatOrDefault("percent", -1f),
chaptersCount = json.getIntOrDefault("chapters", -1),
deletedAt = 0L,
)
fun toFavouriteCategoryEntity() = FavouriteCategoryEntity(
categoryId = json.getInt("category_id"),
createdAt = json.getLong("created_at"),
sortKey = json.getInt("sort_key"),
title = json.getString("title"),
order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name,
track = json.getBooleanOrDefault("track", true),
isVisibleInLibrary = json.getBooleanOrDefault("show_in_lib", true),
deletedAt = 0L,
)
fun toBookmarkEntity() = BookmarkEntity(
mangaId = json.getLong("manga_id"),
pageId = json.getLong("page_id"),
chapterId = json.getLong("chapter_id"),
page = json.getInt("page"),
scroll = json.getInt("scroll"),
imageUrl = json.getString("image_url"),
createdAt = json.getLong("created_at"),
percent = json.getDouble("percent").toFloat(),
)
fun toMangaSourceEntity() = MangaSourceEntity(
source = json.getString("source"),
isEnabled = json.getBoolean("enabled"),
sortKey = json.getInt("sort_key"),
addedIn = json.getIntOrDefault("added_in", 0),
lastUsedAt = json.getLongOrDefault("used_at", 0L),
isPinned = json.getBooleanOrDefault("pinned", false),
)
fun toMap(): Map<String, Any?> {
val map = mutableMapOf<String, Any?>()
val keys = json.keys()
while (keys.hasNext()) {
val key = keys.next()
val value = json.get(key)
map[key] = value
}
return map
}
}

View File

@@ -0,0 +1,103 @@
package org.koitharu.kotatsu.core.backup
import org.json.JSONObject
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.history.data.HistoryEntity
class JsonSerializer private constructor(private val json: JSONObject) {
constructor(e: FavouriteEntity) : this(
JSONObject().apply {
put("manga_id", e.mangaId)
put("category_id", e.categoryId)
put("sort_key", e.sortKey)
put("created_at", e.createdAt)
},
)
constructor(e: FavouriteCategoryEntity) : this(
JSONObject().apply {
put("category_id", e.categoryId)
put("created_at", e.createdAt)
put("sort_key", e.sortKey)
put("title", e.title)
put("order", e.order)
put("track", e.track)
put("show_in_lib", e.isVisibleInLibrary)
},
)
constructor(e: HistoryEntity) : this(
JSONObject().apply {
put("manga_id", e.mangaId)
put("created_at", e.createdAt)
put("updated_at", e.updatedAt)
put("chapter_id", e.chapterId)
put("page", e.page)
put("scroll", e.scroll)
put("percent", e.percent)
put("chapters", e.chaptersCount)
},
)
constructor(e: TagEntity) : this(
JSONObject().apply {
put("id", e.id)
put("title", e.title)
put("key", e.key)
put("source", e.source)
},
)
constructor(e: MangaEntity) : this(
JSONObject().apply {
put("id", e.id)
put("title", e.title)
put("alt_title", e.altTitle)
put("url", e.url)
put("public_url", e.publicUrl)
put("rating", e.rating)
put("nsfw", e.isNsfw)
put("cover_url", e.coverUrl)
put("large_cover_url", e.largeCoverUrl)
put("state", e.state)
put("author", e.author)
put("source", e.source)
},
)
constructor(e: BookmarkEntity) : this(
JSONObject().apply {
put("manga_id", e.mangaId)
put("page_id", e.pageId)
put("chapter_id", e.chapterId)
put("page", e.page)
put("scroll", e.scroll)
put("image_url", e.imageUrl)
put("created_at", e.createdAt)
put("percent", e.percent)
},
)
constructor(e: MangaSourceEntity) : this(
JSONObject().apply {
put("source", e.source)
put("enabled", e.isEnabled)
put("sort_key", e.sortKey)
put("added_in", e.addedIn)
put("used_at", e.lastUsedAt)
put("pinned", e.isPinned)
},
)
constructor(m: Map<String, *>) : this(
JSONObject(m),
)
fun toJson(): JSONObject = json
}

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.core.cache package org.koitharu.kotatsu.core.cache
import org.koitharu.kotatsu.core.util.SynchronizedSieveCache import androidx.collection.LruCache
import org.koitharu.kotatsu.parsers.model.MangaSource
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import org.koitharu.kotatsu.core.cache.MemoryContentCache.Key as CacheKey import org.koitharu.kotatsu.core.cache.MemoryContentCache.Key as CacheKey
@@ -9,9 +8,11 @@ class ExpiringLruCache<T>(
val maxSize: Int, val maxSize: Int,
private val lifetime: Long, private val lifetime: Long,
private val timeUnit: TimeUnit, private val timeUnit: TimeUnit,
) { ) : Iterable<CacheKey> {
private val cache = SynchronizedSieveCache<CacheKey, ExpiringValue<T>>(maxSize) private val cache = LruCache<CacheKey, ExpiringValue<T>>(maxSize)
override fun iterator(): Iterator<CacheKey> = cache.snapshot().keys.iterator()
operator fun get(key: CacheKey): T? { operator fun get(key: CacheKey): T? {
val value = cache[key] ?: return null val value = cache[key] ?: return null
@@ -22,8 +23,7 @@ class ExpiringLruCache<T>(
} }
operator fun set(key: CacheKey, value: T) { operator fun set(key: CacheKey, value: T) {
val value = ExpiringValue(value, lifetime, timeUnit) cache.put(key, ExpiringValue(value, lifetime, timeUnit))
cache.put(key, value)
} }
fun clear() { fun clear() {
@@ -37,8 +37,4 @@ class ExpiringLruCache<T>(
fun remove(key: CacheKey) { fun remove(key: CacheKey) {
cache.remove(key) cache.remove(key)
} }
fun removeAll(source: MangaSource) {
cache.removeIf { key, _ -> key.source == source }
}
} }

View File

@@ -81,7 +81,11 @@ class MemoryContentCache @Inject constructor(application: Application) : Compone
} }
private fun clearCache(cache: ExpiringLruCache<*>, source: MangaSource) { private fun clearCache(cache: ExpiringLruCache<*>, source: MangaSource) {
cache.removeAll(source) cache.forEach { key ->
if (key.source == source) {
cache.remove(key)
}
}
} }
data class Key( data class Key(

View File

@@ -12,13 +12,11 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.bookmarks.data.BookmarksDao import org.koitharu.kotatsu.bookmarks.data.BookmarksDao
import org.koitharu.kotatsu.core.db.dao.ChaptersDao
import org.koitharu.kotatsu.core.db.dao.MangaDao import org.koitharu.kotatsu.core.db.dao.MangaDao
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
import org.koitharu.kotatsu.core.db.dao.PreferencesDao import org.koitharu.kotatsu.core.db.dao.PreferencesDao
import org.koitharu.kotatsu.core.db.dao.TagsDao import org.koitharu.kotatsu.core.db.dao.TagsDao
import org.koitharu.kotatsu.core.db.dao.TrackLogsDao import org.koitharu.kotatsu.core.db.dao.TrackLogsDao
import org.koitharu.kotatsu.core.db.entity.ChapterEntity
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
@@ -38,11 +36,6 @@ import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration20To21 import org.koitharu.kotatsu.core.db.migrations.Migration20To21
import org.koitharu.kotatsu.core.db.migrations.Migration21To22 import org.koitharu.kotatsu.core.db.migrations.Migration21To22
import org.koitharu.kotatsu.core.db.migrations.Migration22To23 import org.koitharu.kotatsu.core.db.migrations.Migration22To23
import org.koitharu.kotatsu.core.db.migrations.Migration23To24
import org.koitharu.kotatsu.core.db.migrations.Migration24To23
import org.koitharu.kotatsu.core.db.migrations.Migration24To25
import org.koitharu.kotatsu.core.db.migrations.Migration25To26
import org.koitharu.kotatsu.core.db.migrations.Migration26To27
import org.koitharu.kotatsu.core.db.migrations.Migration2To3 import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4 import org.koitharu.kotatsu.core.db.migrations.Migration3To4
import org.koitharu.kotatsu.core.db.migrations.Migration4To5 import org.koitharu.kotatsu.core.db.migrations.Migration4To5
@@ -70,14 +63,14 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao import org.koitharu.kotatsu.tracker.data.TracksDao
const val DATABASE_VERSION = 27 const val DATABASE_VERSION = 23
@Database( @Database(
entities = [ entities = [
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, ChapterEntity::class, MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, TrackEntity::class, FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class, ScrobblingEntity::class, TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class, ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class,
], ],
version = DATABASE_VERSION, version = DATABASE_VERSION,
) )
@@ -110,8 +103,6 @@ abstract class MangaDatabase : RoomDatabase() {
abstract fun getStatsDao(): StatsDao abstract fun getStatsDao(): StatsDao
abstract fun getLocalMangaIndexDao(): LocalMangaIndexDao abstract fun getLocalMangaIndexDao(): LocalMangaIndexDao
abstract fun getChaptersDao(): ChaptersDao
} }
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf( fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
@@ -137,11 +128,6 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
Migration20To21(), Migration20To21(),
Migration21To22(), Migration21To22(),
Migration22To23(), Migration22To23(),
Migration23To24(),
Migration24To23(),
Migration24To25(),
Migration25To26(),
Migration26To27(),
) )
fun MangaDatabase(context: Context): MangaDatabase = Room fun MangaDatabase(context: Context): MangaDatabase = Room

View File

@@ -7,5 +7,3 @@ const val TABLE_FAVOURITE_CATEGORIES = "favourite_categories"
const val TABLE_HISTORY = "history" const val TABLE_HISTORY = "history"
const val TABLE_MANGA_TAGS = "manga_tags" const val TABLE_MANGA_TAGS = "manga_tags"
const val TABLE_SOURCES = "sources" const val TABLE_SOURCES = "sources"
const val TABLE_CHAPTERS = "chapters"
const val TABLE_PREFERENCES = "preferences"

View File

@@ -1,30 +0,0 @@
package org.koitharu.kotatsu.core.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import org.koitharu.kotatsu.core.db.entity.ChapterEntity
@Dao
abstract class ChaptersDao {
@Query("SELECT * FROM chapters WHERE manga_id = :mangaId ORDER BY `index` ASC")
abstract suspend fun findAll(mangaId: Long): List<ChapterEntity>
@Query("DELETE FROM chapters WHERE manga_id = :mangaId")
abstract suspend fun deleteAll(mangaId: Long)
@Query("DELETE FROM chapters WHERE manga_id NOT IN (SELECT manga_id FROM history WHERE deleted_at = 0) AND manga_id NOT IN (SELECT manga_id FROM favourites WHERE deleted_at = 0)")
abstract suspend fun gc()
@Transaction
open suspend fun replaceAll(mangaId: Long, entities: Collection<ChapterEntity>) {
deleteAll(mangaId)
insert(entities)
}
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract suspend fun insert(entities: Collection<ChapterEntity>)
}

View File

@@ -20,9 +20,6 @@ abstract class MangaDao {
@Query("SELECT * FROM manga WHERE manga_id = :id") @Query("SELECT * FROM manga WHERE manga_id = :id")
abstract suspend fun find(id: Long): MangaWithTags? abstract suspend fun find(id: Long): MangaWithTags?
@Query("SELECT EXISTS(SELECT * FROM manga WHERE manga_id = :id)")
abstract suspend operator fun contains(id: Long): Boolean
@Transaction @Transaction
@Query("SELECT * FROM manga WHERE public_url = :publicUrl") @Query("SELECT * FROM manga WHERE public_url = :publicUrl")
abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags? abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags?
@@ -58,19 +55,6 @@ abstract class MangaDao {
@Delete @Delete
abstract suspend fun delete(subjects: Collection<MangaEntity>) abstract suspend fun delete(subjects: Collection<MangaEntity>)
@Query(
"""
DELETE FROM manga WHERE NOT EXISTS(SELECT * FROM history WHERE history.manga_id == manga.manga_id)
AND NOT EXISTS(SELECT * FROM favourites WHERE favourites.manga_id == manga.manga_id)
AND NOT EXISTS(SELECT * FROM bookmarks WHERE bookmarks.manga_id == manga.manga_id)
AND NOT EXISTS(SELECT * FROM suggestions WHERE suggestions.manga_id == manga.manga_id)
AND NOT EXISTS(SELECT * FROM scrobblings WHERE scrobblings.manga_id == manga.manga_id)
AND NOT EXISTS(SELECT * FROM local_index WHERE local_index.manga_id == manga.manga_id)
AND manga.manga_id NOT IN (:idsToKeep)
""",
)
abstract suspend fun cleanup(idsToKeep: Set<Long>)
@Transaction @Transaction
open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) { open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) {
upsert(manga) upsert(manga)

View File

@@ -9,15 +9,11 @@ import androidx.room.Transaction
import androidx.room.Upsert import androidx.room.Upsert
import androidx.sqlite.db.SimpleSQLiteQuery import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import org.intellij.lang.annotations.Language
import kotlinx.coroutines.isActive
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.explore.data.SourcesSortOrder import org.koitharu.kotatsu.explore.data.SourcesSortOrder
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper.PROTECTION_CAPTCHA
@Dao @Dao
abstract class MangaSourcesDao { abstract class MangaSourcesDao {
@@ -55,9 +51,6 @@ abstract class MangaSourcesDao {
@Query("UPDATE sources SET pinned = :isPinned WHERE source = :source") @Query("UPDATE sources SET pinned = :isPinned WHERE source = :source")
abstract suspend fun setPinned(source: String, isPinned: Boolean) abstract suspend fun setPinned(source: String, isPinned: Boolean)
@Query("UPDATE sources SET cf_state = :state WHERE source = :source")
abstract suspend fun setCfState(source: String, state: Int)
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
@Transaction @Transaction
abstract suspend fun insertIfAbsent(entries: Collection<MangaSourceEntity>) abstract suspend fun insertIfAbsent(entries: Collection<MangaSourceEntity>)
@@ -68,14 +61,21 @@ abstract class MangaSourcesDao {
@Query("SELECT * FROM sources WHERE pinned = 1") @Query("SELECT * FROM sources WHERE pinned = 1")
abstract suspend fun findAllPinned(): List<MangaSourceEntity> abstract suspend fun findAllPinned(): List<MangaSourceEntity>
@Query("SELECT * FROM sources WHERE cf_state = $PROTECTION_CAPTCHA") fun observeEnabled(order: SourcesSortOrder): Flow<List<MangaSourceEntity>> {
abstract suspend fun findAllCaptchaRequired(): List<MangaSourceEntity> val orderBy = getOrderBy(order)
fun observeAll(enabledOnly: Boolean, order: SourcesSortOrder): Flow<List<MangaSourceEntity>> = @Language("RoomSql")
observeImpl(getQuery(enabledOnly, order)) val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy")
return observeImpl(query)
}
suspend fun findAll(enabledOnly: Boolean, order: SourcesSortOrder): List<MangaSourceEntity> = suspend fun findAllEnabled(order: SourcesSortOrder): List<MangaSourceEntity> {
findAllImpl(getQuery(enabledOnly, order)) val orderBy = getOrderBy(order)
@Language("RoomSql")
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy")
return findAllImpl(query)
}
@Transaction @Transaction
open suspend fun setEnabled(source: String, isEnabled: Boolean) { open suspend fun setEnabled(source: String, isEnabled: Boolean) {
@@ -87,25 +87,11 @@ abstract class MangaSourcesDao {
addedIn = BuildConfig.VERSION_CODE, addedIn = BuildConfig.VERSION_CODE,
lastUsedAt = 0, lastUsedAt = 0,
isPinned = false, isPinned = false,
cfState = CloudFlareHelper.PROTECTION_NOT_DETECTED,
) )
upsert(entity) upsert(entity)
} }
} }
fun dumpEnabled(): Flow<MangaSourceEntity> = flow {
val window = 10
var offset = 0
while (currentCoroutineContext().isActive) {
val list = findAllEnabled(offset, window)
if (list.isEmpty()) {
break
}
offset += window
list.forEach { emit(it) }
}
}
@Query("UPDATE sources SET enabled = :isEnabled WHERE source = :source") @Query("UPDATE sources SET enabled = :isEnabled WHERE source = :source")
protected abstract suspend fun updateIsEnabled(source: String, isEnabled: Boolean): Int protected abstract suspend fun updateIsEnabled(source: String, isEnabled: Boolean): Int
@@ -115,20 +101,6 @@ abstract class MangaSourcesDao {
@RawQuery @RawQuery
protected abstract suspend fun findAllImpl(query: SupportSQLiteQuery): List<MangaSourceEntity> protected abstract suspend fun findAllImpl(query: SupportSQLiteQuery): List<MangaSourceEntity>
@Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY source LIMIT :limit OFFSET :offset")
protected abstract suspend fun findAllEnabled(offset: Int, limit: Int): List<MangaSourceEntity>
private fun getQuery(enabledOnly: Boolean, order: SourcesSortOrder) = SimpleSQLiteQuery(
buildString {
append("SELECT * FROM sources ")
if (enabledOnly) {
append("WHERE enabled = 1 ")
}
append("ORDER BY pinned DESC, ")
append(getOrderBy(order))
},
)
private fun getOrderBy(order: SourcesSortOrder) = when (order) { private fun getOrderBy(order: SourcesSortOrder) = when (order) {
SourcesSortOrder.ALPHABETIC -> "source ASC" SourcesSortOrder.ALPHABETIC -> "source ASC"
SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC" SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"

View File

@@ -15,9 +15,6 @@ abstract class PreferencesDao {
@Query("SELECT * FROM preferences WHERE manga_id = :mangaId") @Query("SELECT * FROM preferences WHERE manga_id = :mangaId")
abstract fun observe(mangaId: Long): Flow<MangaPrefsEntity?> abstract fun observe(mangaId: Long): Flow<MangaPrefsEntity?>
@Query("SELECT * FROM preferences WHERE title_override IS NOT NULL OR cover_override IS NOT NULL OR content_rating_override IS NOT NULL")
abstract suspend fun getOverrides(): List<MangaPrefsEntity>
@Query("UPDATE preferences SET cf_brightness = 0, cf_contrast = 0, cf_invert = 0, cf_grayscale = 0") @Query("UPDATE preferences SET cf_brightness = 0, cf_contrast = 0, cf_invert = 0, cf_grayscale = 0")
abstract suspend fun resetColorFilters() abstract suspend fun resetColorFilters()

View File

@@ -1,32 +0,0 @@
package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import org.koitharu.kotatsu.core.db.TABLE_CHAPTERS
@Entity(
tableName = TABLE_CHAPTERS,
primaryKeys = ["manga_id", "chapter_id"],
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE,
),
],
)
data class ChapterEntity(
@ColumnInfo(name = "chapter_id") val chapterId: Long,
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "name") val title: String,
@ColumnInfo(name = "number") val number: Float,
@ColumnInfo(name = "volume") val volume: Int,
@ColumnInfo(name = "url") val url: String,
@ColumnInfo(name = "scanlator") val scanlator: String?,
@ColumnInfo(name = "upload_date") val uploadDate: Long,
@ColumnInfo(name = "branch") val branch: String?,
@ColumnInfo(name = "source") val source: String,
@ColumnInfo(name = "index") val index: Int,
)

View File

@@ -1,20 +1,14 @@
package org.koitharu.kotatsu.core.db.entity package org.koitharu.kotatsu.core.db.entity
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.longHashCode import org.koitharu.kotatsu.parsers.util.longHashCode
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.parsers.util.toArraySet
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
private const val VALUES_DIVIDER = '\n'
// Entity to model // Entity to model
fun TagEntity.toMangaTag() = MangaTag( fun TagEntity.toMangaTag() = MangaTag(
@@ -27,42 +21,26 @@ fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag)
fun Collection<TagEntity>.toMangaTagsList() = map(TagEntity::toMangaTag) fun Collection<TagEntity>.toMangaTagsList() = map(TagEntity::toMangaTag)
fun MangaEntity.toManga(tags: Set<MangaTag>, chapters: List<ChapterEntity>?) = Manga( fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
id = this.id, id = this.id,
title = this.title, title = this.title,
altTitles = this.altTitles?.split(VALUES_DIVIDER)?.toArraySet().orEmpty(), altTitle = this.altTitle,
state = this.state?.let { MangaState(it) }, state = this.state?.let { MangaState(it) },
rating = this.rating, rating = this.rating,
contentRating = ContentRating(this.contentRating) isNsfw = this.isNsfw,
?: if (isNsfw) ContentRating.ADULT else null,
url = this.url, url = this.url,
publicUrl = this.publicUrl, publicUrl = this.publicUrl,
coverUrl = this.coverUrl, coverUrl = this.coverUrl,
largeCoverUrl = this.largeCoverUrl, largeCoverUrl = this.largeCoverUrl,
authors = this.authors?.split(VALUES_DIVIDER)?.toArraySet().orEmpty(), author = this.author,
source = MangaSource(this.source), source = MangaSource(this.source),
tags = tags, tags = tags,
chapters = chapters?.toMangaChapters(),
) )
fun MangaWithTags.toManga(chapters: List<ChapterEntity>? = null) = manga.toManga(tags.toMangaTags(), chapters) fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
fun Collection<MangaWithTags>.toMangaList() = map { it.toManga() } fun Collection<MangaWithTags>.toMangaList() = map { it.toManga() }
fun ChapterEntity.toMangaChapter() = MangaChapter(
id = chapterId,
title = title.nullIfEmpty(),
number = number,
volume = volume,
url = url,
scanlator = scanlator,
uploadDate = uploadDate,
branch = branch,
source = MangaSource(source),
)
fun Collection<ChapterEntity>.toMangaChapters() = map { it.toMangaChapter() }
// Model to entity // Model to entity
fun Manga.toEntity() = MangaEntity( fun Manga.toEntity() = MangaEntity(
@@ -72,13 +50,12 @@ fun Manga.toEntity() = MangaEntity(
source = source.name, source = source.name,
largeCoverUrl = largeCoverUrl, largeCoverUrl = largeCoverUrl,
coverUrl = coverUrl.orEmpty(), coverUrl = coverUrl.orEmpty(),
altTitles = altTitles.joinToString(VALUES_DIVIDER.toString()), altTitle = altTitle,
rating = rating, rating = rating,
isNsfw = isNsfw, isNsfw = isNsfw,
contentRating = contentRating?.name,
state = state?.name, state = state?.name,
title = title, title = title,
authors = authors.joinToString(VALUES_DIVIDER.toString()), author = author,
) )
fun MangaTag.toEntity() = TagEntity( fun MangaTag.toEntity() = TagEntity(
@@ -86,27 +63,10 @@ fun MangaTag.toEntity() = TagEntity(
key = key, key = key,
source = source.name, source = source.name,
id = "${key}_${source.name}".longHashCode(), id = "${key}_${source.name}".longHashCode(),
isPinned = false, // for future use
) )
fun Collection<MangaTag>.toEntities() = map(MangaTag::toEntity) fun Collection<MangaTag>.toEntities() = map(MangaTag::toEntity)
fun Iterable<IndexedValue<MangaChapter>>.toEntities(mangaId: Long) = map { (index, chapter) ->
ChapterEntity(
chapterId = chapter.id,
mangaId = mangaId,
title = chapter.title.orEmpty(),
number = chapter.number,
volume = chapter.volume,
url = chapter.url,
scanlator = chapter.scanlator,
uploadDate = chapter.uploadDate,
branch = chapter.branch,
source = chapter.source.name,
index = index,
)
}
// Other // Other
fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching { fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
@@ -116,7 +76,3 @@ fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
fun MangaState(name: String): MangaState? = runCatching { fun MangaState(name: String): MangaState? = runCatching {
MangaState.valueOf(name) MangaState.valueOf(name)
}.getOrNull() }.getOrNull()
fun ContentRating(name: String?): ContentRating? = runCatching {
ContentRating.valueOf(name ?: return@runCatching null)
}.getOrNull()

View File

@@ -10,15 +10,14 @@ data class MangaEntity(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val id: Long, @ColumnInfo(name = "manga_id") val id: Long,
@ColumnInfo(name = "title") val title: String, @ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "alt_title") val altTitles: String?, @ColumnInfo(name = "alt_title") val altTitle: String?,
@ColumnInfo(name = "url") val url: String, @ColumnInfo(name = "url") val url: String,
@ColumnInfo(name = "public_url") val publicUrl: String, @ColumnInfo(name = "public_url") val publicUrl: String,
@ColumnInfo(name = "rating") val rating: Float, // normalized value [0..1] or -1 @ColumnInfo(name = "rating") val rating: Float, // normalized value [0..1] or -1
@ColumnInfo(name = "nsfw") val isNsfw: Boolean, @ColumnInfo(name = "nsfw") val isNsfw: Boolean, // TODO change to contentRating
@ColumnInfo(name = "content_rating") val contentRating: String?,
@ColumnInfo(name = "cover_url") val coverUrl: String, @ColumnInfo(name = "cover_url") val coverUrl: String,
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?, @ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
@ColumnInfo(name = "state") val state: String?, @ColumnInfo(name = "state") val state: String?,
@ColumnInfo(name = "author") val authors: String?, @ColumnInfo(name = "author") val author: String?,
@ColumnInfo(name = "source") val source: String, @ColumnInfo(name = "source") val source: String,
) )

View File

@@ -4,10 +4,9 @@ import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.TABLE_PREFERENCES
@Entity( @Entity(
tableName = TABLE_PREFERENCES, tableName = "preferences",
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(
entity = MangaEntity::class, entity = MangaEntity::class,
@@ -26,8 +25,4 @@ data class MangaPrefsEntity(
@ColumnInfo(name = "cf_contrast") val cfContrast: Float, @ColumnInfo(name = "cf_contrast") val cfContrast: Float,
@ColumnInfo(name = "cf_invert") val cfInvert: Boolean, @ColumnInfo(name = "cf_invert") val cfInvert: Boolean,
@ColumnInfo(name = "cf_grayscale") val cfGrayscale: Boolean, @ColumnInfo(name = "cf_grayscale") val cfGrayscale: Boolean,
@ColumnInfo(name = "cf_book") val cfBookEffect: Boolean,
@ColumnInfo(name = "title_override") val titleOverride: String?,
@ColumnInfo(name = "cover_override") val coverUrlOverride: String?,
@ColumnInfo(name = "content_rating_override") val contentRatingOverride: String?,
) )

View File

@@ -17,5 +17,4 @@ data class MangaSourceEntity(
@ColumnInfo(name = "added_in") val addedIn: Int, @ColumnInfo(name = "added_in") val addedIn: Int,
@ColumnInfo(name = "used_at") val lastUsedAt: Long, @ColumnInfo(name = "used_at") val lastUsedAt: Long,
@ColumnInfo(name = "pinned") val isPinned: Boolean, @ColumnInfo(name = "pinned") val isPinned: Boolean,
@ColumnInfo(name = "cf_state") val cfState: Int,
) )

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