Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c5314fe59 | ||
|
|
96be49aa83 | ||
|
|
28b556121b | ||
|
|
558c19e526 | ||
|
|
59c2d20311 | ||
|
|
fa1f2cbf51 | ||
|
|
de8739f143 | ||
|
|
9aa28f6fd2 | ||
|
|
a2b1699047 | ||
|
|
2dce65a448 | ||
|
|
3d68d7c818 | ||
|
|
4987d43042 | ||
|
|
684b494edb | ||
|
|
714b708fa9 | ||
|
|
c462c19a8b | ||
|
|
e34acf010e | ||
|
|
0fb29174c5 | ||
|
|
ca45774cdb | ||
|
|
cccc2c4fe4 | ||
|
|
c73af2d45f | ||
|
|
acf7102d07 | ||
|
|
75fcd31758 | ||
|
|
7bffb5f22d | ||
|
|
c220bd5517 | ||
|
|
7c827b45d5 | ||
|
|
e91d9ee38e | ||
|
|
b6a86a6538 | ||
|
|
16b6b6c071 | ||
|
|
695feef4a6 | ||
|
|
6bf4e0cf89 | ||
|
|
44d8d0f246 | ||
|
|
e617e8d6d3 | ||
|
|
1f411b7530 | ||
|
|
d64bd9d9d3 | ||
|
|
f33dc8f797 | ||
|
|
e63ae12c8c | ||
|
|
cbd3d439cd | ||
|
|
83eb0d9f23 | ||
|
|
3c739eed8e | ||
|
|
d77646adf1 | ||
|
|
5b5e6cba57 | ||
|
|
8fc9b27840 | ||
|
|
fa536220eb | ||
|
|
98f16774c4 | ||
|
|
ce8f57c3ca | ||
|
|
be66106336 | ||
|
|
16c8641a07 | ||
|
|
d3e9ce874a | ||
|
|
aaf9c6a0bf | ||
|
|
c2276eb2cb | ||
|
|
5fbae1256b | ||
|
|
d61ba80bf6 | ||
|
|
74c9fa9488 | ||
|
|
ce732ccca0 | ||
|
|
6b99e360e0 | ||
|
|
1c73d54a94 | ||
|
|
36e21caf96 | ||
|
|
f7f9c53466 | ||
|
|
3f2ee2a925 | ||
|
|
b1c069f62f | ||
|
|
22d48fce8f | ||
|
|
6b2666c701 | ||
|
|
414f438762 | ||
|
|
54f60040b5 | ||
|
|
b0515033da | ||
|
|
d37eb07301 | ||
|
|
c2fa27712c | ||
|
|
10a2589c10 | ||
|
|
05eb96e7c0 | ||
|
|
15a08ad6ae | ||
|
|
3e437c2ecb | ||
|
|
fea667b87c | ||
|
|
93eaaac084 | ||
|
|
a60df582a2 | ||
|
|
aec1d4e0d6 | ||
|
|
507f2e883c | ||
|
|
5e82c75893 | ||
|
|
9c9a389aa5 | ||
|
|
1b3af70690 | ||
|
|
2e17efe82b | ||
|
|
5bed854b9c | ||
|
|
7262b403f0 | ||
|
|
a6fcbefc7b | ||
|
|
7f9ea0efa0 | ||
|
|
934861322e | ||
|
|
e008fbab9b | ||
|
|
2cd9ea19fd | ||
|
|
699a249620 | ||
|
|
6c87d5b0bc | ||
|
|
c92bdae842 | ||
|
|
6ca9608a80 | ||
|
|
8f9c0cbff1 | ||
|
|
cc6b114e4d | ||
|
|
3d5c2123d4 | ||
|
|
36b4e16b7c | ||
|
|
3ebd074e93 | ||
|
|
e9b2b545a4 | ||
|
|
cca6d5fa04 | ||
|
|
36a7a3ebbc | ||
|
|
48ec9a1ea9 | ||
|
|
76a9a0d1ab | ||
|
|
f2175b40c0 | ||
|
|
85b992ca32 | ||
|
|
41fb351fe0 |
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: ⚠️ Source issue
|
||||
url: https://github.com/nv95/kotatsu-parsers/issues/new
|
||||
about: Issues and requests for sources should be opened in the kotatsu-parsers repository instead
|
||||
93
.github/ISSUE_TEMPLATE/report_issue.yml
vendored
Normal file
93
.github/ISSUE_TEMPLATE/report_issue.yml
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
name: 🐞 Issue report
|
||||
description: Report an issue in Kotatsu
|
||||
labels: [bug]
|
||||
body:
|
||||
|
||||
- type: textarea
|
||||
id: reproduce-steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Provide an example of the issue.
|
||||
placeholder: |
|
||||
Example:
|
||||
1. First step
|
||||
2. Second step
|
||||
3. Issue here
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: Explain what you should expect to happen.
|
||||
placeholder: |
|
||||
Example:
|
||||
"This should happen..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual-behavior
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
description: Explain what actually happens.
|
||||
placeholder: |
|
||||
Example:
|
||||
"This happened instead..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: kotatsu-version
|
||||
attributes:
|
||||
label: Kotatsu version
|
||||
description: You can find your Kotatsu version in **Settings → About**.
|
||||
placeholder: |
|
||||
Example: "3.2"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: android-version
|
||||
attributes:
|
||||
label: Android version
|
||||
description: You can find this somewhere in your Android settings.
|
||||
placeholder: |
|
||||
Example: "Android 12"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: device
|
||||
attributes:
|
||||
label: Device
|
||||
description: List your device and model.
|
||||
placeholder: |
|
||||
Example: "LG Nexus 5X"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: other-details
|
||||
attributes:
|
||||
label: Other details
|
||||
placeholder: |
|
||||
Additional details and attachments.
|
||||
|
||||
- type: checkboxes
|
||||
id: acknowledgements
|
||||
attributes:
|
||||
label: Acknowledgements
|
||||
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
|
||||
options:
|
||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||
required: true
|
||||
- label: I have written a short but informative title.
|
||||
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).
|
||||
required: true
|
||||
- label: I have updated the app to version **[3.2](https://github.com/nv95/Kotatsu/releases/latest)**.
|
||||
required: true
|
||||
- label: I will fill out all of the requested information in this form.
|
||||
required: true
|
||||
39
.github/ISSUE_TEMPLATE/request_feature.yml
vendored
Normal file
39
.github/ISSUE_TEMPLATE/request_feature.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: ⭐ Feature request
|
||||
description: Suggest a feature to improve Kotatsu
|
||||
labels: [feature request]
|
||||
body:
|
||||
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
attributes:
|
||||
label: Describe your suggested feature
|
||||
description: How can Kotatsu be improved?
|
||||
placeholder: |
|
||||
Example:
|
||||
"It should work like this..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: other-details
|
||||
attributes:
|
||||
label: Other details
|
||||
placeholder: |
|
||||
Additional details and attachments.
|
||||
|
||||
- type: checkboxes
|
||||
id: acknowledgements
|
||||
attributes:
|
||||
label: Acknowledgements
|
||||
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
|
||||
options:
|
||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||
required: true
|
||||
- label: I have written a short but informative title.
|
||||
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).
|
||||
required: true
|
||||
- label: I have updated the app to version **[3.2](https://github.com/nv95/Kotatsu/releases/latest)**.
|
||||
required: true
|
||||
- label: I will fill out all of the requested information in this form.
|
||||
required: true
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,6 +9,7 @@
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
/.idea/kotlinScripting.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
|
||||
2
.idea/gradle.xml
generated
2
.idea/gradle.xml
generated
@@ -7,7 +7,7 @@
|
||||
<option name="testRunner" value="GRADLE" />
|
||||
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="Embedded JDK" />
|
||||
<option name="gradleJvm" value="Android Studio default JDK" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
|
||||
4
.idea/inspectionProfiles/Project_Default.xml
generated
4
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -1,8 +1,10 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="BooleanLiteralArgument" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="BooleanLiteralArgument" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="Destructure" enabled="true" level="INFO" enabled_by_default="true" />
|
||||
<inspection_tool class="KeySetIterationMayUseEntrySet" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="ReplaceCollectionCountWithSize" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="TrailingComma" enabled="true" level="INFORMATION" enabled_by_default="true" />
|
||||
<inspection_tool class="ZeroLengthArrayInitialization" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
</profile>
|
||||
|
||||
10
README.md
10
README.md
@@ -25,6 +25,8 @@ Download APK from Github Releases:
|
||||
* Tablet-optimized material design UI
|
||||
* Standard and Webtoon-optimized reader
|
||||
* Notifications about new chapters with updates feed
|
||||
* Available in multiple languages
|
||||
* Password protect access to the app
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -35,6 +37,14 @@ Download APK from Github Releases:
|
||||
|  |  |
|
||||
|-----------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|
|
||||
|
||||
### Localization
|
||||
|
||||
<a href="https://hosted.weblate.org/engage/kotatsu/">
|
||||
<img src="https://hosted.weblate.org/widgets/kotatsu/-/287x66-white.png" alt="Translation status" />
|
||||
</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
|
||||
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 32
|
||||
versionCode 400
|
||||
versionName '3.0'
|
||||
versionCode 405
|
||||
versionName '3.2.1'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -65,12 +65,12 @@ android {
|
||||
}
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
|
||||
implementation('com.github.nv95:kotatsu-parsers:3ea7e92e64') {
|
||||
implementation('com.github.nv95:kotatsu-parsers:090ad4b256') {
|
||||
exclude group: 'org.json', module: 'json'
|
||||
}
|
||||
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1'
|
||||
|
||||
implementation 'androidx.core:core-ktx:1.7.0'
|
||||
implementation 'androidx.activity:activity-ktx:1.4.0'
|
||||
@@ -86,7 +86,7 @@ dependencies {
|
||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.0'
|
||||
implementation 'androidx.work:work-runtime-ktx:2.7.1'
|
||||
implementation 'com.google.android.material:material:1.6.0-beta01'
|
||||
implementation 'com.google.android.material:material:1.6.0-rc01'
|
||||
//noinspection LifecycleAnnotationProcessorWithJava8
|
||||
kapt 'androidx.lifecycle:lifecycle-compiler:2.4.1'
|
||||
|
||||
@@ -100,7 +100,7 @@ dependencies {
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
||||
|
||||
implementation 'io.insert-koin:koin-android:3.1.5'
|
||||
implementation 'io.insert-koin:koin-android:3.1.6'
|
||||
implementation 'io.coil-kt:coil-base:1.4.0'
|
||||
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
|
||||
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
||||
@@ -108,7 +108,7 @@ dependencies {
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1'
|
||||
testImplementation 'io.insert-koin:koin-test-junit4:3.1.5'
|
||||
|
||||
androidTestImplementation 'androidx.test:runner:1.4.0'
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import java.util.*
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaParser
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.model.*
|
||||
|
||||
/**
|
||||
* This parser is just for parser development, it should not be used in releases
|
||||
*/
|
||||
class DummyParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.DUMMY) {
|
||||
|
||||
override val configKeyDomain: ConfigKey.Domain
|
||||
get() = ConfigKey.Domain("", null)
|
||||
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = EnumSet.allOf(SortOrder::class.java)
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun getList(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder?
|
||||
): List<Manga> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaParser
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.newParser
|
||||
|
||||
fun MangaParser(source: MangaSource, loaderContext: MangaLoaderContext): MangaParser {
|
||||
return if (source == MangaSource.DUMMY) {
|
||||
DummyParser(loaderContext)
|
||||
} else {
|
||||
source.newParser(loaderContext)
|
||||
}
|
||||
}
|
||||
@@ -8,23 +8,23 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28"
|
||||
tools:ignore="ScopedStorage" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<application
|
||||
android:name="org.koitharu.kotatsu.KotatsuApp"
|
||||
android:allowBackup="true"
|
||||
android:fullBackupContent="@xml/backup_descriptor"
|
||||
android:backupAgent="org.koitharu.kotatsu.settings.backup.AppBackupAgent"
|
||||
android:dataExtractionRules="@xml/backup_rules"
|
||||
android:fullBackupContent="@xml/backup_content"
|
||||
android:fullBackupOnly="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Kotatsu"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
tools:ignore="UnusedAttribute">
|
||||
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.main.ui.MainActivity"
|
||||
android:exported="true">
|
||||
@@ -58,15 +58,6 @@
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.settings.SettingsActivity"
|
||||
android:label="@string/settings" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/settings">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MANAGE_NETWORK_USAGE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.browser.BrowserActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||
@@ -104,12 +95,14 @@
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
|
||||
android:launchMode="singleTop"
|
||||
android:label="@string/downloads" />
|
||||
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity"/>
|
||||
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
<service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" />
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
|
||||
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
||||
|
||||
@@ -2,22 +2,19 @@ package org.koitharu.kotatsu.base.domain
|
||||
|
||||
import androidx.room.withTransaction
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.*
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
||||
|
||||
class MangaDataRepository(private val db: MangaDatabase) {
|
||||
|
||||
suspend fun savePreferences(manga: Manga, mode: ReaderMode) {
|
||||
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
|
||||
val tags = manga.tags.toEntities()
|
||||
db.withTransaction {
|
||||
db.tagsDao.upsert(tags)
|
||||
db.mangaDao.upsert(MangaEntity.from(manga), tags)
|
||||
db.mangaDao.upsert(manga.toEntity(), tags)
|
||||
db.preferencesDao.upsert(
|
||||
MangaPrefsEntity(
|
||||
mangaId = manga.id,
|
||||
@@ -37,21 +34,19 @@ class MangaDataRepository(private val db: MangaDatabase) {
|
||||
|
||||
suspend fun resolveIntent(intent: MangaIntent): Manga? = when {
|
||||
intent.manga != null -> intent.manga
|
||||
intent.mangaId != 0L -> db.mangaDao.find(intent.mangaId)?.toManga()
|
||||
intent.mangaId != 0L -> findMangaById(intent.mangaId)
|
||||
else -> null // TODO resolve uri
|
||||
}
|
||||
|
||||
suspend fun storeManga(manga: Manga) {
|
||||
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
|
||||
val tags = manga.tags.toEntities()
|
||||
db.withTransaction {
|
||||
db.tagsDao.upsert(tags)
|
||||
db.mangaDao.upsert(MangaEntity.from(manga), tags)
|
||||
db.mangaDao.upsert(manga.toEntity(), tags)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun findTags(source: MangaSource): Set<MangaTag> {
|
||||
return db.tagsDao.findTags(source.name).mapToSet {
|
||||
it.toMangaTag()
|
||||
}
|
||||
return db.tagsDao.findTags(source.name).toMangaTags()
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.utils.ext.medianOrNull
|
||||
import org.koitharu.kotatsu.parsers.util.medianOrNull
|
||||
import java.io.InputStream
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.view.KeyEvent
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.appcompat.widget.ActionBarContextView
|
||||
@@ -20,11 +21,13 @@ import androidx.viewbinding.ViewBinding
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
|
||||
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
|
||||
abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
|
||||
abstract class BaseActivity<B : ViewBinding> :
|
||||
AppCompatActivity(),
|
||||
WindowInsetsDelegate.WindowInsetsListener {
|
||||
|
||||
protected lateinit var binding: B
|
||||
@@ -36,6 +39,8 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
|
||||
@Suppress("LeakingThis")
|
||||
protected val insetsDelegate = WindowInsetsDelegate(this)
|
||||
|
||||
val actionModeDelegate = ActionModeDelegate()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
val settings = get<AppSettings>()
|
||||
when {
|
||||
@@ -90,8 +95,10 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
|
||||
return isNight && get<AppSettings>().isAmoledTheme
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
override fun onSupportActionModeStarted(mode: ActionMode) {
|
||||
super.onSupportActionModeStarted(mode)
|
||||
actionModeDelegate.onSupportActionModeStarted(mode)
|
||||
val insets = ViewCompat.getRootWindowInsets(binding.root)
|
||||
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
|
||||
val view = findViewById<ActionBarContextView?>(androidx.appcompat.R.id.action_mode_bar)
|
||||
@@ -100,6 +107,12 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
|
||||
}
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
override fun onSupportActionModeFinished(mode: ActionMode) {
|
||||
super.onSupportActionModeFinished(mode)
|
||||
actionModeDelegate.onSupportActionModeFinished(mode)
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if ( // https://issuetracker.google.com/issues/139738913
|
||||
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q &&
|
||||
@@ -111,4 +124,4 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,12 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
|
||||
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
|
||||
abstract class BaseFragment<B : ViewBinding> : Fragment(),
|
||||
abstract class BaseFragment<B : ViewBinding> :
|
||||
Fragment(),
|
||||
WindowInsetsDelegate.WindowInsetsListener {
|
||||
|
||||
private var viewBinding: B? = null
|
||||
@@ -23,6 +25,9 @@ abstract class BaseFragment<B : ViewBinding> : Fragment(),
|
||||
@Suppress("LeakingThis")
|
||||
protected val insetsDelegate = WindowInsetsDelegate(this)
|
||||
|
||||
protected val actionModeDelegate: ActionModeDelegate
|
||||
get() = (requireActivity() as BaseActivity<*>).actionModeDelegate
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
@@ -47,4 +52,4 @@ abstract class BaseFragment<B : ViewBinding> : Fragment(),
|
||||
protected fun bindingOrNull() = viewBinding
|
||||
|
||||
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,18 @@ import androidx.annotation.CallSuper
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
|
||||
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
import org.koitharu.kotatsu.settings.SettingsHeadersFragment
|
||||
|
||||
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : PreferenceFragmentCompat(),
|
||||
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
PreferenceFragmentCompat(),
|
||||
WindowInsetsDelegate.WindowInsetsListener,
|
||||
RecyclerViewOwner {
|
||||
|
||||
@@ -39,16 +43,20 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : Pre
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (titleId != 0) {
|
||||
activity?.setTitle(titleId)
|
||||
setTitle(getString(titleId))
|
||||
}
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
listView.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
bottom = insets.bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UsePropertyAccessSyntax")
|
||||
protected fun setTitle(title: CharSequence) {
|
||||
(parentFragment as? SettingsHeadersFragment)?.setTitle(title)
|
||||
?: activity?.setTitle(title)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
package org.koitharu.kotatsu.base.ui
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
import kotlinx.coroutines.*
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.base.ui.util.CountedBooleanLiveData
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
|
||||
abstract class BaseViewModel : ViewModel() {
|
||||
|
||||
val onError = SingleLiveEvent<Throwable>()
|
||||
val isLoading = MutableLiveData(false)
|
||||
val isLoading = CountedBooleanLiveData()
|
||||
|
||||
protected fun launchJob(
|
||||
context: CoroutineContext = EmptyCoroutineContext,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.koitharu.kotatsu.base.ui
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
abstract class CoroutineIntentService : BaseService() {
|
||||
|
||||
private val mutex = Mutex()
|
||||
protected open val dispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
launchCoroutine(intent, startId)
|
||||
return Service.START_REDELIVER_INTENT
|
||||
}
|
||||
|
||||
private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch {
|
||||
mutex.withLock {
|
||||
try {
|
||||
withContext(dispatcher) {
|
||||
processIntent(intent)
|
||||
}
|
||||
} finally {
|
||||
stopSelf(startId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract suspend fun processIntent(intent: Intent?)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.base.ui.dialog
|
||||
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
@@ -12,7 +13,6 @@ import kotlinx.coroutines.runBlocking
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.ItemStorageBinding
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.utils.ext.inflate
|
||||
import java.io.File
|
||||
|
||||
class StorageSelectDialog private constructor(private val delegate: AlertDialog) :
|
||||
@@ -66,7 +66,7 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
|
||||
val volumes = getAvailableVolumes(storageManager)
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = convertView ?: parent.inflate(R.layout.item_storage)
|
||||
val view = convertView ?: LayoutInflater.from(parent.context).inflate(R.layout.item_storage, parent, false)
|
||||
val binding = (view.tag as? ItemStorageBinding) ?: ItemStorageBinding.bind(view).also {
|
||||
view.tag = it
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package org.koitharu.kotatsu.base.ui.list.decor
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.view.View
|
||||
import androidx.core.view.children
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.NO_ID
|
||||
|
||||
abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
|
||||
|
||||
private val bounds = Rect()
|
||||
private val boundsF = RectF()
|
||||
private val selection = HashSet<Long>()
|
||||
|
||||
protected var hasBackground: Boolean = true
|
||||
protected var hasForeground: Boolean = false
|
||||
protected var isIncludeDecorAndMargins: Boolean = true
|
||||
|
||||
val checkedItemsCount: Int
|
||||
get() = selection.size
|
||||
|
||||
val checkedItemsIds: Set<Long>
|
||||
get() = selection
|
||||
|
||||
fun toggleItemChecked(id: Long) {
|
||||
if (!selection.remove(id)) {
|
||||
selection.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun setItemIsChecked(id: Long, isChecked: Boolean) {
|
||||
if (isChecked) {
|
||||
selection.add(id)
|
||||
} else {
|
||||
selection.remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun checkAll(ids: Collection<Long>) {
|
||||
selection.addAll(ids)
|
||||
}
|
||||
|
||||
fun clearSelection() {
|
||||
selection.clear()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
if (hasBackground) {
|
||||
doDraw(canvas, parent, state, false)
|
||||
} else {
|
||||
super.onDraw(canvas, parent, state)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
if (hasForeground) {
|
||||
doDraw(canvas, parent, state, true)
|
||||
} else {
|
||||
super.onDrawOver(canvas, parent, state)
|
||||
}
|
||||
}
|
||||
|
||||
private fun doDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State, isOver: Boolean) {
|
||||
val checkpoint = canvas.save()
|
||||
if (parent.clipToPadding) {
|
||||
canvas.clipRect(
|
||||
parent.paddingLeft, parent.paddingTop, parent.width - parent.paddingRight,
|
||||
parent.height - parent.paddingBottom
|
||||
)
|
||||
}
|
||||
|
||||
for (child in parent.children) {
|
||||
val itemId = getItemId(parent, child)
|
||||
if (itemId != NO_ID && itemId in selection) {
|
||||
if (isIncludeDecorAndMargins) {
|
||||
parent.getDecoratedBoundsWithMargins(child, bounds)
|
||||
} else {
|
||||
bounds.set(child.left, child.top, child.right, child.bottom)
|
||||
}
|
||||
boundsF.set(bounds)
|
||||
boundsF.offset(child.translationX, child.translationY)
|
||||
if (isOver) {
|
||||
onDrawForeground(canvas, parent, child, boundsF, state)
|
||||
} else {
|
||||
onDrawBackground(canvas, parent, child, boundsF, state)
|
||||
}
|
||||
}
|
||||
}
|
||||
canvas.restoreToCount(checkpoint)
|
||||
}
|
||||
|
||||
protected open fun getItemId(parent: RecyclerView, child: View) = parent.getChildItemId(child)
|
||||
|
||||
protected open fun onDrawBackground(
|
||||
canvas: Canvas,
|
||||
parent: RecyclerView,
|
||||
child: View,
|
||||
bounds: RectF,
|
||||
state: RecyclerView.State,
|
||||
) = Unit
|
||||
|
||||
protected open fun onDrawForeground(
|
||||
canvas: Canvas,
|
||||
parent: RecyclerView,
|
||||
child: View,
|
||||
bounds: RectF,
|
||||
state: RecyclerView.State,
|
||||
) = Unit
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.koitharu.kotatsu.base.ui.list.decor
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.util.SparseIntArray
|
||||
import android.view.View
|
||||
import androidx.core.util.getOrDefault
|
||||
import androidx.core.util.set
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
class TypedSpacingItemDecoration(
|
||||
vararg spacingMapping: Pair<Int, Int>,
|
||||
private val fallbackSpacing: Int = 0,
|
||||
) : RecyclerView.ItemDecoration() {
|
||||
|
||||
private val mapping = SparseIntArray(spacingMapping.size)
|
||||
|
||||
init {
|
||||
spacingMapping.forEach { (k, v) -> mapping[k] = v }
|
||||
}
|
||||
|
||||
override fun getItemOffsets(
|
||||
outRect: Rect,
|
||||
view: View,
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State
|
||||
) {
|
||||
val itemType = parent.getChildViewHolder(view)?.itemViewType
|
||||
val spacing = if (itemType == null) {
|
||||
fallbackSpacing
|
||||
} else {
|
||||
mapping.getOrDefault(itemType, fallbackSpacing)
|
||||
}
|
||||
outRect.set(spacing, spacing, spacing, spacing)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.koitharu.kotatsu.base.ui.util
|
||||
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
|
||||
class ActionModeDelegate {
|
||||
|
||||
private var activeActionMode: ActionMode? = null
|
||||
private var listeners: MutableList<ActionModeListener>? = null
|
||||
|
||||
val isActionModeStarted: Boolean
|
||||
get() = activeActionMode != null
|
||||
|
||||
fun onSupportActionModeStarted(mode: ActionMode) {
|
||||
activeActionMode = mode
|
||||
listeners?.forEach { it.onActionModeStarted(mode) }
|
||||
}
|
||||
|
||||
fun onSupportActionModeFinished(mode: ActionMode) {
|
||||
activeActionMode = null
|
||||
listeners?.forEach { it.onActionModeFinished(mode) }
|
||||
}
|
||||
|
||||
fun addListener(listener: ActionModeListener) {
|
||||
if (listeners == null) {
|
||||
listeners = ArrayList()
|
||||
}
|
||||
checkNotNull(listeners).add(listener)
|
||||
}
|
||||
|
||||
fun removeListener(listener: ActionModeListener) {
|
||||
listeners?.remove(listener)
|
||||
}
|
||||
|
||||
fun addListener(listener: ActionModeListener, owner: LifecycleOwner) {
|
||||
addListener(listener)
|
||||
owner.lifecycle.addObserver(ListenerLifecycleObserver(listener))
|
||||
}
|
||||
|
||||
private inner class ListenerLifecycleObserver(
|
||||
private val listener: ActionModeListener,
|
||||
) : DefaultLifecycleObserver {
|
||||
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
super.onDestroy(owner)
|
||||
removeListener(listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.koitharu.kotatsu.base.ui.util
|
||||
|
||||
import androidx.appcompat.view.ActionMode
|
||||
|
||||
interface ActionModeListener {
|
||||
|
||||
fun onActionModeStarted(mode: ActionMode)
|
||||
|
||||
fun onActionModeFinished(mode: ActionMode)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package org.koitharu.kotatsu.base.ui.util
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
|
||||
class CountedBooleanLiveData : MutableLiveData<Boolean>(false) {
|
||||
|
||||
private var counter = 0
|
||||
|
||||
override fun setValue(value: Boolean) {
|
||||
if (value) {
|
||||
counter++
|
||||
} else {
|
||||
counter--
|
||||
}
|
||||
val newValue = counter > 0
|
||||
if (newValue != this.value) {
|
||||
super.setValue(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.koitharu.kotatsu.base.ui.util
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout.Behavior
|
||||
import androidx.core.view.ViewCompat
|
||||
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
|
||||
|
||||
class ShrinkOnScrollBehavior : Behavior<ExtendedFloatingActionButton> {
|
||||
|
||||
@Suppress("unused") constructor() : super()
|
||||
@Suppress("unused") constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
|
||||
|
||||
override fun onStartNestedScroll(
|
||||
coordinatorLayout: CoordinatorLayout,
|
||||
child: ExtendedFloatingActionButton,
|
||||
directTargetChild: View,
|
||||
target: View,
|
||||
axes: Int,
|
||||
type: Int
|
||||
): Boolean {
|
||||
return axes == ViewCompat.SCROLL_AXIS_VERTICAL
|
||||
}
|
||||
|
||||
override fun onNestedScroll(
|
||||
coordinatorLayout: CoordinatorLayout,
|
||||
child: ExtendedFloatingActionButton,
|
||||
target: View,
|
||||
dxConsumed: Int,
|
||||
dyConsumed: Int,
|
||||
dxUnconsumed: Int,
|
||||
dyUnconsumed: Int,
|
||||
type: Int,
|
||||
consumed: IntArray
|
||||
) {
|
||||
if (dyConsumed > 0) {
|
||||
if (child.isExtended) {
|
||||
child.shrink()
|
||||
}
|
||||
} else if (dyConsumed < 0) {
|
||||
if (!child.isExtended) {
|
||||
child.extend()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import android.os.Parcelable.Creator
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.Checkable
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
@@ -61,6 +62,12 @@ class CheckableImageView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
class ToggleOnClickListener : OnClickListener {
|
||||
override fun onClick(view: View) {
|
||||
(view as? Checkable)?.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
fun interface OnCheckedChangeListener {
|
||||
|
||||
fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean)
|
||||
|
||||
@@ -13,12 +13,12 @@ import android.graphics.drawable.shapes.RectShape
|
||||
import android.util.AttributeSet
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.appcompat.widget.AppCompatCheckedTextView
|
||||
import androidx.core.content.res.use
|
||||
import androidx.core.content.withStyledAttributes
|
||||
import com.google.android.material.ripple.RippleUtils
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.utils.ext.getThemeColorStateList
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
class ListItemTextView @JvmOverloads constructor(
|
||||
@@ -119,8 +119,7 @@ class ListItemTextView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
private fun getRippleColorFallback(context: Context): ColorStateList {
|
||||
return context.obtainStyledAttributes(intArrayOf(android.R.attr.colorControlHighlight)).use {
|
||||
it.getColorStateList(0)
|
||||
} ?: ColorStateList.valueOf(Color.TRANSPARENT)
|
||||
return context.getThemeColorStateList(android.R.attr.colorControlHighlight)
|
||||
?: ColorStateList.valueOf(Color.TRANSPARENT)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package org.koitharu.kotatsu.base.ui.widgets
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
|
||||
class WindowInsetHolder @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0,
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
private var desiredHeight = 0
|
||||
private var desiredWidth = 0
|
||||
|
||||
@SuppressLint("RtlHardcoded")
|
||||
override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets {
|
||||
val barsInsets = WindowInsetsCompat.toWindowInsetsCompat(insets, this)
|
||||
.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
val gravity = getLayoutGravity()
|
||||
val newWidth = when (gravity and Gravity.HORIZONTAL_GRAVITY_MASK) {
|
||||
Gravity.LEFT -> barsInsets.left
|
||||
Gravity.RIGHT -> barsInsets.right
|
||||
else -> 0
|
||||
}
|
||||
val newHeight = when (gravity and Gravity.VERTICAL_GRAVITY_MASK) {
|
||||
Gravity.TOP -> barsInsets.top
|
||||
Gravity.BOTTOM -> barsInsets.bottom
|
||||
else -> 0
|
||||
}
|
||||
if (newWidth != desiredWidth || newHeight != desiredHeight) {
|
||||
desiredWidth = newWidth
|
||||
desiredHeight = newHeight
|
||||
requestLayout()
|
||||
}
|
||||
return super.dispatchApplyWindowInsets(insets)
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
|
||||
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
|
||||
super.onMeasure(
|
||||
if (desiredWidth == 0 || widthMode == MeasureSpec.EXACTLY) {
|
||||
widthMeasureSpec
|
||||
} else {
|
||||
MeasureSpec.makeMeasureSpec(desiredWidth, widthMode)
|
||||
},
|
||||
if (desiredHeight == 0 || heightMode == MeasureSpec.EXACTLY) {
|
||||
heightMeasureSpec
|
||||
} else {
|
||||
MeasureSpec.makeMeasureSpec(desiredHeight, heightMode)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun getLayoutGravity(): Int {
|
||||
return when (val lp = layoutParams) {
|
||||
is FrameLayout.LayoutParams -> lp.gravity
|
||||
is LinearLayout.LayoutParams -> lp.gravity
|
||||
is CoordinatorLayout.LayoutParams -> lp.gravity
|
||||
else -> Gravity.NO_GRAVITY
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,11 @@ import android.view.MenuItem
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import com.google.android.material.R as materialR
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.network.UserAgentInterceptor
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
|
||||
@@ -28,6 +29,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
}
|
||||
with(binding.webView.settings) {
|
||||
javaScriptEnabled = true
|
||||
userAgentString = UserAgentInterceptor.userAgent
|
||||
}
|
||||
binding.webView.webViewClient = BrowserClient(this)
|
||||
binding.webView.webChromeClient = ProgressChromeClient(binding.progressBar)
|
||||
|
||||
@@ -2,15 +2,11 @@ package org.koitharu.kotatsu.browser
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.webkit.WebView
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.koitharu.kotatsu.core.network.WebViewClientCompat
|
||||
|
||||
class BrowserClient(private val callback: BrowserCallback) : WebViewClientCompat(), KoinComponent {
|
||||
|
||||
private val okHttp by inject<OkHttpClient>(mode = LazyThreadSafetyMode.SYNCHRONIZED)
|
||||
|
||||
override fun onPageFinished(webView: WebView, url: String) {
|
||||
super.onPageFinished(webView, url)
|
||||
callback.onLoadingStateChanged(isLoading = false)
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.utils.MutableZipFile
|
||||
import org.koitharu.kotatsu.utils.ext.format
|
||||
|
||||
class BackupArchive(file: File) : MutableZipFile(file) {
|
||||
|
||||
init {
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun put(entry: BackupEntry) {
|
||||
put(entry.name, entry.data.toString(2))
|
||||
}
|
||||
|
||||
suspend fun getEntry(name: String): BackupEntry {
|
||||
val json = withContext(Dispatchers.Default) {
|
||||
JSONArray(getContent(name))
|
||||
}
|
||||
return BackupEntry(name, json)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val DIR_BACKUPS = "backups"
|
||||
|
||||
suspend fun createNew(context: Context): BackupArchive = runInterruptible(Dispatchers.IO) {
|
||||
val dir = context.run {
|
||||
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
||||
}
|
||||
dir.mkdirs()
|
||||
val filename = buildString {
|
||||
append(context.getString(R.string.app_name).lowercase(Locale.ROOT))
|
||||
append('_')
|
||||
append(Date().format("ddMMyyyy"))
|
||||
append(".bak")
|
||||
}
|
||||
BackupArchive(File(dir, filename))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.Closeable
|
||||
import org.json.JSONArray
|
||||
import java.io.File
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
class BackupZipInput(val file: File) : Closeable {
|
||||
|
||||
private val zipFile = ZipFile(file)
|
||||
|
||||
suspend fun getEntry(name: String): BackupEntry = runInterruptible(Dispatchers.IO) {
|
||||
val entry = zipFile.getEntry(name)
|
||||
val json = zipFile.getInputStream(entry).use {
|
||||
JSONArray(it.bufferedReader().readText())
|
||||
}
|
||||
BackupEntry(name, json)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
zipFile.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.Closeable
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.zip.ZipOutput
|
||||
import org.koitharu.kotatsu.utils.ext.format
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import java.util.zip.Deflater
|
||||
|
||||
class BackupZipOutput(val file: File) : Closeable {
|
||||
|
||||
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
|
||||
|
||||
suspend fun put(entry: BackupEntry) {
|
||||
output.put(entry.name, entry.data.toString(2))
|
||||
}
|
||||
|
||||
suspend fun finish() {
|
||||
output.finish()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
output.close()
|
||||
}
|
||||
}
|
||||
|
||||
private const val DIR_BACKUPS = "backups"
|
||||
|
||||
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
|
||||
val dir = context.run {
|
||||
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
||||
}
|
||||
dir.mkdirs()
|
||||
val filename = buildString {
|
||||
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
||||
append('_')
|
||||
append(Date().format("ddMMyyyy"))
|
||||
append(".bk.zip")
|
||||
}
|
||||
BackupZipOutput(File(dir, filename))
|
||||
}
|
||||
@@ -1,28 +1,9 @@
|
||||
package org.koitharu.kotatsu.core.db
|
||||
|
||||
import androidx.room.Room
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.core.db.migrations.*
|
||||
|
||||
val databaseModule
|
||||
get() = module {
|
||||
single {
|
||||
Room.databaseBuilder(
|
||||
androidContext(),
|
||||
MangaDatabase::class.java,
|
||||
"kotatsu-db"
|
||||
).addMigrations(
|
||||
Migration1To2(),
|
||||
Migration2To3(),
|
||||
Migration3To4(),
|
||||
Migration4To5(),
|
||||
Migration5To6(),
|
||||
Migration6To7(),
|
||||
Migration7To8(),
|
||||
Migration8To9(),
|
||||
).addCallback(
|
||||
DatabasePrePopulateCallback(androidContext().resources)
|
||||
).build()
|
||||
}
|
||||
single { MangaDatabase.create(androidContext()) }
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
package org.koitharu.kotatsu.core.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import org.koitharu.kotatsu.core.db.dao.*
|
||||
import org.koitharu.kotatsu.core.db.entity.*
|
||||
import org.koitharu.kotatsu.core.db.migrations.*
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
||||
@@ -40,4 +43,24 @@ abstract class MangaDatabase : RoomDatabase() {
|
||||
abstract val trackLogsDao: TrackLogsDao
|
||||
|
||||
abstract val suggestionDao: SuggestionDao
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(context: Context): MangaDatabase = Room.databaseBuilder(
|
||||
context,
|
||||
MangaDatabase::class.java,
|
||||
"kotatsu-db"
|
||||
).addMigrations(
|
||||
Migration1To2(),
|
||||
Migration2To3(),
|
||||
Migration3To4(),
|
||||
Migration4To5(),
|
||||
Migration5To6(),
|
||||
Migration6To7(),
|
||||
Migration7To8(),
|
||||
Migration8To9(),
|
||||
).addCallback(
|
||||
DatabasePrePopulateCallback(context.resources)
|
||||
).build()
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ abstract class TagsDao {
|
||||
@Query(
|
||||
"""SELECT tags.* FROM tags
|
||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||
GROUP BY manga_tags.tag_id
|
||||
GROUP BY tags.title
|
||||
ORDER BY COUNT(manga_id) DESC
|
||||
LIMIT :limit"""
|
||||
)
|
||||
@@ -22,7 +22,7 @@ abstract class TagsDao {
|
||||
"""SELECT tags.* FROM tags
|
||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||
WHERE tags.source = :source
|
||||
GROUP BY manga_tags.tag_id
|
||||
GROUP BY tags.title
|
||||
ORDER BY COUNT(manga_id) DESC
|
||||
LIMIT :limit"""
|
||||
)
|
||||
@@ -32,7 +32,7 @@ abstract class TagsDao {
|
||||
"""SELECT tags.* FROM tags
|
||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||
WHERE tags.source = :source AND title LIKE :query
|
||||
GROUP BY manga_tags.tag_id
|
||||
GROUP BY tags.title
|
||||
ORDER BY COUNT(manga_id) DESC
|
||||
LIMIT :limit"""
|
||||
)
|
||||
@@ -42,7 +42,7 @@ abstract class TagsDao {
|
||||
"""SELECT tags.* FROM tags
|
||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||
WHERE title LIKE :query
|
||||
GROUP BY manga_tags.tag_id
|
||||
GROUP BY tags.title
|
||||
ORDER BY COUNT(manga_id) DESC
|
||||
LIMIT :limit"""
|
||||
)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package org.koitharu.kotatsu.core.db.entity
|
||||
|
||||
import java.util.*
|
||||
import org.koitharu.kotatsu.core.model.TrackingLogItem
|
||||
import org.koitharu.kotatsu.parsers.model.*
|
||||
import org.koitharu.kotatsu.parsers.util.longHashCode
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
|
||||
// Entity to model
|
||||
|
||||
fun TagEntity.toMangaTag() = MangaTag(
|
||||
key = this.key,
|
||||
title = this.title.toTitleCase(),
|
||||
source = MangaSource.valueOf(this.source),
|
||||
)
|
||||
|
||||
fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag)
|
||||
|
||||
fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
|
||||
id = this.id,
|
||||
title = this.title,
|
||||
altTitle = this.altTitle,
|
||||
state = this.state?.let { MangaState.valueOf(it) },
|
||||
rating = this.rating,
|
||||
isNsfw = this.isNsfw,
|
||||
url = this.url,
|
||||
publicUrl = this.publicUrl,
|
||||
coverUrl = this.coverUrl,
|
||||
largeCoverUrl = this.largeCoverUrl,
|
||||
author = this.author,
|
||||
source = MangaSource.valueOf(this.source),
|
||||
tags = tags
|
||||
)
|
||||
|
||||
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
|
||||
|
||||
fun TrackLogWithManga.toTrackingLogItem() = TrackingLogItem(
|
||||
id = trackLog.id,
|
||||
chapters = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() },
|
||||
manga = manga.toManga(tags.toMangaTags()),
|
||||
createdAt = Date(trackLog.createdAt)
|
||||
)
|
||||
|
||||
// Model to entity
|
||||
|
||||
fun Manga.toEntity() = MangaEntity(
|
||||
id = id,
|
||||
url = url,
|
||||
publicUrl = publicUrl,
|
||||
source = source.name,
|
||||
largeCoverUrl = largeCoverUrl,
|
||||
coverUrl = coverUrl,
|
||||
altTitle = altTitle,
|
||||
rating = rating,
|
||||
isNsfw = isNsfw,
|
||||
state = state?.name,
|
||||
title = title,
|
||||
author = author,
|
||||
)
|
||||
|
||||
fun MangaTag.toEntity() = TagEntity(
|
||||
title = title,
|
||||
key = key,
|
||||
source = source.name,
|
||||
id = "${key}_${source.name}".longHashCode()
|
||||
)
|
||||
|
||||
fun Collection<MangaTag>.toEntities() = map(MangaTag::toEntity)
|
||||
|
||||
// Other
|
||||
|
||||
@Suppress("FunctionName")
|
||||
fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
|
||||
SortOrder.valueOf(name)
|
||||
}.getOrDefault(fallback)
|
||||
@@ -3,10 +3,6 @@ package org.koitharu.kotatsu.core.db.entity
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
|
||||
@Entity(tableName = "manga")
|
||||
class MangaEntity(
|
||||
@@ -16,46 +12,11 @@ class MangaEntity(
|
||||
@ColumnInfo(name = "alt_title") val altTitle: String?,
|
||||
@ColumnInfo(name = "url") val url: String,
|
||||
@ColumnInfo(name = "public_url") val publicUrl: String,
|
||||
@ColumnInfo(name = "rating") val rating: Float, //normalized value [0..1] or -1
|
||||
@ColumnInfo(name = "rating") val rating: Float, // normalized value [0..1] or -1
|
||||
@ColumnInfo(name = "nsfw") val isNsfw: Boolean,
|
||||
@ColumnInfo(name = "cover_url") val coverUrl: String,
|
||||
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
|
||||
@ColumnInfo(name = "state") val state: String?,
|
||||
@ColumnInfo(name = "author") val author: String?,
|
||||
@ColumnInfo(name = "source") val source: String
|
||||
) {
|
||||
|
||||
fun toManga(tags: Set<MangaTag> = emptySet()) = Manga(
|
||||
id = this.id,
|
||||
title = this.title,
|
||||
altTitle = this.altTitle,
|
||||
state = this.state?.let { MangaState.valueOf(it) },
|
||||
rating = this.rating,
|
||||
isNsfw = this.isNsfw,
|
||||
url = this.url,
|
||||
publicUrl = this.publicUrl,
|
||||
coverUrl = this.coverUrl,
|
||||
largeCoverUrl = this.largeCoverUrl,
|
||||
author = this.author,
|
||||
source = MangaSource.valueOf(this.source),
|
||||
tags = tags
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
fun from(manga: Manga) = MangaEntity(
|
||||
id = manga.id,
|
||||
url = manga.url,
|
||||
publicUrl = manga.publicUrl,
|
||||
source = manga.source.name,
|
||||
largeCoverUrl = manga.largeCoverUrl,
|
||||
coverUrl = manga.coverUrl,
|
||||
altTitle = manga.altTitle,
|
||||
rating = manga.rating,
|
||||
isNsfw = manga.isNsfw,
|
||||
state = manga.state?.name,
|
||||
title = manga.title,
|
||||
author = manga.author
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -6,13 +6,15 @@ import androidx.room.ForeignKey
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "preferences", foreignKeys = [
|
||||
tableName = "preferences",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = MangaEntity::class,
|
||||
parentColumns = ["manga_id"],
|
||||
childColumns = ["manga_id"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)]
|
||||
)
|
||||
]
|
||||
)
|
||||
class MangaPrefsEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
|
||||
@@ -5,7 +5,8 @@ import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
|
||||
@Entity(
|
||||
tableName = "manga_tags", primaryKeys = ["manga_id", "tag_id"], foreignKeys = [
|
||||
tableName = "manga_tags", primaryKeys = ["manga_id", "tag_id"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = MangaEntity::class,
|
||||
parentColumns = ["manga_id"],
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.db.entity
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Junction
|
||||
import androidx.room.Relation
|
||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
||||
|
||||
class MangaWithTags(
|
||||
@Embedded val manga: MangaEntity,
|
||||
@@ -12,10 +11,5 @@ class MangaWithTags(
|
||||
entityColumn = "tag_id",
|
||||
associateBy = Junction(MangaTagsEntity::class)
|
||||
)
|
||||
val tags: List<TagEntity>
|
||||
) {
|
||||
|
||||
fun toManga() = manga.toManga(tags.mapToSet {
|
||||
it.toMangaTag()
|
||||
})
|
||||
}
|
||||
val tags: List<TagEntity>,
|
||||
)
|
||||
@@ -3,10 +3,6 @@ package org.koitharu.kotatsu.core.db.entity
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.util.longHashCode
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
|
||||
@Entity(tableName = "tags")
|
||||
class TagEntity(
|
||||
@@ -15,21 +11,4 @@ class TagEntity(
|
||||
@ColumnInfo(name = "title") val title: String,
|
||||
@ColumnInfo(name = "key") val key: String,
|
||||
@ColumnInfo(name = "source") val source: String
|
||||
) {
|
||||
|
||||
fun toMangaTag() = MangaTag(
|
||||
key = this.key,
|
||||
title = this.title.toTitleCase(),
|
||||
source = MangaSource.valueOf(this.source)
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
fun fromMangaTag(tag: MangaTag) = TagEntity(
|
||||
title = tag.title,
|
||||
key = tag.key,
|
||||
source = tag.source.name,
|
||||
id = "${tag.key}_${tag.source.name}".longHashCode()
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -6,7 +6,8 @@ import androidx.room.ForeignKey
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "tracks", foreignKeys = [
|
||||
tableName = "tracks",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = MangaEntity::class,
|
||||
parentColumns = ["manga_id"],
|
||||
|
||||
@@ -6,7 +6,8 @@ import androidx.room.ForeignKey
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "track_logs", foreignKeys = [
|
||||
tableName = "track_logs",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = MangaEntity::class,
|
||||
parentColumns = ["manga_id"],
|
||||
@@ -20,5 +21,5 @@ class TrackLogEntity(
|
||||
@ColumnInfo(name = "id") val id: Long = 0L,
|
||||
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
||||
@ColumnInfo(name = "chapters") val chapters: String,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis()
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
@@ -3,9 +3,6 @@ package org.koitharu.kotatsu.core.db.entity
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Junction
|
||||
import androidx.room.Relation
|
||||
import org.koitharu.kotatsu.core.model.TrackingLogItem
|
||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
||||
import java.util.*
|
||||
|
||||
class TrackLogWithManga(
|
||||
@Embedded val trackLog: TrackLogEntity,
|
||||
@@ -19,13 +16,5 @@ class TrackLogWithManga(
|
||||
entityColumn = "tag_id",
|
||||
associateBy = Junction(MangaTagsEntity::class)
|
||||
)
|
||||
val tags: List<TagEntity>
|
||||
) {
|
||||
|
||||
fun toTrackingLogItem() = TrackingLogItem(
|
||||
id = trackLog.id,
|
||||
chapters = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() },
|
||||
manga = manga.toManga(tags.mapToSet { x -> x.toMangaTag() }),
|
||||
createdAt = Date(trackLog.createdAt)
|
||||
)
|
||||
}
|
||||
val tags: List<TagEntity>,
|
||||
)
|
||||
@@ -1,25 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.model
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
|
||||
fun Manga.withoutChapters() = if (chapters.isNullOrEmpty()) {
|
||||
this
|
||||
} else {
|
||||
Manga(
|
||||
id = id,
|
||||
title = title,
|
||||
altTitle = altTitle,
|
||||
url = url,
|
||||
publicUrl = publicUrl,
|
||||
rating = rating,
|
||||
isNsfw = isNsfw,
|
||||
coverUrl = coverUrl,
|
||||
tags = tags,
|
||||
state = state,
|
||||
author = author,
|
||||
largeCoverUrl = largeCoverUrl,
|
||||
description = description,
|
||||
chapters = null,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
fun Collection<Manga>.ids() = mapToSet { it.id }
|
||||
@@ -4,7 +4,7 @@ import android.os.Parcel
|
||||
import androidx.core.os.ParcelCompat
|
||||
import org.koitharu.kotatsu.parsers.model.*
|
||||
|
||||
fun Manga.writeToParcel(out: Parcel, flags: Int) {
|
||||
fun Manga.writeToParcel(out: Parcel, flags: Int, withChapters: Boolean) {
|
||||
out.writeLong(id)
|
||||
out.writeString(title)
|
||||
out.writeString(altTitle)
|
||||
@@ -18,7 +18,11 @@ fun Manga.writeToParcel(out: Parcel, flags: Int) {
|
||||
out.writeParcelable(ParcelableMangaTags(tags), flags)
|
||||
out.writeSerializable(state)
|
||||
out.writeString(author)
|
||||
out.writeParcelable(chapters?.let(::ParcelableMangaChapters), flags)
|
||||
if (withChapters) {
|
||||
out.writeParcelable(chapters?.let(::ParcelableMangaChapters), flags)
|
||||
} else {
|
||||
out.writeString(null)
|
||||
}
|
||||
out.writeSerializable(source)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,24 +2,39 @@ package org.koitharu.kotatsu.core.model.parcelable
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import android.util.Log
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
// Limits to avoid TransactionTooLargeException
|
||||
private const val MAX_SAFE_SIZE = 1024 * 512 // Assume that 512 kb is safe parcel size
|
||||
private const val MAX_SAFE_CHAPTERS_COUNT = 40 // this is 100% safe
|
||||
|
||||
class ParcelableManga(
|
||||
val manga: Manga,
|
||||
private val withChapters: Boolean,
|
||||
) : Parcelable {
|
||||
|
||||
constructor(parcel: Parcel) : this(parcel.readManga())
|
||||
|
||||
init {
|
||||
if (BuildConfig.DEBUG && manga.chapters != null) {
|
||||
Log.w("ParcelableManga", "Passing manga with chapters as Parcelable is dangerous!")
|
||||
}
|
||||
}
|
||||
constructor(parcel: Parcel) : this(parcel.readManga(), true)
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
manga.writeToParcel(parcel, flags)
|
||||
val chapters = manga.chapters
|
||||
if (!withChapters || chapters == null) {
|
||||
manga.writeToParcel(parcel, flags, withChapters = false)
|
||||
return
|
||||
}
|
||||
if (chapters.size <= MAX_SAFE_CHAPTERS_COUNT) {
|
||||
// fast path
|
||||
manga.writeToParcel(parcel, flags, withChapters = true)
|
||||
return
|
||||
}
|
||||
val tempParcel = Parcel.obtain()
|
||||
manga.writeToParcel(tempParcel, flags, withChapters = true)
|
||||
val size = tempParcel.dataSize()
|
||||
if (size < MAX_SAFE_SIZE) {
|
||||
parcel.appendFrom(tempParcel, 0, size)
|
||||
} else {
|
||||
manga.writeToParcel(parcel, flags, withChapters = false)
|
||||
}
|
||||
tempParcel.recycle()
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import android.util.Log
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okio.Buffer
|
||||
import java.io.IOException
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
private const val TAG = "CURL"
|
||||
|
||||
class CurlLoggingInterceptor(
|
||||
private val extraCurlOptions: String? = null,
|
||||
) : Interceptor {
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request: Request = chain.request()
|
||||
var compressed = false
|
||||
val curlCmd = StringBuilder("curl")
|
||||
if (extraCurlOptions != null) {
|
||||
curlCmd.append(" ").append(extraCurlOptions)
|
||||
}
|
||||
curlCmd.append(" -X ").append(request.method)
|
||||
val headers = request.headers
|
||||
var i = 0
|
||||
val count = headers.size
|
||||
while (i < count) {
|
||||
val name = headers.name(i)
|
||||
val value = headers.value(i)
|
||||
if ("Accept-Encoding".equals(name, ignoreCase = true) && "gzip".equals(value,
|
||||
ignoreCase = true)
|
||||
) {
|
||||
compressed = true
|
||||
}
|
||||
curlCmd.append(" -H " + "\"").append(name).append(": ").append(value).append("\"")
|
||||
i++
|
||||
}
|
||||
val requestBody = request.body
|
||||
if (requestBody != null) {
|
||||
val buffer = Buffer()
|
||||
requestBody.writeTo(buffer)
|
||||
val contentType = requestBody.contentType()
|
||||
val charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
|
||||
curlCmd.append(" --data $'")
|
||||
.append(buffer.readString(charset).replace("\n", "\\n"))
|
||||
.append("'")
|
||||
}
|
||||
curlCmd.append(if (compressed) " --compressed " else " ").append(request.url)
|
||||
Log.d(TAG, "╭--- cURL (" + request.url + ")")
|
||||
Log.d(TAG, curlCmd.toString())
|
||||
Log.d(TAG, "╰--- (copy and paste the above line to a terminal)")
|
||||
return chain.proceed(request)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import okhttp3.CookieJar
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
@@ -22,9 +21,6 @@ val networkModule
|
||||
cache(get<LocalStorageManager>().createHttpCache())
|
||||
addInterceptor(UserAgentInterceptor())
|
||||
addInterceptor(CloudFlareInterceptor())
|
||||
if (BuildConfig.DEBUG) {
|
||||
addNetworkInterceptor(CurlLoggingInterceptor())
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
single<MangaLoaderContext> { MangaLoaderContextImpl(get(), get(), get()) }
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import android.os.Build
|
||||
import java.util.*
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import java.util.*
|
||||
|
||||
class UserAgentInterceptor : Interceptor {
|
||||
|
||||
@@ -30,5 +30,14 @@ class UserAgentInterceptor : Interceptor {
|
||||
Build.DEVICE,
|
||||
Locale.getDefault().language
|
||||
)
|
||||
|
||||
val userAgentChrome
|
||||
get() = (
|
||||
"Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) " +
|
||||
"Chrome/100.0.4896.127 Mobile Safari/537.36"
|
||||
).format(
|
||||
Build.VERSION.RELEASE,
|
||||
Build.MODEL,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ class ShortcutsRepository(
|
||||
private val context: Context,
|
||||
private val coil: ImageLoader,
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val mangaRepository: MangaDataRepository
|
||||
private val mangaRepository: MangaDataRepository,
|
||||
) {
|
||||
|
||||
private val iconSize by lazy {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.*
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
@@ -28,11 +30,18 @@ interface MangaRepository {
|
||||
|
||||
companion object : KoinComponent {
|
||||
|
||||
private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java)
|
||||
|
||||
operator fun invoke(source: MangaSource): MangaRepository {
|
||||
return if (source == MangaSource.LOCAL) {
|
||||
get<LocalMangaRepository>()
|
||||
} else {
|
||||
RemoteMangaRepository(source, get())
|
||||
if (source == MangaSource.LOCAL) {
|
||||
return get<LocalMangaRepository>()
|
||||
}
|
||||
cache[source]?.get()?.let { return it }
|
||||
return synchronized(cache) {
|
||||
cache[source]?.get()?.let { return it }
|
||||
val repository = RemoteMangaRepository(MangaParser(source, get()))
|
||||
cache[source] = WeakReference(repository)
|
||||
repository
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaParser
|
||||
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.model.*
|
||||
import org.koitharu.kotatsu.parsers.newParser
|
||||
|
||||
class RemoteMangaRepository(
|
||||
override val source: MangaSource,
|
||||
loaderContext: MangaLoaderContext,
|
||||
) : MangaRepository {
|
||||
class RemoteMangaRepository(private val parser: MangaParser) : MangaRepository {
|
||||
|
||||
private val parser: MangaParser = source.newParser(loaderContext)
|
||||
override val source: MangaSource
|
||||
get() = parser.source
|
||||
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = parser.sortOrders
|
||||
@@ -28,7 +24,7 @@ class RemoteMangaRepository(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder?
|
||||
sortOrder: SortOrder?,
|
||||
): List<Manga> = parser.getList(offset, query, tags, sortOrder)
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga = parser.getDetails(manga)
|
||||
@@ -48,4 +44,4 @@ class RemoteMangaRepository(
|
||||
}
|
||||
|
||||
private fun getConfig() = parser.config as SourceSettings
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import com.google.android.material.color.DynamicColors
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.utils.ext.getEnumValue
|
||||
@@ -28,6 +29,16 @@ class AppSettings(context: Context) {
|
||||
|
||||
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
|
||||
private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply {
|
||||
remove(MangaSource.LOCAL)
|
||||
if (!BuildConfig.DEBUG) {
|
||||
remove(MangaSource.DUMMY)
|
||||
}
|
||||
}
|
||||
|
||||
val remoteMangaSources: Set<MangaSource>
|
||||
get() = Collections.unmodifiableSet(remoteSources)
|
||||
|
||||
var listMode: ListMode
|
||||
get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.DETAILED_LIST)
|
||||
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) }
|
||||
@@ -56,6 +67,10 @@ class AppSettings(context: Context) {
|
||||
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
|
||||
set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) }
|
||||
|
||||
var isAllFavouritesVisible: Boolean
|
||||
get() = prefs.getBoolean(KEY_ALL_FAVOURITES_VISIBLE, true)
|
||||
set(value) = prefs.edit { putBoolean(KEY_ALL_FAVOURITES_VISIBLE, value) }
|
||||
|
||||
val isUpdateCheckingEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_APP_UPDATE_AUTO, true)
|
||||
|
||||
@@ -104,10 +119,9 @@ class AppSettings(context: Context) {
|
||||
get() = prefs.getString(KEY_APP_PASSWORD, null)
|
||||
set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) }
|
||||
|
||||
var sourcesOrder: List<Int>
|
||||
var sourcesOrder: List<String>
|
||||
get() = prefs.getString(KEY_SOURCES_ORDER, null)
|
||||
?.split('|')
|
||||
?.mapNotNull(String::toIntOrNull)
|
||||
.orEmpty()
|
||||
set(value) = prefs.edit {
|
||||
putString(KEY_SOURCES_ORDER, value.joinToString("|"))
|
||||
@@ -120,6 +134,20 @@ class AppSettings(context: Context) {
|
||||
val isSourcesSelected: Boolean
|
||||
get() = KEY_SOURCES_HIDDEN in prefs
|
||||
|
||||
val newSources: Set<MangaSource>
|
||||
get() {
|
||||
val known = sourcesOrder.toSet()
|
||||
val hidden = hiddenSources
|
||||
return remoteMangaSources
|
||||
.filterNotTo(EnumSet.noneOf(MangaSource::class.java)) { x ->
|
||||
x.name in known || x.name in hidden
|
||||
}
|
||||
}
|
||||
|
||||
fun markKnownSources(sources: Collection<MangaSource>) {
|
||||
sourcesOrder = sourcesOrder + sources.map { it.name }
|
||||
}
|
||||
|
||||
val isPagesNumbersEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
|
||||
|
||||
@@ -141,6 +169,12 @@ class AppSettings(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
val isDownloadsSlowdownEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_DOWNLOADS_SLOWDOWN, false)
|
||||
|
||||
val downloadsParallelism: Int
|
||||
get() = prefs.getInt(KEY_DOWNLOADS_PARALLELISM, 2)
|
||||
|
||||
val isSuggestionsEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
|
||||
|
||||
@@ -165,12 +199,23 @@ class AppSettings(context: Context) {
|
||||
else -> SimpleDateFormat(format, Locale.getDefault())
|
||||
}
|
||||
|
||||
fun getSuggestionsTagsBlacklistRegex(): Regex? {
|
||||
val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',')
|
||||
if (string.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
val tags = string.split(',')
|
||||
val regex = tags.joinToString(prefix = "(", separator = "|", postfix = ")") { tag ->
|
||||
Regex.escape(tag.trim())
|
||||
}
|
||||
return Regex(regex, RegexOption.IGNORE_CASE)
|
||||
}
|
||||
|
||||
fun getMangaSources(includeHidden: Boolean): List<MangaSource> {
|
||||
val list = MangaSource.values().toMutableList()
|
||||
list.remove(MangaSource.LOCAL)
|
||||
val list = remoteSources.toMutableList()
|
||||
val order = sourcesOrder
|
||||
list.sortBy { x ->
|
||||
val e = order.indexOf(x.ordinal)
|
||||
val e = order.indexOf(x.name)
|
||||
if (e == -1) order.size + x.ordinal else e
|
||||
}
|
||||
if (!includeHidden) {
|
||||
@@ -212,7 +257,7 @@ class AppSettings(context: Context) {
|
||||
const val KEY_DYNAMIC_THEME = "dynamic_theme"
|
||||
const val KEY_THEME_AMOLED = "amoled_theme"
|
||||
const val KEY_DATE_FORMAT = "date_format"
|
||||
const val KEY_SOURCES_ORDER = "sources_order"
|
||||
const val KEY_SOURCES_ORDER = "sources_order_2"
|
||||
const val KEY_SOURCES_HIDDEN = "sources_hidden"
|
||||
const val KEY_TRAFFIC_WARNING = "traffic_warning"
|
||||
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
|
||||
@@ -247,17 +292,19 @@ class AppSettings(context: Context) {
|
||||
const val KEY_PAGES_PRELOAD = "pages_preload"
|
||||
const val KEY_SUGGESTIONS = "suggestions"
|
||||
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
|
||||
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
|
||||
const val KEY_SEARCH_SINGLE_SOURCE = "search_single_source"
|
||||
const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism"
|
||||
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
|
||||
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
|
||||
|
||||
// About
|
||||
const val KEY_APP_UPDATE = "app_update"
|
||||
const val KEY_APP_UPDATE_AUTO = "app_update_auto"
|
||||
const val KEY_APP_TRANSLATION = "about_app_translation"
|
||||
const val KEY_APP_GRATITUDES = "about_gratitudes"
|
||||
const val KEY_FEEDBACK_4PDA = "about_feedback_4pda"
|
||||
const val KEY_FEEDBACK_DISCORD = "about_feedback_discord"
|
||||
const val KEY_FEEDBACK_GITHUB = "about_feedback_github"
|
||||
const val KEY_SUPPORT_DEVELOPER = "about_support_developer"
|
||||
|
||||
private const val NETWORK_NEVER = 0
|
||||
private const val NETWORK_ALWAYS = 1
|
||||
@@ -270,4 +317,4 @@ class AppSettings(context: Context) {
|
||||
private val isSamsung
|
||||
get() = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ package org.koitharu.kotatsu.core.ui
|
||||
import android.content.res.Resources
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.utils.ext.daysDiff
|
||||
import org.koitharu.kotatsu.utils.ext.format
|
||||
import java.util.*
|
||||
|
||||
sealed class DateTimeAgo : ListModel {
|
||||
|
||||
@@ -72,9 +75,33 @@ sealed class DateTimeAgo : ListModel {
|
||||
override fun hashCode(): Int = days
|
||||
}
|
||||
|
||||
class Absolute(private val date: Date) : DateTimeAgo() {
|
||||
|
||||
private val day = date.daysDiff(0)
|
||||
|
||||
override fun format(resources: Resources): String {
|
||||
return date.format("d MMMM")
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Absolute
|
||||
|
||||
if (day != other.day) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return day
|
||||
}
|
||||
}
|
||||
|
||||
object LongAgo : DateTimeAgo() {
|
||||
override fun format(resources: Resources): String {
|
||||
return resources.getString(R.string.long_ago)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
118
app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt
Normal file
118
app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt
Normal file
@@ -0,0 +1,118 @@
|
||||
package org.koitharu.kotatsu.core.zip
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.collection.ArraySet
|
||||
import okio.Closeable
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.util.zip.Deflater
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
class ZipOutput(
|
||||
val file: File,
|
||||
compressionLevel: Int = Deflater.DEFAULT_COMPRESSION,
|
||||
) : Closeable {
|
||||
|
||||
private val entryNames = ArraySet<String>()
|
||||
private var isClosed = false
|
||||
private val output = ZipOutputStream(file.outputStream()).apply {
|
||||
setLevel(compressionLevel)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun put(name: String, file: File): Boolean {
|
||||
return output.appendFile(file, name)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun put(name: String, content: String): Boolean {
|
||||
return output.appendText(content, name)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun addDirectory(name: String): Boolean {
|
||||
val entry = if (name.endsWith("/")) {
|
||||
ZipEntry(name)
|
||||
} else {
|
||||
ZipEntry("$name/")
|
||||
}
|
||||
return if (entryNames.add(entry.name)) {
|
||||
output.putNextEntry(entry)
|
||||
output.closeEntry()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun copyEntryFrom(other: ZipFile, entry: ZipEntry): Boolean {
|
||||
return if (entryNames.add(entry.name)) {
|
||||
val zipEntry = ZipEntry(entry.name)
|
||||
output.putNextEntry(zipEntry)
|
||||
other.getInputStream(entry).use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
output.closeEntry()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun finish() {
|
||||
output.finish()
|
||||
output.flush()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (!isClosed) {
|
||||
output.close()
|
||||
isClosed = true
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun ZipOutputStream.appendFile(fileToZip: File, name: String): Boolean {
|
||||
if (fileToZip.isDirectory) {
|
||||
val entry = if (name.endsWith("/")) {
|
||||
ZipEntry(name)
|
||||
} else {
|
||||
ZipEntry("$name/")
|
||||
}
|
||||
if (!entryNames.add(entry.name)) {
|
||||
return false
|
||||
}
|
||||
putNextEntry(entry)
|
||||
closeEntry()
|
||||
fileToZip.listFiles()?.forEach { childFile ->
|
||||
appendFile(childFile, "$name/${childFile.name}")
|
||||
}
|
||||
} else {
|
||||
FileInputStream(fileToZip).use { fis ->
|
||||
if (!entryNames.add(name)) {
|
||||
return false
|
||||
}
|
||||
val zipEntry = ZipEntry(name)
|
||||
putNextEntry(zipEntry)
|
||||
fis.copyTo(this)
|
||||
closeEntry()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun ZipOutputStream.appendText(content: String, name: String): Boolean {
|
||||
if (!entryNames.add(name)) {
|
||||
return false
|
||||
}
|
||||
val zipEntry = ZipEntry(name)
|
||||
putNextEntry(zipEntry)
|
||||
content.byteInputStream().copyTo(this)
|
||||
closeEntry()
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,9 @@ import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseFragment
|
||||
@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
|
||||
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
||||
import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
@@ -67,8 +68,8 @@ class ChaptersFragment :
|
||||
viewModel.isChaptersReversed.observe(viewLifecycleOwner) {
|
||||
activity?.invalidateOptionsMenu()
|
||||
}
|
||||
viewModel.hasChapters.observe(viewLifecycleOwner) {
|
||||
binding.textViewHolder.isGone = it
|
||||
viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
|
||||
binding.textViewHolder.isVisible = it
|
||||
activity?.invalidateOptionsMenu()
|
||||
}
|
||||
}
|
||||
@@ -94,7 +95,7 @@ class ChaptersFragment :
|
||||
override fun onPrepareOptionsMenu(menu: Menu) {
|
||||
super.onPrepareOptionsMenu(menu)
|
||||
menu.findItem(R.id.action_reversed).isChecked = viewModel.isChaptersReversed.value == true
|
||||
menu.findItem(R.id.action_search).isVisible = viewModel.hasChapters.value == true
|
||||
menu.findItem(R.id.action_search).isVisible = viewModel.isChaptersEmpty.value == false
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||
@@ -154,11 +155,29 @@ class ChaptersFragment :
|
||||
DownloadService.start(
|
||||
context ?: return false,
|
||||
viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false,
|
||||
selectionDecoration?.checkedItemsIds
|
||||
selectionDecoration?.checkedItemsIds?.toSet()
|
||||
)
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
R.id.action_delete -> {
|
||||
val ids = selectionDecoration?.checkedItemsIds
|
||||
val manga = viewModel.manga.value
|
||||
when {
|
||||
ids.isNullOrEmpty() || manga == null -> Unit
|
||||
ids.size == manga.chapters?.size -> viewModel.deleteLocal()
|
||||
else -> {
|
||||
LocalChaptersRemoveService.start(requireContext(), manga, ids)
|
||||
Snackbar.make(
|
||||
binding.recyclerViewChapters,
|
||||
R.string.chapters_will_removed_background,
|
||||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
R.id.action_select_all -> {
|
||||
val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false
|
||||
selectionDecoration?.checkAll(ids)
|
||||
@@ -178,9 +197,7 @@ class ChaptersFragment :
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||
|
||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
val manga = viewModel.manga.value
|
||||
mode.menuInflater.inflate(R.menu.mode_chapters, menu)
|
||||
mode.title = manga?.title
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -190,12 +207,10 @@ class ChaptersFragment :
|
||||
menu.findItem(R.id.action_save).isVisible = items.none { x ->
|
||||
x.chapter.source == MangaSource.LOCAL
|
||||
}
|
||||
mode.subtitle = resources.getQuantityString(
|
||||
R.plurals.chapters_from_x,
|
||||
items.size,
|
||||
items.size,
|
||||
chaptersAdapter?.itemCount ?: 0
|
||||
)
|
||||
menu.findItem(R.id.action_delete).isVisible = items.all { x ->
|
||||
x.chapter.source == MangaSource.LOCAL
|
||||
}
|
||||
mode.title = items.size.toString()
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
@@ -17,6 +16,7 @@ import androidx.appcompat.view.ActionMode
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
@@ -41,13 +41,16 @@ import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter
|
||||
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity
|
||||
import org.koitharu.kotatsu.utils.ShareHelper
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
|
||||
class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediator.TabConfigurationStrategy,
|
||||
class DetailsActivity :
|
||||
BaseActivity<ActivityDetailsBinding>(),
|
||||
TabLayoutMediator.TabConfigurationStrategy,
|
||||
AdapterView.OnItemSelectedListener {
|
||||
|
||||
private val viewModel by viewModel<DetailsViewModel> {
|
||||
@@ -163,7 +166,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
||||
R.id.action_share -> {
|
||||
viewModel.manga.value?.let {
|
||||
if (it.source == MangaSource.LOCAL) {
|
||||
ShareHelper(this).shareCbz(Uri.parse(it.url).toFile())
|
||||
ShareHelper(this).shareCbz(listOf(it.url.toUri().toFile()))
|
||||
} else {
|
||||
ShareHelper(this).shareMangaLink(it)
|
||||
}
|
||||
@@ -171,38 +174,23 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
||||
true
|
||||
}
|
||||
R.id.action_delete -> {
|
||||
viewModel.manga.value?.let { m ->
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.delete_manga)
|
||||
.setMessage(getString(R.string.text_delete_local_manga, m.title))
|
||||
.setPositiveButton(R.string.delete) { _, _ ->
|
||||
viewModel.deleteLocal(m)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
val title = viewModel.manga.value?.title.orEmpty()
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.delete_manga)
|
||||
.setMessage(getString(R.string.text_delete_local_manga, title))
|
||||
.setPositiveButton(R.string.delete) { _, _ ->
|
||||
viewModel.deleteLocal()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
true
|
||||
}
|
||||
R.id.action_save -> {
|
||||
viewModel.manga.value?.let {
|
||||
val chaptersCount = it.chapters?.size ?: 0
|
||||
if (chaptersCount > 5) {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.save_manga)
|
||||
.setMessage(
|
||||
getString(
|
||||
R.string.large_manga_save_confirm,
|
||||
resources.getQuantityString(
|
||||
R.plurals.chapters,
|
||||
chaptersCount,
|
||||
chaptersCount
|
||||
)
|
||||
)
|
||||
)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string.save) { _, _ ->
|
||||
DownloadService.start(this, it)
|
||||
}.show()
|
||||
val branches = viewModel.branches.value.orEmpty()
|
||||
if (chaptersCount > 5 || branches.size > 1) {
|
||||
showSaveConfirmation(it, chaptersCount, branches)
|
||||
} else {
|
||||
DownloadService.start(this, it)
|
||||
}
|
||||
@@ -262,7 +250,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
||||
fun showChapterMissingDialog(chapterId: Long) {
|
||||
val remoteManga = viewModel.getRemoteManga()
|
||||
if (remoteManga == null) {
|
||||
binding.snackbar.show(getString( R.string.chapter_is_missing))
|
||||
binding.snackbar.show(getString(R.string.chapter_is_missing))
|
||||
return
|
||||
}
|
||||
MaterialAlertDialogBuilder(this).apply {
|
||||
@@ -328,11 +316,41 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSaveConfirmation(manga: Manga, chaptersCount: Int, branches: List<String?>) {
|
||||
val dialogBuilder = MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.save_manga)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
if (branches.size > 1) {
|
||||
val items = Array(branches.size) { i -> branches[i].orEmpty() }
|
||||
val currentBranch = viewModel.selectedBranchIndex.value ?: -1
|
||||
val checkedIndices = BooleanArray(branches.size) { i -> i == currentBranch }
|
||||
dialogBuilder.setMultiChoiceItems(items, checkedIndices) { _, i, checked ->
|
||||
checkedIndices[i] = checked
|
||||
}.setPositiveButton(R.string.save) { _, _ ->
|
||||
val selectedBranches = branches.filterIndexedTo(HashSet()) { i, _ -> checkedIndices[i] }
|
||||
val chaptersIds = manga.chapters?.mapNotNullToSet { c ->
|
||||
if (c.branch in selectedBranches) c.id else null
|
||||
}
|
||||
DownloadService.start(this, manga, chaptersIds)
|
||||
}
|
||||
} else {
|
||||
dialogBuilder.setMessage(
|
||||
getString(
|
||||
R.string.large_manga_save_confirm,
|
||||
resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount)
|
||||
)
|
||||
).setPositiveButton(R.string.save) { _, _ ->
|
||||
DownloadService.start(this, manga)
|
||||
}
|
||||
}
|
||||
dialogBuilder.show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newIntent(context: Context, manga: Manga): Intent {
|
||||
return Intent(context, DetailsActivity::class.java)
|
||||
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
|
||||
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
|
||||
}
|
||||
|
||||
fun newIntent(context: Context, mangaId: Long): Intent {
|
||||
@@ -340,4 +358,4 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
||||
.putExtra(MangaIntent.KEY_ID, mangaId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import android.os.Bundle
|
||||
import android.text.Spanned
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.view.*
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.net.toUri
|
||||
@@ -224,14 +225,16 @@ class DetailsFragment :
|
||||
if (viewModel.readingHistory.value == null) {
|
||||
return false
|
||||
}
|
||||
v.showPopupMenu(R.menu.popup_read) {
|
||||
when (it.itemId) {
|
||||
val menu = PopupMenu(v.context, v)
|
||||
menu.inflate(R.menu.popup_read)
|
||||
menu.setOnMenuItemClickListener { menuItem ->
|
||||
when (menuItem.itemId) {
|
||||
R.id.action_read -> {
|
||||
val branch = viewModel.selectedBranchValue
|
||||
startActivity(
|
||||
ReaderActivity.newIntent(
|
||||
context = context ?: return@showPopupMenu false,
|
||||
manga = viewModel.manga.value ?: return@showPopupMenu false,
|
||||
context = context ?: return@setOnMenuItemClickListener false,
|
||||
manga = viewModel.manga.value ?: return@setOnMenuItemClickListener false,
|
||||
state = viewModel.chapters.value?.firstOrNull { c ->
|
||||
c.chapter.branch == branch
|
||||
}?.let { c ->
|
||||
@@ -244,6 +247,7 @@ class DetailsFragment :
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
menu.show()
|
||||
return true
|
||||
}
|
||||
else -> return false
|
||||
|
||||
@@ -24,11 +24,12 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||
import org.koitharu.kotatsu.utils.ext.iterator
|
||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
||||
import java.io.IOException
|
||||
|
||||
class DetailsViewModel(
|
||||
@@ -88,18 +89,18 @@ class DetailsViewModel(
|
||||
|
||||
val branches = mangaData.map {
|
||||
it?.chapters?.mapToSet { x -> x.branch }?.sortedBy { x -> x }.orEmpty()
|
||||
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||
|
||||
val selectedBranchIndex = combine(
|
||||
branches.asFlow(),
|
||||
selectedBranch
|
||||
) { branches, selected ->
|
||||
branches.indexOf(selected)
|
||||
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||
|
||||
val hasChapters = mangaData.map {
|
||||
!(it?.chapters.isNullOrEmpty())
|
||||
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||
val isChaptersEmpty = mangaData.mapNotNull { m ->
|
||||
m?.run { chapters.isNullOrEmpty() }
|
||||
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, false)
|
||||
|
||||
val chapters = combine(
|
||||
combine(
|
||||
@@ -134,8 +135,11 @@ class DetailsViewModel(
|
||||
loadingJob = doLoad()
|
||||
}
|
||||
|
||||
fun deleteLocal(manga: Manga) {
|
||||
fun deleteLocal() {
|
||||
val m = mangaData.value ?: return
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m)
|
||||
checkNotNull(manga) { "Cannot find saved manga for ${m.title}" }
|
||||
val original = localMangaRepository.getRemoteManga(manga)
|
||||
localMangaRepository.delete(manga) || throw IOException("Unable to delete file")
|
||||
runCatching {
|
||||
@@ -191,7 +195,8 @@ class DetailsViewModel(
|
||||
// find default branch
|
||||
val hist = historyRepository.getOne(manga)
|
||||
selectedBranch.value = if (hist != null) {
|
||||
manga.chapters?.find { it.id == hist.chapterId }?.branch
|
||||
val currentChapter = manga.chapters?.find { it.id == hist.chapterId }
|
||||
if (currentChapter != null) currentChapter.branch else predictBranch(manga.chapters)
|
||||
} else {
|
||||
predictBranch(manga.chapters)
|
||||
}
|
||||
@@ -203,6 +208,8 @@ class DetailsViewModel(
|
||||
} else {
|
||||
localMangaRepository.findSavedManga(manga)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
if (BuildConfig.DEBUG) error.printStackTrace()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
@@ -249,10 +256,10 @@ class DetailsViewModel(
|
||||
val dateFormat = settings.getDateFormat()
|
||||
for (i in sourceChapters.indices) {
|
||||
val chapter = sourceChapters[i]
|
||||
val localChapter = chaptersMap.remove(chapter.id)
|
||||
if (chapter.branch != branch) {
|
||||
continue
|
||||
}
|
||||
val localChapter = chaptersMap.remove(chapter.id)
|
||||
result += localChapter?.toListItem(
|
||||
isCurrent = i == currentIndex,
|
||||
isUnread = i > currentIndex,
|
||||
@@ -271,15 +278,19 @@ class DetailsViewModel(
|
||||
}
|
||||
if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source
|
||||
result.ensureCapacity(result.size + chaptersMap.size)
|
||||
chaptersMap.values.mapTo(result) {
|
||||
it.toListItem(
|
||||
isCurrent = false,
|
||||
isUnread = true,
|
||||
isNew = false,
|
||||
isMissing = false,
|
||||
isDownloaded = false,
|
||||
dateFormat = dateFormat,
|
||||
)
|
||||
chaptersMap.values.mapNotNullTo(result) {
|
||||
if (it.branch == branch) {
|
||||
it.toListItem(
|
||||
isCurrent = false,
|
||||
isUnread = true,
|
||||
isNew = false,
|
||||
isMissing = false,
|
||||
isDownloaded = false,
|
||||
dateFormat = dateFormat,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
result.sortBy { it.chapter.number }
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.TextView
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.utils.ext.replaceWith
|
||||
import org.koitharu.kotatsu.parsers.util.replaceWith
|
||||
|
||||
class BranchesAdapter : BaseAdapter() {
|
||||
|
||||
|
||||
@@ -2,69 +2,32 @@ package org.koitharu.kotatsu.details.ui.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.children
|
||||
import android.graphics.RectF
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
|
||||
import org.koitharu.kotatsu.utils.ext.getThemeColor
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
class ChaptersSelectionDecoration(context: Context) : RecyclerView.ItemDecoration() {
|
||||
class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
|
||||
|
||||
private val bounds = Rect()
|
||||
private val selection = HashSet<Long>()
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val radius = context.resources.getDimension(materialR.dimen.abc_control_corner_material)
|
||||
|
||||
init {
|
||||
paint.color = ContextCompat.getColor(context, R.color.selector_foreground)
|
||||
paint.color = context.getThemeColor(materialR.attr.colorSecondaryContainer, Color.LTGRAY)
|
||||
paint.style = Paint.Style.FILL
|
||||
}
|
||||
|
||||
val checkedItemsCount: Int
|
||||
get() = selection.size
|
||||
|
||||
val checkedItemsIds: Set<Long>
|
||||
get() = selection
|
||||
|
||||
fun toggleItemChecked(id: Long) {
|
||||
if (!selection.remove(id)) {
|
||||
selection.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun setItemIsChecked(id: Long, isChecked: Boolean) {
|
||||
if (isChecked) {
|
||||
selection.add(id)
|
||||
} else {
|
||||
selection.remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun checkAll(ids: Collection<Long>) {
|
||||
selection.addAll(ids)
|
||||
}
|
||||
|
||||
fun clearSelection() {
|
||||
selection.clear()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
canvas.save()
|
||||
if (parent.clipToPadding) {
|
||||
canvas.clipRect(
|
||||
parent.paddingLeft, parent.paddingTop, parent.width - parent.paddingRight,
|
||||
parent.height - parent.paddingBottom
|
||||
)
|
||||
}
|
||||
|
||||
for (child in parent.children) {
|
||||
val itemId = parent.getChildItemId(child)
|
||||
if (itemId in selection) {
|
||||
parent.getDecoratedBoundsWithMargins(child, bounds)
|
||||
bounds.offset(child.translationX.toInt(), child.translationY.toInt())
|
||||
canvas.drawRect(bounds, paint)
|
||||
}
|
||||
}
|
||||
canvas.restore()
|
||||
override fun onDrawBackground(
|
||||
canvas: Canvas,
|
||||
parent: RecyclerView,
|
||||
child: View,
|
||||
bounds: RectF,
|
||||
state: RecyclerView.State,
|
||||
) {
|
||||
canvas.drawRoundRect(bounds, radius, radius, paint)
|
||||
}
|
||||
}
|
||||
@@ -40,11 +40,10 @@ class ChapterListItem(
|
||||
override fun hashCode(): Int {
|
||||
var result = chapter.hashCode()
|
||||
result = 31 * result + flags
|
||||
result = 31 * result + uploadDate.hashCode()
|
||||
result = 31 * result + (uploadDate?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
const val FLAG_UNREAD = 2
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.koitharu.kotatsu.download.domain
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.ConnectivityManager
|
||||
import android.webkit.MimeTypeMap
|
||||
import coil.ImageLoader
|
||||
@@ -17,10 +16,12 @@ import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.local.data.MangaZip
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.local.domain.CbzMangaOutput
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.utils.ext.deleteAwait
|
||||
import org.koitharu.kotatsu.utils.ext.referer
|
||||
@@ -29,9 +30,8 @@ import org.koitharu.kotatsu.utils.progress.ProgressJob
|
||||
import java.io.File
|
||||
|
||||
private const val MAX_DOWNLOAD_ATTEMPTS = 3
|
||||
private const val MAX_PARALLEL_DOWNLOADS = 2
|
||||
private const val DOWNLOAD_ERROR_DELAY = 500L
|
||||
private const val TEMP_PAGE_FILE = "page.tmp"
|
||||
private const val SLOWDOWN_DELAY = 200L
|
||||
|
||||
class DownloadManager(
|
||||
private val coroutineScope: CoroutineScope,
|
||||
@@ -40,9 +40,10 @@ class DownloadManager(
|
||||
private val okHttp: OkHttpClient,
|
||||
private val cache: PagesCache,
|
||||
private val localMangaRepository: LocalMangaRepository,
|
||||
private val settings: AppSettings,
|
||||
) {
|
||||
|
||||
private val connectivityManager = context.applicationContext.getSystemService(
|
||||
private val connectivityManager = context.getSystemService(
|
||||
Context.CONNECTIVITY_SERVICE
|
||||
) as ConnectivityManager
|
||||
private val coverWidth = context.resources.getDimensionPixelSize(
|
||||
@@ -51,98 +52,109 @@ class DownloadManager(
|
||||
private val coverHeight = context.resources.getDimensionPixelSize(
|
||||
androidx.core.R.dimen.compat_notification_large_icon_max_height
|
||||
)
|
||||
private val semaphore = Semaphore(MAX_PARALLEL_DOWNLOADS)
|
||||
private val semaphore = Semaphore(settings.downloadsParallelism)
|
||||
|
||||
fun downloadManga(
|
||||
manga: Manga,
|
||||
chaptersIds: Set<Long>?,
|
||||
chaptersIds: LongArray?,
|
||||
startId: Int,
|
||||
): ProgressJob<DownloadState> {
|
||||
val stateFlow = MutableStateFlow<DownloadState>(
|
||||
DownloadState.Queued(startId = startId, manga = manga, cover = null)
|
||||
)
|
||||
val job = downloadMangaImpl(manga, chaptersIds, stateFlow, startId)
|
||||
val job = downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, startId)
|
||||
return ProgressJob(job, stateFlow)
|
||||
}
|
||||
|
||||
private fun downloadMangaImpl(
|
||||
manga: Manga,
|
||||
chaptersIds: Set<Long>?,
|
||||
chaptersIds: LongArray?,
|
||||
outState: MutableStateFlow<DownloadState>,
|
||||
startId: Int,
|
||||
): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) {
|
||||
@Suppress("NAME_SHADOWING") var manga = manga
|
||||
val chaptersIdsSet = chaptersIds?.toMutableSet()
|
||||
val cover = loadCover(manga)
|
||||
outState.value = DownloadState.Queued(startId, manga, cover)
|
||||
localMangaRepository.lockManga(manga.id)
|
||||
semaphore.acquire()
|
||||
coroutineContext[WakeLockNode]?.acquire()
|
||||
outState.value = DownloadState.Preparing(startId, manga, null)
|
||||
var cover: Drawable? = null
|
||||
val destination = localMangaRepository.getOutputDir()
|
||||
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
|
||||
var output: MangaZip? = null
|
||||
val tempFileName = "${manga.id}_$startId.tmp"
|
||||
var output: CbzMangaOutput? = null
|
||||
try {
|
||||
if (manga.source == MangaSource.LOCAL) {
|
||||
manga = localMangaRepository.getRemoteManga(manga) ?: error("Cannot obtain remote manga instance")
|
||||
}
|
||||
val repo = MangaRepository(manga.source)
|
||||
cover = runCatching {
|
||||
imageLoader.execute(
|
||||
ImageRequest.Builder(context)
|
||||
.data(manga.coverUrl)
|
||||
.referer(manga.publicUrl)
|
||||
.size(coverWidth, coverHeight)
|
||||
.scale(Scale.FILL)
|
||||
.build()
|
||||
).drawable
|
||||
}.getOrNull()
|
||||
outState.value = DownloadState.Preparing(startId, manga, cover)
|
||||
val data = if (manga.chapters == null) repo.getDetails(manga) else manga
|
||||
output = MangaZip.findInDir(destination, data)
|
||||
output.prepare(data)
|
||||
val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
|
||||
output = CbzMangaOutput.get(destination, data)
|
||||
val coverUrl = data.largeCoverUrl ?: data.coverUrl
|
||||
downloadFile(coverUrl, data.publicUrl, destination).let { file ->
|
||||
downloadFile(coverUrl, data.publicUrl, destination, tempFileName).let { file ->
|
||||
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
|
||||
}
|
||||
val chapters = if (chaptersIds == null) {
|
||||
data.chapters.orEmpty()
|
||||
} else {
|
||||
data.chapters.orEmpty().filter { x -> x.id in chaptersIds }
|
||||
val chapters = checkNotNull(
|
||||
if (chaptersIdsSet == null) {
|
||||
data.chapters
|
||||
} else {
|
||||
data.chapters?.filter { x -> chaptersIdsSet.remove(x.id) }
|
||||
}
|
||||
) { "Chapters list must not be null" }
|
||||
check(chapters.isNotEmpty()) { "Chapters list must not be empty" }
|
||||
check(chaptersIdsSet.isNullOrEmpty()) {
|
||||
"${chaptersIdsSet?.size} of ${chaptersIds?.size} requested chapters not found in manga"
|
||||
}
|
||||
for ((chapterIndex, chapter) in chapters.withIndex()) {
|
||||
if (chaptersIds == null || chapter.id in chaptersIds) {
|
||||
val pages = repo.getPages(chapter)
|
||||
for ((pageIndex, page) in pages.withIndex()) {
|
||||
failsafe@ do {
|
||||
try {
|
||||
val url = repo.getPageUrl(page)
|
||||
val file =
|
||||
cache[url] ?: downloadFile(url, page.referer, destination)
|
||||
output.addPage(
|
||||
chapter,
|
||||
file,
|
||||
pageIndex,
|
||||
MimeTypeMap.getFileExtensionFromUrl(url)
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
val pages = repo.getPages(chapter)
|
||||
for ((pageIndex, page) in pages.withIndex()) {
|
||||
var retryCounter = 0
|
||||
failsafe@ while (true) {
|
||||
try {
|
||||
val url = repo.getPageUrl(page)
|
||||
val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName)
|
||||
output.addPage(
|
||||
chapter = chapter,
|
||||
file = file,
|
||||
pageNumber = pageIndex,
|
||||
ext = MimeTypeMap.getFileExtensionFromUrl(url),
|
||||
)
|
||||
break@failsafe
|
||||
} catch (e: IOException) {
|
||||
if (retryCounter < MAX_DOWNLOAD_ATTEMPTS) {
|
||||
outState.value = DownloadState.WaitingForNetwork(startId, data, cover)
|
||||
delay(DOWNLOAD_ERROR_DELAY)
|
||||
connectivityManager.waitForNetwork()
|
||||
continue@failsafe
|
||||
retryCounter++
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
} while (false)
|
||||
}
|
||||
}
|
||||
|
||||
outState.value = DownloadState.Progress(
|
||||
startId, data, cover,
|
||||
totalChapters = chapters.size,
|
||||
currentChapter = chapterIndex,
|
||||
totalPages = pages.size,
|
||||
currentPage = pageIndex,
|
||||
)
|
||||
outState.value = DownloadState.Progress(
|
||||
startId, data, cover,
|
||||
totalChapters = chapters.size,
|
||||
currentChapter = chapterIndex,
|
||||
totalPages = pages.size,
|
||||
currentPage = pageIndex,
|
||||
)
|
||||
|
||||
if (settings.isDownloadsSlowdownEnabled) {
|
||||
delay(SLOWDOWN_DELAY)
|
||||
}
|
||||
}
|
||||
}
|
||||
outState.value = DownloadState.PostProcessing(startId, data, cover)
|
||||
if (!output.compress()) {
|
||||
throw RuntimeException("Cannot create target file")
|
||||
}
|
||||
output.mergeWithExisting()
|
||||
output.finalize()
|
||||
val localManga = localMangaRepository.getFromFile(output.file)
|
||||
outState.value = DownloadState.Done(startId, data, cover, localManga)
|
||||
} catch (_: CancellationException) {
|
||||
} catch (e: CancellationException) {
|
||||
outState.value = DownloadState.Cancelled(startId, manga, cover)
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
e.printStackTrace()
|
||||
@@ -151,14 +163,15 @@ class DownloadManager(
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
output?.cleanup()
|
||||
File(destination, TEMP_PAGE_FILE).deleteAwait()
|
||||
File(destination, tempFileName).deleteAwait()
|
||||
}
|
||||
coroutineContext[WakeLockNode]?.release()
|
||||
semaphore.release()
|
||||
localMangaRepository.unlockManga(manga.id)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadFile(url: String, referer: String, destination: File): File {
|
||||
private suspend fun downloadFile(url: String, referer: String, destination: File, tempFileName: String): File {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header(CommonHeaders.REFERER, referer)
|
||||
@@ -166,35 +179,55 @@ class DownloadManager(
|
||||
.get()
|
||||
.build()
|
||||
val call = okHttp.newCall(request)
|
||||
var attempts = MAX_DOWNLOAD_ATTEMPTS
|
||||
val file = File(destination, TEMP_PAGE_FILE)
|
||||
while (true) {
|
||||
try {
|
||||
val response = call.clone().await()
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
file.outputStream().use { out ->
|
||||
checkNotNull(response.body).byteStream().copyTo(out)
|
||||
}
|
||||
}
|
||||
return file
|
||||
} catch (e: IOException) {
|
||||
attempts--
|
||||
if (attempts <= 0) {
|
||||
throw e
|
||||
} else {
|
||||
delay(DOWNLOAD_ERROR_DELAY)
|
||||
}
|
||||
val file = File(destination, tempFileName)
|
||||
val response = call.clone().await()
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
file.outputStream().use { out ->
|
||||
checkNotNull(response.body).byteStream().copyTo(out)
|
||||
}
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) = CoroutineExceptionHandler { _, throwable ->
|
||||
val prevValue = outState.value
|
||||
outState.value = DownloadState.Error(
|
||||
startId = prevValue.startId,
|
||||
manga = prevValue.manga,
|
||||
cover = prevValue.cover,
|
||||
error = throwable,
|
||||
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) =
|
||||
CoroutineExceptionHandler { _, throwable ->
|
||||
val prevValue = outState.value
|
||||
outState.value = DownloadState.Error(
|
||||
startId = prevValue.startId,
|
||||
manga = prevValue.manga,
|
||||
cover = prevValue.cover,
|
||||
error = throwable,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun loadCover(manga: Manga) = runCatching {
|
||||
imageLoader.execute(
|
||||
ImageRequest.Builder(context)
|
||||
.data(manga.coverUrl)
|
||||
.referer(manga.publicUrl)
|
||||
.size(coverWidth, coverHeight)
|
||||
.scale(Scale.FILL)
|
||||
.build()
|
||||
).drawable
|
||||
}.getOrNull()
|
||||
|
||||
class Factory(
|
||||
private val context: Context,
|
||||
private val imageLoader: ImageLoader,
|
||||
private val okHttp: OkHttpClient,
|
||||
private val cache: PagesCache,
|
||||
private val localMangaRepository: LocalMangaRepository,
|
||||
private val settings: AppSettings,
|
||||
) {
|
||||
|
||||
fun create(coroutineScope: CoroutineScope) = DownloadManager(
|
||||
coroutineScope = coroutineScope,
|
||||
context = context,
|
||||
imageLoader = imageLoader,
|
||||
okHttp = okHttp,
|
||||
cache = cache,
|
||||
localMangaRepository = localMangaRepository,
|
||||
settings = settings,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.onEach
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.ItemDownloadBinding
|
||||
import org.koitharu.kotatsu.download.domain.DownloadState
|
||||
import org.koitharu.kotatsu.parsers.util.format
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.text.format.DateUtils
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -16,8 +17,8 @@ import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.download.domain.DownloadState
|
||||
import org.koitharu.kotatsu.download.ui.DownloadsActivity
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.format
|
||||
import org.koitharu.kotatsu.utils.PendingIntentCompat
|
||||
import org.koitharu.kotatsu.utils.ext.format
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@@ -49,7 +50,7 @@ class DownloadNotification(private val context: Context, startId: Int) {
|
||||
builder.setSilent(true)
|
||||
}
|
||||
|
||||
fun create(state: DownloadState): Notification {
|
||||
fun create(state: DownloadState, timeLeft: Long): Notification {
|
||||
builder.setContentTitle(state.manga.title)
|
||||
builder.setContentText(context.getString(R.string.manga_downloading_))
|
||||
builder.setProgress(1, 0, true)
|
||||
@@ -117,7 +118,13 @@ class DownloadNotification(private val context: Context, startId: Int) {
|
||||
}
|
||||
is DownloadState.Progress -> {
|
||||
builder.setProgress(state.max, state.progress, false)
|
||||
builder.setContentText((state.percent * 100).format() + "%")
|
||||
if (timeLeft > 0L) {
|
||||
val eta = DateUtils.getRelativeTimeSpanString(timeLeft, 0L, DateUtils.SECOND_IN_MILLIS)
|
||||
builder.setContentText(eta)
|
||||
} else {
|
||||
val percent = (state.percent * 100).format()
|
||||
builder.setContentText(context.getString(R.string.percent_string_pattern, percent))
|
||||
}
|
||||
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
builder.setStyle(null)
|
||||
builder.setOngoing(true)
|
||||
|
||||
@@ -10,10 +10,8 @@ import android.os.PowerManager
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.transformWhile
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koin.android.ext.android.get
|
||||
@@ -23,7 +21,6 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseService
|
||||
import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.model.withoutChapters
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.download.domain.DownloadManager
|
||||
import org.koitharu.kotatsu.download.domain.DownloadState
|
||||
@@ -31,10 +28,9 @@ import org.koitharu.kotatsu.download.domain.WakeLockNode
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.utils.ext.connectivityManager
|
||||
import org.koitharu.kotatsu.utils.ext.throttle
|
||||
import org.koitharu.kotatsu.utils.ext.toArraySet
|
||||
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
||||
import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.collections.set
|
||||
|
||||
class DownloadService : BaseService() {
|
||||
|
||||
@@ -48,16 +44,12 @@ class DownloadService : BaseService() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
isRunning = true
|
||||
notificationSwitcher = ForegroundNotificationSwitcher(this)
|
||||
val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
|
||||
downloadManager = DownloadManager(
|
||||
downloadManager = get<DownloadManager.Factory>().create(
|
||||
coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)),
|
||||
context = this,
|
||||
imageLoader = get(),
|
||||
okHttp = get(),
|
||||
cache = get(),
|
||||
localMangaRepository = get(),
|
||||
)
|
||||
DownloadNotification.createChannel(this)
|
||||
registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL))
|
||||
@@ -66,11 +58,10 @@ class DownloadService : BaseService() {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
val manga = intent?.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga
|
||||
val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toArraySet()
|
||||
val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS)
|
||||
return if (manga != null) {
|
||||
jobs[startId] = downloadManga(startId, manga, chapters)
|
||||
jobCount.value = jobs.size
|
||||
Toast.makeText(this, R.string.manga_downloading_, Toast.LENGTH_SHORT).show()
|
||||
START_REDELIVER_INTENT
|
||||
} else {
|
||||
stopSelf(startId)
|
||||
@@ -91,13 +82,14 @@ class DownloadService : BaseService() {
|
||||
override fun onDestroy() {
|
||||
unregisterReceiver(controlReceiver)
|
||||
binder = null
|
||||
isRunning = false
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun downloadManga(
|
||||
startId: Int,
|
||||
manga: Manga,
|
||||
chaptersIds: Set<Long>?,
|
||||
chaptersIds: LongArray?,
|
||||
): ProgressJob<DownloadState> {
|
||||
val job = downloadManager.downloadManga(manga, chaptersIds, startId)
|
||||
listenJob(job)
|
||||
@@ -107,19 +99,28 @@ class DownloadService : BaseService() {
|
||||
private fun listenJob(job: ProgressJob<DownloadState>) {
|
||||
lifecycleScope.launch {
|
||||
val startId = job.progressValue.startId
|
||||
val timeLeftEstimator = TimeLeftEstimator()
|
||||
val notification = DownloadNotification(this@DownloadService, startId)
|
||||
notificationSwitcher.notify(startId, notification.create(job.progressValue))
|
||||
notificationSwitcher.notify(startId, notification.create(job.progressValue, -1L))
|
||||
job.progressAsFlow()
|
||||
.onEach { state ->
|
||||
if (state is DownloadState.Progress) {
|
||||
timeLeftEstimator.tick(value = state.progress, total = state.max)
|
||||
} else {
|
||||
timeLeftEstimator.emptyTick()
|
||||
}
|
||||
}
|
||||
.throttle { state -> if (state is DownloadState.Progress) 400L else 0L }
|
||||
.whileActive()
|
||||
.collect { state ->
|
||||
notificationSwitcher.notify(startId, notification.create(state))
|
||||
val timeLeft = timeLeftEstimator.getEstimatedTimeLeft()
|
||||
notificationSwitcher.notify(startId, notification.create(state, timeLeft))
|
||||
}
|
||||
job.join()
|
||||
(job.progressValue as? DownloadState.Done)?.let {
|
||||
sendBroadcast(
|
||||
Intent(ACTION_DOWNLOAD_COMPLETE)
|
||||
.putExtra(EXTRA_MANGA, ParcelableManga(it.localManga.withoutChapters()))
|
||||
.putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false))
|
||||
)
|
||||
}
|
||||
notificationSwitcher.detach(
|
||||
@@ -127,7 +128,7 @@ class DownloadService : BaseService() {
|
||||
if (job.isCancelled) {
|
||||
null
|
||||
} else {
|
||||
notification.create(job.progressValue)
|
||||
notification.create(job.progressValue, -1L)
|
||||
}
|
||||
)
|
||||
stopSelf(startId)
|
||||
@@ -163,11 +164,12 @@ class DownloadService : BaseService() {
|
||||
|
||||
companion object {
|
||||
|
||||
const val ACTION_DOWNLOAD_COMPLETE =
|
||||
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE"
|
||||
var isRunning: Boolean = false
|
||||
private set
|
||||
|
||||
private const val ACTION_DOWNLOAD_CANCEL =
|
||||
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
|
||||
const val ACTION_DOWNLOAD_COMPLETE = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE"
|
||||
|
||||
private const val ACTION_DOWNLOAD_CANCEL = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
|
||||
|
||||
private const val EXTRA_MANGA = "manga"
|
||||
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
|
||||
@@ -179,14 +181,38 @@ class DownloadService : BaseService() {
|
||||
}
|
||||
confirmDataTransfer(context) {
|
||||
val intent = Intent(context, DownloadService::class.java)
|
||||
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga))
|
||||
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
|
||||
if (chaptersIds != null) {
|
||||
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
|
||||
}
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
Toast.makeText(context, R.string.manga_downloading_, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun start(context: Context, manga: Collection<Manga>) {
|
||||
if (manga.isEmpty()) {
|
||||
return
|
||||
}
|
||||
confirmDataTransfer(context) {
|
||||
for (item in manga) {
|
||||
val intent = Intent(context, DownloadService::class.java)
|
||||
intent.putExtra(EXTRA_MANGA, ParcelableManga(item, withChapters = false))
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun confirmAndStart(context: Context, items: Set<Manga>) {
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(R.string.save_manga)
|
||||
.setMessage(R.string.batch_manga_save_confirm)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string.save) { _, _ ->
|
||||
start(context, items)
|
||||
}.show()
|
||||
}
|
||||
|
||||
fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL)
|
||||
.putExtra(EXTRA_CANCEL_ID, startId)
|
||||
|
||||
|
||||
@@ -24,9 +24,10 @@ class ForegroundNotificationSwitcher(
|
||||
@Synchronized
|
||||
fun notify(startId: Int, notification: Notification) {
|
||||
if (notifications.isEmpty()) {
|
||||
handler.postDelayed(StartForegroundRunnable(startId, notification), DEFAULT_DELAY)
|
||||
service.startForeground(startId, notification)
|
||||
} else {
|
||||
notificationManager.notify(startId, notification)
|
||||
}
|
||||
notificationManager.notify(startId, notification)
|
||||
notifications[startId] = notification
|
||||
}
|
||||
|
||||
@@ -45,16 +46,6 @@ class ForegroundNotificationSwitcher(
|
||||
handler.postDelayed(NotifyRunnable(startId, notification), DEFAULT_DELAY)
|
||||
}
|
||||
|
||||
private inner class StartForegroundRunnable(
|
||||
private val startId: Int,
|
||||
private val notification: Notification,
|
||||
) : Runnable {
|
||||
|
||||
override fun run() {
|
||||
service.startForeground(startId, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class NotifyRunnable(
|
||||
private val startId: Int,
|
||||
private val notification: Notification?,
|
||||
|
||||
@@ -15,7 +15,7 @@ val favouritesModule
|
||||
viewModel { categoryId ->
|
||||
FavouritesListViewModel(categoryId.get(), get(), get(), get())
|
||||
}
|
||||
viewModel { FavouritesCategoriesViewModel(get()) }
|
||||
viewModel { FavouritesCategoriesViewModel(get(), get()) }
|
||||
viewModel { manga ->
|
||||
MangaCategoriesViewModel(manga.get(), get())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.koitharu.kotatsu.favourites.data
|
||||
|
||||
import java.util.*
|
||||
import org.koitharu.kotatsu.core.db.entity.SortOrder
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
|
||||
fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory(
|
||||
id = id,
|
||||
title = title,
|
||||
sortKey = sortKey,
|
||||
order = SortOrder(order, SortOrder.NEWEST),
|
||||
createdAt = Date(createdAt),
|
||||
)
|
||||
@@ -3,9 +3,6 @@ package org.koitharu.kotatsu.favourites.data
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import java.util.*
|
||||
|
||||
@Entity(tableName = "favourite_categories")
|
||||
class FavouriteCategoryEntity(
|
||||
@@ -15,13 +12,4 @@ class FavouriteCategoryEntity(
|
||||
@ColumnInfo(name = "sort_key") val sortKey: Int,
|
||||
@ColumnInfo(name = "title") val title: String,
|
||||
@ColumnInfo(name = "order") val order: String,
|
||||
) {
|
||||
|
||||
fun toFavouriteCategory(id: Long? = null) = FavouriteCategory(
|
||||
id = id ?: categoryId.toLong(),
|
||||
title = title,
|
||||
sortKey = sortKey,
|
||||
order = SortOrder.values().find { x -> x.name == order } ?: SortOrder.NEWEST,
|
||||
createdAt = Date(createdAt),
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -6,36 +6,35 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.*
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
||||
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.utils.ext.mapItems
|
||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
||||
|
||||
class FavouritesRepository(private val db: MangaDatabase) {
|
||||
|
||||
suspend fun getAllManga(): List<Manga> {
|
||||
val entities = db.favouritesDao.findAll()
|
||||
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
|
||||
return entities.map { it.manga.toManga(it.tags.toMangaTags()) }
|
||||
}
|
||||
|
||||
fun observeAll(order: SortOrder): Flow<List<Manga>> {
|
||||
return db.favouritesDao.observeAll(order)
|
||||
.mapItems { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
|
||||
.mapItems { it.manga.toManga(it.tags.toMangaTags()) }
|
||||
}
|
||||
|
||||
suspend fun getManga(categoryId: Long): List<Manga> {
|
||||
val entities = db.favouritesDao.findAll(categoryId)
|
||||
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
|
||||
return entities.map { it.manga.toManga(it.tags.toMangaTags()) }
|
||||
}
|
||||
|
||||
fun observeAll(categoryId: Long, order: SortOrder): Flow<List<Manga>> {
|
||||
return db.favouritesDao.observeAll(categoryId, order)
|
||||
.mapItems { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
|
||||
.mapItems { it.manga.toManga(it.tags.toMangaTags()) }
|
||||
}
|
||||
|
||||
fun observeAll(categoryId: Long): Flow<List<Manga>> {
|
||||
@@ -43,21 +42,6 @@ class FavouritesRepository(private val db: MangaDatabase) {
|
||||
.flatMapLatest { order -> observeAll(categoryId, order) }
|
||||
}
|
||||
|
||||
suspend fun getManga(categoryId: Long, offset: Int): List<Manga> {
|
||||
val entities = db.favouritesDao.findAll(categoryId, offset, 20)
|
||||
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
|
||||
}
|
||||
|
||||
suspend fun getAllCategories(): List<FavouriteCategory> {
|
||||
val entities = db.favouriteCategoriesDao.findAll()
|
||||
return entities.map { it.toFavouriteCategory() }
|
||||
}
|
||||
|
||||
suspend fun getCategories(mangaId: Long): List<FavouriteCategory> {
|
||||
val entities = db.favouritesDao.find(mangaId)?.categories
|
||||
return entities?.map { it.toFavouriteCategory() }.orEmpty()
|
||||
}
|
||||
|
||||
fun observeCategories(): Flow<List<FavouriteCategory>> {
|
||||
return db.favouriteCategoriesDao.observeAll().mapItems {
|
||||
it.toFavouriteCategory()
|
||||
@@ -70,8 +54,8 @@ class FavouritesRepository(private val db: MangaDatabase) {
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
|
||||
fun observeCategoriesIds(mangaId: Long): Flow<List<Long>> {
|
||||
return db.favouritesDao.observeIds(mangaId)
|
||||
fun observeCategoriesIds(mangaId: Long): Flow<Set<Long>> {
|
||||
return db.favouritesDao.observeIds(mangaId).map { it.toSet() }
|
||||
}
|
||||
|
||||
suspend fun addCategory(title: String): FavouriteCategory {
|
||||
@@ -80,7 +64,7 @@ class FavouritesRepository(private val db: MangaDatabase) {
|
||||
createdAt = System.currentTimeMillis(),
|
||||
sortKey = db.favouriteCategoriesDao.getNextSortKey(),
|
||||
categoryId = 0,
|
||||
order = SortOrder.UPDATED.name,
|
||||
order = SortOrder.NEWEST.name,
|
||||
)
|
||||
val id = db.favouriteCategoriesDao.insert(entity)
|
||||
return entity.toFavouriteCategory(id)
|
||||
@@ -107,27 +91,37 @@ class FavouritesRepository(private val db: MangaDatabase) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addToCategory(manga: Manga, categoryId: Long) {
|
||||
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
|
||||
suspend fun addToCategory(categoryId: Long, mangas: Collection<Manga>) {
|
||||
db.withTransaction {
|
||||
db.tagsDao.upsert(tags)
|
||||
db.mangaDao.upsert(MangaEntity.from(manga), tags)
|
||||
val entity = FavouriteEntity(manga.id, categoryId, System.currentTimeMillis())
|
||||
db.favouritesDao.insert(entity)
|
||||
for (manga in mangas) {
|
||||
val tags = manga.tags.toEntities()
|
||||
db.tagsDao.upsert(tags)
|
||||
db.mangaDao.upsert(manga.toEntity(), tags)
|
||||
val entity = FavouriteEntity(manga.id, categoryId, System.currentTimeMillis())
|
||||
db.favouritesDao.insert(entity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeFromCategory(manga: Manga, categoryId: Long) {
|
||||
db.favouritesDao.delete(categoryId, manga.id)
|
||||
suspend fun removeFromFavourites(ids: Collection<Long>) {
|
||||
db.withTransaction {
|
||||
for (id in ids) {
|
||||
db.favouritesDao.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeFromFavourites(manga: Manga) {
|
||||
db.favouritesDao.delete(manga.id)
|
||||
suspend fun removeFromCategory(categoryId: Long, ids: Collection<Long>) {
|
||||
db.withTransaction {
|
||||
for (id in ids) {
|
||||
db.favouritesDao.delete(categoryId, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeOrder(categoryId: Long): Flow<SortOrder> {
|
||||
return db.favouriteCategoriesDao.observe(categoryId)
|
||||
.map { x -> SortOrder.values().find { it.name == x.order } ?: SortOrder.NEWEST }
|
||||
.map { x -> SortOrder(x.order, SortOrder.NEWEST) }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
}
|
||||
@@ -2,29 +2,36 @@ package org.koitharu.kotatsu.favourites.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.*
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.children
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.base.ui.util.ActionModeListener
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.ui.titleRes
|
||||
import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
|
||||
import org.koitharu.kotatsu.main.ui.AppBarOwner
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.utils.ext.measureHeight
|
||||
import org.koitharu.kotatsu.utils.ext.showPopupMenu
|
||||
import java.util.*
|
||||
import org.koitharu.kotatsu.utils.ext.resolveDp
|
||||
|
||||
class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
|
||||
FavouritesTabLongClickListener, CategoriesEditDelegate.CategoriesEditCallback {
|
||||
class FavouritesContainerFragment :
|
||||
BaseFragment<FragmentFavouritesBinding>(),
|
||||
FavouritesTabLongClickListener,
|
||||
CategoriesEditDelegate.CategoriesEditCallback,
|
||||
ActionModeListener {
|
||||
|
||||
private val viewModel by viewModel<FavouritesCategoriesViewModel>()
|
||||
private val editDelegate by lazy(LazyThreadSafetyMode.NONE) {
|
||||
@@ -45,14 +52,15 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val adapter = FavouritesPagerAdapter(this, this)
|
||||
viewModel.categories.value?.let {
|
||||
adapter.replaceData(wrapCategories(it))
|
||||
viewModel.visibleCategories.value?.let {
|
||||
adapter.replaceData(it)
|
||||
}
|
||||
binding.pager.adapter = adapter
|
||||
pagerAdapter = adapter
|
||||
TabLayoutMediator(binding.tabs, binding.pager, adapter).attach()
|
||||
actionModeDelegate.addListener(this, viewLifecycleOwner)
|
||||
|
||||
viewModel.categories.observe(viewLifecycleOwner, ::onCategoriesChanged)
|
||||
viewModel.visibleCategories.observe(viewLifecycleOwner, ::onCategoriesChanged)
|
||||
viewModel.onError.observe(viewLifecycleOwner, ::onError)
|
||||
}
|
||||
|
||||
@@ -61,13 +69,24 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onActionModeStarted(mode: ActionMode) {
|
||||
binding.pager.isUserInputEnabled = false
|
||||
binding.tabs.setTabsEnabled(false)
|
||||
}
|
||||
|
||||
override fun onActionModeFinished(mode: ActionMode) {
|
||||
binding.pager.isUserInputEnabled = true
|
||||
binding.tabs.setTabsEnabled(true)
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
|
||||
binding.root.updatePadding(
|
||||
top = headerHeight - insets.top
|
||||
)
|
||||
binding.pager.updatePadding(
|
||||
top = -headerHeight
|
||||
// 8 dp is needed so that the top of the list is not attached to tabs (visible when ActionMode is active)
|
||||
top = -headerHeight + resources.resolveDp(8)
|
||||
)
|
||||
binding.tabs.apply {
|
||||
updatePadding(
|
||||
@@ -80,8 +99,8 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
|
||||
}
|
||||
}
|
||||
|
||||
private fun onCategoriesChanged(categories: List<FavouriteCategory>) {
|
||||
pagerAdapter?.replaceData(wrapCategories(categories))
|
||||
private fun onCategoriesChanged(categories: List<CategoryListModel>) {
|
||||
pagerAdapter?.replaceData(categories)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
@@ -103,23 +122,10 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
|
||||
Snackbar.make(binding.pager, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
override fun onTabLongClick(tabView: View, category: FavouriteCategory): Boolean {
|
||||
val menuRes = if (category.id == 0L) R.menu.popup_category_empty else R.menu.popup_category
|
||||
tabView.showPopupMenu(menuRes, { menu ->
|
||||
createOrderSubmenu(menu, category)
|
||||
}) {
|
||||
when (it.itemId) {
|
||||
R.id.action_remove -> editDelegate.deleteCategory(category)
|
||||
R.id.action_rename -> editDelegate.renameCategory(category)
|
||||
R.id.action_create -> editDelegate.createCategory()
|
||||
R.id.action_order -> return@showPopupMenu false
|
||||
else -> {
|
||||
val order = CategoriesActivity.SORT_ORDERS.getOrNull(it.order)
|
||||
?: return@showPopupMenu false
|
||||
viewModel.setCategoryOrder(category.id, order)
|
||||
}
|
||||
}
|
||||
true
|
||||
override fun onTabLongClick(tabView: View, item: CategoryListModel): Boolean {
|
||||
when (item) {
|
||||
is CategoryListModel.All -> showAllCategoriesMenu(tabView)
|
||||
is CategoryListModel.CategoryItem -> showCategoryMenu(tabView, item.category)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -136,28 +142,57 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
|
||||
viewModel.createCategory(name)
|
||||
}
|
||||
|
||||
private fun wrapCategories(categories: List<FavouriteCategory>): List<FavouriteCategory> {
|
||||
val data = ArrayList<FavouriteCategory>(categories.size + 1)
|
||||
data += FavouriteCategory(0L, getString(R.string.all_favourites), -1, SortOrder.NEWEST, Date())
|
||||
data += categories
|
||||
return data
|
||||
}
|
||||
|
||||
private fun createOrderSubmenu(menu: Menu, category: FavouriteCategory) {
|
||||
val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return
|
||||
for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) {
|
||||
val menuItem = submenu.add(
|
||||
R.id.group_order,
|
||||
Menu.NONE,
|
||||
i,
|
||||
item.titleRes
|
||||
)
|
||||
val menuItem = submenu.add(R.id.group_order, Menu.NONE, i, item.titleRes)
|
||||
menuItem.isCheckable = true
|
||||
menuItem.isChecked = item == category.order
|
||||
}
|
||||
submenu.setGroupCheckable(R.id.group_order, true, true)
|
||||
}
|
||||
|
||||
private fun TabLayout.setTabsEnabled(enabled: Boolean) {
|
||||
val tabStrip = getChildAt(0) as? ViewGroup ?: return
|
||||
for (tab in tabStrip.children) {
|
||||
tab.isEnabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
private fun showCategoryMenu(tabView: View, category: FavouriteCategory) {
|
||||
val menu = PopupMenu(tabView.context, tabView)
|
||||
menu.inflate(R.menu.popup_category)
|
||||
createOrderSubmenu(menu.menu, category)
|
||||
menu.setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.action_remove -> editDelegate.deleteCategory(category)
|
||||
R.id.action_rename -> editDelegate.renameCategory(category)
|
||||
R.id.action_create -> editDelegate.createCategory()
|
||||
R.id.action_order -> return@setOnMenuItemClickListener false
|
||||
else -> {
|
||||
val order = CategoriesActivity.SORT_ORDERS.getOrNull(it.order)
|
||||
?: return@setOnMenuItemClickListener false
|
||||
viewModel.setCategoryOrder(category.id, order)
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
menu.show()
|
||||
}
|
||||
|
||||
private fun showAllCategoriesMenu(tabView: View) {
|
||||
val menu = PopupMenu(tabView.context, tabView)
|
||||
menu.inflate(R.menu.popup_category_all)
|
||||
menu.setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.action_create -> editDelegate.createCategory()
|
||||
R.id.action_hide -> viewModel.setAllCategoriesVisible(false)
|
||||
}
|
||||
true
|
||||
}
|
||||
menu.show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newInstance() = FavouritesContainerFragment()
|
||||
|
||||
@@ -7,14 +7,16 @@ import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
|
||||
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment
|
||||
|
||||
class FavouritesPagerAdapter(
|
||||
fragment: Fragment,
|
||||
private val longClickListener: FavouritesTabLongClickListener
|
||||
) : FragmentStateAdapter(fragment.childFragmentManager, fragment.viewLifecycleOwner.lifecycle),
|
||||
TabLayoutMediator.TabConfigurationStrategy, View.OnLongClickListener {
|
||||
TabLayoutMediator.TabConfigurationStrategy,
|
||||
View.OnLongClickListener {
|
||||
|
||||
private val differ = AsyncListDiffer(this, DiffCallback())
|
||||
|
||||
@@ -35,12 +37,15 @@ class FavouritesPagerAdapter(
|
||||
|
||||
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
|
||||
val item = differ.currentList[position]
|
||||
tab.text = item.title
|
||||
tab.text = when (item) {
|
||||
is CategoryListModel.All -> tab.view.context.getString(R.string.all_favourites)
|
||||
is CategoryListModel.CategoryItem -> item.category.title
|
||||
}
|
||||
tab.view.tag = item.id
|
||||
tab.view.setOnLongClickListener(this)
|
||||
}
|
||||
|
||||
fun replaceData(data: List<FavouriteCategory>) {
|
||||
fun replaceData(data: List<CategoryListModel>) {
|
||||
differ.submitList(data)
|
||||
}
|
||||
|
||||
@@ -50,16 +55,22 @@ class FavouritesPagerAdapter(
|
||||
return longClickListener.onTabLongClick(v, item)
|
||||
}
|
||||
|
||||
private class DiffCallback : DiffUtil.ItemCallback<FavouriteCategory>() {
|
||||
private class DiffCallback : DiffUtil.ItemCallback<CategoryListModel>() {
|
||||
|
||||
override fun areItemsTheSame(
|
||||
oldItem: FavouriteCategory,
|
||||
newItem: FavouriteCategory
|
||||
): Boolean = oldItem.id == newItem.id
|
||||
oldItem: CategoryListModel,
|
||||
newItem: CategoryListModel
|
||||
): Boolean = when {
|
||||
oldItem is CategoryListModel.All && newItem is CategoryListModel.All -> true
|
||||
oldItem is CategoryListModel.CategoryItem && newItem is CategoryListModel.CategoryItem -> {
|
||||
oldItem.category.id == newItem.category.id
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: FavouriteCategory,
|
||||
newItem: FavouriteCategory
|
||||
): Boolean = oldItem.id == newItem.id && oldItem.title == newItem.title
|
||||
oldItem: CategoryListModel,
|
||||
newItem: CategoryListModel
|
||||
): Boolean = oldItem == newItem
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package org.koitharu.kotatsu.favourites.ui
|
||||
|
||||
import android.view.View
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
|
||||
|
||||
fun interface FavouritesTabLongClickListener {
|
||||
|
||||
fun onTabLongClick(tabView: View, category: FavouriteCategory): Boolean
|
||||
fun onTabLongClick(tabView: View, item: CategoryListModel): Boolean
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.koitharu.kotatsu.favourites.ui.categories
|
||||
|
||||
interface AllCategoriesToggleListener {
|
||||
|
||||
fun onAllCategoriesToggle(isVisible: Boolean)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
@@ -20,14 +21,16 @@ import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.ui.titleRes
|
||||
import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.utils.ext.measureHeight
|
||||
import org.koitharu.kotatsu.utils.ext.showPopupMenu
|
||||
|
||||
class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
|
||||
class CategoriesActivity :
|
||||
BaseActivity<ActivityCategoriesBinding>(),
|
||||
OnListItemClickListener<FavouriteCategory>,
|
||||
View.OnClickListener, CategoriesEditDelegate.CategoriesEditCallback {
|
||||
View.OnClickListener,
|
||||
CategoriesEditDelegate.CategoriesEditCallback, AllCategoriesToggleListener {
|
||||
|
||||
private val viewModel by viewModel<FavouritesCategoriesViewModel>()
|
||||
|
||||
@@ -39,7 +42,7 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityCategoriesBinding.inflate(layoutInflater))
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
adapter = CategoriesAdapter(this)
|
||||
adapter = CategoriesAdapter(this, this)
|
||||
editDelegate = CategoriesEditDelegate(this, this)
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
binding.recyclerView.adapter = adapter
|
||||
@@ -47,7 +50,7 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
|
||||
reorderHelper = ItemTouchHelper(ReorderHelperCallback())
|
||||
reorderHelper.attachToRecyclerView(binding.recyclerView)
|
||||
|
||||
viewModel.categories.observe(this, ::onCategoriesChanged)
|
||||
viewModel.allCategories.observe(this, ::onCategoriesChanged)
|
||||
viewModel.onError.observe(this, ::onError)
|
||||
}
|
||||
|
||||
@@ -58,29 +61,34 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
|
||||
}
|
||||
|
||||
override fun onItemClick(item: FavouriteCategory, view: View) {
|
||||
view.showPopupMenu(R.menu.popup_category, { menu ->
|
||||
createOrderSubmenu(menu, item)
|
||||
}) {
|
||||
when (it.itemId) {
|
||||
val menu = PopupMenu(view.context, view)
|
||||
menu.inflate(R.menu.popup_category)
|
||||
createOrderSubmenu(menu.menu, item)
|
||||
menu.setOnMenuItemClickListener { menuItem ->
|
||||
when (menuItem.itemId) {
|
||||
R.id.action_remove -> editDelegate.deleteCategory(item)
|
||||
R.id.action_rename -> editDelegate.renameCategory(item)
|
||||
R.id.action_order -> return@showPopupMenu false
|
||||
R.id.action_order -> return@setOnMenuItemClickListener false
|
||||
else -> {
|
||||
val order = SORT_ORDERS.getOrNull(it.order) ?: return@showPopupMenu false
|
||||
val order = SORT_ORDERS.getOrNull(menuItem.order) ?: return@setOnMenuItemClickListener false
|
||||
viewModel.setCategoryOrder(item.id, order)
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
menu.show()
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: FavouriteCategory, view: View): Boolean {
|
||||
reorderHelper.startDrag(
|
||||
binding.recyclerView.findContainingViewHolder(view) ?: return false
|
||||
)
|
||||
val viewHolder = binding.recyclerView.findContainingViewHolder(view) ?: return false
|
||||
reorderHelper.startDrag(viewHolder)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onAllCategoriesToggle(isVisible: Boolean) {
|
||||
viewModel.setAllCategoriesVisible(isVisible)
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
binding.fabAdd.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
rightMargin = topMargin + insets.right
|
||||
@@ -90,11 +98,11 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
|
||||
binding.recyclerView.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
bottom = 2 * insets.bottom + binding.fabAdd.measureHeight()
|
||||
bottom = 2 * insets.bottom + binding.fabAdd.measureHeight(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun onCategoriesChanged(categories: List<FavouriteCategory>) {
|
||||
private fun onCategoriesChanged(categories: List<CategoryListModel>) {
|
||||
adapter.items = categories
|
||||
binding.textViewHolder.isVisible = categories.isEmpty()
|
||||
}
|
||||
@@ -135,13 +143,19 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
|
||||
ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0
|
||||
) {
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
|
||||
|
||||
override fun onMove(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder,
|
||||
): Boolean = true
|
||||
): Boolean = viewHolder.itemViewType == target.itemViewType
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
|
||||
override fun canDropOver(
|
||||
recyclerView: RecyclerView,
|
||||
current: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder,
|
||||
): Boolean = current.itemViewType == target.itemViewType
|
||||
|
||||
override fun onMoved(
|
||||
recyclerView: RecyclerView,
|
||||
@@ -155,6 +169,8 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
|
||||
super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y)
|
||||
viewModel.reorderCategories(fromPos, toPos)
|
||||
}
|
||||
|
||||
override fun isLongPressDragEnabled(): Boolean = false
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -4,13 +4,18 @@ import androidx.recyclerview.widget.DiffUtil
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.adapter.allCategoriesAD
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.adapter.categoryAD
|
||||
|
||||
class CategoriesAdapter(
|
||||
onItemClickListener: OnListItemClickListener<FavouriteCategory>,
|
||||
) : AsyncListDifferDelegationAdapter<FavouriteCategory>(DiffCallback()) {
|
||||
allCategoriesToggleListener: AllCategoriesToggleListener,
|
||||
) : AsyncListDifferDelegationAdapter<CategoryListModel>(DiffCallback()) {
|
||||
|
||||
init {
|
||||
delegatesManager.addDelegate(categoryAD(onItemClickListener))
|
||||
.addDelegate(allCategoriesAD(allCategoriesToggleListener))
|
||||
setHasStableIds(true)
|
||||
}
|
||||
|
||||
@@ -18,28 +23,23 @@ class CategoriesAdapter(
|
||||
return items[position].id
|
||||
}
|
||||
|
||||
private class DiffCallback : DiffUtil.ItemCallback<FavouriteCategory>() {
|
||||
private class DiffCallback : DiffUtil.ItemCallback<CategoryListModel>() {
|
||||
|
||||
override fun areItemsTheSame(
|
||||
oldItem: FavouriteCategory,
|
||||
newItem: FavouriteCategory,
|
||||
): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
oldItem: CategoryListModel,
|
||||
newItem: CategoryListModel,
|
||||
): Boolean = oldItem.id == newItem.id
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: FavouriteCategory,
|
||||
newItem: FavouriteCategory,
|
||||
): Boolean {
|
||||
return oldItem.id == newItem.id && oldItem.title == newItem.title
|
||||
&& oldItem.order == newItem.order
|
||||
}
|
||||
oldItem: CategoryListModel,
|
||||
newItem: CategoryListModel,
|
||||
): Boolean = oldItem == newItem
|
||||
|
||||
override fun getChangePayload(
|
||||
oldItem: FavouriteCategory,
|
||||
newItem: FavouriteCategory,
|
||||
oldItem: CategoryListModel,
|
||||
newItem: CategoryListModel,
|
||||
): Any? = when {
|
||||
oldItem.title == newItem.title && oldItem.order != newItem.order -> newItem.order
|
||||
oldItem is CategoryListModel.All && newItem is CategoryListModel.All -> Unit
|
||||
else -> super.getChangePayload(oldItem, newItem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,20 +3,36 @@ package org.koitharu.kotatsu.favourites.ui.categories
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.*
|
||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||
import java.util.*
|
||||
|
||||
class FavouritesCategoriesViewModel(
|
||||
private val repository: FavouritesRepository
|
||||
private val repository: FavouritesRepository,
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private var reorderJob: Job? = null
|
||||
|
||||
val categories = repository.observeCategories()
|
||||
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||
val allCategories = combine(
|
||||
repository.observeCategories(),
|
||||
observeAllCategoriesVisible(),
|
||||
) { list, showAll ->
|
||||
mapCategories(list, showAll, true)
|
||||
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||
|
||||
val visibleCategories = combine(
|
||||
repository.observeCategories(),
|
||||
observeAllCategoriesVisible(),
|
||||
) { list, showAll ->
|
||||
mapCategories(list, showAll, showAll)
|
||||
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||
|
||||
fun createCategory(name: String) {
|
||||
launchJob {
|
||||
@@ -42,14 +58,40 @@ class FavouritesCategoriesViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun setAllCategoriesVisible(isVisible: Boolean) {
|
||||
settings.isAllFavouritesVisible = isVisible
|
||||
}
|
||||
|
||||
fun reorderCategories(oldPos: Int, newPos: Int) {
|
||||
val prevJob = reorderJob
|
||||
reorderJob = launchJob(Dispatchers.Default) {
|
||||
prevJob?.join()
|
||||
val items = categories.value ?: error("This should not happen")
|
||||
val items = allCategories.value ?: error("This should not happen")
|
||||
val ids = items.mapTo(ArrayList(items.size)) { it.id }
|
||||
Collections.swap(ids, oldPos, newPos)
|
||||
ids.remove(0L)
|
||||
repository.reorderCategories(ids)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapCategories(
|
||||
categories: List<FavouriteCategory>,
|
||||
isAllCategoriesVisible: Boolean,
|
||||
withAllCategoriesItem: Boolean,
|
||||
): List<CategoryListModel> {
|
||||
val result = ArrayList<CategoryListModel>(categories.size + 1)
|
||||
if (withAllCategoriesItem) {
|
||||
result.add(CategoryListModel.All(isAllCategoriesVisible))
|
||||
}
|
||||
categories.mapTo(result) {
|
||||
CategoryListModel.CategoryItem(it)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun observeAllCategoriesVisible() = settings.observe()
|
||||
.filter { it == AppSettings.KEY_ALL_FAVOURITES_VISIBLE }
|
||||
.map { settings.isAllFavouritesVisible }
|
||||
.onStart { emit(settings.isAllFavouritesVisible) }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package org.koitharu.kotatsu.favourites.ui.categories.adapter
|
||||
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemCategoriesAllBinding
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.AllCategoriesToggleListener
|
||||
|
||||
fun allCategoriesAD(
|
||||
allCategoriesToggleListener: AllCategoriesToggleListener,
|
||||
) = adapterDelegateViewBinding<CategoryListModel.All, CategoryListModel, ItemCategoriesAllBinding>(
|
||||
{ inflater, parent -> ItemCategoriesAllBinding.inflate(inflater, parent, false) }
|
||||
) {
|
||||
|
||||
binding.imageViewToggle.setOnClickListener {
|
||||
allCategoriesToggleListener.onAllCategoriesToggle(!item.isVisible)
|
||||
}
|
||||
|
||||
bind {
|
||||
binding.imageViewToggle.isChecked = item.isVisible
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.koitharu.kotatsu.favourites.ui.categories
|
||||
package org.koitharu.kotatsu.favourites.ui.categories.adapter
|
||||
|
||||
import android.view.MotionEvent
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
@@ -8,23 +8,23 @@ import org.koitharu.kotatsu.databinding.ItemCategoryBinding
|
||||
|
||||
fun categoryAD(
|
||||
clickListener: OnListItemClickListener<FavouriteCategory>
|
||||
) = adapterDelegateViewBinding<FavouriteCategory, FavouriteCategory, ItemCategoryBinding>(
|
||||
) = adapterDelegateViewBinding<CategoryListModel.CategoryItem, CategoryListModel, ItemCategoryBinding>(
|
||||
{ inflater, parent -> ItemCategoryBinding.inflate(inflater, parent, false) }
|
||||
) {
|
||||
|
||||
binding.imageViewMore.setOnClickListener {
|
||||
clickListener.onItemClick(item, it)
|
||||
clickListener.onItemClick(item.category, it)
|
||||
}
|
||||
@Suppress("ClickableViewAccessibility")
|
||||
binding.imageViewHandle.setOnTouchListener { v, event ->
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||
clickListener.onItemLongClick(item, itemView)
|
||||
clickListener.onItemLongClick(item.category, itemView)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.text = item.title
|
||||
binding.textViewTitle.text = item.category.title
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package org.koitharu.kotatsu.favourites.ui.categories.adapter
|
||||
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
sealed interface CategoryListModel : ListModel {
|
||||
|
||||
val id: Long
|
||||
|
||||
class All(
|
||||
val isVisible: Boolean,
|
||||
) : CategoryListModel {
|
||||
|
||||
override val id: Long = 0L
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as All
|
||||
|
||||
if (isVisible != other.isVisible) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return isVisible.hashCode()
|
||||
}
|
||||
}
|
||||
|
||||
class CategoryItem(
|
||||
val category: FavouriteCategory,
|
||||
) : CategoryListModel {
|
||||
|
||||
override val id: Long
|
||||
get() = category.id
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as CategoryItem
|
||||
|
||||
if (category.id != other.category.id) return false
|
||||
if (category.title != other.category.title) return false
|
||||
if (category.order != other.category.order) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = category.id.hashCode()
|
||||
result = 31 * result + category.title.hashCode()
|
||||
result = 31 * result + category.order.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,15 @@ package org.koitharu.kotatsu.favourites.ui.categories.select
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.FragmentManager
|
||||
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.core.model.FavouriteCategory
|
||||
@@ -26,10 +27,10 @@ class FavouriteCategoriesDialog :
|
||||
BaseBottomSheet<DialogFavoriteCategoriesBinding>(),
|
||||
OnListItemClickListener<MangaCategoryItem>,
|
||||
CategoriesEditDelegate.CategoriesEditCallback,
|
||||
View.OnClickListener {
|
||||
Toolbar.OnMenuItemClickListener {
|
||||
|
||||
private val viewModel by viewModel<MangaCategoriesViewModel> {
|
||||
parametersOf(requireNotNull(arguments?.getParcelable<ParcelableManga>(MangaIntent.KEY_MANGA)).manga)
|
||||
parametersOf(requireNotNull(arguments?.getParcelableArrayList<ParcelableManga>(KEY_MANGA_LIST)).map { it.manga })
|
||||
}
|
||||
|
||||
private var adapter: MangaCategoriesAdapter? = null
|
||||
@@ -46,7 +47,7 @@ class FavouriteCategoriesDialog :
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
adapter = MangaCategoriesAdapter(this)
|
||||
binding.recyclerViewCategories.adapter = adapter
|
||||
binding.textViewAdd.setOnClickListener(this)
|
||||
binding.toolbar.setOnMenuItemClickListener(this)
|
||||
|
||||
viewModel.content.observe(viewLifecycleOwner, this::onContentChanged)
|
||||
viewModel.onError.observe(viewLifecycleOwner, ::onError)
|
||||
@@ -57,9 +58,13 @@ class FavouriteCategoriesDialog :
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.textView_add -> editDelegate.createCategory()
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_create -> {
|
||||
editDelegate.createCategory()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,10 +91,15 @@ class FavouriteCategoriesDialog :
|
||||
companion object {
|
||||
|
||||
private const val TAG = "FavouriteCategoriesDialog"
|
||||
private const val KEY_MANGA_LIST = "manga_list"
|
||||
|
||||
fun show(fm: FragmentManager, manga: Manga) = FavouriteCategoriesDialog()
|
||||
.withArgs(1) {
|
||||
putParcelable(MangaIntent.KEY_MANGA, ParcelableManga(manga))
|
||||
}.show(fm, TAG)
|
||||
fun show(fm: FragmentManager, manga: Manga) = Companion.show(fm, listOf(manga))
|
||||
|
||||
fun show(fm: FragmentManager, manga: Collection<Manga>) = FavouriteCategoriesDialog().withArgs(1) {
|
||||
putParcelableArrayList(
|
||||
KEY_MANGA_LIST,
|
||||
manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it, withChapters = false) }
|
||||
)
|
||||
}.show(fm, TAG)
|
||||
}
|
||||
}
|
||||
@@ -4,19 +4,20 @@ import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.model.ids
|
||||
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||
|
||||
class MangaCategoriesViewModel(
|
||||
private val manga: Manga,
|
||||
private val manga: List<Manga>,
|
||||
private val favouritesRepository: FavouritesRepository
|
||||
) : BaseViewModel() {
|
||||
|
||||
val content = combine(
|
||||
favouritesRepository.observeCategories(),
|
||||
favouritesRepository.observeCategoriesIds(manga.id)
|
||||
observeCategoriesIds(),
|
||||
) { all, checked ->
|
||||
all.map {
|
||||
MangaCategoryItem(
|
||||
@@ -30,9 +31,9 @@ class MangaCategoriesViewModel(
|
||||
fun setChecked(categoryId: Long, isChecked: Boolean) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
if (isChecked) {
|
||||
favouritesRepository.addToCategory(manga, categoryId)
|
||||
favouritesRepository.addToCategory(categoryId, manga)
|
||||
} else {
|
||||
favouritesRepository.removeFromCategory(manga, categoryId)
|
||||
favouritesRepository.removeFromCategory(categoryId, manga.ids())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,4 +43,25 @@ class MangaCategoriesViewModel(
|
||||
favouritesRepository.addCategory(name)
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeCategoriesIds() = if (manga.size == 1) {
|
||||
// Fast path
|
||||
favouritesRepository.observeCategoriesIds(manga[0].id)
|
||||
} else {
|
||||
combine(
|
||||
manga.map { favouritesRepository.observeCategoriesIds(it.id) }
|
||||
) { array ->
|
||||
val result = HashSet<Long>()
|
||||
var isFirst = true
|
||||
for (ids in array) {
|
||||
if (isFirst) {
|
||||
result.addAll(ids)
|
||||
isFirst = false
|
||||
} else {
|
||||
result.retainAll(ids.toSet())
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
package org.koitharu.kotatsu.favourites.ui.list
|
||||
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.utils.ext.withArgs
|
||||
|
||||
class FavouritesListFragment : MangaListFragment() {
|
||||
@@ -23,17 +23,27 @@ class FavouritesListFragment : MangaListFragment() {
|
||||
|
||||
override fun onScrolledToEnd() = Unit
|
||||
|
||||
override fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) {
|
||||
super.onCreatePopupMenu(inflater, menu, data)
|
||||
inflater.inflate(R.menu.popup_favourites, menu)
|
||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
mode.menuInflater.inflate(R.menu.mode_favourites, menu)
|
||||
return super.onCreateActionMode(mode, menu)
|
||||
}
|
||||
|
||||
override fun onPopupMenuItemSelected(item: MenuItem, data: Manga) = when (item.itemId) {
|
||||
R.id.action_remove -> {
|
||||
viewModel.removeFromFavourites(data)
|
||||
true
|
||||
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
menu.findItem(R.id.action_save)?.isVisible = selectedItems.none {
|
||||
it.source == MangaSource.LOCAL
|
||||
}
|
||||
return super.onPrepareActionMode(mode, menu)
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_remove -> {
|
||||
viewModel.removeFromFavourites(selectedItemsIds)
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
else -> super.onActionItemClicked(mode, item)
|
||||
}
|
||||
else -> super.onPopupMenuItemSelected(item, data)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -13,7 +13,6 @@ import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||
import org.koitharu.kotatsu.list.ui.model.toUi
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||
@@ -56,12 +55,15 @@ class FavouritesListViewModel(
|
||||
|
||||
override fun onRetry() = Unit
|
||||
|
||||
fun removeFromFavourites(manga: Manga) {
|
||||
fun removeFromFavourites(ids: Set<Long>) {
|
||||
if (ids.isEmpty()) {
|
||||
return
|
||||
}
|
||||
launchJob {
|
||||
if (categoryId == 0L) {
|
||||
repository.removeFromFavourites(manga)
|
||||
repository.removeFromFavourites(ids)
|
||||
} else {
|
||||
repository.removeFromCategory(manga, categoryId)
|
||||
repository.removeFromCategory(categoryId, ids)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.koitharu.kotatsu.history.data
|
||||
|
||||
import java.util.*
|
||||
import org.koitharu.kotatsu.core.model.MangaHistory
|
||||
|
||||
fun HistoryEntity.toMangaHistory() = MangaHistory(
|
||||
createdAt = Date(createdAt),
|
||||
updatedAt = Date(updatedAt),
|
||||
chapterId = chapterId,
|
||||
page = page,
|
||||
scroll = scroll.toInt()
|
||||
)
|
||||
@@ -5,7 +5,6 @@ import kotlinx.coroutines.flow.Flow
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
|
||||
|
||||
@Dao
|
||||
abstract class HistoryDao {
|
||||
|
||||
@@ -23,8 +22,15 @@ abstract class HistoryDao {
|
||||
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history)")
|
||||
abstract suspend fun findAllManga(): List<MangaEntity>
|
||||
|
||||
@Query("SELECT * FROM tags WHERE tag_id IN (SELECT tag_id FROM manga_tags WHERE manga_id IN (SELECT manga_id FROM history))")
|
||||
abstract suspend fun findAllTags(): List<TagEntity>
|
||||
@Query(
|
||||
"""SELECT tags.* FROM tags
|
||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||
INNER JOIN history ON history.manga_id = manga_tags.manga_id
|
||||
GROUP BY manga_tags.tag_id
|
||||
ORDER BY COUNT(manga_tags.manga_id) DESC
|
||||
LIMIT :limit"""
|
||||
)
|
||||
abstract suspend fun findPopularTags(limit: Int): List<TagEntity>
|
||||
|
||||
@Query("SELECT * FROM history WHERE manga_id = :id")
|
||||
abstract suspend fun find(id: Long): HistoryEntity?
|
||||
@@ -32,6 +38,9 @@ abstract class HistoryDao {
|
||||
@Query("SELECT * FROM history WHERE manga_id = :id")
|
||||
abstract fun observe(id: Long): Flow<HistoryEntity?>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM history")
|
||||
abstract fun observeCount(): Flow<Int>
|
||||
|
||||
@Query("DELETE FROM history")
|
||||
abstract suspend fun clear()
|
||||
|
||||
@@ -60,5 +69,4 @@ abstract class HistoryDao {
|
||||
true
|
||||
} else false
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,11 +5,10 @@ import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.PrimaryKey
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
import org.koitharu.kotatsu.core.model.MangaHistory
|
||||
import java.util.*
|
||||
|
||||
@Entity(
|
||||
tableName = "history", foreignKeys = [
|
||||
tableName = "history",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = MangaEntity::class,
|
||||
parentColumns = ["manga_id"],
|
||||
@@ -26,13 +25,4 @@ class HistoryEntity(
|
||||
@ColumnInfo(name = "chapter_id") val chapterId: Long,
|
||||
@ColumnInfo(name = "page") val page: Int,
|
||||
@ColumnInfo(name = "scroll") val scroll: Float,
|
||||
) {
|
||||
|
||||
fun toMangaHistory() = MangaHistory(
|
||||
createdAt = Date(createdAt),
|
||||
updatedAt = Date(updatedAt),
|
||||
chapterId = chapterId,
|
||||
page = page,
|
||||
scroll = scroll.toInt()
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -19,5 +19,5 @@ class HistoryWithManga(
|
||||
entityColumn = "tag_id",
|
||||
associateBy = Junction(MangaTagsEntity::class)
|
||||
)
|
||||
val tags: List<TagEntity>
|
||||
val tags: List<TagEntity>,
|
||||
)
|
||||
@@ -2,18 +2,18 @@ package org.koitharu.kotatsu.history.domain
|
||||
|
||||
import androidx.room.withTransaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.*
|
||||
import org.koitharu.kotatsu.core.model.MangaHistory
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||
import org.koitharu.kotatsu.history.data.toMangaHistory
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import org.koitharu.kotatsu.utils.ext.mapItems
|
||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
||||
|
||||
class HistoryRepository(
|
||||
private val db: MangaDatabase,
|
||||
@@ -23,20 +23,25 @@ class HistoryRepository(
|
||||
|
||||
suspend fun getList(offset: Int, limit: Int = 20): List<Manga> {
|
||||
val entities = db.historyDao.findAll(offset, limit)
|
||||
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
|
||||
return entities.map { it.manga.toManga(it.tags.toMangaTags()) }
|
||||
}
|
||||
|
||||
suspend fun getLastOrNull(): Manga? {
|
||||
val entity = db.historyDao.findAll(0, 1).firstOrNull() ?: return null
|
||||
return entity.manga.toManga(entity.tags.toMangaTags())
|
||||
}
|
||||
|
||||
fun observeAll(): Flow<List<Manga>> {
|
||||
return db.historyDao.observeAll().mapItems {
|
||||
it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag))
|
||||
it.manga.toManga(it.tags.toMangaTags())
|
||||
}
|
||||
}
|
||||
|
||||
fun observeAllWithHistory(): Flow<List<MangaWithHistory>> {
|
||||
return db.historyDao.observeAll().mapItems {
|
||||
MangaWithHistory(
|
||||
it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)),
|
||||
it.history.toMangaHistory()
|
||||
it.manga.toManga(it.tags.toMangaTags()),
|
||||
it.history.toMangaHistory(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -47,14 +52,20 @@ class HistoryRepository(
|
||||
}
|
||||
}
|
||||
|
||||
fun observeHasItems(): Flow<Boolean> {
|
||||
return db.historyDao.observeCount()
|
||||
.map { it > 0 }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) {
|
||||
if (manga.isNsfw && settings.isHistoryExcludeNsfw) {
|
||||
return
|
||||
}
|
||||
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
|
||||
val tags = manga.tags.toEntities()
|
||||
db.withTransaction {
|
||||
db.tagsDao.upsert(tags)
|
||||
db.mangaDao.upsert(MangaEntity.from(manga), tags)
|
||||
db.mangaDao.upsert(manga.toEntity(), tags)
|
||||
db.historyDao.upsert(
|
||||
HistoryEntity(
|
||||
mangaId = manga.id,
|
||||
@@ -62,7 +73,7 @@ class HistoryRepository(
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
chapterId = chapterId,
|
||||
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
|
||||
)
|
||||
)
|
||||
trackingRepository.upsert(manga)
|
||||
@@ -81,17 +92,25 @@ class HistoryRepository(
|
||||
db.historyDao.delete(manga.id)
|
||||
}
|
||||
|
||||
suspend fun delete(ids: Collection<Long>) {
|
||||
db.withTransaction {
|
||||
for (id in ids) {
|
||||
db.historyDao.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to replace one manga with another one
|
||||
* Useful for replacing saved manga on deleting it with remove source
|
||||
*/
|
||||
suspend fun deleteOrSwap(manga: Manga, alternative: Manga?) {
|
||||
if (alternative == null || db.mangaDao.update(MangaEntity.from(alternative)) <= 0) {
|
||||
if (alternative == null || db.mangaDao.update(alternative.toEntity()) <= 0) {
|
||||
db.historyDao.delete(manga.id)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getAllTags(): Set<MangaTag> {
|
||||
return db.historyDao.findAllTags().mapToSet { x -> x.toMangaTag() }
|
||||
suspend fun getPopularTags(limit: Int): List<MangaTag> {
|
||||
return db.historyDao.findPopularTags(limit).map { x -> x.toMangaTag() }
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,12 @@ import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.ellipsize
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
class HistoryListFragment : MangaListFragment() {
|
||||
|
||||
@@ -20,7 +19,6 @@ class HistoryListFragment : MangaListFragment() {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
viewModel.onItemRemoved.observe(viewLifecycleOwner, ::onItemRemoved)
|
||||
viewModel.isGroupingEnabled.observe(viewLifecycleOwner) {
|
||||
activity?.invalidateOptionsMenu()
|
||||
}
|
||||
@@ -59,30 +57,29 @@ class HistoryListFragment : MangaListFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) {
|
||||
super.onCreatePopupMenu(inflater, menu, data)
|
||||
inflater.inflate(R.menu.popup_history, menu)
|
||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
mode.menuInflater.inflate(R.menu.mode_history, menu)
|
||||
return super.onCreateActionMode(mode, menu)
|
||||
}
|
||||
|
||||
override fun onPopupMenuItemSelected(item: MenuItem, data: Manga): Boolean {
|
||||
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
menu.findItem(R.id.action_save)?.isVisible = selectedItems.none {
|
||||
it.source == MangaSource.LOCAL
|
||||
}
|
||||
return super.onPrepareActionMode(mode, menu)
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_remove -> {
|
||||
viewModel.removeFromHistory(data)
|
||||
viewModel.removeFromHistory(selectedItemsIds)
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
else -> super.onPopupMenuItemSelected(item, data)
|
||||
else -> super.onActionItemClicked(mode, item)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onItemRemoved(item: Manga) {
|
||||
Snackbar.make(
|
||||
binding.recyclerView, getString(
|
||||
R.string._s_removed_from_history,
|
||||
item.title.ellipsize(16)
|
||||
), Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newInstance() = HistoryListFragment()
|
||||
|
||||
@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.history.ui
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.*
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -13,14 +15,10 @@ import org.koitharu.kotatsu.history.domain.HistoryRepository
|
||||
import org.koitharu.kotatsu.history.domain.MangaWithHistory
|
||||
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
||||
import org.koitharu.kotatsu.list.ui.model.*
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||
import org.koitharu.kotatsu.utils.ext.daysDiff
|
||||
import org.koitharu.kotatsu.utils.ext.onFirst
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class HistoryListViewModel(
|
||||
private val repository: HistoryRepository,
|
||||
@@ -29,7 +27,6 @@ class HistoryListViewModel(
|
||||
private val trackingRepository: TrackingRepository,
|
||||
) : MangaListViewModel(settings) {
|
||||
|
||||
val onItemRemoved = SingleLiveEvent<Manga>()
|
||||
val isGroupingEnabled = MutableLiveData<Boolean>()
|
||||
|
||||
private val historyGrouping = settings.observe()
|
||||
@@ -72,10 +69,12 @@ class HistoryListViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun removeFromHistory(manga: Manga) {
|
||||
fun removeFromHistory(ids: Set<Long>) {
|
||||
if (ids.isEmpty()) {
|
||||
return
|
||||
}
|
||||
launchJob {
|
||||
repository.delete(manga)
|
||||
onItemRemoved.call(manga)
|
||||
repository.delete(ids)
|
||||
shortcutsRepository.updateShortcuts()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ class ListModeSelectDialog : AlertDialogFragment<DialogListModeBinding>(),
|
||||
binding.textViewGridTitle.isVisible = mode == ListMode.GRID
|
||||
binding.sliderGrid.isVisible = mode == ListMode.GRID
|
||||
|
||||
binding.sliderGrid.setLabelFormatter(IntPercentLabelFormatter())
|
||||
binding.sliderGrid.setLabelFormatter(IntPercentLabelFormatter(view.context))
|
||||
binding.sliderGrid.setValueRounded(settings.gridSize.toFloat())
|
||||
binding.sliderGrid.addOnSliderTouchListener(this)
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ package org.koitharu.kotatsu.list.ui
|
||||
import android.os.Bundle
|
||||
import android.view.*
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.collection.ArraySet
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.GravityCompat
|
||||
import androidx.core.view.isNotEmpty
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
@@ -18,30 +20,38 @@ import org.koitharu.kotatsu.base.ui.list.FitHeightGridLayoutManager
|
||||
import org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager
|
||||
import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener
|
||||
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
|
||||
import org.koitharu.kotatsu.base.ui.list.decor.TypedSpacingItemDecoration
|
||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog
|
||||
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
|
||||
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter.Companion.ITEM_TYPE_MANGA_GRID
|
||||
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
|
||||
import org.koitharu.kotatsu.main.ui.AppBarOwner
|
||||
import org.koitharu.kotatsu.main.ui.MainActivity
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.utils.ShareHelper
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
|
||||
abstract class MangaListFragment :
|
||||
BaseFragment<FragmentListBinding>(),
|
||||
PaginationScrollListener.Callback,
|
||||
MangaListListener,
|
||||
SwipeRefreshLayout.OnRefreshListener {
|
||||
SwipeRefreshLayout.OnRefreshListener,
|
||||
ActionMode.Callback {
|
||||
|
||||
private var listAdapter: MangaListAdapter? = null
|
||||
private var paginationListener: PaginationScrollListener? = null
|
||||
private var selectionDecoration: MangaSelectionDecoration? = null
|
||||
private var actionMode: ActionMode? = null
|
||||
private val spanResolver = MangaListSpanResolver()
|
||||
private val spanSizeLookup = SpanSizeLookup()
|
||||
private val listCommitCallback = Runnable {
|
||||
@@ -51,6 +61,12 @@ abstract class MangaListFragment :
|
||||
|
||||
protected abstract val viewModel: MangaListViewModel
|
||||
|
||||
protected val selectedItemsIds: Set<Long>
|
||||
get() = selectionDecoration?.checkedItemsIds?.toSet().orEmpty()
|
||||
|
||||
protected val selectedItems: Set<Manga>
|
||||
get() = collectSelectedItems()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setHasOptionsMenu(true)
|
||||
@@ -68,10 +84,12 @@ abstract class MangaListFragment :
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
listener = this,
|
||||
)
|
||||
selectionDecoration = MangaSelectionDecoration(view.context)
|
||||
paginationListener = PaginationScrollListener(4, this)
|
||||
with(binding.recyclerView) {
|
||||
setHasFixedSize(true)
|
||||
adapter = listAdapter
|
||||
addItemDecoration(selectionDecoration!!)
|
||||
addOnScrollListener(paginationListener!!)
|
||||
}
|
||||
with(binding.swipeRefreshLayout) {
|
||||
@@ -91,6 +109,7 @@ abstract class MangaListFragment :
|
||||
override fun onDestroyView() {
|
||||
listAdapter = null
|
||||
paginationListener = null
|
||||
selectionDecoration = null
|
||||
spanSizeLookup.invalidateCache()
|
||||
super.onDestroyView()
|
||||
}
|
||||
@@ -109,22 +128,28 @@ abstract class MangaListFragment :
|
||||
}
|
||||
|
||||
override fun onItemClick(item: Manga, view: View) {
|
||||
if (selectionDecoration?.checkedItemsCount != 0) {
|
||||
selectionDecoration?.toggleItemChecked(item.id)
|
||||
if (selectionDecoration?.checkedItemsCount == 0) {
|
||||
actionMode?.finish()
|
||||
} else {
|
||||
actionMode?.invalidate()
|
||||
binding.recyclerView.invalidateItemDecorations()
|
||||
}
|
||||
return
|
||||
}
|
||||
startActivity(DetailsActivity.newIntent(context ?: return, item))
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: Manga, view: View): Boolean {
|
||||
val menu = PopupMenu(context ?: return false, view)
|
||||
onCreatePopupMenu(menu.menuInflater, menu.menu, item)
|
||||
return if (menu.menu.hasVisibleItems()) {
|
||||
menu.setOnMenuItemClickListener {
|
||||
onPopupMenuItemSelected(it, item)
|
||||
}
|
||||
menu.gravity = GravityCompat.END or Gravity.TOP
|
||||
menu.show()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
if (actionMode == null) {
|
||||
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
|
||||
}
|
||||
return actionMode?.also {
|
||||
selectionDecoration?.setItemIsChecked(item.id, true)
|
||||
binding.recyclerView.invalidateItemDecorations()
|
||||
it.invalidate()
|
||||
} != null
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
@@ -219,8 +244,11 @@ abstract class MangaListFragment :
|
||||
ListMode.LIST -> {
|
||||
layoutManager = FitHeightLinearLayoutManager(context)
|
||||
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
|
||||
addItemDecoration(SpacingItemDecoration(spacing))
|
||||
updatePadding(left = spacing, right = spacing)
|
||||
val decoration = TypedSpacingItemDecoration(
|
||||
MangaListAdapter.ITEM_TYPE_MANGA_LIST to 0,
|
||||
fallbackSpacing = spacing
|
||||
)
|
||||
addItemDecoration(decoration)
|
||||
}
|
||||
ListMode.DETAILED_LIST -> {
|
||||
layoutManager = FitHeightLinearLayoutManager(context)
|
||||
@@ -238,12 +266,67 @@ abstract class MangaListFragment :
|
||||
addOnLayoutChangeListener(spanResolver)
|
||||
}
|
||||
}
|
||||
selectionDecoration?.let { addItemDecoration(it) }
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) = Unit
|
||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
return menu.isNotEmpty()
|
||||
}
|
||||
|
||||
protected open fun onPopupMenuItemSelected(item: MenuItem, data: Manga) = false
|
||||
@CallSuper
|
||||
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
mode.title = selectionDecoration?.checkedItemsCount?.toString()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_select_all -> {
|
||||
val ids = listAdapter?.items?.mapNotNull {
|
||||
(it as? MangaItemModel)?.id
|
||||
} ?: return false
|
||||
selectionDecoration?.checkAll(ids)
|
||||
binding.recyclerView.invalidateItemDecorations()
|
||||
mode.invalidate()
|
||||
true
|
||||
}
|
||||
R.id.action_share -> {
|
||||
ShareHelper(requireContext()).shareMangaLinks(selectedItems)
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
R.id.action_favourite -> {
|
||||
FavouriteCategoriesDialog.show(childFragmentManager, selectedItems)
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
R.id.action_save -> {
|
||||
DownloadService.confirmAndStart(requireContext(), selectedItems)
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyActionMode(mode: ActionMode) {
|
||||
selectionDecoration?.clearSelection()
|
||||
binding.recyclerView.invalidateItemDecorations()
|
||||
actionMode = null
|
||||
}
|
||||
|
||||
private fun collectSelectedItems(): Set<Manga> {
|
||||
val checkedIds = selectionDecoration?.checkedItemsIds ?: return emptySet()
|
||||
val items = listAdapter?.items ?: return emptySet()
|
||||
val result = ArraySet<Manga>(checkedIds.size)
|
||||
for (item in items) {
|
||||
if (item is MangaItemModel && item.id in checkedIds) {
|
||||
result.add(item.manga)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() {
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package org.koitharu.kotatsu.list.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.view.View
|
||||
import androidx.cardview.widget.CardView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.NO_ID
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
|
||||
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
|
||||
import org.koitharu.kotatsu.utils.ext.getItem
|
||||
import org.koitharu.kotatsu.utils.ext.getThemeColor
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
|
||||
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle)
|
||||
private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer)
|
||||
private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
|
||||
private val fillColor = ColorUtils.setAlphaComponent(
|
||||
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
|
||||
0x74
|
||||
)
|
||||
private val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner)
|
||||
|
||||
init {
|
||||
hasBackground = false
|
||||
hasForeground = true
|
||||
isIncludeDecorAndMargins = false
|
||||
|
||||
paint.strokeWidth = context.resources.getDimension(R.dimen.selection_stroke_width)
|
||||
checkIcon?.setTint(strokeColor)
|
||||
}
|
||||
|
||||
override fun getItemId(parent: RecyclerView, child: View): Long {
|
||||
val holder = parent.getChildViewHolder(child) ?: return NO_ID
|
||||
val item = holder.getItem(MangaItemModel::class.java) ?: return NO_ID
|
||||
return item.id
|
||||
}
|
||||
|
||||
override fun onDrawForeground(
|
||||
canvas: Canvas,
|
||||
parent: RecyclerView,
|
||||
child: View,
|
||||
bounds: RectF,
|
||||
state: RecyclerView.State,
|
||||
) {
|
||||
val isCard = child is CardView
|
||||
val radius = (child as? CardView)?.radius ?: defaultRadius
|
||||
paint.color = fillColor
|
||||
paint.style = Paint.Style.FILL
|
||||
canvas.drawRoundRect(bounds, radius, radius, paint)
|
||||
paint.color = strokeColor
|
||||
paint.style = Paint.Style.STROKE
|
||||
canvas.drawRoundRect(bounds, radius, radius, paint)
|
||||
if (isCard) {
|
||||
checkIcon?.run {
|
||||
setBounds(
|
||||
(bounds.left + iconOffset).toInt(),
|
||||
(bounds.top + iconOffset).toInt(),
|
||||
(bounds.left + iconOffset + intrinsicWidth).toInt(),
|
||||
(bounds.top + iconOffset + intrinsicHeight).toInt(),
|
||||
)
|
||||
draw(canvas)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,9 @@ package org.koitharu.kotatsu.list.ui.model
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
data class MangaGridModel(
|
||||
val id: Long,
|
||||
override val id: Long,
|
||||
val title: String,
|
||||
val coverUrl: String,
|
||||
val manga: Manga,
|
||||
override val manga: Manga,
|
||||
val counter: Int,
|
||||
) : ListModel
|
||||
) : MangaItemModel
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.koitharu.kotatsu.list.ui.model
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
sealed interface MangaItemModel : ListModel {
|
||||
|
||||
val id: Long
|
||||
val manga: Manga
|
||||
}
|
||||
@@ -3,12 +3,12 @@ package org.koitharu.kotatsu.list.ui.model
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
data class MangaListDetailedModel(
|
||||
val id: Long,
|
||||
override val id: Long,
|
||||
val title: String,
|
||||
val subtitle: String?,
|
||||
val tags: String,
|
||||
val coverUrl: String,
|
||||
val rating: String?,
|
||||
val manga: Manga,
|
||||
override val manga: Manga,
|
||||
val counter: Int,
|
||||
) : ListModel
|
||||
) : MangaItemModel
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user