Compare commits

..

7 Commits

Author SHA1 Message Date
fd01367601 Move Kitsu scrobbler to kotlin dir 2023-06-05 17:33:12 +03:00
cb64740349 Merge branch 'devel' into feature/kitsu
# Conflicts:
#	app/src/main/res/values/strings.xml
2023-06-05 17:21:56 +03:00
6fdcaf0d02 Merge branch 'devel' into feature/kitsu
# Conflicts:
#	app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt
#	app/src/main/res/values/strings.xml
2023-05-29 17:29:05 +03:00
56de725cf1 Open Kitsu auth activity 2023-05-06 18:15:24 +03:00
7a2ad47405 Merge branch 'devel' into feature/kitsu 2023-05-06 18:07:02 +03:00
41551451b0 Part 1 2023-05-06 16:15:17 +03:00
d5c24cd5c8 Initial adding of Kitsu scrobbler 2023-05-03 13:54:19 +03:00
341 changed files with 6612 additions and 10334 deletions

View File

@@ -61,6 +61,4 @@ body:
label: Acknowledgements
options:
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
required: true
- label: If this is an issue with a parser, I should be opening an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose).
required: true
required: true

1
.idea/.gitignore generated vendored
View File

@@ -1,4 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
/migrations.xml

3
.idea/gradle.xml generated
View File

@@ -4,6 +4,8 @@
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="jbr-17" />
<option name="modules">
@@ -12,7 +14,6 @@
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>

View File

@@ -1,3 +0,0 @@
[weblate]
url = https://hosted.weblate.org/api/
translation = kotatsu/strings

View File

@@ -19,7 +19,7 @@ Kotatsu is a free and open source manga reader for Android.
* Tablet-optimized Material You UI
* Standard and Webtoon-optimized reader
* Notifications about new chapters with updates feed
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
* Password/fingerprint protect access to the app
* History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices

View File

@@ -14,11 +14,9 @@ android {
defaultConfig {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
//TODO: update as soon as sources becomes available
//noinspection OldTargetApi
targetSdkVersion 33
versionCode 564
versionName '5.3.7'
versionCode 547
versionName '5.1.3'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -81,17 +79,17 @@ afterEvaluate {
}
dependencies {
//noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:69e0a531df') {
implementation('com.github.KotatsuApp:kotatsu-parsers:44e28b40d3') {
exclude group: 'org.json', module: 'json'
}
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.22'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.2'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.21'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.activity:activity-ktx:1.7.2'
implementation 'androidx.fragment:fragment-ktx:1.6.0'
implementation 'androidx.fragment:fragment-ktx:1.5.7'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-service:2.6.1'
@@ -106,57 +104,55 @@ dependencies {
//noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1'
// TODO https://issuetracker.google.com/issues/254846063
implementation 'androidx.work:work-runtime-ktx:2.8.1'
//noinspection GradleDependency
implementation('com.google.guava:guava:32.0.1-android') {
implementation('com.google.guava:guava:32.0.0-android') {
exclude group: 'com.google.guava', module: 'failureaccess'
exclude group: 'org.checkerframework', module: 'checker-qual'
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
}
implementation 'androidx.room:room-runtime:2.5.2'
implementation 'androidx.room:room-ktx:2.5.2'
//noinspection KaptUsageInsteadOfKsp
kapt 'androidx.room:room-compiler:2.5.2'
implementation 'androidx.room:room-runtime:2.5.1'
implementation 'androidx.room:room-ktx:2.5.1'
kapt 'androidx.room:room-compiler:2.5.1'
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0'
implementation 'com.squareup.okio:okio:3.4.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'
implementation 'com.google.dagger:hilt-android:2.47'
kapt 'com.google.dagger:hilt-compiler:2.47'
implementation 'com.google.dagger:hilt-android:2.46.1'
kapt 'com.google.dagger:hilt-compiler:2.46.1'
implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0'
implementation 'io.coil-kt:coil-base:2.4.0'
implementation 'io.coil-kt:coil-svg:2.4.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:9b1d20be67'
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.10.1'
implementation 'ch.acra:acra-dialog:5.10.1'
implementation 'ch.acra:acra-http:5.9.7'
implementation 'ch.acra:acra-dialog:5.9.7'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20230618'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.2'
testImplementation 'org.json:json:20230227'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.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.5'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.2'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
androidTestImplementation 'androidx.room:room-testing:2.5.2'
androidTestImplementation 'androidx.room:room-testing:2.5.1'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.47'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.47'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.46.1'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.46.1'
}

View File

@@ -23,7 +23,7 @@ import javax.inject.Inject
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class AppShortcutManagerTest {
class ShortcutsUpdaterTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@@ -32,7 +32,7 @@ class AppShortcutManagerTest {
lateinit var historyRepository: HistoryRepository
@Inject
lateinit var appShortcutManager: AppShortcutManager
lateinit var shortcutsUpdater: ShortcutsUpdater
@Inject
lateinit var database: MangaDatabase
@@ -72,6 +72,6 @@ class AppShortcutManagerTest {
private suspend fun awaitUpdate() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.awaitForIdle()
appShortcutManager.await()
shortcutsUpdater.await()
}
}

View File

@@ -17,7 +17,7 @@ import java.util.EnumSet
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("")
get() = ConfigKey.Domain()
override val sortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java)

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.core.util
package org.koitharu.kotatsu.util
import android.util.Log
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver

View File

@@ -1,3 +1,3 @@
package org.koitharu.kotatsu.core.util.ext
package org.koitharu.kotatsu.util.ext
fun Throwable.printStackTraceDebug() = printStackTrace()

View File

@@ -18,22 +18,6 @@
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
<intent>
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
</intent>
</queries>
<application
android:name="org.koitharu.kotatsu.KotatsuApp"
@@ -48,7 +32,6 @@
android:largeHeap="true"
android:localeConfig="@xml/locales"
android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Kotatsu"
@@ -115,17 +98,10 @@
<data android:host="sync-settings" />
</intent-filter>
</activity>
<activity
android:name="org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity"
android:label="@string/local_manga_directories" />
<activity
android:name="org.koitharu.kotatsu.browser.BrowserActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize" />
<activity
android:name="org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize" />
<activity
android:name="org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
@@ -183,9 +159,21 @@
<data android:host="shikimori-auth" />
<data android:host="anilist-auth" />
<data android:host="mal-auth" />
<data android:host="kitsu-auth" />
</intent-filter>
</activity>
<activity
android:name=".scrobbling.kitsu.ui.KitsuAuthActivity"
android:exported="false"
android:label="@string/kitsu"
tools:ignore="AppLinkUrlError">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="kotatsu+kitsu" />
</intent-filter>
</activity>
<service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" />
<service
@@ -208,7 +196,8 @@
<service
android:name="org.koitharu.kotatsu.sync.ui.favourites.FavouritesSyncService"
android:exported="false"
android:label="@string/favourites">
android:label="@string/favourites"
android:process=":sync">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>
@@ -219,7 +208,8 @@
<service
android:name="org.koitharu.kotatsu.sync.ui.history.HistorySyncService"
android:exported="false"
android:label="@string/history">
android:label="@string/history"
android:process=":sync">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>

View File

@@ -12,7 +12,6 @@ import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.acra.ACRA
import org.acra.ReportField
import org.acra.config.dialog
import org.acra.config.httpSender
@@ -20,7 +19,6 @@ import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.acra.sender.HttpSender
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.os.AppValidator
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
@@ -28,7 +26,6 @@ import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
import javax.inject.Inject
@HiltAndroidApp
@@ -49,15 +46,8 @@ class KotatsuApp : Application(), Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory
@Inject
lateinit var appValidator: AppValidator
@Inject
lateinit var workScheduleManager: WorkScheduleManager
override fun onCreate() {
super.onCreate()
ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.toString())
if (BuildConfig.DEBUG) {
enableStrictMode()
}
@@ -67,7 +57,6 @@ class KotatsuApp : Application(), Configuration.Provider {
processLifecycleScope.launch(Dispatchers.Default) {
setupDatabaseObservers()
}
workScheduleManager.init()
WorkServiceStopHelper(applicationContext).setup()
}
@@ -78,10 +67,6 @@ class KotatsuApp : Application(), Configuration.Provider {
reportFormat = StringFormat.JSON
excludeMatchingSharedPreferencesKeys = listOf(
"sources_\\w+",
AppSettings.KEY_APP_PASSWORD,
AppSettings.KEY_PROXY_LOGIN,
AppSettings.KEY_PROXY_ADDRESS,
AppSettings.KEY_PROXY_PASSWORD,
)
httpSender {
uri = getString(R.string.url_error_report)
@@ -98,10 +83,8 @@ class KotatsuApp : Application(), Configuration.Provider {
ReportField.PHONE_MODEL,
ReportField.STACK_TRACE,
ReportField.CRASH_CONFIGURATION,
ReportField.CUSTOM_DATA,
ReportField.SHARED_PREFERENCES,
)
dialog {
text = getString(R.string.crash_text)
title = getString(R.string.error_occurred)

View File

@@ -1,8 +1,6 @@
package org.koitharu.kotatsu.bookmarks.domain
import org.koitharu.kotatsu.local.data.ImageFileFilter
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import java.util.Date
class Bookmark(
@@ -16,21 +14,6 @@ class Bookmark(
val percent: Float,
) {
val directImageUrl: String?
get() = if (isImageUrlDirect()) imageUrl else null
fun toMangaPage() = MangaPage(
id = pageId,
url = imageUrl,
preview = null,
source = manga.source,
)
private fun isImageUrlDirect(): Boolean {
val extension = imageUrl.substringAfterLast('.')
return extension.isNotEmpty() && ImageFileFilter().isExtensionValid(extension)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false

View File

@@ -15,8 +15,8 @@ import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.core.util.ext.mapItems
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import javax.inject.Inject
@Reusable

View File

@@ -95,11 +95,8 @@ class BookmarksFragment :
override fun onItemClick(item: Bookmark, view: View) {
if (selectionController?.onItemClick(item.manga, item.pageId) != true) {
val intent = ReaderActivity.IntentBuilder(view.context)
.bookmark(item)
.incognito(true)
.build()
startActivity(intent, scaleUpActivityOptionsOf(view))
val intent = ReaderActivity.newIntent(view.context, item)
startActivity(intent, scaleUpActivityOptionsOf(view).toBundle())
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
}
}

View File

@@ -5,19 +5,15 @@ import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.drawable.TextDrawable
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.decodeRegion
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getThemeResId
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
import org.koitharu.kotatsu.parsers.util.format
import com.google.android.material.R as materialR
fun bookmarkListAD(
coil: ImageLoader,
@@ -32,8 +28,7 @@ fun bookmarkListAD(
binding.root.setOnLongClickListener(listener)
bind {
val data: Any = item.directImageUrl ?: item.toMangaPage()
binding.imageViewThumb.newImageRequest(lifecycleOwner, data)?.run {
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageUrl)?.run {
size(CoverSizeResolver(binding.imageViewThumb))
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)

View File

@@ -8,7 +8,6 @@ import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.webkit.CookieManager
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
@@ -17,7 +16,6 @@ import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.network.UserAgents
import com.google.android.material.R as materialR
@SuppressLint("SetJavaScriptEnabled")
@@ -36,9 +34,8 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
}
with(viewBinding.webView.settings) {
javaScriptEnabled = true
userAgentString = UserAgents.CHROME_MOBILE
userAgentString = CommonHeadersInterceptor.userAgentChrome
}
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
viewBinding.webView.webViewClient = BrowserClient(this)
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
@@ -106,7 +103,6 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
override fun onDestroy() {
super.onDestroy()
viewBinding.webView.stopLoading()
viewBinding.webView.destroy()
}

View File

@@ -1,175 +0,0 @@
package org.koitharu.kotatsu.browser.cloudflare
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import android.webkit.CookieManager
import android.webkit.WebSettings
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.graphics.Insets
import androidx.core.net.toUri
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import dagger.hilt.android.AndroidEntryPoint
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.TaggedActivityResult
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.network.UserAgents
import javax.inject.Inject
import com.google.android.material.R as materialR
@AndroidEntryPoint
class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCallback {
private var pendingResult = RESULT_CANCELED
@Inject
lateinit var cookieJar: MutableCookieJar
private var onBackPressedCallback: WebViewBackPressedCallback? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) {
return
}
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
}
val url = intent?.dataString.orEmpty()
with(viewBinding.webView.settings) {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
userAgentString = intent?.getStringExtra(ARG_UA) ?: UserAgents.CHROME_MOBILE
}
viewBinding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
onBackPressedDispatcher.addCallback(it)
}
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
if (savedInstanceState != null) {
return
}
if (url.isEmpty()) {
finishAfterTransition()
} else {
onTitleChanged(getString(R.string.loading_), url)
viewBinding.webView.loadUrl(url)
}
}
override fun onDestroy() {
viewBinding.webView.run {
stopLoading()
destroy()
}
super.onDestroy()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
viewBinding.webView.saveState(outState)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
viewBinding.webView.restoreState(savedInstanceState)
}
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.appbar.updatePadding(
top = insets.top,
)
viewBinding.root.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom,
)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
android.R.id.home -> {
viewBinding.webView.stopLoading()
finishAfterTransition()
true
}
else -> super.onOptionsItemSelected(item)
}
override fun onResume() {
super.onResume()
viewBinding.webView.onResume()
}
override fun onPause() {
viewBinding.webView.onPause()
super.onPause()
}
override fun finish() {
setResult(pendingResult)
super.finish()
}
override fun onPageLoaded() {
viewBinding.progressBar.isInvisible = true
}
override fun onCheckPassed() {
pendingResult = RESULT_OK
finishAfterTransition()
}
override fun onLoadingStateChanged(isLoading: Boolean) {
viewBinding.progressBar.isVisible = isLoading
}
override fun onHistoryChanged() {
onBackPressedCallback?.onHistoryChanged()
}
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
setTitle(title)
supportActionBar?.subtitle = subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
}
class Contract : ActivityResultContract<Pair<String, Headers?>, TaggedActivityResult>() {
override fun createIntent(context: Context, input: Pair<String, Headers?>): Intent {
return newIntent(context, input.first, input.second)
}
override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult {
return TaggedActivityResult(TAG, resultCode)
}
}
companion object {
const val TAG = "CloudFlareActivity"
private const val ARG_UA = "ua"
fun newIntent(
context: Context,
url: String,
headers: Headers?,
) = Intent(context, CloudFlareActivity::class.java).apply {
data = url.toUri()
headers?.get(CommonHeaders.USER_AGENT)?.let {
putExtra(ARG_UA, it)
}
}
}
}

View File

@@ -0,0 +1,123 @@
package org.koitharu.kotatsu.browser.cloudflare
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.WebSettings
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isInvisible
import androidx.fragment.app.setFragmentResult
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import okhttp3.Headers
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.FragmentCloudflareBinding
import javax.inject.Inject
@AndroidEntryPoint
class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), CloudFlareCallback {
private lateinit var url: String
private val pendingResult = Bundle(1)
@Inject
lateinit var cookieJar: MutableCookieJar
private var onBackPressedCallback: WebViewBackPressedCallback? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
url = requireArguments().getString(ARG_URL).orEmpty()
}
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
) = FragmentCloudflareBinding.inflate(inflater, container, false)
override fun onViewBindingCreated(binding: FragmentCloudflareBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
with(binding.webView.settings) {
javaScriptEnabled = true
cacheMode = WebSettings.LOAD_DEFAULT
domStorageEnabled = true
databaseEnabled = true
userAgentString = arguments?.getString(ARG_UA) ?: CommonHeadersInterceptor.userAgentChrome
}
binding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
CookieManager.getInstance().setAcceptThirdPartyCookies(binding.webView, true)
if (url.isEmpty()) {
dismissAllowingStateLoss()
} else {
binding.webView.loadUrl(url)
}
}
override fun onDestroyView() {
requireViewBinding().webView.stopLoading()
requireViewBinding().webView.destroy()
onBackPressedCallback = null
super.onDestroyView()
}
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder).setNegativeButton(android.R.string.cancel, null)
}
override fun onDialogCreated(dialog: AlertDialog) {
super.onDialogCreated(dialog)
onBackPressedCallback = WebViewBackPressedCallback(requireViewBinding().webView).also {
dialog.onBackPressedDispatcher.addCallback(it)
}
}
override fun onResume() {
super.onResume()
requireViewBinding().webView.onResume()
}
override fun onPause() {
requireViewBinding().webView.onPause()
super.onPause()
}
override fun onDismiss(dialog: DialogInterface) {
setFragmentResult(TAG, pendingResult)
super.onDismiss(dialog)
}
override fun onPageLoaded() {
viewBinding?.progressBar?.isInvisible = true
}
override fun onCheckPassed() {
pendingResult.putBoolean(EXTRA_RESULT, true)
dismissAllowingStateLoss()
}
override fun onHistoryChanged() {
onBackPressedCallback?.onHistoryChanged()
}
companion object {
const val TAG = "CloudFlareDialog"
const val EXTRA_RESULT = "result"
private const val ARG_URL = "url"
private const val ARG_UA = "ua"
fun newInstance(url: String, headers: Headers?) = CloudFlareDialog().withArgs(2) {
putString(ARG_URL, url)
headers?.get(CommonHeaders.USER_AGENT)?.let {
putString(ARG_UA, it)
}
}
}
}

View File

@@ -29,18 +29,19 @@ 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.os.AppShortcutManager
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.util.AcraScreenLogger
import org.koitharu.kotatsu.core.util.IncognitoModeIndicator
import org.koitharu.kotatsu.core.util.ext.activityManager
import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.domain.ListExtraProviderImpl
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.CbzFetcher
import org.koitharu.kotatsu.local.data.LocalStorageChanges
@@ -64,6 +65,9 @@ interface AppModule {
@Binds
fun bindImageGetter(coilImageGetter: CoilImageGetter): Html.ImageGetter
@Binds
fun bindListExtraProvider(impl: ListExtraProviderImpl): ListExtraProvider
companion object {
@Provides
@@ -126,12 +130,12 @@ interface AppModule {
@ElementsIntoSet
fun provideDatabaseObservers(
widgetUpdater: WidgetUpdater,
appShortcutManager: AppShortcutManager,
shortcutsUpdater: ShortcutsUpdater,
backupObserver: BackupObserver,
syncController: SyncController,
): Set<@JvmSuppressWildcards InvalidationTracker.Observer> = arraySetOf(
widgetUpdater,
appShortcutManager,
shortcutsUpdater,
backupObserver,
syncController,
)
@@ -142,12 +146,10 @@ interface AppModule {
appProtectHelper: AppProtectHelper,
activityRecreationHandle: ActivityRecreationHandle,
incognitoModeIndicator: IncognitoModeIndicator,
acraScreenLogger: AcraScreenLogger,
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
appProtectHelper,
activityRecreationHandle,
incognitoModeIndicator,
acraScreenLogger,
)
@Provides

View File

@@ -33,21 +33,11 @@ abstract class ErrorObserver(
return resolver != null && ExceptionResolver.canResolve(error)
}
private fun isAlive(): Boolean {
return when {
fragment != null -> fragment.view != null
activity != null -> !activity.isDestroyed
else -> true
}
}
protected fun resolve(error: Throwable) {
if (isAlive()) {
lifecycleScope.launch {
val isResolved = resolver?.resolve(error) ?: false
if (isActive) {
onResolved?.accept(isResolved)
}
lifecycleScope.launch {
val isResolved = resolver?.resolve(error) ?: false
if (isActive) {
onResolved?.accept(isResolved)
}
}
}

View File

@@ -6,13 +6,15 @@ import androidx.annotation.StringRes
import androidx.collection.ArrayMap
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Headers
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.util.TaggedActivityResult
import org.koitharu.kotatsu.core.util.isSuccess
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -21,26 +23,20 @@ import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
class ExceptionResolver private constructor(
private val activity: FragmentActivity?,
private val fragment: Fragment?,
) : ActivityResultCallback<TaggedActivityResult> {
private val continuations = ArrayMap<String, Continuation<Boolean>>(1)
private val activity: FragmentActivity?
private val fragment: Fragment?
private val sourceAuthContract: ActivityResultLauncher<MangaSource>
private val cloudflareContract: ActivityResultLauncher<Pair<String, Headers?>>
private lateinit var sourceAuthContract: ActivityResultLauncher<MangaSource>
constructor(activity: FragmentActivity) {
this.activity = activity
fragment = null
constructor(activity: FragmentActivity) : this(activity = activity, fragment = null) {
sourceAuthContract = activity.registerForActivityResult(SourceAuthActivity.Contract(), this)
cloudflareContract = activity.registerForActivityResult(CloudFlareActivity.Contract(), this)
}
constructor(fragment: Fragment) {
this.fragment = fragment
activity = null
constructor(fragment: Fragment) : this(activity = null, fragment = fragment) {
sourceAuthContract = fragment.registerForActivityResult(SourceAuthActivity.Contract(), this)
cloudflareContract = fragment.registerForActivityResult(CloudFlareActivity.Contract(), this)
}
override fun onActivityResult(result: TaggedActivityResult) {
@@ -62,9 +58,22 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
else -> false
}
private suspend fun resolveCF(url: String, headers: Headers): Boolean = suspendCoroutine { cont ->
continuations[CloudFlareActivity.TAG] = cont
cloudflareContract.launch(url to headers)
private suspend fun resolveCF(url: String, headers: Headers): Boolean {
val dialog = CloudFlareDialog.newInstance(url, headers)
val fm = getFragmentManager()
return suspendCancellableCoroutine { cont ->
fm.clearFragmentResult(CloudFlareDialog.TAG)
continuations[CloudFlareDialog.TAG] = cont
fm.setFragmentResultListener(CloudFlareDialog.TAG, checkNotNull(fragment ?: activity)) { key, result ->
continuations.remove(key)?.resume(result.getBoolean(CloudFlareDialog.EXTRA_RESULT))
}
dialog.show(fm, CloudFlareDialog.TAG)
cont.invokeOnCancellation {
continuations.remove(CloudFlareDialog.TAG, cont)
fm.clearFragmentResultListener(CloudFlareDialog.TAG)
dialog.dismissAllowingStateLoss()
}
}
}
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->

View File

@@ -1,17 +0,0 @@
package org.koitharu.kotatsu.core.exceptions.resolve
import android.view.View
import android.widget.Toast
import androidx.fragment.app.Fragment
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
class ToastErrorObserver(
host: View,
fragment: Fragment?,
) : ErrorObserver(host, fragment, null, null) {
override suspend fun emit(value: Throwable) {
val toast = Toast.makeText(host.context, value.getDisplayMessage(host.context.resources), Toast.LENGTH_SHORT)
toast.show()
}
}

View File

@@ -1,5 +1,9 @@
package org.koitharu.kotatsu.core.github
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -10,22 +14,28 @@ import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.network.BaseHttpClient
import org.koitharu.kotatsu.core.os.AppValidator
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.asArrayList
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.byte2HexFormatted
import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull
import org.koitharu.kotatsu.parsers.util.parseJsonArray
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.security.MessageDigest
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import javax.inject.Inject
import javax.inject.Singleton
private const val CERT_SHA1 = "2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE"
private const val CONTENT_TYPE_APK = "application/vnd.android.package-archive"
@Singleton
class AppUpdateRepository @Inject constructor(
private val appValidator: AppValidator,
@ApplicationContext private val context: Context,
private val settings: AppSettings,
@BaseHttpClient private val okHttp: OkHttpClient,
) {
@@ -75,7 +85,7 @@ class AppUpdateRepository @Inject constructor(
}
fun isUpdateSupported(): Boolean {
return BuildConfig.DEBUG || appValidator.isOriginalApp
return BuildConfig.DEBUG || getCertificateSHA1Fingerprint() == CERT_SHA1
}
suspend fun getCurrentVersionChangelog(): String? {
@@ -84,6 +94,22 @@ class AppUpdateRepository @Inject constructor(
return available.find { x -> x.versionId == currentVersion }?.description
}
@Suppress("DEPRECATION")
@SuppressLint("PackageManagerGetSignatures")
private fun getCertificateSHA1Fingerprint(): String? = runCatching {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES)
val signatures = requireNotNull(packageInfo?.signatures)
val cert: ByteArray = signatures.first().toByteArray()
val input: InputStream = ByteArrayInputStream(cert)
val cf = CertificateFactory.getInstance("X509")
val c = cf.generateCertificate(input) as X509Certificate
val md: MessageDigest = MessageDigest.getInstance("SHA1")
val publicKey: ByteArray = md.digest(c.encoded)
return publicKey.byte2HexFormatted()
}.onFailure { error ->
error.printStackTraceDebug()
}.getOrNull()
private inline fun JSONArray.find(predicate: (JSONObject) -> Boolean): JSONObject? {
val size = length()
for (i in 0 until size) {

View File

@@ -17,7 +17,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.core.util.ext.subdir
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat

View File

@@ -8,14 +8,10 @@ import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
@JvmName("mangaIds")
fun Collection<Manga>.ids() = mapToSet { it.id }
fun Collection<Manga>.distinctById() = distinctBy { it.id }
@JvmName("chaptersIds")
fun Collection<MangaChapter>.ids() = mapToSet { it.id }
fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
if (size <= 1) {
return size

View File

@@ -41,10 +41,6 @@ class ParcelableManga(
return 0
}
override fun toString(): String {
return "ParcelableManga(manga=$manga, withChapters=$withChapters)"
}
companion object CREATOR : Parcelable.Creator<ParcelableManga> {
override fun createFromParcel(parcel: Parcel): ParcelableManga {
return ParcelableManga(parcel)

View File

@@ -23,10 +23,6 @@ class ParcelableMangaChapters(
return 0
}
override fun toString(): String {
return "ParcelableMangaChapters(chapters=$chapters)"
}
companion object CREATOR : Parcelable.Creator<ParcelableMangaChapters> {
override fun createFromParcel(parcel: Parcel): ParcelableMangaChapters {
return ParcelableMangaChapters(parcel)
@@ -36,4 +32,4 @@ class ParcelableMangaChapters(
return arrayOfNulls(size)
}
}
}
}

View File

@@ -23,10 +23,6 @@ class ParcelableMangaPages(
return 0
}
override fun toString(): String {
return "ParcelableMangaPages(pages=$pages)"
}
companion object CREATOR : Parcelable.Creator<ParcelableMangaPages> {
override fun createFromParcel(parcel: Parcel): ParcelableMangaPages {
return ParcelableMangaPages(parcel)
@@ -36,4 +32,4 @@ class ParcelableMangaPages(
return arrayOfNulls(size)
}
}
}
}

View File

@@ -24,10 +24,6 @@ class ParcelableMangaTags(
return 0
}
override fun toString(): String {
return "ParcelableMangaTags(tags=$tags)"
}
companion object CREATOR : Parcelable.Creator<ParcelableMangaTags> {
override fun createFromParcel(parcel: Parcel): ParcelableMangaTags {
return ParcelableMangaTags(parcel)

View File

@@ -1,7 +1,7 @@
package org.koitharu.kotatsu.core.network
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.io.IOException
import java.net.InetSocketAddress
import java.net.Proxy

View File

@@ -13,13 +13,13 @@ private const val SERVER_CLOUDFLARE = "cloudflare"
class CloudFlareInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
val request = chain.request()
val response = chain.proceed(request)
if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) {
if (response.header(HEADER_SERVER)?.startsWith(SERVER_CLOUDFLARE) == true) {
val request = response.request
response.closeQuietly()
throw CloudFlareProtectedException(
url = request.url.toString(),
url = response.request.url.toString(),
headers = request.headers,
)
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.network
import android.os.Build
import android.util.Log
import dagger.Lazy
import okhttp3.Headers
@@ -9,11 +10,11 @@ import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.UserAgents
import org.koitharu.kotatsu.parsers.util.mergeWith
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.net.IDN
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
@@ -38,7 +39,7 @@ class CommonHeadersInterceptor @Inject constructor(
headersBuilder.mergeWith(it, replaceExisting = false)
}
if (headersBuilder[CommonHeaders.USER_AGENT] == null) {
headersBuilder[CommonHeaders.USER_AGENT] = UserAgents.CHROME_MOBILE
headersBuilder[CommonHeaders.USER_AGENT] = userAgentFallback
}
if (headersBuilder[CommonHeaders.REFERER] == null && repository != null) {
val idn = IDN.toASCII(repository.domain)
@@ -61,4 +62,26 @@ class CommonHeadersInterceptor @Inject constructor(
override fun request(): Request = request
}
companion object {
val userAgentFallback
get() = "Kotatsu/%s (Android %s; %s; %s %s; %s)".format(
BuildConfig.VERSION_NAME,
Build.VERSION.RELEASE,
Build.MODEL,
Build.BRAND,
Build.DEVICE,
Locale.getDefault().language,
)
val userAgentChrome
get() = (
"Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/100.0.4896.127 Mobile Safari/537.36"
).format(
Build.VERSION.RELEASE,
Build.MODEL,
)
}
}

View File

@@ -6,7 +6,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.dnsoverhttps.DnsOverHttps
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.net.InetAddress
import java.net.UnknownHostException

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.core.network
import android.annotation.SuppressLint
import okhttp3.OkHttpClient
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.security.SecureRandom
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext

View File

@@ -4,7 +4,6 @@ import android.webkit.CookieManager
import androidx.annotation.WorkerThread
import okhttp3.Cookie
import okhttp3.HttpUrl
import org.koitharu.kotatsu.core.util.ext.newBuilder
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@@ -31,21 +30,6 @@ class AndroidCookieJar : MutableCookieJar {
}
}
override fun removeCookies(url: HttpUrl) {
val cookies = loadForRequest(url)
if (cookies.isEmpty()) {
return
}
val urlString = url.toString()
for (c in cookies) {
val nc = c.newBuilder()
.expiresAt(System.currentTimeMillis() - 100000)
.build()
cookieManager.setCookie(urlString, nc.toString())
}
check(loadForRequest(url).isEmpty())
}
override suspend fun clear() = suspendCoroutine<Boolean> { continuation ->
cookieManager.removeAllCookies(continuation::resume)
}

View File

@@ -13,8 +13,5 @@ interface MutableCookieJar : CookieJar {
@WorkerThread
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>)
@WorkerThread
fun removeCookies(url: HttpUrl)
suspend fun clear(): Boolean
}

View File

@@ -8,7 +8,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Cookie
import okhttp3.HttpUrl
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
private const val PREFS_NAME = "cookies"
@@ -21,7 +21,6 @@ class PreferencesCookieJar(
private var isLoaded = false
@WorkerThread
@Synchronized
override fun loadForRequest(url: HttpUrl): List<Cookie> {
loadPersistent()
val expired = HashSet<String>()
@@ -41,7 +40,6 @@ class PreferencesCookieJar(
}
@WorkerThread
@Synchronized
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val wrapped = cookies.map { CookieWrapper(it) }
prefs.edit(commit = true) {
@@ -55,22 +53,6 @@ class PreferencesCookieJar(
}
}
@Synchronized
@WorkerThread
override fun removeCookies(url: HttpUrl) {
loadPersistent()
val toRemove = HashSet<String>()
for ((key, cookie) in cache) {
if (cookie.isExpired() || cookie.cookie.matches(url)) {
toRemove += key
}
}
if (toRemove.isNotEmpty()) {
cache.removeAll(toRemove)
removePersistent(toRemove)
}
}
override suspend fun clear(): Boolean {
cache.clear()
withContext(Dispatchers.IO) {

View File

@@ -1,46 +0,0 @@
package org.koitharu.kotatsu.core.os
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.util.byte2HexFormatted
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.security.MessageDigest
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AppValidator @Inject constructor(
@ApplicationContext private val context: Context,
) {
val isOriginalApp by lazy {
getCertificateSHA1Fingerprint() == CERT_SHA1
}
@Suppress("DEPRECATION")
@SuppressLint("PackageManagerGetSignatures")
private fun getCertificateSHA1Fingerprint(): String? = runCatching {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES)
val signatures = requireNotNull(packageInfo?.signatures)
val cert: ByteArray = signatures.first().toByteArray()
val input: InputStream = ByteArrayInputStream(cert)
val cf = CertificateFactory.getInstance("X509")
val c = cf.generateCertificate(input) as X509Certificate
val md: MessageDigest = MessageDigest.getInstance("SHA1")
val publicKey: ByteArray = md.digest(c.encoded)
return publicKey.byte2HexFormatted()
}.onFailure { error ->
error.printStackTraceDebug()
}.getOrNull()
private companion object {
private const val CERT_SHA1 = "2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE"
}
}

View File

@@ -15,6 +15,7 @@ import androidx.core.graphics.drawable.toBitmap
import androidx.room.InvalidationTracker
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Precision
import coil.size.Scale
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
@@ -24,19 +25,18 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.image.ThumbnailTransformation
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AppShortcutManager @Inject constructor(
class ShortcutsUpdater @Inject constructor(
@ApplicationContext private val context: Context,
private val coil: ImageLoader,
private val historyRepository: HistoryRepository,
@@ -128,8 +128,8 @@ class AppShortcutManager @Inject constructor(
.data(manga.coverUrl)
.size(iconSize.width, iconSize.height)
.tag(manga.source)
.precision(Precision.EXACT)
.scale(Scale.FILL)
.transformations(ThumbnailTransformation())
.build(),
).getDrawableOrThrow().toBitmap()
}.fold(
@@ -142,9 +142,8 @@ class AppShortcutManager @Inject constructor(
.setLongLabel(manga.title)
.setIcon(icon)
.setIntent(
ReaderActivity.IntentBuilder(context)
.mangaId(manga.id)
.build(),
ReaderActivity.newIntent(context, manga.id)
.setAction(ReaderActivity.ACTION_MANGA_READ),
)
}

View File

@@ -9,6 +9,6 @@ fun MangaParser(source: MangaSource, loaderContext: MangaLoaderContext): MangaPa
return if (source == MangaSource.DUMMY) {
DummyParser(loaderContext)
} else {
loaderContext.newParserInstance(source)
source.newParser(loaderContext)
}
}
}

View File

@@ -0,0 +1,37 @@
package org.koitharu.kotatsu.core.parser
import android.content.Context
import androidx.annotation.ColorRes
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.model.MangaTag
import javax.inject.Inject
@Reusable
class MangaTagHighlighter @Inject constructor(
@ApplicationContext context: Context,
) {
private val dict by lazy {
context.resources.openRawResource(R.raw.tags_redlist).use {
val set = HashSet<String>()
it.bufferedReader().forEachLine { x ->
val line = x.trim()
if (line.isNotEmpty()) {
set.add(line)
}
}
set
}
}
@ColorRes
fun getTint(tag: MangaTag): Int {
return if (tag.title.lowercase() in dict) {
R.color.warning
} else {
0
}
}
}

View File

@@ -21,11 +21,9 @@ import org.koitharu.kotatsu.core.util.ext.filterToSet
import org.koitharu.kotatsu.core.util.ext.getEnumValue
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.putEnumValue
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.shelf.domain.model.ShelfSection
import java.io.File
@@ -236,28 +234,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
if (key == null) ScreenshotsPolicy.ALLOW else ScreenshotsPolicy.valueOf(key)
}.getOrDefault(ScreenshotsPolicy.ALLOW)
var userSpecifiedMangaDirectories: Set<File>
get() {
val set = prefs.getStringSet(KEY_LOCAL_MANGA_DIRS, emptySet()).orEmpty()
return set.mapNotNullToSet { File(it).takeIfReadable() }
}
set(value) {
val set = value.mapToSet { it.absolutePath }
prefs.edit { putStringSet(KEY_LOCAL_MANGA_DIRS, set) }
}
var mangaStorageDir: File?
get() = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
File(it)
}?.takeIf { it.exists() && it in userSpecifiedMangaDirectories }
}?.takeIf { it.exists() }
set(value) = prefs.edit {
if (value == null) {
remove(KEY_LOCAL_STORAGE)
} else {
val userDirs = userSpecifiedMangaDirectories
if (value !in userDirs) {
userSpecifiedMangaDirectories = userDirs + value
}
putString(KEY_LOCAL_STORAGE, value.path)
}
}
@@ -444,6 +428,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SHIKIMORI = "shikimori"
const val KEY_ANILIST = "anilist"
const val KEY_MAL = "mal"
const val KEY_KITSU = "kitsu"
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
const val KEY_DOWNLOADS_WIFI = "downloads_wifi"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
@@ -477,7 +462,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_PROXY_LOGIN = "proxy_login"
const val KEY_PROXY_PASSWORD = "proxy_password"
const val KEY_IMAGES_PROXY = "images_proxy"
const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs"
// About
const val KEY_APP_UPDATE = "app_update"

View File

@@ -27,7 +27,6 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
.setView(binding.root)
.run(::onBuildDialog)
.create()
.also(::onDialogCreated)
}
final override fun onCreateView(

View File

@@ -45,7 +45,7 @@ abstract class BaseActivity<B : ViewBinding> :
protected val exceptionResolver = ExceptionResolver(this)
@JvmField
protected val insetsDelegate = WindowInsetsDelegate()
protected val insetsDelegate = WindowInsetsDelegate(this)
@JvmField
val actionModeDelegate = ActionModeDelegate()
@@ -62,7 +62,6 @@ abstract class BaseActivity<B : ViewBinding> :
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
insetsDelegate.handleImeInsets = true
insetsDelegate.addInsetsListener(this)
putDataToExtras(intent)
}

View File

@@ -26,7 +26,7 @@ abstract class BaseFragment<B : ViewBinding> :
protected val exceptionResolver = ExceptionResolver(this)
@JvmField
protected val insetsDelegate = WindowInsetsDelegate()
protected val insetsDelegate = WindowInsetsDelegate(this)
protected val actionModeDelegate: ActionModeDelegate
get() = (requireActivity() as BaseActivity<*>).actionModeDelegate
@@ -44,13 +44,11 @@ abstract class BaseFragment<B : ViewBinding> :
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
insetsDelegate.onViewCreated(view)
insetsDelegate.addInsetsListener(this)
onViewBindingCreated(requireViewBinding(), savedInstanceState)
}
override fun onDestroyView() {
viewBinding = null
insetsDelegate.removeInsetsListener(this)
insetsDelegate.onDestroyView()
super.onDestroyView()
}

View File

@@ -4,11 +4,9 @@ import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import androidx.core.content.ContextCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.viewbinding.ViewBinding
import org.koitharu.kotatsu.R
abstract class BaseFullscreenActivity<B : ViewBinding> :
BaseActivity<B>() {
@@ -20,11 +18,7 @@ abstract class BaseFullscreenActivity<B : ViewBinding> :
with(window) {
insetsControllerCompat = WindowInsetsControllerCompat(this, decorView)
statusBarColor = Color.TRANSPARENT
navigationBarColor = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
ContextCompat.getColor(this@BaseFullscreenActivity, R.color.dim)
} else {
Color.TRANSPARENT
}
navigationBarColor = Color.TRANSPARENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
attributes.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES

View File

@@ -12,10 +12,10 @@ import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.SettingsHeadersFragment
import javax.inject.Inject
@Suppress("LeakingThis")
@AndroidEntryPoint
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
PreferenceFragmentCompat(),
@@ -26,28 +26,27 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
lateinit var settings: AppSettings
@JvmField
protected val insetsDelegate = WindowInsetsDelegate()
protected val insetsDelegate = WindowInsetsDelegate(this)
override val recyclerView: RecyclerView
get() = listView
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.setBackgroundColor(view.context.getThemeColor(android.R.attr.colorBackground))
listView.clipToPadding = false
insetsDelegate.onViewCreated(view)
insetsDelegate.addInsetsListener(this)
}
override fun onDestroyView() {
insetsDelegate.removeInsetsListener(this)
insetsDelegate.onDestroyView()
super.onDestroyView()
}
override fun onResume() {
super.onResume()
setTitle(if (titleId != 0) getString(titleId) else null)
if (titleId != 0) {
setTitle(getString(titleId))
}
}
@CallSuper
@@ -57,7 +56,9 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
)
}
protected fun setTitle(title: CharSequence?) {
(activity as? SettingsActivity)?.setSectionTitle(title)
@Suppress("UsePropertyAccessSyntax")
protected fun setTitle(title: CharSequence) {
(parentFragment as? SettingsHeadersFragment)?.setTitle(title)
?: activity?.setTitle(title)
}
}

View File

@@ -17,7 +17,7 @@ import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.util.ext.EventFlow
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
@@ -34,7 +34,7 @@ abstract class BaseViewModel : ViewModel() {
val isLoading: StateFlow<Boolean>
get() = loadingCounter.map { it > 0 }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), loadingCounter.value > 0)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
protected fun launchJob(
context: CoroutineContext = EmptyCoroutineContext,

View File

@@ -9,7 +9,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
abstract class CoroutineIntentService : BaseService() {

View File

@@ -0,0 +1,101 @@
package org.koitharu.kotatsu.core.ui.dialog
import android.content.Context
import android.content.DialogInterface
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemStorageBinding
import org.koitharu.kotatsu.local.data.LocalStorageManager
import java.io.File
class StorageSelectDialog private constructor(private val delegate: AlertDialog) :
DialogInterface by delegate {
fun show() = delegate.show()
class Builder(context: Context, storageManager: LocalStorageManager, listener: OnStorageSelectListener) {
private val adapter = VolumesAdapter(storageManager)
private val delegate = MaterialAlertDialogBuilder(context)
init {
if (adapter.isEmpty) {
delegate.setMessage(R.string.cannot_find_available_storage)
} else {
val defaultValue = runBlocking {
storageManager.getDefaultWriteableDir()
}
adapter.selectedItemPosition = adapter.volumes.indexOfFirst {
it.first.canonicalPath == defaultValue?.canonicalPath
}
delegate.setAdapter(adapter) { d, i ->
listener.onStorageSelected(adapter.getItem(i).first)
d.dismiss()
}
}
}
fun setTitle(@StringRes titleResId: Int): Builder {
delegate.setTitle(titleResId)
return this
}
fun setTitle(title: CharSequence): Builder {
delegate.setTitle(title)
return this
}
fun setNegativeButton(@StringRes textId: Int): Builder {
delegate.setNegativeButton(textId, null)
return this
}
fun create() = StorageSelectDialog(delegate.create())
}
private class VolumesAdapter(storageManager: LocalStorageManager) : BaseAdapter() {
var selectedItemPosition: Int = -1
val volumes = getAvailableVolumes(storageManager)
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: LayoutInflater.from(parent.context).inflate(R.layout.item_storage, parent, false)
val binding = (view.tag as? ItemStorageBinding) ?: ItemStorageBinding.bind(view).also {
view.tag = it
}
val item = volumes[position]
binding.imageViewIndicator.isChecked = selectedItemPosition == position
binding.textViewTitle.text = item.second
binding.textViewSubtitle.text = item.first.path
return view
}
override fun getItem(position: Int): Pair<File, String> = volumes[position]
override fun getItemId(position: Int) = position.toLong()
override fun getCount() = volumes.size
override fun hasStableIds() = true
private fun getAvailableVolumes(storageManager: LocalStorageManager): List<Pair<File, String>> {
return runBlocking {
storageManager.getWriteableDirs().map {
it to storageManager.getStorageDisplayName(it)
}
}
}
}
fun interface OnStorageSelectListener {
fun onStorageSelected(file: File)
}
}

View File

@@ -1,100 +0,0 @@
package org.koitharu.kotatsu.core.ui.drawable
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.os.Build
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import androidx.annotation.ColorInt
import androidx.annotation.Px
import androidx.annotation.StyleRes
import androidx.core.graphics.withTranslation
import com.google.android.material.resources.TextAppearance
import com.google.android.material.resources.TextAppearanceFontCallback
import org.koitharu.kotatsu.core.util.ext.getThemeColor
class TextDrawable(
val text: CharSequence,
) : Drawable() {
private val paint = TextPaint(Paint.ANTI_ALIAS_FLAG)
private var cachedLayout: StaticLayout? = null
@SuppressLint("RestrictedApi")
constructor(context: Context, text: CharSequence, @StyleRes textAppearanceId: Int) : this(text) {
val ta = TextAppearance(context, textAppearanceId)
paint.color = ta.textColor?.defaultColor ?: context.getThemeColor(android.R.attr.textColorPrimary, Color.BLACK)
paint.typeface = ta.fallbackFont
ta.getFontAsync(
context, paint,
object : TextAppearanceFontCallback() {
override fun onFontRetrieved(typeface: Typeface?, fontResolvedSynchronously: Boolean) = Unit
override fun onFontRetrievalFailed(reason: Int) = Unit
},
)
paint.letterSpacing = ta.letterSpacing
}
var alignment = Layout.Alignment.ALIGN_NORMAL
var lineSpacingMultiplier = 1f
@Px
var lineSpacingExtra = 0f
@get:ColorInt
var textColor: Int
get() = paint.color
set(@ColorInt value) {
paint.color = value
}
override fun draw(canvas: Canvas) {
val b = bounds
if (b.isEmpty) {
return
}
canvas.withTranslation(x = b.left.toFloat(), y = b.top.toFloat()) {
obtainLayout().draw(canvas)
}
}
override fun setAlpha(alpha: Int) {
paint.alpha = alpha
}
override fun setColorFilter(colorFilter: ColorFilter?) {
paint.setColorFilter(colorFilter)
}
@Suppress("DeprecatedCallableAddReplaceWith")
@Deprecated("Deprecated in Java")
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
private fun obtainLayout(): StaticLayout {
val width = bounds.width()
cachedLayout?.let {
if (it.width == width) {
return it
}
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
.setAlignment(alignment)
.setLineSpacing(lineSpacingExtra, lineSpacingMultiplier)
.setIncludePad(true)
.build()
} else {
@Suppress("DEPRECATION")
StaticLayout(text, paint, width, alignment, lineSpacingMultiplier, lineSpacingExtra, true)
}.also { cachedLayout = it }
}
}

View File

@@ -1,24 +0,0 @@
package org.koitharu.kotatsu.core.ui.image
import android.graphics.Bitmap
import android.media.ThumbnailUtils
import coil.size.Size
import coil.size.pxOrElse
import coil.transform.Transformation
class ThumbnailTransformation : Transformation {
override val cacheKey: String = javaClass.name
override suspend fun transform(input: Bitmap, size: Size): Bitmap {
return ThumbnailUtils.extractThumbnail(
input,
size.width.pxOrElse { input.width },
size.height.pxOrElse { input.height },
)
}
override fun equals(other: Any?) = other is ThumbnailTransformation
override fun hashCode() = javaClass.hashCode()
}

View File

@@ -9,7 +9,6 @@ import android.util.AttributeSet
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.annotation.*
@@ -25,7 +24,6 @@ import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.isLayoutReversed
import org.koitharu.kotatsu.core.util.ext.parents
import org.koitharu.kotatsu.databinding.FastScrollerBinding
import kotlin.math.roundToInt
import com.google.android.material.R as materialR
@@ -117,9 +115,6 @@ class FastScroller @JvmOverloads constructor(
return viewHeight * proportion
}
val isScrollbarVisible: Boolean
get() = binding.scrollbar.isVisible
init {
clipChildren = false
orientation = HORIZONTAL
@@ -170,9 +165,7 @@ class FastScroller @JvmOverloads constructor(
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
if (!isScrollbarVisible || event.x.toInt() !in binding.scrollbar.left..binding.scrollbar.right) {
return false
}
if (event.x.toInt() !in binding.scrollbar.left..binding.scrollbar.right) return false
requestDisallowInterceptTouchEvent(true)
setHandleSelected(true)
@@ -303,12 +296,10 @@ class FastScroller @JvmOverloads constructor(
if (parent is ViewGroup) {
setLayoutParams(parent as ViewGroup)
} else {
val viewGroup = findValidParent(recyclerView)
if (viewGroup != null) {
viewGroup.addView(this)
setLayoutParams(viewGroup)
}
} else if (recyclerView.parent is ViewGroup) {
val viewGroup = recyclerView.parent as ViewGroup
viewGroup.addView(this)
setLayoutParams(viewGroup)
}
recyclerView.addOnScrollListener(scrollListener)
@@ -522,14 +513,6 @@ class FastScroller @JvmOverloads constructor(
return BubbleSize.values().getOrNull(ordinal) ?: defaultValue
}
private fun findValidParent(view: View): ViewGroup? = view.parents.firstNotNullOfOrNull { p ->
if (p is FrameLayout || p is ConstraintLayout || p is CoordinatorLayout || p is RelativeLayout) {
p as ViewGroup
} else {
null
}
}
private val BubbleSize.textSize
@Px get() = resources.getDimension(textSizeId)

View File

@@ -6,6 +6,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import androidx.activity.ComponentDialog
import androidx.activity.OnBackPressedDispatcher
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.core.view.updateLayoutParams
@@ -35,7 +36,7 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
get() = behavior?.state == AdaptiveSheetBehavior.STATE_EXPANDED
val onBackPressedDispatcher: OnBackPressedDispatcher
get() = requireComponentDialog().onBackPressedDispatcher
get() = (requireDialog() as ComponentDialog).onBackPressedDispatcher
final override fun onCreateView(
inflater: LayoutInflater,

View File

@@ -14,7 +14,7 @@ class ActionModeDelegate : OnBackPressedCallback(false) {
get() = activeActionMode != null
override fun handleOnBackPressed() {
finishActionMode()
activeActionMode?.finish()
}
fun onSupportActionModeStarted(mode: ActionMode) {
@@ -45,10 +45,6 @@ class ActionModeDelegate : OnBackPressedCallback(false) {
owner.lifecycle.addObserver(ListenerLifecycleObserver(listener))
}
fun finishActionMode() {
activeActionMode?.finish()
}
private inner class ListenerLifecycleObserver(
private val listener: ActionModeListener,
) : DefaultLifecycleObserver {

View File

@@ -1,20 +1,19 @@
package org.koitharu.kotatsu.core.ui.util
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
fun interface ReversibleHandle {
suspend fun reverse()
}
fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.Default, CoroutineStart.ATOMIC) {
fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.Default) {
runCatchingCancellable {
withContext(NonCancellable) {
reverse()

View File

@@ -5,9 +5,10 @@ import androidx.core.graphics.Insets
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import java.util.LinkedList
class WindowInsetsDelegate : OnApplyWindowInsetsListener, View.OnLayoutChangeListener {
class WindowInsetsDelegate(
private val listener: WindowInsetsListener,
) : OnApplyWindowInsetsListener, View.OnLayoutChangeListener {
@JvmField
var handleImeInsets: Boolean = false
@@ -15,7 +16,6 @@ class WindowInsetsDelegate : OnApplyWindowInsetsListener, View.OnLayoutChangeLis
@JvmField
var interceptingWindowInsetsListener: OnApplyWindowInsetsListener? = null
private val listeners = LinkedList<WindowInsetsListener>()
private var lastInsets: Insets? = null
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
@@ -29,7 +29,7 @@ class WindowInsetsDelegate : OnApplyWindowInsetsListener, View.OnLayoutChangeLis
handledInsets.getInsets(WindowInsetsCompat.Type.systemBars())
}
if (newInsets != lastInsets) {
listeners.forEach { it.onWindowInsetsChanged(newInsets) }
listener.onWindowInsetsChanged(newInsets)
lastInsets = newInsets
}
return handledInsets
@@ -52,15 +52,6 @@ class WindowInsetsDelegate : OnApplyWindowInsetsListener, View.OnLayoutChangeLis
}
}
fun addInsetsListener(listener: WindowInsetsListener) {
listeners.add(listener)
lastInsets?.let { listener.onWindowInsetsChanged(it) }
}
fun removeInsetsListener(listener: WindowInsetsListener) {
listeners.remove(listener)
}
fun onViewCreated(view: View) {
ViewCompat.setOnApplyWindowInsetsListener(view, this)
view.addOnLayoutChangeListener(this)

View File

@@ -92,14 +92,6 @@ class SlidingBottomNavigationView @JvmOverloads constructor(
)
}
fun showOrHide(show: Boolean) {
if (show) {
show()
} else {
hide()
}
}
private fun animateTranslation(targetY: Float, duration: Long, interpolator: TimeInterpolator) {
currentAnimator = animate()
.translationY(targetY)

View File

@@ -24,7 +24,6 @@ import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ViewTwoLinesItemBinding
@SuppressLint("RestrictedApi")
@@ -36,18 +35,6 @@ class TwoLinesItemView @JvmOverloads constructor(
private val binding = ViewTwoLinesItemBinding.inflate(LayoutInflater.from(context), this)
var title: CharSequence?
get() = binding.title.text
set(value) {
binding.title.text = value
}
var subtitle: CharSequence?
get() = binding.subtitle.textAndVisible
set(value) {
binding.subtitle.textAndVisible = value
}
init {
var textColors: ColorStateList? = null
context.withStyledAttributes(
@@ -89,7 +76,8 @@ class TwoLinesItemView @JvmOverloads constructor(
}
fun setIconResource(@DrawableRes resId: Int) {
binding.icon.setImageResource(resId)
val icon = if (resId != 0) ContextCompat.getDrawable(context, resId) else null
binding.icon.setImageDrawable(icon)
}
private fun createShapeDrawable(ta: TypedArray): InsetDrawable {

View File

@@ -1,60 +0,0 @@
package org.koitharu.kotatsu.core.util
import android.app.Activity
import android.content.Context
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks
import org.acra.ACRA
import org.koitharu.kotatsu.core.ui.DefaultActivityLifecycleCallbacks
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.WeakHashMap
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AcraScreenLogger @Inject constructor() : FragmentLifecycleCallbacks(), DefaultActivityLifecycleCallbacks {
private val timeFormat = SimpleDateFormat.getTimeInstance(DateFormat.DEFAULT, Locale.ROOT)
private val keys = WeakHashMap<Any, String>()
override fun onFragmentAttached(fm: FragmentManager, f: Fragment, context: Context) {
super.onFragmentAttached(fm, f, context)
ACRA.errorReporter.putCustomData(f.key(), f.arguments.contentToString())
}
override fun onFragmentDetached(fm: FragmentManager, f: Fragment) {
super.onFragmentDetached(fm, f)
ACRA.errorReporter.removeCustomData(f.key())
keys.remove(f)
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
super.onActivityCreated(activity, savedInstanceState)
ACRA.errorReporter.putCustomData(activity.key(), activity.intent.extras.contentToString())
(activity as? FragmentActivity)?.supportFragmentManager?.registerFragmentLifecycleCallbacks(this, true)
}
override fun onActivityDestroyed(activity: Activity) {
super.onActivityDestroyed(activity)
ACRA.errorReporter.removeCustomData(activity.key())
keys.remove(activity)
}
private fun Any.key() = keys.getOrPut(this) {
"${time()}: ${javaClass.simpleName}"
}
private fun time() = timeFormat.format(Date())
@Suppress("DEPRECATION")
private fun Bundle?.contentToString() = this?.keySet()?.joinToString { k ->
val v = get(k)
"$k=$v"
} ?: toString()
}

View File

@@ -0,0 +1,6 @@
package org.koitharu.kotatsu.core.util
fun interface BufferedObserver<T> {
fun onChanged(t: T, previous: T?)
}

View File

@@ -5,8 +5,7 @@ import android.app.Activity
class TaggedActivityResult(
val tag: String,
val result: Int,
) {
)
val isSuccess: Boolean
get() = result == Activity.RESULT_OK
}
val TaggedActivityResult.isSuccess: Boolean
get() = this.result == Activity.RESULT_OK

View File

@@ -61,10 +61,6 @@ class WorkManagerHelper(
return workManagerImpl.getWorkInfoById(id).await()
}
suspend fun getUniqueWorkInfoByName(name: String): List<WorkInfo> {
return workManagerImpl.getWorkInfosForUniqueWork(name).await().orEmpty()
}
suspend fun updateWork(request: WorkRequest): WorkManager.UpdateResult {
return workManagerImpl.updateWork(request).await()
}

View File

@@ -6,7 +6,6 @@ import android.app.ActivityManager.MemoryInfo
import android.app.ActivityOptions
import android.content.Context
import android.content.Context.ACTIVITY_SERVICE
import android.content.Context.POWER_SERVICE
import android.content.ContextWrapper
import android.content.OperationApplicationException
import android.content.SharedPreferences
@@ -17,8 +16,6 @@ import android.database.SQLException
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.view.View
import android.view.ViewPropertyAnimator
@@ -46,6 +43,7 @@ import org.jsoup.internal.StringUtil.StringJoiner
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import kotlin.math.roundToLong
@@ -53,9 +51,6 @@ import kotlin.math.roundToLong
val Context.activityManager: ActivityManager?
get() = getSystemService(ACTIVITY_SERVICE) as? ActivityManager
val Context.powerManager: PowerManager?
get() = getSystemService(POWER_SERVICE) as? PowerManager
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatchingCancellable {
@@ -146,10 +141,6 @@ fun Context.isLowRamDevice(): Boolean {
return activityManager?.isLowRamDevice ?: false
}
fun Context.isPowerSaveMode(): Boolean {
return powerManager?.isPowerSaveMode == true
}
val Context.ramAvailable: Long
get() {
val result = MemoryInfo()
@@ -157,17 +148,13 @@ val Context.ramAvailable: Long
return result.availMem
}
fun scaleUpActivityOptionsOf(view: View): Bundle? = if (view.context.isAnimationsEnabled) {
ActivityOptions.makeScaleUpAnimation(
view,
0,
0,
view.width,
view.height,
).toBundle()
} else {
null
}
fun scaleUpActivityOptionsOf(view: View): ActivityOptions = ActivityOptions.makeScaleUpAnimation(
view,
0,
0,
view.width,
view.height,
)
fun Resources.getLocalesConfig(): LocaleListCompat {
val tagsList = StringJoiner(",")

View File

@@ -9,8 +9,8 @@ import android.os.Parcel
import android.os.Parcelable
import androidx.core.content.IntentCompat
import androidx.core.os.BundleCompat
import androidx.core.os.ParcelCompat
import androidx.lifecycle.SavedStateHandle
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaTags
import java.io.Serializable
// https://issuetracker.google.com/issues/240585930
@@ -36,11 +36,11 @@ inline fun <reified T : Serializable> Bundle.getSerializableCompat(key: String):
}
inline fun <reified T : Parcelable> Parcel.readParcelableCompat(): T? {
return ParcelCompat.readParcelable(this, T::class.java.classLoader, T::class.java)
return readParcelable(ParcelableMangaTags::class.java.classLoader) as T?
}
inline fun <reified T : Serializable> Parcel.readSerializableCompat(): T? {
return ParcelCompat.readSerializable(this, T::class.java.classLoader, T::class.java)
return readSerializable() as T?
}
inline fun <reified T : Serializable> Bundle.requireSerializable(key: String): T {
@@ -49,6 +49,12 @@ inline fun <reified T : Serializable> Bundle.requireSerializable(key: String): T
}
}
inline fun <reified T : Parcelable> Bundle.requireParcelable(key: String): T {
return checkNotNull(getParcelableCompat(key)) {
"Parcelable of type \"${T::class.java.name}\" not found at \"$key\""
}
}
fun <T> SavedStateHandle.require(key: String): T {
return checkNotNull(get(key)) {
"Value $key not found in SavedStateHandle or has a wrong type"

View File

@@ -1,90 +0,0 @@
package org.koitharu.kotatsu.core.util.ext
import android.annotation.TargetApi
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.storage.StorageManager
import android.provider.DocumentsContract
import org.koitharu.kotatsu.parsers.util.removeSuffix
import java.io.File
import java.lang.reflect.Array as ArrayReflect
private const val PRIMARY_VOLUME_NAME = "primary"
fun Uri.resolveFile(context: Context): File? {
val volumeId = getVolumeIdFromTreeUri(this) ?: return null
val volumePath = getVolumePath(volumeId, context)?.removeSuffix(File.separatorChar) ?: return null
val documentPath = getDocumentPathFromTreeUri(this)?.removeSuffix(File.separatorChar) ?: return null
return File(
if (documentPath.isNotEmpty()) {
if (documentPath.startsWith(File.separator)) {
volumePath + documentPath
} else {
volumePath + File.separator + documentPath
}
} else {
volumePath
},
)
}
private fun getVolumePath(volumeId: String, context: Context): String? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
getVolumePathForAndroid11AndAbove(volumeId, context)
} else {
getVolumePathBeforeAndroid11(volumeId, context)
}
}
private fun getVolumePathBeforeAndroid11(volumeId: String, context: Context): String? = runCatching {
val mStorageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
val storageVolumeClazz = Class.forName("android.os.storage.StorageVolume")
val getVolumeList = mStorageManager.javaClass.getMethod("getVolumeList")
val getUuid = storageVolumeClazz.getMethod("getUuid")
val getPath = storageVolumeClazz.getMethod("getPath")
val isPrimary = storageVolumeClazz.getMethod("isPrimary")
val result = getVolumeList.invoke(mStorageManager)
val length = ArrayReflect.getLength(checkNotNull(result))
(0 until length).firstNotNullOfOrNull { i ->
val storageVolumeElement = ArrayReflect.get(result, i)
val uuid = getUuid.invoke(storageVolumeElement) as String
val primary = isPrimary.invoke(storageVolumeElement) as Boolean
when {
primary && volumeId == PRIMARY_VOLUME_NAME -> getPath.invoke(storageVolumeElement) as String
uuid == volumeId -> getPath.invoke(storageVolumeElement) as String
else -> null
}
}
}.onFailure {
it.printStackTraceDebug()
}.getOrNull()
@TargetApi(Build.VERSION_CODES.R)
private fun getVolumePathForAndroid11AndAbove(volumeId: String, context: Context): String? = runCatching {
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
storageManager.storageVolumes.firstNotNullOfOrNull { volume ->
if (volume.isPrimary && volumeId == PRIMARY_VOLUME_NAME) {
volume.directory?.path
} else {
val uuid = volume.uuid
if (uuid != null && uuid == volumeId) volume.directory?.path else null
}
}
}.onFailure {
it.printStackTraceDebug()
}.getOrNull()
private fun getVolumeIdFromTreeUri(treeUri: Uri): String? {
val docId = DocumentsContract.getTreeDocumentId(treeUri)
val split = docId.split(":".toRegex())
return split.firstOrNull()?.takeUnless { it.isEmpty() }
}
private fun getDocumentPathFromTreeUri(treeUri: Uri): String? {
val docId = DocumentsContract.getTreeDocumentId(treeUri)
val split: Array<String?> = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
return if (split.size >= 2 && split[1] != null) split[1] else File.separator
}

View File

@@ -26,8 +26,6 @@ fun File.takeIfReadable() = takeIf { it.exists() && it.canRead() }
fun File.takeIfWriteable() = takeIf { it.exists() && it.canWrite() }
fun File.isNotEmpty() = length() != 0L
fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).bufferedReader().use {
it.readText()
}

View File

@@ -4,7 +4,6 @@ import android.os.SystemClock
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
@@ -63,23 +62,3 @@ fun <T> Flow<T>.zipWithPrevious(): Flow<Pair<T?, T>> = flow {
emit(result)
}
}
@Suppress("UNCHECKED_CAST")
fun <T1, T2, T3, T4, T5, T6, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
transform: suspend (T1, T2, T3, T4, T5, T6) -> R
): Flow<R> = combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> ->
transform(
args[0] as T1,
args[1] as T2,
args[2] as T3,
args[3] as T4,
args[4] as T5,
args[5] as T6,
)
}

View File

@@ -14,13 +14,7 @@ import org.koitharu.kotatsu.core.util.Event
fun <T> Flow<T>.observe(owner: LifecycleOwner, collector: FlowCollector<T>) {
val start = if (this is StateFlow) CoroutineStart.UNDISPATCHED else CoroutineStart.DEFAULT
owner.lifecycleScope.launch(start = start) {
collect(collector)
}
}
fun <T> Flow<T>.observe(owner: LifecycleOwner, minState: Lifecycle.State, collector: FlowCollector<T>) {
owner.lifecycleScope.launch {
owner.lifecycle.repeatOnLifecycle(minState) {
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
collect(collector)
}
}

View File

@@ -52,11 +52,3 @@ suspend fun Fragment.awaitViewLifecycle(): LifecycleOwner {
}
}
}
fun DialogFragment.showDistinct(fm: FragmentManager, tag: String) {
val existing = fm.findFragmentByTag(tag) as? DialogFragment?
if (existing != null && existing.isVisible && existing.arguments == this.arguments) {
return
}
show(fm, tag)
}

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.core.util.ext
import okhttp3.Cookie
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
@@ -39,23 +38,3 @@ fun Response.ensureSuccess() = apply {
throw IllegalStateException(message)
}
}
fun Cookie.newBuilder(): Cookie.Builder = 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()
}
}

View File

@@ -2,9 +2,10 @@ package org.koitharu.kotatsu.core.util.ext
import androidx.annotation.FloatRange
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.util.UUID
inline fun <C : CharSequence> C?.ifNullOrEmpty(defaultValue: () -> C): C {
inline fun String?.ifNullOrEmpty(defaultValue: () -> String): String {
return if (this.isNullOrEmpty()) defaultValue() else this
}
@@ -34,9 +35,3 @@ fun String.almostEquals(other: String, @FloatRange(from = 0.0) threshold: Float)
val diff = lowercase().levenshteinDistance(other.lowercase()) / ((length + other.length) / 2f)
return diff < threshold
}
fun CharSequence.sanitize(): CharSequence {
return filterNot { c -> c.isReplacement() }
}
fun Char.isReplacement() = this in '\uFFF0'..'\uFFFF'

View File

@@ -60,10 +60,3 @@ fun Context.getThemeColorStateList(
) = obtainStyledAttributes(intArrayOf(resId)).use {
it.getColorStateList(0)
}
fun Context.getThemeResId(
@AttrRes resId: Int,
fallback: Int
): Int = obtainStyledAttributes(intArrayOf(resId)).use {
it.getResourceId(0, fallback)
}

View File

@@ -34,7 +34,6 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
is FileNotFoundException -> resources.getString(R.string.file_not_found)
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
is SyncApiException,
is ContentUnavailableException,
@@ -88,6 +87,3 @@ fun Throwable.isWebViewUnavailable(): Boolean {
return (this is AndroidRuntimeException && message?.contains("WebView") == true) ||
cause?.isWebViewUnavailable() == true
}
@Suppress("FunctionName")
fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT)

View File

@@ -1,14 +0,0 @@
package org.koitharu.kotatsu.core.util.ext
import androidx.annotation.DrawableRes
import androidx.appcompat.widget.Toolbar
fun Toolbar.setNavigationIconSafe(@DrawableRes iconRes: Int, retry: Boolean = true) {
try {
setNavigationIcon(iconRes)
} catch (e: IllegalStateException) {
if (retry) {
post { setNavigationIconSafe(iconRes, retry = false) }
}
}
}

View File

@@ -10,13 +10,12 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaChapters
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.history.data.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.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import javax.inject.Inject
@AndroidEntryPoint
@@ -116,9 +115,6 @@ class MangaPrefetchService : CoroutineIntentService() {
if (source == MangaSource.LOCAL) {
return false
}
if (context.isPowerSaveMode()) {
return false
}
val entryPoint = EntryPointAccessors.fromApplication(context, PrefetchCompanionEntryPoint::class.java)
return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled
}

View File

@@ -1,92 +0,0 @@
package org.koitharu.kotatsu.details.ui
import android.transition.TransitionManager
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.graphics.Insets
import androidx.core.view.setMargins
import androidx.core.view.updateLayoutParams
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.util.ext.getThemeDimensionPixelSize
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.databinding.ItemTipBinding
import com.google.android.material.R as materialR
class ButtonTip(
private val root: ViewGroup,
private val insetsDelegate: WindowInsetsDelegate,
private val viewModel: DetailsViewModel,
) : View.OnClickListener, WindowInsetsDelegate.WindowInsetsListener {
private var selfBinding = ItemTipBinding.inflate(LayoutInflater.from(root.context), root, false)
private val actionBarSize = root.context.getThemeDimensionPixelSize(materialR.attr.actionBarSize)
init {
selfBinding.textView.setText(R.string.details_button_tip)
selfBinding.imageViewIcon.setImageResource(R.drawable.ic_tap)
selfBinding.root.id = R.id.layout_tip
selfBinding.buttonClose.setOnClickListener(this)
}
override fun onClick(v: View?) {
remove()
}
override fun onWindowInsetsChanged(insets: Insets) {
if (root is CoordinatorLayout) {
selfBinding.root.updateLayoutParams<CoordinatorLayout.LayoutParams> {
bottomMargin = topMargin + insets.bottom + insets.top + actionBarSize
}
}
}
fun addToRoot() {
val lp: ViewGroup.LayoutParams = when (root) {
is CoordinatorLayout -> CoordinatorLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).apply {
// anchorId = R.id.layout_bottom
// anchorGravity = Gravity.TOP
gravity = Gravity.BOTTOM
setMargins(root.resources.getDimensionPixelOffset(R.dimen.margin_normal))
bottomMargin += actionBarSize
}
is ConstraintLayout -> ConstraintLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).apply {
width = root.resources.getDimensionPixelSize(R.dimen.m3_side_sheet_width)
setMargins(root.resources.getDimensionPixelOffset(R.dimen.margin_normal))
}
else -> ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
root.addView(selfBinding.root, lp)
if (root is ConstraintLayout) {
val cs = ConstraintSet()
cs.clone(root)
cs.connect(R.id.layout_tip, ConstraintSet.TOP, R.id.appbar, ConstraintSet.BOTTOM)
cs.connect(R.id.layout_tip, ConstraintSet.START, R.id.card_chapters, ConstraintSet.START)
cs.connect(R.id.layout_tip, ConstraintSet.END, R.id.card_chapters, ConstraintSet.END)
cs.applyTo(root)
}
insetsDelegate.addInsetsListener(this)
}
fun remove() {
if (root.context.isAnimationsEnabled) {
TransitionManager.beginDelayedTransition(root)
}
insetsDelegate.removeInsetsListener(this)
root.removeView(selfBinding.root)
viewModel.onButtonTipClosed()
}
}

View File

@@ -30,7 +30,6 @@ class ChaptersBottomSheetMediator(
}
override fun onActionModeStarted(mode: ActionMode) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
lock()
}

View File

@@ -18,7 +18,6 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
@@ -26,7 +25,7 @@ import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
import kotlin.math.roundToInt
@@ -67,9 +66,6 @@ class ChaptersFragment :
viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
binding.textViewHolder.isVisible = it
}
viewModel.onSelectChapter.observeEvent(viewLifecycleOwner) {
selectionController?.onItemLongClick(it)
}
}
override fun onDestroyView() {
@@ -83,11 +79,12 @@ class ChaptersFragment :
return
}
startActivity(
IntentBuilder(view.context)
.manga(viewModel.manga.value ?: return)
.state(ReaderState(item.chapter.id, 0, 0))
.build(),
scaleUpActivityOptionsOf(view),
ReaderActivity.newIntent(
context = view.context,
manga = viewModel.manga.value ?: return,
state = ReaderState(item.chapter.id, 0, 0),
),
scaleUpActivityOptionsOf(view).toBundle(),
)
}

View File

@@ -30,7 +30,7 @@ import kotlinx.coroutines.flow.filterNotNull
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog
@@ -43,7 +43,6 @@ import org.koitharu.kotatsu.core.util.ext.measureHeight
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat
import org.koitharu.kotatsu.core.util.ext.setNavigationIconSafe
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService
@@ -54,9 +53,8 @@ import org.koitharu.kotatsu.details.ui.model.MangaBranch
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet
import java.lang.ref.WeakReference
import javax.inject.Inject
import com.google.android.material.R as materialR
@@ -69,10 +67,9 @@ class DetailsActivity :
PopupMenu.OnMenuItemClickListener {
@Inject
lateinit var appShortcutManager: AppShortcutManager
lateinit var shortcutsUpdater: ShortcutsUpdater
private lateinit var viewBadge: ViewBadge
private var buttonTip: WeakReference<ButtonTip>? = null
private val viewModel: DetailsViewModel by viewModels()
private lateinit var chaptersMenuProvider: ChaptersMenuProvider
@@ -125,7 +122,6 @@ class DetailsActivity :
viewModel.onShowToast.observeEvent(this) {
makeSnackbar(getString(it), Snackbar.LENGTH_SHORT).show()
}
viewModel.onShowTip.observeEvent(this) { showTip() }
viewModel.historyInfo.observe(this, ::onHistoryChanged)
viewModel.selectedBranch.observe(this) {
viewBinding.toolbarChapters?.subtitle = it
@@ -148,7 +144,7 @@ class DetailsActivity :
activity = this,
viewModel = viewModel,
snackbarHost = viewBinding.containerChapters,
appShortcutManager = appShortcutManager,
shortcutsUpdater = shortcutsUpdater,
),
)
}
@@ -166,8 +162,6 @@ class DetailsActivity :
override fun onLongClick(v: View): Boolean = when (v.id) {
R.id.button_read -> {
buttonTip?.get()?.remove()
buttonTip = null
val menu = PopupMenu(v.context, v)
menu.inflate(R.menu.popup_read)
menu.setOnMenuItemClickListener(this)
@@ -212,7 +206,7 @@ class DetailsActivity :
}
if (isExpanded) {
toolbar.addMenuProvider(chaptersMenuProvider)
toolbar.setNavigationIconSafe(materialR.drawable.abc_ic_clear_material)
toolbar.setNavigationIcon(materialR.drawable.abc_ic_clear_material)
} else {
toolbar.removeMenuProvider(chaptersMenuProvider)
toolbar.navigationIcon = null
@@ -250,7 +244,7 @@ class DetailsActivity :
bottomMargin = insets.bottom + marginEnd
}
viewBinding.dragHandle?.updateLayoutParams<MarginLayoutParams> {
bottomMargin = insets.top
bottomMargin = insets.bottom
}
}
@@ -302,11 +296,12 @@ class DetailsActivity :
snackbar.show()
} else {
startActivity(
IntentBuilder(this)
.manga(manga)
.branch(viewModel.selectedBranchValue)
.incognito(isIncognitoMode)
.build(),
ReaderActivity.newIntent(
context = this,
manga = manga,
branch = viewModel.selectedBranchValue,
isIncognitoMode = isIncognitoMode,
),
)
if (isIncognitoMode) {
Toast.makeText(this, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
@@ -350,16 +345,8 @@ class DetailsActivity :
}
}
private fun showTip() {
val tip = ButtonTip(viewBinding.root as ViewGroup, insetsDelegate, viewModel)
tip.addToRoot()
buttonTip = WeakReference(tip)
}
companion object {
const val TIP_BUTTON = "btn_read"
fun newIntent(context: Context, manga: Manga): Intent {
return Intent(context, DetailsActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))

View File

@@ -15,7 +15,6 @@ import androidx.core.view.updatePadding
import androidx.fragment.app.activityViewModels
import coil.ImageLoader
import coil.request.ImageRequest
import coil.request.SuccessResult
import coil.util.CoilUtils
import com.google.android.material.chip.Chip
import dagger.hilt.android.AndroidEntryPoint
@@ -24,6 +23,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
import org.koitharu.kotatsu.core.model.countChaptersByBranch
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
@@ -45,7 +45,6 @@ import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration
import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -68,7 +67,7 @@ class DetailsFragment :
lateinit var coil: ImageLoader
@Inject
lateinit var tagHighlighter: ListExtraProvider
lateinit var tagHighlighter: MangaTagHighlighter
private val viewModel by activityViewModels<DetailsViewModel>()
@@ -97,8 +96,8 @@ class DetailsFragment :
override fun onItemClick(item: Bookmark, view: View) {
startActivity(
ReaderActivity.IntentBuilder(view.context).bookmark(item).incognito(true).build(),
scaleUpActivityOptionsOf(view),
ReaderActivity.newIntent(view.context, item),
scaleUpActivityOptionsOf(view).toBundle(),
)
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
}
@@ -258,7 +257,7 @@ class DetailsFragment :
manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl },
manga.source,
),
scaleUpActivityOptionsOf(v),
scaleUpActivityOptionsOf(v).toBundle(),
)
}
}
@@ -284,7 +283,7 @@ class DetailsFragment :
manga.tags.map { tag ->
ChipsView.ChipModel(
title = tag.title,
tint = tagHighlighter.getTagTint(tag),
tint = tagHighlighter.getTint(tag),
icon = 0,
data = tag,
isCheckable = false,
@@ -297,7 +296,7 @@ class DetailsFragment :
private fun loadCover(manga: Manga) {
val imageUrl = manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }
val lastResult = CoilUtils.result(requireViewBinding().imageViewCover)
if (lastResult is SuccessResult && lastResult.request.data == imageUrl) {
if (lastResult?.request?.data == imageUrl) {
return
}
val request = ImageRequest.Builder(context ?: return)

View File

@@ -15,12 +15,13 @@ import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
import org.koitharu.kotatsu.details.ui.model.MangaBranch
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
@@ -28,8 +29,8 @@ class DetailsMenuProvider(
private val activity: FragmentActivity,
private val viewModel: DetailsViewModel,
private val snackbarHost: View,
private val appShortcutManager: AppShortcutManager,
) : MenuProvider, OnListItemClickListener<DownloadOption> {
private val shortcutsUpdater: ShortcutsUpdater,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_details, menu)
@@ -43,7 +44,7 @@ class DetailsMenuProvider(
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
menu.findItem(R.id.action_favourite).setIcon(
if (viewModel.favouriteCategories.value) R.drawable.ic_heart else R.drawable.ic_heart_outline,
if (viewModel.favouriteCategories.value == true) R.drawable.ic_heart else R.drawable.ic_heart_outline,
)
}
@@ -79,7 +80,15 @@ class DetailsMenuProvider(
}
R.id.action_save -> {
DownloadDialogHelper(snackbarHost, viewModel).show(this)
viewModel.manga.value?.let {
val chaptersCount = it.chapters?.size ?: 0
val branches = viewModel.branches.value.orEmpty()
if (chaptersCount > 5 || branches.size > 1) {
showSaveConfirmation(it, chaptersCount, branches)
} else {
viewModel.download(null)
}
}
}
R.id.action_browser -> {
@@ -103,7 +112,7 @@ class DetailsMenuProvider(
R.id.action_shortcut -> {
viewModel.manga.value?.let {
activity.lifecycleScope.launch {
if (!appShortcutManager.requestPinShortcut(it)) {
if (!shortcutsUpdater.requestPinShortcut(it)) {
Snackbar.make(snackbarHost, R.string.operation_not_supported, Snackbar.LENGTH_SHORT)
.show()
}
@@ -116,16 +125,35 @@ class DetailsMenuProvider(
return true
}
override fun onItemClick(item: DownloadOption, view: View) {
val chaptersIds: Set<Long>? = when (item) {
is DownloadOption.WholeManga -> null
is DownloadOption.SelectionHint -> {
viewModel.startChaptersSelection()
return
private fun showSaveConfirmation(manga: Manga, chaptersCount: Int, branches: List<MangaBranch>) {
val dialogBuilder = MaterialAlertDialogBuilder(activity)
.setTitle(R.string.save_manga)
.setNegativeButton(android.R.string.cancel, null)
if (branches.size > 1) {
val items = Array(branches.size) { i -> branches[i].name.orEmpty() }
val currentBranch = branches.indexOfFirst { it.isSelected }
val checkedIndices = BooleanArray(branches.size) { i -> i == currentBranch }
dialogBuilder.setMultiChoiceItems(items, checkedIndices) { _, i, checked ->
checkedIndices[i] = checked
}.setPositiveButton(R.string.save) { _, _ ->
val selectedBranches = branches.mapIndexedNotNullTo(HashSet()) { i, b ->
if (checkedIndices[i]) b.name else null
}
val chaptersIds = manga.chapters?.mapNotNullToSet { c ->
if (c.branch in selectedBranches) c.id else null
}
viewModel.download(chaptersIds)
}
} else {
dialogBuilder.setMessage(
activity.getString(
R.string.large_manga_save_confirm,
activity.resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount),
),
).setPositiveButton(R.string.save) { _, _ ->
viewModel.download(null)
}
else -> item.chaptersIds
}
viewModel.download(chaptersIds)
dialogBuilder.show()
}
}

View File

@@ -18,8 +18,6 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
@@ -31,17 +29,14 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.combine
import org.koitharu.kotatsu.core.util.ext.computeSize
import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.core.util.ext.sanitize
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.details.domain.BranchComparator
import org.koitharu.kotatsu.details.domain.DetailsInteractor
@@ -74,7 +69,6 @@ class DetailsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
private val doubleMangaLoadUseCase: DoubleMangaLoadUseCase,
networkState: NetworkState,
) : BaseViewModel() {
private val intent = MangaIntent(savedStateHandle)
@@ -83,8 +77,6 @@ class DetailsViewModel @Inject constructor(
private var loadingJob: Job
val onShowToast = MutableEventFlow<Int>()
val onShowTip = MutableEventFlow<Unit>()
val onSelectChapter = MutableEventFlow<Long>()
val onDownloadStarted = MutableEventFlow<Unit>()
val manga = doubleManga.map { it?.any }
@@ -143,7 +135,7 @@ class DetailsViewModel @Inject constructor(
if (description.isNullOrEmpty()) {
emit(null)
} else {
emit(description.parseAsHtml().filterSpans().sanitize())
emit(description.parseAsHtml().filterSpans())
emit(description.parseAsHtml(imageGetter = imageGetter).filterSpans())
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), null)
@@ -180,9 +172,8 @@ class DetailsViewModel @Inject constructor(
selectedBranch,
newChaptersCount,
bookmarks,
networkState,
) { manga, history, branch, news, bookmarks, isOnline ->
mapChapters(manga?.remote?.takeIf { isOnline }, manga?.local, history, news, branch, bookmarks)
) { manga, history, branch, news, bookmarks ->
mapChapters(manga?.remote, manga?.local, history, news, branch, bookmarks)
},
isChaptersReversed,
chaptersQuery,
@@ -199,12 +190,6 @@ class DetailsViewModel @Inject constructor(
localStorageChanges
.collect { onDownloadComplete(it) }
}
launchJob(Dispatchers.Default) {
if (settings.isTipEnabled(DetailsActivity.TIP_BUTTON)) {
manga.filterNot { it?.chapters.isNullOrEmpty() }.first()
onShowTip.call(Unit)
}
}
}
fun reload() {
@@ -291,18 +276,6 @@ class DetailsViewModel @Inject constructor(
}
}
fun startChaptersSelection() {
val chapters = chapters.value
val chapter = chapters.find {
it.isUnread && !it.isDownloaded
} ?: chapters.firstOrNull() ?: return
onSelectChapter.call(chapter.chapter.id)
}
fun onButtonTipClosed() {
settings.closeTip(DetailsActivity.TIP_BUTTON)
}
private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
val result = doubleMangaLoadUseCase(intent)
val manga = result.requireAny()

View File

@@ -1,64 +0,0 @@
package org.koitharu.kotatsu.details.ui
import android.content.DialogInterface
import android.view.View
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
import org.koitharu.kotatsu.download.ui.dialog.downloadOptionAD
class DownloadDialogHelper(
private val host: View,
private val viewModel: DetailsViewModel,
) {
fun show(callback: OnListItemClickListener<DownloadOption>) {
val branch = viewModel.selectedBranchValue
val allChapters = viewModel.manga.value?.chapters ?: return
val branchChapters = viewModel.manga.value?.getChapters(branch).orEmpty()
val history = viewModel.history.value
val options = buildList {
add(DownloadOption.WholeManga(allChapters.ids()))
if (branch != null && branchChapters.isNotEmpty()) {
add(DownloadOption.AllChapters(branch, branchChapters.ids()))
}
if (history != null) {
val unreadChapters = branchChapters.takeLastWhile { it.id != history.chapterId }
if (unreadChapters.isNotEmpty() && unreadChapters.size < branchChapters.size) {
add(DownloadOption.AllUnreadChapters(unreadChapters.ids(), branch))
if (unreadChapters.size > 5) {
add(DownloadOption.NextUnreadChapters(unreadChapters.take(5).ids()))
if (unreadChapters.size > 10) {
add(DownloadOption.NextUnreadChapters(unreadChapters.take(10).ids()))
}
}
}
} else {
if (branchChapters.size > 5) {
add(DownloadOption.FirstChapters(branchChapters.take(5).ids()))
if (branchChapters.size > 10) {
add(DownloadOption.FirstChapters(branchChapters.take(10).ids()))
}
}
}
add(DownloadOption.SelectionHint())
}
var dialog: DialogInterface? = null
val listener = OnListItemClickListener<DownloadOption> { item, _ ->
callback.onItemClick(item, host)
dialog?.dismiss()
}
dialog = RecyclerViewAlertDialog.Builder<DownloadOption>(host.context)
.addAdapterDelegate(downloadOptionAD(listener))
.setCancelable(true)
.setTitle(R.string.download)
.setNegativeButton(android.R.string.cancel)
.setItems(options)
.create()
.also { it.show() }
}
}

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.details.ui
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
class MangaDetailsAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) {
override fun getItemCount() = 2
override fun createFragment(position: Int): Fragment = when (position) {
0 -> DetailsFragment()
1 -> ChaptersFragment()
else -> throw IndexOutOfBoundsException("No fragment for position $position")
}
}

View File

@@ -23,7 +23,6 @@ import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.sanitize
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetScrobblingBinding
@@ -110,7 +109,7 @@ class ScrobblingInfoSheet :
R.id.imageView_cover -> {
val coverUrl = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.coverUrl ?: return
val options = scaleUpActivityOptionsOf(v)
startActivity(ImageActivity.newIntent(v.context, coverUrl, null), options)
startActivity(ImageActivity.newIntent(v.context, coverUrl, null), options.toBundle())
}
}
}
@@ -123,7 +122,7 @@ class ScrobblingInfoSheet :
}
requireViewBinding().textViewTitle.text = scrobbling.title
requireViewBinding().ratingBar.rating = scrobbling.rating * requireViewBinding().ratingBar.numStars
requireViewBinding().textViewDescription.text = scrobbling.description?.sanitize()
requireViewBinding().textViewDescription.text = scrobbling.description
requireViewBinding().spinnerStatus.setSelection(scrobbling.status?.ordinal ?: -1)
requireViewBinding().imageViewLogo.contentDescription = getString(scrobbling.scrobbler.titleResId)
requireViewBinding().imageViewLogo.setImageResource(scrobbling.scrobbler.iconResId)

View File

@@ -1,99 +0,0 @@
package org.koitharu.kotatsu.download.ui.dialog
import android.content.res.Resources
import androidx.annotation.DrawableRes
import org.koitharu.kotatsu.R
import java.util.Locale
import com.google.android.material.R as materialR
sealed interface DownloadOption {
val chaptersIds: Set<Long>
@get:DrawableRes
val iconResId: Int
val chaptersCount: Int
get() = chaptersIds.size
fun getLabel(resources: Resources): CharSequence
class AllChapters(
val branch: String,
override val chaptersIds: Set<Long>,
) : DownloadOption {
override val iconResId = R.drawable.ic_select_group
override fun getLabel(resources: Resources): CharSequence {
return resources.getString(R.string.download_option_all_chapters, branch)
}
}
class WholeManga(
override val chaptersIds: Set<Long>,
) : DownloadOption {
override val iconResId = materialR.drawable.abc_ic_menu_selectall_mtrl_alpha
override fun getLabel(resources: Resources): CharSequence {
return resources.getString(R.string.download_option_whole_manga)
}
}
class FirstChapters(
override val chaptersIds: Set<Long>,
) : DownloadOption {
override val iconResId = R.drawable.ic_list_start
override fun getLabel(resources: Resources): CharSequence {
return resources.getString(
R.string.download_option_first_n_chapters,
resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount)
.lowercase(Locale.getDefault()),
)
}
}
class AllUnreadChapters(
override val chaptersIds: Set<Long>,
val branch: String?,
) : DownloadOption {
override val iconResId = R.drawable.ic_list_end
override fun getLabel(resources: Resources): CharSequence {
return if (branch == null) {
resources.getString(R.string.download_option_all_unread)
} else {
resources.getString(R.string.download_option_all_unread_b, branch)
}
}
}
class NextUnreadChapters(
override val chaptersIds: Set<Long>,
) : DownloadOption {
override val iconResId = R.drawable.ic_list_next
override fun getLabel(resources: Resources): CharSequence {
return resources.getString(
R.string.download_option_next_unread_n_chapters,
resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount)
.lowercase(Locale.getDefault()),
)
}
}
class SelectionHint : DownloadOption {
override val chaptersIds: Set<Long> = emptySet()
override val iconResId = R.drawable.ic_tap
override fun getLabel(resources: Resources): CharSequence {
return resources.getString(R.string.download_option_manual_selection)
}
}
}

View File

@@ -1,27 +0,0 @@
package org.koitharu.kotatsu.download.ui.dialog
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemDownloadOptionBinding
fun downloadOptionAD(
onClickListener: OnListItemClickListener<DownloadOption>,
) = adapterDelegateViewBinding<DownloadOption, DownloadOption, ItemDownloadOptionBinding>(
{ layoutInflater, parent -> ItemDownloadOptionBinding.inflate(layoutInflater, parent, false) },
) {
binding.root.setOnClickListener { v -> onClickListener.onItemClick(item, v) }
bind {
with(binding.root) {
title = item.getLabel(resources)
subtitle = if (item.chaptersCount == 0) null else resources.getQuantityString(
R.plurals.chapters,
item.chaptersCount,
item.chaptersCount,
)
setIconResource(item.iconResId)
}
}
}

View File

@@ -7,7 +7,6 @@ import android.view.MenuItem
import androidx.core.view.MenuProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.settings.SettingsActivity
class DownloadsMenuProvider(
private val context: Context,
@@ -24,10 +23,6 @@ class DownloadsMenuProvider(
R.id.action_resume -> viewModel.resumeAll()
R.id.action_cancel_all -> confirmCancelAll()
R.id.action_remove_completed -> confirmRemoveCompleted()
R.id.action_settings -> {
context.startActivity(SettingsActivity.newDownloadsSettingsIntent(context))
}
else -> return false
}
return true

View File

@@ -141,13 +141,11 @@ class DownloadsViewModel @Inject constructor(
fun remove(ids: Set<Long>) {
launchJob(Dispatchers.Default) {
val snapshot = works.value ?: return@launchJob
val uuids = HashSet<UUID>(ids.size)
for (work in snapshot) {
if (work.id.mostSignificantBits in ids) {
uuids.add(work.id)
workScheduler.delete(work.id)
}
}
workScheduler.delete(uuids)
onActionDone.call(ReversibleAction(R.string.downloads_removed, null))
}
}

View File

@@ -32,7 +32,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.util.UUID
import com.google.android.material.R as materialR

View File

@@ -45,7 +45,6 @@ import org.koitharu.kotatsu.core.util.WorkManagerHelper
import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.core.util.progress.TimeLeftEstimator
import org.koitharu.kotatsu.download.domain.DownloadState
@@ -61,6 +60,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.io.File
import java.util.UUID
import java.util.concurrent.TimeUnit
@@ -322,9 +322,9 @@ class DownloadWorker @AssistedInject constructor(
manga: Manga,
includedIds: LongArray?,
): List<MangaChapter> {
val chapters = checkNotNull(manga.chapters) {
val chapters = checkNotNull(manga.chapters?.toMutableList()) {
"Chapters list must not be null"
}.toMutableList()
}
if (includedIds != null) {
val chaptersIdsSet = includedIds.toMutableSet()
chapters.retainAll { x -> chaptersIdsSet.remove(x.id) }
@@ -399,13 +399,6 @@ class DownloadWorker @AssistedInject constructor(
WorkManagerHelper(workManager).deleteWork(id)
}
suspend fun delete(ids: Collection<UUID>) {
val wm = workManager
val helper = WorkManagerHelper(wm)
ids.forEach { id -> wm.cancelWorkById(id).await() }
helper.deleteWorks(ids)
}
suspend fun removeCompleted() {
val helper = WorkManagerHelper(workManager)
val finishedWorks = helper.getFinishedWorkInfosByTag(TAG)
@@ -413,7 +406,10 @@ class DownloadWorker @AssistedInject constructor(
}
suspend fun updateConstraints() {
val constraints = createConstraints()
val constraints = Constraints.Builder()
.setRequiresStorageNotLow(true)
.setRequiredNetworkType(if (settings.isDownloadsWiFiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
.build()
val helper = WorkManagerHelper(workManager)
val works = helper.getWorkInfosByTag(TAG)
for (work in works) {
@@ -422,7 +418,6 @@ class DownloadWorker @AssistedInject constructor(
}
val request = OneTimeWorkRequestBuilder<DownloadWorker>()
.setConstraints(constraints)
.addTag(TAG)
.setId(work.id)
.build()
helper.updateWork(request)
@@ -430,15 +425,15 @@ class DownloadWorker @AssistedInject constructor(
}
private suspend fun scheduleImpl(data: Collection<Data>) {
if (data.isEmpty()) {
return
}
val constraints = createConstraints()
val constraints = Constraints.Builder()
.setRequiresStorageNotLow(true)
.setRequiredNetworkType(if (settings.isDownloadsWiFiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
.build()
val requests = data.map { inputData ->
OneTimeWorkRequestBuilder<DownloadWorker>()
.setConstraints(constraints)
.addTag(TAG)
.keepResultsForAtLeast(30, TimeUnit.DAYS)
.keepResultsForAtLeast(7, TimeUnit.DAYS)
.setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.SECONDS)
.setInputData(inputData)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
@@ -446,10 +441,6 @@ class DownloadWorker @AssistedInject constructor(
}
workManager.enqueue(requests).await()
}
private fun createConstraints() = Constraints.Builder()
.setRequiredNetworkType(if (settings.isDownloadsWiFiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
.build()
}
private companion object {

View File

@@ -9,7 +9,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import javax.inject.Inject
class ExploreRepository @Inject constructor(

View File

@@ -112,7 +112,7 @@ class FavouriteCategoriesActivity :
}
val intent = FavouritesActivity.newIntent(this, item)
val options = scaleUpActivityOptionsOf(view)
startActivity(intent, options)
startActivity(intent, options.toBundle())
}
override fun onItemLongClick(item: FavouriteCategory, view: View): Boolean {

View File

@@ -14,7 +14,6 @@ import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetFavoriteCategoriesBinding
import org.koitharu.kotatsu.favourites.ui.categories.select.adapter.MangaCategoriesAdapter
@@ -82,6 +81,6 @@ class FavouriteCategoriesSheet :
)
},
)
}.showDistinct(fm, TAG)
}.show(fm, TAG)
}
}

View File

@@ -12,7 +12,6 @@ import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet.Companion.KEY_MANGA_LIST
import org.koitharu.kotatsu.favourites.ui.categories.select.model.CategoriesHeaderItem
@@ -26,7 +25,7 @@ class MangaCategoriesViewModel @Inject constructor(
private val favouritesRepository: FavouritesRepository,
) : BaseViewModel() {
private val manga = savedStateHandle.require<List<ParcelableManga>>(KEY_MANGA_LIST).map { it.manga }
private val manga = requireNotNull(savedStateHandle.get<List<ParcelableManga>>(KEY_MANGA_LIST)).map { it.manga }
private val header = CategoriesHeaderItem()
val content: StateFlow<List<ListModel>> = combine(

View File

@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.call
@@ -20,6 +21,8 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.ARG_CATEGORY_ID
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState
@@ -27,16 +30,19 @@ import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import javax.inject.Inject
@HiltViewModel
class FavouritesListViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repository: FavouritesRepository,
private val listExtraProvider: ListExtraProvider,
settings: AppSettings,
private val trackingRepository: TrackingRepository,
private val historyRepository: HistoryRepository,
private val settings: AppSettings,
private val tagHighlighter: MangaTagHighlighter,
downloadScheduler: DownloadWorker.Scheduler,
) : MangaListViewModel(settings, downloadScheduler) {
) : MangaListViewModel(settings, downloadScheduler), ListExtraProvider {
val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID
@@ -70,7 +76,7 @@ class FavouritesListViewModel @Inject constructor(
),
)
else -> list.toUi(mode, listExtraProvider)
else -> list.toUi(mode, this, tagHighlighter)
}
}.catch {
emit(listOf(it.toErrorState(canRetry = false)))
@@ -102,4 +108,20 @@ class FavouritesListViewModel @Inject constructor(
repository.setCategoryOrder(categoryId, order)
}
}
override suspend fun getCounter(mangaId: Long): Int {
return if (settings.isTrackerEnabled) {
trackingRepository.getNewChaptersCount(mangaId)
} else {
0
}
}
override suspend fun getProgress(mangaId: Long): Float {
return if (settings.isReadingIndicatorsEnabled) {
historyRepository.getProgress(mangaId)
} else {
PROGRESS_NONE
}
}
}

View File

@@ -22,7 +22,6 @@ import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.lifecycleScope
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.model.FilterItem
@@ -36,6 +35,7 @@ import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.text.Collator
import java.util.LinkedList
import java.util.Locale
@@ -49,7 +49,7 @@ class FilterCoordinator @Inject constructor(
dataRepository: MangaDataRepository,
private val searchRepository: MangaSearchRepository,
lifecycle: ViewModelLifecycle,
) : MangaFilter {
) : FilterOwner {
private val coroutineScope = lifecycle.lifecycleScope
private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE))

View File

@@ -19,8 +19,9 @@ import com.google.android.material.R as materialR
class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsView.OnChipClickListener {
private val filter: MangaFilter
get() = (requireActivity() as FilterOwner).filter
private val owner by lazy(LazyThreadSafetyMode.NONE) {
FilterOwner.from(requireActivity())
}
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding {
return FragmentFilterHeaderBinding.inflate(inflater, container, false)
@@ -29,7 +30,7 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
binding.chipsTags.onChipClickListener = this
filter.header.observe(viewLifecycleOwner, ::onDataChanged)
owner.header.observe(viewLifecycleOwner, ::onDataChanged)
}
override fun onWindowInsetsChanged(insets: Insets) = Unit
@@ -39,7 +40,7 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
if (tag == null) {
FilterSheetFragment.show(parentFragmentManager)
} else {
filter.onTagItemClick(FilterItem.Tag(tag, !chip.isChecked))
owner.onTagItemClick(FilterItem.Tag(tag, !chip.isChecked))
}
}

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