Compare commits

...

51 Commits
v4.1.1 ... v4.3

Author SHA1 Message Date
Koitharu
3e7a48d27a Fix NPE during PagesCache initialization 2023-01-22 09:21:24 +02:00
Koitharu
eeba959ba5 Replace fadingEdges with scrollIndicators 2023-01-22 09:09:45 +02:00
Zakhar Timoshenko
e7fa1036be Fading chips on detailed list 2023-01-21 20:55:54 +03:00
Zakhar Timoshenko
542a7e1141 Merge remote-tracking branch 'origin/devel' into devel 2023-01-21 20:48:25 +03:00
Zakhar Timoshenko
5951f4438a Fix dialog background on Android 5 2023-01-21 20:47:56 +03:00
Koitharu
1fbae6bd7b Translated using Weblate (Russian)
Currently translated at 100.0% (405 of 405 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-01-21 19:14:27 +02:00
Dan
b73924aea8 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (402 of 402 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (8 of 8 strings)

Co-authored-by: Dan <denqwerta@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/uk/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-01-21 19:14:27 +02:00
Shippo
005443f4ae Translated using Weblate (Arabic)
Currently translated at 14.9% (60 of 401 strings)

Co-authored-by: Shippo <Shipox@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2023-01-21 19:14:27 +02:00
J. Lavoie
abb55d4424 Translated using Weblate (Italian)
Currently translated at 99.2% (398 of 401 strings)

Translated using Weblate (French)

Currently translated at 100.0% (401 of 401 strings)

Translated using Weblate (Italian)

Currently translated at 80.7% (324 of 401 strings)

Translated using Weblate (German)

Currently translated at 97.5% (391 of 401 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
2023-01-21 19:14:27 +02:00
ssantos
e0538da079 Translated using Weblate (Portuguese)
Currently translated at 99.2% (398 of 401 strings)

Translated using Weblate (Portuguese)

Currently translated at 66.5% (267 of 401 strings)

Co-authored-by: ssantos <ssantos@web.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2023-01-21 19:14:27 +02:00
Eric
665bf5a034 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (405 of 405 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (402 of 402 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (401 of 401 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
2023-01-21 19:14:27 +02:00
Evgeniy Khramov
dc7e1282c6 Translated using Weblate (Russian)
Currently translated at 100.0% (401 of 401 strings)

Co-authored-by: Evgeniy Khramov <thejenjagamertjg@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-01-21 19:14:27 +02:00
Oğuz Ersen
3a877d4f4a Translated using Weblate (Turkish)
Currently translated at 100.0% (405 of 405 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (402 of 402 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (401 of 401 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-01-21 19:14:27 +02:00
gallegonovato
8a23c9a327 Translated using Weblate (Spanish)
Currently translated at 100.0% (405 of 405 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (402 of 402 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (401 of 401 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-01-21 19:14:27 +02:00
Koitharu
452c0edfc7 Fix Continue button behavior 2023-01-20 17:06:29 +02:00
Koitharu
2b9307aa17 Update dynamic colors 2023-01-20 15:48:54 +02:00
Koitharu
f91d5e1c29 Update gradle 2023-01-20 15:13:16 +02:00
Koitharu
2fbfd14252 Fix tracker cancellation errors 2023-01-20 14:28:59 +02:00
Koitharu
c09dd92cff Logger for debug logs 2023-01-20 11:32:29 +02:00
Koitharu
6b08074a70 Fix changelog formatting 2023-01-19 19:52:00 +02:00
Koitharu
9cb5971182 Option to change app language #282 2023-01-19 18:58:15 +02:00
Zakhar Timoshenko
6f37d95c24 Adjust alert dialogs to M3 guidelines 2023-01-19 07:37:00 +03:00
Zakhar Timoshenko
d290ba24b7 Use Markwon for pretty changelogs 2023-01-19 07:35:56 +03:00
Koitharu
f57d23026b Update room 2023-01-17 08:17:43 +02:00
Koitharu
1a70ccff55 Merge branch 'master' into devel 2023-01-17 07:41:09 +02:00
Koitharu
bd6a51e58d Fix crash on cold launch 2023-01-09 19:12:37 +02:00
Koitharu
a9c122b144 Option to mark chapter as current #56 2023-01-09 16:34:18 +02:00
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
142 changed files with 2722 additions and 604 deletions

2
.idea/gradle.xml generated
View File

@@ -7,7 +7,7 @@
<option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="Embedded JDK" />
<option name="gradleJvm" value="jbr-17" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />

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

@@ -7,16 +7,16 @@ plugins {
}
android {
compileSdkVersion 33
buildToolsVersion '33.0.0'
namespace 'org.koitharu.kotatsu'
compileSdk = 33
buildToolsVersion = '33.0.1'
namespace = 'org.koitharu.kotatsu'
defaultConfig {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 33
versionCode 508
versionName '4.1.1'
versionCode 511
versionName '4.3'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -56,6 +56,7 @@ android {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs += [
'-opt-in=kotlin.ExperimentalStdlibApi',
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-opt-in=kotlinx.coroutines.FlowPreview',
'-opt-in=kotlin.contracts.ExperimentalContracts',
@@ -83,12 +84,14 @@ afterEvaluate {
}
}
dependencies {
implementation('com.github.KotatsuApp:kotatsu-parsers:c4acb9725f') {
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.appcompat:appcompat:1.6.0"
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.activity:activity-ktx:1.6.1'
implementation 'androidx.fragment:fragment-ktx:1.5.5'
@@ -103,17 +106,17 @@ dependencies {
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.work:work-runtime-ktx:2.7.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.7.0'
implementation 'com.google.android.material:material:1.8.0-rc01'
//noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.5.1'
implementation 'androidx.room:room-runtime:2.4.3'
implementation 'androidx.room:room-ktx:2.4.3'
kapt 'androidx.room:room-compiler:2.4.3'
implementation 'androidx.room:room-runtime:2.5.0'
implementation 'androidx.room:room-ktx:2.5.0'
kapt 'androidx.room:room-compiler:2.5.0'
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'
@@ -127,6 +130,7 @@ dependencies {
implementation 'io.coil-kt:coil-svg:2.2.2'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:1b19231b2f'
implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2'
implementation 'ch.acra:acra-http:5.9.7'
implementation 'ch.acra:acra-dialog:5.9.7'
@@ -137,14 +141,14 @@ 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'
androidTestImplementation 'androidx.room:room-testing:2.4.3'
androidTestImplementation 'androidx.room:room-testing:2.5.0'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.14.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.44.2'

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

@@ -188,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"
@@ -250,7 +253,7 @@
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"/>
android:value="@bool/com_samsung_android_icon_container_has_icon_container" />
</application>

View File

@@ -50,6 +50,7 @@ class KotatsuApp : Application(), Configuration.Provider {
enableStrictMode()
}
AppCompatDelegate.setDefaultNightMode(settings.theme)
AppCompatDelegate.setApplicationLocales(settings.appLocales)
setupActivityLifecycleCallbacks()
processLifecycleScope.launch(Dispatchers.Default) {
setupDatabaseObservers()

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

@@ -8,10 +8,12 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout.Behavior
import androidx.core.view.ViewCompat
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
class ShrinkOnScrollBehavior : Behavior<ExtendedFloatingActionButton> {
open class ShrinkOnScrollBehavior : Behavior<ExtendedFloatingActionButton> {
@Suppress("unused") constructor() : super()
@Suppress("unused") constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
@Suppress("unused")
constructor() : super()
@Suppress("unused")
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
override fun onStartNestedScroll(
coordinatorLayout: CoordinatorLayout,
@@ -45,4 +47,4 @@ class ShrinkOnScrollBehavior : Behavior<ExtendedFloatingActionButton> {
}
}
}
}
}

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,5 @@
package org.koitharu.kotatsu.core.cache
import androidx.collection.LruCache
class DeferredLruCache<T>(maxSize: Int) : LruCache<ContentCache.Key, SafeDeferred<T>>(maxSize)

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

@@ -13,16 +13,6 @@ abstract class PreferencesDao {
@Query("SELECT * FROM preferences WHERE manga_id = :mangaId")
abstract fun observe(mangaId: Long): Flow<MangaPrefsEntity?>
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(pref: MangaPrefsEntity): Long
@Update
abstract suspend fun update(pref: MangaPrefsEntity): Int
@Transaction
open suspend fun upsert(pref: MangaPrefsEntity) {
if (update(pref) == 0) {
insert(pref)
}
}
@Upsert
abstract suspend fun upsert(pref: MangaPrefsEntity)
}

View File

@@ -14,7 +14,7 @@ abstract class TagsDao {
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit"""
LIMIT :limit""",
)
abstract suspend fun findPopularTags(limit: Int): List<TagEntity>
@@ -24,7 +24,7 @@ abstract class TagsDao {
WHERE tags.source = :source
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit"""
LIMIT :limit""",
)
abstract suspend fun findPopularTags(source: String, limit: Int): List<TagEntity>
@@ -34,7 +34,7 @@ abstract class TagsDao {
WHERE tags.source = :source AND title LIKE :query
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit"""
LIMIT :limit""",
)
abstract suspend fun findTags(source: String, query: String, limit: Int): List<TagEntity>
@@ -44,22 +44,10 @@ abstract class TagsDao {
WHERE title LIKE :query
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit"""
LIMIT :limit""",
)
abstract suspend fun findTags(query: String, limit: Int): List<TagEntity>
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(tag: TagEntity): Long
@Update(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun update(tag: TagEntity): Int
@Transaction
open suspend fun upsert(tags: Iterable<TagEntity>) {
tags.forEach { tag ->
if (update(tag) <= 0) {
insert(tag)
}
}
}
}
@Upsert
abstract suspend fun upsert(tags: Iterable<TagEntity>)
}

View File

@@ -80,6 +80,12 @@ class AppUpdateRepository @Inject constructor(
return BuildConfig.DEBUG || getCertificateSHA1Fingerprint() == CERT_SHA1
}
suspend fun getCurrentVersionChangelog(): String? {
val currentVersion = VersionId(BuildConfig.VERSION_NAME)
val available = getAvailableVersions()
return available.find { x -> x.versionId == currentVersion }?.description
}
@Suppress("DEPRECATION")
@SuppressLint("PackageManagerGetSignatures")
private fun getCertificateSHA1Fingerprint(): String? = runCatching {

View File

@@ -0,0 +1,128 @@
package org.koitharu.kotatsu.core.logs
import android.content.Context
import androidx.annotation.WorkerThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.ext.subdir
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.ConcurrentLinkedQueue
private const val DIR = "logs"
private const val FLUSH_DELAY = 2_000L
private const val MAX_SIZE_BYTES = 1024 * 1024 // 1 MB
class FileLogger(
context: Context,
private val settings: AppSettings,
name: String,
) {
val file by lazy {
val dir = context.getExternalFilesDir(DIR) ?: context.filesDir.subdir(DIR)
File(dir, "$name.log")
}
val isEnabled: Boolean
get() = settings.isLoggingEnabled
private val dateFormat = SimpleDateFormat.getDateTimeInstance(
SimpleDateFormat.SHORT,
SimpleDateFormat.SHORT,
Locale.ROOT,
)
private val buffer = ConcurrentLinkedQueue<String>()
private val mutex = Mutex()
private var flushJob: Job? = null
fun log(message: String, e: Throwable? = null) {
if (!isEnabled) {
return
}
val text = buildString {
append(dateFormat.format(Date()))
append(": ")
if (e != null) {
append("E!")
}
append(message)
if (e != null) {
append(' ')
append(e.stackTraceToString())
appendLine()
}
}
buffer.add(text)
postFlush()
}
suspend fun flush() {
if (!isEnabled) {
return
}
flushJob?.cancelAndJoin()
flushImpl()
}
private fun postFlush() {
if (flushJob?.isActive == true) {
return
}
flushJob = processLifecycleScope.launch(Dispatchers.Default) {
delay(FLUSH_DELAY)
runCatchingCancellable {
flushImpl()
}.onFailure {
it.printStackTraceDebug()
}
}
}
private suspend fun flushImpl() {
mutex.withLock {
if (buffer.isEmpty()) {
return
}
runInterruptible(Dispatchers.IO) {
if (file.length() > MAX_SIZE_BYTES) {
rotate()
}
FileOutputStream(file, true).use {
while (true) {
val message = buffer.poll() ?: break
it.write(message.toByteArray())
it.write('\n'.code)
}
it.flush()
}
}
}
}
@WorkerThread
private fun rotate() {
val length = file.length()
val bakFile = File(file.parentFile, file.name + ".bak")
file.renameTo(bakFile)
bakFile.inputStream().use { input ->
input.skip(length - MAX_SIZE_BYTES / 2)
file.outputStream().use { output ->
input.copyTo(output)
output.flush()
}
}
bakFile.delete()
}
}

View File

@@ -0,0 +1,7 @@
package org.koitharu.kotatsu.core.logs
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class TrackerLogger

View File

@@ -0,0 +1,31 @@
package org.koitharu.kotatsu.core.logs
import android.content.Context
import androidx.collection.arraySetOf
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ElementsIntoSet
import org.koitharu.kotatsu.core.prefs.AppSettings
@Module
@InstallIn(SingletonComponent::class)
object LoggersModule {
@Provides
@TrackerLogger
fun provideTrackerLogger(
@ApplicationContext context: Context,
settings: AppSettings,
) = FileLogger(context, settings, "tracker")
@Provides
@ElementsIntoSet
fun provideAllLoggers(
@TrackerLogger trackerLogger: FileLogger,
): Set<@JvmSuppressWildcards FileLogger> = arraySetOf(
trackerLogger,
)
}

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,31 @@
package org.koitharu.kotatsu.core.parser
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.currentCoroutineContext
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 +47,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 +78,16 @@ class RemoteMangaRepository(private val parser: MangaParser) : MangaRepository {
}
private fun getConfig() = parser.config as SourceSettings
}
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
if (dispatcher == null || dispatcher is MainCoroutineDispatcher) {
dispatcher = Dispatchers.Default
}
return SafeDeferred(
processLifecycleScope.async(dispatcher) {
runCatchingCancellable { block() }
},
)
}
}

View File

@@ -2,12 +2,12 @@ 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
import androidx.collection.arraySetOf
import androidx.core.content.edit
import androidx.core.os.LocaleListCompat
import androidx.preference.PreferenceManager
import com.google.android.material.color.DynamicColors
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -17,6 +17,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 +35,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)
@@ -78,6 +80,17 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getInt(KEY_GRID_SIZE, 100)
set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) }
var appLocales: LocaleListCompat
get() {
val raw = prefs.getString(KEY_APP_LOCALE, null)
return LocaleListCompat.forLanguageTags(raw)
}
set(value) {
prefs.edit {
putString(KEY_APP_LOCALE, value.toLanguageTags())
}
}
val readerPageSwitch: Set<String>
get() = prefs.getStringSet(KEY_READER_SWITCHERS, null) ?: setOf(PAGE_SWITCH_TAPS)
@@ -146,6 +159,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getString(KEY_APP_PASSWORD, null)
set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) }
val isLoggingEnabled: Boolean
get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false)
var isBiometricProtectionEnabled: Boolean
get() = prefs.getBoolean(KEY_PROTECT_APP_BIOMETRIC, true)
set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) }
@@ -156,6 +172,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 +255,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 +311,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 +372,13 @@ 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"
const val KEY_APP_LOCALE = "app_locale"
const val KEY_LOGGING_ENABLED = "logging"
const val KEY_LOGS_SHARE = "logs_share"
// 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,116 @@
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 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) {
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

@@ -1,15 +1,16 @@
package org.koitharu.kotatsu.details.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 androidx.appcompat.view.ActionMode
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.snackbar.Snackbar
import kotlin.math.roundToInt
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.ListSelectionController
@@ -24,8 +25,8 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback
import org.koitharu.kotatsu.utils.ext.parents
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import kotlin.math.roundToInt
class ChaptersFragment :
BaseFragment<FragmentChaptersBinding>(),
@@ -102,6 +103,7 @@ class ChaptersFragment :
mode.finish()
true
}
R.id.action_delete -> {
val ids = selectionController?.peekCheckedIds()
val manga = viewModel.manga.value
@@ -120,6 +122,7 @@ class ChaptersFragment :
mode.finish()
true
}
R.id.action_select_range -> {
val items = chaptersAdapter?.items ?: return false
val ids = HashSet(controller.peekCheckedIds())
@@ -139,11 +142,20 @@ class ChaptersFragment :
controller.addAll(ids)
true
}
R.id.action_select_all -> {
val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false
selectionController?.addAll(ids)
controller.addAll(ids)
true
}
R.id.action_mark_current -> {
val id = controller.peekCheckedIds().singleOrNull() ?: return false
viewModel.markChapterAsCurrent(id)
mode.finish()
true
}
else -> false
}
}
@@ -164,6 +176,7 @@ class ChaptersFragment :
x.chapter.source == MangaSource.LOCAL
}
menu.findItem(R.id.action_select_all).isVisible = items.size < allItems.size
menu.findItem(R.id.action_mark_current).isVisible = items.size == 1
mode.title = items.size.toString()
var hasGap = false
for (i in 0 until items.size - 1) {

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(
@@ -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

@@ -89,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("")
@@ -105,7 +111,7 @@ class DetailsViewModel @AssistedInject constructor(
val historyInfo: LiveData<HistoryInfo> = combine(
delegate.manga,
history,
settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled },
historyRepository.observeShouldSkip(delegate.manga),
) { m, h, im ->
HistoryInfo(m, h, im)
}.asFlowLiveData(
@@ -275,6 +281,17 @@ class DetailsViewModel @AssistedInject constructor(
}
}
fun markChapterAsCurrent(chapterId: Long) {
launchJob(Dispatchers.Default) {
val manga = checkNotNull(delegate.manga.value)
val chapters = checkNotNull(manga.chapters)
val chapterIndex = chapters.indexOfFirst { it.id == chapterId }
check(chapterIndex in chapters.indices) { "Chapter not found" }
val percent = chapterIndex / chapters.size.toFloat()
historyRepository.addOrUpdate(manga = manga, chapterId = chapterId, page = 0, scroll = 0, percent = percent)
}
}
private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
delegate.doLoad()
}

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

@@ -21,9 +21,6 @@ abstract class FavouriteCategoriesDao {
@Insert(onConflict = OnConflictStrategy.ABORT)
abstract suspend fun insert(category: FavouriteCategoryEntity): Long
@Update
abstract suspend fun update(category: FavouriteCategoryEntity): Int
suspend fun delete(id: Long) = setDeletedAt(id, System.currentTimeMillis())
@Query("UPDATE favourite_categories SET title = :title, `order` = :order, `track` = :tracker WHERE category_id = :id")
@@ -51,12 +48,8 @@ abstract class FavouriteCategoriesDao {
return (getMaxSortKey() ?: 0) + 1
}
@Transaction
open suspend fun upsert(entity: FavouriteCategoryEntity) {
if (update(entity) == 0) {
insert(entity)
}
}
@Upsert
abstract suspend fun upsert(entity: FavouriteCategoryEntity)
@Query("UPDATE favourite_categories SET deleted_at = :deletedAt WHERE category_id = :id")
protected abstract suspend fun setDeletedAt(id: Long, deletedAt: Long)

View File

@@ -99,11 +99,6 @@ abstract class FavouritesDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insert(favourite: FavouriteEntity)
/** UPDATE **/
@Update
abstract suspend fun update(favourite: FavouriteEntity): Int
/** DELETE **/
suspend fun delete(mangaId: Long) = setDeletedAt(
@@ -138,12 +133,8 @@ abstract class FavouritesDao {
/** TOOLS **/
@Transaction
open suspend fun upsert(entity: FavouriteEntity) {
if (update(entity) == 0) {
insert(entity)
}
}
@Upsert
abstract suspend fun upsert(entity: FavouriteEntity)
@Transaction
@RawQuery(observedEntities = [FavouriteEntity::class])
@@ -166,6 +157,7 @@ abstract class FavouritesDao {
SortOrder.NEWEST,
SortOrder.UPDATED,
-> "created_at DESC"
SortOrder.ALPHABETICAL -> "title ASC"
else -> throw IllegalArgumentException("Sort order $sortOrder is not supported")
}

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

@@ -21,7 +21,7 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
private val cacheDir = checkNotNull(findSuitableDir(context)) {
val dirs = (context.externalCacheDirs + context.cacheDir).joinToString(";") {
it.absolutePath
it?.absolutePath.toString()
}
"Cannot find any suitable directory for PagesCache: [$dirs]"
}
@@ -60,6 +60,6 @@ private fun createDiskLruCacheSafe(dir: File, size: Long): DiskLruCache {
private fun findSuitableDir(context: Context): File? {
val dirs = context.externalCacheDirs + context.cacheDir
return dirs.firstNotNullOfOrNull {
it.subdir(CacheDir.PAGES.dir).takeIfWriteable()
it?.subdir(CacheDir.PAGES.dir)?.takeIfWriteable()
}
}

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

@@ -0,0 +1,39 @@
package org.koitharu.kotatsu.main.ui
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.ViewCompat
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
import org.koitharu.kotatsu.base.ui.util.ShrinkOnScrollBehavior
import org.koitharu.kotatsu.base.ui.widgets.SlidingBottomNavigationView
class MainActionButtonBehavior : ShrinkOnScrollBehavior {
constructor() : super()
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
override fun layoutDependsOn(
parent: CoordinatorLayout,
child: ExtendedFloatingActionButton,
dependency: View
): Boolean {
return dependency is SlidingBottomNavigationView || super.layoutDependsOn(parent, child, dependency)
}
override fun onDependentViewChanged(
parent: CoordinatorLayout,
child: ExtendedFloatingActionButton,
dependency: View
): Boolean {
val bottom = child.bottom
val bottomLine = parent.height
return if (bottom > bottomLine) {
ViewCompat.offsetTopAndBottom(child, bottomLine - bottom)
true
} else {
false
}
}
}

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

@@ -38,7 +38,10 @@ class MainNavigationDelegate(
}
override fun onNavigationItemReselected(item: MenuItem) {
val fragment = fragmentManager.findFragmentByTag(TAG_PRIMARY) as? RecyclerViewOwner ?: return
val fragment = fragmentManager.findFragmentByTag(TAG_PRIMARY)
if (fragment == null || fragment !is RecyclerViewOwner || fragment.view == null) {
return
}
val recyclerView = fragment.recyclerView
recyclerView.smoothScrollToPosition(0)
}
@@ -46,6 +49,10 @@ class MainNavigationDelegate(
fun onCreate(savedInstanceState: Bundle?) {
primaryFragment?.let {
onFragmentChanged(it, fromUser = false)
val itemId = getItemId(it)
if (navBar.selectedItemId != itemId) {
navBar.selectedItemId = itemId
}
} ?: onNavigationItemSelected(navBar.selectedItemId)
}
@@ -92,6 +99,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

@@ -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

@@ -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

@@ -1,27 +1,38 @@
package org.koitharu.kotatsu.settings
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.View
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.app.LocaleManagerCompat
import androidx.core.view.postDelayed
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.TwoStatePreference
import com.google.android.material.color.DynamicColors
import dagger.hilt.android.AndroidEntryPoint
import java.util.*
import javax.inject.Inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.base.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.parsers.util.names
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
import org.koitharu.kotatsu.settings.utils.ActivityListPreference
import org.koitharu.kotatsu.settings.utils.SliderPreference
import org.koitharu.kotatsu.utils.ext.getLocalesConfig
import org.koitharu.kotatsu.utils.ext.map
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
import org.koitharu.kotatsu.utils.ext.toList
import java.util.Date
import java.util.Locale
import javax.inject.Inject
@AndroidEntryPoint
class AppearanceSettingsFragment :
@@ -52,7 +63,7 @@ class AppearanceSettingsFragment :
entries = entryValues.map { value ->
val formattedDate = settings.getDateFormat(value.toString()).format(now)
if (value == "") {
"${context.getString(R.string.system_default)} ($formattedDate)"
getString(R.string.default_s, formattedDate)
} else {
formattedDate
}
@@ -62,6 +73,20 @@ class AppearanceSettingsFragment :
}
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
?.isChecked = !settings.appPassword.isNullOrEmpty()
findPreference<ActivityListPreference>(AppSettings.KEY_APP_LOCALE)?.run {
initLocalePicker(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
activityIntent = Intent(
Settings.ACTION_APP_LOCALE_SETTINGS,
Uri.fromParts("package", context.packageName, null),
)
}
summaryProvider = Preference.SummaryProvider<ActivityListPreference> {
val locale = AppCompatDelegate.getApplicationLocales().get(0)
locale?.getDisplayName(locale)?.toTitleCase(locale) ?: getString(R.string.automatic)
}
setDefaultValueCompat("")
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -79,16 +104,23 @@ class AppearanceSettingsFragment :
AppSettings.KEY_THEME -> {
AppCompatDelegate.setDefaultNightMode(settings.theme)
}
AppSettings.KEY_DYNAMIC_THEME -> {
postRestart()
}
AppSettings.KEY_THEME_AMOLED -> {
postRestart()
}
AppSettings.KEY_APP_PASSWORD -> {
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
?.isChecked = !settings.appPassword.isNullOrEmpty()
}
AppSettings.KEY_APP_LOCALE -> {
AppCompatDelegate.setApplicationLocales(settings.appLocales)
}
}
}
@@ -104,6 +136,7 @@ class AppearanceSettingsFragment :
}
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
@@ -113,4 +146,45 @@ class AppearanceSettingsFragment :
activityRecreationHandle.recreateAll()
}
}
private fun initLocalePicker(preference: ListPreference) {
val locales = resources.getLocalesConfig()
.toList()
.sortedWith(LocaleComparator(preference.context))
preference.entries = Array(locales.size + 1) { i ->
if (i == 0) {
getString(R.string.automatic)
} else {
val lc = locales[i - 1]
lc.getDisplayName(lc).toTitleCase(lc)
}
}
preference.entryValues = Array(locales.size + 1) { i ->
if (i == 0) {
""
} else {
locales[i - 1].toLanguageTag()
}
}
}
private class LocaleComparator(context: Context) : Comparator<Locale> {
private val deviceLocales = LocaleManagerCompat.getSystemLocales(context)
.map { it.language }
override fun compare(a: Locale, b: Locale): Int {
return if (a === b) {
0
} else {
val indexA = deviceLocales.indexOf(a.language)
val indexB = deviceLocales.indexOf(b.language)
if (indexA == -1 && indexB == -1) {
compareValues(a.language, b.language)
} else {
-2 - (indexA - indexB)
}
}
}
}
}

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

@@ -78,6 +78,7 @@ class SettingsActivity :
startActivity(intent)
true
}
else -> super.onOptionsItemSelected(item)
}
@@ -132,6 +133,7 @@ class SettingsActivity :
ACTION_SOURCE -> SourceSettingsFragment.newInstance(
intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource ?: MangaSource.LOCAL,
)
ACTION_MANAGE_SOURCES -> SourcesSettingsFragment()
else -> SettingsHeadersFragment()
}

View File

@@ -7,16 +7,24 @@ import androidx.core.net.toUri
import androidx.fragment.app.viewModels
import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.core.logs.FileLogger
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.utils.ShareHelper
import javax.inject.Inject
@AndroidEntryPoint
class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
private val viewModel by viewModels<AboutSettingsViewModel>()
@Inject
lateinit var loggers: Set<@JvmSuppressWildcards FileLogger>
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_about)
findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run {
@@ -39,10 +47,17 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
viewModel.checkForUpdates()
true
}
AppSettings.KEY_APP_TRANSLATION -> {
openLink(getString(R.string.url_weblate), preference.title)
true
}
AppSettings.KEY_LOGS_SHARE -> {
ShareHelper(preference.context).shareLogs(loggers)
true
}
else -> super.onPreferenceTreeClick(preference)
}
}

View File

@@ -3,22 +3,24 @@ package org.koitharu.kotatsu.settings.about
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import com.google.android.material.R as materialR
import androidx.core.text.buildSpannedString
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.noties.markwon.Markwon
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.utils.FileSize
import com.google.android.material.R as materialR
class AppUpdateDialog(private val context: Context) {
fun show(version: AppVersion) {
val message = buildString {
val message = buildSpannedString {
append(context.getString(R.string.new_version_s, version.name))
appendLine()
append(context.getString(R.string.size_s, FileSize.BYTES.format(context, version.apkSize)))
appendLine()
appendLine()
append(version.description)
append(Markwon.create(context).toMarkdown(version.description))
}
MaterialAlertDialogBuilder(
context,

View File

@@ -4,11 +4,11 @@ import android.app.backup.BackupManager
import android.content.Context
import androidx.room.InvalidationTracker
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BackupObserver @Inject constructor(
@@ -17,7 +17,7 @@ class BackupObserver @Inject constructor(
private val backupManager = BackupManager(context)
override fun onInvalidated(tables: MutableSet<String>) {
override fun onInvalidated(tables: Set<String>) {
backupManager.dataChanged()
}
}

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 :
@@ -47,7 +47,7 @@ class ToolsFragment :
super.onViewCreated(view, savedInstanceState)
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,7 +69,8 @@ class ToolsFragment :
intent.data = url.toUri()
startActivity(Intent.createChooser(intent, getString(R.string.open_in_browser)))
}
R.id.card_update -> {
R.id.button_changelog -> {
val version = viewModel.appUpdate.value ?: return
AppUpdateDialog(v.context).show(version)
}
@@ -92,7 +93,6 @@ class ToolsFragment :
return
}
binding.cardUpdate.textSecondary.text = getString(R.string.new_version_s, version.name)
binding.cardUpdate.textChangelog.text = version.description
binding.cardUpdate.root.isVisible = true
}

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.settings.tracker
import androidx.lifecycle.MutableLiveData
import androidx.room.InvalidationTracker
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import okio.Closeable
import org.koitharu.kotatsu.base.ui.BaseViewModel
@@ -11,6 +10,7 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES
import org.koitharu.kotatsu.core.db.removeObserverAsync
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import javax.inject.Inject
@HiltViewModel
class TrackerSettingsViewModel @Inject constructor(
@@ -39,7 +39,7 @@ class TrackerSettingsViewModel @Inject constructor(
InvalidationTracker.Observer(arrayOf(TABLE_FAVOURITE_CATEGORIES)),
Closeable {
override fun onInvalidated(tables: MutableSet<String>) {
override fun onInvalidated(tables: Set<String>) {
vm?.updateCategoriesCount()
}

View File

@@ -0,0 +1,38 @@
package org.koitharu.kotatsu.settings.utils
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.util.AttributeSet
import androidx.preference.ListPreference
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
class ActivityListPreference : ListPreference {
var activityIntent: Intent? = null
constructor(
context: Context,
attrs: AttributeSet?,
defStyleAttr: Int,
defStyleRes: Int
) : super(context, attrs, defStyleAttr, defStyleRes)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context) : super(context)
override fun onClick() {
val intent = activityIntent
if (intent == null) {
super.onClick()
return
}
try {
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
e.printStackTraceDebug()
super.onClick()
}
}
}

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

@@ -44,7 +44,7 @@ class SyncController @Inject constructor(
private val defaultGcPeriod: Long // gc period if sync disabled
get() = TimeUnit.HOURS.toMillis(2)
override fun onInvalidated(tables: MutableSet<String>) {
override fun onInvalidated(tables: Set<String>) {
requestSync(
favourites = TABLE_FAVOURITES in tables || TABLE_FAVOURITE_CATEGORIES in tables,
history = TABLE_HISTORY in tables,

View File

@@ -11,11 +11,10 @@ import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.sqlite.db.SupportSQLiteQueryBuilder
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import java.util.concurrent.Callable
import org.koitharu.kotatsu.core.db.*
import java.util.concurrent.Callable
abstract class SyncProvider : ContentProvider() {
@@ -44,15 +43,14 @@ abstract class SyncProvider : ContentProvider() {
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor? = if (getTableName(uri) != null) {
val sqlQuery = SupportSQLiteQueryBuilder.builder(uri.lastPathSegment)
): Cursor? {
val tableName = getTableName(uri) ?: return null
val sqlQuery = SupportSQLiteQueryBuilder.builder(tableName)
.columns(projection)
.selection(selection, selectionArgs)
.orderBy(sortOrder)
.create()
database.openHelper.readableDatabase.query(sqlQuery)
} else {
null
return database.openHelper.readableDatabase.query(sqlQuery)
}
override fun getType(uri: Uri): String? {

View File

@@ -1,12 +1,10 @@
package org.koitharu.kotatsu.tracker.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.MapInfo
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
@@ -46,22 +44,12 @@ abstract class TracksDao {
@Query("UPDATE tracks SET chapters_new = 0")
abstract suspend fun clearCounters()
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(entity: TrackEntity): Long
@Update
abstract suspend fun update(entity: TrackEntity): Int
@Query("DELETE FROM tracks WHERE manga_id = :mangaId")
abstract suspend fun delete(mangaId: Long)
@Query("DELETE FROM tracks WHERE manga_id NOT IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites)")
abstract suspend fun gc()
@Transaction
open suspend fun upsert(entity: TrackEntity) {
if (update(entity) == 0) {
insert(entity)
}
}
@Upsert
abstract suspend fun upsert(entity: TrackEntity)
}

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

@@ -12,14 +12,33 @@ import androidx.core.content.ContextCompat
import androidx.hilt.work.HiltWorker
import androidx.lifecycle.LiveData
import androidx.lifecycle.map
import androidx.work.*
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import androidx.work.WorkerParameters
import coil.ImageLoader
import coil.request.ImageRequest
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.logs.FileLogger
import org.koitharu.kotatsu.core.logs.TrackerLogger
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.parsers.model.Manga
@@ -31,6 +50,7 @@ import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.ext.toBitmapOrNull
import org.koitharu.kotatsu.utils.ext.trySetForeground
import java.util.concurrent.TimeUnit
@HiltWorker
class TrackWorker @AssistedInject constructor(
@@ -39,6 +59,7 @@ class TrackWorker @AssistedInject constructor(
private val coil: ImageLoader,
private val settings: AppSettings,
private val tracker: Tracker,
@TrackerLogger private val logger: FileLogger,
) : CoroutineWorker(context, workerParams) {
private val notificationManager by lazy {
@@ -46,6 +67,20 @@ class TrackWorker @AssistedInject constructor(
}
override suspend fun doWork(): Result {
logger.log("doWork()")
try {
return doWorkImpl()
} catch (e: Throwable) {
logger.log("fatal", e)
throw e
} finally {
withContext(NonCancellable) {
logger.flush()
}
}
}
private suspend fun doWorkImpl(): Result {
if (!settings.isTrackerEnabled) {
return Result.success(workDataOf(0, 0))
}
@@ -53,12 +88,12 @@ class TrackWorker @AssistedInject constructor(
trySetForeground()
}
val tracks = tracker.getAllTracks()
logger.log("Total ${tracks.size} tracks")
if (tracks.isEmpty()) {
return Result.success(workDataOf(0, 0))
}
val updates = checkUpdatesAsync(tracks)
val results = updates.awaitAll()
val results = checkUpdatesAsync(tracks)
tracker.gc()
var success = 0
@@ -70,6 +105,7 @@ class TrackWorker @AssistedInject constructor(
success++
}
}
logger.log("Result: success: $success, failed: $failed")
val resultData = workDataOf(success, failed)
return if (success == 0 && failed != 0) {
Result.failure(resultData)
@@ -78,13 +114,15 @@ class TrackWorker @AssistedInject constructor(
}
}
private suspend fun checkUpdatesAsync(tracks: List<TrackingItem>): List<Deferred<MangaUpdates?>> {
private suspend fun checkUpdatesAsync(tracks: List<TrackingItem>): List<MangaUpdates?> {
val dispatcher = Dispatchers.Default.limitedParallelism(MAX_PARALLELISM)
val deferredList = coroutineScope {
return supervisorScope {
tracks.map { (track, channelId) ->
async(dispatcher) {
runCatchingCancellable {
tracker.fetchUpdates(track, commit = true)
}.onFailure {
logger.log("checkUpdatesAsync", it)
}.onSuccess { updates ->
if (updates.isValid && updates.isNotEmpty()) {
showNotification(
@@ -95,9 +133,8 @@ class TrackWorker @AssistedInject constructor(
}
}.getOrNull()
}
}
}.awaitAll()
}
return deferredList
}
private suspend fun showNotification(manga: Manga, channelId: String?, newChapters: List<MangaChapter>) {

View File

@@ -4,10 +4,11 @@ import android.content.Context
import android.net.Uri
import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider
import java.io.File
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.logs.FileLogger
import org.koitharu.kotatsu.parsers.model.Manga
import java.io.File
private const val TYPE_TEXT = "text/plain"
private const val TYPE_IMAGE = "image/*"
@@ -79,4 +80,15 @@ class ShareHelper(private val context: Context) {
.setChooserTitle(R.string.share)
.startChooser()
}
}
fun shareLogs(loggers: Collection<FileLogger>) {
val intentBuilder = ShareCompat.IntentBuilder(context)
.setType(TYPE_TEXT)
for (logger in loggers) {
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", logger.file)
intentBuilder.addStream(uri)
}
intentBuilder.setChooserTitle(R.string.share_logs)
intentBuilder.startChooser()
}
}

View File

@@ -8,6 +8,7 @@ import android.content.OperationApplicationException
import android.content.SharedPreferences
import android.content.SyncResult
import android.content.pm.ResolveInfo
import android.content.res.Resources
import android.database.SQLException
import android.graphics.Color
import android.net.ConnectivityManager
@@ -20,6 +21,7 @@ import android.view.Window
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.IntegerRes
import androidx.core.app.ActivityOptionsCompat
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import androidx.work.CoroutineWorker
@@ -34,8 +36,12 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import okio.IOException
import org.json.JSONException
import org.jsoup.internal.StringUtil.StringJoiner
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.InternalResourceHelper
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import kotlin.math.roundToLong
val Context.activityManager: ActivityManager?
@@ -146,3 +152,23 @@ fun scaleUpActivityOptionsOf(view: View): ActivityOptions = ActivityOptions.make
view.width,
view.height,
)
fun Resources.getLocalesConfig(): LocaleListCompat {
val tagsList = StringJoiner(",")
try {
val xpp: XmlPullParser = getXml(R.xml.locales)
while (xpp.eventType != XmlPullParser.END_DOCUMENT) {
if (xpp.eventType == XmlPullParser.START_TAG) {
if (xpp.name == "locale") {
tagsList.add(xpp.getAttributeValue(0))
}
}
xpp.next()
}
} catch (e: XmlPullParserException) {
e.printStackTraceDebug()
} catch (e: IOException) {
e.printStackTraceDebug()
}
return LocaleListCompat.forLanguageTags(tagsList.complete())
}

View File

@@ -6,19 +6,19 @@ import android.content.Context
import android.content.Intent
import androidx.room.InvalidationTracker
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.widget.recent.RecentWidgetProvider
import org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class WidgetUpdater @Inject constructor(
@ApplicationContext private val context: Context,
) : InvalidationTracker.Observer(TABLE_HISTORY, TABLE_FAVOURITES) {
override fun onInvalidated(tables: MutableSet<String>) {
override fun onInvalidated(tables: Set<String>) {
if (TABLE_HISTORY in tables) {
updateWidgets(RecentWidgetProvider::class.java)
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape>
<solid android:color="?attr/colorSurface" />
<corners android:radius="@dimen/m3_alert_dialog_corner_size" />
</shape>
</item>
<item>
<shape>
<solid android:color="@color/m3_popupmenu_overlay_color" />
<corners android:radius="@dimen/m3_alert_dialog_corner_size" />
</shape>
</item>
</layer-list>

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

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000"
android:pathData="M23.5,17L18.5,22L15,18.5L16.5,17L18.5,19L22,15.5L23.5,17M12,9A3,3 0 0,1 15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9M12,4.5C17,4.5 21.27,7.61 23,12C22.75,12.65 22.44,13.26 22.08,13.85C21.5,13.5 20.86,13.25 20.18,13.12L20.82,12C19.17,8.64 15.76,6.5 12,6.5C8.24,6.5 4.83,8.64 3.18,12C4.83,15.36 8.24,17.5 12,17.5L13.21,17.43C13.07,17.93 13,18.46 13,19V19.46L12,19.5C7,19.5 2.73,16.39 1,12C2.73,7.61 7,4.5 12,4.5Z" />
</vector>

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