Compare commits

..

53 Commits
v4.0.5 ... v4.2

Author SHA1 Message Date
Koitharu
ed56170809 Update okio 2023-01-09 15:25:47 +02:00
Koitharu
a36e5fce29 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (400 of 400 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (400 of 400 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-01-09 14:55:03 +02:00
gallegonovato
760bfaf4d7 Translated using Weblate (Spanish)
Currently translated at 100.0% (400 of 400 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-01-09 14:55:03 +02:00
Koitharu
24463720b1 Update tools fragment layout 2023-01-09 14:32:02 +02:00
Koitharu
516470e8ae Update manga details list item 2023-01-09 13:36:45 +02:00
Koitharu
7f530d0476 Fix list mode changing 2023-01-09 11:19:17 +02:00
Koitharu
8a2706d70b Hide prefetch option if not available 2023-01-09 10:49:25 +02:00
Koitharu
27f09480a0 Prefetch last manga 2023-01-09 10:49:25 +02:00
Koitharu
c03dcf6d2e Fix memory leaks 2023-01-09 10:49:25 +02:00
Koitharu
bdb2ae9c2f Manage prefetch cache memory 2023-01-09 10:49:25 +02:00
Koitharu
3413fe6943 Content prefetch settings 2023-01-09 10:49:25 +02:00
Koitharu
e6ae9e8bd6 Prefetch chapter 2023-01-09 10:49:25 +02:00
Koitharu
8d9426f257 Add option to change favourites sort order #290 2023-01-08 08:18:55 +02:00
Koitharu
30247e3def Fix horizontal autoscroll in shelf 2023-01-08 08:18:54 +02:00
Koitharu
084dc32d2d Circle chapters indicators 2023-01-08 08:18:54 +02:00
Koitharu
3393f1397b Show incognito indicator if manga will not be saved in history 2023-01-08 08:18:54 +02:00
Koitharu
61784bcfc4 Hide manga updates if tracker is disabled 2023-01-08 08:18:54 +02:00
Koitharu
bd692fc60c Update dependencies 2023-01-08 08:18:54 +02:00
Zakhar Timoshenko
738299e8d3 Merge pull request #287 from mitonik/devel
Translate to Polish
2023-01-04 18:46:36 +03:00
Koitharu
c8b6dc27b2 Fix branch selection in reader 2023-01-04 17:39:06 +02:00
Koitharu
1493aa39a3 CookieJar implementation for non-WebView environment 2023-01-04 15:59:31 +02:00
Koitharu
f115031846 Fix error handling in CoroutineIntentService 2023-01-04 14:39:36 +02:00
Koitharu
571b85dfd8 Fix fast scroller NPE 2023-01-04 13:26:18 +02:00
Michał Antonik
75cc9e9030 Translate to Polish 2023-01-02 23:00:46 +01:00
Koitharu
656a707b4c Bump version 2023-01-01 08:49:15 +02:00
Koitharu
04afe7a934 Bind ssiv with lifecycle 2023-01-01 08:17:07 +02:00
Koitharu
689670b3ff Show inconito mode indicator on Read button 2023-01-01 08:17:03 +02:00
Zakhar Timoshenko
6273a9decb Merge pull request #281 from weblate/weblate-kotatsu-strings
Translations update from Hosted Weblate
2022-12-31 22:37:57 +03:00
Eric
72336d4f71 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (399 of 399 strings)

Co-authored-by: Eric <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2022-12-31 20:33:30 +01:00
J. Lavoie
731e998eb2 Translated using Weblate (French)
Currently translated at 100.0% (399 of 399 strings)

Translated using Weblate (Italian)

Currently translated at 79.9% (319 of 399 strings)

Translated using Weblate (German)

Currently translated at 97.2% (388 of 399 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2022-12-31 20:33:30 +01:00
gallegonovato
9bf53114de Translated using Weblate (Spanish)
Currently translated at 100.0% (399 of 399 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2022-12-31 20:33:30 +01:00
Zakhar Timoshenko
0e1b8db688 Update parsers, Happy New Year! 2022-12-31 22:32:35 +03:00
Zakhar Timoshenko
3a62e2e6c0 Add monochrome icon on One UI 5.0 2022-12-31 15:08:47 +03:00
Koitharu
08764cb3cb Translated using Weblate (Russian)
Currently translated at 100.0% (399 of 399 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
Evgeniy Khramov
9c52545f63 Translated using Weblate (Russian)
Currently translated at 100.0% (398 of 398 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (8 of 8 strings)

Co-authored-by: Evgeniy Khramov <thejenjagamertjg@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-12-27 09:00:05 +02:00
gallegonovato
a6c30d33d4 Translated using Weblate (Spanish)
Currently translated at 100.0% (398 of 398 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
Neko Nekowazarashi
25974af229 Translated using Weblate (Indonesian)
Currently translated at 90.2% (359 of 398 strings)

Co-authored-by: Neko Nekowazarashi <kodra@nekoweb.my.id>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
Andy Hong
607dfc9be3 Translated using Weblate (Korean)
Currently translated at 26.8% (107 of 398 strings)

Added translation using Weblate (Korean)

Added translation using Weblate (Korean)

Co-authored-by: Andy Hong <andy963963@icloud.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ko/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
mondstern
560e669700 Translated using Weblate (German)
Currently translated at 97.2% (387 of 398 strings)

Co-authored-by: mondstern <mondstern@snopyta.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
J. Lavoie
ba403c9360 Translated using Weblate (French)
Currently translated at 100.0% (398 of 398 strings)

Translated using Weblate (German)

Currently translated at 96.2% (383 of 398 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
Eric
0f1c9ff05d Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (398 of 398 strings)

Co-authored-by: Eric <hamburger1024@duck.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
kuragehime
662f08e115 Translated using Weblate (Japanese)
Currently translated at 100.0% (398 of 398 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
Oğuz Ersen
d647a32e9f Translated using Weblate (Turkish)
Currently translated at 100.0% (399 of 399 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (398 of 398 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
Koitharu
375e72cb98 Pass language to voice search 2022-12-27 08:55:39 +02:00
Koitharu
34c7cafdfe Use AlphanumComparator for importing manga dir 2022-12-27 08:39:27 +02:00
NOTMASTER09
03e0eefe4d Fix DirMangaImporter 2022-12-27 08:36:18 +02:00
Koitharu
f41425f03d Popup menu on sources in Explore fragment 2022-12-26 20:02:02 +02:00
Koitharu
400b91278f Allow source login on error 2022-12-26 19:27:38 +02:00
Koitharu
9088f77ae5 Update dependencies 2022-12-26 19:15:27 +02:00
Koitharu
86da3217d1 Add A13 locale list 2022-12-26 19:15:17 +02:00
Koitharu
24908e52af Update network error message 2022-12-09 18:32:10 +02:00
Koitharu
1261a6790d Improve pages cache creation 2022-12-09 18:24:55 +02:00
Koitharu
59fa61864a Update parsers 2022-12-09 18:19:39 +02:00
125 changed files with 2211 additions and 425 deletions

2
.idea/kotlinc.xml generated
View File

@@ -4,6 +4,6 @@
<option name="jvmTarget" value="1.8" />
</component>
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.7.20" />
<option name="version" value="1.8.0" />
</component>
</project>

View File

@@ -15,8 +15,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 33
versionCode 505
versionName '4.0.5'
versionCode 509
versionName '4.2'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -83,15 +83,16 @@ afterEvaluate {
}
}
dependencies {
implementation('com.github.KotatsuApp:kotatsu-parsers:2ab03cb668') {
implementation('com.github.KotatsuApp:kotatsu-parsers:cf00732023') {
exclude group: 'org.json', module: 'json'
}
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.activity:activity-ktx:1.6.1'
implementation 'androidx.fragment:fragment-ktx:1.5.4'
implementation 'androidx.fragment:fragment-ktx:1.5.5'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1'
implementation 'androidx.lifecycle:lifecycle-service:2.5.1'
@@ -113,7 +114,7 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3'
implementation 'com.squareup.okio:okio:3.2.0'
implementation 'com.squareup.okio:okio:3.3.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
@@ -125,7 +126,7 @@ dependencies {
implementation 'io.coil-kt:coil-base:2.2.2'
implementation 'io.coil-kt:coil-svg:2.2.2'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:f8a38b08fe'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:1b19231b2f'
implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'ch.acra:acra-http:5.9.7'
@@ -137,10 +138,10 @@ dependencies {
testImplementation 'org.json:json:20220924'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
androidTestImplementation 'androidx.test:runner:1.5.1'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0'
androidTestImplementation 'androidx.test:core-ktx:1.5.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.4'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'

View File

@@ -14,3 +14,4 @@
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
-keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; }

View File

@@ -29,6 +29,7 @@
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:largeHeap="true"
android:localeConfig="@xml/locales"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
@@ -187,6 +188,9 @@
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_history" />
</service>
<service
android:name="org.koitharu.kotatsu.details.service.MangaPrefetchService"
android:exported="false" />
<provider
android:name="org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider"
@@ -247,6 +251,9 @@
<meta-data
android:name="android.webkit.WebView.MetricsOptOut"
android:value="true" />
<meta-data
android:name="com.samsung.android.icon_container.has_icon_container"
android:value="@bool/com_samsung_android_icon_container_has_icon_container" />
</application>

View File

@@ -31,6 +31,9 @@ abstract class CoroutineIntentService : BaseService() {
processIntent(startId, intent)
}
}
} catch (e: Throwable) {
e.printStackTraceDebug()
onError(startId, e)
} finally {
stopSelf(startId)
}

View File

@@ -516,6 +516,6 @@ class FastScroller @JvmOverloads constructor(
interface SectionIndexer {
fun getSectionText(context: Context, position: Int): CharSequence
fun getSectionText(context: Context, position: Int): CharSequence?
}
}
}

View File

@@ -4,12 +4,12 @@ import android.graphics.Bitmap
import android.webkit.WebView
import android.webkit.WebViewClient
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.koitharu.kotatsu.core.network.AndroidCookieJar
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
private const val CF_CLEARANCE = "cf_clearance"
class CloudFlareClient(
private val cookieJar: AndroidCookieJar,
private val cookieJar: MutableCookieJar,
private val callback: CloudFlareCallback,
private val targetUrl: String,
) : WebViewClient() {
@@ -42,4 +42,4 @@ class CloudFlareClient(
return cookieJar.loadForRequest(targetUrl.toHttpUrl())
.find { it.name == CF_CLEARANCE }?.value
}
}
}

View File

@@ -12,13 +12,13 @@ import androidx.core.view.isInvisible
import androidx.fragment.app.setFragmentResult
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.network.AndroidCookieJar
import org.koitharu.kotatsu.core.network.UserAgentInterceptor
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.databinding.FragmentCloudflareBinding
import org.koitharu.kotatsu.utils.ext.stringArgument
import org.koitharu.kotatsu.utils.ext.withArgs
import javax.inject.Inject
@AndroidEntryPoint
class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), CloudFlareCallback {
@@ -27,7 +27,7 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
private val pendingResult = Bundle(1)
@Inject
lateinit var cookieJar: AndroidCookieJar
lateinit var cookieJar: MutableCookieJar
override fun onInflateView(
inflater: LayoutInflater,

View File

@@ -4,6 +4,7 @@ import android.app.Application
import android.content.Context
import android.provider.SearchRecentSuggestions
import android.text.Html
import android.util.AndroidRuntimeException
import androidx.collection.arraySetOf
import androidx.room.InvalidationTracker
import coil.ComponentRegistry
@@ -23,8 +24,14 @@ import okhttp3.CookieJar
import okhttp3.OkHttpClient
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.base.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.cache.StubContentCache
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.network.*
import org.koitharu.kotatsu.core.network.cookies.AndroidCookieJar
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.network.cookies.PreferencesCookieJar
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
@@ -40,6 +47,7 @@ import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
import org.koitharu.kotatsu.settings.backup.BackupObserver
import org.koitharu.kotatsu.sync.domain.SyncController
import org.koitharu.kotatsu.utils.IncognitoModeIndicator
import org.koitharu.kotatsu.utils.ext.activityManager
import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.isLowRamDevice
import org.koitharu.kotatsu.utils.image.CoilImageGetter
@@ -52,7 +60,7 @@ import javax.inject.Singleton
interface AppModule {
@Binds
fun bindCookieJar(androidCookieJar: AndroidCookieJar): CookieJar
fun bindCookieJar(androidCookieJar: MutableCookieJar): CookieJar
@Binds
fun bindMangaLoaderContext(mangaLoaderContextImpl: MangaLoaderContextImpl): MangaLoaderContext
@@ -62,6 +70,17 @@ interface AppModule {
companion object {
@Provides
@Singleton
fun provideCookieJar(
@ApplicationContext context: Context
): MutableCookieJar = try {
AndroidCookieJar()
} catch (e: AndroidRuntimeException) {
// WebView is not available
PreferencesCookieJar(context)
}
@Provides
@Singleton
fun provideOkHttpClient(
@@ -167,5 +186,17 @@ interface AppModule {
activityRecreationHandle,
incognitoModeIndicator,
)
@Provides
@Singleton
fun provideContentCache(
application: Application,
): ContentCache {
return if (application.activityManager?.isLowRamDevice == true) {
StubContentCache()
} else {
MemoryContentCache(application)
}
}
}
}

View File

@@ -0,0 +1,23 @@
package org.koitharu.kotatsu.core.cache
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
interface ContentCache {
val isCachingEnabled: Boolean
suspend fun getDetails(source: MangaSource, url: String): Manga?
fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>)
suspend fun getPages(source: MangaSource, url: String): List<MangaPage>?
fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>)
data class Key(
val source: MangaSource,
val url: String,
)
}

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.core.cache
import androidx.collection.LruCache
class DeferredLruCache<T>(maxSize: Int) : LruCache<ContentCache.Key, SafeDeferred<T>>(maxSize) {
override fun entryRemoved(
evicted: Boolean,
key: ContentCache.Key,
oldValue: SafeDeferred<T>,
newValue: SafeDeferred<T>?,
) {
super.entryRemoved(evicted, key, oldValue, newValue)
oldValue.cancel()
}
}

View File

@@ -0,0 +1,59 @@
package org.koitharu.kotatsu.core.cache
import android.app.Application
import android.content.ComponentCallbacks2
import android.content.res.Configuration
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
class MemoryContentCache(application: Application) : ContentCache, ComponentCallbacks2 {
init {
application.registerComponentCallbacks(this)
}
private val detailsCache = DeferredLruCache<Manga>(4)
private val pagesCache = DeferredLruCache<List<MangaPage>>(4)
override val isCachingEnabled: Boolean = true
override suspend fun getDetails(source: MangaSource, url: String): Manga? {
return detailsCache[ContentCache.Key(source, url)]?.awaitOrNull()
}
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) {
detailsCache.put(ContentCache.Key(source, url), details)
}
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? {
return pagesCache[ContentCache.Key(source, url)]?.awaitOrNull()
}
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) {
pagesCache.put(ContentCache.Key(source, url), pages)
}
override fun onConfigurationChanged(newConfig: Configuration) = Unit
override fun onLowMemory() = Unit
override fun onTrimMemory(level: Int) {
trimCache(detailsCache, level)
trimCache(pagesCache, level)
}
private fun trimCache(cache: DeferredLruCache<*>, level: Int) {
when (level) {
ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL,
ComponentCallbacks2.TRIM_MEMORY_COMPLETE,
ComponentCallbacks2.TRIM_MEMORY_MODERATE -> cache.evictAll()
ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN,
ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW,
ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> cache.trimToSize(1)
else -> cache.trimToSize(cache.maxSize() / 2)
}
}
}

View File

@@ -0,0 +1,20 @@
package org.koitharu.kotatsu.core.cache
import kotlinx.coroutines.Deferred
class SafeDeferred<T>(
private val delegate: Deferred<Result<T>>,
) {
suspend fun await(): T {
return delegate.await().getOrThrow()
}
suspend fun awaitOrNull(): T? {
return delegate.await().getOrNull()
}
fun cancel() {
delegate.cancel()
}
}

View File

@@ -0,0 +1,18 @@
package org.koitharu.kotatsu.core.cache
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
class StubContentCache : ContentCache {
override val isCachingEnabled: Boolean = false
override suspend fun getDetails(source: MangaSource, url: String): Manga? = null
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) = Unit
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? = null
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) = Unit
}

View File

@@ -1,19 +1,17 @@
package org.koitharu.kotatsu.core.network
package org.koitharu.kotatsu.core.network.cookies
import android.webkit.CookieManager
import javax.inject.Inject
import javax.inject.Singleton
import androidx.annotation.WorkerThread
import okhttp3.Cookie
import okhttp3.HttpUrl
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
@Singleton
class AndroidCookieJar @Inject constructor() : CookieJar {
class AndroidCookieJar : MutableCookieJar {
private val cookieManager = CookieManager.getInstance()
@WorkerThread
override fun loadForRequest(url: HttpUrl): List<Cookie> {
val rawCookie = cookieManager.getCookie(url.toString()) ?: return emptyList()
return rawCookie.split(';').mapNotNull {
@@ -21,6 +19,7 @@ class AndroidCookieJar @Inject constructor() : CookieJar {
}
}
@WorkerThread
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
if (cookies.isEmpty()) {
return
@@ -31,7 +30,7 @@ class AndroidCookieJar @Inject constructor() : CookieJar {
}
}
suspend fun clear() = suspendCoroutine<Boolean> { continuation ->
override suspend fun clear() = suspendCoroutine<Boolean> { continuation ->
cookieManager.removeAllCookies(continuation::resume)
}
}

View File

@@ -0,0 +1,84 @@
package org.koitharu.kotatsu.core.network.cookies
import android.util.Base64
import okhttp3.Cookie
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.ObjectInputStream
import java.io.ObjectOutputStream
class CookieWrapper(
val cookie: Cookie,
) {
constructor(encodedString: String) : this(
ObjectInputStream(ByteArrayInputStream(Base64.decode(encodedString, Base64.NO_WRAP))).use {
val name = it.readUTF()
val value = it.readUTF()
val expiresAt = it.readLong()
val domain = it.readUTF()
val path = it.readUTF()
val secure = it.readBoolean()
val httpOnly = it.readBoolean()
val persistent = it.readBoolean()
val hostOnly = it.readBoolean()
Cookie.Builder().also { c ->
c.name(name)
c.value(value)
if (persistent) {
c.expiresAt(expiresAt)
}
if (hostOnly) {
c.hostOnlyDomain(domain)
} else {
c.domain(domain)
}
c.path(path)
if (secure) {
c.secure()
}
if (httpOnly) {
c.httpOnly()
}
}.build()
},
)
fun encode(): String {
val output = ByteArrayOutputStream()
ObjectOutputStream(output).use {
it.writeUTF(cookie.name)
it.writeUTF(cookie.value)
it.writeLong(cookie.expiresAt)
it.writeUTF(cookie.domain)
it.writeUTF(cookie.path)
it.writeBoolean(cookie.secure)
it.writeBoolean(cookie.httpOnly)
it.writeBoolean(cookie.persistent)
it.writeBoolean(cookie.hostOnly)
}
return Base64.encodeToString(output.toByteArray(), Base64.NO_WRAP)
}
fun isExpired() = cookie.expiresAt < System.currentTimeMillis()
fun key(): String {
return (if (cookie.secure) "https" else "http") + "://" + cookie.domain + cookie.path + "|" + cookie.name
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as CookieWrapper
if (cookie != other.cookie) return false
return true
}
override fun hashCode(): Int {
return cookie.hashCode()
}
}

View File

@@ -0,0 +1,17 @@
package org.koitharu.kotatsu.core.network.cookies
import androidx.annotation.WorkerThread
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
interface MutableCookieJar : CookieJar {
@WorkerThread
override fun loadForRequest(url: HttpUrl): List<Cookie>
@WorkerThread
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>)
suspend fun clear(): Boolean
}

View File

@@ -0,0 +1,89 @@
package org.koitharu.kotatsu.core.network.cookies
import android.content.Context
import androidx.annotation.WorkerThread
import androidx.collection.ArrayMap
import androidx.core.content.edit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Cookie
import okhttp3.HttpUrl
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
private const val PREFS_NAME = "cookies"
class PreferencesCookieJar(
context: Context,
) : MutableCookieJar {
private val cache = ArrayMap<String, CookieWrapper>()
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private var isLoaded = false
@WorkerThread
override fun loadForRequest(url: HttpUrl): List<Cookie> {
loadPersistent()
val expired = HashSet<String>()
val result = ArrayList<Cookie>()
for ((key, cookie) in cache) {
if (cookie.isExpired()) {
expired += key
} else if (cookie.cookie.matches(url)) {
result += cookie.cookie
}
}
if (expired.isNotEmpty()) {
cache.removeAll(expired)
removePersistent(expired)
}
return result
}
@WorkerThread
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val wrapped = cookies.map { CookieWrapper(it) }
prefs.edit(commit = true) {
for (cookie in wrapped) {
val key = cookie.key()
cache[key] = cookie
if (cookie.cookie.persistent) {
putString(key, cookie.encode())
}
}
}
}
override suspend fun clear(): Boolean {
cache.clear()
withContext(Dispatchers.IO) {
prefs.edit(commit = true) { clear() }
}
return true
}
@Synchronized
private fun loadPersistent() {
if (!isLoaded) {
val map = prefs.all
cache.ensureCapacity(map.size)
for ((k, v) in map) {
val cookie = try {
CookieWrapper(v as String)
} catch (e: Exception) {
e.printStackTraceDebug()
continue
}
cache[k] = cookie
}
isLoaded = true
}
}
private fun removePersistent(keys: Collection<String>) {
prefs.edit(commit = true) {
for (key in keys) {
remove(key)
}
}
}
}

View File

@@ -6,25 +6,25 @@ import android.util.Base64
import android.webkit.WebView
import androidx.core.os.LocaleListCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.koitharu.kotatsu.core.network.AndroidCookieJar
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.toList
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@Singleton
class MangaLoaderContextImpl @Inject constructor(
override val httpClient: OkHttpClient,
override val cookieJar: AndroidCookieJar,
override val cookieJar: MutableCookieJar,
@ApplicationContext private val androidContext: Context,
) : MangaLoaderContext() {

View File

@@ -1,13 +1,19 @@
package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.lang.ref.WeakReference
import java.util.*
import java.util.EnumMap
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.collections.set
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.*
interface MangaRepository {
@@ -31,6 +37,7 @@ interface MangaRepository {
class Factory @Inject constructor(
private val localMangaRepository: LocalMangaRepository,
private val loaderContext: MangaLoaderContext,
private val contentCache: ContentCache,
) {
private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java)
@@ -42,7 +49,7 @@ interface MangaRepository {
cache[source]?.get()?.let { return it }
return synchronized(cache) {
cache[source]?.get()?.let { return it }
val repository = RemoteMangaRepository(MangaParser(source, loaderContext))
val repository = RemoteMangaRepository(MangaParser(source, loaderContext), contentCache)
cache[source] = WeakReference(repository)
repository
}

View File

@@ -1,12 +1,28 @@
package org.koitharu.kotatsu.core.parser
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.cache.SafeDeferred
import org.koitharu.kotatsu.core.prefs.SourceSettings
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.model.Favicons
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
class RemoteMangaRepository(private val parser: MangaParser) : MangaRepository {
class RemoteMangaRepository(
private val parser: MangaParser,
private val cache: ContentCache,
) : MangaRepository {
override val source: MangaSource
get() = parser.source
@@ -28,9 +44,23 @@ class RemoteMangaRepository(private val parser: MangaParser) : MangaRepository {
return parser.getList(offset, tags, sortOrder)
}
override suspend fun getDetails(manga: Manga): Manga = parser.getDetails(manga)
override suspend fun getDetails(manga: Manga): Manga {
cache.getDetails(source, manga.url)?.let { return it }
val details = asyncSafe {
parser.getDetails(manga)
}
cache.putDetails(source, manga.url, details)
return details.await()
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = parser.getPages(chapter)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
cache.getPages(source, chapter.url)?.let { return it }
val pages = asyncSafe {
parser.getPages(chapter)
}
cache.putPages(source, chapter.url, pages)
return pages.await()
}
override suspend fun getPageUrl(page: MangaPage): String = parser.getPageUrl(page)
@@ -45,4 +75,10 @@ class RemoteMangaRepository(private val parser: MangaParser) : MangaRepository {
}
private fun getConfig() = parser.config as SourceSettings
}
private fun <T> asyncSafe(block: suspend CoroutineScope.() -> T) = SafeDeferred(
processLifecycleScope.async(Dispatchers.Default) {
runCatchingCancellable { block() }
},
)
}

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.core.prefs
import android.content.Context
import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.net.Uri
import android.provider.Settings
import androidx.appcompat.app.AppCompatDelegate
@@ -17,6 +16,7 @@ import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.shelf.domain.ShelfSection
import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.getEnumValue
import org.koitharu.kotatsu.utils.ext.observe
import org.koitharu.kotatsu.utils.ext.putEnumValue
@@ -34,6 +34,7 @@ import javax.inject.Singleton
class AppSettings @Inject constructor(@ApplicationContext context: Context) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
private val connectivityManager = context.connectivityManager
private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply {
remove(MangaSource.LOCAL)
@@ -156,6 +157,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isDynamicShortcutsEnabled: Boolean
get() = prefs.getBoolean(KEY_SHORTCUTS, true)
fun isContentPrefetchEnabled(): Boolean {
val policy = NetworkPolicy.from(prefs.getString(KEY_PREFETCH_CONTENT, null), NetworkPolicy.NEVER)
return policy.isNetworkAllowed(connectivityManager)
}
var sourcesOrder: List<String>
get() = prefs.getString(KEY_SOURCES_ORDER, null)
?.split('|')
@@ -234,12 +240,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isWebtoonZoomEnable: Boolean
get() = prefs.getBoolean(KEY_WEBTOON_ZOOM, true)
fun isPagesPreloadAllowed(cm: ConnectivityManager): Boolean {
return when (prefs.getString(KEY_PAGES_PRELOAD, null)?.toIntOrNull()) {
NETWORK_ALWAYS -> true
NETWORK_NEVER -> false
else -> cm.isActiveNetworkMetered
}
fun isPagesPreloadEnabled(): Boolean {
val policy = NetworkPolicy.from(prefs.getString(KEY_PAGES_PRELOAD, null), NetworkPolicy.NON_METERED)
return policy.isNetworkAllowed(connectivityManager)
}
fun getDateFormat(format: String = prefs.getString(KEY_DATE_FORMAT, "").orEmpty()): DateFormat =
@@ -293,7 +296,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val TRACK_FAVOURITES = "favourites"
const val KEY_LIST_MODE = "list_mode_2"
const val KEY_APP_SECTION = "app_section_2"
const val KEY_THEME = "theme"
const val KEY_DYNAMIC_THEME = "dynamic_theme"
const val KEY_THEME_AMOLED = "amoled_theme"
@@ -355,13 +357,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_LOCAL_LIST_ORDER = "local_order"
const val KEY_WEBTOON_ZOOM = "webtoon_zoom"
const val KEY_SHELF_SECTIONS = "shelf_sections_2"
const val KEY_PREFETCH_CONTENT = "prefetch_content"
// About
const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_TRANSLATION = "about_app_translation"
private const val NETWORK_NEVER = 0
private const val NETWORK_ALWAYS = 1
private const val NETWORK_NON_METERED = 2
}
}

View File

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.core.prefs
import android.net.ConnectivityManager
enum class NetworkPolicy(
private val key: Int,
) {
NEVER(0),
ALWAYS(1),
NON_METERED(2);
fun isNetworkAllowed(cm: ConnectivityManager) = when (this) {
NEVER -> false
ALWAYS -> true
NON_METERED -> !cm.isActiveNetworkMetered
}
companion object {
fun from(key: String?, default: NetworkPolicy): NetworkPolicy {
val intKey = key?.toIntOrNull() ?: return default
return enumValues<NetworkPolicy>().find { it.key == intKey } ?: default
}
}
}

View File

@@ -52,10 +52,13 @@ class ZipOutput(
return if (entryNames.add(entry.name)) {
val zipEntry = ZipEntry(entry.name)
output.putNextEntry(zipEntry)
other.getInputStream(entry).use { input ->
input.copyTo(output)
try {
other.getInputStream(entry).use { input ->
input.copyTo(output)
}
} finally {
output.closeEntry()
}
output.closeEntry()
true
} else {
false

View File

@@ -0,0 +1,117 @@
package org.koitharu.kotatsu.details.service
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors
import kotlinx.coroutines.coroutineScope
import org.koitharu.kotatsu.base.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaChapters
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository
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.utils.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import javax.inject.Inject
@AndroidEntryPoint
class MangaPrefetchService : CoroutineIntentService() {
@Inject
lateinit var mangaRepositoryFactory: MangaRepository.Factory
@Inject
lateinit var cache: ContentCache
@Inject
lateinit var historyRepository: HistoryRepository
override suspend fun processIntent(startId: Int, intent: Intent) {
when (intent.action) {
ACTION_PREFETCH_DETAILS -> prefetchDetails(
manga = intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga ?: return,
)
ACTION_PREFETCH_PAGES -> prefetchPages(
chapter = intent.getParcelableExtraCompat<ParcelableMangaChapters>(EXTRA_CHAPTER)
?.chapters?.singleOrNull() ?: return,
)
ACTION_PREFETCH_LAST -> prefetchLast()
}
}
override fun onError(startId: Int, error: Throwable) = Unit
private suspend fun prefetchDetails(manga: Manga) = coroutineScope {
val source = mangaRepositoryFactory.create(manga.source)
runCatchingCancellable { source.getDetails(manga) }
}
private suspend fun prefetchPages(chapter: MangaChapter) {
val source = mangaRepositoryFactory.create(chapter.source)
runCatchingCancellable { source.getPages(chapter) }
}
private suspend fun prefetchLast() {
val last = historyRepository.getLastOrNull() ?: return
if (last.source == MangaSource.LOCAL) return
val repo = mangaRepositoryFactory.create(last.source)
val details = runCatchingCancellable { repo.getDetails(last) }.getOrNull() ?: return
val chapters = details.chapters
if (chapters.isNullOrEmpty()) {
return
}
val history = historyRepository.getOne(last)
val chapter = if (history == null) {
chapters.firstOrNull()
} else {
chapters.find { x -> x.id == history.chapterId } ?: chapters.firstOrNull()
} ?: return
runCatchingCancellable { repo.getPages(chapter) }
}
companion object {
private const val EXTRA_MANGA = "manga"
private const val EXTRA_CHAPTER = "manga"
private const val ACTION_PREFETCH_DETAILS = "details"
private const val ACTION_PREFETCH_PAGES = "pages"
private const val ACTION_PREFETCH_LAST = "last"
fun prefetchDetails(context: Context, manga: Manga) {
if (!isPrefetchAvailable(context, manga.source)) return
val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_DETAILS
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
context.startService(intent)
}
fun prefetchPages(context: Context, chapter: MangaChapter) {
if (!isPrefetchAvailable(context, chapter.source)) return
val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_PAGES
intent.putExtra(EXTRA_CHAPTER, ParcelableMangaChapters(listOf(chapter)))
context.startService(intent)
}
fun prefetchLast(context: Context) {
if (!isPrefetchAvailable(context, null)) return
val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_LAST
context.startService(intent)
}
private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean {
if (source == MangaSource.LOCAL) {
return false
}
val entryPoint = EntryPointAccessors.fromApplication(context, PrefetchCompanionEntryPoint::class.java)
return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled()
}
}
}

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.details.service
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.prefs.AppSettings
@EntryPoint
@InstallIn(SingletonComponent::class)
interface PrefetchCompanionEntryPoint {
val settings: AppSettings
val contentCache: ContentCache
}

View File

@@ -18,6 +18,7 @@ import androidx.core.graphics.Insets
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.BaseTransientBottomBar
@@ -33,6 +34,8 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.core.ui.MangaErrorDialog
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
@@ -120,6 +123,7 @@ class DetailsActivity :
viewModel.branches.observe(this) {
binding.buttonDropdown.isVisible = it.size > 1
}
viewModel.chapters.observe(this, PrefetchObserver(this))
registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE))
addMenuProvider(
@@ -228,18 +232,18 @@ class DetailsActivity :
}
}
private fun onHistoryChanged(info: HistoryInfo?) {
private fun onHistoryChanged(info: HistoryInfo) {
with(binding.buttonRead) {
if (info?.history != null) {
if (info.history != null) {
setText(R.string._continue)
setIconResource(R.drawable.ic_play)
setIconResource(if (info.isIncognitoMode) R.drawable.ic_incognito else R.drawable.ic_play)
} else {
setText(R.string.read)
setIconResource(R.drawable.ic_read)
setIconResource(if (info.isIncognitoMode) R.drawable.ic_incognito else R.drawable.ic_play)
}
}
val text = when {
info == null -> getString(R.string.loading_)
!info.isValid -> getString(R.string.loading_)
info.currentChapter >= 0 -> getString(R.string.chapter_d_of_d, info.currentChapter + 1, info.totalChapters)
info.totalChapters == 0 -> getString(R.string.no_chapters)
else -> resources.getQuantityString(R.plurals.chapters, info.totalChapters, info.totalChapters)
@@ -325,6 +329,24 @@ class DetailsActivity :
return sb
}
private class PrefetchObserver(
private val context: Context,
) : Observer<List<ChapterListItem>> {
private var isCalled = false
override fun onChanged(t: List<ChapterListItem>?) {
if (t.isNullOrEmpty()) {
return
}
if (!isCalled) {
isCalled = true
val item = t.find { it.hasFlag(ChapterListItem.FLAG_CURRENT) } ?: t.first()
MangaPrefetchService.prefetchPages(context, item.chapter)
}
}
}
companion object {
fun newIntent(context: Context, manga: Manga): Intent {

View File

@@ -18,7 +18,6 @@ import coil.request.ImageRequest
import coil.util.CoilUtils
import com.google.android.material.chip.Chip
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
@@ -27,9 +26,9 @@ import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration
import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
@@ -45,8 +44,20 @@ import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.ext.computeSize
import org.koitharu.kotatsu.utils.ext.crossfade
import org.koitharu.kotatsu.utils.ext.drawableTop
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.ifNullOrEmpty
import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.resolveDp
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.utils.ext.textAndVisible
import org.koitharu.kotatsu.utils.ext.toFileOrNull
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import org.koitharu.kotatsu.utils.image.CoverSizeResolver
import javax.inject.Inject
@AndroidEntryPoint
class DetailsFragment :
@@ -75,7 +86,7 @@ class DetailsFragment :
binding.chipsTags.onChipClickListener = this
viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated)
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
viewModel.readingHistory.observe(viewLifecycleOwner, ::onHistoryChanged)
viewModel.historyInfo.observe(viewLifecycleOwner, ::onHistoryChanged)
viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged)
viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged)
viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged)
@@ -123,12 +134,14 @@ class DetailsFragment :
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_finished)
}
}
MangaState.ONGOING -> {
infoLayout.textViewState.apply {
textAndVisible = resources.getString(R.string.state_ongoing)
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_ongoing)
}
}
else -> infoLayout.textViewState.isVisible = false
}
if (manga.source == MangaSource.LOCAL) {
@@ -178,8 +191,8 @@ class DetailsFragment :
}
}
private fun onHistoryChanged(history: MangaHistory?) {
binding.progressView.setPercent(history?.percent ?: PROGRESS_NONE, animate = true)
private fun onHistoryChanged(history: HistoryInfo) {
binding.progressView.setPercent(history.history?.percent ?: PROGRESS_NONE, animate = true)
}
private fun onLoadingStateChanged(isLoading: Boolean) {
@@ -229,6 +242,7 @@ class DetailsFragment :
),
)
}
R.id.textView_source -> {
startActivity(
MangaListActivity.newIntent(
@@ -237,6 +251,7 @@ class DetailsFragment :
),
)
}
R.id.imageView_cover -> {
startActivity(
ImageActivity.newIntent(v.context, manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }),
@@ -249,7 +264,7 @@ class DetailsFragment :
override fun onLongClick(v: View): Boolean {
when (v.id) {
R.id.button_read -> {
if (viewModel.readingHistory.value == null) {
if (viewModel.historyInfo.value?.history == null) {
return false
}
val menu = PopupMenu(v.context, v)
@@ -271,12 +286,14 @@ class DetailsFragment :
)
true
}
else -> false
}
}
menu.show()
return true
}
else -> return false
}
}

View File

@@ -13,10 +13,18 @@ import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import java.io.IOException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
@@ -46,6 +54,7 @@ import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import java.io.IOException
class DetailsViewModel @AssistedInject constructor(
@Assisted intent: MangaIntent,
@@ -80,8 +89,14 @@ class DetailsViewModel @AssistedInject constructor(
private val favourite = favouritesRepository.observeCategoriesIds(delegate.mangaId).map { it.isNotEmpty() }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
private val newChapters = trackingRepository.observeNewChaptersCount(delegate.mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
private val newChapters = settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled }
.flatMapLatest { isEnabled ->
if (isEnabled) {
trackingRepository.observeNewChaptersCount(delegate.mangaId)
} else {
flowOf(0)
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
private val chaptersQuery = MutableStateFlow("")
@@ -91,17 +106,18 @@ class DetailsViewModel @AssistedInject constructor(
val manga = delegate.manga.filterNotNull().asLiveData(viewModelScope.coroutineContext)
val favouriteCategories = favourite.asLiveData(viewModelScope.coroutineContext)
val newChaptersCount = newChapters.asLiveData(viewModelScope.coroutineContext)
@Deprecated("")
val readingHistory = history.asLiveData(viewModelScope.coroutineContext)
val isChaptersReversed = chaptersReversed.asLiveData(viewModelScope.coroutineContext)
val historyInfo = combine(
val historyInfo: LiveData<HistoryInfo> = combine(
delegate.manga,
history,
) { m, h ->
HistoryInfo(m, h)
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, null)
historyRepository.observeShouldSkip(delegate.manga),
) { m, h, im ->
HistoryInfo(m, h, im)
}.asFlowLiveData(
context = viewModelScope.coroutineContext + Dispatchers.Default,
defaultValue = HistoryInfo(null, null, false),
)
val bookmarks = delegate.manga.flatMapLatest {
if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())

View File

@@ -42,7 +42,8 @@ class ChaptersAdapter(
}
}
override fun getSectionText(context: Context, position: Int): CharSequence {
return items[position].chapter.number.toString()
override fun getSectionText(context: Context, position: Int): CharSequence? {
val item = items.getOrNull(position) ?: return null
return item.chapter.number.toString()
}
}
}

View File

@@ -7,8 +7,12 @@ class HistoryInfo(
val totalChapters: Int,
val currentChapter: Int,
val history: MangaHistory?,
val isIncognitoMode: Boolean,
) {
val isValid: Boolean
get() = totalChapters >= 0
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
@@ -18,6 +22,7 @@ class HistoryInfo(
if (totalChapters != other.totalChapters) return false
if (currentChapter != other.currentChapter) return false
if (history != other.history) return false
if (isIncognitoMode != other.isIncognitoMode) return false
return true
}
@@ -26,20 +31,21 @@ class HistoryInfo(
var result = totalChapters
result = 31 * result + currentChapter
result = 31 * result + (history?.hashCode() ?: 0)
result = 31 * result + isIncognitoMode.hashCode()
return result
}
}
@Suppress("FunctionName")
fun HistoryInfo(manga: Manga?, history: MangaHistory?): HistoryInfo? {
val chapters = manga?.chapters ?: return null
fun HistoryInfo(manga: Manga?, history: MangaHistory?, isIncognitoMode: Boolean): HistoryInfo {
val chapters = manga?.chapters
return HistoryInfo(
totalChapters = chapters.size,
currentChapter = if (history != null) {
totalChapters = chapters?.size ?: -1,
currentChapter = if (history != null && !chapters.isNullOrEmpty()) {
chapters.indexOfFirst { it.id == history.chapterId }
} else {
-1
},
history = history,
isIncognitoMode = isIncognitoMode,
)
}

View File

@@ -2,8 +2,10 @@ package org.koitharu.kotatsu.explore.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels
@@ -11,11 +13,12 @@ import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.reverseAsync
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.bookmarks.ui.BookmarksActivity
import org.koitharu.kotatsu.databinding.FragmentExploreBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
@@ -31,6 +34,7 @@ import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import javax.inject.Inject
@AndroidEntryPoint
class ExploreFragment :
@@ -67,6 +71,7 @@ class ExploreFragment :
}
viewModel.onError.observe(viewLifecycleOwner, ::onError)
viewModel.onOpenManga.observe(viewLifecycleOwner, ::onOpenManga)
viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone)
}
override fun onDestroyView() {
@@ -95,6 +100,7 @@ class ExploreFragment :
viewModel.openRandom()
return
}
else -> return
}
startActivity(intent)
@@ -105,6 +111,14 @@ class ExploreFragment :
startActivity(intent)
}
override fun onItemLongClick(item: ExploreItem.Source, view: View): Boolean {
val menu = PopupMenu(view.context, view)
menu.inflate(R.menu.popup_source)
menu.setOnMenuItemClickListener(SourceMenuListener(item))
menu.show()
return true
}
override fun onRetryClick(error: Throwable) = Unit
override fun onEmptyActionClick() = onManageClick(requireView())
@@ -124,6 +138,37 @@ class ExploreFragment :
startActivity(intent)
}
private fun onActionDone(action: ReversibleAction) {
val handle = action.handle
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
val snackbar = Snackbar.make(binding.recyclerView, action.stringResId, length)
if (handle != null) {
snackbar.setAction(R.string.undo) { handle.reverseAsync() }
}
snackbar.anchorView = (activity as? BottomNavOwner)?.bottomNav
snackbar.show()
}
private inner class SourceMenuListener(
private val sourceItem: ExploreItem.Source,
) : PopupMenu.OnMenuItemClickListener {
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_settings -> {
startActivity(SettingsActivity.newSourceSettingsIntent(requireContext(), sourceItem.source))
}
R.id.action_hide -> {
viewModel.hideSource(sourceItem.source)
}
else -> return false
}
return true
}
}
companion object {
fun newInstance() = ExploreFragment()

View File

@@ -4,11 +4,17 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.explore.ui.model.ExploreItem
@@ -16,6 +22,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import javax.inject.Inject
@HiltViewModel
class ExploreViewModel @Inject constructor(
@@ -24,6 +31,7 @@ class ExploreViewModel @Inject constructor(
) : BaseViewModel() {
val onOpenManga = SingleLiveEvent<Manga>()
val onActionDone = SingleLiveEvent<ReversibleAction>()
val content: LiveData<List<ExploreItem>> = isLoading.asFlow().flatMapLatest { loading ->
if (loading) {
@@ -40,6 +48,16 @@ class ExploreViewModel @Inject constructor(
}
}
fun hideSource(source: MangaSource) {
launchJob(Dispatchers.Default) {
settings.hiddenSources += source.name
val rollback = ReversibleHandle {
settings.hiddenSources -= source.name
}
onActionDone.postCall(ReversibleAction(R.string.source_disabled, rollback))
}
}
private fun createContentFlow() = settings.observe()
.filter {
it == AppSettings.KEY_SOURCES_HIDDEN ||

View File

@@ -7,15 +7,16 @@ import android.view.View
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.PopupMenu
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.titleRes
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.addMenuProvider
import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.withArgs
import javax.inject.Inject
@AndroidEntryPoint
class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener {
@@ -23,9 +24,7 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
@Inject
lateinit var viewModelFactory: FavouritesListViewModel.Factory
override val viewModel by assistedViewModels<FavouritesListViewModel> {
viewModelFactory.create(categoryId)
}
override val viewModel by assistedViewModels { viewModelFactory.create(categoryId) }
private val categoryId: Long
get() = arguments?.getLong(ARG_CATEGORY_ID) ?: NO_ID
@@ -34,6 +33,9 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
if (viewModel.categoryId != NO_ID) {
addMenuProvider(FavouritesListMenuProvider(view.context, viewModel))
}
viewModel.sortOrder.observe(viewLifecycleOwner) { activity?.invalidateOptionsMenu() }
}
@@ -73,6 +75,7 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
mode.finish()
true
}
else -> super.onActionItemClicked(controller, mode, item)
}
}

View File

@@ -0,0 +1,56 @@
package org.koitharu.kotatsu.favourites.ui.list
import android.content.Context
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import androidx.core.view.forEach
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.titleRes
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
import org.koitharu.kotatsu.parsers.model.SortOrder
class FavouritesListMenuProvider(
private val context: Context,
private val viewModel: FavouritesListViewModel,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_favourites, menu)
val subMenu = menu.findItem(R.id.action_order)?.subMenu ?: return
for (order in FavouriteCategoriesActivity.SORT_ORDERS) {
subMenu.add(R.id.group_order, Menu.NONE, order.ordinal, order.titleRes)
}
subMenu.setGroupCheckable(R.id.group_order, true, true)
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
val order = viewModel.sortOrder.value ?: return
menu.findItem(R.id.action_order)?.subMenu?.forEach { item ->
if (item.order == order.ordinal) {
item.isChecked = true
}
}
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
if (menuItem.groupId == R.id.group_order) {
val order = enumValues<SortOrder>()[menuItem.order]
viewModel.setSortOrder(order)
return true
}
return when (menuItem.itemId) {
R.id.action_edit -> {
context.startActivity(
FavouritesCategoryEditActivity.newIntent(context, viewModel.categoryId),
)
true
}
else -> false
}
}
}

View File

@@ -31,7 +31,7 @@ import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
class FavouritesListViewModel @AssistedInject constructor(
@Assisted private val categoryId: Long,
@Assisted val categoryId: Long,
private val repository: FavouritesRepository,
private val trackingRepository: TrackingRepository,
private val historyRepository: HistoryRepository,
@@ -55,7 +55,7 @@ class FavouritesListViewModel @AssistedInject constructor(
} else {
repository.observeAll(categoryId)
},
createListModeFlow(),
listModeFlow,
) { list, mode ->
when {
list.isEmpty() -> listOf(
@@ -117,7 +117,11 @@ class FavouritesListViewModel @AssistedInject constructor(
}
override suspend fun getCounter(mangaId: Long): Int {
return trackingRepository.getNewChaptersCount(mangaId)
return if (settings.isTrackerEnabled) {
trackingRepository.getNewChaptersCount(mangaId)
} else {
0
}
}
override suspend fun getProgress(mangaId: Long): Float {

View File

@@ -1,15 +1,23 @@
package org.koitharu.kotatsu.history.domain
import androidx.room.withTransaction
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.*
import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTag
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.history.data.toMangaHistory
import org.koitharu.kotatsu.parsers.model.Manga
@@ -18,6 +26,7 @@ import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.domain.tryScrobble
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.mapItems
import javax.inject.Inject
const val PROGRESS_NONE = -1f
@@ -66,7 +75,7 @@ class HistoryRepository @Inject constructor(
}
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int, percent: Float) {
if (manga.isNsfw && settings.isHistoryExcludeNsfw || settings.isIncognitoModeEnabled) {
if (shouldSkip(manga)) {
return
}
val tags = manga.tags.toEntities()
@@ -138,6 +147,30 @@ class HistoryRepository @Inject constructor(
return db.historyDao.findPopularTags(limit).map { x -> x.toMangaTag() }
}
fun shouldSkip(manga: Manga): Boolean {
return manga.isNsfw && settings.isHistoryExcludeNsfw || settings.isIncognitoModeEnabled
}
fun observeShouldSkip(manga: Manga): Flow<Boolean> {
return settings.observe()
.filter { key -> key == AppSettings.KEY_INCOGNITO_MODE || key == AppSettings.KEY_HISTORY_EXCLUDE_NSFW }
.onStart { emit("") }
.map { shouldSkip(manga) }
.distinctUntilChanged()
}
fun observeShouldSkip(mangaFlow: Flow<Manga?>): Flow<Boolean> {
return mangaFlow
.distinctUntilChangedBy { it?.isNsfw }
.flatMapLatest { m ->
if (m != null) {
observeShouldSkip(m)
} else {
settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled }
}
}
}
private suspend fun recover(ids: Collection<Long>) {
db.withTransaction {
for (id in ids) {

View File

@@ -14,14 +14,14 @@ class HistoryListAdapter(
listener: MangaListListener
) : MangaListAdapter(coil, lifecycleOwner, listener), FastScroller.SectionIndexer {
override fun getSectionText(context: Context, position: Int): CharSequence {
override fun getSectionText(context: Context, position: Int): CharSequence? {
val list = items
for (i in (0..position).reversed()) {
val item = list[i]
val item = list.getOrNull(i) ?: continue
if (item is DateTimeAgo) {
return item.format(context.resources)
}
}
return ""
return null
}
}
}

View File

@@ -3,9 +3,6 @@ package org.koitharu.kotatsu.history.ui
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.*
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
@@ -21,11 +18,20 @@ import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.MangaWithHistory
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toGridModel
import org.koitharu.kotatsu.list.ui.model.toListDetailedModel
import org.koitharu.kotatsu.list.ui.model.toListModel
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.onFirst
import java.util.Date
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltViewModel
class HistoryListViewModel @Inject constructor(
@@ -42,7 +48,7 @@ class HistoryListViewModel @Inject constructor(
override val content = combine(
repository.observeAllWithHistory(),
historyGrouping,
createListModeFlow(),
listModeFlow,
) { list, grouped, mode ->
when {
list.isEmpty() -> listOf(
@@ -53,6 +59,7 @@ class HistoryListViewModel @Inject constructor(
actionStringRes = 0,
),
)
else -> mapList(list, grouped, mode)
}
}.onStart {
@@ -103,7 +110,11 @@ class HistoryListViewModel @Inject constructor(
}
prevDate = date
}
val counter = trackingRepository.getNewChaptersCount(manga.id)
val counter = if (settings.isTrackerEnabled) {
trackingRepository.getNewChaptersCount(manga.id)
} else {
0
}
val percent = if (showPercent) history.percent else PROGRESS_NONE
result += when (mode) {
ListMode.LIST -> manga.toListModel(counter, percent)

View File

@@ -49,6 +49,9 @@ class ListModeBottomSheet :
}
override fun onButtonChecked(group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean) {
if (!isChecked) {
return
}
val mode = when (checkedId) {
R.id.button_list -> ListMode.LIST
R.id.button_list_detailed -> ListMode.DETAILED_LIST

View File

@@ -1,7 +1,11 @@
package org.koitharu.kotatsu.list.ui
import android.os.Bundle
import android.view.*
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.MarginLayoutParams
import androidx.annotation.CallSuper
import androidx.appcompat.view.ActionMode
@@ -15,7 +19,6 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.reverseAsync
@@ -42,13 +45,23 @@ import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
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.reader.ui.ReaderActivity
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.ext.addMenuProvider
import org.koitharu.kotatsu.utils.ext.clearItemDecorations
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.getThemeColor
import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.resolveDp
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import javax.inject.Inject
@AndroidEntryPoint
abstract class MangaListFragment :
@@ -138,6 +151,20 @@ abstract class MangaListFragment :
return selectionController?.onItemLongClick(item.id) ?: false
}
override fun onReadClick(manga: Manga, view: View) {
if (selectionController?.onItemClick(manga.id) != true) {
val intent = ReaderActivity.newIntent(context ?: return, manga)
startActivity(intent, scaleUpActivityOptionsOf(view).toBundle())
}
}
override fun onTagClick(manga: Manga, tag: MangaTag, view: View) {
if (selectionController?.onItemClick(manga.id) != true) {
val intent = MangaListActivity.newIntent(context ?: return, setOf(tag))
startActivity(intent)
}
}
@CallSuper
override fun onRefresh() {
binding.swipeRefreshLayout.isRefreshing = true
@@ -251,12 +278,14 @@ abstract class MangaListFragment :
)
addItemDecoration(decoration)
}
ListMode.DETAILED_LIST -> {
layoutManager = FitHeightLinearLayoutManager(context)
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
updatePadding(left = spacing, right = spacing)
addItemDecoration(SpacingItemDecoration(spacing))
}
ListMode.GRID -> {
layoutManager = FitHeightGridLayoutManager(context, checkNotNull(spanResolver).spanCount).also {
it.spanSizeLookup = spanSizeLookup
@@ -284,21 +313,25 @@ abstract class MangaListFragment :
selectionController?.addAll(ids)
true
}
R.id.action_share -> {
ShareHelper(requireContext()).shareMangaLinks(selectedItems)
mode.finish()
true
}
R.id.action_favourite -> {
FavouriteCategoriesBottomSheet.show(childFragmentManager, selectedItems)
mode.finish()
true
}
R.id.action_save -> {
DownloadService.confirmAndStart(requireContext(), selectedItems)
mode.finish()
true
}
else -> false
}
}

View File

@@ -1,26 +1,29 @@
package org.koitharu.kotatsu.list.ui
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsLiveData
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData
abstract class MangaListViewModel(
private val settings: AppSettings,
) : BaseViewModel() {
abstract val content: LiveData<List<ListModel>>
val listMode = MutableLiveData<ListMode>()
protected val listModeFlow = settings.observeAsFlow(AppSettings.KEY_LIST_MODE) { listMode }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, settings.listMode)
val listMode = listModeFlow.asFlowLiveData(viewModelScope.coroutineContext)
val onActionDone = SingleLiveEvent<ReversibleAction>()
val gridScale = settings.observeAsLiveData(
context = viewModelScope.coroutineContext + Dispatchers.Default,
@@ -30,13 +33,6 @@ abstract class MangaListViewModel(
open fun onUpdateFilter(tags: Set<MangaTag>) = Unit
protected fun createListModeFlow() = settings.observeAsFlow(AppSettings.KEY_LIST_MODE) { listMode }
.onEach {
if (listMode.value != it) {
listMode.postValue(it)
}
}
abstract fun onRefresh()
abstract fun onRetry()

View File

@@ -22,11 +22,12 @@ open class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDec
protected val paint = Paint(Paint.ANTI_ALIAS_FLAG)
protected val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle)
protected val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer)
protected val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_offset)
protected val iconSize = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_size)
protected val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
protected val fillColor = ColorUtils.setAlphaComponent(
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
0x74
0x74,
)
protected val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner)
@@ -65,11 +66,11 @@ open class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDec
setBounds(
(bounds.left + iconOffset).toInt(),
(bounds.top + iconOffset).toInt(),
(bounds.left + iconOffset + intrinsicWidth).toInt(),
(bounds.top + iconOffset + intrinsicHeight).toInt(),
(bounds.left + iconOffset + iconSize).toInt(),
(bounds.top + iconOffset + iconSize).toInt(),
)
draw(canvas)
}
}
}
}
}

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.list.ui.adapter
import android.view.View
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
interface MangaDetailsClickListener : OnListItemClickListener<Manga> {
fun onReadClick(manga: Manga, view: View)
fun onTagClick(manga: Manga, tag: MangaTag, view: View)
}

View File

@@ -1,34 +1,52 @@
package org.koitharu.kotatsu.list.ui.adapter
import android.view.View
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import com.google.android.material.badge.BadgeDrawable
import com.google.android.material.chip.Chip
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.textAndVisible
import org.koitharu.kotatsu.utils.image.CoverSizeResolver
fun mangaListDetailedItemAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga>,
clickListener: MangaDetailsClickListener,
) = adapterDelegateViewBinding<MangaListDetailedModel, ListModel, ItemMangaListDetailsBinding>(
{ inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) },
) {
var badge: BadgeDrawable? = null
itemView.setOnClickListener {
clickListener.onItemClick(item.manga, it)
}
itemView.setOnLongClickListener {
clickListener.onItemLongClick(item.manga, it)
val listenerAdapter = object : View.OnClickListener, View.OnLongClickListener, ChipsView.OnChipClickListener {
override fun onClick(v: View) = when (v.id) {
R.id.button_read -> clickListener.onReadClick(item.manga, v)
else -> clickListener.onItemClick(item.manga, v)
}
override fun onLongClick(v: View): Boolean = clickListener.onItemLongClick(item.manga, v)
override fun onChipClick(chip: Chip, data: Any?) {
val tag = data as? MangaTag ?: return
clickListener.onTagClick(item.manga, tag, chip)
}
}
itemView.setOnClickListener(listenerAdapter)
itemView.setOnLongClickListener(listenerAdapter)
binding.buttonRead.setOnClickListener(listenerAdapter)
binding.chipsTags.onChipClickListener = listenerAdapter
bind { payloads ->
binding.textViewTitle.text = item.title
@@ -44,8 +62,9 @@ fun mangaListDetailedItemAD(
lifecycle(lifecycleOwner)
enqueueWith(coil)
}
binding.textViewRating.textAndVisible = item.rating
binding.textViewTags.text = item.tags
binding.chipsTags.setChips(item.tags)
binding.ratingBar.isVisible = item.manga.hasRating
binding.ratingBar.rating = binding.ratingBar.numStars * item.manga.rating
badge = itemView.bindBadge(badge, item.counter)
}

View File

@@ -1,11 +1,9 @@
package org.koitharu.kotatsu.list.ui.adapter
import android.view.View
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
interface MangaListListener : OnListItemClickListener<Manga>, ListStateHolderListener, ListHeaderClickListener {
interface MangaListListener : MangaDetailsClickListener, ListStateHolderListener, ListHeaderClickListener {
fun onUpdateFilter(tags: Set<MangaTag>)

View File

@@ -1,8 +1,7 @@
package org.koitharu.kotatsu.list.ui.model
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.ListMode
@@ -11,6 +10,8 @@ import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.ifZero
import java.net.SocketTimeoutException
import java.net.UnknownHostException
fun Manga.toListModel(counter: Int, progress: Float) = MangaListModel(
id = id,
@@ -26,12 +27,11 @@ fun Manga.toListDetailedModel(counter: Int, progress: Float) = MangaListDetailed
id = id,
title = title,
subtitle = altTitle,
rating = if (hasRating) String.format("%.1f", rating * 5) else null,
tags = tags.joinToString(", ") { it.title },
coverUrl = coverUrl,
manga = this,
counter = counter,
progress = progress,
tags = tags.map { ChipsView.ChipModel(0, it.title, false, false, it) },
)
fun Manga.toGridModel(counter: Int, progress: Float) = MangaGridModel(
@@ -69,9 +69,11 @@ suspend fun <C : MutableCollection<in MangaItemModel>> List<Manga>.toUi(
ListMode.LIST -> mapTo(destination) {
it.toListModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id))
}
ListMode.DETAILED_LIST -> mapTo(destination) {
it.toListDetailedModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id))
}
ListMode.GRID -> mapTo(destination) {
it.toGridModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id))
}
@@ -95,5 +97,6 @@ private fun getErrorIcon(error: Throwable) = when (error) {
is UnknownHostException,
is SocketTimeoutException,
-> R.drawable.ic_plug_large
else -> R.drawable.ic_error_large
}

View File

@@ -1,15 +1,15 @@
package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.parsers.model.Manga
data class MangaListDetailedModel(
override val id: Long,
override val title: String,
val subtitle: String?,
val tags: String,
override val coverUrl: String,
val rating: String?,
override val manga: Manga,
override val counter: Int,
override val progress: Float,
) : MangaItemModel
val tags: List<ChipsView.ChipModel>,
) : MangaItemModel

View File

@@ -23,10 +23,7 @@ class CbzFetcher(
val zip = ZipFile(uri.schemeSpecificPart)
val entry = zip.getEntry(uri.fragment)
val ext = MimeTypeMap.getFileExtensionFromUrl(entry.name)
val bufferedSource = ExtraCloseableBufferedSource(
zip.getInputStream(entry).source().buffer(),
zip,
)
val bufferedSource = zip.getInputStream(entry).source().withExtraCloseable(zip).buffer()
SourceResult(
source = ImageSource(
source = bufferedSource,
@@ -50,4 +47,4 @@ class CbzFetcher(
}
class CbzMetadata(val uri: Uri) : ImageSource.Metadata()
}
}

View File

@@ -1,18 +0,0 @@
package org.koitharu.kotatsu.local.data
import okhttp3.internal.closeQuietly
import okio.BufferedSource
import okio.Closeable
class ExtraCloseableBufferedSource(
private val delegate: BufferedSource,
vararg closeable: Closeable,
) : BufferedSource by delegate {
private val extraCloseable = closeable
override fun close() {
delegate.close()
extraCloseable.forEach { x -> x.closeQuietly() }
}
}

View File

@@ -0,0 +1,21 @@
package org.koitharu.kotatsu.local.data
import okhttp3.internal.closeQuietly
import okio.Closeable
import okio.Source
private class ExtraCloseableSource(
private val delegate: Source,
private val extraCloseable: Closeable,
) : Source by delegate {
override fun close() {
try {
delegate.close()
} finally {
extraCloseable.closeQuietly()
}
}
}
fun Source.withExtraCloseable(closeable: Closeable): Source = ExtraCloseableSource(this, closeable)

View File

@@ -10,6 +10,7 @@ import org.koitharu.kotatsu.utils.ext.copyToSuspending
import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.subdir
import org.koitharu.kotatsu.utils.ext.takeIfReadable
import org.koitharu.kotatsu.utils.ext.takeIfWriteable
import java.io.File
import java.io.InputStream
import javax.inject.Inject
@@ -18,9 +19,14 @@ import javax.inject.Singleton
@Singleton
class PagesCache @Inject constructor(@ApplicationContext context: Context) {
private val cacheDir = context.externalCacheDir ?: context.cacheDir
private val cacheDir = checkNotNull(findSuitableDir(context)) {
val dirs = (context.externalCacheDirs + context.cacheDir).joinToString(";") {
it.absolutePath
}
"Cannot find any suitable directory for PagesCache: [$dirs]"
}
private val lruCache = createDiskLruCacheSafe(
dir = cacheDir.subdir(CacheDir.PAGES.dir),
dir = cacheDir,
size = FileSize.MEGABYTES.convert(200, FileSize.BYTES),
)
@@ -29,7 +35,7 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
}
suspend fun put(url: String, inputStream: InputStream): File = withContext(Dispatchers.IO) {
val file = File(cacheDir, url.longHashCode().toString())
val file = File(cacheDir.parentFile, url.longHashCode().toString())
try {
file.outputStream().use { out ->
inputStream.copyToSuspending(out)
@@ -50,3 +56,10 @@ private fun createDiskLruCacheSafe(dir: File, size: Long): DiskLruCache {
DiskLruCache.create(dir, size)
}
}
private fun findSuitableDir(context: Context): File? {
val dirs = context.externalCacheDirs + context.cacheDir
return dirs.firstNotNullOfOrNull {
it.subdir(CacheDir.PAGES.dir).takeIfWriteable()
}
}

View File

@@ -13,6 +13,7 @@ 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.model.RATING_UNKNOWN
import org.koitharu.kotatsu.utils.AlphanumComparator
import org.koitharu.kotatsu.utils.ext.copyToSuspending
import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.longOf
@@ -58,7 +59,7 @@ class DirMangaImporter(
private suspend fun addPages(output: CbzMangaOutput, root: DocumentFile, path: String, state: State) {
var number = 0
for (file in root.listFiles()) {
for (file in root.listFiles().sortedWith(compareBy(AlphanumComparator()) { it.name.orEmpty() })) {
when {
file.isDirectory -> {
addPages(output, file, path + "/" + file.name, state)

View File

@@ -4,14 +4,16 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import java.io.IOException
import java.util.*
import javax.inject.Inject
import kotlinx.coroutines.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -20,7 +22,11 @@ import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader2
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.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
@@ -30,6 +36,9 @@ import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import java.io.IOException
import java.util.LinkedList
import javax.inject.Inject
@HiltViewModel
class LocalListViewModel @Inject constructor(
@@ -48,7 +57,7 @@ class LocalListViewModel @Inject constructor(
override val content = combine(
mangaList,
createListModeFlow(),
listModeFlow,
sortOrder.asFlow(),
selectedTags,
listError,
@@ -181,7 +190,11 @@ class LocalListViewModel @Inject constructor(
}
override suspend fun getCounter(mangaId: Long): Int {
return trackingRepository.getNewChaptersCount(mangaId)
return if (settings.isTrackerEnabled) {
trackingRepository.getNewChaptersCount(mangaId)
} else {
0
}
}
override suspend fun getProgress(mangaId: Long): Float {

View File

@@ -41,6 +41,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.widgets.SlidingBottomNavigationView
import org.koitharu.kotatsu.databinding.ActivityMainBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
@@ -334,6 +335,7 @@ class MainActivity :
TrackWorker.setup(applicationContext)
SuggestionsWorker.setup(applicationContext)
}
MangaPrefetchService.prefetchLast(this@MainActivity)
requestNotificationsPermission()
}
}

View File

@@ -46,6 +46,7 @@ class MainNavigationDelegate(
fun onCreate(savedInstanceState: Bundle?) {
primaryFragment?.let {
onFragmentChanged(it, fromUser = false)
navBar.selectedItemId = getItemId(it)
} ?: onNavigationItemSelected(navBar.selectedItemId)
}
@@ -92,6 +93,14 @@ class MainNavigationDelegate(
return true
}
private fun getItemId(fragment: Fragment) = when (fragment) {
is ShelfFragment -> R.id.nav_shelf
is ExploreFragment -> R.id.nav_explore
is FeedFragment -> R.id.nav_feed
is ToolsFragment -> R.id.nav_tools
else -> 0
}
private fun setPrimaryFragment(fragment: Fragment) {
fragmentManager.beginTransaction()
.setReorderingAllowed(true)

View File

@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.github.AppUpdateRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsLiveData
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
@@ -34,9 +35,12 @@ class MainViewModel @Inject constructor(
val onOpenReader = SingleLiveEvent<Manga>()
val isResumeEnabled = historyRepository
.observeHasItems()
.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false)
val isResumeEnabled = combine(
historyRepository.observeHasItems(),
settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled },
) { hasItems, incognito ->
hasItems && !incognito
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false)
val isFeedAvailable = settings.observeAsLiveData(
context = viewModelScope.coroutineContext + Dispatchers.Default,

View File

@@ -1,12 +1,10 @@
package org.koitharu.kotatsu.reader.domain
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.collection.LongSparseArray
import androidx.collection.set
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
@@ -31,7 +29,6 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.withProgress
import org.koitharu.kotatsu.utils.progress.ProgressDeferred
@@ -50,13 +47,11 @@ class PageLoader @Inject constructor(
private val okHttp: OkHttpClient,
private val cache: PagesCache,
private val settings: AppSettings,
@ApplicationContext context: Context,
private val mangaRepositoryFactory: MangaRepository.Factory,
) : Closeable {
val loaderScope = CoroutineScope(SupervisorJob() + InternalErrorHandler() + Dispatchers.Default)
private val connectivityManager = context.connectivityManager
private val tasks = LongSparseArray<ProgressDeferred<File, Float>>()
private val convertLock = Mutex()
private var repository: MangaRepository? = null
@@ -73,7 +68,7 @@ class PageLoader @Inject constructor(
}
fun isPrefetchApplicable(): Boolean {
return repository is RemoteMangaRepository && settings.isPagesPreloadAllowed(connectivityManager)
return repository is RemoteMangaRepository && settings.isPagesPreloadEnabled()
}
fun prefetch(pages: List<ReaderPage>) {

View File

@@ -313,7 +313,7 @@ class ReaderViewModel @AssistedInject constructor(
} ?: ReaderState(manga, preselectedBranch)
}
val branch = chapters[currentState.value?.chapterId ?: 0L].branch
val branch = chapters[currentState.value?.chapterId ?: 0L]?.branch
mangaData.value = manga.filterChapters(branch)
readerMode.postValue(mode)

View File

@@ -21,6 +21,7 @@ import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import java.io.File
import java.io.IOException
@@ -138,6 +139,7 @@ class PageHolderDelegate(
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
e.printStackTraceDebug()
state = State.ERROR
error = e
callback.onError(e)

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.ui.pager.reversed
import android.graphics.PointF
import android.view.Gravity
import android.widget.FrameLayout
import androidx.lifecycle.LifecycleOwner
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.ZoomMode
@@ -13,12 +14,13 @@ import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.standard.PageHolder
class ReversedPageHolder(
owner: LifecycleOwner,
binding: ItemPageBinding,
loader: PageLoader,
settings: ReaderSettings,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : PageHolder(binding, loader, settings, networkState, exceptionResolver) {
) : PageHolder(owner, binding, loader, settings, networkState, exceptionResolver) {
init {
(binding.textViewNumber.layoutParams as FrameLayout.LayoutParams)

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.reader.ui.pager.reversed
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.lifecycle.LifecycleOwner
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.ItemPageBinding
@@ -10,6 +11,7 @@ import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class ReversedPagesAdapter(
private val lifecycleOwner: LifecycleOwner,
loader: PageLoader,
settings: ReaderSettings,
networkState: NetworkState,
@@ -23,6 +25,7 @@ class ReversedPagesAdapter(
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) = ReversedPageHolder(
owner = lifecycleOwner,
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
loader = loader,
settings = settings,

View File

@@ -39,10 +39,11 @@ class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
pagerAdapter = ReversedPagesAdapter(
viewModel.pageLoader,
viewModel.readerSettings,
networkState,
exceptionResolver,
lifecycleOwner = viewLifecycleOwner,
loader = viewModel.pageLoader,
settings = viewModel.readerSettings,
networkState = networkState,
exceptionResolver = exceptionResolver,
)
with(binding.pager) {
adapter = pagerAdapter

View File

@@ -5,6 +5,7 @@ import android.graphics.PointF
import android.net.Uri
import android.view.View
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.R
@@ -19,6 +20,7 @@ import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.*
open class PageHolder(
owner: LifecycleOwner,
binding: ItemPageBinding,
loader: PageLoader,
settings: ReaderSettings,
@@ -28,6 +30,7 @@ open class PageHolder(
View.OnClickListener {
init {
binding.ssiv.bindToLifecycle(owner)
binding.ssiv.isEagerLoadingEnabled = !isLowRamDevice(context)
binding.ssiv.addOnImageEventListener(delegate)
@Suppress("LeakingThis")

View File

@@ -38,10 +38,11 @@ class PagerReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
pagesAdapter = PagesAdapter(
viewModel.pageLoader,
viewModel.readerSettings,
networkState,
exceptionResolver,
lifecycleOwner = viewLifecycleOwner,
loader = viewModel.pageLoader,
settings = viewModel.readerSettings,
networkState = networkState,
exceptionResolver = exceptionResolver,
)
with(binding.pager) {
adapter = pagesAdapter

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.reader.ui.pager.standard
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.lifecycle.LifecycleOwner
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.ItemPageBinding
@@ -10,6 +11,7 @@ import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class PagesAdapter(
private val lifecycleOwner: LifecycleOwner,
loader: PageLoader,
settings: ReaderSettings,
networkState: NetworkState,
@@ -23,6 +25,7 @@ class PagesAdapter(
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) = PageHolder(
owner = lifecycleOwner,
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
loader = loader,
settings = settings,

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.lifecycle.LifecycleOwner
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding
@@ -10,6 +11,7 @@ import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class WebtoonAdapter(
private val lifecycleOwner: LifecycleOwner,
loader: PageLoader,
settings: ReaderSettings,
networkState: NetworkState,
@@ -23,6 +25,7 @@ class WebtoonAdapter(
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) = WebtoonHolder(
owner = lifecycleOwner,
binding = ItemPageWebtoonBinding.inflate(
LayoutInflater.from(parent.context),
parent,

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.net.Uri
import android.view.View
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder
@@ -22,6 +23,7 @@ import org.koitharu.kotatsu.utils.ext.setProgressCompat
import org.koitharu.kotatsu.utils.ext.showCompat
class WebtoonHolder(
owner: LifecycleOwner,
binding: ItemPageWebtoonBinding,
loader: PageLoader,
settings: ReaderSettings,
@@ -34,6 +36,7 @@ class WebtoonHolder(
private val goneOnInvisibleListener = GoneOnInvisibleListener(bindingInfo.progressBar)
init {
binding.ssiv.bindToLifecycle(owner)
binding.ssiv.regionDecoderFactory = SkiaPooledImageRegionDecoder.Factory()
binding.ssiv.addOnImageEventListener(delegate)
bindingInfo.buttonRetry.setOnClickListener(this)

View File

@@ -35,10 +35,11 @@ class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
webtoonAdapter = WebtoonAdapter(
viewModel.pageLoader,
viewModel.readerSettings,
networkState,
exceptionResolver,
lifecycleOwner = viewLifecycleOwner,
loader = viewModel.pageLoader,
settings = viewModel.readerSettings,
networkState = networkState,
exceptionResolver = exceptionResolver,
)
with(binding.recyclerView) {
setHasFixedSize(true)

View File

@@ -63,7 +63,7 @@ class RemoteListViewModel @AssistedInject constructor(
override val content = combine(
mangaList,
createListModeFlow(),
listModeFlow,
createHeaderFlow(),
listError,
hasNextPage,

View File

@@ -39,7 +39,7 @@ class SearchViewModel @AssistedInject constructor(
override val content = combine(
mangaList,
createListModeFlow(),
listModeFlow,
listError,
hasNextPage,
) { list, mode, error, hasNext ->

View File

@@ -12,7 +12,6 @@ import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.list.ListSelectionController
@@ -27,11 +26,15 @@ import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.search.ui.multi.adapter.MultiSearchAdapter
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.invalidateNestedItemDecorations
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import javax.inject.Inject
@AndroidEntryPoint
class MultiSearchActivity :
@@ -110,6 +113,20 @@ class MultiSearchActivity :
return selectionController.onItemLongClick(item.id)
}
override fun onReadClick(manga: Manga, view: View) {
if (!selectionController.onItemClick(manga.id)) {
val intent = ReaderActivity.newIntent(this, manga)
startActivity(intent, scaleUpActivityOptionsOf(view).toBundle())
}
}
override fun onTagClick(manga: Manga, tag: MangaTag, view: View) {
if (!selectionController.onItemClick(manga.id)) {
val intent = MangaListActivity.newIntent(this, setOf(tag))
startActivity(intent)
}
}
override fun onRetryClick(error: Throwable) {
viewModel.doSearch(viewModel.query.value.orEmpty())
}
@@ -139,16 +156,19 @@ class MultiSearchActivity :
mode.finish()
true
}
R.id.action_favourite -> {
FavouriteCategoriesBottomSheet.show(supportFragmentManager, collectSelectedItems())
mode.finish()
true
}
R.id.action_save -> {
DownloadService.confirmAndStart(this, collectSelectedItems())
mode.finish()
true
}
else -> false
}
}

View File

@@ -9,14 +9,13 @@ import androidx.preference.ListPreference
import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import java.io.File
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.LocalStorageManager
@@ -26,6 +25,8 @@ import org.koitharu.kotatsu.sync.ui.SyncSettingsIntent
import org.koitharu.kotatsu.utils.ext.getStorageName
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import java.io.File
import javax.inject.Inject
@AndroidEntryPoint
class ContentSettingsFragment :
@@ -36,9 +37,12 @@ class ContentSettingsFragment :
@Inject
lateinit var storageManager: LocalStorageManager
@Inject
lateinit var contentCache: ContentCache
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_content)
findPreference<Preference>(AppSettings.KEY_PREFETCH_CONTENT)?.isVisible = contentCache.isCachingEnabled
findPreference<SliderPreference>(AppSettings.KEY_DOWNLOADS_PARALLELISM)?.run {
summary = value.toString()
setOnPreferenceChangeListener { preference, newValue ->
@@ -82,11 +86,13 @@ class ContentSettingsFragment :
AppSettings.KEY_LOCAL_STORAGE -> {
findPreference<Preference>(key)?.bindStorageName()
}
AppSettings.KEY_SUGGESTIONS -> {
findPreference<Preference>(AppSettings.KEY_SUGGESTIONS)?.setSummary(
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled,
)
}
AppSettings.KEY_SOURCES_HIDDEN -> {
bindRemoteSourcesSummary()
}
@@ -104,6 +110,7 @@ class ContentSettingsFragment :
.show()
true
}
AppSettings.KEY_SYNC -> {
val am = AccountManager.get(requireContext())
val accountType = getString(R.string.account_type_sync)
@@ -119,6 +126,7 @@ class ContentSettingsFragment :
}
true
}
else -> super.onPreferenceTreeClick(preference)
}
}

View File

@@ -12,7 +12,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.network.AndroidCookieJar
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.CacheDir
@@ -41,7 +41,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
lateinit var shikimoriRepository: ShikimoriRepository
@Inject
lateinit var cookieJar: AndroidCookieJar
lateinit var cookieJar: MutableCookieJar
@Inject
lateinit var shortcutsUpdater: ShortcutsUpdater

View File

@@ -86,7 +86,6 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
}.onSuccess { username ->
preference.title = getString(R.string.logged_in_as, username)
}.onFailure { error ->
preference.isEnabled = error is AuthRequiredException
when {
error is AuthRequiredException -> Unit
ExceptionResolver.canResolve(error) -> {

View File

@@ -16,7 +16,6 @@ import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.core.widget.TextViewCompat
import androidx.fragment.app.viewModels
import com.google.android.material.R as materialR
import com.google.android.material.color.MaterialColors
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
@@ -30,6 +29,7 @@ import org.koitharu.kotatsu.settings.about.AppUpdateDialog
import org.koitharu.kotatsu.settings.tools.model.StorageUsage
import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.getThemeColor
import com.google.android.material.R as materialR
@AndroidEntryPoint
class ToolsFragment :
@@ -48,6 +48,7 @@ class ToolsFragment :
binding.buttonSettings.setOnClickListener(this)
binding.buttonDownloads.setOnClickListener(this)
binding.cardUpdate.root.setOnClickListener(this)
binding.cardUpdate.buttonChangelog.setOnClickListener(this)
binding.cardUpdate.buttonDownload.setOnClickListener(this)
binding.switchIncognito.setOnCheckedChangeListener(this)
@@ -69,10 +70,13 @@ class ToolsFragment :
intent.data = url.toUri()
startActivity(Intent.createChooser(intent, getString(R.string.open_in_browser)))
}
R.id.card_update -> {
val version = viewModel.appUpdate.value ?: return
AppUpdateDialog(v.context).show(version)
}
R.id.button_changelog -> showChangelog()
}
}
@@ -141,6 +145,13 @@ class ToolsFragment :
return MaterialColors.harmonize(color, backgroundColor)
}
private fun showChangelog() {
TransitionManager.beginDelayedTransition(binding.cardUpdate.root)
binding.cardUpdate.buttonChangelog.isVisible = false
binding.cardUpdate.textSecondary.isVisible = true
binding.cardUpdate.textChangelog.isVisible = true
}
companion object {
fun newInstance() = ToolsFragment()

View File

@@ -53,17 +53,22 @@ class ShelfViewModel @Inject constructor(
val content: LiveData<List<ListModel>> = combine(
settings.observeAsFlow(AppSettings.KEY_SHELF_SECTIONS) { shelfSections },
settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled },
networkState,
repository.observeShelfContent(),
) { sections, isConnected, content ->
mapList(content, sections, isConnected)
) { sections, isTrackerEnabled, isConnected, content ->
mapList(content, isTrackerEnabled, sections, isConnected)
}.debounce(500)
.catch { e ->
emit(listOf(e.toErrorState(canRetry = false)))
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
override suspend fun getCounter(mangaId: Long): Int {
return trackingRepository.getNewChaptersCount(mangaId)
return if (settings.isTrackerEnabled) {
trackingRepository.getNewChaptersCount(mangaId)
} else {
0
}
}
override suspend fun getProgress(mangaId: Long): Float {
@@ -135,6 +140,7 @@ class ShelfViewModel @Inject constructor(
private suspend fun mapList(
content: ShelfContent,
isTrackerEnabled: Boolean,
sections: List<ShelfSection>,
isNetworkAvailable: Boolean,
): List<ListModel> {
@@ -144,7 +150,10 @@ class ShelfViewModel @Inject constructor(
when (section) {
ShelfSection.HISTORY -> mapHistory(result, content.history)
ShelfSection.LOCAL -> mapLocal(result, content.local)
ShelfSection.UPDATED -> mapUpdated(result, content.updated)
ShelfSection.UPDATED -> if (isTrackerEnabled) {
mapUpdated(result, content.updated)
}
ShelfSection.FAVORITES -> mapFavourites(result, content.favourites)
}
}
@@ -194,7 +203,7 @@ class ShelfViewModel @Inject constructor(
val showPercent = settings.isReadingIndicatorsEnabled
destination += ShelfSectionModel.History(
items = list.map { (manga, history) ->
val counter = trackingRepository.getNewChaptersCount(manga.id)
val counter = getCounter(manga.id)
val percent = if (showPercent) history.percent else PROGRESS_NONE
manga.toGridModel(counter, percent)
},

View File

@@ -12,13 +12,13 @@ class ScrollKeepObserver(
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
val position = minOf(toPosition, fromPosition) // if items are swapping positions may be swapped too
if (position < layoutManager.findFirstVisibleItemPosition()) {
if (position == 0 || position < layoutManager.findFirstVisibleItemPosition()) {
postScroll(position)
}
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (positionStart < layoutManager.findFirstVisibleItemPosition()) {
if (positionStart == 0 || positionStart < layoutManager.findFirstVisibleItemPosition()) {
postScroll(positionStart)
}
}

View File

@@ -46,9 +46,9 @@ class ShelfAdapter(
.addDelegate(errorStateListAD(listener))
}
override fun getSectionText(context: Context, position: Int): CharSequence {
val item = items.getOrNull(position) as? ShelfSectionModel
return item?.getTitle(context.resources) ?: ""
override fun getSectionText(context: Context, position: Int): CharSequence? {
val item = items.getOrNull(position) as? ShelfSectionModel ?: return null
return item.getTitle(context.resources)
}
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {

View File

@@ -23,9 +23,10 @@ class ShelfSettingsViewModel @Inject constructor(
val content = combine(
settings.observeAsFlow(AppSettings.KEY_SHELF_SECTIONS) { shelfSections },
settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled },
favouritesRepository.observeCategories(),
) { sections, categories ->
buildList(sections, categories)
) { sections, isTrackerEnabled, categories ->
buildList(sections, isTrackerEnabled, categories)
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
private var updateJob: Job? = null
@@ -64,13 +65,18 @@ class ShelfSettingsViewModel @Inject constructor(
private fun buildList(
sections: List<ShelfSection>,
isTrackerEnabled: Boolean,
categories: List<FavouriteCategory>
): List<ShelfSettingsItemModel> {
val result = ArrayList<ShelfSettingsItemModel>()
val sectionsList = ShelfSection.values().toMutableList()
if (!isTrackerEnabled) {
sectionsList.remove(ShelfSection.UPDATED)
}
for (section in sections) {
sectionsList.remove(section)
result.addSection(section, true, categories)
if (sectionsList.remove(section)) {
result.addSection(section, true, categories)
}
}
for (section in sectionsList) {
result.addSection(section, false, categories)

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.suggestions.ui
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
@@ -17,6 +16,7 @@ import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.onFirst
import javax.inject.Inject
@HiltViewModel
class SuggestionsViewModel @Inject constructor(
@@ -26,7 +26,7 @@ class SuggestionsViewModel @Inject constructor(
override val content = combine(
repository.observeAll(),
createListModeFlow(),
listModeFlow,
) { list, mode ->
when {
list.isEmpty() -> listOf(
@@ -37,6 +37,7 @@ class SuggestionsViewModel @Inject constructor(
actionStringRes = 0,
),
)
else -> list.toUi(mode)
}
}.onStart {

View File

@@ -144,6 +144,10 @@ class FeedFragment :
startActivity(DetailsActivity.newIntent(context ?: return, item))
}
override fun onReadClick(manga: Manga, view: View) = Unit
override fun onTagClick(manga: Manga, tag: MangaTag, view: View) = Unit
companion object {
fun newInstance() = FeedFragment()

View File

@@ -34,7 +34,7 @@ class UpdatesViewModel @Inject constructor(
override val content = combine(
repository.observeUpdatedManga(),
createListModeFlow(),
listModeFlow,
) { mangaMap, mode ->
when {
mangaMap.isEmpty() -> listOf(

View File

@@ -5,12 +5,16 @@ import android.content.Context
import android.content.Intent
import android.speech.RecognizerIntent
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.os.ConfigurationCompat
import java.util.Locale
class VoiceInputContract : ActivityResultContract<String?, String?>() {
override fun createIntent(context: Context, input: String?): Intent {
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
val locale = ConfigurationCompat.getLocales(context.resources.configuration).get(0) ?: Locale.getDefault()
intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, locale.toLanguageTag())
intent.putExtra(RecognizerIntent.EXTRA_PROMPT, input)
return intent
}
@@ -23,4 +27,4 @@ class VoiceInputContract : ActivityResultContract<String?, String?>() {
null
}
}
}
}

View File

@@ -23,6 +23,8 @@ fun File.subdir(name: String) = File(this, name).also {
fun File.takeIfReadable() = takeIf { it.exists() && it.canRead() }
fun File.takeIfWriteable() = takeIf { it.exists() && it.canWrite() }
fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).bufferedReader().use {
it.readText()
}
@@ -74,4 +76,4 @@ private fun computeSizeInternal(file: File): Long {
} else {
return file.length()
}
}
}

View File

@@ -1,12 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="5dp" />
android:shape="oval">
<solid android:color="?colorAccent" />
<padding
android:bottom="2dp"
android:left="2dp"
android:right="2dp"
android:top="2dp" />
</shape>
</shape>

View File

@@ -1,12 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="5dp" />
android:shape="oval">
<solid android:color="?attr/colorTertiary" />
<padding
android:bottom="2dp"
android:left="2dp"
android:right="2dp"
android:top="2dp" />
</shape>
</shape>

View File

@@ -1,8 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="5dp" />
android:shape="oval">
<solid android:color="?colorBackgroundFloating" />
<stroke
android:width="1dp"
@@ -12,4 +11,4 @@
android:left="2dp"
android:right="2dp"
android:top="2dp" />
</shape>
</shape>

View File

@@ -1,8 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="5dp" />
android:shape="oval">
<stroke
android:width="2dp"
android:color="?android:textColorTertiary" />
@@ -11,4 +10,4 @@
android:left="2dp"
android:right="2dp"
android:top="2dp" />
</shape>
</shape>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple
xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/selector_overlay">
<item>
<shape android:shape="oval">
<solid android:color="?colorSurface" />
</shape>
</item>
<item android:id="@android:id/mask">
<shape android:shape="oval">
<solid android:color="@color/selector_overlay" />
</shape>
</item>
</ripple>

View File

@@ -1,7 +1,7 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="14dp"
android:height="14dp"
android:width="18dp"
android:height="18dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">

View File

@@ -34,9 +34,9 @@
<org.koitharu.kotatsu.history.ui.util.ReadingProgressView
android:id="@+id/progressView"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_margin="4dp"
android:layout_width="@dimen/card_indicator_size"
android:layout_height="@dimen/card_indicator_size"
android:layout_margin="@dimen/card_indicator_offset"
app:layout_constraintBottom_toBottomOf="@id/imageView_cover"
app:layout_constraintEnd_toEndOf="@id/imageView_cover" />

View File

@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.5" />
<include
android:id="@+id/card_update"
layout="@layout/layout_app_update"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="@dimen/margin_normal"
android:visibility="gone"
app:layout_constraintEnd_toStartOf="@id/guideline"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<include
android:id="@+id/layout_sync"
layout="@layout/layout_synchronization"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/guideline"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<include
android:id="@+id/layout_storage"
layout="@layout/layout_memory_usage"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toStartOf="@id/guideline"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/card_update" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switch_incognito"
style="?listItemTextViewStyle"
android:layout_width="0dp"
android:layout_height="?android:listPreferredItemHeightSmall"
android:layout_marginTop="2dp"
android:drawableStart="@drawable/ic_incognito"
android:drawablePadding="?android:listPreferredItemPaddingStart"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
android:text="@string/incognito_mode"
android:textAppearance="?attr/textAppearanceButton"
android:textColor="?attr/colorControlNormal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/guideline"
app:layout_constraintTop_toBottomOf="@id/layout_sync"
app:layout_goneMarginTop="8dp" />
<org.koitharu.kotatsu.base.ui.widgets.ListItemTextView
android:id="@+id/button_downloads"
android:layout_width="0dp"
android:layout_height="?android:listPreferredItemHeightSmall"
android:drawableStart="@drawable/ic_download"
android:drawablePadding="?android:listPreferredItemPaddingStart"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
android:text="@string/downloads"
android:textAppearance="?attr/textAppearanceButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/guideline"
app:layout_constraintTop_toBottomOf="@id/switch_incognito" />
<org.koitharu.kotatsu.base.ui.widgets.ListItemTextView
android:id="@+id/button_settings"
android:layout_width="0dp"
android:layout_height="?android:listPreferredItemHeightSmall"
android:drawableStart="@drawable/ic_settings"
android:drawablePadding="?android:listPreferredItemPaddingStart"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
android:text="@string/settings"
android:textAppearance="?attr/textAppearanceButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/guideline"
app:layout_constraintTop_toBottomOf="@id/button_downloads" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>

View File

@@ -34,9 +34,9 @@
<org.koitharu.kotatsu.history.ui.util.ReadingProgressView
android:id="@+id/progressView"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_margin="4dp"
android:layout_width="@dimen/card_indicator_size"
android:layout_height="@dimen/card_indicator_size"
android:layout_margin="@dimen/card_indicator_offset"
app:layout_constraintBottom_toBottomOf="@id/imageView_cover"
app:layout_constraintEnd_toEndOf="@id/imageView_cover" />

View File

@@ -13,13 +13,15 @@
<TextView
android:id="@+id/textView_number"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="?android:listPreferredItemPaddingStart"
android:background="@drawable/bg_badge_default"
android:ellipsize="none"
android:gravity="center"
android:minWidth="26dp"
android:singleLine="true"
android:textAlignment="center"
android:textSize="14sp"
tools:text="13"
tools:textColor="?attr/colorOnPrimary" />
@@ -67,4 +69,4 @@
android:layout_marginEnd="?android:listPreferredItemPaddingEnd"
android:src="@drawable/ic_save_ok" />
</LinearLayout>
</LinearLayout>

View File

@@ -7,13 +7,13 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
app:cardCornerRadius="16dp">
app:cardCornerRadius="16dp"
tools:layout_width="140dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="4dp">
android:orientation="vertical">
<FrameLayout
android:layout_width="match_parent"
@@ -31,10 +31,10 @@
<org.koitharu.kotatsu.history.ui.util.ReadingProgressView
android:id="@+id/progressView"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_width="@dimen/card_indicator_size"
android:layout_height="@dimen/card_indicator_size"
android:layout_gravity="bottom|end"
android:layout_margin="4dp" />
android:layout_margin="@dimen/card_indicator_offset" />
</FrameLayout>
@@ -45,8 +45,7 @@
android:elegantTextHeight="false"
android:ellipsize="end"
android:lines="2"
android:paddingHorizontal="8dp"
android:paddingVertical="4dp"
android:padding="8dp"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?android:attr/textColorPrimary"
app:layout_constraintStart_toStartOf="parent"

View File

@@ -4,31 +4,29 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="@dimen/manga_list_details_item_height"
android:layout_height="wrap_content"
app:cardCornerRadius="16dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:padding="4dp">
android:layout_height="match_parent">
<org.koitharu.kotatsu.base.ui.widgets.CoverImageView
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/imageView_cover"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="vertical"
android:layout_width="98dp"
android:layout_height="0dp"
android:scaleType="centerCrop"
app:layout_constraintDimensionRatio="13:18"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover"
tools:src="@sample/covers" />
<org.koitharu.kotatsu.history.ui.util.ReadingProgressView
android:id="@+id/progressView"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_margin="4dp"
android:layout_width="@dimen/card_indicator_size"
android:layout_height="@dimen/card_indicator_size"
android:layout_margin="@dimen/card_indicator_offset"
app:layout_constraintBottom_toBottomOf="@id/imageView_cover"
app:layout_constraintEnd_toEndOf="@id/imageView_cover" />
@@ -36,13 +34,13 @@
android:id="@+id/textView_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginStart="12dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="12dp"
android:ellipsize="end"
android:maxLines="2"
android:textAppearance="?attr/textAppearanceTitleMedium"
app:layout_constraintBottom_toTopOf="@+id/textView_subtitle"
android:textAppearance="?attr/textAppearanceTitleLarge"
android:textSize="20sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
app:layout_constraintTop_toTopOf="parent"
@@ -52,61 +50,68 @@
android:id="@+id/textView_subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="8dp"
android:layout_marginStart="12dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="12dp"
android:ellipsize="none"
android:gravity="center_vertical"
android:maxLines="2"
android:requiresFadingEdge="horizontal"
android:textAppearance="?attr/textAppearanceBodyMedium"
app:layout_constraintBottom_toTopOf="@+id/linearLayout"
android:textAppearance="?attr/textAppearanceSubtitle1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
app:layout_constraintTop_toBottomOf="@+id/textView_title"
tools:text="@tools:sample/lorem/random" />
<LinearLayout
android:id="@+id/linearLayout"
<HorizontalScrollView
android:id="@+id/scrollView_tags"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="8dp"
android:layout_marginStart="12dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="12dp"
android:layout_marginBottom="8dp"
android:baselineAligned="false"
android:clipChildren="false"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"
android:scrollbars="none"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
app:layout_constraintTop_toBottomOf="@+id/textView_subtitle">
app:layout_constraintTop_toBottomOf="@id/textView_subtitle"
app:layout_goneMarginTop="12dp">
<TextView
android:id="@+id/textView_tags"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:ellipsize="none"
android:gravity="center_vertical"
android:requiresFadingEdge="horizontal"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceBodySmall"
tools:text="@sample/genres" />
<TextView
android:id="@+id/textView_rating"
<org.koitharu.kotatsu.base.ui.widgets.ChipsView
android:id="@+id/chips_tags"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawablePadding="4dp"
android:elegantTextHeight="false"
android:gravity="center_vertical"
android:paddingStart="6dp"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceBodySmall"
app:drawableEndCompat="@drawable/ic_star"
tools:ignore="RtlSymmetry"
tools:text="9.6" />
app:chipSpacingHorizontal="6dp"
app:chipSpacingVertical="6dp"
app:singleLine="true" />
</HorizontalScrollView>
</LinearLayout>
<RatingBar
android:id="@+id/ratingBar"
style="?android:ratingBarStyleSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:isIndicator="true"
android:paddingVertical="8dp"
app:layout_constraintEnd_toEndOf="@id/imageView_cover"
app:layout_constraintStart_toStartOf="@id/imageView_cover"
app:layout_constraintTop_toBottomOf="@id/imageView_cover" />
<ImageView
android:id="@+id/button_read"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="12dp"
android:layout_marginBottom="12dp"
android:background="@drawable/bg_circle_button"
android:contentDescription="@string/read"
android:scaleType="center"
android:src="@drawable/ic_read"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/scrollView_tags"
app:layout_constraintVertical_bias="1" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
style="@style/Widget.Material3.CardView.Outlined"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto"
style="@style/Widget.Material3.CardView.Outlined"
app:cardBackgroundColor="?scrimBackground">
<org.koitharu.kotatsu.base.ui.widgets.CoverImageView
@@ -17,17 +17,17 @@
<TextView
android:id="@+id/textView_number"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_width="@dimen/card_indicator_size"
android:layout_height="@dimen/card_indicator_size"
android:layout_gravity="bottom|end"
android:layout_margin="4dp"
android:layout_margin="@dimen/card_indicator_offset"
android:ellipsize="none"
android:gravity="center"
android:minWidth="26dp"
android:singleLine="true"
android:textAlignment="center"
android:textColor="?attr/colorOnTertiary"
android:textSize="14sp"
tools:background="@drawable/bg_badge_default"
tools:text="2" />
</com.google.android.material.card.MaterialCardView>
</com.google.android.material.card.MaterialCardView>

View File

@@ -13,18 +13,6 @@
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/icon"
android:layout_width="86dp"
android:layout_height="86dp"
android:background="@drawable/bg_circle"
android:contentDescription="@null"
android:padding="16dp"
android:scaleType="fitCenter"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_totoro" />
<TextView
android:id="@+id/textPrimary"
android:layout_width="0dp"
@@ -33,7 +21,7 @@
android:text="@string/app_update_available"
android:textAppearance="?attr/textAppearanceTitleLarge"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/icon"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
@@ -44,10 +32,11 @@
android:layout_marginTop="@dimen/margin_small"
android:text="@string/new_version_s"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/icon"
app:layout_constraintTop_toBottomOf="@id/textPrimary" />
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/textPrimary"
tools:visibility="visible" />
<TextView
android:id="@+id/textChangelog"
@@ -55,14 +44,25 @@
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_normal"
android:layout_marginTop="@dimen/margin_small"
android:background="@drawable/bg_badge_empty"
android:fontFamily="monospace"
android:padding="8dp"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/icon"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/textSecondary"
tools:text="- Fixes\n- Improvements" />
tools:text="- Fixes\n- Improvements"
tools:visibility="visible" />
<Button
android:id="@+id/button_changelog"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:layout_marginEnd="12dp"
android:text="@string/details"
app:layout_constraintEnd_toStartOf="@id/button_download"
app:layout_constraintTop_toBottomOf="@id/textChangelog" />
<Button
android:id="@+id/button_download"
@@ -74,7 +74,6 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/textChangelog" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_order"
android:orderInCategory="50"
android:title="@string/sort_order">
<menu>
<group android:id="@+id/group_order" />
</menu>
</item>
<item
android:id="@+id/action_edit"
android:orderInCategory="50"
android:title="@string/edit_category"
android:titleCondensed="@string/edit" />
</menu>

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