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
532 changed files with 6327 additions and 11499 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

2
.gitignore vendored
View File

@@ -26,4 +26,4 @@
.cxx
/.idea/deviceManager.xml
/.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>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="jbr-21" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>

106
README.md
View File

@@ -1,107 +1,57 @@
<div align="center">
# Kotatsu
<a href="https://kotatsu.app">
<img src="./.github/assets/vtuber.png" alt="Kotatsu Logo" title="Kotatsu" width="600"/>
</a>
Kotatsu is a free and open-source manga reader for Android with built-in online content sources.
# [Kotatsu](https://kotatsu.app)
**[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)
[![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)
### 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.
* Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (very unstable, use at your own risk).
</div>
- **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.
- Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (very unstable, use at your own risk).
### Main Features
<div align="left">
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers) (with 1100+ manga sources)
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers)
* Search manga by name, genres, and more filters
* Reading history and bookmarks
* Favorites organized by user-defined categories
* Reading history, bookmarks, and incognito mode support
* Download manga and read it offline. Third-party CBZ archives are also supported
* Clean and convenient Material You UI, optimized for phones, tablets, and desktop
* Standard and Webtoon-optimized customizable reader, gesture support on reading interface
* Notifications about new chapters with updates feed, manga recommendations (with filters)
* Downloading manga and reading it offline. Third-party CBZ archives also supported
* Tablet-optimized Material You UI
* Standard and Webtoon-optimized customizable reader
* Notifications about new chapters with updates feed
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
* Password / fingerprint-protected access to the app
* Automatically sync app data with other devices on the same account
* Support for older devices running Android 5+
* Password/fingerprint-protected access to the app
</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">
<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>
| ![](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/tenInchScreenshots/1.png) | ![](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/tenInchScreenshots/2.png) |
|-----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------|
### Localization
<a href="https://hosted.weblate.org/engage/kotatsu/">
<img src="https://hosted.weblate.org/widget/kotatsu/horizontal-auto.png" alt="Translation status" />
</a>
[<img src="https://hosted.weblate.org/widgets/kotatsu/-/287x66-white.png" alt="Translation status">](https://hosted.weblate.org/engage/kotatsu/)
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is localized in a number of different languages.**<br>
**📌 If you would like to help improve these or add new languages,
please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)**
Kotatsu is localized in a number of different languages, if you would like to help improve these or add new languages,
please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)
### Contributing
<br>
<a href="https://github.com/KotatsuApp/Kotatsu">
<picture>
<source srcset="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu&bg_color=0d1117&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" media="(prefers-color-scheme: dark)">
<img src="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" alt="Kotatsu GitHub Repository">
</picture>
</a>
<a href="https://github.com/KotatsuApp/Kotatsu-parsers">
<picture>
<source srcset="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu-parsers&bg_color=0d1117&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" media="(prefers-color-scheme: dark)">
<img src="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu-parsers&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" alt="Kotatsu-parsers GitHub Repository">
</picture>
</a><br></br>
</br>
**📌 Pull requests are welcome, if you want: See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines**
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the guidelines.
### License
[![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 & install instructions.
</div>
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.
### DMCA disclaimer
<div align="left">
The developers of this application do not have any affiliation with the content available in the app. It collects content from sources that are freely available through any web browser.
</div>
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

View File

@@ -7,29 +7,52 @@ plugins {
id 'com.google.devtools.ksp'
id 'kotlin-parcelize'
id 'dagger.hilt.android.plugin'
id 'androidx.room'
}
def Properties versionProps = getVersionProps()
android {
compileSdk = 35
buildToolsVersion = '35.0.0'
namespace = 'org.koitharu.kotatsu'
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'
minSdk = 21
targetSdk = 35
versionCode = 1003
versionName = '8.0-b3'
versionCode = code * 1000 + build
versionName = base + (build == 0 ? '' : '.' + build) + (variant == '' ? '' : '-') + variant
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {
arg('room.generateKotlin', 'true')
arg('room.schemaLocation', "$projectDir/schemas")
}
androidResources {
generateLocaleConfig true
}
}
signingConfigs {
release {
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")
}
}
buildTypes {
debug {
applicationIdSuffix = '.debug'
@@ -38,6 +61,7 @@ android {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
nightly {
initWith release
@@ -74,12 +98,8 @@ android {
'-opt-in=kotlinx.coroutines.FlowPreview',
'-opt-in=kotlin.contracts.ExperimentalContracts',
'-opt-in=coil3.annotation.ExperimentalCoilApi',
'-opt-in=coil3.annotation.InternalCoilApi',
]
}
room {
schemaDirectory "$projectDir/schemas"
}
lint {
abortOnError true
disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled', 'SimpleDateFormat'
@@ -198,3 +218,22 @@ dependencies {
androidTestImplementation libs.hilt.android.testing
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

@@ -1,12 +1,9 @@
package org.koitharu.kotatsu
import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import android.os.StrictMode
import androidx.core.content.edit
import androidx.fragment.app.strictmode.FragmentStrictMode
import leakcanary.LeakCanary
import org.koitharu.kotatsu.core.BaseApp
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.PagesCache
@@ -16,23 +13,9 @@ import org.koitharu.kotatsu.reader.ui.ReaderViewModel
class KotatsuApp : BaseApp() {
var isLeakCanaryEnabled: Boolean
get() = getDebugPreferences(this).getBoolean(KEY_LEAK_CANARY, true)
set(value) {
getDebugPreferences(this).edit { putBoolean(KEY_LEAK_CANARY, value) }
configureLeakCanary()
}
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
enableStrictMode()
configureLeakCanary()
}
private fun configureLeakCanary() {
LeakCanary.config = LeakCanary.config.copy(
dumpHeap = isLeakCanaryEnabled,
)
}
private fun enableStrictMode() {
@@ -72,7 +55,7 @@ class KotatsuApp : BaseApp() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
penaltyListener(notifier.executor, notifier)
}
}.build(),
}.build()
)
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder().apply {
detectWrongFragmentContainer()
@@ -87,13 +70,4 @@ class KotatsuApp : BaseApp() {
}
}.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(
PendingIntentCompat.getActivity(
context,
violation.hashCode(),
0,
ShareHelper(context).getShareTextIntent(violation.stackTraceToString()),
0,
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

@@ -6,7 +6,6 @@ import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import leakcanary.LeakCanary
import org.koitharu.kotatsu.KotatsuApp
import org.koitharu.kotatsu.R
import org.koitharu.workinspector.WorkInspector
@@ -14,18 +13,10 @@ class SettingsMenuProvider(
private val context: Context,
) : MenuProvider {
private val application: KotatsuApp
get() = context.applicationContext as KotatsuApp
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_settings, menu)
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(R.id.action_leakcanary).isChecked = application.isLeakCanaryEnabled
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_leaks -> {
context.startActivity(LeakCanary.newLeakDisplayActivityIntent())
@@ -37,13 +28,6 @@ class SettingsMenuProvider(
true
}
R.id.action_leakcanary -> {
val checked = !menuItem.isChecked
menuItem.isChecked = checked
application.isLeakCanaryEnabled = checked
true
}
else -> false
}
}

View File

@@ -1,23 +1,15 @@
<?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"
xmlns:tools="http://schemas.android.com/tools">
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_leakcanary"
android:checkable="true"
android:title="LeakCanary"
app:showAsAction="never"
tools:ignore="HardcodedText" />
<item
android:id="@+id/action_leaks"
android:id="@id/action_leaks"
android:title="@string/leak_canary_display_activity_label"
app:showAsAction="never" />
<item
android:id="@+id/action_works"
android:id="@id/action_works"
android:title="@string/wi_lib_name"
app:showAsAction="never" />

View File

@@ -49,7 +49,6 @@
android:enableOnBackInvokedCallback="@bool/is_predictive_back_enabled"
android:fullBackupContent="@xml/backup_content"
android:fullBackupOnly="true"
android:hasFragileUserData="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:largeHeap="true"
@@ -281,10 +280,6 @@
<service
android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService"
android:label="@string/local_manga_processing" />
<service
android:name="org.koitharu.kotatsu.settings.backup.RestoreService"
android:foregroundServiceType="dataSync"
android:label="@string/restore_backup" />
<service
android:name="org.koitharu.kotatsu.local.ui.ImportService"
android:foregroundServiceType="dataSync"

View File

@@ -3,28 +3,32 @@ package org.koitharu.kotatsu.alternatives.domain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
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.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.search.domain.SearchKind
import org.koitharu.kotatsu.search.domain.SearchV2Helper
import javax.inject.Inject
private const val MAX_PARALLELISM = 4
private const val MATCH_THRESHOLD_DEFAULT = 0.2f
class AlternativesUseCase @Inject constructor(
private val sourcesRepository: MangaSourcesRepository,
private val searchHelperFactory: SearchV2Helper.Factory,
private val mangaRepositoryFactory: MangaRepository.Factory,
) {
suspend operator fun invoke(manga: Manga): Flow<Manga> {
suspend operator fun invoke(manga: Manga): Flow<Manga> = invoke(manga, MATCH_THRESHOLD_DEFAULT)
suspend operator fun invoke(manga: Manga, matchThreshold: Float): Flow<Manga> {
val sources = getSources(manga.source)
if (sources.isEmpty()) {
return emptyFlow()
@@ -32,23 +36,27 @@ class AlternativesUseCase @Inject constructor(
val semaphore = Semaphore(MAX_PARALLELISM)
return channelFlow {
for (source in sources) {
val repository = mangaRepositoryFactory.create(source)
if (!repository.filterCapabilities.isSearchSupported) {
continue
}
launch {
val searchHelper = searchHelperFactory.create(source)
val list = runCatchingCancellable {
semaphore.withPermit {
searchHelper(manga.title, SearchKind.TITLE)?.manga
repository.getList(offset = 0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title))
}
}.getOrNull()
list?.forEach {
launch {
val details = runCatchingCancellable {
mangaRepositoryFactory.create(it.source).getDetails(it)
}.getOrDefault(it)
send(details)
}.getOrDefault(emptyList())
for (item in list) {
if (item.matches(manga, matchThreshold)) {
send(item)
}
}
}
}
}.map {
runCatchingCancellable {
mangaRepositoryFactory.create(it.source).getDetails(it)
}.getOrDefault(it)
}
}
@@ -60,6 +68,18 @@ class AlternativesUseCase @Inject constructor(
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 {
var res = 0
if (this is MangaParserSource && ref is MangaParserSource) {

View File

@@ -29,13 +29,12 @@ class AutoFixUseCase @Inject constructor(
) {
suspend operator fun invoke(mangaId: Long): Pair<Manga, Manga?> {
val seed = checkNotNull(
mangaDataRepository.findMangaById(mangaId, withChapters = true),
) { "Manga $mangaId not found" }.getDetailsSafe()
val seed = checkNotNull(mangaDataRepository.findMangaById(mangaId)) { "Manga $mangaId not found" }
.getDetailsSafe()
if (seed.isHealthy()) {
return seed to null // no fix required
}
val replacement = alternativesUseCase(seed)
val replacement = alternativesUseCase(seed, matchThreshold = 0.02f)
.filter { it.isHealthy() }
.runningFold<Manga, Manga?>(null) { best, candidate ->
if (best == null || best < candidate) {

View File

@@ -4,7 +4,6 @@ import android.text.style.ForegroundColorSpan
import androidx.core.content.ContextCompat
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import coil3.ImageLoader
import coil3.request.ImageRequest
@@ -27,7 +26,6 @@ import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
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.getQuantityStringSafe
import org.koitharu.kotatsu.core.util.ext.mangaExtra
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.newImageRequest
@@ -53,22 +51,10 @@ fun alternativeAD(
binding.chipSource.setOnClickListener(clickListener)
bind { payloads ->
binding.textViewTitle.text = item.mangaModel.title
with(binding.iconsView) {
clearIcons()
if (item.mangaModel.isSaved) addIcon(R.drawable.ic_storage)
if (item.mangaModel.isFavorite) addIcon(R.drawable.ic_heart_outline)
isVisible = iconsCount > 0
}
binding.textViewTitle.text = item.manga.title
binding.textViewSubtitle.text = buildSpannedString {
if (item.chaptersCount > 0) {
append(
context.resources.getQuantityStringSafe(
R.plurals.chapters,
item.chaptersCount,
item.chaptersCount,
),
)
append(context.resources.getQuantityString(R.plurals.chapters, item.chaptersCount, item.chaptersCount))
} else {
append(context.getString(R.string.no_chapters))
}
@@ -84,10 +70,7 @@ fun alternativeAD(
}
}
}
binding.progressView.setProgress(
item.mangaModel.progress,
ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads,
)
binding.progressView.setProgress(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
binding.chipSource.also { chip ->
chip.text = item.manga.source.getTitle(chip.context)
ImageRequest.Builder(context)

View File

@@ -1,24 +1,28 @@
package org.koitharu.kotatsu.alternatives.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.widget.Toast
import androidx.activity.viewModels
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import coil3.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
@@ -26,6 +30,8 @@ import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.search.ui.MangaListActivity
import javax.inject.Inject
@AndroidEntryPoint
@@ -50,7 +56,6 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
.addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
.addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
with(viewBinding.recyclerView) {
consumeInsetsAsPadding(Gravity.START or Gravity.END or Gravity.BOTTOM)
setHasFixedSize(true)
addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false))
adapter = listAdapter
@@ -60,16 +65,33 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
viewModel.content.observe(this, listAdapter)
viewModel.onMigrated.observeEvent(this) {
Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show()
router.openDetails(it)
startActivity(DetailsActivity.newIntent(this, it))
finishAfterTransition()
}
}
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.root.updatePadding(
left = insets.left,
right = insets.right,
)
viewBinding.recyclerView.updatePadding(
bottom = insets.bottom + viewBinding.recyclerView.paddingTop,
)
}
override fun onItemClick(item: MangaAlternativeModel, view: View) {
when (view.id) {
R.id.chip_source -> 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)
else -> router.openDetails(item.manga)
else -> startActivity(DetailsActivity.newIntent(this, item.manga))
}
}
@@ -92,4 +114,10 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
}
}.show()
}
companion object {
fun newIntent(context: Context, manga: Manga) = Intent(context, AlternativesActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
}
}

View File

@@ -13,19 +13,19 @@ import org.koitharu.kotatsu.alternatives.domain.AlternativesUseCase
import org.koitharu.kotatsu.alternatives.domain.MigrateUseCase
import org.koitharu.kotatsu.core.model.chaptersCount
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.parser.MangaIntent
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.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
@@ -36,10 +36,11 @@ class AlternativesViewModel @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val alternativesUseCase: AlternativesUseCase,
private val migrateUseCase: MigrateUseCase,
private val mangaListMapper: MangaListMapper,
private val historyRepository: HistoryRepository,
private val settings: AppSettings,
) : BaseViewModel() {
val manga = savedStateHandle.require<ParcelableManga>(AppRouter.KEY_MANGA).manga
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
val onMigrated = MutableEventFlow<Manga>()
val content = MutableStateFlow<List<ListModel>>(listOf(LoadingState))
@@ -54,7 +55,8 @@ class AlternativesViewModel @Inject constructor(
alternativesUseCase(ref)
.map {
MangaAlternativeModel(
mangaModel = mangaListMapper.toListModel(it, ListMode.GRID) as MangaGridModel,
manga = it,
progress = getProgress(it.id),
referenceChapters = refCount,
)
}.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item ->
@@ -86,4 +88,8 @@ class AlternativesViewModel @Inject constructor(
onMigrated.call(target)
}
}
private suspend fun getProgress(mangaId: Long): ReadingProgress? {
return historyRepository.getProgress(mangaId, settings.progressIndicatorMode)
}
}

View File

@@ -10,6 +10,7 @@ import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import coil3.ImageLoader
import coil3.request.ImageRequest
@@ -19,15 +20,13 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase
import org.koitharu.kotatsu.core.ErrorReporterReceiver
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.powerManager
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
@@ -53,14 +52,12 @@ class AutoFixService : CoroutineIntentService() {
val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS))
startForeground(this)
for (mangaId in ids) {
powerManager.withPartialWakeLock(TAG) {
val result = runCatchingCancellable {
autoFixUseCase.invoke(mangaId)
}
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
val notification = buildNotification(result)
notificationManager.notify(TAG, startId, notification)
}
val result = runCatchingCancellable {
autoFixUseCase.invoke(mangaId)
}
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
val notification = buildNotification(result)
notificationManager.notify(TAG, startId, notification)
}
}
}
@@ -125,7 +122,7 @@ class AutoFixService : CoroutineIntentService() {
).toBitmapOrNull(),
)
notification.setSubText(replacement.title)
val intent = AppRouter.detailsIntent(applicationContext, replacement)
val intent = DetailsActivity.newIntent(applicationContext, replacement)
notification.setContentIntent(
PendingIntentCompat.getActivity(
applicationContext,

View File

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

View File

@@ -1,5 +1,54 @@
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

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.bookmarks.ui
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
@@ -10,6 +9,9 @@ import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager
import coil3.ImageLoader
@@ -18,19 +20,17 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
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.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.GridSpanResolver
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
@@ -39,6 +39,7 @@ import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import javax.inject.Inject
@AndroidEntryPoint
@@ -85,7 +86,6 @@ class AllBookmarksFragment :
)
val spanSizeLookup = SpanSizeLookup()
with(binding.recyclerView) {
consumeInsetsAsPadding(Gravity.BOTTOM or Gravity.START or Gravity.END)
setHasFixedSize(true)
val spanResolver = GridSpanResolver(resources)
addItemDecoration(TypedListSpacingDecoration(context, false))
@@ -115,26 +115,26 @@ class AllBookmarksFragment :
override fun onItemClick(item: Bookmark, view: View) {
if (selectionController?.onItemClick(item.pageId) != true) {
val intent = ReaderIntent.Builder(view.context)
val intent = ReaderActivity.IntentBuilder(view.context)
.bookmark(item)
.incognito(true)
.build()
router.openReader(intent)
startActivity(intent)
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
}
}
override fun onListHeaderClick(item: ListHeader, view: View) {
val manga = item.payload as? Manga ?: return
router.openDetails(manga)
startActivity(DetailsActivity.newIntent(view.context, manga))
}
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 {
return selectionController?.onItemContextClick(view, item.pageId) == true
return selectionController?.onItemContextClick(view, item.pageId) ?: false
}
override fun onRetryClick(error: Throwable) = Unit
@@ -177,6 +177,16 @@ class AllBookmarksFragment :
}
}
override fun onWindowInsetsChanged(insets: Insets) {
val rv = requireViewBinding().recyclerView
rv.updatePadding(
bottom = insets.bottom + rv.paddingTop,
)
rv.fastScroller.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = insets.bottom
}
}
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup(), Runnable {
init {
@@ -198,4 +208,16 @@ class AllBookmarksFragment :
invalidateSpanIndexCache()
}
}
companion object {
@Deprecated(
"",
ReplaceWith(
"BookmarksFragment()",
"org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment",
),
)
fun newInstance() = AllBookmarksFragment()
}
}

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

@@ -10,7 +10,6 @@ import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
@@ -26,7 +25,6 @@ class BookmarksAdapter(
init {
addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(coil, lifecycleOwner, clickListener))
addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener))
addDelegate(ListItemType.STATE_ERROR, errorStateListAD(null))
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, null))

View File

@@ -1,23 +1,27 @@
package org.koitharu.kotatsu.browser
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.Gravity
import android.view.Menu
import android.view.MenuItem
import android.webkit.CookieManager
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import com.google.android.material.snackbar.Snackbar
import androidx.core.view.updatePadding
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.network.CommonHeaders
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.consumeInsetsAsPadding
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.model.MangaSource
import javax.inject.Inject
import com.google.android.material.R as materialR
@@ -38,11 +42,11 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
}
val mangaSource = MangaSource(intent?.getStringExtra(AppRouter.KEY_SOURCE))
val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
val userAgent = repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
viewBinding.webView.configureForParser(userAgent)
viewBinding.webView.consumeInsetsAsPadding(Gravity.START or Gravity.END or Gravity.BOTTOM)
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
viewBinding.webView.webViewClient = BrowserClient(this)
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
@@ -55,7 +59,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
finishAfterTransition()
} else {
onTitleChanged(
intent?.getStringExtra(AppRouter.KEY_TITLE) ?: getString(R.string.loading_),
intent?.getStringExtra(EXTRA_TITLE) ?: getString(R.string.loading_),
url,
)
viewBinding.webView.loadUrl(url)
@@ -76,8 +80,14 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
}
R.id.action_browser -> {
if (!router.openExternalBrowser(viewBinding.webView.url.orEmpty(), item.title)) {
Snackbar.make(viewBinding.webView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
val url = viewBinding.webView.url?.toUriOrNull()
if (url != null) {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = url
try {
startActivity(Intent.createChooser(intent, item.title))
} catch (_: ActivityNotFoundException) {
}
}
true
}
@@ -115,4 +125,28 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
override fun onHistoryChanged() {
onBackPressedCallback.onHistoryChanged()
}
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.appbar.updatePadding(
top = insets.top,
)
viewBinding.root.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom,
)
}
companion object {
private const val EXTRA_TITLE = "title"
private const val EXTRA_SOURCE = "source"
fun newIntent(context: Context, url: String, source: MangaSource?, title: String?): Intent {
return Intent(context, BrowserActivity::class.java)
.setData(Uri.parse(url))
.putExtra(EXTRA_TITLE, title)
.putExtra(EXTRA_SOURCE, source?.name)
}
}
}

View File

@@ -17,8 +17,6 @@ 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.nav.AppRouter
import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -30,9 +28,6 @@ class CaptchaNotifier(
if (!context.checkNotificationPermission(CHANNEL_ID)) {
return
}
if (exception.source != null && SourceSettings(context, exception.source).isCaptchaNotificationsDisabled) {
return
}
val manager = NotificationManagerCompat.from(context)
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
.setName(context.getString(R.string.captcha_required))
@@ -43,7 +38,7 @@ class CaptchaNotifier(
.build()
manager.createNotificationChannel(channel)
val intent = AppRouter.cloudFlareResolveIntent(context, exception)
val intent = CloudFlareActivity.newIntent(context, exception)
.setData(exception.url.toUri())
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(channel.name)

View File

@@ -1,32 +1,37 @@
package org.koitharu.kotatsu.browser.cloudflare
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Gravity
import android.view.Menu
import android.view.MenuItem
import android.webkit.CookieManager
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.graphics.Insets
import androidx.core.net.toUri
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.yield
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
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.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
import javax.inject.Inject
import com.google.android.material.R as materialR
@@ -57,12 +62,12 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
return
}
cfClient = CloudFlareClient(cookieJar, this, url)
viewBinding.webView.configureForParser(intent?.getStringExtra(AppRouter.KEY_USER_AGENT))
viewBinding.webView.consumeInsetsAsPadding(Gravity.START or Gravity.END or Gravity.BOTTOM)
viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA))
viewBinding.webView.webViewClient = cfClient
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
onBackPressedDispatcher.addCallback(it)
}
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
if (savedInstanceState == null) {
onTitleChanged(getString(R.string.loading_), url)
viewBinding.webView.loadUrl(url)
@@ -84,6 +89,17 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
return super.onCreateOptionsMenu(menu)
}
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.appbar.updatePadding(
top = insets.top,
)
viewBinding.root.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom,
)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
android.R.id.home -> {
viewBinding.webView.stopLoading()
@@ -124,7 +140,7 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
override fun onCheckPassed() {
pendingResult = RESULT_OK
val source = intent?.getStringExtra(AppRouter.KEY_SOURCE)
val source = intent?.getStringExtra(ARG_SOURCE)
if (source != null) {
CaptchaNotifier(this).dismiss(MangaSource(source))
}
@@ -166,16 +182,38 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
class Contract : ActivityResultContract<CloudFlareProtectedException, Boolean>() {
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 {
return resultCode == RESULT_OK
return resultCode == Activity.RESULT_OK
}
}
companion object {
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

@@ -6,7 +6,6 @@ import android.os.Build
import android.provider.SearchRecentSuggestions
import android.text.Html
import androidx.collection.arraySetOf
import androidx.core.content.ContextCompat
import androidx.room.InvalidationTracker
import androidx.work.WorkManager
import coil3.ImageLoader
@@ -77,12 +76,6 @@ interface AppModule {
companion object {
@Provides
@LocalizedAppContext
fun provideLocalizedContext(
@ApplicationContext context: Context,
): Context = ContextCompat.getContextForLanguage(context)
@Provides
@Singleton
fun provideNetworkState(

View File

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

View File

@@ -8,7 +8,6 @@ import android.net.Uri
import android.os.BadParcelableException
import androidx.core.app.PendingIntentCompat
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.report
@@ -16,19 +15,20 @@ import org.koitharu.kotatsu.core.util.ext.report
class ErrorReporterReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val e = intent?.getSerializableExtraCompat<Throwable>(AppRouter.KEY_ERROR) ?: return
val e = intent?.getSerializableExtraCompat<Throwable>(EXTRA_ERROR) ?: return
e.report()
}
companion object {
private const val EXTRA_ERROR = "err"
private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR"
fun getPendingIntent(context: Context, e: Throwable): PendingIntent? = try {
val intent = Intent(context, ErrorReporterReceiver::class.java)
intent.setAction(ACTION_REPORT)
intent.setData(Uri.parse("err://${e.hashCode()}"))
intent.putExtra(AppRouter.KEY_ERROR, e)
intent.putExtra(EXTRA_ERROR, e)
PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)
} catch (e: BadParcelableException) {
e.printStackTraceDebug()

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

@@ -16,7 +16,6 @@ class BackupEntry(
CATEGORIES("categories"),
FAVOURITES("favourites"),
SETTINGS("settings"),
SETTINGS_READER_GRID("reader_grid"),
BOOKMARKS("bookmarks"),
SOURCES("sources"),
}

View File

@@ -1,18 +1,15 @@
package org.koitharu.kotatsu.core.backup
import androidx.room.withTransaction
import kotlinx.coroutines.flow.FlowCollector
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.core.util.progress.Progress
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 org.koitharu.kotatsu.reader.data.TapGridSettings
import java.util.Date
import javax.inject.Inject
@@ -21,7 +18,6 @@ private const val PAGE_SIZE = 10
class BackupRepository @Inject constructor(
private val db: MangaDatabase,
private val settings: AppSettings,
private val tapGridSettings: TapGridSettings,
) {
suspend fun dumpHistory(): BackupEntry {
@@ -107,14 +103,6 @@ class BackupRepository @Inject constructor(
return entry
}
fun dumpReaderGridSettings(): BackupEntry {
val entry = BackupEntry(BackupEntry.Name.SETTINGS_READER_GRID, JSONArray())
val settingsDump = tapGridSettings.getAllValues()
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()
@@ -140,11 +128,9 @@ class BackupRepository @Inject constructor(
return if (timestamp == 0L) null else Date(timestamp)
}
suspend fun restoreHistory(entry: BackupEntry, outProgress: FlowCollector<Progress>?): CompositeResult {
suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
val list = entry.data.asTypedList<JSONObject>()
outProgress?.emit(Progress(progress = 0, total = list.size))
for ((index, item) in list.withIndex()) {
for (item in entry.data.asTypedList<JSONObject>()) {
val mangaJson = item.getJSONObject("manga")
val manga = JsonDeserializer(mangaJson).toMangaEntity()
val tags = mangaJson.getJSONArray("tags").mapJSON {
@@ -158,7 +144,6 @@ class BackupRepository @Inject constructor(
db.getHistoryDao().upsert(history)
}
}
outProgress?.emit(Progress(progress = index, total = list.size))
}
return result
}
@@ -174,11 +159,9 @@ class BackupRepository @Inject constructor(
return result
}
suspend fun restoreFavourites(entry: BackupEntry, outProgress: FlowCollector<Progress>?): CompositeResult {
suspend fun restoreFavourites(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
val list = entry.data.asTypedList<JSONObject>()
outProgress?.emit(Progress(progress = 0, total = list.size))
for ((index, item) in list.withIndex()) {
for (item in entry.data.asTypedList<JSONObject>()) {
val mangaJson = item.getJSONObject("manga")
val manga = JsonDeserializer(mangaJson).toMangaEntity()
val tags = mangaJson.getJSONArray("tags").mapJSON {
@@ -192,7 +175,6 @@ class BackupRepository @Inject constructor(
db.getFavouritesDao().upsert(favourite)
}
}
outProgress?.emit(Progress(progress = index, total = list.size))
}
return result
}
@@ -239,14 +221,4 @@ class BackupRepository @Inject constructor(
}
return result
}
fun restoreReaderGridSettings(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.asTypedList<JSONObject>()) {
result += runCatchingCancellable {
tapGridSettings.upsertAll(JsonDeserializer(item).toMap())
}
}
return result
}
}

View File

@@ -27,10 +27,6 @@ class CompositeResult {
}
}
operator fun plusAssign(error: Throwable) {
errors.add(error)
}
operator fun plusAssign(other: CompositeResult) {
this.successCount += other.successCount
this.errors += other.errors

View File

@@ -28,16 +28,15 @@ class JsonDeserializer(private val json: JSONObject) {
fun toMangaEntity() = MangaEntity(
id = json.getLong("id"),
title = json.getString("title"),
altTitles = json.getStringOrNull("alt_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),
contentRating = json.getStringOrNull("content_rating"),
coverUrl = json.getString("cover_url"),
largeCoverUrl = json.getStringOrNull("large_cover_url"),
state = json.getStringOrNull("state"),
authors = json.getStringOrNull("author"),
author = json.getStringOrNull("author"),
source = json.getString("source"),
)

View File

@@ -58,16 +58,15 @@ class JsonSerializer private constructor(private val json: JSONObject) {
JSONObject().apply {
put("id", e.id)
put("title", e.title)
put("alt_title", e.altTitles)
put("alt_title", e.altTitle)
put("url", e.url)
put("public_url", e.publicUrl)
put("rating", e.rating)
put("nsfw", e.isNsfw)
put("content_rating", e.contentRating)
put("cover_url", e.coverUrl)
put("large_cover_url", e.largeCoverUrl)
put("state", e.state)
put("author", e.authors)
put("author", e.author)
put("source", e.source)
},
)

View File

@@ -1,93 +0,0 @@
package org.koitharu.kotatsu.core.backup
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)
suspend fun uploadBackup(file: File) {
val requestBody = file.asRequestBody("application/zip".toMediaTypeOrNull())
val multipartBody = MultipartBody.Builder()
.setType(MultipartBody.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

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

View File

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

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")
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
@Query("SELECT * FROM manga WHERE public_url = :publicUrl")
abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags?
@@ -58,19 +55,6 @@ abstract class MangaDao {
@Delete
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
open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) {
upsert(manga)

View File

@@ -10,6 +10,7 @@ import androidx.room.Upsert
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
@@ -60,11 +61,21 @@ abstract class MangaSourcesDao {
@Query("SELECT * FROM sources WHERE pinned = 1")
abstract suspend fun findAllPinned(): List<MangaSourceEntity>
fun observeAll(enabledOnly: Boolean, order: SourcesSortOrder): Flow<List<MangaSourceEntity>> =
observeImpl(getQuery(enabledOnly, order))
fun observeEnabled(order: SourcesSortOrder): Flow<List<MangaSourceEntity>> {
val orderBy = getOrderBy(order)
suspend fun findAll(enabledOnly: Boolean, order: SourcesSortOrder): List<MangaSourceEntity> =
findAllImpl(getQuery(enabledOnly, order))
@Language("RoomSql")
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy")
return observeImpl(query)
}
suspend fun findAllEnabled(order: SourcesSortOrder): List<MangaSourceEntity> {
val orderBy = getOrderBy(order)
@Language("RoomSql")
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy")
return findAllImpl(query)
}
@Transaction
open suspend fun setEnabled(source: String, isEnabled: Boolean) {
@@ -90,17 +101,6 @@ abstract class MangaSourcesDao {
@RawQuery
protected abstract suspend fun findAllImpl(query: SupportSQLiteQuery): 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) {
SourcesSortOrder.ALPHABETIC -> "source ASC"
SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"

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
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.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.longHashCode
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
private const val VALUES_DIVIDER = '\n'
// Entity to model
fun TagEntity.toMangaTag() = MangaTag(
@@ -27,42 +21,26 @@ fun Collection<TagEntity>.toMangaTags() = mapToSet(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,
title = this.title,
altTitles = this.altTitles?.split(VALUES_DIVIDER)?.toArraySet().orEmpty(),
altTitle = this.altTitle,
state = this.state?.let { MangaState(it) },
rating = this.rating,
contentRating = ContentRating(this.contentRating)
?: if (isNsfw) ContentRating.ADULT else null,
isNsfw = this.isNsfw,
url = this.url,
publicUrl = this.publicUrl,
coverUrl = this.coverUrl,
largeCoverUrl = this.largeCoverUrl,
authors = this.authors?.split(VALUES_DIVIDER)?.toArraySet().orEmpty(),
author = this.author,
source = MangaSource(this.source),
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 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
fun Manga.toEntity() = MangaEntity(
@@ -72,13 +50,12 @@ fun Manga.toEntity() = MangaEntity(
source = source.name,
largeCoverUrl = largeCoverUrl,
coverUrl = coverUrl.orEmpty(),
altTitles = altTitles.joinToString(VALUES_DIVIDER.toString()),
altTitle = altTitle,
rating = rating,
isNsfw = isNsfw,
contentRating = contentRating?.name,
state = state?.name,
title = title,
authors = authors.joinToString(VALUES_DIVIDER.toString()),
author = author,
)
fun MangaTag.toEntity() = TagEntity(
@@ -90,22 +67,6 @@ fun MangaTag.toEntity() = TagEntity(
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
fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
@@ -115,7 +76,3 @@ fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
fun MangaState(name: String): MangaState? = runCatching {
MangaState.valueOf(name)
}.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)
@ColumnInfo(name = "manga_id") val id: Long,
@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 = "public_url") val publicUrl: String,
@ColumnInfo(name = "rating") val rating: Float, // normalized value [0..1] or -1
@ColumnInfo(name = "nsfw") val isNsfw: Boolean,
@ColumnInfo(name = "content_rating") val contentRating: String?,
@ColumnInfo(name = "nsfw") val isNsfw: Boolean, // TODO change to contentRating
@ColumnInfo(name = "cover_url") val coverUrl: String,
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: 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,
)

View File

@@ -1,11 +0,0 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration23To24 : Migration(23, 24) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS `chapters` (`chapter_id` INTEGER NOT NULL, `manga_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `number` REAL NOT NULL, `volume` INTEGER NOT NULL, `url` TEXT NOT NULL, `scanlator` TEXT, `upload_date` INTEGER NOT NULL, `branch` TEXT, `source` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `chapter_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
}
}

View File

@@ -1,11 +0,0 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration24To23 : Migration(24, 23) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("DROP TABLE IF EXISTS `chapters`")
}
}

View File

@@ -1,12 +0,0 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration24To25 : Migration(24, 25) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE manga ADD COLUMN content_rating TEXT DEFAULT NULL")
db.execSQL("UPDATE manga SET content_rating = 'ADULT' WHERE nsfw = 1")
}
}

View File

@@ -6,6 +6,7 @@ import androidx.core.util.Consumer
import androidx.fragment.app.Fragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isSerializable
import org.koitharu.kotatsu.parsers.exception.ParseException
@@ -31,10 +32,10 @@ class DialogErrorObserver(
if (canResolve(value)) {
dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener)
} else if (value is ParseException) {
val router = router()
if (router != null && value.isSerializable()) {
val fm = fragmentManager
if (fm != null && value.isSerializable()) {
dialogBuilder.setPositiveButton(R.string.details) { _, _ ->
router.showErrorDialog(value)
ErrorDetailsDialog.show(fm, value, value.url)
}
}
}

View File

@@ -4,7 +4,6 @@ import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.core.util.Consumer
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.LifecycleOwner
@@ -12,7 +11,6 @@ import androidx.lifecycle.coroutineScope
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.util.ext.findActivity
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
@@ -35,8 +33,6 @@ abstract class ErrorObserver(
return resolver != null && ExceptionResolver.canResolve(error)
}
protected fun router() = fragment?.router ?: (activity as? FragmentActivity)?.router
private fun isAlive(): Boolean {
return when {
fragment != null -> fragment.view != null
@@ -48,7 +44,7 @@ abstract class ErrorObserver(
protected fun resolve(error: Throwable) {
if (isAlive()) {
lifecycleScope.launch {
val isResolved = resolver?.resolve(error) == true
val isResolved = resolver?.resolve(error) ?: false
if (isActive) {
onResolved?.accept(isResolved)
}

View File

@@ -5,20 +5,19 @@ import android.widget.Toast
import androidx.activity.result.ActivityResultCaller
import androidx.annotation.StringRes
import androidx.collection.MutableScatterMap
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.util.ext.restartApplication
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
@@ -27,6 +26,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException
import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import java.security.cert.CertPathValidatorException
import javax.inject.Provider
@@ -49,8 +49,8 @@ class ExceptionResolver @AssistedInject constructor(
handleActivityResult(CloudFlareActivity.TAG, it)
}
fun showErrorDetails(e: Throwable, url: String? = null) {
host.router()?.showErrorDialog(e, url)
fun showDetails(e: Throwable, url: String?) {
ErrorDetailsDialog.show(host.getChildFragmentManager(), e, url)
}
suspend fun resolve(e: Throwable): Boolean = when (e) {
@@ -63,7 +63,9 @@ class ExceptionResolver @AssistedInject constructor(
}
is ProxyConfigException -> {
host.router()?.openProxySettings()
host.withContext {
startActivity(SettingsActivity.newProxySettingsIntent(this))
}
false
}
@@ -83,7 +85,9 @@ class ExceptionResolver @AssistedInject constructor(
true
} else {
host.withContext {
authHelper.startAuth(this, e.scrobbler).onFailure(::showErrorDetails)
authHelper.startAuth(this, e.scrobbler).onFailure {
showDetails(it, null)
}
}
false
}
@@ -102,12 +106,12 @@ class ExceptionResolver @AssistedInject constructor(
sourceAuthContract.launch(source)
}
private fun openInBrowser(url: String) {
host.router()?.openBrowser(url, null, null)
private fun openInBrowser(url: String) = host.withContext {
startActivity(BrowserActivity.newIntent(this, url, null, null))
}
private fun openAlternatives(manga: Manga) {
host.router()?.openAlternatives(manga)
private fun openAlternatives(manga: Manga) = host.withContext {
startActivity(AlternativesActivity.newIntent(this, manga))
}
private fun handleActivityResult(tag: String, result: Boolean) {
@@ -136,12 +140,6 @@ class ExceptionResolver @AssistedInject constructor(
getContext()?.apply(block)
}
private fun Host.router(): AppRouter? = when (this) {
is FragmentActivity -> router
is Fragment -> router
else -> null
}
interface Host : ActivityResultCaller {
fun getChildFragmentManager(): FragmentManager

View File

@@ -5,6 +5,7 @@ import androidx.core.util.Consumer
import androidx.fragment.app.Fragment
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isSerializable
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
@@ -32,10 +33,10 @@ class SnackbarErrorObserver(
resolve(value)
}
} else if (value is ParseException) {
val router = router()
if (router != null && value.isSerializable()) {
val fm = fragmentManager
if (fm != null && value.isSerializable()) {
snackbar.setAction(R.string.details) {
router.showErrorDialog(value)
ErrorDetailsDialog.show(fm, value, value.url)
}
}
}

View File

@@ -21,7 +21,6 @@ import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull
import org.koitharu.kotatsu.parsers.util.parseJsonArray
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrNull
import javax.inject.Inject
import javax.inject.Singleton
@@ -86,8 +85,8 @@ class AppUpdateRepository @Inject constructor(
}
@Suppress("KotlinConstantConditions")
suspend fun isUpdateSupported(): Boolean {
return BuildConfig.BUILD_TYPE != BUILD_TYPE_RELEASE || appValidator.isOriginalApp.getOrNull() == true
fun isUpdateSupported(): Boolean {
return BuildConfig.BUILD_TYPE != BUILD_TYPE_RELEASE || appValidator.isOriginalApp
}
suspend fun getCurrentVersionChangelog(): String? {

View File

@@ -4,26 +4,26 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.os.Build
import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import org.aomedia.avif.android.AvifDecoder
import org.aomedia.avif.android.AvifDecoder.Info
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.core.util.MimeTypes
import org.koitharu.kotatsu.core.util.ext.MimeType
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.io.File
import java.io.InputStream
import java.nio.ByteBuffer
import java.nio.file.Files
object BitmapDecoderCompat {
private const val FORMAT_AVIF = "avif"
@Blocking
fun decode(file: File): Bitmap = when (val format = probeMimeType(file)?.subtype) {
fun decode(file: File): Bitmap = when (val format = getMimeType(file)?.subtype) {
FORMAT_AVIF -> file.inputStream().use { decodeAvif(it.toByteBuffer()) }
else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
ImageDecoder.decodeBitmap(ImageDecoder.createSource(file))
@@ -33,7 +33,7 @@ object BitmapDecoderCompat {
}
@Blocking
fun decode(stream: InputStream, type: MimeType?, isMutable: Boolean = false): Bitmap {
fun decode(stream: InputStream, type: MediaType?, isMutable: Boolean = false): Bitmap {
val format = type?.subtype
if (format == FORMAT_AVIF) {
return decodeAvif(stream.toByteBuffer())
@@ -51,20 +51,12 @@ object BitmapDecoderCompat {
}
}
@Blocking
fun probeMimeType(file: File): MimeType? {
return MimeTypes.probeMimeType(file) ?: detectBitmapType(file)
private fun getMimeType(file: File): MediaType? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Files.probeContentType(file.toPath())?.toMediaTypeOrNull()
} else {
MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension)?.toMediaTypeOrNull()
}
@Blocking
private fun detectBitmapType(file: File): MimeType? = runCatchingCancellable {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeFile(file.path, options)?.recycle()
return options.outMimeType?.toMimeTypeOrNull()
}.getOrNull()
private fun checkBitmapNotNull(bitmap: Bitmap?, format: String?): Bitmap =
bitmap ?: throw ImageDecodeException(null, format)

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.image
import android.net.Uri
import android.webkit.MimeTypeMap
import coil3.ImageLoader
import coil3.decode.DataSource
import coil3.decode.ImageSource
@@ -11,7 +12,6 @@ import coil3.toAndroidUri
import kotlinx.coroutines.runInterruptible
import okio.Path.Companion.toPath
import okio.openZip
import org.koitharu.kotatsu.core.util.MimeTypes
import org.koitharu.kotatsu.core.util.ext.isZipUri
import coil3.Uri as CoilUri
@@ -23,10 +23,9 @@ class CbzFetcher(
override suspend fun fetch() = runInterruptible {
val filePath = uri.schemeSpecificPart.toPath()
val entryName = requireNotNull(uri.fragment)
val fs = options.fileSystem.openZip(filePath)
SourceFetchResult(
source = ImageSource(entryName.toPath(), fs, closeable = fs),
mimeType = MimeTypes.getMimeTypeFromExtension(entryName)?.toString(),
source = ImageSource(entryName.toPath(), options.fileSystem.openZip(filePath)),
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(entryName.substringAfterLast('.', "")),
dataSource = DataSource.DISK,
)
}

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.core.model
import android.content.res.Resources
import android.net.Uri
import android.text.SpannableStringBuilder
import androidx.annotation.DrawableRes
@@ -169,24 +168,3 @@ private fun SpannableStringBuilder.appendTagsSummary(filter: MangaListFilter) {
}
}
}
fun MangaChapter.getLocalizedTitle(resources: Resources, index: Int = -1): String {
title?.let {
if (it.isNotBlank()) {
return it
}
}
val num = numberString()
val vol = volumeString()
return when {
num != null && vol != null -> resources.getString(R.string.chapter_volume_number, vol, num)
num != null -> resources.getString(R.string.chapter_number, num)
index > 0 -> resources.getString(
R.string.chapters_time_pattern,
resources.getString(R.string.unnamed_chapter),
index.toString(),
)
else -> resources.getString(R.string.unnamed_chapter)
}
}

View File

@@ -2,16 +2,11 @@ package org.koitharu.kotatsu.core.model
import android.content.Context
import android.graphics.Color
import android.os.Build
import android.text.SpannableStringBuilder
import android.text.style.ForegroundColorSpan
import android.text.style.ImageSpan
import android.text.style.RelativeSizeSpan
import android.text.style.SuperscriptSpan
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.core.text.inSpans
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
@@ -105,16 +100,3 @@ fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
) {
append(context.getString(R.string.nsfw))
}
fun SpannableStringBuilder.appendIcon(textView: TextView, @DrawableRes resId: Int): SpannableStringBuilder {
val icon = ContextCompat.getDrawable(textView.context, resId) ?: return this
icon.setTintList(textView.textColors)
val size = textView.lineHeight
icon.setBounds(0, 0, size, size)
val alignment = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ImageSpan.ALIGN_CENTER
} else {
ImageSpan.ALIGN_BOTTOM
}
return inSpans(ImageSpan(icon, alignment)) { append(' ') }
}

View File

@@ -1,13 +0,0 @@
package org.koitharu.kotatsu.core.model
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.list.domain.ListFilterOption
fun ListFilterOption.toChipModel(isChecked: Boolean) = ChipsView.ChipModel(
title = titleText,
titleResId = titleResId,
icon = iconResId,
iconData = getIconData(),
isChecked = isChecked,
data = this,
)

View File

@@ -17,7 +17,7 @@ data class ParcelableChapter(
override fun create(parcel: Parcel) = ParcelableChapter(
MangaChapter(
id = parcel.readLong(),
title = parcel.readString(),
name = parcel.readString().orEmpty(),
number = parcel.readFloat(),
volume = parcel.readInt(),
url = parcel.readString().orEmpty(),
@@ -30,7 +30,7 @@ data class ParcelableChapter(
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
parcel.writeLong(id)
parcel.writeString(title)
parcel.writeString(name)
parcel.writeFloat(number)
parcel.writeInt(volume)
parcel.writeString(url)

View File

@@ -7,8 +7,6 @@ import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.core.util.ext.readStringSet
import org.koitharu.kotatsu.core.util.ext.writeStringSet
import org.koitharu.kotatsu.parsers.model.Manga
@Parcelize
@@ -22,7 +20,7 @@ data class ParcelableManga(
override fun ParcelableManga.write(parcel: Parcel, flags: Int) = with(manga) {
parcel.writeLong(id)
parcel.writeString(title)
parcel.writeStringSet(altTitles)
parcel.writeString(altTitle)
parcel.writeString(url)
parcel.writeString(publicUrl)
parcel.writeFloat(rating)
@@ -32,7 +30,7 @@ data class ParcelableManga(
parcel.writeString(description.takeIf { withDescription })
parcel.writeParcelable(ParcelableMangaTags(tags), flags)
parcel.writeSerializable(state)
parcel.writeStringSet(authors)
parcel.writeString(author)
parcel.writeString(source.name)
}
@@ -40,7 +38,7 @@ data class ParcelableManga(
Manga(
id = parcel.readLong(),
title = requireNotNull(parcel.readString()),
altTitles = parcel.readStringSet(),
altTitle = parcel.readString(),
url = requireNotNull(parcel.readString()),
publicUrl = requireNotNull(parcel.readString()),
rating = parcel.readFloat(),
@@ -50,7 +48,7 @@ data class ParcelableManga(
description = parcel.readString(),
tags = requireNotNull(parcel.readParcelableCompat<ParcelableMangaTags>()).tags,
state = parcel.readSerializableCompat(),
authors = parcel.readStringSet(),
author = parcel.readString(),
chapters = null,
source = MangaSource(parcel.readString()),
),

View File

@@ -30,7 +30,6 @@ object MangaListFilterParceler : Parceler<MangaListFilter> {
parcel.writeInt(year)
parcel.writeInt(yearFrom)
parcel.writeInt(yearTo)
parcel.writeString(author)
}
override fun create(parcel: Parcel) = MangaListFilter(
@@ -46,7 +45,6 @@ object MangaListFilterParceler : Parceler<MangaListFilter> {
year = parcel.readInt(),
yearFrom = parcel.readInt(),
yearTo = parcel.readInt(),
author = parcel.readString(),
)
}

View File

@@ -1,731 +0,0 @@
package org.koitharu.kotatsu.core.nav
import android.accounts.Account
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.view.View
import androidx.annotation.CheckResult
import androidx.annotation.UiContext
import androidx.core.net.toUri
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.findFragment
import androidx.lifecycle.LifecycleOwner
import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity
import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.MangaSourceInfo
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaListFilter
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPage
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.prefs.TriStateOption
import org.koitharu.kotatsu.core.ui.dialog.BigButtonsAlertDialog
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.findActivity
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet
import org.koitharu.kotatsu.details.ui.related.RelatedMangaActivity
import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingInfoSheet
import org.koitharu.kotatsu.download.ui.dialog.DownloadDialogFragment
import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
import org.koitharu.kotatsu.favourites.ui.FavouritesActivity
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteDialog
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment
import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet
import org.koitharu.kotatsu.history.ui.HistoryActivity
import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.list.ui.config.ListConfigBottomSheet
import org.koitharu.kotatsu.list.ui.config.ListConfigSection
import org.koitharu.kotatsu.local.ui.ImportDialogFragment
import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog
import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.main.ui.welcome.WelcomeSheet
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.isNullOrEmpty
import org.koitharu.kotatsu.parsers.util.mapToArray
import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity
import org.koitharu.kotatsu.reader.ui.config.ReaderConfigSheet
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.ui.config.ScrobblerConfigActivity
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.domain.SearchKind
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.multi.SearchActivity
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.about.AppUpdateActivity
import org.koitharu.kotatsu.settings.backup.BackupDialogFragment
import org.koitharu.kotatsu.settings.backup.RestoreDialogFragment
import org.koitharu.kotatsu.settings.reader.ReaderTapGridConfigActivity
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity
import org.koitharu.kotatsu.settings.storage.MangaDirectorySelectDialog
import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity
import org.koitharu.kotatsu.settings.tracker.categories.TrackerCategoriesConfigSheet
import org.koitharu.kotatsu.stats.ui.StatsActivity
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity
import org.koitharu.kotatsu.tracker.ui.updates.UpdatesActivity
class AppRouter private constructor(
private val activity: FragmentActivity?,
private val fragment: Fragment?,
) {
constructor(activity: FragmentActivity) : this(activity, null)
constructor(fragment: Fragment) : this(null, fragment)
private val settings: AppSettings by lazy {
EntryPointAccessors.fromApplication<AppRouterEntryPoint>(checkNotNull(contextOrNull())).settings
}
/** Activities **/
fun openList(source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?) {
startActivity(listIntent(contextOrNull() ?: return, source, filter, sortOrder))
}
fun openList(tag: MangaTag) = openList(tag.source, MangaListFilter(tags = setOf(tag)), null)
fun openSearch(query: String, kind: SearchKind = SearchKind.SIMPLE) {
startActivity(
Intent(contextOrNull() ?: return, SearchActivity::class.java)
.putExtra(KEY_QUERY, query)
.putExtra(KEY_KIND, kind),
)
}
fun openSearch(source: MangaSource, query: String) = openList(source, MangaListFilter(query = query), null)
fun openDetails(manga: Manga) {
startActivity(detailsIntent(contextOrNull() ?: return, manga))
}
fun openDetails(mangaId: Long) {
startActivity(detailsIntent(contextOrNull() ?: return, mangaId))
}
fun openDetails(link: Uri) {
startActivity(
Intent(contextOrNull() ?: return, DetailsActivity::class.java)
.setData(link),
)
}
fun openReader(manga: Manga, anchor: View? = null) {
openReader(
ReaderIntent.Builder(contextOrNull() ?: return)
.manga(manga)
.build(),
anchor,
)
}
fun openReader(intent: ReaderIntent, anchor: View? = null) {
startActivity(intent.intent, anchor?.let { view -> scaleUpActivityOptionsOf(view) })
}
fun openAlternatives(manga: Manga) {
startActivity(
Intent(contextOrNull() ?: return, AlternativesActivity::class.java)
.putExtra(KEY_MANGA, ParcelableManga(manga)),
)
}
fun openRelated(manga: Manga) {
startActivity(
Intent(contextOrNull(), RelatedMangaActivity::class.java)
.putExtra(KEY_MANGA, ParcelableManga(manga)),
)
}
fun openImage(url: String, source: MangaSource?, anchor: View? = null) {
startActivity(
Intent(contextOrNull(), ImageActivity::class.java)
.setData(url.toUri())
.putExtra(KEY_SOURCE, source?.name),
anchor?.let { scaleUpActivityOptionsOf(it) },
)
}
fun openBookmarks() = startActivity(AllBookmarksActivity::class.java)
fun openAppUpdate() = startActivity(AppUpdateActivity::class.java)
fun openSuggestions() {
startActivity(suggestionsIntent(contextOrNull() ?: return))
}
fun openSourcesCatalog() = startActivity(SourcesCatalogActivity::class.java)
fun openDownloads() = startActivity(DownloadsActivity::class.java)
fun openDirectoriesSettings() = startActivity(MangaDirectoriesActivity::class.java)
fun openBrowser(url: String, source: MangaSource?, title: String?) {
startActivity(
Intent(contextOrNull() ?: return, BrowserActivity::class.java)
.setData(url.toUri())
.putExtra(KEY_TITLE, title)
.putExtra(KEY_SOURCE, source?.name),
)
}
fun openColorFilterConfig(manga: Manga, page: MangaPage) {
startActivity(
Intent(contextOrNull(), ColorFilterConfigActivity::class.java)
.putExtra(KEY_MANGA, ParcelableManga(manga))
.putExtra(KEY_PAGES, ParcelableMangaPage(page)),
)
}
fun openHistory() = startActivity(HistoryActivity::class.java)
fun openFavorites() = startActivity(FavouritesActivity::class.java)
fun openFavorites(category: FavouriteCategory) {
startActivity(
Intent(contextOrNull() ?: return, FavouritesActivity::class.java)
.putExtra(KEY_ID, category.id)
.putExtra(KEY_TITLE, category.title),
)
}
fun openFavoriteCategories() = startActivity(FavouriteCategoriesActivity::class.java)
fun openFavoriteCategoryEdit(categoryId: Long) {
startActivity(
Intent(contextOrNull() ?: return, FavouritesCategoryEditActivity::class.java)
.putExtra(KEY_ID, categoryId),
)
}
fun openFavoriteCategoryCreate() = openFavoriteCategoryEdit(FavouritesCategoryEditActivity.NO_ID)
fun openMangaUpdates() {
startActivity(mangaUpdatesIntent(contextOrNull() ?: return))
}
fun openSettings() = startActivity(SettingsActivity::class.java)
fun openReaderSettings() {
startActivity(readerSettingsIntent(contextOrNull() ?: return))
}
fun openProxySettings() {
startActivity(proxySettingsIntent(contextOrNull() ?: return))
}
fun openDownloadsSetting() {
startActivity(downloadsSettingsIntent(contextOrNull() ?: return))
}
fun openSourceSettings(source: MangaSource) {
startActivity(sourceSettingsIntent(contextOrNull() ?: return, source))
}
fun openSuggestionsSettings() {
startActivity(suggestionsSettingsIntent(contextOrNull() ?: return))
}
fun openSourcesSettings() {
startActivity(sourcesSettingsIntent(contextOrNull() ?: return))
}
fun openReaderTapGridSettings() = startActivity(ReaderTapGridConfigActivity::class.java)
fun openScrobblerSettings(scrobbler: ScrobblerService) {
startActivity(
Intent(contextOrNull() ?: return, ScrobblerConfigActivity::class.java)
.putExtra(KEY_ID, scrobbler.id),
)
}
fun openSourceAuth(source: MangaSource) {
startActivity(sourceAuthIntent(contextOrNull() ?: return, source))
}
fun openManageSources() {
startActivity(
manageSourcesIntent(contextOrNull() ?: return),
)
}
fun openStatistic() = startActivity(StatsActivity::class.java)
@CheckResult
fun openExternalBrowser(url: String, chooserTitle: CharSequence? = null): Boolean {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = url.toUriOrNull() ?: return false
return startActivitySafe(
if (!chooserTitle.isNullOrEmpty()) {
Intent.createChooser(intent, chooserTitle)
} else {
intent
},
)
}
@CheckResult
fun openSystemSyncSettings(account: Account): Boolean {
val args = Bundle(1)
args.putParcelable(ACCOUNT_KEY, account)
val intent = Intent(ACTION_ACCOUNT_SYNC_SETTINGS)
intent.putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, args)
return startActivitySafe(intent)
}
/** Dialogs **/
fun showDownloadDialog(manga: Manga, snackbarHost: View?) = showDownloadDialog(setOf(manga), snackbarHost)
fun showDownloadDialog(manga: Collection<Manga>, snackbarHost: View?) {
if (manga.isEmpty()) {
return
}
val fm = getFragmentManager() ?: return
if (snackbarHost != null) {
getLifecycleOwner()?.let { lifecycleOwner ->
DownloadDialogFragment.registerCallback(fm, lifecycleOwner, snackbarHost)
}
} else {
DownloadDialogFragment.unregisterCallback(fm)
}
DownloadDialogFragment().withArgs(1) {
putParcelableArray(KEY_MANGA, manga.mapToArray { ParcelableManga(it, withDescription = false) })
}.showDistinct()
}
fun showLocalInfoDialog(manga: Manga) {
LocalInfoDialog().withArgs(1) {
putParcelable(KEY_MANGA, ParcelableManga(manga))
}.showDistinct()
}
fun showDirectorySelectDialog() {
MangaDirectorySelectDialog().showDistinct()
}
fun showFavoriteDialog(manga: Manga) = showFavoriteDialog(setOf(manga))
fun showFavoriteDialog(manga: Collection<Manga>) {
if (manga.isEmpty()) {
return
}
FavoriteDialog().withArgs(1) {
putParcelableArrayList(
KEY_MANGA_LIST,
manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it, withDescription = false) },
)
}.showDistinct()
}
fun showTagDialog(tag: MangaTag) {
buildAlertDialog(contextOrNull() ?: return) {
setTitle(tag.title)
setItems(
arrayOf(
context.getString(R.string.search_on_s, tag.source.getTitle(context)),
context.getString(R.string.search_everywhere),
),
) { _, which ->
when (which) {
0 -> openList(tag)
1 -> openSearch(tag.title, SearchKind.TAG)
}
}
setNegativeButton(R.string.close, null)
setCancelable(true)
}.show()
}
fun showAuthorDialog(author: String, source: MangaSource) {
buildAlertDialog(contextOrNull() ?: return) {
setTitle(author)
setItems(
arrayOf(
context.getString(R.string.search_on_s, source.getTitle(context)),
context.getString(R.string.search_everywhere),
),
) { _, which ->
when (which) {
0 -> openList(source, MangaListFilter(author = author), null)
1 -> openSearch(author, SearchKind.AUTHOR)
}
}
setNegativeButton(R.string.close, null)
setCancelable(true)
}.show()
}
fun showErrorDialog(error: Throwable, url: String? = null) {
ErrorDetailsDialog().withArgs(2) {
putSerializable(KEY_ERROR, error)
putString(KEY_URL, url)
}.show()
}
fun showBackupRestoreDialog(fileUri: Uri) {
RestoreDialogFragment().withArgs(1) {
putString(KEY_FILE, fileUri.toString())
}.show()
}
fun showBackupCreateDialog() {
BackupDialogFragment().show()
}
fun showImportDialog() {
ImportDialogFragment().showDistinct()
}
fun showFilterSheet(): Boolean = if (isFilterSupported()) {
FilterSheetFragment().showDistinct()
} else {
false
}
fun showTagsCatalogSheet(excludeMode: Boolean) {
if (!isFilterSupported()) {
return
}
TagsCatalogSheet().withArgs(1) {
putBoolean(KEY_EXCLUDE, excludeMode)
}.showDistinct()
}
fun showListConfigSheet(section: ListConfigSection) {
ListConfigBottomSheet().withArgs(1) {
putParcelable(KEY_LIST_SECTION, section)
}.showDistinct()
}
fun showStatisticSheet(manga: Manga) {
MangaStatsSheet().withArgs(1) {
putParcelable(KEY_MANGA, ParcelableManga(manga))
}.showDistinct()
}
fun showReaderConfigSheet(mode: ReaderMode) {
ReaderConfigSheet().withArgs(1) {
putInt(KEY_READER_MODE, mode.id)
}.showDistinct()
}
fun showWelcomeSheet() {
WelcomeSheet().showDistinct()
}
fun showChapterPagesSheet() {
ChaptersPagesSheet().showDistinct()
}
fun showChapterPagesSheet(defaultTab: Int) {
ChaptersPagesSheet().withArgs(1) {
putInt(KEY_TAB, defaultTab)
}.showDistinct()
}
fun showScrobblingSelectorSheet(manga: Manga, scrobblerService: ScrobblerService?) {
ScrobblingSelectorSheet().withArgs(2) {
putParcelable(KEY_MANGA, ParcelableManga(manga))
if (scrobblerService != null) {
putInt(KEY_ID, scrobblerService.id)
}
}.show()
}
fun showScrobblingInfoSheet(index: Int) {
ScrobblingInfoSheet().withArgs(1) {
putInt(KEY_INDEX, index)
}.showDistinct()
}
fun showTrackerCategoriesConfigSheet() {
TrackerCategoriesConfigSheet().showDistinct()
}
fun askForDownloadOverMeteredNetwork(onConfirmed: (allow: Boolean) -> Unit) {
val context = contextOrNull() ?: return
when (settings.allowDownloadOnMeteredNetwork) {
TriStateOption.ENABLED -> onConfirmed(true)
TriStateOption.DISABLED -> onConfirmed(false)
TriStateOption.ASK -> {
if (!context.connectivityManager.isActiveNetworkMetered) {
onConfirmed(true)
return
}
val listener = DialogInterface.OnClickListener { _, which ->
when (which) {
DialogInterface.BUTTON_POSITIVE -> {
settings.allowDownloadOnMeteredNetwork = TriStateOption.ENABLED
onConfirmed(true)
}
DialogInterface.BUTTON_NEUTRAL -> {
onConfirmed(true)
}
DialogInterface.BUTTON_NEGATIVE -> {
settings.allowDownloadOnMeteredNetwork = TriStateOption.DISABLED
onConfirmed(false)
}
}
}
BigButtonsAlertDialog.Builder(context)
.setIcon(R.drawable.ic_network_cellular)
.setTitle(R.string.download_cellular_confirm)
.setPositiveButton(R.string.allow_always, listener)
.setNeutralButton(R.string.allow_once, listener)
.setNegativeButton(R.string.dont_allow, listener)
.create()
.show()
}
}
}
/** Public utils **/
fun isFilterSupported(): Boolean = when {
fragment != null -> fragment.activity is FilterCoordinator.Owner
activity != null -> activity is FilterCoordinator.Owner
else -> false
}
fun isChapterPagesSheetShown(): Boolean {
val sheet = getFragmentManager()?.findFragmentByTag(fragmentTag<ChaptersPagesSheet>()) as? ChaptersPagesSheet
return sheet?.dialog?.isShowing == true
}
fun closeWelcomeSheet(): Boolean {
val tag = fragmentTag<WelcomeSheet>()
val sheet = fragment?.findFragmentByTagRecursive(tag)
?: activity?.supportFragmentManager?.findFragmentByTag(tag)
?: return false
return if (sheet is WelcomeSheet) {
sheet.dismissAllowingStateLoss()
true
} else {
false
}
}
/** Private utils **/
private fun startActivity(intent: Intent, options: Bundle? = null) {
fragment?.startActivity(intent, options)
?: activity?.startActivity(intent, options)
}
private fun startActivitySafe(intent: Intent): Boolean = try {
startActivity(intent)
true
} catch (_: ActivityNotFoundException) {
false
}
private fun startActivity(activityClass: Class<out Activity>) {
startActivity(Intent(contextOrNull() ?: return, activityClass))
}
private fun getFragmentManager(): FragmentManager? {
return fragment?.childFragmentManager ?: activity?.supportFragmentManager
}
@UiContext
private fun contextOrNull(): Context? = activity ?: fragment?.context
private fun getLifecycleOwner(): LifecycleOwner? = activity ?: fragment?.viewLifecycleOwner
private fun DialogFragment.showDistinct(): Boolean {
val fm = this@AppRouter.getFragmentManager() ?: return false
val tag = javaClass.fragmentTag()
val existing = fm.findFragmentByTag(tag) as? DialogFragment?
if (existing != null && existing.isVisible && existing.arguments == this.arguments) {
return false
}
show(fm, tag)
return true
}
private fun DialogFragment.show() {
show(
this@AppRouter.getFragmentManager() ?: return,
javaClass.fragmentTag(),
)
}
private fun Fragment.findFragmentByTagRecursive(fragmentTag: String): Fragment? {
childFragmentManager.findFragmentByTag(fragmentTag)?.let {
return it
}
val parent = parentFragment
return if (parent != null) {
parent.findFragmentByTagRecursive(fragmentTag)
} else {
parentFragmentManager.findFragmentByTag(fragmentTag)
}
}
companion object {
fun from(view: View): AppRouter? = runCatching {
AppRouter(view.findFragment<Fragment>())
}.getOrElse {
(view.context.findActivity() as? FragmentActivity)?.let(::AppRouter)
}
fun detailsIntent(context: Context, manga: Manga) = Intent(context, DetailsActivity::class.java)
.putExtra(KEY_MANGA, ParcelableManga(manga))
fun detailsIntent(context: Context, mangaId: Long) = Intent(context, DetailsActivity::class.java)
.putExtra(KEY_ID, mangaId)
fun listIntent(context: Context, source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?): Intent =
Intent(context, MangaListActivity::class.java)
.setAction(ACTION_MANGA_EXPLORE)
.putExtra(KEY_SOURCE, source.name)
.apply {
if (!filter.isNullOrEmpty()) {
putExtra(KEY_FILTER, ParcelableMangaListFilter(filter))
}
if (sortOrder != null) {
putExtra(KEY_SORT_ORDER, sortOrder)
}
}
fun cloudFlareResolveIntent(context: Context, exception: CloudFlareProtectedException): Intent =
Intent(context, CloudFlareActivity::class.java).apply {
data = exception.url.toUri()
putExtra(KEY_SOURCE, exception.source?.name)
exception.headers[CommonHeaders.USER_AGENT]?.let {
putExtra(KEY_USER_AGENT, it)
}
}
fun suggestionsIntent(context: Context) = Intent(context, SuggestionsActivity::class.java)
fun homeIntent(context: Context) = Intent(context, MainActivity::class.java)
fun mangaUpdatesIntent(context: Context) = Intent(context, UpdatesActivity::class.java)
fun readerSettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_READER)
fun suggestionsSettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_SUGGESTIONS)
fun trackerSettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_TRACKER)
fun proxySettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_PROXY)
fun historySettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_HISTORY)
fun sourcesSettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_SOURCES)
fun manageSourcesIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_MANAGE_SOURCES)
fun downloadsSettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_MANAGE_DOWNLOADS)
fun sourceSettingsIntent(context: Context, source: MangaSource): Intent = when (source) {
is MangaSourceInfo -> sourceSettingsIntent(context, source.mangaSource)
is ExternalMangaSource -> Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
.setData(Uri.fromParts("package", source.packageName, null))
else -> Intent(context, SettingsActivity::class.java)
.setAction(ACTION_SOURCE)
.putExtra(KEY_SOURCE, source.name)
}
fun sourceAuthIntent(context: Context, source: MangaSource): Intent {
return Intent(context, SourceAuthActivity::class.java)
.putExtra(KEY_SOURCE, source.name)
}
const val KEY_DATA = "data"
const val KEY_ENTRIES = "entries"
const val KEY_ERROR = "error"
const val KEY_EXCLUDE = "exclude"
const val KEY_FILE = "file"
const val KEY_FILTER = "filter"
const val KEY_ID = "id"
const val KEY_INDEX = "index"
const val KEY_KIND = "kind"
const val KEY_LIST_SECTION = "list_section"
const val KEY_MANGA = "manga"
const val KEY_MANGA_LIST = "manga_list"
const val KEY_PAGES = "pages"
const val KEY_QUERY = "query"
const val KEY_READER_MODE = "reader_mode"
const val KEY_SORT_ORDER = "sort_order"
const val KEY_SOURCE = "source"
const val KEY_TAB = "tab"
const val KEY_TITLE = "title"
const val KEY_URL = "url"
const val KEY_USER_AGENT = "user_agent"
const val ACTION_HISTORY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_HISTORY"
const val ACTION_MANAGE_DOWNLOADS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_DOWNLOADS"
const val ACTION_MANAGE_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES_LIST"
const val ACTION_MANGA_EXPLORE = "${BuildConfig.APPLICATION_ID}.action.EXPLORE_MANGA"
const val ACTION_PROXY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_PROXY"
const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS"
const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS"
const val ACTION_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES"
const val ACTION_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS"
const val ACTION_TRACKER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_TRACKER"
private const val ACCOUNT_KEY = "account"
private const val ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS"
private const val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args"
private fun Class<out Fragment>.fragmentTag() = name // TODO
private inline fun <reified F : Fragment> fragmentTag() = F::class.java.fragmentTag()
}
}

View File

@@ -1,13 +0,0 @@
package org.koitharu.kotatsu.core.nav
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.koitharu.kotatsu.core.prefs.AppSettings
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AppRouterEntryPoint {
val settings: AppSettings
}

View File

@@ -1,39 +0,0 @@
package org.koitharu.kotatsu.core.nav
import android.app.ActivityOptions
import android.os.Bundle
import android.view.View
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
inline val FragmentActivity.router: AppRouter
get() = AppRouter(this)
inline val Fragment.router: AppRouter
get() = AppRouter(this)
tailrec fun Fragment.dismissParentDialog(): Boolean {
return when (val parent = parentFragment) {
null -> return false
is DialogFragment -> {
parent.dismiss()
true
}
else -> parent.dismissParentDialog()
}
}
fun scaleUpActivityOptionsOf(view: View): Bundle? = if (view.context.isAnimationsEnabled) {
ActivityOptions.makeScaleUpAnimation(
view,
0,
0,
view.width,
view.height,
).toBundle()
} else {
null
}

View File

@@ -1,61 +0,0 @@
package org.koitharu.kotatsu.core.nav
import android.content.Context
import android.content.Intent
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
@JvmInline
value class ReaderIntent private constructor(
val intent: Intent,
) {
class Builder(context: Context) {
private val intent = Intent(context, ReaderActivity::class.java)
.setAction(ACTION_MANGA_READ)
fun manga(manga: Manga) = apply {
intent.putExtra(AppRouter.KEY_MANGA, ParcelableManga(manga))
}
fun mangaId(mangaId: Long) = apply {
intent.putExtra(AppRouter.KEY_ID, mangaId)
}
fun incognito(incognito: Boolean) = apply {
intent.putExtra(EXTRA_INCOGNITO, incognito)
}
fun branch(branch: String?) = apply {
intent.putExtra(EXTRA_BRANCH, branch)
}
fun state(state: ReaderState?) = apply {
intent.putExtra(EXTRA_STATE, state)
}
fun bookmark(bookmark: Bookmark) = manga(
bookmark.manga,
).state(
ReaderState(
chapterId = bookmark.chapterId,
page = bookmark.page,
scroll = bookmark.scroll,
),
)
fun build() = ReaderIntent(intent)
}
companion object {
const val ACTION_MANGA_READ = "${BuildConfig.APPLICATION_ID}.action.READ_MANGA"
const val EXTRA_STATE = "state"
const val EXTRA_BRANCH = "branch"
const val EXTRA_INCOGNITO = "incognito"
}
}

View File

@@ -23,8 +23,6 @@ import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.nav.ReaderIntent
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -38,6 +36,8 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.search.ui.MangaListActivity
import javax.inject.Inject
import javax.inject.Singleton
@@ -155,10 +155,9 @@ class AppShortcutManager @Inject constructor(
.setIcon(icon)
.setLongLived(true)
.setIntent(
ReaderIntent.Builder(context)
ReaderActivity.IntentBuilder(context)
.mangaId(manga.id)
.build()
.intent,
.build(),
)
.build()
}
@@ -182,7 +181,7 @@ class AppShortcutManager @Inject constructor(
.setLongLabel(title)
.setIcon(icon)
.setLongLived(true)
.setIntent(AppRouter.listIntent(context, source, null, null))
.setIntent(MangaListActivity.newIntent(context, source, null))
.build()
}
}

View File

@@ -1,12 +1,9 @@
package org.koitharu.kotatsu.core.os
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.content.pm.PackageInfoCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import javax.inject.Inject
import javax.inject.Singleton
@@ -14,8 +11,8 @@ import javax.inject.Singleton
class AppValidator @Inject constructor(
@ApplicationContext private val context: Context,
) {
@SuppressLint("InlinedApi")
val isOriginalApp = suspendLazy(Dispatchers.Default) {
@Suppress("NewApi")
val isOriginalApp by lazy {
val certificates = mapOf(CERT_SHA256.hexToByteArray() to PackageManager.CERT_INPUT_SHA256)
PackageInfoCompat.hasSignatures(context.packageManager, context.packageName, certificates, false)
}

View File

@@ -69,7 +69,7 @@ class NetworkState(
return true
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
activeNetwork?.let { isOnline(it) } == true
activeNetwork?.let { isOnline(it) } ?: false
} else {
@Suppress("DEPRECATION")
activeNetworkInfo?.isConnected == true

View File

@@ -1,92 +0,0 @@
package org.koitharu.kotatsu.core.os
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.storage.StorageManager
import android.provider.DocumentsContract
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultCaller
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.core.app.ActivityOptionsCompat
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
// https://stackoverflow.com/questions/77555641/saf-no-activity-found-to-handle-intent-android-intent-action-open-document-tr
class OpenDocumentTreeHelper(
activityResultCaller: ActivityResultCaller,
flags: Int,
callback: ActivityResultCallback<Uri?>
) : ActivityResultLauncher<Uri?>() {
constructor(activityResultCaller: ActivityResultCaller, callback: ActivityResultCallback<Uri?>) : this(
activityResultCaller,
0,
callback,
)
private val pickFileTreeLauncherQ = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
activityResultCaller.registerForActivityResult(OpenDocumentTreeContractQ(flags), callback)
} else {
null
}
private val pickFileTreeLauncherLegacy = activityResultCaller.registerForActivityResult(
contract = OpenDocumentTreeContractLegacy(flags),
callback = callback,
)
override fun launch(input: Uri?, options: ActivityOptionsCompat?) {
if (pickFileTreeLauncherQ == null) {
pickFileTreeLauncherLegacy.launch(input, options)
return
}
try {
pickFileTreeLauncherQ.launch(input, options)
} catch (e: Exception) {
e.printStackTraceDebug()
pickFileTreeLauncherLegacy.launch(input, options)
}
}
override fun unregister() {
pickFileTreeLauncherQ?.unregister()
pickFileTreeLauncherLegacy.unregister()
}
override val contract: ActivityResultContract<Uri?, *>
get() = pickFileTreeLauncherQ?.contract ?: pickFileTreeLauncherLegacy.contract
private open class OpenDocumentTreeContractLegacy(
private val flags: Int,
) : ActivityResultContracts.OpenDocumentTree() {
override fun createIntent(context: Context, input: Uri?): Intent {
val intent = super.createIntent(context, input)
intent.addFlags(flags)
return intent
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private class OpenDocumentTreeContractQ(
private val flags: Int,
) : OpenDocumentTreeContractLegacy(flags) {
override fun createIntent(context: Context, input: Uri?): Intent {
val intent = (context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager)
?.primaryStorageVolume
?.createOpenDocumentTreeIntent()
if (intent == null) { // fallback
return super.createIntent(context, input)
}
intent.addFlags(flags)
if (input != null) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, input)
}
return intent
}
}
}

View File

@@ -1,18 +0,0 @@
package org.koitharu.kotatsu.core.os
import kotlinx.coroutines.Dispatchers
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import java.io.InputStreamReader
object RomCompat {
val isMiui = suspendLazy<Boolean>(Dispatchers.IO) {
getProp("ro.miui.ui.version.name").isNotEmpty()
}
@Blocking
private fun getProp(propName: String) = Runtime.getRuntime().exec("getprop $propName").inputStream.use {
it.reader().use(InputStreamReader::readText).trim()
}
}

View File

@@ -2,22 +2,22 @@ package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.AbstractMangaParser
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities
import java.util.EnumSet
/**
* This parser is just for parser development, it should not be used in releases
*/
class DummyParser(context: MangaLoaderContext) : AbstractMangaParser(context, MangaParserSource.DUMMY) {
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaParserSource.DUMMY) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("localhost")
@@ -25,14 +25,14 @@ class DummyParser(context: MangaLoaderContext) : AbstractMangaParser(context, Ma
override val availableSortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java)
override val searchQueryCapabilities: MangaSearchQueryCapabilities
get() = MangaSearchQueryCapabilities()
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
override val filterCapabilities: MangaListFilterCapabilities
get() = MangaListFilterCapabilities()
override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null)
override suspend fun getList(query: MangaSearchQuery): List<Manga> = stub(null)
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> = stub(null)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)

View File

@@ -14,8 +14,6 @@ import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.nav.MangaIntent
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.parsers.model.Manga
@@ -29,7 +27,6 @@ import javax.inject.Provider
class MangaDataRepository @Inject constructor(
private val db: MangaDatabase,
private val resolverProvider: Provider<MangaLinkResolver>,
private val appShortcutManagerProvider: Provider<AppShortcutManager>,
) {
suspend fun saveReaderMode(manga: Manga, mode: ReaderMode) {
@@ -48,8 +45,8 @@ class MangaDataRepository @Inject constructor(
entity.copy(
cfBrightness = colorFilter?.brightness ?: 0f,
cfContrast = colorFilter?.contrast ?: 0f,
cfInvert = colorFilter?.isInverted == true,
cfGrayscale = colorFilter?.isGrayscale == true,
cfInvert = colorFilter?.isInverted ?: false,
cfGrayscale = colorFilter?.isGrayscale ?: false,
),
)
}
@@ -73,13 +70,8 @@ class MangaDataRepository @Inject constructor(
.distinctUntilChanged()
}
suspend fun findMangaById(mangaId: Long, withChapters: Boolean): Manga? {
val chapters = if (withChapters) {
db.getChaptersDao().findAll(mangaId).takeUnless { it.isEmpty() }
} else {
null
}
return db.getMangaDao().find(mangaId)?.toManga(chapters)
suspend fun findMangaById(mangaId: Long): Manga? {
return db.getMangaDao().find(mangaId)?.toManga()
}
suspend fun findMangaByPublicUrl(publicUrl: String): Manga? {
@@ -88,7 +80,7 @@ class MangaDataRepository @Inject constructor(
suspend fun resolveIntent(intent: MangaIntent): Manga? = when {
intent.manga != null -> intent.manga
intent.mangaId != 0L -> findMangaById(intent.mangaId, true)
intent.mangaId != 0L -> findMangaById(intent.mangaId)
intent.uri != null -> resolverProvider.get().resolve(intent.uri)
else -> null
}
@@ -105,26 +97,10 @@ class MangaDataRepository @Inject constructor(
val tags = manga.tags.toEntities()
db.getTagsDao().upsert(tags)
db.getMangaDao().upsert(manga.toEntity(), tags)
if (!manga.isLocal) {
manga.chapters?.let { chapters ->
db.getChaptersDao().replaceAll(manga.id, chapters.withIndex().toEntities(manga.id))
}
}
}
}
}
suspend fun updateChapters(manga: Manga) {
val chapters = manga.chapters
if (!chapters.isNullOrEmpty() && manga.id in db.getMangaDao()) {
db.getChaptersDao().replaceAll(manga.id, chapters.withIndex().toEntities(manga.id))
}
}
suspend fun gcChaptersCache() {
db.getChaptersDao().gc()
}
suspend fun findTags(source: MangaSource): Set<MangaTag> {
return db.getTagsDao().findTags(source.name).toMangaTags()
}
@@ -138,14 +114,6 @@ class MangaDataRepository @Inject constructor(
}
}
suspend fun cleanupDatabase() {
db.withTransaction {
gcChaptersCache()
val idsFromShortcuts = appShortcutManagerProvider.get().getMangaShortcuts()
db.getMangaDao().cleanup(idsFromShortcuts)
}
}
private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? {
return if (cfBrightness != 0f || cfContrast != 0f || cfInvert || cfGrayscale) {
ReaderColorFilter(cfBrightness, cfContrast, cfInvert, cfGrayscale)

View File

@@ -1,12 +1,11 @@
package org.koitharu.kotatsu.core.nav
package org.koitharu.kotatsu.core.parser
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.lifecycle.SavedStateHandle
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.nav.AppRouter.Companion.KEY_ID
import org.koitharu.kotatsu.core.nav.AppRouter.Companion.KEY_MANGA
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.getParcelableCompat
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.parsers.model.Manga
@@ -26,7 +25,7 @@ class MangaIntent private constructor(
constructor(savedStateHandle: SavedStateHandle) : this(
manga = savedStateHandle.get<ParcelableManga>(KEY_MANGA)?.manga,
id = savedStateHandle[KEY_ID] ?: ID_NONE,
uri = savedStateHandle[AppRouter.KEY_DATA],
uri = savedStateHandle[BaseActivity.EXTRA_DATA],
)
constructor(args: Bundle?) : this(
@@ -42,6 +41,9 @@ class MangaIntent private constructor(
const val ID_NONE = 0L
const val KEY_MANGA = "manga"
const val KEY_ID = "id"
fun of(manga: Manga) = MangaIntent(manga, manga.id, null)
}
}

View File

@@ -6,7 +6,6 @@ import dagger.Reusable
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
@@ -110,11 +109,4 @@ class MangaLinkResolver @Inject constructor(
chapters = null,
source = source,
)
companion object {
fun isValidLink(str: String): Boolean {
return str.isHttpUrl() || str.startsWith("kotatsu://", ignoreCase = true)
}
}
}

View File

@@ -23,7 +23,6 @@ import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
import org.koitharu.kotatsu.core.util.ext.toList
import org.koitharu.kotatsu.core.util.ext.toMimeType
import org.koitharu.kotatsu.core.util.ext.use
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
@@ -79,14 +78,13 @@ class MangaLoaderContextImpl @Inject constructor(
override fun redrawImageResponse(response: Response, redraw: (image: Bitmap) -> Bitmap): Response {
return response.map { body ->
BitmapDecoderCompat.decode(body.byteStream(), body.contentType()?.toMimeType(), isMutable = true)
.use { bitmap ->
(redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper).use { result ->
Buffer().also {
result.compressTo(it.outputStream())
}.asResponseBody("image/jpeg".toMediaType())
}
BitmapDecoderCompat.decode(body.byteStream(), body.contentType(), isMutable = true).use { bitmap ->
(redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper).use { result ->
Buffer().also {
result.compressTo(it.outputStream())
}.asResponseBody("image/jpeg".toMediaType())
}
}
}
}

View File

@@ -42,7 +42,7 @@ class ExternalMangaRepository(
override var defaultSortOrder: SortOrder
get() = capabilities?.availableSortOrders?.firstOrNull() ?: SortOrder.ALPHABETICAL
set(value) = Unit
set(_) = Unit
override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptions.get()

View File

@@ -229,7 +229,7 @@ class ExternalPluginContentSource(
do {
result += MangaChapter(
id = cursor.getLong(COLUMN_ID),
title = cursor.getStringOrNull(COLUMN_NAME),
name = cursor.getString(COLUMN_NAME),
number = cursor.getFloatOrDefault(COLUMN_NUMBER, 0f),
volume = cursor.getIntOrDefault(COLUMN_VOLUME, 0),
url = cursor.getString(COLUMN_URL),
@@ -252,7 +252,7 @@ class ExternalPluginContentSource(
publicUrl = getString(COLUMN_PUBLIC_URL),
rating = getFloat(COLUMN_RATING),
isNsfw = getBooleanOrDefault(COLUMN_IS_NSFW, false),
coverUrl = getStringOrNull(COLUMN_COVER_URL),
coverUrl = getString(COLUMN_COVER_URL),
tags = getStringOrNull(COLUMN_TAGS)?.split(':')?.mapNotNullToSet {
val parts = it.splitTwoParts('=') ?: return@mapNotNullToSet null
MangaTag(key = parts.first, title = parts.second, source = source)

View File

@@ -20,7 +20,7 @@ class ExternalPluginCursor(private val source: ExternalMangaSource, cursor: Curs
return when {
columnIndex < 0 -> null
isNull(columnIndex) -> null
else -> getString(columnIndex).takeUnless { it == "null" }
else -> getString(columnIndex)
}
}

View File

@@ -15,13 +15,12 @@ import androidx.core.os.LocaleListCompat
import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager
import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.R
import org.json.JSONArray
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.getEnumValue
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.putAll
import org.koitharu.kotatsu.core.util.ext.putEnumValue
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
@@ -45,7 +44,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
private val connectivityManager = context.connectivityManager
private val mangaListBadgesDefault = ArraySet(context.resources.getStringArray(R.array.values_list_badges))
var listMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.GRID)
@@ -143,11 +141,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReaderOptimizationEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_OPTIMIZE, false)
val readerControls: Set<ReaderControl>
get() = prefs.getStringSet(KEY_READER_CONTROLS, null)?.mapNotNullTo(EnumSet.noneOf(ReaderControl::class.java)) {
ReaderControl.entries.find(it)
} ?: ReaderControl.DEFAULT
val isOfflineCheckDisabled: Boolean
get() = prefs.getBoolean(KEY_OFFLINE_DISABLED, false)
@@ -306,10 +299,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getInt(KEY_SOURCES_VERSION, 0)
set(value) = prefs.edit { putInt(KEY_SOURCES_VERSION, value) }
var isAllSourcesEnabled: Boolean
get() = prefs.getBoolean(KEY_SOURCES_ENABLED_ALL, false)
set(value) = prefs.edit { putBoolean(KEY_SOURCES_ENABLED_ALL, value) }
val isPagesNumbersEnabled: Boolean
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
@@ -374,8 +363,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReaderBarEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_BAR, true)
val isReaderBarTransparent: Boolean
get() = prefs.getBoolean(KEY_READER_BAR_TRANSPARENT, true)
val isReaderSliderEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_SLIDER, true)
val isReaderKeepScreenOn: Boolean
get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true)
@@ -500,12 +489,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull()
set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) }
val isBackupTelegramUploadEnabled: Boolean
get() = prefs.getBoolean(KEY_BACKUP_TG_ENABLED, false)
val backupTelegramChatId: String?
get() = prefs.getString(KEY_BACKUP_TG_CHAT, null)?.nullIfEmpty()
val isReadingTimeEstimationEnabled: Boolean
get() = prefs.getBoolean(KEY_READING_TIME, true)
@@ -548,15 +531,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
prefs.edit { putString(KEY_PAGES_SAVE_DIR, uri?.toString()) }
}
fun getMangaListBadges(): Int {
val raw = prefs.getStringSet(KEY_MANGA_LIST_BADGES, mangaListBadgesDefault).orEmpty()
var result = 0
for (item in raw) {
result = result or item.toInt()
}
return result
}
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
prefs.registerOnSharedPreferenceChangeListener(listener)
}
@@ -569,7 +543,20 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
fun getAllValues(): Map<String, *> = prefs.all
fun upsertAll(m: Map<String, *>) = prefs.edit { putAll(m) }
fun upsertAll(m: Map<String, *>) {
prefs.edit {
m.forEach { e ->
when (val v = e.value) {
is Boolean -> putBoolean(e.key, v)
is Int -> putInt(e.key, v)
is Long -> putLong(e.key, v)
is Float -> putFloat(e.key, v)
is String -> putString(e.key, v)
is JSONArray -> putStringSet(e.key, v.toStringSet())
}
}
}
}
private fun isBackgroundNetworkRestricted(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
@@ -579,6 +566,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
}
}
private fun JSONArray.toStringSet(): Set<String> {
val len = length()
val result = ArraySet<String>(len)
for (i in 0 until len) {
result.add(getString(i))
}
return result
}
companion object {
const val TRACK_HISTORY = "history"
@@ -625,7 +621,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_NOTIFICATIONS_LIGHT = "notifications_light"
const val KEY_NOTIFICATIONS_INFO = "tracker_notifications_info"
const val KEY_READER_ANIMATION = "reader_animation2"
const val KEY_READER_CONTROLS = "reader_controls"
const val KEY_READER_MODE = "reader_mode"
const val KEY_READER_MODE_DETECT = "reader_mode_detect"
const val KEY_READER_CROP = "reader_crop"
@@ -669,7 +664,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SYNC = "sync"
const val KEY_SYNC_SETTINGS = "sync_settings"
const val KEY_READER_BAR = "reader_bar"
const val KEY_READER_BAR_TRANSPARENT = "reader_bar_transparent"
const val KEY_READER_SLIDER = "reader_slider"
const val KEY_READER_BACKGROUND = "reader_background"
const val KEY_READER_SCREEN_ON = "reader_screen_on"
const val KEY_SHORTCUTS = "dynamic_shortcuts"
@@ -720,16 +715,13 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_FEED_HEADER = "feed_header"
const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types"
const val KEY_SOURCES_VERSION = "sources_version"
const val KEY_SOURCES_ENABLED_ALL = "sources_enabled_all"
const val KEY_QUICK_FILTER = "quick_filter"
const val KEY_BACKUP_TG_ENABLED = "backup_periodic_tg_enabled"
const val KEY_BACKUP_TG_CHAT = "backup_periodic_tg_chat_id"
const val KEY_MANGA_LIST_BADGES = "manga_list_badges"
// keys for non-persistent preferences
const val KEY_APP_VERSION = "app_version"
const val KEY_IGNORE_DOZE = "ignore_dose"
const val KEY_TRACKER_DEBUG = "tracker_debug"
const val KEY_APP_UPDATE = "app_update"
const val KEY_LINK_WEBLATE = "about_app_translation"
const val KEY_LINK_TELEGRAM = "about_telegram"
const val KEY_LINK_GITHUB = "about_github"
@@ -737,10 +729,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_PROXY_TEST = "proxy_test"
const val KEY_OPEN_BROWSER = "open_browser"
const val KEY_HANDLE_LINKS = "handle_links"
const val KEY_BACKUP_TG_OPEN = "backup_periodic_tg_open"
const val KEY_BACKUP_TG_TEST = "backup_periodic_tg_test"
const val KEY_CLEAR_MANGA_DATA = "manga_data_clear"
const val KEY_STORAGE_USAGE = "storage_usage"
// old keys are for migration only
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"

View File

@@ -1,13 +1,11 @@
package org.koitharu.kotatsu.core.prefs
import android.content.Context
import android.graphics.drawable.Drawable
import android.view.ContextThemeWrapper
import androidx.annotation.Keep
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toDrawable
import org.koitharu.kotatsu.core.util.ext.getThemeDrawable
import org.koitharu.kotatsu.core.util.ext.isNightMode
import com.google.android.material.R as materialR
@Keep
@@ -15,7 +13,7 @@ enum class ReaderBackground {
DEFAULT, LIGHT, DARK, WHITE, BLACK;
fun resolve(context: Context): Drawable? = when (this) {
fun resolve(context: Context) = when (this) {
DEFAULT -> context.getThemeDrawable(android.R.attr.windowBackground)
LIGHT -> ContextThemeWrapper(context, materialR.style.ThemeOverlay_Material3_Light)
.getThemeDrawable(android.R.attr.windowBackground)
@@ -26,14 +24,4 @@ enum class ReaderBackground {
WHITE -> ContextCompat.getColor(context, android.R.color.white).toDrawable()
BLACK -> ContextCompat.getColor(context, android.R.color.black).toDrawable()
}
fun isLight(context: Context): Boolean = when (this) {
DEFAULT -> !context.resources.isNightMode
LIGHT,
WHITE -> true
DARK,
BLACK -> false
}
}

View File

@@ -1,15 +0,0 @@
package org.koitharu.kotatsu.core.prefs
import java.util.EnumSet
enum class ReaderControl {
PREV_CHAPTER, NEXT_CHAPTER, SLIDER, PAGES_SHEET, SCREEN_ROTATION, SAVE_PAGE;
companion object {
val DEFAULT: Set<ReaderControl> = EnumSet.of(
PREV_CHAPTER, NEXT_CHAPTER, SLIDER, PAGES_SHEET,
)
}
}

View File

@@ -25,9 +25,6 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
val isSlowdownEnabled: Boolean
get() = prefs.getBoolean(KEY_SLOWDOWN, false)
val isCaptchaNotificationsDisabled: Boolean
get() = prefs.getBoolean(KEY_NO_CAPTCHA, false)
@Suppress("UNCHECKED_CAST")
override fun <T> get(key: ConfigKey<T>): T {
return when (key) {
@@ -68,6 +65,5 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
const val KEY_SORT_ORDER = "sort_order"
const val KEY_SLOWDOWN = "slowdown"
const val KEY_NO_CAPTCHA = "no_captcha"
}
}

View File

@@ -1,20 +1,18 @@
package org.koitharu.kotatsu.core.ui
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.os.Build
import android.graphics.Color
import android.os.Bundle
import android.view.KeyEvent
import android.view.View
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.annotation.CallSuper
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityCompat
import androidx.core.view.WindowCompat
import androidx.fragment.app.FragmentManager
import androidx.viewbinding.ViewBinding
import dagger.hilt.android.EntryPointAccessors
@@ -23,15 +21,16 @@ import kotlinx.coroutines.flow.flowOf
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
abstract class BaseActivity<B : ViewBinding> :
AppCompatActivity(),
ExceptionResolver.Host,
ScreenshotPolicyHelper.ContentContainer {
ScreenshotPolicyHelper.ContentContainer,
WindowInsetsDelegate.WindowInsetsListener {
private var isAmoledTheme = false
@@ -41,20 +40,16 @@ abstract class BaseActivity<B : ViewBinding> :
protected lateinit var exceptionResolver: ExceptionResolver
private set
@JvmField
protected val insetsDelegate = WindowInsetsDelegate()
@JvmField
val actionModeDelegate = ActionModeDelegate()
private lateinit var entryPoint: BaseActivityEntryPoint
override fun attachBaseContext(newBase: Context) {
entryPoint = EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(newBase.applicationContext)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
AppCompatDelegate.setApplicationLocales(entryPoint.settings.appLocales)
}
super.attachBaseContext(newBase)
}
private var defaultStatusBarColor = Color.TRANSPARENT
override fun onCreate(savedInstanceState: Bundle?) {
val entryPoint = EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(this)
val settings = entryPoint.settings
isAmoledTheme = settings.isAmoledTheme
setTheme(settings.colorScheme.styleResId)
@@ -63,8 +58,10 @@ abstract class BaseActivity<B : ViewBinding> :
}
putDataToExtras(intent)
exceptionResolver = entryPoint.exceptionResolverFactory.create(this)
enableEdgeToEdge()
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
insetsDelegate.handleImeInsets = true
insetsDelegate.addInsetsListener(this)
}
override fun onPostCreate(savedInstanceState: Bundle?) {
@@ -98,6 +95,7 @@ abstract class BaseActivity<B : ViewBinding> :
super.setContentView(binding.root)
val toolbar = (binding.root.findViewById<View>(R.id.toolbar) as? Toolbar)
toolbar?.let(this::setSupportActionBar)
insetsDelegate.onViewCreated(binding.root)
}
override fun onSupportNavigateUp(): Boolean {
@@ -161,7 +159,7 @@ abstract class BaseActivity<B : ViewBinding> :
override fun isNsfwContent(): Flow<Boolean> = flowOf(false)
private fun putDataToExtras(intent: Intent?) {
intent?.putExtra(AppRouter.KEY_DATA, intent.data)
intent?.putExtra(EXTRA_DATA, intent.data)
}
protected fun setContentViewWebViewSafe(viewBindingProducer: () -> B): Boolean {
@@ -180,4 +178,9 @@ abstract class BaseActivity<B : ViewBinding> :
}
protected fun hasViewBinding() = ::viewBinding.isInitialized
companion object {
const val EXTRA_DATA = "data"
}
}

View File

@@ -10,10 +10,12 @@ import androidx.viewbinding.ViewBinding
import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
abstract class BaseFragment<B : ViewBinding> :
Fragment(),
ExceptionResolver.Host {
ExceptionResolver.Host,
WindowInsetsDelegate.WindowInsetsListener {
var viewBinding: B? = null
private set
@@ -21,6 +23,9 @@ abstract class BaseFragment<B : ViewBinding> :
protected lateinit var exceptionResolver: ExceptionResolver
private set
@JvmField
protected val insetsDelegate = WindowInsetsDelegate()
protected val actionModeDelegate: ActionModeDelegate
get() = (requireActivity() as BaseActivity<*>).actionModeDelegate
@@ -42,11 +47,15 @@ abstract class BaseFragment<B : ViewBinding> :
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
insetsDelegate.onViewCreated(view)
insetsDelegate.addInsetsListener(this)
onViewBindingCreated(requireViewBinding(), savedInstanceState)
}
override fun onDestroyView() {
viewBinding = null
insetsDelegate.removeInsetsListener(this)
insetsDelegate.onDestroyView()
super.onDestroyView()
}

View File

@@ -1,21 +1,27 @@
package org.koitharu.kotatsu.core.ui
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Gravity
import android.view.View
import androidx.annotation.CallSuper
import androidx.annotation.StringRes
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceScreen
import androidx.preference.get
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.getThemeDrawable
import org.koitharu.kotatsu.core.util.ext.parentView
@@ -26,6 +32,7 @@ import com.google.android.material.R as materialR
@AndroidEntryPoint
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
PreferenceFragmentCompat(),
WindowInsetsDelegate.WindowInsetsListener,
RecyclerViewOwner,
ExceptionResolver.Host {
@@ -35,7 +42,10 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
@Inject
lateinit var settings: AppSettings
override val recyclerView: RecyclerView?
@JvmField
protected val insetsDelegate = WindowInsetsDelegate()
override val recyclerView: RecyclerView
get() = listView
override fun onAttach(context: Context) {
@@ -49,7 +59,14 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
val themedContext = (view.parentView ?: view).context
view.setBackgroundColor(themedContext.getThemeColor(android.R.attr.colorBackground))
listView.clipToPadding = false
listView.consumeInsetsAsPadding(Gravity.BOTTOM or Gravity.START or Gravity.END)
insetsDelegate.onViewCreated(view)
insetsDelegate.addInsetsListener(this)
}
override fun onDestroyView() {
insetsDelegate.removeInsetsListener(this)
insetsDelegate.onDestroyView()
super.onDestroyView()
}
override fun onResume() {
@@ -61,10 +78,25 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
}
}
@CallSuper
override fun onWindowInsetsChanged(insets: Insets) {
listView.updatePadding(
bottom = insets.bottom,
)
}
protected open fun setTitle(title: CharSequence?) {
(activity as? SettingsActivity)?.setSectionTitle(title)
}
protected fun startActivitySafe(intent: Intent): Boolean = try {
startActivity(intent)
true
} catch (_: ActivityNotFoundException) {
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
false
}
private fun focusPreference(key: String) {
val pref = findPreference<Preference>(key)
if (pref == null) {

View File

@@ -0,0 +1,5 @@
package org.koitharu.kotatsu.core.ui
import androidx.lifecycle.LifecycleService
abstract class BaseService : LifecycleService()

View File

@@ -14,7 +14,6 @@ import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
@@ -43,8 +42,6 @@ abstract class CoroutineIntentService : BaseService() {
intentJobContext.processIntent(intent)
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
e.printStackTraceDebug()
intentJobContext.onError(e)

View File

@@ -1,40 +0,0 @@
package org.koitharu.kotatsu.core.ui
import android.os.Bundle
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.fragment.app.Fragment
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.databinding.ActivityContainerBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
@AndroidEntryPoint
abstract class FragmentContainerActivity(private val fragmentClass: Class<out Fragment>) :
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, fragmentClass, getFragmentExtras())
}
}
}
protected open fun getFragmentExtras(): Bundle? = intent.extras
}

View File

@@ -4,7 +4,6 @@ import android.content.Context
import android.view.LayoutInflater
import android.widget.CompoundButton.OnCheckedChangeListener
import androidx.annotation.StringRes
import androidx.annotation.UiContext
import androidx.appcompat.app.AlertDialog
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.LinearLayoutManager
@@ -18,7 +17,7 @@ import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
import com.google.android.material.R as materialR
inline fun buildAlertDialog(
@UiContext context: Context,
context: Context,
isCentered: Boolean = false,
block: MaterialAlertDialogBuilder.() -> Unit,
): AlertDialog = MaterialAlertDialogBuilder(

View File

@@ -0,0 +1,58 @@
package org.koitharu.kotatsu.core.ui.dialog
import android.content.Context
import android.content.DialogInterface
import androidx.annotation.UiContext
import androidx.core.net.ConnectivityManagerCompat
import dagger.Lazy
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.TriStateOption
import org.koitharu.kotatsu.core.util.ext.connectivityManager
import javax.inject.Inject
class CommonAlertDialogs @Inject constructor(
private val settings: Lazy<AppSettings>,
) {
fun askForDownloadOverMeteredNetwork(
@UiContext context: Context,
onConfirmed: (allow: Boolean) -> Unit
) {
when (settings.get().allowDownloadOnMeteredNetwork) {
TriStateOption.ENABLED -> onConfirmed(true)
TriStateOption.DISABLED -> onConfirmed(false)
TriStateOption.ASK -> {
if (!ConnectivityManagerCompat.isActiveNetworkMetered(context.connectivityManager)) {
onConfirmed(true)
return
}
val listener = DialogInterface.OnClickListener { _, which ->
when (which) {
DialogInterface.BUTTON_POSITIVE -> {
settings.get().allowDownloadOnMeteredNetwork = TriStateOption.ENABLED
onConfirmed(true)
}
DialogInterface.BUTTON_NEUTRAL -> {
onConfirmed(true)
}
DialogInterface.BUTTON_NEGATIVE -> {
settings.get().allowDownloadOnMeteredNetwork = TriStateOption.DISABLED
onConfirmed(false)
}
}
}
BigButtonsAlertDialog.Builder(context)
.setIcon(R.drawable.ic_network_cellular)
.setTitle(R.string.download_cellular_confirm)
.setPositiveButton(R.string.allow_always, listener)
.setNeutralButton(R.string.allow_once, listener)
.setNegativeButton(R.string.dont_allow, listener)
.create()
.show()
}
}
}
}

View File

@@ -1,5 +1,8 @@
package org.koitharu.kotatsu.core.ui.dialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
@@ -7,15 +10,14 @@ import androidx.core.text.HtmlCompat
import androidx.core.text.htmlEncode
import androidx.core.text.method.LinkMovementMethodCompat
import androidx.core.text.parseAsHtml
import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.util.ext.copyToClipboard
import org.koitharu.kotatsu.core.util.ext.getCauseUrl
import org.koitharu.kotatsu.core.util.ext.isReportable
import org.koitharu.kotatsu.core.util.ext.report
import org.koitharu.kotatsu.core.util.ext.requireSerializable
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.DialogErrorDetailsBinding
class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
@@ -25,7 +27,7 @@ class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val args = requireArguments()
exception = args.requireSerializable(AppRouter.KEY_ERROR)
exception = args.requireSerializable(ARG_ERROR)
}
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogErrorDetailsBinding {
@@ -39,7 +41,7 @@ class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
text = context.getString(
R.string.manga_error_description_pattern,
exception.message?.htmlEncode().orEmpty(),
arguments?.getString(AppRouter.KEY_URL) ?: exception.getCauseUrl(),
arguments?.getString(ARG_URL),
).parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY)
}
}
@@ -51,7 +53,7 @@ class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
.setNegativeButton(android.R.string.cancel, null)
.setTitle(R.string.error_occurred)
.setNeutralButton(androidx.preference.R.string.copy) { _, _ ->
context?.copyToClipboard(getString(R.string.error), exception.stackTraceToString())
copyToClipboard()
}
if (exception.isReportable()) {
builder.setPositiveButton(R.string.report) { _, _ ->
@@ -61,4 +63,24 @@ class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
}
return builder
}
private fun copyToClipboard() {
val clipboardManager = context?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
?: return
clipboardManager.setPrimaryClip(
ClipData.newPlainText(getString(R.string.error), exception.stackTraceToString()),
)
}
companion object {
private const val TAG = "ErrorDetailsDialog"
private const val ARG_ERROR = "error"
private const val ARG_URL = "url"
fun show(fm: FragmentManager, error: Throwable, url: String?) = ErrorDetailsDialog().withArgs(2) {
putSerializable(ARG_ERROR, error)
putString(ARG_URL, url)
}.show(fm, TAG)
}
}

View File

@@ -6,16 +6,11 @@ import android.graphics.Canvas
import android.graphics.drawable.Animatable
import androidx.annotation.StyleRes
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import coil3.Image
import coil3.asImage
import coil3.getExtra
import coil3.request.ImageRequest
import com.google.android.material.animation.ArgbEvaluatorCompat
import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.util.KotatsuColors
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.mangaSourceKey
import kotlin.math.abs
class AnimatedFaviconDrawable(
@@ -28,12 +23,12 @@ class AnimatedFaviconDrawable(
private val period = context.getAnimationDuration(R.integer.config_longAnimTime) * 2
private val timeAnimator = TimeAnimator()
private var colorHigh = MaterialColors.harmonize(colorForeground, currentBackgroundColor)
private var colorLow = ArgbEvaluatorCompat.getInstance().evaluate(0.3f, colorHigh, currentBackgroundColor)
private val colorHigh = MaterialColors.harmonize(KotatsuColors.random(name), colorBackground)
private val colorLow = ArgbEvaluatorCompat.getInstance().evaluate(0.3f, colorHigh, colorBackground)
init {
timeAnimator.setTimeListener(this)
onStateChange(state)
updateColor()
}
override fun draw(canvas: Canvas) {
@@ -44,11 +39,9 @@ class AnimatedFaviconDrawable(
super.draw(canvas)
}
// override fun setAlpha(alpha: Int) = Unit
//
// override fun getAlpha(): Int = 255
//
// override fun isOpaque(): Boolean = false
override fun setAlpha(alpha: Int) = Unit
override fun getAlpha(): Int = 255
override fun onTimeUpdate(animation: TimeAnimator?, totalTime: Long, deltaTime: Long) {
callback?.also {
@@ -67,33 +60,13 @@ class AnimatedFaviconDrawable(
override fun isRunning(): Boolean = timeAnimator.isStarted
override fun onStateChange(state: IntArray): Boolean {
val res = super.onStateChange(state)
colorHigh = MaterialColors.harmonize(currentForegroundColor, currentBackgroundColor)
colorLow = ArgbEvaluatorCompat.getInstance().evaluate(0.3f, colorHigh, currentBackgroundColor)
updateColor()
return res
}
private fun updateColor() {
if (period <= 0f) {
return
}
val ph = period / 2
val fraction = abs((System.currentTimeMillis() % period) - ph) / ph.toFloat()
currentForegroundColor = ArgbEvaluatorCompat.getInstance()
colorForeground = ArgbEvaluatorCompat.getInstance()
.evaluate(interpolator.getInterpolation(fraction), colorLow, colorHigh)
}
class Factory(
@StyleRes private val styleResId: Int,
) : ((ImageRequest) -> Image?) {
override fun invoke(request: ImageRequest): Image? {
val source = request.getExtra(mangaSourceKey) ?: return null
val context = request.context
val title = source.getTitle(context)
return AnimatedFaviconDrawable(context, styleResId, title).asImage()
}
}
}

View File

@@ -7,7 +7,6 @@ import android.graphics.ColorFilter
import android.graphics.PixelFormat
import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable
import androidx.core.graphics.ColorUtils
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import com.google.android.material.animation.ArgbEvaluatorCompat
import org.koitharu.kotatsu.R
@@ -24,7 +23,6 @@ class AnimatedPlaceholderDrawable(context: Context) : Drawable(), Animatable, Ti
private val interpolator = FastOutSlowInInterpolator()
private val period = context.getAnimationDuration(R.integer.config_longAnimTime) * 2
private val timeAnimator = TimeAnimator()
private var currentAlpha: Int = 255
init {
timeAnimator.setTimeListener(this)
@@ -40,17 +38,16 @@ class AnimatedPlaceholderDrawable(context: Context) : Drawable(), Animatable, Ti
}
override fun setAlpha(alpha: Int) {
currentAlpha = alpha
updateColor()
// this.alpha = alpha FIXME coil's crossfade
}
override fun setColorFilter(colorFilter: ColorFilter?) = Unit
@Suppress("DeprecatedCallableAddReplaceWith")
@Deprecated("Deprecated in Java")
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
override fun getOpacity(): Int = PixelFormat.OPAQUE
override fun getAlpha(): Int = currentAlpha
override fun setColorFilter(colorFilter: ColorFilter?) = Unit
override fun getAlpha(): Int = 255
override fun onTimeUpdate(animation: TimeAnimator?, totalTime: Long, deltaTime: Long) {
callback?.also {
@@ -75,10 +72,7 @@ class AnimatedPlaceholderDrawable(context: Context) : Drawable(), Animatable, Ti
}
val ph = period / 2
val fraction = abs((System.currentTimeMillis() % period) - ph) / ph.toFloat()
currentColor = ColorUtils.setAlphaComponent(
ArgbEvaluatorCompat.getInstance()
.evaluate(interpolator.getInterpolation(fraction), colorLow, colorHigh),
currentAlpha
)
currentColor = ArgbEvaluatorCompat.getInstance()
.evaluate(interpolator.getInterpolation(fraction), colorLow, colorHigh)
}
}

View File

@@ -1,12 +1,13 @@
package org.koitharu.kotatsu.core.ui.image
import android.view.View
import android.view.View.OnLayoutChangeListener
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.view.ViewTreeObserver.OnPreDrawListener
import android.widget.ImageView
import coil3.size.Dimension
import coil3.size.Size
import coil3.size.ViewSizeResolver
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import kotlin.math.roundToInt
@@ -19,67 +20,31 @@ class CoverSizeResolver(
) : ViewSizeResolver<ImageView> {
override suspend fun size(): Size {
// Fast path: the view is already measured.
getSize()?.let { return it }
// Slow path: wait for the view to be measured.
return suspendCancellableCoroutine { continuation ->
val viewTreeObserver = view.viewTreeObserver
val preDrawListener = object : OnPreDrawListener {
private var isResumed = false
override fun onPreDraw(): Boolean {
val size = getSize()
if (size != null) {
viewTreeObserver.removePreDrawListenerSafe(this)
if (!isResumed) {
isResumed = true
continuation.resume(size)
}
}
return true
}
}
viewTreeObserver.addOnPreDrawListener(preDrawListener)
continuation.invokeOnCancellation {
viewTreeObserver.removePreDrawListenerSafe(preDrawListener)
return suspendCancellableCoroutine { cont ->
val layoutListener = LayoutListener(cont)
view.addOnLayoutChangeListener(layoutListener)
cont.invokeOnCancellation {
view.removeOnLayoutChangeListener(layoutListener)
}
}
}
private fun getSize(): Size? {
var width = getWidth()
var height = getHeight()
when {
width == null && height == null -> {
return null
}
height == null && width != null -> {
height = Dimension((width.px * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).roundToInt())
}
width == null && height != null -> {
width = Dimension((height.px * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).roundToInt())
}
val lp = view.layoutParams
var width = getDimension(lp.width, view.width, view.paddingLeft + view.paddingRight)
var height = getDimension(lp.height, view.height, view.paddingTop + view.paddingBottom)
if (width == null && height == null) {
return null
}
if (height == null && width != null) {
height = Dimension((width.px * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).roundToInt())
} else if (width == null && height != null) {
width = Dimension((height.px * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).roundToInt())
}
return Size(checkNotNull(width), checkNotNull(height))
}
private fun getWidth() = getDimension(
paramSize = view.layoutParams?.width ?: -1,
viewSize = view.width,
paddingSize = if (subtractPadding) view.paddingLeft + view.paddingRight else 0
)
private fun getHeight() = getDimension(
paramSize = view.layoutParams?.height ?: -1,
viewSize = view.height,
paddingSize = if (subtractPadding) view.paddingTop + view.paddingBottom else 0
)
private fun getDimension(paramSize: Int, viewSize: Int, paddingSize: Int): Dimension.Pixels? {
if (paramSize == ViewGroup.LayoutParams.WRAP_CONTENT) {
return null
@@ -95,11 +60,24 @@ class CoverSizeResolver(
return null
}
private fun ViewTreeObserver.removePreDrawListenerSafe(victim: OnPreDrawListener) {
if (isAlive) {
removeOnPreDrawListener(victim)
} else {
view.viewTreeObserver.removeOnPreDrawListener(victim)
private inner class LayoutListener(
private val continuation: CancellableContinuation<Size>,
) : OnLayoutChangeListener {
override fun onLayoutChange(
v: View,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int,
) {
val size = getSize() ?: return
v.removeOnLayoutChangeListener(this)
continuation.resume(size)
}
}
}

View File

@@ -1,47 +1,34 @@
package org.koitharu.kotatsu.core.ui.image
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PixelFormat
import android.graphics.Rect
import android.graphics.RectF
import android.os.Build
import androidx.annotation.RequiresApi
import android.graphics.drawable.Drawable
import androidx.annotation.StyleRes
import androidx.core.content.withStyledAttributes
import androidx.core.graphics.withClip
import coil3.Image
import coil3.asImage
import coil3.getExtra
import coil3.request.ImageRequest
import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.util.KotatsuColors
import org.koitharu.kotatsu.core.util.ext.hasFocusStateSpecified
import org.koitharu.kotatsu.core.util.ext.mangaSourceKey
open class FaviconDrawable(
context: Context,
@StyleRes styleResId: Int,
name: String,
) : PaintDrawable() {
) : Drawable() {
override val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG)
protected var currentBackgroundColor = Color.WHITE
private set
private var colorBackground: ColorStateList = ColorStateList.valueOf(currentBackgroundColor)
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
protected var colorBackground = Color.WHITE
protected var colorForeground = Color.DKGRAY
protected var currentForegroundColor = Color.DKGRAY
protected var currentStrokeColor = Color.LTGRAY
private set
private var colorStroke: ColorStateList = ColorStateList.valueOf(currentStrokeColor)
private var colorStroke = Color.LTGRAY
private val letter = name.take(1).uppercase()
private var cornerSize = 0f
private var intrinsicSize = -1
private val textBounds = Rect()
private val tempRect = Rect()
private val boundsF = RectF()
@@ -49,17 +36,14 @@ open class FaviconDrawable(
init {
context.withStyledAttributes(styleResId, R.styleable.FaviconFallbackDrawable) {
colorBackground = getColorStateList(R.styleable.FaviconFallbackDrawable_backgroundColor) ?: colorBackground
colorStroke = getColorStateList(R.styleable.FaviconFallbackDrawable_strokeColor) ?: colorStroke
colorBackground = getColor(R.styleable.FaviconFallbackDrawable_backgroundColor, colorBackground)
colorStroke = getColor(R.styleable.FaviconFallbackDrawable_strokeColor, colorStroke)
cornerSize = getDimension(R.styleable.FaviconFallbackDrawable_cornerSize, cornerSize)
paint.strokeWidth = getDimension(R.styleable.FaviconFallbackDrawable_strokeWidth, 0f) * 2f
intrinsicSize = getDimensionPixelSize(R.styleable.FaviconFallbackDrawable_drawableSize, intrinsicSize)
}
paint.textAlign = Paint.Align.CENTER
paint.isFakeBoldText = true
colorForeground = KotatsuColors.random(name)
currentForegroundColor = MaterialColors.harmonize(colorForeground, colorBackground.defaultColor)
onStateChange(state)
colorForeground = MaterialColors.harmonize(KotatsuColors.random(name), colorBackground)
}
override fun draw(canvas: Canvas) {
@@ -83,42 +67,31 @@ open class FaviconDrawable(
clipPath.close()
}
override fun getIntrinsicWidth(): Int = intrinsicSize
override fun getIntrinsicHeight(): Int = intrinsicSize
override fun isOpaque(): Boolean = cornerSize == 0f && colorBackground.isOpaque
override fun isStateful(): Boolean = colorStroke.isStateful || colorBackground.isStateful
@RequiresApi(Build.VERSION_CODES.S)
override fun hasFocusStateSpecified(): Boolean =
colorBackground.hasFocusStateSpecified() || colorStroke.hasFocusStateSpecified()
override fun onStateChange(state: IntArray): Boolean {
val prevStrokeColor = currentStrokeColor
val prevBackgroundColor = currentBackgroundColor
currentStrokeColor = colorStroke.getColorForState(state, colorStroke.defaultColor)
currentBackgroundColor = colorBackground.getColorForState(state, colorBackground.defaultColor)
if (currentBackgroundColor != prevBackgroundColor) {
currentForegroundColor = MaterialColors.harmonize(colorForeground, currentBackgroundColor)
}
return prevBackgroundColor != currentBackgroundColor || prevStrokeColor != currentStrokeColor
override fun setAlpha(alpha: Int) {
paint.alpha = alpha
}
override fun setColorFilter(colorFilter: ColorFilter?) {
paint.colorFilter = colorFilter
}
@Suppress("DeprecatedCallableAddReplaceWith")
@Deprecated("Deprecated in Java")
override fun getOpacity() = PixelFormat.TRANSPARENT
private fun doDraw(canvas: Canvas) {
// background
paint.color = currentBackgroundColor
paint.color = colorBackground
paint.style = Paint.Style.FILL
canvas.drawPaint(paint)
// letter
paint.color = currentForegroundColor
paint.color = colorForeground
val cx = (boundsF.left + boundsF.right) * 0.6f
val ty = boundsF.bottom * 0.7f + textBounds.height() * 0.5f - textBounds.bottom
canvas.drawText(letter, cx, ty, paint)
if (paint.strokeWidth > 0f) {
// stroke
paint.color = currentStrokeColor
paint.color = colorStroke
paint.style = Paint.Style.STROKE
canvas.drawPath(clipPath, paint)
}
@@ -130,16 +103,4 @@ open class FaviconDrawable(
paint.getTextBounds(text, 0, text.length, tempRect)
return testTextSize * width / tempRect.width()
}
class Factory(
@StyleRes private val styleResId: Int,
) : ((ImageRequest) -> Image?) {
override fun invoke(request: ImageRequest): Image? {
val source = request.getExtra(mangaSourceKey) ?: return null
val context = request.context
val title = source.getTitle(context)
return FaviconDrawable(context, styleResId, title).asImage()
}
}
}

View File

@@ -1,53 +0,0 @@
package org.koitharu.kotatsu.core.ui.image
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.drawable.Drawable
@Suppress("OVERRIDE_DEPRECATION")
abstract class PaintDrawable : Drawable() {
protected abstract val paint: Paint
override fun setAlpha(alpha: Int) {
paint.alpha = alpha
}
override fun getAlpha(): Int {
return paint.alpha
}
override fun setColorFilter(colorFilter: ColorFilter?) {
paint.colorFilter = colorFilter
}
override fun getColorFilter(): ColorFilter? {
return paint.colorFilter
}
override fun setDither(dither: Boolean) {
paint.isDither = dither
}
override fun setFilterBitmap(filter: Boolean) {
paint.isFilterBitmap = filter
}
override fun isFilterBitmap(): Boolean {
return paint.isFilterBitmap
}
override fun getOpacity(): Int {
if (paint.colorFilter != null) {
return PixelFormat.TRANSLUCENT
}
return when (paint.alpha) {
0 -> PixelFormat.TRANSPARENT
255 -> if (isOpaque()) PixelFormat.OPAQUE else PixelFormat.TRANSLUCENT
else -> PixelFormat.TRANSLUCENT
}
}
protected open fun isOpaque() = false
}

View File

@@ -1,83 +0,0 @@
package org.koitharu.kotatsu.core.ui.image
import android.content.res.ColorStateList
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PointF
import android.graphics.Rect
import android.os.Build
import android.widget.TextView
import androidx.annotation.RequiresApi
import androidx.core.graphics.PaintCompat
import org.koitharu.kotatsu.core.util.ext.hasFocusStateSpecified
class TextDrawable(
val text: String,
) : PaintDrawable() {
override val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG)
private val textBounds = Rect()
private val textPoint = PointF()
var textSize: Float
get() = paint.textSize
set(value) {
paint.textSize = value
measureTextBounds()
}
var textColor: ColorStateList = ColorStateList.valueOf(Color.BLACK)
set(value) {
field = value
onStateChange(state)
}
init {
onStateChange(state)
measureTextBounds()
}
override fun draw(canvas: Canvas) {
canvas.drawText(text, textPoint.x, textPoint.y, paint)
}
override fun onBoundsChange(bounds: Rect) {
textPoint.set(
bounds.exactCenterX() - textBounds.exactCenterX(),
bounds.exactCenterY() - textBounds.exactCenterY(),
)
}
override fun getIntrinsicWidth(): Int = textBounds.width()
override fun getIntrinsicHeight(): Int = textBounds.height()
override fun isStateful(): Boolean = textColor.isStateful
@RequiresApi(Build.VERSION_CODES.S)
override fun hasFocusStateSpecified(): Boolean = textColor.hasFocusStateSpecified()
override fun onStateChange(state: IntArray): Boolean {
val prevColor = paint.color
paint.color = textColor.getColorForState(state, textColor.defaultColor)
return paint.color != prevColor
}
private fun measureTextBounds() {
paint.getTextBounds(text, 0, text.length, textBounds)
onBoundsChange(bounds)
}
companion object {
fun compound(textView: TextView, text: String): TextDrawable? {
val drawable = TextDrawable(text)
drawable.textSize = textView.textSize
drawable.textColor = textView.textColors
return drawable.takeIf {
PaintCompat.hasGlyph(drawable.paint, text)
}
}
}
}

View File

@@ -1,41 +0,0 @@
package org.koitharu.kotatsu.core.ui.image
import android.graphics.drawable.Drawable
import android.view.Gravity
import android.widget.TextView
import androidx.annotation.GravityInt
import coil3.target.GenericViewTarget
class TextViewTarget(
override val view: TextView,
@GravityInt compoundDrawable: Int,
) : GenericViewTarget<TextView>() {
private val drawableIndex: Int = when (compoundDrawable) {
Gravity.START -> 0
Gravity.TOP -> 2
Gravity.END -> 3
Gravity.BOTTOM -> 4
else -> -1
}
override var drawable: Drawable?
get() = if (drawableIndex != -1) {
view.compoundDrawablesRelative[drawableIndex]
} else {
null
}
set(value) {
if (drawableIndex == -1) {
return
}
val drawables = view.compoundDrawablesRelative
drawables[drawableIndex] = value
view.setCompoundDrawablesRelativeWithIntrinsicBounds(
drawables[0],
drawables[1],
drawables[2],
drawables[3],
)
}
}

View File

@@ -37,8 +37,8 @@ class FastScrollRecyclerView @JvmOverloads constructor(
init {
fastScroller.id = R.id.fast_scroller
fastScroller.layoutParams = ViewGroup.LayoutParams(
LayoutParams.WRAP_CONTENT,
LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
}

View File

@@ -11,9 +11,7 @@ import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.RelativeLayout
import android.widget.*
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.DimenRes
@@ -164,7 +162,7 @@ class FastScroller @JvmOverloads constructor(
override fun onSizeChanged(w: Int, h: Int, oldW: Int, oldH: Int) {
super.onSizeChanged(w, h, oldW, oldH)
viewHeight = h - paddingTop - paddingBottom
viewHeight = h
}
@SuppressLint("ClickableViewAccessibility")
@@ -231,7 +229,6 @@ class FastScroller @JvmOverloads constructor(
*
* @param params The [ViewGroup.LayoutParams] for this view, cannot be null
*/
@Suppress("RemoveRedundantQualifierName")
override fun setLayoutParams(params: ViewGroup.LayoutParams) {
params.width = LayoutParams.WRAP_CONTENT
super.setLayoutParams(params)
@@ -532,7 +529,7 @@ class FastScroller @JvmOverloads constructor(
private fun findValidParent(view: View): ViewGroup? = view.ancestors.firstNotNullOfOrNull { p ->
if (p is FrameLayout || p is ConstraintLayout || p is CoordinatorLayout || p is RelativeLayout) {
p
p as ViewGroup
} else {
null
}

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