Merge branch 'devel' into feature/fab-transition

pull/178/head
Koitharu 4 years ago
commit bef63b9f8f
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

@ -0,0 +1,29 @@
**PLEASE READ THIS**
I acknowledge that:
- I have updated to the latest version of the app (https://github.com/KotatsuApp/Kotatsu/releases/latest)
- If this is an issue with a parser, that I should be opening an issue in https://github.com/KotatsuApp/kotatsu-parsers
- I have searched the existing issues and this is new ticket **NOT** a duplicate or related to another open or closed issue
- I will fill out the title and the information in this template
Note that the issue will be automatically closed if you do not fill out the title or requested information.
**DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT**
---
## Device information
* Kotatsu version: ?
* Android version: ?
* Device: ?
## Steps to reproduce
1. First step
2. Second step
## Issue/Request
?
## Other details
Additional details and attachments.

@ -1,5 +1,5 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: ⚠️ Source issue - name: ⚠️ Source issue
url: https://github.com/nv95/kotatsu-parsers/issues/new url: https://github.com/KotatsuApp/kotatsu-parsers/issues/new
about: Issues and requests for sources should be opened in the kotatsu-parsers repository instead about: Issues and requests for sources should be opened in the kotatsu-parsers repository instead

@ -85,9 +85,7 @@ body:
required: true required: true
- label: I have written a short but informative title. - label: I have written a short but informative title.
required: true required: true
- label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new). - label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new).
required: true
- label: I have updated the app to version **[3.3](https://github.com/nv95/Kotatsu/releases/latest)**.
required: true required: true
- label: I will fill out all of the requested information in this form. - label: I will fill out all of the requested information in this form.
required: true required: true

@ -21,6 +21,16 @@ body:
placeholder: | placeholder: |
Additional details and attachments. Additional details and attachments.
- type: input
id: kotatsu-version
attributes:
label: Kotatsu version
description: You can find your Kotatsu version in **Settings → About**.
placeholder: |
Example: "3.3"
validations:
required: true
- type: checkboxes - type: checkboxes
id: acknowledgements id: acknowledgements
attributes: attributes:
@ -31,9 +41,7 @@ body:
required: true required: true
- label: I have written a short but informative title. - label: I have written a short but informative title.
required: true required: true
- label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new). - label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new).
required: true
- label: I have updated the app to version **[3.3](https://github.com/nv95/Kotatsu/releases/latest)**.
required: true required: true
- label: I will fill out all of the requested information in this form. - label: I will fill out all of the requested information in this form.
required: true required: true

@ -0,0 +1,29 @@
name: Issue moderator
on:
issues:
types: [opened, edited, reopened]
issue_comment:
types: [created]
jobs:
moderate:
runs-on: ubuntu-latest
steps:
- name: Moderate issues
uses: tachiyomiorg/issue-moderator-action@v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
auto-close-rules: |
[
{
"type": "body",
"regex": ".*DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT.*",
"message": "The acknowledgment section was not removed."
},
{
"type": "body",
"regex": ".*\\* (Kotatsu version|Android version|Device): \\?.*",
"message": "Requested information in the template was not filled out."
}
]

1
.gitignore vendored

@ -13,6 +13,7 @@
/.idea/kotlinScripting.xml /.idea/kotlinScripting.xml
/.idea/deploymentTargetDropDown.xml /.idea/deploymentTargetDropDown.xml
/.idea/androidTestResultsUserPreferences.xml /.idea/androidTestResultsUserPreferences.xml
/.idea/render.experimental.xml
.DS_Store .DS_Store
/build /build
/captures /captures

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RenderSettings">
<option name="quality" value="0.25" />
</component>
</project>

@ -2,7 +2,7 @@
Kotatsu is a free and open source manga reader for Android. Kotatsu is a free and open source manga reader for Android.
![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) ![Kotlin](https://img.shields.io/github/languages/top/nv95/Kotatsu) ![License](https://img.shields.io/github/license/nv95/Kotatsu) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![4pda](https://img.shields.io/badge/discuss-4pda-2982CC)](http://4pda.ru/forum/index.php?showtopic=697669) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) ![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) ![Kotlin](https://img.shields.io/github/languages/top/KotatsuApp/Kotatsu) ![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![4pda](https://img.shields.io/badge/discuss-4pda-2982CC)](http://4pda.ru/forum/index.php?showtopic=697669) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5)
### Download ### Download
@ -10,16 +10,15 @@ Kotatsu is a free and open source manga reader for Android.
alt="Get it on F-Droid" alt="Get it on F-Droid"
height="80">](https://f-droid.org/packages/org.koitharu.kotatsu) height="80">](https://f-droid.org/packages/org.koitharu.kotatsu)
Download APK from Github Releases: Download APK from GitHub Releases:
- [Latest release](https://github.com/nv95/Kotatsu/releases/latest) - [Latest release](https://github.com/KotatsuApp/Kotatsu/releases/latest)
- [Legacy build](https://github.com/nv95/Kotatsu/releases/tag/v0.4-legacy) (with Android 4.1+ support)
### Main Features ### Main Features
* Online manga catalogues * Online manga catalogues
* Search manga by name and genre * Search manga by name and genres
* Reading history * Reading history and bookmarks
* Favourites organized by user-defined categories * Favourites organized by user-defined categories
* Downloading manga and reading it offline. Third-party CBZ archives also supported * Downloading manga and reading it offline. Third-party CBZ archives also supported
* Tablet-optimized material design UI * Tablet-optimized material design UI
@ -30,12 +29,12 @@ Download APK from Github Releases:
### Screenshots ### Screenshots
| ![Screenshot_20200226-210337](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/1.png) | ![](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/2.png) | ![Screenshot_20200226-210232](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/3.png) | | ![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/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/4.png) | ![Screenshot_20200226-210151](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/5.png) | ![Screenshot_20200226-210223](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/6.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) |
| ![](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/tenInchScreenshots/1.png) | ![](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/tenInchScreenshots/2.png) | | ![](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 ### Localization
@ -43,9 +42,11 @@ Download APK from Github Releases:
<img src="https://hosted.weblate.org/widgets/kotatsu/-/287x66-white.png" alt="Translation status" /> <img src="https://hosted.weblate.org/widgets/kotatsu/-/287x66-white.png" alt="Translation status" />
</a> </a>
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 <a href="https://hosted.weblate.org/engage/kotatsu/">project page</a> 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 <a href="https://hosted.weblate.org/engage/kotatsu/">project page</a>
### License ### License
[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html) [![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html)
Kotatsu is Free Software: You can use, study share and improve it at your Kotatsu is Free Software: You can use, study share and improve it at your

@ -14,8 +14,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 32 targetSdkVersion 32
versionCode 410 versionCode 411
versionName '3.3.1' versionName '3.3.2'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -24,6 +24,10 @@ android {
arg 'room.schemaLocation', "$projectDir/schemas".toString() arg 'room.schemaLocation', "$projectDir/schemas".toString()
} }
} }
// define this values in your local.properties file
buildConfigField 'String', 'SHIKIMORI_CLIENT_ID', "\"${localProperty('shikimori.clientId')}\""
buildConfigField 'String', 'SHIKIMORI_CLIENT_SECRET', "\"${localProperty('shikimori.clientSecret')}\""
} }
buildTypes { buildTypes {
debug { debug {
@ -73,28 +77,29 @@ afterEvaluate {
} }
dependencies { dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation('com.github.nv95:kotatsu-parsers:dc0129c76c') { implementation('com.github.nv95:kotatsu-parsers:c92f89f307') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.2' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.3'
implementation 'androidx.core:core-ktx:1.8.0' implementation 'androidx.core:core-ktx:1.8.0'
implementation 'androidx.activity:activity-ktx:1.5.0-rc01' implementation 'androidx.activity:activity-ktx:1.5.0-rc01'
implementation 'androidx.fragment:fragment-ktx:1.5.0-rc01' implementation 'androidx.fragment:fragment-ktx:1.5.0-rc01'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.0-rc01' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.0-rc02'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.0-rc01' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.0-rc02'
implementation 'androidx.lifecycle:lifecycle-service:2.5.0-rc01' implementation 'androidx.lifecycle:lifecycle-service:2.5.0-rc02'
implementation 'androidx.lifecycle:lifecycle-process:2.5.0-rc01' implementation 'androidx.lifecycle:lifecycle-process:2.5.0-rc02'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.2.1' implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01' implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
implementation 'androidx.preference:preference-ktx:1.2.0' implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.work:work-runtime-ktx:2.7.1' implementation 'androidx.work:work-runtime-ktx:2.7.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha04'
implementation 'com.google.android.material:material:1.7.0-alpha02' implementation 'com.google.android.material:material:1.7.0-alpha02'
//noinspection LifecycleAnnotationProcessorWithJava8 //noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.5.0-rc01' kapt 'androidx.lifecycle:lifecycle-compiler:2.5.0-rc02'
implementation 'androidx.room:room-runtime:2.4.2' implementation 'androidx.room:room-runtime:2.4.2'
implementation 'androidx.room:room-ktx:2.4.2' implementation 'androidx.room:room-ktx:2.4.2'
@ -102,7 +107,7 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:4.10.0' implementation 'com.squareup.okhttp3:okhttp:4.10.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3' implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3'
implementation 'com.squareup.okio:okio:3.1.0' implementation 'com.squareup.okio:okio:3.2.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
@ -112,18 +117,21 @@ dependencies {
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0' implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'ch.acra:acra-mail:5.9.3'
implementation 'ch.acra:acra-dialog:5.9.3'
debugImplementation 'org.jsoup:jsoup:1.15.1' debugImplementation 'org.jsoup:jsoup:1.15.1'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.2' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.3'
androidTestImplementation 'androidx.test:runner:1.4.0' androidTestImplementation 'androidx.test:runner:1.4.0'
androidTestImplementation 'androidx.test:rules:1.4.0' androidTestImplementation 'androidx.test:rules:1.4.0'
androidTestImplementation 'androidx.test:core-ktx:1.4.0' androidTestImplementation 'androidx.test:core-ktx:1.4.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3' androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.2' androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.3'
androidTestImplementation 'io.insert-koin:koin-test:3.2.0' androidTestImplementation 'io.insert-koin:koin-test:3.2.0'
androidTestImplementation 'io.insert-koin:koin-test-junit4:3.2.0' androidTestImplementation 'io.insert-koin:koin-test-junit4:3.2.0'

@ -59,7 +59,15 @@
android:label="@string/search_manga" /> android:label="@string/search_manga" />
<activity <activity
android:name="org.koitharu.kotatsu.settings.SettingsActivity" android:name="org.koitharu.kotatsu.settings.SettingsActivity"
android:label="@string/settings" /> android:exported="true"
android:label="@string/settings">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="kotatsu" />
</intent-filter>
</activity>
<activity <activity
android:name="org.koitharu.kotatsu.browser.BrowserActivity" android:name="org.koitharu.kotatsu.browser.BrowserActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
@ -68,11 +76,6 @@
android:name="org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity" android:name="org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity
android:name="org.koitharu.kotatsu.core.ui.CrashActivity"
android:label="@string/error_occurred"
android:theme="@android:style/Theme.DeviceDefault"
android:windowSoftInputMode="stateAlwaysHidden" />
<activity <activity
android:name="org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity" android:name="org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity"
android:label="@string/favourites_categories" android:label="@string/favourites_categories"
@ -86,9 +89,6 @@
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" /> <action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name="org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity"
android:label="@string/search" />
<activity <activity
android:name="org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity" android:name="org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity"
android:label="@string/search" /> android:label="@string/search" />

@ -1,9 +1,15 @@
package org.koitharu.kotatsu package org.koitharu.kotatsu
import android.app.Application import android.app.Application
import android.content.Context
import android.os.StrictMode import android.os.StrictMode
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.fragment.app.strictmode.FragmentStrictMode import androidx.fragment.app.strictmode.FragmentStrictMode
import org.acra.ReportField
import org.acra.config.dialog
import org.acra.config.mailSender
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
@ -13,7 +19,6 @@ import org.koitharu.kotatsu.core.db.databaseModule
import org.koitharu.kotatsu.core.github.githubModule import org.koitharu.kotatsu.core.github.githubModule
import org.koitharu.kotatsu.core.network.networkModule import org.koitharu.kotatsu.core.network.networkModule
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.AppCrashHandler
import org.koitharu.kotatsu.core.ui.uiModule import org.koitharu.kotatsu.core.ui.uiModule
import org.koitharu.kotatsu.details.detailsModule import org.koitharu.kotatsu.details.detailsModule
import org.koitharu.kotatsu.favourites.favouritesModule import org.koitharu.kotatsu.favourites.favouritesModule
@ -26,6 +31,7 @@ import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.reader.readerModule import org.koitharu.kotatsu.reader.readerModule
import org.koitharu.kotatsu.remotelist.remoteListModule import org.koitharu.kotatsu.remotelist.remoteListModule
import org.koitharu.kotatsu.scrobbling.shikimori.shikimoriModule
import org.koitharu.kotatsu.search.searchModule import org.koitharu.kotatsu.search.searchModule
import org.koitharu.kotatsu.settings.settingsModule import org.koitharu.kotatsu.settings.settingsModule
import org.koitharu.kotatsu.suggestions.suggestionsModule import org.koitharu.kotatsu.suggestions.suggestionsModule
@ -41,7 +47,6 @@ class KotatsuApp : Application() {
enableStrictMode() enableStrictMode()
} }
initKoin() initKoin()
Thread.setDefaultUncaughtExceptionHandler(AppCrashHandler(applicationContext))
AppCompatDelegate.setDefaultNightMode(get<AppSettings>().theme) AppCompatDelegate.setDefaultNightMode(get<AppSettings>().theme)
registerActivityLifecycleCallbacks(get<AppProtectHelper>()) registerActivityLifecycleCallbacks(get<AppProtectHelper>())
registerActivityLifecycleCallbacks(get<ActivityRecreationHandle>()) registerActivityLifecycleCallbacks(get<ActivityRecreationHandle>())
@ -70,11 +75,43 @@ class KotatsuApp : Application() {
readerModule, readerModule,
appWidgetModule, appWidgetModule,
suggestionsModule, suggestionsModule,
shikimoriModule,
bookmarksModule, bookmarksModule,
) )
} }
} }
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
initAcra {
buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.KEY_VALUE_LIST
reportContent = listOf(
ReportField.PACKAGE_NAME,
ReportField.APP_VERSION_CODE,
ReportField.APP_VERSION_NAME,
ReportField.ANDROID_VERSION,
ReportField.PHONE_MODEL,
ReportField.CRASH_CONFIGURATION,
ReportField.STACK_TRACE,
ReportField.CUSTOM_DATA,
ReportField.SHARED_PREFERENCES,
)
dialog {
text = getString(R.string.crash_text)
title = getString(R.string.error_occurred)
positiveButtonText = getString(R.string.send)
resIcon = R.drawable.ic_alert_outline
resTheme = android.R.style.Theme_Material_Light_Dialog_Alert
}
mailSender {
mailTo = getString(R.string.email_error_report)
reportAsFile = true
reportFileName = "stacktrace.txt"
}
}
}
private fun enableStrictMode() { private fun enableStrictMode() {
StrictMode.setThreadPolicy( StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder() StrictMode.ThreadPolicy.Builder()

@ -12,7 +12,6 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.ActionBarContextView import androidx.appcompat.widget.ActionBarContextView
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityCompat
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
@ -83,8 +82,9 @@ abstract class BaseActivity<B : ViewBinding> :
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) { // TODO remove if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) { // TODO remove
ActivityCompat.recreate(this) // ActivityCompat.recreate(this)
return true throw RuntimeException("Test crash")
// return true
} }
return super.onKeyDown(keyCode, event) return super.onKeyDown(keyCode, event)
} }

@ -60,6 +60,15 @@ abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
return AppBottomSheetDialog(requireContext(), theme) return AppBottomSheetDialog(requireContext(), theme)
} }
fun addBottomSheetCallback(callback: BottomSheetBehavior.BottomSheetCallback) {
val b = behavior ?: return
b.addBottomSheetCallback(callback)
val rootView = dialog?.findViewById<View>(materialR.id.design_bottom_sheet)
if (rootView != null) {
callback.onStateChanged(rootView, b.state)
}
}
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
protected fun setExpanded(isExpanded: Boolean, isLocked: Boolean) { protected fun setExpanded(isExpanded: Boolean, isLocked: Boolean) {

@ -6,14 +6,12 @@ import androidx.annotation.CallSuper
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.SettingsHeadersFragment import org.koitharu.kotatsu.settings.SettingsHeadersFragment
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :

@ -1,89 +0,0 @@
package org.koitharu.kotatsu.base.ui.dialog
import android.content.Context
import android.content.DialogInterface
import android.text.InputFilter
import android.view.LayoutInflater
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.databinding.DialogInputBinding
class TextInputDialog private constructor(
private val delegate: AlertDialog,
) : DialogInterface by delegate {
fun show() = delegate.show()
class Builder(context: Context) {
private val binding = DialogInputBinding.inflate(LayoutInflater.from(context))
private val delegate = MaterialAlertDialogBuilder(context)
.setView(binding.root)
fun setTitle(@StringRes titleResId: Int): Builder {
delegate.setTitle(titleResId)
return this
}
fun setTitle(title: CharSequence): Builder {
delegate.setTitle(title)
return this
}
fun setHint(@StringRes hintResId: Int): Builder {
binding.inputEdit.hint = binding.root.context.getString(hintResId)
return this
}
fun setMaxLength(maxLength: Int, strict: Boolean): Builder {
with(binding.inputLayout) {
counterMaxLength = maxLength
isCounterEnabled = maxLength > 0
}
if (strict && maxLength > 0) {
binding.inputEdit.filters += InputFilter.LengthFilter(maxLength)
}
return this
}
fun setInputType(inputType: Int): Builder {
binding.inputEdit.inputType = inputType
return this
}
fun setText(text: String): Builder {
binding.inputEdit.setText(text)
binding.inputEdit.setSelection(text.length)
return this
}
fun setPositiveButton(
@StringRes textId: Int,
listener: (DialogInterface, String) -> Unit
): Builder {
delegate.setPositiveButton(textId) { dialog, _ ->
listener(dialog, binding.inputEdit.text?.toString().orEmpty())
}
return this
}
fun setNegativeButton(
@StringRes textId: Int,
listener: DialogInterface.OnClickListener? = null
): Builder {
delegate.setNegativeButton(textId, listener)
return this
}
fun setOnCancelListener(listener: DialogInterface.OnCancelListener): Builder {
delegate.setOnCancelListener(listener)
return this
}
fun create() =
TextInputDialog(delegate.create())
}
}

@ -12,7 +12,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
private val bounds = Rect() private val bounds = Rect()
private val boundsF = RectF() private val boundsF = RectF()
private val selection = HashSet<Long>() protected val selection = HashSet<Long>()
protected var hasBackground: Boolean = true protected var hasBackground: Boolean = true
protected var hasForeground: Boolean = false protected var hasForeground: Boolean = false

@ -17,19 +17,28 @@
package org.koitharu.kotatsu.base.ui.widgets package org.koitharu.kotatsu.base.ui.widgets
import android.content.Context import android.content.Context
import android.content.res.ColorStateList
import android.graphics.drawable.Drawable
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.widget.Button
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.TextView import androidx.annotation.ColorInt
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.postDelayed import androidx.core.view.postDelayed
import org.koitharu.kotatsu.R import com.google.android.material.color.MaterialColors
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.databinding.FadingSnackbarLayoutBinding
import org.koitharu.kotatsu.utils.ext.getThemeColorStateList
import com.google.android.material.R as materialR
private const val ENTER_DURATION = 300L private const val ENTER_DURATION = 300L
private const val EXIT_DURATION = 200L private const val EXIT_DURATION = 200L
private const val SHORT_DURATION = 1_500L private const val SHORT_DURATION_MS = 1_500L
private const val LONG_DURATION = 2_750L private const val LONG_DURATION_MS = 2_750L
/** /**
* A custom snackbar implementation allowing more control over placement and entry/exit animations. * A custom snackbar implementation allowing more control over placement and entry/exit animations.
* *
@ -40,16 +49,13 @@ private const val LONG_DURATION = 2_750L
class FadingSnackbar @JvmOverloads constructor( class FadingSnackbar @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
defStyleAttr: Int = 0 defStyleAttr: Int = 0,
) : FrameLayout(context, attrs, defStyleAttr) { ) : FrameLayout(context, attrs, defStyleAttr) {
private val message: TextView private val binding = FadingSnackbarLayoutBinding.inflate(LayoutInflater.from(context), this)
private val action: Button
init { init {
val view = LayoutInflater.from(context).inflate(R.layout.fading_snackbar_layout, this, true) binding.snackbarLayout.background = createThemedBackground()
message = view.findViewById(R.id.snackbar_text)
action = view.findViewById(R.id.snackbar_action)
} }
fun dismiss() { fun dismiss() {
@ -62,33 +68,66 @@ class FadingSnackbar @JvmOverloads constructor(
} }
fun show( fun show(
messageText: CharSequence? = null, messageText: CharSequence?,
@StringRes actionId: Int? = null, @StringRes actionId: Int = 0,
longDuration: Boolean = true, duration: Int = Snackbar.LENGTH_SHORT,
actionClick: () -> Unit = { dismiss() }, onActionClick: (FadingSnackbar.() -> Unit)? = null,
dismissListener: () -> Unit = { } onDismiss: (() -> Unit)? = null,
) { ) {
message.text = messageText binding.snackbarText.text = messageText
if (actionId != null) { if (actionId != 0) {
action.run { with(binding.snackbarAction) {
visibility = VISIBLE visibility = VISIBLE
text = context.getString(actionId) text = context.getString(actionId)
setOnClickListener { setOnClickListener {
actionClick() onActionClick?.invoke(this@FadingSnackbar) ?: dismiss()
} }
} }
} else { } else {
action.visibility = GONE binding.snackbarAction.visibility = GONE
} }
alpha = 0f alpha = 0f
visibility = VISIBLE visibility = VISIBLE
animate() animate()
.alpha(1f) .alpha(1f)
.duration = ENTER_DURATION .duration = ENTER_DURATION
val showDuration = ENTER_DURATION + if (longDuration) LONG_DURATION else SHORT_DURATION if (duration == Snackbar.LENGTH_INDEFINITE) {
postDelayed(showDuration) { return
}
val durationMs = ENTER_DURATION + if (duration == Snackbar.LENGTH_LONG) LONG_DURATION_MS else SHORT_DURATION_MS
postDelayed(durationMs) {
dismiss() dismiss()
dismissListener() onDismiss?.invoke()
}
}
private fun createThemedBackground(): Drawable {
val backgroundColor = MaterialColors.layer(this, materialR.attr.colorSurface, materialR.attr.colorOnSurface, 1f)
val shapeAppearanceModel = ShapeAppearanceModel.builder(
context,
materialR.style.ShapeAppearance_Material3_Corner_ExtraSmall,
0
).build()
val background = createMaterialShapeDrawableBackground(
backgroundColor,
shapeAppearanceModel,
)
val backgroundTint = context.getThemeColorStateList(materialR.attr.colorSurfaceInverse)
return if (backgroundTint != null) {
val wrappedDrawable = DrawableCompat.wrap(background)
DrawableCompat.setTintList(wrappedDrawable, backgroundTint)
wrappedDrawable
} else {
DrawableCompat.wrap(background)
} }
} }
private fun createMaterialShapeDrawableBackground(
@ColorInt backgroundColor: Int,
shapeAppearanceModel: ShapeAppearanceModel,
): MaterialShapeDrawable {
val background = MaterialShapeDrawable(shapeAppearanceModel)
background.fillColor = ColorStateList.valueOf(backgroundColor)
return background
}
} }

@ -25,4 +25,5 @@ class BookmarkEntity(
@ColumnInfo(name = "scroll") val scroll: Int, @ColumnInfo(name = "scroll") val scroll: Int,
@ColumnInfo(name = "image") val imageUrl: String, @ColumnInfo(name = "image") val imageUrl: String,
@ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "percent") val percent: Float,
) )

@ -18,6 +18,7 @@ fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
scroll = scroll, scroll = scroll,
imageUrl = imageUrl, imageUrl = imageUrl,
createdAt = Date(createdAt), createdAt = Date(createdAt),
percent = percent,
) )
fun Bookmark.toEntity() = BookmarkEntity( fun Bookmark.toEntity() = BookmarkEntity(
@ -28,4 +29,5 @@ fun Bookmark.toEntity() = BookmarkEntity(
scroll = scroll, scroll = scroll,
imageUrl = imageUrl, imageUrl = imageUrl,
createdAt = createdAt.time, createdAt = createdAt.time,
percent = percent,
) )

@ -11,6 +11,7 @@ class Bookmark(
val scroll: Int, val scroll: Int,
val imageUrl: String, val imageUrl: String,
val createdAt: Date, val createdAt: Date,
val percent: Float,
) { ) {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
@ -26,6 +27,7 @@ class Bookmark(
if (scroll != other.scroll) return false if (scroll != other.scroll) return false
if (imageUrl != other.imageUrl) return false if (imageUrl != other.imageUrl) return false
if (createdAt != other.createdAt) return false if (createdAt != other.createdAt) return false
if (percent != other.percent) return false
return true return true
} }
@ -38,6 +40,7 @@ class Bookmark(
result = 31 * result + scroll result = 31 * result + scroll
result = 31 * result + imageUrl.hashCode() result = 31 * result + imageUrl.hashCode()
result = 31 * result + createdAt.hashCode() result = 31 * result + createdAt.hashCode()
result = 31 * result + percent.hashCode()
return result return result
} }
} }

@ -111,6 +111,7 @@ class BackupRepository(private val db: MangaDatabase) {
jo.put("chapter_id", chapterId) jo.put("chapter_id", chapterId)
jo.put("page", page) jo.put("page", page)
jo.put("scroll", scroll) jo.put("scroll", scroll)
jo.put("percent", percent)
return jo return jo
} }

@ -15,11 +15,11 @@ class BackupZipOutput(val file: File) : Closeable {
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION) private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
suspend fun put(entry: BackupEntry) { suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) {
output.put(entry.name, entry.data.toString(2)) output.put(entry.name, entry.data.toString(2))
} }
suspend fun finish() { suspend fun finish() = runInterruptible(Dispatchers.IO) {
output.finish() output.finish()
} }

@ -9,10 +9,7 @@ import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.history.data.HistoryEntity import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.json.JSONIterator import org.koitharu.kotatsu.parsers.util.json.*
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.json.mapJSON
class RestoreRepository(private val db: MangaDatabase) { class RestoreRepository(private val db: MangaDatabase) {
@ -95,7 +92,8 @@ class RestoreRepository(private val db: MangaDatabase) {
updatedAt = json.getLong("updated_at"), updatedAt = json.getLong("updated_at"),
chapterId = json.getLong("chapter_id"), chapterId = json.getLong("chapter_id"),
page = json.getInt("page"), page = json.getInt("page"),
scroll = json.getDouble("scroll").toFloat() scroll = json.getDouble("scroll").toFloat(),
percent = json.getFloatOrDefault("percent", -1f),
) )
private fun parseCategory(json: JSONObject) = FavouriteCategoryEntity( private fun parseCategory(json: JSONObject) = FavouriteCategoryEntity(

@ -6,8 +6,14 @@ import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.bookmarks.data.BookmarksDao import org.koitharu.kotatsu.bookmarks.data.BookmarksDao
import org.koitharu.kotatsu.core.db.dao.* import org.koitharu.kotatsu.core.db.dao.MangaDao
import org.koitharu.kotatsu.core.db.entity.* 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.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.db.migrations.* import org.koitharu.kotatsu.core.db.migrations.*
import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
@ -15,6 +21,8 @@ import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.favourites.data.FavouritesDao import org.koitharu.kotatsu.favourites.data.FavouritesDao
import org.koitharu.kotatsu.history.data.HistoryDao import org.koitharu.kotatsu.history.data.HistoryDao
import org.koitharu.kotatsu.history.data.HistoryEntity import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.scrobbling.data.ScrobblingDao
import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity
import org.koitharu.kotatsu.suggestions.data.SuggestionDao import org.koitharu.kotatsu.suggestions.data.SuggestionDao
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
import org.koitharu.kotatsu.tracker.data.TrackEntity import org.koitharu.kotatsu.tracker.data.TrackEntity
@ -26,8 +34,9 @@ import org.koitharu.kotatsu.tracker.data.TracksDao
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class, TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
ScrobblingEntity::class,
], ],
version = 11, version = 12,
) )
abstract class MangaDatabase : RoomDatabase() { abstract class MangaDatabase : RoomDatabase() {
@ -50,6 +59,8 @@ abstract class MangaDatabase : RoomDatabase() {
abstract val suggestionDao: SuggestionDao abstract val suggestionDao: SuggestionDao
abstract val bookmarksDao: BookmarksDao abstract val bookmarksDao: BookmarksDao
abstract val scrobblingDao: ScrobblingDao
} }
fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder( fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder(
@ -67,6 +78,7 @@ fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder(
Migration8To9(), Migration8To9(),
Migration9To10(), Migration9To10(),
Migration10To11(), Migration10To11(),
Migration11To12(),
).addCallback( ).addCallback(
DatabasePrePopulateCallback(context.resources) DatabasePrePopulateCallback(context.resources)
).build() ).build()

@ -0,0 +1,27 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration11To12 : Migration(11, 12) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `scrobblings` (
`scrobbler` INTEGER NOT NULL,
`id` INTEGER NOT NULL,
`manga_id` INTEGER NOT NULL,
`target_id` INTEGER NOT NULL,
`status` TEXT,
`chapter` INTEGER NOT NULL,
`comment` TEXT,
`rating` REAL NOT NULL,
PRIMARY KEY(`scrobbler`, `id`, `manga_id`)
)
""".trimIndent()
)
database.execSQL("ALTER TABLE history ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
database.execSQL("ALTER TABLE bookmarks ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
}
}

@ -1,8 +1,6 @@
package org.koitharu.kotatsu.core.exceptions package org.koitharu.kotatsu.core.exceptions
import androidx.annotation.StringRes
import okio.IOException import okio.IOException
import org.koitharu.kotatsu.R
class CloudFlareProtectedException( class CloudFlareProtectedException(
val url: String val url: String

@ -11,4 +11,5 @@ data class MangaHistory(
val chapterId: Long, val chapterId: Long,
val page: Int, val page: Int,
val scroll: Int, val scroll: Int,
val percent: Float,
) : Parcelable ) : Parcelable

@ -9,6 +9,7 @@ object CommonHeaders {
const val ACCEPT = "Accept" const val ACCEPT = "Accept"
const val CONTENT_DISPOSITION = "Content-Disposition" const val CONTENT_DISPOSITION = "Content-Disposition"
const val COOKIE = "Cookie" const val COOKIE = "Cookie"
const val AUTHORIZATION = "Authorization"
val CACHE_CONTROL_DISABLED: CacheControl val CACHE_CONTROL_DISABLED: CacheControl
get() = CacheControl.Builder().noStore().build() get() = CacheControl.Builder().noStore().build()

@ -10,6 +10,10 @@ import androidx.collection.arraySetOf
import androidx.core.content.edit import androidx.core.content.edit
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
@ -18,12 +22,9 @@ import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.getEnumValue import org.koitharu.kotatsu.utils.ext.getEnumValue
import org.koitharu.kotatsu.utils.ext.observe
import org.koitharu.kotatsu.utils.ext.putEnumValue import org.koitharu.kotatsu.utils.ext.putEnumValue
import org.koitharu.kotatsu.utils.ext.toUriOrNull import org.koitharu.kotatsu.utils.ext.toUriOrNull
import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
class AppSettings(context: Context) { class AppSettings(context: Context) {
@ -40,7 +41,7 @@ class AppSettings(context: Context) {
get() = Collections.unmodifiableSet(remoteSources) get() = Collections.unmodifiableSet(remoteSources)
var listMode: ListMode var listMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.DETAILED_LIST) get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.GRID)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) } set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) }
var defaultSection: AppSection var defaultSection: AppSection
@ -104,10 +105,13 @@ class AppSettings(context: Context) {
val isReaderModeDetectionEnabled: Boolean val isReaderModeDetectionEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_MODE_DETECT, true) get() = prefs.getBoolean(KEY_READER_MODE_DETECT, true)
var historyGrouping: Boolean var isHistoryGroupingEnabled: Boolean
get() = prefs.getBoolean(KEY_HISTORY_GROUPING, true) get() = prefs.getBoolean(KEY_HISTORY_GROUPING, true)
set(value) = prefs.edit { putBoolean(KEY_HISTORY_GROUPING, value) } set(value) = prefs.edit { putBoolean(KEY_HISTORY_GROUPING, value) }
val isReadingIndicatorsEnabled: Boolean
get() = prefs.getBoolean(KEY_READING_INDICATORS, true)
val isHistoryExcludeNsfw: Boolean val isHistoryExcludeNsfw: Boolean
get() = prefs.getBoolean(KEY_HISTORY_EXCLUDE_NSFW, false) get() = prefs.getBoolean(KEY_HISTORY_EXCLUDE_NSFW, false)
@ -125,6 +129,10 @@ class AppSettings(context: Context) {
get() = prefs.getString(KEY_APP_PASSWORD, null) get() = prefs.getString(KEY_APP_PASSWORD, null)
set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) } set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) }
var isBiometricProtectionEnabled: Boolean
get() = prefs.getBoolean(KEY_PROTECT_APP_BIOMETRIC, true)
set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) }
var sourcesOrder: List<String> var sourcesOrder: List<String>
get() = prefs.getString(KEY_SOURCES_ORDER, null) get() = prefs.getString(KEY_SOURCES_ORDER, null)
?.split('|') ?.split('|')
@ -242,15 +250,7 @@ class AppSettings(context: Context) {
prefs.unregisterOnSharedPreferenceChangeListener(listener) prefs.unregisterOnSharedPreferenceChangeListener(listener)
} }
fun observe() = callbackFlow<String> { fun observe() = prefs.observe()
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
trySendBlocking(key)
}
prefs.registerOnSharedPreferenceChangeListener(listener)
awaitClose {
prefs.unregisterOnSharedPreferenceChangeListener(listener)
}
}
companion object { companion object {
@ -293,11 +293,13 @@ class AppSettings(context: Context) {
const val KEY_READER_MODE_DETECT = "reader_mode_detect" const val KEY_READER_MODE_DETECT = "reader_mode_detect"
const val KEY_APP_PASSWORD = "app_password" const val KEY_APP_PASSWORD = "app_password"
const val KEY_PROTECT_APP = "protect_app" const val KEY_PROTECT_APP = "protect_app"
const val KEY_PROTECT_APP_BIOMETRIC = "protect_app_bio"
const val KEY_APP_VERSION = "app_version" const val KEY_APP_VERSION = "app_version"
const val KEY_ZOOM_MODE = "zoom_mode" const val KEY_ZOOM_MODE = "zoom_mode"
const val KEY_BACKUP = "backup" const val KEY_BACKUP = "backup"
const val KEY_RESTORE = "restore" const val KEY_RESTORE = "restore"
const val KEY_HISTORY_GROUPING = "history_grouping" const val KEY_HISTORY_GROUPING = "history_grouping"
const val KEY_READING_INDICATORS = "reading_indicators"
const val KEY_REVERSE_CHAPTERS = "reverse_chapters" const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw" const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
const val KEY_PAGES_NUMBERS = "pages_numbers" const val KEY_PAGES_NUMBERS = "pages_numbers"
@ -307,6 +309,7 @@ class AppSettings(context: Context) {
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw" const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags" const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
const val KEY_SEARCH_SINGLE_SOURCE = "search_single_source" const val KEY_SEARCH_SINGLE_SOURCE = "search_single_source"
const val KEY_SHIKIMORI = "shikimori"
const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism" const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism"
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown" const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible" const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
@ -316,9 +319,6 @@ class AppSettings(context: Context) {
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_UPDATE_AUTO = "app_update_auto" const val KEY_APP_UPDATE_AUTO = "app_update_auto"
const val KEY_APP_TRANSLATION = "about_app_translation" const val KEY_APP_TRANSLATION = "about_app_translation"
const val KEY_FEEDBACK_4PDA = "about_feedback_4pda"
const val KEY_FEEDBACK_DISCORD = "about_feedback_discord"
const val KEY_FEEDBACK_GITHUB = "about_feedback_github"
private const val NETWORK_NEVER = 0 private const val NETWORK_NEVER = 0
private const val NETWORK_ALWAYS = 1 private const val NETWORK_ALWAYS = 1

@ -1,22 +0,0 @@
package org.koitharu.kotatsu.core.ui
import android.content.Context
import android.content.Intent
import android.util.Log
import kotlin.system.exitProcess
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
class AppCrashHandler(private val applicationContext: Context) : Thread.UncaughtExceptionHandler {
override fun uncaughtException(t: Thread, e: Throwable) {
val intent = CrashActivity.newIntent(applicationContext, e)
intent.flags = (Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
try {
applicationContext.startActivity(intent)
} catch (t: Throwable) {
t.printStackTraceDebug()
}
Log.e("CRASH", e.message, e)
exitProcess(1)
}
}

@ -1,83 +0,0 @@
package org.koitharu.kotatsu.core.ui
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ActivityCrashBinding
import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.utils.ShareHelper
class CrashActivity : Activity(), View.OnClickListener {
private lateinit var binding: ActivityCrashBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityCrashBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.textView.text = intent.getStringExtra(Intent.EXTRA_TEXT)
binding.buttonClose.setOnClickListener(this)
binding.buttonRestart.setOnClickListener(this)
binding.buttonReport.setOnClickListener(this)
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.opt_crash, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_share -> {
ShareHelper(this).shareText(binding.textView.text.toString())
}
else -> return super.onOptionsItemSelected(item)
}
return true
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_close -> {
finish()
}
R.id.button_restart -> {
val intent = Intent(applicationContext, MainActivity::class.java)
intent.flags = (Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
finish()
}
R.id.button_report -> {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse("https://github.com/nv95/Kotatsu/issues")
try {
startActivity(Intent.createChooser(intent, getString(R.string.report_github)))
} catch (_: ActivityNotFoundException) {
}
}
}
}
companion object {
private const val MAX_TRACE_SIZE = 131071
fun newIntent(context: Context, error: Throwable): Intent {
val crashInfo = error
.stackTraceToString()
.trimIndent()
.ellipsize(MAX_TRACE_SIZE)
val intent = Intent(context, CrashActivity::class.java)
intent.putExtra(Intent.EXTRA_TEXT, crashInfo)
return intent
}
}
}

@ -8,6 +8,6 @@ val detailsModule
get() = module { get() = module {
viewModel { intent -> viewModel { intent ->
DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get(), get()) DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get(), get(), get())
} }
} }

@ -21,9 +21,11 @@ import androidx.core.view.updatePadding
import androidx.fragment.app.commit import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.acra.ktx.sendWithAcra
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@ -37,11 +39,13 @@ import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter
import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.scrobbling.ui.selector.ScrobblingSelectorBottomSheet
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
@ -81,7 +85,7 @@ class DetailsActivity :
viewModel.onMangaRemoved.observe(this, ::onMangaRemoved) viewModel.onMangaRemoved.observe(this, ::onMangaRemoved)
viewModel.onError.observe(this, ::onError) viewModel.onError.observe(this, ::onError)
viewModel.onShowToast.observe(this) { viewModel.onShowToast.observe(this) {
binding.snackbar.show(messageText = getString(it), longDuration = false) binding.snackbar.show(messageText = getString(it))
} }
registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE)) registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE))
@ -114,6 +118,21 @@ class DetailsActivity :
Toast.makeText(this, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show() Toast.makeText(this, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show()
finishAfterTransition() finishAfterTransition()
} }
e is ParseException || e is IllegalArgumentException || e is IllegalStateException -> {
binding.snackbar.show(
messageText = e.getDisplayMessage(resources),
actionId = R.string.report,
duration = if (viewModel.manga.value?.chapters == null) {
Snackbar.LENGTH_INDEFINITE
} else {
Snackbar.LENGTH_LONG
},
onActionClick = {
e.sendWithAcra()
dismiss()
}
)
}
else -> { else -> {
binding.snackbar.show(e.getDisplayMessage(resources)) binding.snackbar.show(e.getDisplayMessage(resources))
} }
@ -151,14 +170,11 @@ class DetailsActivity :
override fun onPrepareOptionsMenu(menu: Menu): Boolean { override fun onPrepareOptionsMenu(menu: Menu): Boolean {
val manga = viewModel.manga.value val manga = viewModel.manga.value
menu.findItem(R.id.action_save).isVisible = menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != MangaSource.LOCAL
manga?.source != null && manga.source != MangaSource.LOCAL menu.findItem(R.id.action_delete).isVisible = manga?.source == MangaSource.LOCAL
menu.findItem(R.id.action_delete).isVisible = menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL
manga?.source == MangaSource.LOCAL menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(this)
menu.findItem(R.id.action_browser).isVisible = menu.findItem(R.id.action_shiki_track).isVisible = viewModel.isScrobblingAvailable
manga?.source != MangaSource.LOCAL
menu.findItem(R.id.action_shortcut).isVisible =
ShortcutManagerCompat.isRequestPinShortcutSupported(this)
return super.onPrepareOptionsMenu(menu) return super.onPrepareOptionsMenu(menu)
} }
@ -199,6 +215,12 @@ class DetailsActivity :
} }
true true
} }
R.id.action_shiki_track -> {
viewModel.manga.value?.let {
ScrobblingSelectorBottomSheet.show(supportFragmentManager, it)
}
true
}
R.id.action_shortcut -> { R.id.action_shortcut -> {
viewModel.manga.value?.let { viewModel.manga.value?.let {
lifecycleScope.launch { lifecycleScope.launch {

@ -17,6 +17,7 @@ import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.size.Scale
import coil.util.CoilUtils import coil.util.CoilUtils
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -31,7 +32,9 @@ import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.BookmarksAdapter import org.koitharu.kotatsu.bookmarks.ui.BookmarksAdapter
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingInfoBottomSheet
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.image.ui.ImageActivity import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@ -39,6 +42,7 @@ import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.FileSize
@ -67,6 +71,7 @@ class DetailsFragment :
binding.buttonRead.setOnClickListener(this) binding.buttonRead.setOnClickListener(this)
binding.buttonRead.setOnLongClickListener(this) binding.buttonRead.setOnLongClickListener(this)
binding.imageViewCover.setOnClickListener(this) binding.imageViewCover.setOnClickListener(this)
binding.scrobblingLayout.root.setOnClickListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance() binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
binding.chipsTags.onChipClickListener = this binding.chipsTags.onChipClickListener = this
viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated) viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated)
@ -74,6 +79,7 @@ class DetailsFragment :
viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged) viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged)
viewModel.readingHistory.observe(viewLifecycleOwner, ::onHistoryChanged) viewModel.readingHistory.observe(viewLifecycleOwner, ::onHistoryChanged)
viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged) viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged)
viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged)
addMenuProvider(DetailsMenuProvider()) addMenuProvider(DetailsMenuProvider())
} }
@ -176,6 +182,7 @@ class DetailsFragment :
setIconResource(R.drawable.ic_play) setIconResource(R.drawable.ic_play)
} }
} }
binding.progressView.setPercent(history?.percent ?: PROGRESS_NONE, animate = true)
} }
private fun onFavouriteChanged(isFavourite: Boolean) { private fun onFavouriteChanged(isFavourite: Boolean) {
@ -209,12 +216,39 @@ class DetailsFragment :
} }
} }
private fun onScrobblingInfoChanged(scrobbling: ScrobblingInfo?) {
with(binding.scrobblingLayout) {
root.isVisible = scrobbling != null
if (scrobbling == null) {
CoilUtils.dispose(imageViewCover)
return
}
imageViewCover.newImageRequest(scrobbling.coverUrl)
.crossfade(true)
.placeholder(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder)
.scale(Scale.FILL)
.lifecycle(viewLifecycleOwner)
.enqueueWith(coil)
textViewTitle.text = scrobbling.title
textViewTitle.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, scrobbling.scrobbler.iconResId, 0)
ratingBar.rating = scrobbling.rating * ratingBar.numStars
textViewStatus.text = scrobbling.status?.let {
resources.getStringArray(R.array.scrobbling_statuses).getOrNull(it.ordinal)
}
}
}
override fun onClick(v: View) { override fun onClick(v: View) {
val manga = viewModel.manga.value ?: return val manga = viewModel.manga.value ?: return
when (v.id) { when (v.id) {
R.id.button_favorite -> { R.id.button_favorite -> {
FavouriteCategoriesBottomSheet.show(childFragmentManager, manga) FavouriteCategoriesBottomSheet.show(childFragmentManager, manga)
} }
R.id.scrobbling_layout -> {
ScrobblingInfoBottomSheet.show(childFragmentManager)
}
R.id.button_read -> { R.id.button_read -> {
val chapterId = viewModel.readingHistory.value?.chapterId val chapterId = viewModel.readingHistory.value?.chapterId
if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) { if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {

@ -4,7 +4,6 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.asFlow import androidx.lifecycle.asFlow
import androidx.lifecycle.asLiveData import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import java.io.IOException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@ -26,10 +25,13 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import java.io.IOException
class DetailsViewModel( class DetailsViewModel(
intent: MangaIntent, intent: MangaIntent,
@ -40,6 +42,7 @@ class DetailsViewModel(
mangaDataRepository: MangaDataRepository, mangaDataRepository: MangaDataRepository,
private val bookmarksRepository: BookmarksRepository, private val bookmarksRepository: BookmarksRepository,
private val settings: AppSettings, private val settings: AppSettings,
private val scrobbler: Scrobbler,
) : BaseViewModel() { ) : BaseViewModel() {
private val delegate = MangaDetailsDelegate( private val delegate = MangaDetailsDelegate(
@ -79,6 +82,11 @@ class DetailsViewModel(
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
val onMangaRemoved = SingleLiveEvent<Manga>() val onMangaRemoved = SingleLiveEvent<Manga>()
val isScrobblingAvailable: Boolean
get() = scrobbler.isAvailable
val scrobblingInfo = scrobbler.observeScrobblingInfo(delegate.mangaId)
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null)
val branches: LiveData<List<String?>> = delegate.manga.map { val branches: LiveData<List<String?>> = delegate.manga.map {
val chapters = it?.chapters ?: return@map emptyList() val chapters = it?.chapters ?: return@map emptyList()
@ -92,9 +100,12 @@ class DetailsViewModel(
branches.indexOf(selected) branches.indexOf(selected)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
val isChaptersEmpty: LiveData<Boolean> = delegate.manga.map { m -> val isChaptersEmpty: LiveData<Boolean> = combine(
m != null && m.chapters.isNullOrEmpty() delegate.manga,
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, false) isLoading.asFlow(),
) { m, loading ->
m != null && m.chapters.isNullOrEmpty() && !loading
}.asLiveDataDistinct(viewModelScope.coroutineContext, false)
val chapters = combine( val chapters = combine(
combine( combine(
@ -185,6 +196,25 @@ class DetailsViewModel(
} }
} }
fun updateScrobbling(rating: Float, status: ScrobblingStatus?) {
launchJob(Dispatchers.Default) {
scrobbler.updateScrobblingInfo(
mangaId = delegate.mangaId,
rating = rating,
status = status,
comment = null,
)
}
}
fun unregisterScrobbling() {
launchJob(Dispatchers.Default) {
scrobbler.unregisterScrobbling(
mangaId = delegate.mangaId
)
}
}
private fun doLoad() = launchLoadingJob(Dispatchers.Default) { private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
delegate.doLoad() delegate.doLoad()
} }

@ -3,6 +3,7 @@ package org.koitharu.kotatsu.details.ui
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.acra.ACRA
import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException
@ -13,6 +14,7 @@ import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@ -20,6 +22,7 @@ import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.utils.ext.iterator import org.koitharu.kotatsu.utils.ext.iterator
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.setCurrentManga
class MangaDetailsDelegate( class MangaDetailsDelegate(
private val intent: MangaIntent, private val intent: MangaIntent,
@ -32,6 +35,7 @@ class MangaDetailsDelegate(
private val mangaData = MutableStateFlow(intent.manga) private val mangaData = MutableStateFlow(intent.manga)
val selectedBranch = MutableStateFlow<String?>(null) val selectedBranch = MutableStateFlow<String?>(null)
// Remote manga for saved and saved for remote // Remote manga for saved and saved for remote
val relatedManga = MutableStateFlow<Manga?>(null) val relatedManga = MutableStateFlow<Manga?>(null)
val manga: StateFlow<Manga?> val manga: StateFlow<Manga?>
@ -41,6 +45,7 @@ class MangaDetailsDelegate(
suspend fun doLoad() { suspend fun doLoad() {
var manga = mangaDataRepository.resolveIntent(intent) var manga = mangaDataRepository.resolveIntent(intent)
?: throw MangaNotFoundException("Cannot find manga") ?: throw MangaNotFoundException("Cannot find manga")
ACRA.setCurrentManga(manga)
mangaData.value = manga mangaData.value = manga
manga = MangaRepository(manga.source).getDetails(manga) manga = MangaRepository(manga.source).getDetails(manga)
// find default branch // find default branch

@ -0,0 +1,150 @@
package org.koitharu.kotatsu.details.ui.scrobbling
import android.app.ActivityOptions
import android.content.Intent
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.RatingBar
import android.widget.Toast
import androidx.appcompat.widget.PopupMenu
import androidx.core.net.toUri
import androidx.fragment.app.FragmentManager
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Scale
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.databinding.SheetScrobblingBinding
import org.koitharu.kotatsu.details.ui.DetailsViewModel
import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.scrobbling.ui.selector.ScrobblingSelectorBottomSheet
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class ScrobblingInfoBottomSheet :
BaseBottomSheet<SheetScrobblingBinding>(),
AdapterView.OnItemSelectedListener,
RatingBar.OnRatingBarChangeListener,
View.OnClickListener,
PopupMenu.OnMenuItemClickListener {
private val viewModel by sharedViewModel<DetailsViewModel>()
private val coil by inject<ImageLoader>(mode = LazyThreadSafetyMode.NONE)
private var menu: PopupMenu? = null
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetScrobblingBinding {
return SheetScrobblingBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged)
viewModel.onError.observe(viewLifecycleOwner) {
Toast.makeText(view.context, it.getDisplayMessage(view.resources), Toast.LENGTH_SHORT).show()
}
binding.spinnerStatus.onItemSelectedListener = this
binding.ratingBar.onRatingBarChangeListener = this
binding.buttonMenu.setOnClickListener(this)
binding.imageViewCover.setOnClickListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
menu = PopupMenu(view.context, binding.buttonMenu).apply {
inflate(R.menu.opt_scrobbling)
setOnMenuItemClickListener(this@ScrobblingInfoBottomSheet)
}
}
override fun onDestroyView() {
super.onDestroyView()
menu = null
}
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
viewModel.updateScrobbling(
rating = binding.ratingBar.rating / binding.ratingBar.numStars,
status = enumValues<ScrobblingStatus>().getOrNull(position),
)
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
override fun onRatingChanged(ratingBar: RatingBar, rating: Float, fromUser: Boolean) {
if (fromUser) {
viewModel.updateScrobbling(
rating = rating / ratingBar.numStars,
status = enumValues<ScrobblingStatus>().getOrNull(binding.spinnerStatus.selectedItemPosition),
)
}
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_menu -> menu?.show()
R.id.imageView_cover -> {
val coverUrl = viewModel.scrobblingInfo.value?.coverUrl ?: return
val options = ActivityOptions.makeScaleUpAnimation(v, 0, 0, v.width, v.height)
startActivity(ImageActivity.newIntent(v.context, coverUrl), options.toBundle())
}
}
}
private fun onScrobblingInfoChanged(scrobbling: ScrobblingInfo?) {
if (scrobbling == null) {
dismissAllowingStateLoss()
return
}
binding.textViewTitle.text = scrobbling.title
binding.ratingBar.rating = scrobbling.rating * binding.ratingBar.numStars
binding.textViewDescription.text = scrobbling.description
binding.spinnerStatus.setSelection(scrobbling.status?.ordinal ?: -1)
ImageRequest.Builder(context ?: return)
.target(binding.imageViewCover)
.data(scrobbling.coverUrl)
.crossfade(true)
.lifecycle(viewLifecycleOwner)
.placeholder(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder)
.scale(Scale.FILL)
.enqueueWith(coil)
}
companion object {
private const val TAG = "ScrobblingInfoBottomSheet"
fun show(fm: FragmentManager) = ScrobblingInfoBottomSheet().show(fm, TAG)
}
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_browser -> {
val url = viewModel.scrobblingInfo.value?.externalUrl ?: return false
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
startActivity(
Intent.createChooser(intent, getString(R.string.open_in_browser))
)
}
R.id.action_unregister -> {
viewModel.unregisterScrobbling()
dismiss()
}
R.id.action_edit -> {
val manga = viewModel.manga.value ?: return false
ScrobblingSelectorBottomSheet.show(parentFragmentManager, manga)
dismiss()
}
}
return true
}
}

@ -11,8 +11,8 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import com.google.android.material.R as materialR
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.CrashActivity
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.DownloadsActivity import org.koitharu.kotatsu.download.ui.DownloadsActivity
@ -20,7 +20,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.utils.PendingIntentCompat import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import com.google.android.material.R as materialR
class DownloadNotification(private val context: Context, startId: Int) { class DownloadNotification(private val context: Context, startId: Int) {
@ -92,14 +91,6 @@ class DownloadNotification(private val context: Context, startId: Int) {
builder.setContentText(message) builder.setContentText(message)
builder.setAutoCancel(true) builder.setAutoCancel(true)
builder.setOngoing(false) builder.setOngoing(false)
builder.setContentIntent(
PendingIntent.getActivity(
context,
state.manga.hashCode(),
CrashActivity.newIntent(context, state.error),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
)
builder.setCategory(NotificationCompat.CATEGORY_ERROR) builder.setCategory(NotificationCompat.CATEGORY_ERROR)
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
} }

@ -11,10 +11,10 @@ import org.koitharu.kotatsu.favourites.ui.list.FavouritesListViewModel
val favouritesModule val favouritesModule
get() = module { get() = module {
factory { FavouritesRepository(get(), get()) } single { FavouritesRepository(get(), get()) }
viewModel { categoryId -> viewModel { categoryId ->
FavouritesListViewModel(categoryId.get(), get(), get(), get()) FavouritesListViewModel(categoryId.get(), get(), get(), get(), get())
} }
viewModel { FavouritesCategoriesViewModel(get(), get()) } viewModel { FavouritesCategoriesViewModel(get(), get()) }
viewModel { manga -> viewModel { manga ->

@ -4,6 +4,7 @@ import android.content.Context
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import com.google.android.material.R as materialR
class CategoriesEditDelegate( class CategoriesEditDelegate(
private val context: Context, private val context: Context,
@ -11,9 +12,10 @@ class CategoriesEditDelegate(
) { ) {
fun deleteCategory(category: FavouriteCategory) { fun deleteCategory(category: FavouriteCategory) {
MaterialAlertDialogBuilder(context) MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered)
.setMessage(context.getString(R.string.category_delete_confirm, category.title)) .setMessage(context.getString(R.string.category_delete_confirm, category.title))
.setTitle(R.string.remove_category) .setTitle(R.string.remove_category)
.setIcon(R.drawable.ic_delete)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.remove) { _, _ -> .setPositiveButton(R.string.remove) { _, _ ->
callback.onDeleteCategory(category) callback.onDeleteCategory(category)

@ -3,8 +3,6 @@ package org.koitharu.kotatsu.favourites.ui.categories.edit
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.AdapterView import android.widget.AdapterView
@ -24,7 +22,8 @@ import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class FavouritesCategoryEditActivity : BaseActivity<ActivityCategoryEditBinding>(), AdapterView.OnItemClickListener { class FavouritesCategoryEditActivity : BaseActivity<ActivityCategoryEditBinding>(), AdapterView.OnItemClickListener,
View.OnClickListener {
private val viewModel by viewModel<FavouritesCategoryEditViewModel> { private val viewModel by viewModel<FavouritesCategoryEditViewModel> {
parametersOf(intent.getLongExtra(EXTRA_ID, NO_ID)) parametersOf(intent.getLongExtra(EXTRA_ID, NO_ID))
@ -39,6 +38,7 @@ class FavouritesCategoryEditActivity : BaseActivity<ActivityCategoryEditBinding>
setHomeAsUpIndicator(com.google.android.material.R.drawable.abc_ic_clear_material) setHomeAsUpIndicator(com.google.android.material.R.drawable.abc_ic_clear_material)
} }
initSortSpinner() initSortSpinner()
binding.buttonDone.setOnClickListener(this)
viewModel.onSaved.observe(this) { finishAfterTransition() } viewModel.onSaved.observe(this) { finishAfterTransition() }
viewModel.category.observe(this, ::onCategoryChanged) viewModel.category.observe(this, ::onCategoryChanged)
@ -62,22 +62,14 @@ class FavouritesCategoryEditActivity : BaseActivity<ActivityCategoryEditBinding>
} }
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onClick(v: View) {
menuInflater.inflate(R.menu.opt_config, menu) when (v.id) {
menu.findItem(R.id.action_done)?.setTitle(R.string.save) R.id.button_done -> viewModel.save(
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.action_done -> {
viewModel.save(
title = binding.editName.text?.toString().orEmpty(), title = binding.editName.text?.toString().orEmpty(),
sortOrder = getSelectedSortOrder(), sortOrder = getSelectedSortOrder(),
isTrackerEnabled = binding.switchTracker.isChecked, isTrackerEnabled = binding.switchTracker.isChecked,
) )
true
} }
else -> super.onOptionsItemSelected(item)
} }
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {

@ -2,11 +2,9 @@ package org.koitharu.kotatsu.favourites.ui.categories.select
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@ -28,7 +26,7 @@ class FavouriteCategoriesBottomSheet :
BaseBottomSheet<DialogFavoriteCategoriesBinding>(), BaseBottomSheet<DialogFavoriteCategoriesBinding>(),
OnListItemClickListener<MangaCategoryItem>, OnListItemClickListener<MangaCategoryItem>,
CategoriesEditDelegate.CategoriesEditCallback, CategoriesEditDelegate.CategoriesEditCallback,
Toolbar.OnMenuItemClickListener, View.OnClickListener { View.OnClickListener {
private val viewModel by viewModel<MangaCategoriesViewModel> { private val viewModel by viewModel<MangaCategoriesViewModel> {
parametersOf(requireNotNull(arguments?.getParcelableArrayList<ParcelableManga>(KEY_MANGA_LIST)).map { it.manga }) parametersOf(requireNotNull(arguments?.getParcelableArrayList<ParcelableManga>(KEY_MANGA_LIST)).map { it.manga })
@ -45,7 +43,7 @@ class FavouriteCategoriesBottomSheet :
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
adapter = MangaCategoriesAdapter(this) adapter = MangaCategoriesAdapter(this)
binding.recyclerViewCategories.adapter = adapter binding.recyclerViewCategories.adapter = adapter
binding.toolbar.setOnMenuItemClickListener(this) binding.buttonDone.setOnClickListener(this)
binding.itemCreate.setOnClickListener(this) binding.itemCreate.setOnClickListener(this)
viewModel.content.observe(viewLifecycleOwner, this::onContentChanged) viewModel.content.observe(viewLifecycleOwner, this::onContentChanged)
@ -57,19 +55,10 @@ class FavouriteCategoriesBottomSheet :
super.onDestroyView() super.onDestroyView()
} }
override fun onMenuItemClick(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_done -> {
dismiss()
true
}
else -> false
}
}
override fun onClick(v: View) { override fun onClick(v: View) {
when (v.id) { when (v.id) {
R.id.item_create -> startActivity(FavouritesCategoryEditActivity.newIntent(requireContext())) R.id.item_create -> startActivity(FavouritesCategoryEditActivity.newIntent(requireContext()))
R.id.button_done -> dismiss()
} }
} }

@ -11,7 +11,9 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
import org.koitharu.kotatsu.list.domain.CountersProvider import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
@ -25,8 +27,9 @@ class FavouritesListViewModel(
private val categoryId: Long, private val categoryId: Long,
private val repository: FavouritesRepository, private val repository: FavouritesRepository,
private val trackingRepository: TrackingRepository, private val trackingRepository: TrackingRepository,
settings: AppSettings, private val historyRepository: HistoryRepository,
) : MangaListViewModel(settings), CountersProvider { private val settings: AppSettings,
) : MangaListViewModel(settings), ListExtraProvider {
var sortOrder: LiveData<SortOrder?> = if (categoryId == NO_ID) { var sortOrder: LiveData<SortOrder?> = if (categoryId == NO_ID) {
MutableLiveData(null) MutableLiveData(null)
@ -47,7 +50,7 @@ class FavouritesListViewModel(
when { when {
list.isEmpty() -> listOf( list.isEmpty() -> listOf(
EmptyState( EmptyState(
icon = R.drawable.ic_heart_outline, icon = R.drawable.ic_empty_favourites,
textPrimary = R.string.text_empty_holder_primary, textPrimary = R.string.text_empty_holder_primary,
textSecondary = if (categoryId == NO_ID) { textSecondary = if (categoryId == NO_ID) {
R.string.you_have_not_favourites_yet R.string.you_have_not_favourites_yet
@ -92,4 +95,12 @@ class FavouritesListViewModel(
override suspend fun getCounter(mangaId: Long): Int { override suspend fun getCounter(mangaId: Long): Int {
return trackingRepository.getNewChaptersCount(mangaId) return trackingRepository.getNewChaptersCount(mangaId)
} }
override suspend fun getProgress(mangaId: Long): Float {
return if (settings.isReadingIndicatorsEnabled) {
historyRepository.getProgress(mangaId)
} else {
PROGRESS_NONE
}
}
} }

@ -8,6 +8,7 @@ import org.koitharu.kotatsu.history.ui.HistoryListViewModel
val historyModule val historyModule
get() = module { get() = module {
factory { HistoryRepository(get(), get(), get()) } single { HistoryRepository(get(), get(), get(), getAll()) }
viewModel { HistoryListViewModel(get(), get(), get(), get()) } viewModel { HistoryListViewModel(get(), get(), get(), get()) }
} }

@ -1,12 +1,13 @@
package org.koitharu.kotatsu.history.data package org.koitharu.kotatsu.history.data
import java.util.*
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import java.util.*
fun HistoryEntity.toMangaHistory() = MangaHistory( fun HistoryEntity.toMangaHistory() = MangaHistory(
createdAt = Date(createdAt), createdAt = Date(createdAt),
updatedAt = Date(updatedAt), updatedAt = Date(updatedAt),
chapterId = chapterId, chapterId = chapterId,
page = page, page = page,
scroll = scroll.toInt() scroll = scroll.toInt(),
percent = percent,
) )

@ -45,26 +45,36 @@ abstract class HistoryDao {
@Query("SELECT COUNT(*) FROM history") @Query("SELECT COUNT(*) FROM history")
abstract fun observeCount(): Flow<Int> abstract fun observeCount(): Flow<Int>
@Query("SELECT percent FROM history WHERE manga_id = :id")
abstract fun findProgress(id: Long): Float?
@Query("DELETE FROM history") @Query("DELETE FROM history")
abstract suspend fun clear() abstract suspend fun clear()
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(entity: HistoryEntity): Long abstract suspend fun insert(entity: HistoryEntity): Long
@Query("UPDATE history SET page = :page, chapter_id = :chapterId, scroll = :scroll, updated_at = :updatedAt WHERE manga_id = :mangaId") @Query("UPDATE history SET page = :page, chapter_id = :chapterId, scroll = :scroll, percent = :percent, updated_at = :updatedAt WHERE manga_id = :mangaId")
abstract suspend fun update( abstract suspend fun update(
mangaId: Long, mangaId: Long,
page: Int, page: Int,
chapterId: Long, chapterId: Long,
scroll: Float, scroll: Float,
updatedAt: Long percent: Float,
updatedAt: Long,
): Int ): Int
@Query("DELETE FROM history WHERE manga_id = :mangaId") @Query("DELETE FROM history WHERE manga_id = :mangaId")
abstract suspend fun delete(mangaId: Long) abstract suspend fun delete(mangaId: Long)
suspend fun update(entity: HistoryEntity) = suspend fun update(entity: HistoryEntity) = update(
update(entity.mangaId, entity.page, entity.chapterId, entity.scroll, entity.updatedAt) mangaId = entity.mangaId,
page = entity.page,
chapterId = entity.chapterId,
scroll = entity.scroll,
percent = entity.percent,
updatedAt = entity.updatedAt
)
@Transaction @Transaction
open suspend fun upsert(entity: HistoryEntity): Boolean { open suspend fun upsert(entity: HistoryEntity): Boolean {

@ -13,16 +13,17 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
entity = MangaEntity::class, entity = MangaEntity::class,
parentColumns = ["manga_id"], parentColumns = ["manga_id"],
childColumns = ["manga_id"], childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE onDelete = ForeignKey.CASCADE,
) )
] ]
) )
class HistoryEntity( class HistoryEntity(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long, @ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(), @ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "updated_at") val updatedAt: Long, @ColumnInfo(name = "updated_at") val updatedAt: Long,
@ColumnInfo(name = "chapter_id") val chapterId: Long, @ColumnInfo(name = "chapter_id") val chapterId: Long,
@ColumnInfo(name = "page") val page: Int, @ColumnInfo(name = "page") val page: Int,
@ColumnInfo(name = "scroll") val scroll: Float, @ColumnInfo(name = "scroll") val scroll: Float,
@ColumnInfo(name = "percent") val percent: Float,
) )

@ -13,13 +13,18 @@ import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.history.data.toMangaHistory import org.koitharu.kotatsu.history.data.toMangaHistory
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.domain.tryScrobble
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.mapItems import org.koitharu.kotatsu.utils.ext.mapItems
const val PROGRESS_NONE = -1f
class HistoryRepository( class HistoryRepository(
private val db: MangaDatabase, private val db: MangaDatabase,
private val trackingRepository: TrackingRepository, private val trackingRepository: TrackingRepository,
private val settings: AppSettings, private val settings: AppSettings,
private val scrobblers: List<Scrobbler>,
) { ) {
suspend fun getList(offset: Int, limit: Int = 20): List<Manga> { suspend fun getList(offset: Int, limit: Int = 20): List<Manga> {
@ -59,7 +64,7 @@ class HistoryRepository(
.distinctUntilChanged() .distinctUntilChanged()
} }
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) { suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int, percent: Float) {
if (manga.isNsfw && settings.isHistoryExcludeNsfw) { if (manga.isNsfw && settings.isHistoryExcludeNsfw) {
return return
} }
@ -75,9 +80,14 @@ class HistoryRepository(
chapterId = chapterId, chapterId = chapterId,
page = page, page = page,
scroll = scroll.toFloat(), // we migrate to int, but decide to not update database scroll = scroll.toFloat(), // we migrate to int, but decide to not update database
percent = percent,
) )
) )
trackingRepository.syncWithHistory(manga, chapterId) trackingRepository.syncWithHistory(manga, chapterId)
val chapter = manga.chapters?.find { x -> x.id == chapterId }
if (chapter != null) {
scrobblers.forEach { it.tryScrobble(manga.id, chapter) }
}
} }
} }
@ -85,6 +95,10 @@ class HistoryRepository(
return db.historyDao.find(manga.id)?.toMangaHistory() return db.historyDao.find(manga.id)?.toMangaHistory()
} }
suspend fun getProgress(mangaId: Long): Float {
return db.historyDao.findProgress(mangaId) ?: PROGRESS_NONE
}
suspend fun clear() { suspend fun clear() {
db.historyDao.clear() db.historyDao.clear()
} }

@ -7,6 +7,7 @@ import android.view.MenuItem
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import com.google.android.material.R as materialR
class HistoryListMenuProvider( class HistoryListMenuProvider(
private val context: Context, private val context: Context,
@ -19,9 +20,10 @@ class HistoryListMenuProvider(
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_clear_history -> { R.id.action_clear_history -> {
MaterialAlertDialogBuilder(context) MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered)
.setTitle(R.string.clear_history) .setTitle(R.string.clear_history)
.setMessage(R.string.text_clear_history_prompt) .setMessage(R.string.text_clear_history_prompt)
.setIcon(R.drawable.ic_delete)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ -> .setPositiveButton(R.string.clear) { _, _ ->
viewModel.clearHistory() viewModel.clearHistory()

@ -2,8 +2,6 @@ package org.koitharu.kotatsu.history.ui
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import java.util.*
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -19,6 +17,7 @@ import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.DateTimeAgo import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.MangaWithHistory import org.koitharu.kotatsu.history.domain.MangaWithHistory
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
@ -26,6 +25,8 @@ import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.daysDiff import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.onFirst import org.koitharu.kotatsu.utils.ext.onFirst
import java.util.*
import java.util.concurrent.TimeUnit
class HistoryListViewModel( class HistoryListViewModel(
private val repository: HistoryRepository, private val repository: HistoryRepository,
@ -37,7 +38,7 @@ class HistoryListViewModel(
val isGroupingEnabled = MutableLiveData<Boolean>() val isGroupingEnabled = MutableLiveData<Boolean>()
val onItemsRemoved = SingleLiveEvent<ReversibleHandle>() val onItemsRemoved = SingleLiveEvent<ReversibleHandle>()
private val historyGrouping = settings.observeAsFlow(AppSettings.KEY_HISTORY_GROUPING) { historyGrouping } private val historyGrouping = settings.observeAsFlow(AppSettings.KEY_HISTORY_GROUPING) { isHistoryGroupingEnabled }
.onEach { isGroupingEnabled.postValue(it) } .onEach { isGroupingEnabled.postValue(it) }
override val content = combine( override val content = combine(
@ -48,7 +49,7 @@ class HistoryListViewModel(
when { when {
list.isEmpty() -> listOf( list.isEmpty() -> listOf(
EmptyState( EmptyState(
icon = R.drawable.ic_history, icon = R.drawable.ic_empty_history,
textPrimary = R.string.text_history_holder_primary, textPrimary = R.string.text_history_holder_primary,
textSecondary = R.string.text_history_holder_secondary, textSecondary = R.string.text_history_holder_secondary,
actionStringRes = 0, actionStringRes = 0,
@ -89,7 +90,7 @@ class HistoryListViewModel(
} }
fun setGrouping(isGroupingEnabled: Boolean) { fun setGrouping(isGroupingEnabled: Boolean) {
settings.historyGrouping = isGroupingEnabled settings.isHistoryGroupingEnabled = isGroupingEnabled
} }
private suspend fun mapList( private suspend fun mapList(
@ -98,6 +99,7 @@ class HistoryListViewModel(
mode: ListMode mode: ListMode
): List<ListModel> { ): List<ListModel> {
val result = ArrayList<ListModel>(if (grouped) (list.size * 1.4).toInt() else list.size + 1) val result = ArrayList<ListModel>(if (grouped) (list.size * 1.4).toInt() else list.size + 1)
val showPercent = settings.isReadingIndicatorsEnabled
var prevDate: DateTimeAgo? = null var prevDate: DateTimeAgo? = null
if (!grouped) { if (!grouped) {
result += ListHeader(null, R.string.history, null) result += ListHeader(null, R.string.history, null)
@ -111,10 +113,11 @@ class HistoryListViewModel(
prevDate = date prevDate = date
} }
val counter = trackingRepository.getNewChaptersCount(manga.id) val counter = trackingRepository.getNewChaptersCount(manga.id)
val percent = if (showPercent) history.percent else PROGRESS_NONE
result += when (mode) { result += when (mode) {
ListMode.LIST -> manga.toListModel(counter) ListMode.LIST -> manga.toListModel(counter, percent)
ListMode.DETAILED_LIST -> manga.toListDetailedModel(counter) ListMode.DETAILED_LIST -> manga.toListDetailedModel(counter, percent)
ListMode.GRID -> manga.toGridModel(counter) ListMode.GRID -> manga.toGridModel(counter, percent)
} }
} }
return result return result

@ -0,0 +1,151 @@
package org.koitharu.kotatsu.history.ui.util
import android.content.Context
import android.graphics.*
import android.graphics.drawable.Drawable
import androidx.annotation.StyleRes
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.graphics.ColorUtils
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import kotlin.math.roundToInt
class ReadingProgressDrawable(
context: Context,
@StyleRes styleResId: Int,
) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val checkDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_check)
private val lineColor: Int
private val outlineColor: Int
private val backgroundColor: Int
private val textColor: Int
private val textPattern = context.getString(R.string.percent_string_pattern)
private val textBounds = Rect()
private val tempRect = Rect()
private val hasBackground: Boolean
private val hasOutline: Boolean
private val hasText: Boolean
private val desiredHeight: Int
private val desiredWidth: Int
private val autoFitTextSize: Boolean
var progress: Float = PROGRESS_NONE
set(value) {
field = value
text = textPattern.format((value * 100f).toInt().toString())
paint.getTextBounds(text, 0, text.length, textBounds)
invalidateSelf()
}
private var text = ""
init {
val ta = context.obtainStyledAttributes(styleResId, R.styleable.ProgressDrawable)
desiredHeight = ta.getDimensionPixelSize(R.styleable.ProgressDrawable_android_height, -1)
desiredWidth = ta.getDimensionPixelSize(R.styleable.ProgressDrawable_android_width, -1)
autoFitTextSize = ta.getBoolean(R.styleable.ProgressDrawable_autoFitTextSize, false)
lineColor = ta.getColor(R.styleable.ProgressDrawable_android_strokeColor, Color.BLACK)
outlineColor = ta.getColor(R.styleable.ProgressDrawable_outlineColor, Color.TRANSPARENT)
backgroundColor = ColorUtils.setAlphaComponent(
ta.getColor(R.styleable.ProgressDrawable_android_fillColor, Color.TRANSPARENT),
(255 * ta.getFloat(R.styleable.ProgressDrawable_android_fillAlpha, 0f)).toInt(),
)
textColor = ta.getColor(R.styleable.ProgressDrawable_android_textColor, lineColor)
paint.strokeCap = Paint.Cap.ROUND
paint.textAlign = Paint.Align.CENTER
paint.textSize = ta.getDimension(R.styleable.ProgressDrawable_android_textSize, paint.textSize)
paint.strokeWidth = ta.getDimension(R.styleable.ProgressDrawable_strokeWidth, 1f)
ta.recycle()
hasBackground = Color.alpha(backgroundColor) != 0
hasOutline = Color.alpha(outlineColor) != 0
hasText = Color.alpha(textColor) != 0 && paint.textSize > 0
checkDrawable?.setTint(textColor)
}
override fun onBoundsChange(bounds: Rect) {
super.onBoundsChange(bounds)
if (autoFitTextSize) {
val innerWidth = bounds.width() - (paint.strokeWidth * 2f)
paint.textSize = getTextSizeForWidth(innerWidth, "100%")
paint.getTextBounds(text, 0, text.length, textBounds)
invalidateSelf()
}
}
override fun draw(canvas: Canvas) {
if (progress < 0f) {
return
}
val cx = bounds.exactCenterX()
val cy = bounds.exactCenterY()
val radius = minOf(bounds.width(), bounds.height()) / 2f
if (hasBackground) {
paint.style = Paint.Style.FILL
paint.color = backgroundColor
canvas.drawCircle(cx, cy, radius, paint)
}
val innerRadius = radius - paint.strokeWidth / 2f
paint.style = Paint.Style.STROKE
if (hasOutline) {
paint.color = outlineColor
canvas.drawCircle(cx, cy, innerRadius, paint)
}
paint.color = lineColor
canvas.drawArc(
cx - innerRadius,
cy - innerRadius,
cx + innerRadius,
cy + innerRadius,
-90f,
360f * progress,
false,
paint,
)
if (hasText) {
if (checkDrawable != null && progress >= 1f - Math.ulp(progress)) {
tempRect.set(bounds)
tempRect *= 0.6
checkDrawable.bounds = tempRect
checkDrawable.draw(canvas)
} else {
paint.style = Paint.Style.FILL
paint.color = textColor
val ty = bounds.height() / 2f + textBounds.height() / 2f - textBounds.bottom
canvas.drawText(text, cx, ty, paint)
}
}
}
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.TRANSLUCENT
override fun getIntrinsicHeight() = desiredHeight
override fun getIntrinsicWidth() = desiredWidth
private fun getTextSizeForWidth(width: Float, text: String): Float {
val testTextSize = 48f
paint.textSize = testTextSize
paint.getTextBounds(text, 0, text.length, tempRect)
return testTextSize * width / tempRect.width()
}
private operator fun Rect.timesAssign(factor: Double) {
val newWidth = (width() * factor).roundToInt()
val newHeight = (height() * factor).roundToInt()
inset(
(width() - newWidth) / 2,
(height() - newHeight) / 2,
)
}
}

@ -0,0 +1,114 @@
package org.koitharu.kotatsu.history.ui.util
import android.animation.Animator
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Outline
import android.util.AttributeSet
import android.view.View
import android.view.ViewOutlineProvider
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.annotation.AttrRes
import androidx.annotation.StyleRes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
class ReadingProgressView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr), ValueAnimator.AnimatorUpdateListener, Animator.AnimatorListener {
private var percentAnimator: ValueAnimator? = null
private val animationDuration = context.resources.getInteger(android.R.integer.config_shortAnimTime).toLong()
@StyleRes
private val drawableStyle: Int
var percent: Float
get() = peekProgressDrawable()?.progress ?: PROGRESS_NONE
set(value) {
cancelAnimation()
getProgressDrawable().progress = value
}
init {
val ta = context.obtainStyledAttributes(attrs, R.styleable.ReadingProgressView, defStyleAttr, 0)
drawableStyle = ta.getResourceId(R.styleable.ReadingProgressView_progressStyle, R.style.ProgressDrawable)
ta.recycle()
outlineProvider = OutlineProvider()
if (isInEditMode) {
percent = 0.27f
}
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
percentAnimator?.run {
if (isRunning) end()
}
percentAnimator = null
}
override fun onAnimationUpdate(animation: ValueAnimator) {
val p = animation.animatedValue as Float
getProgressDrawable().progress = p
}
override fun onAnimationStart(animation: Animator?) = Unit
override fun onAnimationEnd(animation: Animator?) {
if (percentAnimator === animation) {
percentAnimator = null
}
}
override fun onAnimationCancel(animation: Animator?) = Unit
override fun onAnimationRepeat(animation: Animator?) = Unit
fun setPercent(value: Float, animate: Boolean) {
val currentDrawable = peekProgressDrawable()
if (!animate || currentDrawable == null || value == PROGRESS_NONE) {
percent = value
return
}
percentAnimator?.cancel()
percentAnimator = ValueAnimator.ofFloat(
currentDrawable.progress.coerceAtLeast(0f),
value
).apply {
duration = animationDuration
interpolator = AccelerateDecelerateInterpolator()
addUpdateListener(this@ReadingProgressView)
addListener(this@ReadingProgressView)
start()
}
}
private fun cancelAnimation() {
percentAnimator?.cancel()
percentAnimator = null
}
private fun peekProgressDrawable(): ReadingProgressDrawable? {
return background as? ReadingProgressDrawable
}
private fun getProgressDrawable(): ReadingProgressDrawable {
var d = peekProgressDrawable()
if (d != null) {
return d
}
d = ReadingProgressDrawable(context, drawableStyle)
background = d
return d
}
private class OutlineProvider : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setOval(0, 0, view.width, view.height)
}
}
}

@ -1,6 +1,8 @@
package org.koitharu.kotatsu.list.domain package org.koitharu.kotatsu.list.domain
fun interface CountersProvider { interface ListExtraProvider {
suspend fun getCounter(mangaId: Long): Int suspend fun getCounter(mangaId: Long): Int
suspend fun getProgress(mangaId: Long): Float
} }

@ -9,7 +9,6 @@ import androidx.collection.ArraySet
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isNotEmpty import androidx.core.view.isNotEmpty
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar

@ -18,17 +18,17 @@ import org.koitharu.kotatsu.utils.ext.getItem
import org.koitharu.kotatsu.utils.ext.getThemeColor import org.koitharu.kotatsu.utils.ext.getThemeColor
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() { open class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG) protected val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle) protected val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle)
private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer) protected val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer)
private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED) protected val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
private val fillColor = ColorUtils.setAlphaComponent( protected val fillColor = ColorUtils.setAlphaComponent(
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f), ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
0x74 0x74
) )
private val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner) protected val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner)
init { init {
hasBackground = false hasBackground = false

@ -11,6 +11,7 @@ import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemMangaGridBinding import org.koitharu.kotatsu.databinding.ItemMangaGridBinding
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaGridModel import org.koitharu.kotatsu.list.ui.model.MangaGridModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@ -43,8 +44,9 @@ fun mangaGridItemAD(
} }
} }
bind { bind { payloads ->
binding.textViewTitle.text = item.title binding.textViewTitle.text = item.title
binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads)
imageRequest?.dispose() imageRequest?.dispose()
imageRequest = binding.imageViewCover.newImageRequest(item.coverUrl) imageRequest = binding.imageViewCover.newImageRequest(item.coverUrl)
.referer(item.manga.publicUrl) .referer(item.manga.publicUrl)
@ -60,6 +62,7 @@ fun mangaGridItemAD(
onViewRecycled { onViewRecycled {
itemView.clearBadge(badge) itemView.clearBadge(badge)
binding.progressView.percent = PROGRESS_NONE
badge = null badge = null
imageRequest?.dispose() imageRequest?.dispose()
imageRequest = null imageRequest = null

@ -54,9 +54,14 @@ class MangaListAdapter(
override fun getChangePayload(oldItem: ListModel, newItem: ListModel): Any? { override fun getChangePayload(oldItem: ListModel, newItem: ListModel): Any? {
return when (newItem) { return when (newItem) {
is MangaListModel, is MangaItemModel -> {
is MangaGridModel, oldItem as MangaItemModel
is MangaListDetailedModel, if (oldItem.progress != newItem.progress) {
PAYLOAD_PROGRESS
} else {
Unit
}
}
is CurrentFilterModel -> Unit is CurrentFilterModel -> Unit
else -> super.getChangePayload(oldItem, newItem) else -> super.getChangePayload(oldItem, newItem)
} }
@ -77,5 +82,7 @@ class MangaListAdapter(
const val ITEM_TYPE_HEADER = 9 const val ITEM_TYPE_HEADER = 9
const val ITEM_TYPE_FILTER = 10 const val ITEM_TYPE_FILTER = 10
const val ITEM_TYPE_HEADER_FILTER = 11 const val ITEM_TYPE_HEADER_FILTER = 11
val PAYLOAD_PROGRESS = Any()
} }
} }

@ -10,6 +10,7 @@ import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@ -36,10 +37,11 @@ fun mangaListDetailedItemAD(
clickListener.onItemLongClick(item.manga, it) clickListener.onItemLongClick(item.manga, it)
} }
bind { bind { payloads ->
imageRequest?.dispose() imageRequest?.dispose()
binding.textViewTitle.text = item.title binding.textViewTitle.text = item.title
binding.textViewSubtitle.textAndVisible = item.subtitle binding.textViewSubtitle.textAndVisible = item.subtitle
binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads)
imageRequest = binding.imageViewCover.newImageRequest(item.coverUrl) imageRequest = binding.imageViewCover.newImageRequest(item.coverUrl)
.referer(item.manga.publicUrl) .referer(item.manga.publicUrl)
.placeholder(R.drawable.ic_placeholder) .placeholder(R.drawable.ic_placeholder)
@ -56,6 +58,7 @@ fun mangaListDetailedItemAD(
onViewRecycled { onViewRecycled {
itemView.clearBadge(badge) itemView.clearBadge(badge)
binding.progressView.percent = PROGRESS_NONE
badge = null badge = null
imageRequest?.dispose() imageRequest?.dispose()
imageRequest = null imageRequest = null

@ -4,21 +4,23 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.list.domain.CountersProvider import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.ifZero import org.koitharu.kotatsu.utils.ext.ifZero
fun Manga.toListModel(counter: Int) = MangaListModel( fun Manga.toListModel(counter: Int, progress: Float) = MangaListModel(
id = id, id = id,
title = title, title = title,
subtitle = tags.joinToString(", ") { it.title }, subtitle = tags.joinToString(", ") { it.title },
coverUrl = coverUrl, coverUrl = coverUrl,
manga = this, manga = this,
counter = counter, counter = counter,
progress = progress,
) )
fun Manga.toListDetailedModel(counter: Int) = MangaListDetailedModel( fun Manga.toListDetailedModel(counter: Int, progress: Float) = MangaListDetailedModel(
id = id, id = id,
title = title, title = title,
subtitle = altTitle, subtitle = altTitle,
@ -27,50 +29,48 @@ fun Manga.toListDetailedModel(counter: Int) = MangaListDetailedModel(
coverUrl = coverUrl, coverUrl = coverUrl,
manga = this, manga = this,
counter = counter, counter = counter,
progress = progress,
) )
fun Manga.toGridModel(counter: Int) = MangaGridModel( fun Manga.toGridModel(counter: Int, progress: Float) = MangaGridModel(
id = id, id = id,
title = title, title = title,
coverUrl = coverUrl, coverUrl = coverUrl,
manga = this, manga = this,
counter = counter, counter = counter,
progress = progress,
) )
suspend fun List<Manga>.toUi( suspend fun List<Manga>.toUi(
mode: ListMode, mode: ListMode,
countersProvider: CountersProvider, extraProvider: ListExtraProvider,
): List<MangaItemModel> = when (mode) { ): List<MangaItemModel> = when (mode) {
ListMode.LIST -> map { it.toListModel(countersProvider.getCounter(it.id)) } ListMode.LIST -> map {
ListMode.DETAILED_LIST -> map { it.toListDetailedModel(countersProvider.getCounter(it.id)) } it.toListModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id))
ListMode.GRID -> map { it.toGridModel(countersProvider.getCounter(it.id)) } }
} ListMode.DETAILED_LIST -> map {
it.toListDetailedModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id))
suspend fun <C : MutableCollection<ListModel>> List<Manga>.toUi( }
destination: C, ListMode.GRID -> map {
mode: ListMode, it.toGridModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id))
countersProvider: CountersProvider, }
): C = when (mode) {
ListMode.LIST -> mapTo(destination) { it.toListModel(countersProvider.getCounter(it.id)) }
ListMode.DETAILED_LIST -> mapTo(destination) { it.toListDetailedModel(countersProvider.getCounter(it.id)) }
ListMode.GRID -> mapTo(destination) { it.toGridModel(countersProvider.getCounter(it.id)) }
} }
fun List<Manga>.toUi( fun List<Manga>.toUi(
mode: ListMode, mode: ListMode,
): List<MangaItemModel> = when (mode) { ): List<MangaItemModel> = when (mode) {
ListMode.LIST -> map { it.toListModel(0) } ListMode.LIST -> map { it.toListModel(0, PROGRESS_NONE) }
ListMode.DETAILED_LIST -> map { it.toListDetailedModel(0) } ListMode.DETAILED_LIST -> map { it.toListDetailedModel(0, PROGRESS_NONE) }
ListMode.GRID -> map { it.toGridModel(0) } ListMode.GRID -> map { it.toGridModel(0, PROGRESS_NONE) }
} }
fun <C : MutableCollection<ListModel>> List<Manga>.toUi( fun <C : MutableCollection<ListModel>> List<Manga>.toUi(
destination: C, destination: C,
mode: ListMode, mode: ListMode,
): C = when (mode) { ): C = when (mode) {
ListMode.LIST -> mapTo(destination) { it.toListModel(0) } ListMode.LIST -> mapTo(destination) { it.toListModel(0, PROGRESS_NONE) }
ListMode.DETAILED_LIST -> mapTo(destination) { it.toListDetailedModel(0) } ListMode.DETAILED_LIST -> mapTo(destination) { it.toListDetailedModel(0, PROGRESS_NONE) }
ListMode.GRID -> mapTo(destination) { it.toGridModel(0) } ListMode.GRID -> mapTo(destination) { it.toGridModel(0, PROGRESS_NONE) }
} }
fun Throwable.toErrorState(canRetry: Boolean = true) = ErrorState( fun Throwable.toErrorState(canRetry: Boolean = true) = ErrorState(

@ -4,8 +4,9 @@ import org.koitharu.kotatsu.parsers.model.Manga
data class MangaGridModel( data class MangaGridModel(
override val id: Long, override val id: Long,
val title: String, override val title: String,
val coverUrl: String, override val coverUrl: String,
override val manga: Manga, override val manga: Manga,
val counter: Int, override val counter: Int,
override val progress: Float,
) : MangaItemModel ) : MangaItemModel

@ -6,4 +6,8 @@ sealed interface MangaItemModel : ListModel {
val id: Long val id: Long
val manga: Manga val manga: Manga
val title: String
val coverUrl: String
val counter: Int
val progress: Float
} }

@ -4,11 +4,12 @@ import org.koitharu.kotatsu.parsers.model.Manga
data class MangaListDetailedModel( data class MangaListDetailedModel(
override val id: Long, override val id: Long,
val title: String, override val title: String,
val subtitle: String?, val subtitle: String?,
val tags: String, val tags: String,
val coverUrl: String, override val coverUrl: String,
val rating: String?, val rating: String?,
override val manga: Manga, override val manga: Manga,
val counter: Int, override val counter: Int,
override val progress: Float,
) : MangaItemModel ) : MangaItemModel

@ -4,9 +4,10 @@ import org.koitharu.kotatsu.parsers.model.Manga
data class MangaListModel( data class MangaListModel(
override val id: Long, override val id: Long,
val title: String, override val title: String,
val subtitle: String, val subtitle: String,
val coverUrl: String, override val coverUrl: String,
override val manga: Manga, override val manga: Manga,
val counter: Int, override val counter: Int,
override val progress: Float,
) : MangaItemModel ) : MangaItemModel

@ -49,7 +49,7 @@ class LocalListViewModel(
list == null -> listOf(LoadingState) list == null -> listOf(LoadingState)
list.isEmpty() -> listOf( list.isEmpty() -> listOf(
EmptyState( EmptyState(
icon = R.drawable.ic_storage, icon = R.drawable.ic_empty_local,
textPrimary = R.string.text_local_holder_primary, textPrimary = R.string.text_local_holder_primary,
textSecondary = R.string.text_local_holder_secondary, textSecondary = R.string.text_local_holder_secondary,
actionStringRes = R.string._import, actionStringRes = R.string._import,

@ -306,8 +306,9 @@ class MainActivity :
} }
override fun onClearSearchHistory() { override fun onClearSearchHistory() {
MaterialAlertDialogBuilder(this) MaterialAlertDialogBuilder(this, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered)
.setTitle(R.string.clear_search_history) .setTitle(R.string.clear_search_history)
.setIcon(R.drawable.ic_clear_all)
.setMessage(R.string.text_clear_search_history_prompt) .setMessage(R.string.text_clear_search_history_prompt)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ -> .setPositiveButton(R.string.clear) { _, _ ->

@ -10,6 +10,11 @@ import android.view.View
import android.view.WindowManager import android.view.WindowManager
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.widget.TextView import android.widget.TextView
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS
import androidx.biometric.BiometricPrompt
import androidx.biometric.BiometricPrompt.AuthenticationCallback
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@ -17,8 +22,11 @@ import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityProtectBinding import org.koitharu.kotatsu.databinding.ActivityProtectBinding
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class ProtectActivity : BaseActivity<ActivityProtectBinding>(), TextView.OnEditorActionListener, class ProtectActivity :
TextWatcher, View.OnClickListener { BaseActivity<ActivityProtectBinding>(),
TextView.OnEditorActionListener,
TextWatcher,
View.OnClickListener {
private val viewModel by viewModel<ProtectViewModel>() private val viewModel by viewModel<ProtectViewModel>()
@ -39,8 +47,10 @@ class ProtectActivity : BaseActivity<ActivityProtectBinding>(), TextView.OnEdito
finishAfterTransition() finishAfterTransition()
} }
if (!useFingerprint()) {
binding.editPassword.requestFocus() binding.editPassword.requestFocus()
} }
}
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
val basePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding) val basePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding)
@ -85,6 +95,31 @@ class ProtectActivity : BaseActivity<ActivityProtectBinding>(), TextView.OnEdito
binding.layoutPassword.isEnabled = !isLoading binding.layoutPassword.isEnabled = !isLoading
} }
private fun useFingerprint(): Boolean {
if (!viewModel.isBiometricEnabled) {
return false
}
if (BiometricManager.from(this).canAuthenticate(BIOMETRIC_WEAK) != BIOMETRIC_SUCCESS) {
return false
}
val prompt = BiometricPrompt(this, BiometricCallback())
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setAllowedAuthenticators(BIOMETRIC_WEAK)
.setTitle(getString(R.string.app_name))
.setConfirmationRequired(false)
.setNegativeButtonText(getString(android.R.string.cancel))
.build()
prompt.authenticate(promptInfo)
return true
}
private inner class BiometricCallback : AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
viewModel.unlock()
}
}
companion object { companion object {
private const val EXTRA_INTENT = "src_intent" private const val EXTRA_INTENT = "src_intent"

@ -19,6 +19,9 @@ class ProtectViewModel(
val onUnlockSuccess = SingleLiveEvent<Unit>() val onUnlockSuccess = SingleLiveEvent<Unit>()
val isBiometricEnabled
get() = settings.isBiometricProtectionEnabled
fun tryUnlock(password: String) { fun tryUnlock(password: String) {
if (job?.isActive == true) { if (job?.isActive == true) {
return return
@ -27,12 +30,16 @@ class ProtectViewModel(
val passwordHash = password.md5() val passwordHash = password.md5()
val appPasswordHash = settings.appPassword val appPasswordHash = settings.appPassword
if (passwordHash == appPasswordHash) { if (passwordHash == appPasswordHash) {
protectHelper.unlock() unlock()
onUnlockSuccess.call(Unit)
} else { } else {
delay(PASSWORD_COMPARE_DELAY) delay(PASSWORD_COMPARE_DELAY)
throw WrongPasswordException() throw WrongPasswordException()
} }
} }
} }
fun unlock() {
protectHelper.unlock()
onUnlockSuccess.call(Unit)
}
} }

@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.acra.ktx.sendWithAcra
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@ -238,6 +239,8 @@ class ReaderActivity :
val resolveTextId = ExceptionResolver.getResolveStringId(e) val resolveTextId = ExceptionResolver.getResolveStringId(e)
if (resolveTextId != 0) { if (resolveTextId != 0) {
dialog.setPositiveButton(resolveTextId, listener) dialog.setPositiveButton(resolveTextId, listener)
} else {
dialog.setPositiveButton(R.string.report, listener)
} }
dialog.show() dialog.show()
} }
@ -392,7 +395,11 @@ class ReaderActivity :
override fun onClick(dialog: DialogInterface?, which: Int) { override fun onClick(dialog: DialogInterface?, which: Int) {
if (which == DialogInterface.BUTTON_POSITIVE) { if (which == DialogInterface.BUTTON_POSITIVE) {
dialog?.dismiss() dialog?.dismiss()
if (ExceptionResolver.canResolve(exception)) {
tryResolve(exception) tryResolve(exception)
} else {
exception.sendWithAcra()
}
} else { } else {
onCancel(dialog) onCancel(dialog)
} }

@ -6,9 +6,9 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import java.util.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import org.acra.ACRA
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent import org.koitharu.kotatsu.base.domain.MangaIntent
@ -21,6 +21,7 @@ import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.* import org.koitharu.kotatsu.core.prefs.*
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
@ -32,6 +33,8 @@ import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.processLifecycleScope import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import org.koitharu.kotatsu.utils.ext.setCurrentManga
import java.util.*
private const val BOUNDS_PAGE_OFFSET = 2 private const val BOUNDS_PAGE_OFFSET = 2
private const val PAGES_TRIM_THRESHOLD = 120 private const val PAGES_TRIM_THRESHOLD = 120
@ -135,13 +138,16 @@ class ReaderViewModel(
} }
} }
// TODO check performance
fun saveCurrentState(state: ReaderState? = null) { fun saveCurrentState(state: ReaderState? = null) {
if (state != null) { if (state != null) {
currentState.value = state currentState.value = state
} }
val readerState = state ?: currentState.value ?: return
historyRepository.saveStateAsync( historyRepository.saveStateAsync(
mangaData.value ?: return, manga = mangaData.value ?: return,
state ?: currentState.value ?: return state = readerState,
percent = computePercent(readerState.chapterId, readerState.page),
) )
} }
@ -223,7 +229,7 @@ class ReaderViewModel(
if (bookmarkJob?.isActive == true) { if (bookmarkJob?.isActive == true) {
return return
} }
bookmarkJob = launchJob { bookmarkJob = launchJob(Dispatchers.Default) {
loadingJob?.join() loadingJob?.join()
val state = checkNotNull(currentState.value) val state = checkNotNull(currentState.value)
val page = checkNotNull(getCurrentPage()) { "Page not found" } val page = checkNotNull(getCurrentPage()) { "Page not found" }
@ -235,9 +241,10 @@ class ReaderViewModel(
scroll = state.scroll, scroll = state.scroll,
imageUrl = page.preview ?: pageLoader.getPageUrl(page), imageUrl = page.preview ?: pageLoader.getPageUrl(page),
createdAt = Date(), createdAt = Date(),
percent = computePercent(state.chapterId, state.page),
) )
bookmarksRepository.addBookmark(bookmark) bookmarksRepository.addBookmark(bookmark)
onShowToast.call(R.string.bookmark_added) onShowToast.postCall(R.string.bookmark_added)
} }
} }
@ -257,6 +264,7 @@ class ReaderViewModel(
private fun loadImpl() { private fun loadImpl() {
loadingJob = launchLoadingJob(Dispatchers.Default) { loadingJob = launchLoadingJob(Dispatchers.Default) {
var manga = dataRepository.resolveIntent(intent) ?: throw MangaNotFoundException("Cannot find manga") var manga = dataRepository.resolveIntent(intent) ?: throw MangaNotFoundException("Cannot find manga")
ACRA.setCurrentManga(manga)
mangaData.value = manga mangaData.value = manga
val repo = MangaRepository(manga.source) val repo = MangaRepository(manga.source)
manga = repo.getDetails(manga) manga = repo.getDetails(manga)
@ -279,7 +287,8 @@ class ReaderViewModel(
val pages = loadChapter(requireNotNull(currentState.value).chapterId) val pages = loadChapter(requireNotNull(currentState.value).chapterId)
// save state // save state
currentState.value?.let { currentState.value?.let {
historyRepository.addOrUpdate(manga, it.chapterId, it.page, it.scroll) val percent = computePercent(it.chapterId, it.page)
historyRepository.addOrUpdate(manga, it.chapterId, it.page, it.scroll, percent)
shortcutsRepository.updateShortcuts() shortcutsRepository.updateShortcuts()
} }
@ -364,20 +373,35 @@ class ReaderViewModel(
it.printStackTraceDebug() it.printStackTraceDebug()
}.getOrDefault(defaultMode) }.getOrDefault(defaultMode)
} }
private fun computePercent(chapterId: Long, pageIndex: Int): Float {
val chapters = manga?.chapters ?: return PROGRESS_NONE
val chaptersCount = chapters.size
val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId }
val pages = content.value?.pages ?: return PROGRESS_NONE
val pagesCount = pages.count { x -> x.chapterId == chapterId }
if (chaptersCount == 0 || pagesCount == 0) {
return PROGRESS_NONE
}
val pagePercent = (pageIndex + 1) / pagesCount.toFloat()
val ppc = 1f / chaptersCount
return ppc * chapterIndex + ppc * pagePercent
}
} }
/** /**
* This function is not a member of the ReaderViewModel * This function is not a member of the ReaderViewModel
* because it should work independently of the ViewModel's lifecycle. * because it should work independently of the ViewModel's lifecycle.
*/ */
private fun HistoryRepository.saveStateAsync(manga: Manga, state: ReaderState): Job { private fun HistoryRepository.saveStateAsync(manga: Manga, state: ReaderState, percent: Float): Job {
return processLifecycleScope.launch(Dispatchers.Default) { return processLifecycleScope.launch(Dispatchers.Default) {
runCatching { runCatching {
addOrUpdate( addOrUpdate(
manga = manga, manga = manga,
chapterId = state.chapterId, chapterId = state.chapterId,
page = state.page, page = state.page,
scroll = state.scroll scroll = state.scroll,
percent = percent,
) )
}.onFailure { }.onFailure {
it.printStackTraceDebug() it.printStackTraceDebug()

@ -16,6 +16,7 @@ abstract class BasePageHolder<B : ViewBinding>(
exceptionResolver: ExceptionResolver exceptionResolver: ExceptionResolver
) : RecyclerView.ViewHolder(binding.root), PageHolderDelegate.Callback { ) : RecyclerView.ViewHolder(binding.root), PageHolderDelegate.Callback {
@Suppress("LeakingThis")
protected val delegate = PageHolderDelegate(loader, settings, this, exceptionResolver) protected val delegate = PageHolderDelegate(loader, settings, this, exceptionResolver)
protected val bindingInfo = LayoutPageInfoBinding.bind(binding.root) protected val bindingInfo = LayoutPageInfoBinding.bind(binding.root)

@ -11,10 +11,11 @@ import org.koitharu.kotatsu.utils.ext.resetTransformations
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
@Suppress("LeakingThis")
abstract class BaseReaderAdapter<H : BasePageHolder<*>>( abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
private val loader: PageLoader, private val loader: PageLoader,
private val settings: AppSettings, private val settings: AppSettings,
private val exceptionResolver: ExceptionResolver private val exceptionResolver: ExceptionResolver,
) : RecyclerView.Adapter<H>() { ) : RecyclerView.Adapter<H>() {
private val differ = AsyncListDiffer(this, DiffCallback()) private val differ = AsyncListDiffer(this, DiffCallback())

@ -151,7 +151,7 @@ class RemoteListViewModel(
} }
private fun createEmptyState(filterState: FilterState) = EmptyState( private fun createEmptyState(filterState: FilterState) = EmptyState(
icon = R.drawable.ic_book_cross, icon = R.drawable.ic_empty_search,
textPrimary = R.string.nothing_found, textPrimary = R.string.nothing_found,
textSecondary = 0, textSecondary = 0,
actionStringRes = if (filterState.tags.isEmpty()) 0 else R.string.reset_filter, actionStringRes = if (filterState.tags.isEmpty()) 0 else R.string.reset_filter,

@ -0,0 +1,23 @@
package org.koitharu.kotatsu.scrobbling.data
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
abstract class ScrobblingDao {
@Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId")
abstract suspend fun find(scrobbler: Int, mangaId: Long): ScrobblingEntity?
@Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId")
abstract fun observe(scrobbler: Int, mangaId: Long): Flow<ScrobblingEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insert(entity: ScrobblingEntity)
@Update
abstract suspend fun update(entity: ScrobblingEntity)
@Query("DELETE FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId")
abstract suspend fun delete(scrobbler: Int, mangaId: Long)
}

@ -0,0 +1,35 @@
package org.koitharu.kotatsu.scrobbling.data
import androidx.room.ColumnInfo
import androidx.room.Entity
@Entity(
tableName = "scrobblings",
primaryKeys = ["scrobbler", "id", "manga_id"],
)
class ScrobblingEntity(
@ColumnInfo(name = "scrobbler") val scrobbler: Int,
@ColumnInfo(name = "id") val id: Int,
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "target_id") val targetId: Long,
@ColumnInfo(name = "status") val status: String?,
@ColumnInfo(name = "chapter") val chapter: Int,
@ColumnInfo(name = "comment") val comment: String?,
@ColumnInfo(name = "rating") val rating: Float,
) {
fun copy(
status: String?,
comment: String?,
rating: Float,
) = ScrobblingEntity(
scrobbler = scrobbler,
id = id,
mangaId = mangaId,
targetId = targetId,
status = status,
chapter = chapter,
comment = comment,
rating = rating,
)
}

@ -0,0 +1,80 @@
package org.koitharu.kotatsu.scrobbling.domain
import androidx.collection.LongSparseArray
import androidx.collection.getOrElse
import androidx.core.text.parseAsHtml
import java.util.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity
import org.koitharu.kotatsu.scrobbling.domain.model.*
import org.koitharu.kotatsu.utils.ext.findKey
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
abstract class Scrobbler(
protected val db: MangaDatabase,
val scrobblerService: ScrobblerService,
) {
private val infoCache = LongSparseArray<ScrobblerMangaInfo>()
protected val statuses = EnumMap<ScrobblingStatus, String>(ScrobblingStatus::class.java)
abstract val isAvailable: Boolean
abstract suspend fun findManga(query: String, offset: Int): List<ScrobblerManga>
abstract suspend fun linkManga(mangaId: Long, targetId: Long)
abstract suspend fun scrobble(mangaId: Long, chapter: MangaChapter)
suspend fun getScrobblingInfoOrNull(mangaId: Long): ScrobblingInfo? {
val entity = db.scrobblingDao.find(scrobblerService.id, mangaId) ?: return null
return entity.toScrobblingInfo(mangaId)
}
abstract suspend fun updateScrobblingInfo(mangaId: Long, rating: Float, status: ScrobblingStatus?, comment: String?)
fun observeScrobblingInfo(mangaId: Long): Flow<ScrobblingInfo?> {
return db.scrobblingDao.observe(scrobblerService.id, mangaId)
.map { it?.toScrobblingInfo(mangaId) }
}
abstract suspend fun unregisterScrobbling(mangaId: Long)
protected abstract suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo
private suspend fun ScrobblingEntity.toScrobblingInfo(mangaId: Long): ScrobblingInfo? {
val mangaInfo = infoCache.getOrElse(targetId) {
runCatching {
getMangaInfo(targetId)
}.onFailure {
it.printStackTraceDebug()
}.onSuccess {
infoCache.put(targetId, it)
}.getOrNull() ?: return null
}
return ScrobblingInfo(
scrobbler = scrobblerService,
mangaId = mangaId,
targetId = targetId,
status = statuses.findKey(status),
chapter = chapter,
comment = comment,
rating = rating,
title = mangaInfo.name,
coverUrl = mangaInfo.cover,
description = mangaInfo.descriptionHtml.parseAsHtml(),
externalUrl = mangaInfo.url,
)
}
}
suspend fun Scrobbler.tryScrobble(mangaId: Long, chapter: MangaChapter): Boolean {
return runCatching {
scrobble(mangaId, chapter)
}.onFailure {
it.printStackTraceDebug()
}.isSuccess
}

@ -0,0 +1,40 @@
package org.koitharu.kotatsu.scrobbling.domain.model
import org.koitharu.kotatsu.list.ui.model.ListModel
class ScrobblerManga(
val id: Long,
val name: String,
val altName: String?,
val cover: String,
val url: String,
) : ListModel {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ScrobblerManga
if (id != other.id) return false
if (name != other.name) return false
if (altName != other.altName) return false
if (cover != other.cover) return false
if (url != other.url) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + name.hashCode()
result = 31 * result + altName.hashCode()
result = 31 * result + cover.hashCode()
result = 31 * result + url.hashCode()
return result
}
override fun toString(): String {
return "ScrobblerManga #$id \"$name\" $url"
}
}

@ -0,0 +1,9 @@
package org.koitharu.kotatsu.scrobbling.domain.model
class ScrobblerMangaInfo(
val id: Long,
val name: String,
val cover: String,
val url: String,
val descriptionHtml: String,
)

@ -0,0 +1,14 @@
package org.koitharu.kotatsu.scrobbling.domain.model
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
enum class ScrobblerService(
val id: Int,
@StringRes val titleResId: Int,
@DrawableRes val iconResId: Int,
) {
SHIKIMORI(1, R.string.shikimori, R.drawable.ic_shikimori)
}

@ -0,0 +1,52 @@
package org.koitharu.kotatsu.scrobbling.domain.model
class ScrobblingInfo(
val scrobbler: ScrobblerService,
val mangaId: Long,
val targetId: Long,
val status: ScrobblingStatus?,
val chapter: Int,
val comment: String?,
val rating: Float,
val title: String,
val coverUrl: String,
val description: CharSequence?,
val externalUrl: String,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ScrobblingInfo
if (scrobbler != other.scrobbler) return false
if (mangaId != other.mangaId) return false
if (targetId != other.targetId) return false
if (status != other.status) return false
if (chapter != other.chapter) return false
if (comment != other.comment) return false
if (rating != other.rating) return false
if (title != other.title) return false
if (coverUrl != other.coverUrl) return false
if (description != other.description) return false
if (externalUrl != other.externalUrl) return false
return true
}
override fun hashCode(): Int {
var result = scrobbler.hashCode()
result = 31 * result + mangaId.hashCode()
result = 31 * result + targetId.hashCode()
result = 31 * result + (status?.hashCode() ?: 0)
result = 31 * result + chapter
result = 31 * result + (comment?.hashCode() ?: 0)
result = 31 * result + rating.hashCode()
result = 31 * result + title.hashCode()
result = 31 * result + coverUrl.hashCode()
result = 31 * result + (description?.hashCode() ?: 0)
result = 31 * result + externalUrl.hashCode()
return result
}
}

@ -0,0 +1,11 @@
package org.koitharu.kotatsu.scrobbling.domain.model
enum class ScrobblingStatus {
PLANNED,
READING,
RE_READING,
COMPLETED,
ON_HOLD,
DROPPED,
}

@ -0,0 +1,32 @@
package org.koitharu.kotatsu.scrobbling.shikimori
import okhttp3.OkHttpClient
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.bind
import org.koin.dsl.module
import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriAuthenticator
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriInterceptor
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriStorage
import org.koitharu.kotatsu.scrobbling.shikimori.domain.ShikimoriScrobbler
import org.koitharu.kotatsu.scrobbling.shikimori.ui.ShikimoriSettingsViewModel
import org.koitharu.kotatsu.scrobbling.ui.selector.ScrobblingSelectorViewModel
val shikimoriModule
get() = module {
single { ShikimoriStorage(androidContext()) }
factory {
val okHttp = OkHttpClient.Builder().apply {
authenticator(ShikimoriAuthenticator(get(), ::get))
addInterceptor(ShikimoriInterceptor(get()))
}.build()
ShikimoriRepository(okHttp, get(), get())
}
factory { ShikimoriScrobbler(get(), get()) } bind Scrobbler::class
viewModel { params ->
ShikimoriSettingsViewModel(get(), params.getOrNull())
}
viewModel { params -> ScrobblingSelectorViewModel(params[0], get()) }
}

@ -0,0 +1,51 @@
package org.koitharu.kotatsu.scrobbling.shikimori.data
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.network.CommonHeaders
class ShikimoriAuthenticator(
private val storage: ShikimoriStorage,
private val repositoryProvider: () -> ShikimoriRepository,
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
val accessToken = storage.accessToken ?: return null
if (!isRequestWithAccessToken(response)) {
return null
}
synchronized(this) {
val newAccessToken = storage.accessToken ?: return null
if (accessToken != newAccessToken) {
return newRequestWithAccessToken(response.request, newAccessToken)
}
val updatedAccessToken = refreshAccessToken() ?: return null
return newRequestWithAccessToken(response.request, updatedAccessToken)
}
}
private fun isRequestWithAccessToken(response: Response): Boolean {
val header = response.request.header(CommonHeaders.AUTHORIZATION)
return header?.startsWith("Bearer") == true
}
private fun newRequestWithAccessToken(request: Request, accessToken: String): Request {
return request.newBuilder()
.header(CommonHeaders.AUTHORIZATION, "Bearer $accessToken")
.build()
}
private fun refreshAccessToken(): String? = runCatching {
val repository = repositoryProvider()
runBlocking { repository.authorize(null) }
return storage.accessToken
}.onFailure {
if (BuildConfig.DEBUG) {
it.printStackTrace()
}
}.getOrNull()
}

@ -0,0 +1,24 @@
package org.koitharu.kotatsu.scrobbling.shikimori.data
import okhttp3.Interceptor
import okhttp3.Response
import okio.IOException
import org.koitharu.kotatsu.core.network.CommonHeaders
private const val USER_AGENT_SHIKIMORI = "Kotatsu"
class ShikimoriInterceptor(private val storage: ShikimoriStorage) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
request.header(CommonHeaders.USER_AGENT, USER_AGENT_SHIKIMORI)
storage.accessToken?.let {
request.header(CommonHeaders.AUTHORIZATION, "Bearer $it")
}
val response = chain.proceed(request.build())
if (!response.isSuccessful && !response.isRedirect) {
throw IOException("${response.code} ${response.message}")
}
return response
}
}

@ -0,0 +1,199 @@
package org.koitharu.kotatsu.scrobbling.shikimori.data
import okhttp3.FormBody
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.parseJson
import org.koitharu.kotatsu.parsers.util.parseJsonArray
import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerMangaInfo
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser
import org.koitharu.kotatsu.utils.ext.toRequestBody
private const val REDIRECT_URI = "kotatsu://shikimori-auth"
private const val BASE_URL = "https://shikimori.one/"
private const val MANGA_PAGE_SIZE = 10
class ShikimoriRepository(
private val okHttp: OkHttpClient,
private val storage: ShikimoriStorage,
private val db: MangaDatabase,
) {
val oauthUrl: String
get() = "${BASE_URL}oauth/authorize?client_id=${BuildConfig.SHIKIMORI_CLIENT_ID}&" +
"redirect_uri=$REDIRECT_URI&response_type=code&scope="
val isAuthorized: Boolean
get() = storage.accessToken != null
suspend fun authorize(code: String?) {
val body = FormBody.Builder()
body.add("grant_type", "authorization_code")
body.add("client_id", BuildConfig.SHIKIMORI_CLIENT_ID)
body.add("client_secret", BuildConfig.SHIKIMORI_CLIENT_SECRET)
if (code != null) {
body.add("redirect_uri", REDIRECT_URI)
body.add("code", code)
} else {
body.add("refresh_token", checkNotNull(storage.refreshToken))
}
val request = Request.Builder()
.post(body.build())
.url("${BASE_URL}oauth/token")
val response = okHttp.newCall(request.build()).await().parseJson()
storage.accessToken = response.getString("access_token")
storage.refreshToken = response.getString("refresh_token")
}
suspend fun loadUser(): ShikimoriUser {
val request = Request.Builder()
.get()
.url("${BASE_URL}api/users/whoami")
val response = okHttp.newCall(request.build()).await().parseJson()
return ShikimoriUser(response).also { storage.user = it }
}
fun getCachedUser(): ShikimoriUser? {
return storage.user
}
suspend fun unregister(mangaId: Long) {
return db.scrobblingDao.delete(ScrobblerService.SHIKIMORI.id, mangaId)
}
fun logout() {
storage.clear()
}
suspend fun findManga(query: String, offset: Int): List<ScrobblerManga> {
val page = offset / MANGA_PAGE_SIZE
val pageOffset = offset % MANGA_PAGE_SIZE
val url = BASE_URL.toHttpUrl().newBuilder()
.addPathSegment("api")
.addPathSegment("mangas")
.addEncodedQueryParameter("page", (page + 1).toString())
.addEncodedQueryParameter("limit", MANGA_PAGE_SIZE.toString())
.addEncodedQueryParameter("censored", false.toString())
.addQueryParameter("search", query)
.build()
val request = Request.Builder().url(url).get().build()
val response = okHttp.newCall(request).await().parseJsonArray()
val list = response.mapJSON { ScrobblerManga(it) }
return if (pageOffset != 0) list.drop(pageOffset) else list
}
suspend fun createRate(mangaId: Long, shikiMangaId: Long) {
val user = getCachedUser() ?: loadUser()
val payload = JSONObject()
payload.put(
"user_rate",
JSONObject().apply {
put("target_id", shikiMangaId)
put("target_type", "Manga")
put("user_id", user.id)
}
)
val url = BASE_URL.toHttpUrl().newBuilder()
.addPathSegment("api")
.addPathSegment("v2")
.addPathSegment("user_rates")
.build()
val request = Request.Builder().url(url).post(payload.toRequestBody()).build()
val response = okHttp.newCall(request).await().parseJson()
saveRate(response, mangaId)
}
suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter) {
val payload = JSONObject()
payload.put(
"user_rate",
JSONObject().apply {
put("chapters", chapter.number)
}
)
val url = BASE_URL.toHttpUrl().newBuilder()
.addPathSegment("api")
.addPathSegment("v2")
.addPathSegment("user_rates")
.addPathSegment(rateId.toString())
.build()
val request = Request.Builder().url(url).patch(payload.toRequestBody()).build()
val response = okHttp.newCall(request).await().parseJson()
saveRate(response, mangaId)
}
suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) {
val payload = JSONObject()
payload.put(
"user_rate",
JSONObject().apply {
put("score", rating.toString())
if (comment != null) {
put("text", comment)
}
if (status != null) {
put("status", status)
}
}
)
val url = BASE_URL.toHttpUrl().newBuilder()
.addPathSegment("api")
.addPathSegment("v2")
.addPathSegment("user_rates")
.addPathSegment(rateId.toString())
.build()
val request = Request.Builder().url(url).patch(payload.toRequestBody()).build()
val response = okHttp.newCall(request).await().parseJson()
saveRate(response, mangaId)
}
suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo {
val request = Request.Builder()
.get()
.url("${BASE_URL}api/mangas/$id")
val response = okHttp.newCall(request.build()).await().parseJson()
return ScrobblerMangaInfo(response)
}
private suspend fun saveRate(json: JSONObject, mangaId: Long) {
val entity = ScrobblingEntity(
scrobbler = ScrobblerService.SHIKIMORI.id,
id = json.getInt("id"),
mangaId = mangaId,
targetId = json.getLong("target_id"),
status = json.getString("status"),
chapter = json.getInt("chapters"),
comment = json.getString("text"),
rating = json.getDouble("score").toFloat() / 10f,
)
db.scrobblingDao.insert(entity)
}
private fun ScrobblerManga(json: JSONObject) = ScrobblerManga(
id = json.getLong("id"),
name = json.getString("name"),
altName = json.getStringOrNull("russian"),
cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl("shikimori.one"),
url = json.getString("url").toAbsoluteUrl("shikimori.one"),
)
private fun ScrobblerMangaInfo(json: JSONObject) = ScrobblerMangaInfo(
id = json.getLong("id"),
name = json.getString("name"),
cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl("shikimori.one"),
url = json.getString("url").toAbsoluteUrl("shikimori.one"),
descriptionHtml = json.getString("description_html"),
)
}

@ -0,0 +1,36 @@
package org.koitharu.kotatsu.scrobbling.shikimori.data
import android.content.Context
import androidx.core.content.edit
import org.json.JSONObject
import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser
private const val PREF_NAME = "shikimori"
private const val KEY_ACCESS_TOKEN = "access_token"
private const val KEY_REFRESH_TOKEN = "refresh_token"
private const val KEY_USER = "user"
class ShikimoriStorage(context: Context) {
private val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
var accessToken: String?
get() = prefs.getString(KEY_ACCESS_TOKEN, null)
set(value) = prefs.edit { putString(KEY_ACCESS_TOKEN, value) }
var refreshToken: String?
get() = prefs.getString(KEY_REFRESH_TOKEN, null)
set(value) = prefs.edit { putString(KEY_REFRESH_TOKEN, value) }
var user: ShikimoriUser?
get() = prefs.getString(KEY_USER, null)?.let {
ShikimoriUser(JSONObject(it))
}
set(value) = prefs.edit {
putString(KEY_USER, value?.toJson()?.toString())
}
fun clear() = prefs.edit {
clear()
}
}

@ -0,0 +1,42 @@
package org.koitharu.kotatsu.scrobbling.shikimori.data.model
import org.json.JSONObject
class ShikimoriUser(
val id: Long,
val nickname: String,
val avatar: String,
) {
constructor(json: JSONObject) : this(
id = json.getLong("id"),
nickname = json.getString("nickname"),
avatar = json.getString("avatar"),
)
fun toJson() = JSONObject().apply {
put("id", id)
put("nickname", nickname)
put("avatar", avatar)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ShikimoriUser
if (id != other.id) return false
if (nickname != other.nickname) return false
if (avatar != other.avatar) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + nickname.hashCode()
result = 31 * result + avatar.hashCode()
return result
}
}

@ -0,0 +1,68 @@
package org.koitharu.kotatsu.scrobbling.shikimori.domain
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerMangaInfo
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository
private const val RATING_MAX = 10f
class ShikimoriScrobbler(
private val repository: ShikimoriRepository,
db: MangaDatabase,
) : Scrobbler(db, ScrobblerService.SHIKIMORI) {
init {
statuses[ScrobblingStatus.PLANNED] = "planned"
statuses[ScrobblingStatus.READING] = "watching"
statuses[ScrobblingStatus.RE_READING] = "rewatching"
statuses[ScrobblingStatus.COMPLETED] = "completed"
statuses[ScrobblingStatus.ON_HOLD] = "on_hold"
statuses[ScrobblingStatus.DROPPED] = "dropped"
}
override val isAvailable: Boolean
get() = repository.isAuthorized
override suspend fun findManga(query: String, offset: Int): List<ScrobblerManga> {
return repository.findManga(query, offset)
}
override suspend fun linkManga(mangaId: Long, targetId: Long) {
repository.createRate(mangaId, targetId)
}
override suspend fun scrobble(mangaId: Long, chapter: MangaChapter) {
val entity = db.scrobblingDao.find(scrobblerService.id, mangaId) ?: return
repository.updateRate(entity.id, entity.mangaId, chapter)
}
override suspend fun updateScrobblingInfo(
mangaId: Long,
rating: Float,
status: ScrobblingStatus?,
comment: String?,
) {
val entity = db.scrobblingDao.find(scrobblerService.id, mangaId)
requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" }
repository.updateRate(
rateId = entity.id,
mangaId = entity.mangaId,
rating = rating * RATING_MAX,
status = statuses[status],
comment = comment,
)
}
override suspend fun unregisterScrobbling(mangaId: Long) {
repository.unregister(mangaId)
}
override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo {
return repository.getMangaInfo(id)
}
}

@ -0,0 +1,79 @@
package org.koitharu.kotatsu.scrobbling.shikimori.ui
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.preference.Preference
import coil.ImageLoader
import coil.request.ImageRequest
import coil.transform.CircleCropTransformation
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser
import org.koitharu.kotatsu.utils.PreferenceIconTarget
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.withArgs
class ShikimoriSettingsFragment : BasePreferenceFragment(R.string.shikimori) {
private val viewModel by viewModel<ShikimoriSettingsViewModel> {
parametersOf(arguments?.getString(ARG_AUTH_CODE))
}
private val coil by inject<ImageLoader>(mode = LazyThreadSafetyMode.NONE)
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_shikimori)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.user.observe(viewLifecycleOwner, this::onUserChanged)
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
KEY_USER -> openAuthorization()
KEY_LOGOUT -> {
viewModel.logout()
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
private fun onUserChanged(user: ShikimoriUser?) {
val pref = findPreference<Preference>(KEY_USER) ?: return
pref.isSelectable = user == null
pref.title = user?.nickname ?: getString(R.string.sign_in)
ImageRequest.Builder(requireContext())
.data(user?.avatar)
.transformations(CircleCropTransformation())
.target(PreferenceIconTarget(pref))
.enqueueWith(coil)
findPreference<Preference>(KEY_LOGOUT)?.isVisible = user != null
}
private fun openAuthorization(): Boolean {
return runCatching {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(viewModel.authorizationUrl)
startActivity(intent)
}.isSuccess
}
companion object {
private const val KEY_USER = "shiki_user"
private const val KEY_LOGOUT = "shiki_logout"
private const val ARG_AUTH_CODE = "auth_code"
fun newInstance(authCode: String?) = ShikimoriSettingsFragment().withArgs(1) {
putString(ARG_AUTH_CODE, authCode)
}
}
}

@ -0,0 +1,48 @@
package org.koitharu.kotatsu.scrobbling.shikimori.ui
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository
import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser
class ShikimoriSettingsViewModel(
private val repository: ShikimoriRepository,
authCode: String?,
) : BaseViewModel() {
val authorizationUrl: String
get() = repository.oauthUrl
val user = MutableLiveData<ShikimoriUser?>()
init {
if (authCode != null) {
authorize(authCode)
} else {
loadUser()
}
}
fun logout() {
launchJob(Dispatchers.Default) {
repository.logout()
user.postValue(null)
}
}
private fun loadUser() = launchJob(Dispatchers.Default) {
val userModel = if (repository.isAuthorized) {
repository.getCachedUser()?.let(user::postValue)
repository.loadUser()
} else {
null
}
user.postValue(userModel)
}
private fun authorize(code: String) = launchJob(Dispatchers.Default) {
repository.authorize(code)
user.postValue(repository.loadUser())
}
}

@ -0,0 +1,155 @@
package org.koitharu.kotatsu.scrobbling.ui.selector
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.view.*
import android.widget.Toast
import androidx.appcompat.widget.SearchView
import androidx.fragment.app.FragmentManager
import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.databinding.SheetScrobblingSelectorBinding
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.ui.selector.adapter.ShikiMangaSelectionDecoration
import org.koitharu.kotatsu.scrobbling.ui.selector.adapter.ShikimoriSelectorAdapter
import org.koitharu.kotatsu.utils.BottomSheetToolbarController
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.withArgs
class ScrobblingSelectorBottomSheet :
BaseBottomSheet<SheetScrobblingSelectorBinding>(),
OnListItemClickListener<ScrobblerManga>,
PaginationScrollListener.Callback,
View.OnClickListener,
MenuItem.OnActionExpandListener,
SearchView.OnQueryTextListener,
DialogInterface.OnKeyListener {
private val viewModel by viewModel<ScrobblingSelectorViewModel> {
parametersOf(requireNotNull(requireArguments().getParcelable<ParcelableManga>(MangaIntent.KEY_MANGA)).manga)
}
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetScrobblingSelectorBinding {
return SheetScrobblingSelectorBinding.inflate(inflater, container, false)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return super.onCreateDialog(savedInstanceState).also {
it.setOnKeyListener(this)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.toolbar.setNavigationOnClickListener { dismiss() }
addBottomSheetCallback(BottomSheetToolbarController(binding.toolbar))
val listAdapter = ShikimoriSelectorAdapter(viewLifecycleOwner, get(), this)
val decoration = ShikiMangaSelectionDecoration(view.context)
with(binding.recyclerView) {
adapter = listAdapter
addItemDecoration(decoration)
addOnScrollListener(PaginationScrollListener(4, this@ScrobblingSelectorBottomSheet))
}
binding.buttonDone.setOnClickListener(this)
initOptionsMenu()
viewModel.content.observe(viewLifecycleOwner) { listAdapter.items = it }
viewModel.selectedItemId.observe(viewLifecycleOwner) {
decoration.checkedItemId = it
binding.recyclerView.invalidateItemDecorations()
}
viewModel.onError.observe(viewLifecycleOwner, ::onError)
viewModel.onClose.observe(viewLifecycleOwner) {
dismiss()
}
viewModel.searchQuery.observe(viewLifecycleOwner) {
binding.toolbar.subtitle = it
}
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_done -> viewModel.onDoneClick()
}
}
override fun onItemClick(item: ScrobblerManga, view: View) {
viewModel.selectedItemId.value = item.id
}
override fun onScrolledToEnd() {
viewModel.loadList(append = true)
}
override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
setExpanded(isExpanded = true, isLocked = true)
return true
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
val searchView = (item.actionView as? SearchView) ?: return false
searchView.setQuery("", false)
searchView.post { setExpanded(isExpanded = false, isLocked = false) }
return true
}
override fun onQueryTextSubmit(query: String?): Boolean {
if (query == null || query.length < 3) {
return false
}
viewModel.search(query)
binding.toolbar.menu.findItem(R.id.action_search)?.collapseActionView()
return true
}
override fun onQueryTextChange(newText: String?): Boolean = false
override fun onKey(dialog: DialogInterface?, keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode == KeyEvent.KEYCODE_BACK) {
val menuItem = binding.toolbar.menu.findItem(R.id.action_search) ?: return false
if (menuItem.isActionViewExpanded) {
if (event?.action == KeyEvent.ACTION_UP) {
menuItem.collapseActionView()
}
return true
}
}
return false
}
private fun onError(e: Throwable) {
Toast.makeText(requireContext(), e.getDisplayMessage(resources), Toast.LENGTH_LONG).show()
if (viewModel.isEmpty) {
dismissAllowingStateLoss()
}
}
private fun initOptionsMenu() {
binding.toolbar.inflateMenu(R.menu.opt_shiki_selector)
val searchMenuItem = binding.toolbar.menu.findItem(R.id.action_search)
searchMenuItem.setOnActionExpandListener(this)
val searchView = searchMenuItem.actionView as SearchView
searchView.setOnQueryTextListener(this)
searchView.setIconifiedByDefault(false)
searchView.queryHint = searchMenuItem.title
}
companion object {
private const val TAG = "ScrobblingSelectorBottomSheet"
fun show(fm: FragmentManager, manga: Manga) =
ScrobblingSelectorBottomSheet().withArgs(1) {
putParcelable(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = false))
}.show(fm, TAG)
}
}

@ -0,0 +1,101 @@
package org.koitharu.kotatsu.scrobbling.ui.selector
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import androidx.recyclerview.widget.RecyclerView.NO_ID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
class ScrobblingSelectorViewModel(
val manga: Manga,
private val scrobbler: Scrobbler,
) : BaseViewModel() {
private val shikiMangaList = MutableStateFlow<List<ScrobblerManga>?>(null)
private val hasNextPage = MutableStateFlow(false)
private var loadingJob: Job? = null
private var doneJob: Job? = null
val content: LiveData<List<ListModel>> = combine(
shikiMangaList.filterNotNull(),
hasNextPage
) { list, isHasNextPage ->
when {
list.isEmpty() -> listOf()
isHasNextPage -> list + LoadingFooter
else -> list
}
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
val selectedItemId = MutableLiveData(NO_ID)
val searchQuery = MutableLiveData(manga.title)
val onClose = SingleLiveEvent<Unit>()
val isEmpty: Boolean
get() = shikiMangaList.value.isNullOrEmpty()
init {
launchJob(Dispatchers.Default) {
try {
val info = scrobbler.getScrobblingInfoOrNull(manga.id)
if (info != null) {
selectedItemId.postValue(info.targetId)
}
} finally {
loadList(append = false)
}
}
}
fun search(query: String) {
loadingJob?.cancel()
searchQuery.value = query
loadList(append = false)
}
fun loadList(append: Boolean) {
if (loadingJob?.isActive == true) {
return
}
if (append && !hasNextPage.value) {
return
}
loadingJob = launchLoadingJob(Dispatchers.Default) {
val offset = if (append) shikiMangaList.value?.size ?: 0 else 0
val list = scrobbler.findManga(checkNotNull(searchQuery.value), offset)
if (!append) {
shikiMangaList.value = list
} else if (list.isNotEmpty()) {
shikiMangaList.value = shikiMangaList.value?.plus(list) ?: list
}
hasNextPage.value = list.isNotEmpty()
}
}
fun onDoneClick() {
if (doneJob?.isActive == true) {
return
}
val targetId = selectedItemId.value ?: NO_ID
if (targetId == NO_ID) {
onClose.call(Unit)
}
doneJob = launchJob(Dispatchers.Default) {
scrobbler.linkManga(manga.id, targetId)
onClose.postCall(Unit)
}
}
}

@ -0,0 +1,52 @@
package org.koitharu.kotatsu.scrobbling.ui.selector.adapter
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_ID
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
import org.koitharu.kotatsu.utils.ext.getItem
class ShikiMangaSelectionDecoration(context: Context) : MangaSelectionDecoration(context) {
var checkedItemId: Long
get() = selection.singleOrNull() ?: NO_ID
set(value) {
clearSelection()
if (value != NO_ID) {
selection.add(value)
}
}
override fun getItemId(parent: RecyclerView, child: View): Long {
val holder = parent.getChildViewHolder(child) ?: return NO_ID
val item = holder.getItem(ScrobblerManga::class.java) ?: return NO_ID
return item.id
}
override fun onDrawForeground(
canvas: Canvas,
parent: RecyclerView,
child: View,
bounds: RectF,
state: RecyclerView.State,
) {
paint.color = strokeColor
paint.style = Paint.Style.STROKE
canvas.drawRoundRect(bounds, defaultRadius, defaultRadius, paint)
checkIcon?.run {
val offset = (bounds.height() - intrinsicHeight) / 2
setBounds(
(bounds.right - offset - intrinsicWidth).toInt(),
(bounds.top + offset).toInt(),
(bounds.right - offset).toInt(),
(bounds.top + offset + intrinsicHeight).toInt(),
)
draw(canvas)
}
}
}

@ -0,0 +1,52 @@
package org.koitharu.kotatsu.scrobbling.ui.selector.adapter
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import coil.request.Disposable
import coil.size.Scale
import coil.util.CoilUtils
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemMangaListBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.textAndVisible
fun shikimoriMangaAD(
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
clickListener: OnListItemClickListener<ScrobblerManga>,
) = adapterDelegateViewBinding<ScrobblerManga, ListModel, ItemMangaListBinding>(
{ inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) }
) {
var imageRequest: Disposable? = null
itemView.setOnClickListener {
clickListener.onItemClick(item, it)
}
bind {
imageRequest?.dispose()
binding.textViewTitle.text = item.name
binding.textViewSubtitle.textAndVisible = item.altName
imageRequest = binding.imageViewCover.newImageRequest(item.cover)
.placeholder(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder)
.scale(Scale.FILL)
.allowRgb565(true)
.lifecycle(lifecycleOwner)
.enqueueWith(coil)
}
onViewRecycled {
imageRequest?.dispose()
imageRequest = null
CoilUtils.dispose(binding.imageViewCover)
binding.imageViewCover.setImageDrawable(null)
}
}

@ -0,0 +1,40 @@
package org.koitharu.kotatsu.scrobbling.ui.selector.adapter
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlin.jvm.internal.Intrinsics
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
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.scrobbling.domain.model.ScrobblerManga
class ShikimoriSelectorAdapter(
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
clickListener: OnListItemClickListener<ScrobblerManga>,
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
init {
delegatesManager.addDelegate(loadingStateAD())
.addDelegate(shikimoriMangaAD(lifecycleOwner, coil, clickListener))
.addDelegate(loadingFooterAD())
}
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
return when {
oldItem === newItem -> true
oldItem is ScrobblerManga && newItem is ScrobblerManga -> oldItem.id == newItem.id
else -> false
}
}
override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
return Intrinsics.areEqual(oldItem, newItem)
}
}
}

@ -3,7 +3,6 @@ package org.koitharu.kotatsu.search.ui
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.graphics.Insets import androidx.core.graphics.Insets

@ -35,7 +35,7 @@ class SearchViewModel(
list == null -> listOf(LoadingState) list == null -> listOf(LoadingState)
list.isEmpty() -> listOf( list.isEmpty() -> listOf(
EmptyState( EmptyState(
icon = R.drawable.ic_book_search, icon = R.drawable.ic_empty_search,
textPrimary = R.string.nothing_found, textPrimary = R.string.nothing_found,
textSecondary = R.string.text_search_holder_secondary, textSecondary = R.string.text_search_holder_secondary,
actionStringRes = 0, actionStringRes = 0,

@ -42,7 +42,7 @@ class MultiSearchViewModel(
loading -> LoadingState loading -> LoadingState
error != null -> error.toErrorState(canRetry = true) error != null -> error.toErrorState(canRetry = true)
else -> EmptyState( else -> EmptyState(
icon = R.drawable.ic_book_search, icon = R.drawable.ic_empty_search,
textPrimary = R.string.nothing_found, textPrimary = R.string.nothing_found,
textSecondary = R.string.text_search_holder_secondary, textSecondary = R.string.text_search_holder_secondary,
actionStringRes = 0, actionStringRes = 0,

@ -5,6 +5,7 @@ import android.os.Bundle
import android.view.View import android.view.View
import androidx.preference.ListPreference import androidx.preference.ListPreference
import androidx.preference.Preference import androidx.preference.Preference
import java.io.File
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@ -18,7 +19,6 @@ import org.koitharu.kotatsu.settings.utils.SliderPreference
import org.koitharu.kotatsu.utils.ext.getStorageName import org.koitharu.kotatsu.utils.ext.getStorageName
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import java.io.File
class ContentSettingsFragment : class ContentSettingsFragment :
BasePreferenceFragment(R.string.content), BasePreferenceFragment(R.string.content),
@ -49,12 +49,12 @@ class ContentSettingsFragment :
).names() ).names()
setDefaultValueCompat(DoHProvider.NONE.name) setDefaultValueCompat(DoHProvider.NONE.name)
} }
bindRemoteSourcesSummary()
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName() findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName()
bindRemoteSourcesSummary()
settings.subscribe(this) settings.subscribe(this)
} }
@ -108,9 +108,7 @@ class ContentSettingsFragment :
private fun bindRemoteSourcesSummary() { private fun bindRemoteSourcesSummary() {
findPreference<Preference>(AppSettings.KEY_REMOTE_SOURCES)?.run { findPreference<Preference>(AppSettings.KEY_REMOTE_SOURCES)?.run {
val total = settings.remoteMangaSources.size val total = settings.remoteMangaSources.size
summary = getString( summary = getString(R.string.enabled_d_of_d, total - settings.hiddenSources.size, total)
R.string.enabled_d_of_d, total - settings.hiddenSources.size, total
)
} }
} }
} }

@ -1,5 +1,7 @@
package org.koitharu.kotatsu.settings package org.koitharu.kotatsu.settings
import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.preference.Preference import androidx.preference.Preference
@ -14,6 +16,7 @@ import org.koitharu.kotatsu.core.network.AndroidCookieJar
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository
import org.koitharu.kotatsu.search.domain.MangaSearchRepository import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.FileSize
@ -25,6 +28,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
private val trackerRepo by inject<TrackingRepository>(mode = LazyThreadSafetyMode.NONE) private val trackerRepo by inject<TrackingRepository>(mode = LazyThreadSafetyMode.NONE)
private val searchRepository by inject<MangaSearchRepository>(mode = LazyThreadSafetyMode.NONE) private val searchRepository by inject<MangaSearchRepository>(mode = LazyThreadSafetyMode.NONE)
private val storageManager by inject<LocalStorageManager>(mode = LazyThreadSafetyMode.NONE) private val storageManager by inject<LocalStorageManager>(mode = LazyThreadSafetyMode.NONE)
private val shikimoriRepository by inject<ShikimoriRepository>(mode = LazyThreadSafetyMode.NONE)
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_history) addPreferencesFromResource(R.xml.pref_history)
@ -50,6 +54,11 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
} }
} }
override fun onResume() {
super.onResume()
bindShikimoriSummary()
}
override fun onPreferenceTreeClick(preference: Preference): Boolean { override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) { return when (preference.key) {
AppSettings.KEY_PAGES_CACHE_CLEAR -> { AppSettings.KEY_PAGES_CACHE_CLEAR -> {
@ -81,6 +90,14 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
} }
true true
} }
AppSettings.KEY_SHIKIMORI -> {
if (!shikimoriRepository.isAuthorized) {
launchShikimoriAuth()
true
} else {
super.onPreferenceTreeClick(preference)
}
}
else -> super.onPreferenceTreeClick(preference) else -> super.onPreferenceTreeClick(preference)
} }
} }
@ -142,4 +159,22 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
} }
}.show() }.show()
} }
private fun bindShikimoriSummary() {
findPreference<Preference>(AppSettings.KEY_SHIKIMORI)?.summary = if (shikimoriRepository.isAuthorized) {
getString(R.string.logged_in_as, shikimoriRepository.getCachedUser()?.nickname)
} else {
getString(R.string.disabled)
}
}
private fun launchShikimoriAuth() {
runCatching {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(shikimoriRepository.oauthUrl)
startActivity(intent)
}.onFailure {
Snackbar.make(listView, it.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
}
}
} }

@ -3,6 +3,7 @@ package org.koitharu.kotatsu.settings
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
@ -22,6 +23,7 @@ import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.databinding.ActivitySettingsBinding import org.koitharu.kotatsu.databinding.ActivitySettingsBinding
import org.koitharu.kotatsu.main.ui.AppBarOwner import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.scrobbling.shikimori.ui.ShikimoriSettingsFragment
import org.koitharu.kotatsu.utils.ext.isScrolledToTop import org.koitharu.kotatsu.utils.ext.isScrolledToTop
class SettingsActivity : class SettingsActivity :
@ -89,7 +91,7 @@ class SettingsActivity :
val fm = supportFragmentManager val fm = supportFragmentManager
val fragment = fm.fragmentFactory.instantiate(classLoader, pref.fragment ?: return false) val fragment = fm.fragmentFactory.instantiate(classLoader, pref.fragment ?: return false)
fragment.arguments = pref.extras fragment.arguments = pref.extras
// fragment.setTargetFragment(caller, 0) fragment.setTargetFragment(caller, 0)
openFragment(fragment) openFragment(fragment)
return true return true
} }
@ -116,8 +118,11 @@ class SettingsActivity :
private fun openDefaultFragment() { private fun openDefaultFragment() {
val fragment = when (intent?.action) { val fragment = when (intent?.action) {
Intent.ACTION_VIEW -> handleUri(intent.data) ?: return
ACTION_READER -> ReaderSettingsFragment() ACTION_READER -> ReaderSettingsFragment()
ACTION_SUGGESTIONS -> SuggestionsSettingsFragment() ACTION_SUGGESTIONS -> SuggestionsSettingsFragment()
ACTION_SHIKIMORI -> ShikimoriSettingsFragment()
ACTION_TRACKER -> TrackerSettingsFragment()
ACTION_SOURCE -> SourceSettingsFragment.newInstance( ACTION_SOURCE -> SourceSettingsFragment.newInstance(
intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource ?: MangaSource.LOCAL intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource ?: MangaSource.LOCAL
) )
@ -129,23 +134,44 @@ class SettingsActivity :
} }
} }
private fun handleUri(uri: Uri?): Fragment? {
when (uri?.host) {
HOST_SHIKIMORI_AUTH ->
return ShikimoriSettingsFragment.newInstance(authCode = uri.getQueryParameter("code"))
}
finishAfterTransition()
return null
}
companion object { companion object {
private const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS" private const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS"
private const val ACTION_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS" private const val ACTION_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS"
private const val ACTION_TRACKER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_TRACKER"
private const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS" private const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS"
private const val ACTION_SHIKIMORI = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SHIKIMORI_SETTINGS"
private const val EXTRA_SOURCE = "source" private const val EXTRA_SOURCE = "source"
private const val HOST_SHIKIMORI_AUTH = "shikimori-auth"
fun newIntent(context: Context) = Intent(context, SettingsActivity::class.java) fun newIntent(context: Context) = Intent(context, SettingsActivity::class.java)
fun newReaderSettingsIntent(context: Context) = fun newReaderSettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java) Intent(context, SettingsActivity::class.java)
.setAction(ACTION_READER) .setAction(ACTION_READER)
fun newShikimoriSettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_SHIKIMORI)
fun newSuggestionsSettingsIntent(context: Context) = fun newSuggestionsSettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java) Intent(context, SettingsActivity::class.java)
.setAction(ACTION_SUGGESTIONS) .setAction(ACTION_SUGGESTIONS)
fun newTrackerSettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_TRACKER)
fun newSourceSettingsIntent(context: Context, source: MangaSource) = fun newSourceSettingsIntent(context: Context, source: MangaSource) =
Intent(context, SettingsActivity::class.java) Intent(context, SettingsActivity::class.java)
.setAction(ACTION_SOURCE) .setAction(ACTION_SOURCE)

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

Loading…
Cancel
Save