Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b46c00f2d0 | ||
|
|
9358617a3a | ||
|
|
ba9f31835f | ||
|
|
357308bfbb | ||
|
|
cab56209c1 | ||
|
|
e9cd32c870 | ||
|
|
357517ceac | ||
|
|
a57fcce72b | ||
|
|
2e2a818c05 | ||
|
|
b6f618101f | ||
|
|
0ce368751a | ||
|
|
1d28538893 | ||
|
|
4ad2f3f608 | ||
|
|
5301cc7f97 | ||
|
|
1290db4a7c | ||
|
|
1f1309d934 | ||
|
|
350f1521a6 | ||
|
|
cebce20bed | ||
|
|
e5b6947586 | ||
|
|
ac96c49b60 | ||
|
|
a4345a40bf | ||
|
|
f518acb8ee | ||
|
|
b39a51d497 | ||
|
|
8819d8b1ee | ||
|
|
05a502b89a | ||
|
|
c320e3c26a | ||
|
|
938849c31e | ||
|
|
95c243daa1 | ||
|
|
6ce6a02b56 | ||
|
|
e92e9fb393 | ||
|
|
f4186a2787 | ||
|
|
8b93b699d3 |
13
README.md
13
README.md
@@ -1,8 +1,8 @@
|
||||
# Kotatsu
|
||||
|
||||
Kotatsu is a free and open source manga reader for Android.
|
||||
Kotatsu is a free and open-source manga reader for Android with built-in online content sources.
|
||||
|
||||
   [](https://hosted.weblate.org/engage/kotatsu/) [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5)
|
||||
[](https://github.com/KotatsuApp/kotatsu-parsers)  [](https://hosted.weblate.org/engage/kotatsu/) [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
||||
|
||||
### Download
|
||||
|
||||
@@ -12,16 +12,15 @@ Kotatsu is a free and open source manga reader for Android.
|
||||
### Main Features
|
||||
|
||||
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers)
|
||||
* Search manga by name and genres
|
||||
* Search manga by name, genres, and more filters
|
||||
* Reading history and bookmarks
|
||||
* Favourites organized by user-defined categories
|
||||
* Favorites organized by user-defined categories
|
||||
* Downloading manga and reading it offline. Third-party CBZ archives also supported
|
||||
* Tablet-optimized Material You UI
|
||||
* Standard and Webtoon-optimized reader
|
||||
* Standard and Webtoon-optimized customizable reader
|
||||
* Notifications about new chapters with updates feed
|
||||
* 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
|
||||
* Password/fingerprint-protected access to the app
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 21
|
||||
targetSdk = 35
|
||||
versionCode = 673
|
||||
versionName = '7.6'
|
||||
versionCode = 675
|
||||
versionName = '7.6.2'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||
ksp {
|
||||
@@ -64,7 +64,7 @@ android {
|
||||
}
|
||||
lint {
|
||||
abortOnError true
|
||||
disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged', 'SetJavaScriptEnabled'
|
||||
disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled'
|
||||
}
|
||||
testOptions {
|
||||
unitTests.includeAndroidResources true
|
||||
@@ -82,8 +82,7 @@ afterEvaluate {
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
//noinspection GradleDependency
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:3cdd391410') {
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:6f7e1fcfb2') {
|
||||
exclude group: 'org.json', module: 'json'
|
||||
}
|
||||
|
||||
@@ -94,7 +93,7 @@ dependencies {
|
||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||
implementation 'androidx.core:core-ktx:1.13.1'
|
||||
implementation 'androidx.activity:activity-ktx:1.9.2'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.8.3'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.8.4'
|
||||
implementation 'androidx.transition:transition-ktx:1.5.1'
|
||||
implementation 'androidx.collection:collection-ktx:1.4.4'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6'
|
||||
@@ -137,7 +136,7 @@ dependencies {
|
||||
|
||||
implementation 'io.coil-kt:coil-base:2.7.0'
|
||||
implementation 'io.coil-kt:coil-svg:2.7.0'
|
||||
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:4ec7176962'
|
||||
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:e04098de68'
|
||||
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
||||
implementation 'io.noties.markwon:core:4.6.2'
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.StrictMode
|
||||
import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||
import org.koitharu.kotatsu.core.BaseApp
|
||||
@@ -18,30 +19,55 @@ class KotatsuApp : BaseApp() {
|
||||
}
|
||||
|
||||
private fun enableStrictMode() {
|
||||
val notifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
StrictModeNotifier(this)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
StrictMode.setThreadPolicy(
|
||||
StrictMode.ThreadPolicy.Builder()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build(),
|
||||
StrictMode.ThreadPolicy.Builder().apply {
|
||||
detectNetwork()
|
||||
detectDiskWrites()
|
||||
detectCustomSlowCalls()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectUnbufferedIo()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) detectResourceMismatches()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) detectExplicitGc()
|
||||
penaltyLog()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
||||
penaltyListener(notifier.executor, notifier)
|
||||
}
|
||||
}.build(),
|
||||
)
|
||||
StrictMode.setVmPolicy(
|
||||
StrictMode.VmPolicy.Builder()
|
||||
.detectAll()
|
||||
.setClassInstanceLimit(LocalMangaRepository::class.java, 1)
|
||||
.setClassInstanceLimit(PagesCache::class.java, 1)
|
||||
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
|
||||
.setClassInstanceLimit(PageLoader::class.java, 1)
|
||||
.setClassInstanceLimit(ReaderViewModel::class.java, 1)
|
||||
.penaltyLog()
|
||||
.build(),
|
||||
StrictMode.VmPolicy.Builder().apply {
|
||||
detectActivityLeaks()
|
||||
detectLeakedSqlLiteObjects()
|
||||
detectLeakedClosableObjects()
|
||||
detectLeakedRegistrationObjects()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectContentUriWithoutPermission()
|
||||
detectFileUriExposure()
|
||||
setClassInstanceLimit(LocalMangaRepository::class.java, 1)
|
||||
setClassInstanceLimit(PagesCache::class.java, 1)
|
||||
setClassInstanceLimit(MangaLoaderContext::class.java, 1)
|
||||
setClassInstanceLimit(PageLoader::class.java, 1)
|
||||
setClassInstanceLimit(ReaderViewModel::class.java, 1)
|
||||
penaltyLog()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
||||
penaltyListener(notifier.executor, notifier)
|
||||
}
|
||||
}.build()
|
||||
)
|
||||
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
|
||||
.penaltyDeath()
|
||||
.detectFragmentReuse()
|
||||
.detectWrongFragmentContainer()
|
||||
.detectRetainInstanceUsage()
|
||||
.detectSetUserVisibleHint()
|
||||
.detectFragmentTagUsage()
|
||||
.build()
|
||||
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder().apply {
|
||||
detectWrongFragmentContainer()
|
||||
detectFragmentTagUsage()
|
||||
detectRetainInstanceUsage()
|
||||
detectSetUserVisibleHint()
|
||||
detectWrongNestedHierarchy()
|
||||
detectFragmentReuse()
|
||||
penaltyLog()
|
||||
if (notifier != null) {
|
||||
penaltyListener(notifier)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package org.koitharu.kotatsu
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.Notification.BigTextStyle
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.StrictMode
|
||||
import android.os.strictmode.Violation
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asExecutor
|
||||
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||
import kotlin.math.absoluteValue
|
||||
import androidx.fragment.app.strictmode.Violation as FragmentViolation
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.P)
|
||||
class StrictModeNotifier(
|
||||
private val context: Context,
|
||||
) : StrictMode.OnVmViolationListener, StrictMode.OnThreadViolationListener, FragmentStrictMode.OnViolationListener {
|
||||
|
||||
val executor = Dispatchers.Default.asExecutor()
|
||||
|
||||
private val notificationManager by lazy {
|
||||
val nm = checkNotNull(context.getSystemService<NotificationManager>())
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.strict_mode),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
)
|
||||
nm.createNotificationChannel(channel)
|
||||
nm
|
||||
}
|
||||
|
||||
override fun onVmViolation(v: Violation) = showNotification(v)
|
||||
|
||||
override fun onThreadViolation(v: Violation) = showNotification(v)
|
||||
|
||||
override fun onViolation(violation: FragmentViolation) = showNotification(violation)
|
||||
|
||||
private fun showNotification(violation: Throwable) = Notification.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.setContentTitle(context.getString(R.string.strict_mode))
|
||||
.setContentText(violation.message)
|
||||
.setStyle(
|
||||
BigTextStyle()
|
||||
.setBigContentTitle(context.getString(R.string.strict_mode))
|
||||
.setSummaryText(violation.message)
|
||||
.bigText(violation.stackTraceToString()),
|
||||
).setShowWhen(true)
|
||||
.setContentIntent(ErrorReporterReceiver.getPendingIntent(context, violation))
|
||||
.setAutoCancel(true)
|
||||
.setGroup(CHANNEL_ID)
|
||||
.build()
|
||||
.let { notificationManager.notify(CHANNEL_ID, violation.hashCode().absoluteValue, it) }
|
||||
|
||||
private companion object {
|
||||
|
||||
const val CHANNEL_ID = "strict_mode"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">Kotatsu Dev</string>
|
||||
</resources>
|
||||
<string name="strict_mode">Strict mode</string>
|
||||
</resources>
|
||||
|
||||
@@ -165,13 +165,14 @@ class AutoFixService : CoroutineIntentService() {
|
||||
} else {
|
||||
error.getDisplayMessage(applicationContext.resources)
|
||||
},
|
||||
)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.addAction(
|
||||
).setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
ErrorReporterReceiver.getPendingIntent(applicationContext, error)?.let { reportIntent ->
|
||||
notification.addAction(
|
||||
R.drawable.ic_alert_outline,
|
||||
applicationContext.getString(R.string.report),
|
||||
ErrorReporterReceiver.getPendingIntent(applicationContext, error),
|
||||
reportIntent,
|
||||
)
|
||||
}
|
||||
}
|
||||
return notification.build()
|
||||
}
|
||||
|
||||
@@ -17,9 +17,6 @@ data class Bookmark(
|
||||
val percent: Float,
|
||||
) : ListModel {
|
||||
|
||||
val directImageUrl: String?
|
||||
get() = if (isImageUrlDirect()) imageUrl else null
|
||||
|
||||
val imageLoadData: Any
|
||||
get() = if (isImageUrlDirect()) imageUrl else toMangaPage()
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import okhttp3.internal.userAgent
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
@@ -45,7 +44,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
}
|
||||
val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
|
||||
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
|
||||
repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
|
||||
val userAgent = repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
|
||||
viewBinding.webView.configureForParser(userAgent)
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.koitharu.kotatsu.core
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.provider.SearchRecentSuggestions
|
||||
import android.text.Html
|
||||
|
||||
@@ -5,9 +5,11 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.BadParcelableException
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.report
|
||||
|
||||
class ErrorReporterReceiver : BroadcastReceiver() {
|
||||
@@ -22,12 +24,15 @@ class ErrorReporterReceiver : BroadcastReceiver() {
|
||||
private const val EXTRA_ERROR = "err"
|
||||
private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR"
|
||||
|
||||
fun getPendingIntent(context: Context, e: Throwable): PendingIntent {
|
||||
fun getPendingIntent(context: Context, e: Throwable): PendingIntent? = try {
|
||||
val intent = Intent(context, ErrorReporterReceiver::class.java)
|
||||
intent.setAction(ACTION_REPORT)
|
||||
intent.setData(Uri.parse("err://${e.hashCode()}"))
|
||||
intent.putExtra(EXTRA_ERROR, e)
|
||||
return checkNotNull(PendingIntentCompat.getBroadcast(context, 0, intent, 0, false))
|
||||
PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)
|
||||
} catch (e: BadParcelableException) {
|
||||
e.printStackTraceDebug()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
import okhttp3.Headers
|
||||
import okio.IOException
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
|
||||
@@ -3,5 +3,5 @@ package org.koitharu.kotatsu.core.exceptions
|
||||
import okio.IOException
|
||||
|
||||
class NoDataReceivedException(
|
||||
private val url: String,
|
||||
url: String,
|
||||
) : IOException("No data has been received from $url")
|
||||
|
||||
@@ -6,7 +6,6 @@ import androidx.activity.result.ActivityResultCaller
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.collection.MutableScatterMap
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
@@ -19,7 +18,8 @@ import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.findActivity
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.restartApplication
|
||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
@@ -124,15 +124,16 @@ class ExceptionResolver @AssistedInject constructor(
|
||||
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(R.string.ignore_ssl_errors)
|
||||
.setMessage(R.string.ignore_ssl_errors_summary)
|
||||
.setPositiveButton(R.string.apply) { _, _ ->
|
||||
buildAlertDialog(ctx) {
|
||||
setTitle(R.string.ignore_ssl_errors)
|
||||
setMessage(R.string.ignore_ssl_errors_summary)
|
||||
setPositiveButton(R.string.apply) { _, _ ->
|
||||
settings.isSSLBypassEnabled = true
|
||||
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_SHORT).show()
|
||||
ctx.findActivity()?.finishAffinity()
|
||||
}.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_LONG).show()
|
||||
ctx.restartApplication()
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
}.show()
|
||||
}
|
||||
|
||||
private inline fun Host.withContext(block: Context.() -> Unit) {
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
package org.koitharu.kotatsu.core.fs
|
||||
|
||||
import android.os.Build
|
||||
import org.koitharu.kotatsu.core.util.iterator.CloseableIterator
|
||||
import androidx.annotation.RequiresApi
|
||||
import org.koitharu.kotatsu.core.util.CloseableSequence
|
||||
import org.koitharu.kotatsu.core.util.iterator.MappingIterator
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
class FileSequence(private val dir: File) : Sequence<File> {
|
||||
sealed interface FileSequence : CloseableSequence<File> {
|
||||
|
||||
override fun iterator(): Iterator<File> {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val stream = Files.newDirectoryStream(dir.toPath())
|
||||
CloseableIterator(MappingIterator(stream.iterator(), Path::toFile), stream)
|
||||
} else {
|
||||
dir.listFiles().orEmpty().iterator()
|
||||
}
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
class StreamImpl(dir: File) : FileSequence {
|
||||
|
||||
private val stream = Files.newDirectoryStream(dir.toPath())
|
||||
|
||||
override fun iterator(): Iterator<File> = MappingIterator(stream.iterator(), Path::toFile)
|
||||
|
||||
override fun close() = stream.close()
|
||||
}
|
||||
|
||||
class ListImpl(dir: File) : FileSequence {
|
||||
|
||||
private val list = dir.listFiles().orEmpty()
|
||||
|
||||
override fun iterator(): Iterator<File> = list.iterator()
|
||||
|
||||
override fun close() = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.logs
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
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 java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
|
||||
private const val DIR = "logs"
|
||||
private const val FLUSH_DELAY = 2_000L
|
||||
private const val MAX_SIZE_BYTES = 1024 * 1024 // 1 MB
|
||||
|
||||
class FileLogger(
|
||||
context: Context,
|
||||
private val settings: AppSettings,
|
||||
name: String,
|
||||
) {
|
||||
|
||||
val file by lazy {
|
||||
val dir = context.getExternalFilesDir(DIR) ?: context.filesDir.subdir(DIR)
|
||||
File(dir, "$name.log")
|
||||
}
|
||||
val isEnabled: Boolean
|
||||
get() = settings.isLoggingEnabled
|
||||
private val dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT).withLocale(Locale.ROOT)
|
||||
private val buffer = ConcurrentLinkedQueue<String>()
|
||||
private val mutex = Mutex()
|
||||
private var flushJob: Job? = null
|
||||
|
||||
fun log(message: String, e: Throwable? = null) {
|
||||
if (!isEnabled) {
|
||||
return
|
||||
}
|
||||
val text = buildString {
|
||||
append(dateTimeFormatter.format(LocalDateTime.now()))
|
||||
append(": ")
|
||||
if (e != null) {
|
||||
append("E!")
|
||||
}
|
||||
append(message)
|
||||
if (e != null) {
|
||||
append(' ')
|
||||
append(e.stackTraceToString())
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
buffer.add(text)
|
||||
postFlush()
|
||||
}
|
||||
|
||||
inline fun log(messageProducer: () -> String) {
|
||||
if (isEnabled) {
|
||||
log(messageProducer())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun flush() {
|
||||
if (!isEnabled) {
|
||||
return
|
||||
}
|
||||
flushJob?.cancelAndJoin()
|
||||
flushImpl()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun flushBlocking() {
|
||||
if (!isEnabled) {
|
||||
return
|
||||
}
|
||||
runBlockingSafe { flushJob?.cancelAndJoin() }
|
||||
runBlockingSafe { flushImpl() }
|
||||
}
|
||||
|
||||
private fun postFlush() {
|
||||
if (flushJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
flushJob = processLifecycleScope.launch(Dispatchers.Default) {
|
||||
delay(FLUSH_DELAY)
|
||||
runCatchingCancellable {
|
||||
flushImpl()
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun flushImpl() = withContext(NonCancellable) {
|
||||
mutex.withLock {
|
||||
if (buffer.isEmpty()) {
|
||||
return@withContext
|
||||
}
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
if (file.length() > MAX_SIZE_BYTES) {
|
||||
rotate()
|
||||
}
|
||||
FileOutputStream(file, true).use {
|
||||
while (true) {
|
||||
val message = buffer.poll() ?: break
|
||||
it.write(message.toByteArray())
|
||||
it.write('\n'.code)
|
||||
}
|
||||
it.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun rotate() {
|
||||
val length = file.length()
|
||||
val bakFile = File(file.parentFile, file.name + ".bak")
|
||||
file.renameTo(bakFile)
|
||||
bakFile.inputStream().use { input ->
|
||||
input.skip(length - MAX_SIZE_BYTES / 2)
|
||||
file.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
output.flush()
|
||||
}
|
||||
}
|
||||
bakFile.delete()
|
||||
}
|
||||
|
||||
private inline fun runBlockingSafe(crossinline block: suspend () -> Unit) = try {
|
||||
runBlocking(NonCancellable) { block() }
|
||||
} catch (_: InterruptedException) {
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.logs
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class TrackerLogger
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class SyncLogger
|
||||
@@ -1,40 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.logs
|
||||
|
||||
import android.content.Context
|
||||
import androidx.collection.arraySetOf
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.ElementsIntoSet
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object LoggersModule {
|
||||
|
||||
@Provides
|
||||
@TrackerLogger
|
||||
fun provideTrackerLogger(
|
||||
@ApplicationContext context: Context,
|
||||
settings: AppSettings,
|
||||
) = FileLogger(context, settings, "tracker")
|
||||
|
||||
@Provides
|
||||
@SyncLogger
|
||||
fun provideSyncLogger(
|
||||
@ApplicationContext context: Context,
|
||||
settings: AppSettings,
|
||||
) = FileLogger(context, settings, "sync")
|
||||
|
||||
@Provides
|
||||
@ElementsIntoSet
|
||||
fun provideAllLoggers(
|
||||
@TrackerLogger trackerLogger: FileLogger,
|
||||
@SyncLogger syncLogger: FileLogger,
|
||||
): Set<@JvmSuppressWildcards FileLogger> = arraySetOf(
|
||||
trackerLogger,
|
||||
syncLogger,
|
||||
)
|
||||
}
|
||||
@@ -74,7 +74,7 @@ interface NetworkModule {
|
||||
if (settings.isSSLBypassEnabled) {
|
||||
disableCertificateVerification()
|
||||
} else {
|
||||
installExtraCertsificates(contextProvider.get())
|
||||
installExtraCertificates(contextProvider.get())
|
||||
}
|
||||
cache(cache)
|
||||
addInterceptor(GZipInterceptor())
|
||||
|
||||
@@ -35,7 +35,7 @@ fun OkHttpClient.Builder.disableCertificateVerification() = also { builder ->
|
||||
}
|
||||
}
|
||||
|
||||
fun OkHttpClient.Builder.installExtraCertsificates(context: Context) = also { builder ->
|
||||
fun OkHttpClient.Builder.installExtraCertificates(context: Context) = also { builder ->
|
||||
val certificatesBuilder = HandshakeCertificates.Builder()
|
||||
.addPlatformTrustedCertificates()
|
||||
val assets = context.assets.list("").orEmpty()
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.util.Base64
|
||||
import android.webkit.WebView
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -30,6 +31,7 @@ import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import org.koitharu.kotatsu.parsers.util.map
|
||||
import org.koitharu.kotatsu.parsers.util.mimeType
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
@@ -86,7 +88,7 @@ class MangaLoaderContextImpl @Inject constructor(
|
||||
result.compressTo(it.outputStream())
|
||||
}.asResponseBody("image/jpeg".toMediaType())
|
||||
}
|
||||
} ?: error("Cannot decode bitmap")
|
||||
} ?: throw ImageDecodeException(response.request.url.toString(), response.mimeType)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||
import org.koitharu.kotatsu.parsers.MangaParser
|
||||
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.Favicons
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
@@ -17,13 +16,10 @@ import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import org.koitharu.kotatsu.parsers.util.domain
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.util.Locale
|
||||
|
||||
class ParserMangaRepository(
|
||||
private val parser: MangaParser,
|
||||
|
||||
@@ -17,7 +17,7 @@ import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import java.util.EnumSet
|
||||
|
||||
class ExternalMangaRepository(
|
||||
private val contentResolver: ContentResolver,
|
||||
contentResolver: ContentResolver,
|
||||
override val source: ExternalMangaSource,
|
||||
cache: MemoryContentCache,
|
||||
) : CachingMangaRepository(cache) {
|
||||
|
||||
@@ -239,9 +239,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
}
|
||||
} ?: EnumSet.allOf(SearchSuggestionType::class.java)
|
||||
|
||||
val isLoggingEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false)
|
||||
|
||||
var isBiometricProtectionEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_PROTECT_APP_BIOMETRIC, true)
|
||||
set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) }
|
||||
@@ -665,7 +662,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_WEBTOON_ZOOM_OUT = "webtoon_zoom_out"
|
||||
const val KEY_PREFETCH_CONTENT = "prefetch_content"
|
||||
const val KEY_APP_LOCALE = "app_locale"
|
||||
const val KEY_LOGGING_ENABLED = "logging"
|
||||
const val KEY_SOURCES_GRID = "sources_grid"
|
||||
const val KEY_UPDATES_UNSTABLE = "updates_unstable"
|
||||
const val KEY_TIPS_CLOSED = "tips_closed"
|
||||
@@ -709,9 +705,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_APP_VERSION = "app_version"
|
||||
const val KEY_IGNORE_DOZE = "ignore_dose"
|
||||
const val KEY_TRACKER_DEBUG = "tracker_debug"
|
||||
const val KEY_LOGS_SHARE = "logs_share"
|
||||
const val KEY_APP_UPDATE = "app_update"
|
||||
const val KEY_APP_TRANSLATION = "about_app_translation"
|
||||
const val KEY_LINK_WEBLATE = "about_app_translation"
|
||||
const val KEY_LINK_TELEGRAM = "about_telegram"
|
||||
const val KEY_LINK_GITHUB = "about_github"
|
||||
const val KEY_LINK_MANUAL = "about_help"
|
||||
const val PROXY_TEST = "proxy_test"
|
||||
|
||||
// old keys are for migration only
|
||||
|
||||
@@ -80,11 +80,11 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
(activity as? SettingsActivity)?.setSectionTitle(title)
|
||||
}
|
||||
|
||||
protected fun startActivitySafe(intent: Intent) {
|
||||
try {
|
||||
startActivity(intent)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
protected fun startActivitySafe(intent: Intent): Boolean = try {
|
||||
startActivity(intent)
|
||||
true
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import java.util.Collections
|
||||
import org.koitharu.kotatsu.parsers.util.move
|
||||
import java.util.LinkedList
|
||||
|
||||
open class ReorderableListAdapter<T : ListModel> : ListDelegationAdapter<List<T>>(), FlowCollector<List<T>?> {
|
||||
@@ -28,13 +28,17 @@ open class ReorderableListAdapter<T : ListModel> : ListDelegationAdapter<List<T>
|
||||
listListeners.forEach { it.onCurrentListChanged(oldList, newList) }
|
||||
}
|
||||
|
||||
@Deprecated("Use emit() to dispatch list updates", level = DeprecationLevel.ERROR)
|
||||
override fun setItems(items: List<T>?) {
|
||||
super.setItems(items)
|
||||
}
|
||||
@Deprecated(
|
||||
message = "Use emit() to dispatch list updates",
|
||||
level = DeprecationLevel.ERROR,
|
||||
replaceWith = ReplaceWith("emit(items)"),
|
||||
)
|
||||
override fun setItems(items: List<T>?) = super.setItems(items)
|
||||
|
||||
fun reorderItems(oldPos: Int, newPos: Int) {
|
||||
Collections.swap(items ?: return, oldPos, newPos)
|
||||
val reordered = items?.toMutableList() ?: return
|
||||
reordered.move(oldPos, newPos)
|
||||
super.setItems(reordered)
|
||||
notifyItemMoved(oldPos, newPos)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.koitharu.kotatsu.core.ui.dialog
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.UiContext
|
||||
import org.koitharu.kotatsu.R
|
||||
|
||||
object CommonAlertDialogs {
|
||||
|
||||
fun showDownloadConfirmation(
|
||||
@UiContext context: Context,
|
||||
onConfirmed: (startPaused: Boolean) -> Unit,
|
||||
) = buildAlertDialog(context, isCentered = true) {
|
||||
var startPaused = false
|
||||
setTitle(R.string.save_manga)
|
||||
setIcon(R.drawable.ic_download)
|
||||
setMessage(R.string.save_manga_confirm)
|
||||
setCheckbox(R.string.start_download, true) { _, isChecked ->
|
||||
startPaused = !isChecked
|
||||
}
|
||||
setPositiveButton(R.string.save) { _, _ ->
|
||||
onConfirmed(startPaused)
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
}.show()
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package org.koitharu.kotatsu.core.util
|
||||
|
||||
interface CloseableSequence<T> : Sequence<T>, AutoCloseable
|
||||
@@ -2,12 +2,10 @@ package org.koitharu.kotatsu.core.util
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.logs.FileLogger
|
||||
import org.koitharu.kotatsu.core.model.appUrl
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.io.File
|
||||
@@ -84,25 +82,4 @@ class ShareHelper(private val context: Context) {
|
||||
.setChooserTitle(R.string.share)
|
||||
.startChooser()
|
||||
}
|
||||
|
||||
fun shareLogs(loggers: Collection<FileLogger>) {
|
||||
val intentBuilder = ShareCompat.IntentBuilder(context)
|
||||
.setType(TYPE_TEXT)
|
||||
var hasLogs = false
|
||||
for (logger in loggers) {
|
||||
val logFile = logger.file
|
||||
if (!logFile.exists()) {
|
||||
continue
|
||||
}
|
||||
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", logFile)
|
||||
intentBuilder.addStream(uri)
|
||||
hasLogs = true
|
||||
}
|
||||
if (hasLogs) {
|
||||
intentBuilder.setChooserTitle(R.string.share_logs)
|
||||
intentBuilder.startChooser()
|
||||
} else {
|
||||
Toast.makeText(context, R.string.nothing_here, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@ import android.app.ActivityManager
|
||||
import android.app.ActivityManager.MemoryInfo
|
||||
import android.app.ActivityOptions
|
||||
import android.app.LocaleConfig
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Context.ACTIVITY_SERVICE
|
||||
import android.content.Context.POWER_SERVICE
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.content.OperationApplicationException
|
||||
import android.content.SharedPreferences
|
||||
import android.content.SyncResult
|
||||
@@ -33,6 +35,7 @@ import androidx.annotation.IntegerRes
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.app.AppCompatDialog
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -61,6 +64,7 @@ import okio.use
|
||||
import org.json.JSONException
|
||||
import org.jsoup.internal.StringUtil.StringJoiner
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.main.ui.MainActivity
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
@@ -274,3 +278,10 @@ fun WebView.configureForParser(userAgentOverride: String?) = with(settings) {
|
||||
userAgentString = userAgentOverride
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.restartApplication() {
|
||||
val activity = findActivity()
|
||||
val intent = Intent.makeRestartActivityTask(ComponentName(this, MainActivity::class.java))
|
||||
startActivity(intent)
|
||||
activity?.finishAndRemoveTask()
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ fun LongSet.toLongArray(): LongArray {
|
||||
return result
|
||||
}
|
||||
|
||||
fun LongSet.toSet(): Set<Long> = toCollection(ArraySet<Long>(size))
|
||||
fun LongSet.toSet(): Set<Long> = toCollection(ArraySet(size))
|
||||
|
||||
fun <R : MutableCollection<Long>> LongSet.toCollection(out: R): R = out.also { result ->
|
||||
forEach(result::add)
|
||||
|
||||
@@ -15,7 +15,6 @@ import org.jetbrains.annotations.Blocking
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.fs.FileSequence
|
||||
import java.io.File
|
||||
import java.io.FileFilter
|
||||
import java.io.InputStream
|
||||
import java.nio.file.attribute.BasicFileAttributes
|
||||
import java.util.zip.ZipEntry
|
||||
@@ -87,9 +86,13 @@ suspend fun File.computeSize(): Long = runInterruptible(Dispatchers.IO) {
|
||||
walkCompat(includeDirectories = false).sumOf { it.length() }
|
||||
}
|
||||
|
||||
fun File.children() = FileSequence(this)
|
||||
inline fun <R> File.withChildren(block: (children: Sequence<File>) -> R): R = FileSequence(this).use(block)
|
||||
|
||||
fun Sequence<File>.filterWith(filter: FileFilter): Sequence<File> = filter { f -> filter.accept(f) }
|
||||
fun FileSequence(dir: File): FileSequence = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
FileSequence.StreamImpl(dir)
|
||||
} else {
|
||||
FileSequence.ListImpl(dir)
|
||||
}
|
||||
|
||||
val File.creationTime
|
||||
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
||||
@@ -17,7 +17,6 @@ import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.transform
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.flow.transformWhile
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@@ -29,7 +29,7 @@ fun Context.getThemeColor(
|
||||
@Px
|
||||
fun Context.getThemeDimensionPixelSize(
|
||||
@AttrRes resId: Int,
|
||||
@ColorInt fallback: Int = 0,
|
||||
@Px fallback: Int = 0,
|
||||
) = obtainStyledAttributes(intArrayOf(resId)).use {
|
||||
it.getDimensionPixelSize(0, fallback)
|
||||
}
|
||||
@@ -37,7 +37,7 @@ fun Context.getThemeDimensionPixelSize(
|
||||
@Px
|
||||
fun Context.getThemeDimensionPixelOffset(
|
||||
@AttrRes resId: Int,
|
||||
@ColorInt fallback: Int = 0,
|
||||
@Px fallback: Int = 0,
|
||||
) = obtainStyledAttributes(intArrayOf(resId)).use {
|
||||
it.getDimensionPixelOffset(0, fallback)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.ActivityNotFoundException
|
||||
import android.content.res.Resources
|
||||
import androidx.annotation.DrawableRes
|
||||
import coil.network.HttpException
|
||||
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
||||
import okio.FileNotFoundException
|
||||
import okio.IOException
|
||||
import okio.ProtocolException
|
||||
@@ -80,6 +81,11 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
|
||||
is UnknownHostException,
|
||||
is SocketTimeoutException -> resources.getString(R.string.network_error)
|
||||
|
||||
is ImageDecodeException -> resources.getString(
|
||||
R.string.error_image_format,
|
||||
format.ifNullOrEmpty { resources.getString(R.string.unknown) },
|
||||
)
|
||||
|
||||
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
|
||||
is IncompatiblePluginException -> resources.getString(R.string.plugin_incompatible)
|
||||
is WrongPasswordException -> resources.getString(R.string.wrong_password)
|
||||
@@ -107,6 +113,18 @@ fun Throwable.getDisplayIcon() = when (this) {
|
||||
else -> R.drawable.ic_error_large
|
||||
}
|
||||
|
||||
fun Throwable.getCauseUrl(): String? = when (this) {
|
||||
is ParseException -> url
|
||||
is NotFoundException -> url
|
||||
is TooManyRequestExceptions -> url
|
||||
is CaughtException -> cause?.getCauseUrl()
|
||||
is CloudFlareBlockedException -> url
|
||||
is CloudFlareProtectedException -> url
|
||||
is HttpStatusException -> url
|
||||
is HttpException -> response.request.url.toString()
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String? = when (statusCode) {
|
||||
404 -> resources.getString(R.string.not_found_404)
|
||||
in 500..599 -> resources.getString(R.string.server_error, statusCode)
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.util.iterator
|
||||
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okio.Closeable
|
||||
|
||||
class CloseableIterator<T>(
|
||||
private val upstream: Iterator<T>,
|
||||
private val closeable: Closeable,
|
||||
) : Iterator<T>, Closeable {
|
||||
|
||||
private var isClosed = false
|
||||
|
||||
override fun hasNext(): Boolean {
|
||||
val result = upstream.hasNext()
|
||||
if (!result) {
|
||||
close()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override fun next(): T {
|
||||
try {
|
||||
return upstream.next()
|
||||
} catch (e: NoSuchElementException) {
|
||||
close()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (!isClosed) {
|
||||
closeable.closeQuietly()
|
||||
isClosed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,10 @@ package org.koitharu.kotatsu.core.zip
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.collection.ArraySet
|
||||
import okio.Closeable
|
||||
import org.koitharu.kotatsu.core.util.ext.children
|
||||
import org.koitharu.kotatsu.core.util.ext.withChildren
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.zip.Deflater
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
@@ -17,7 +18,7 @@ class ZipOutput(
|
||||
) : Closeable {
|
||||
|
||||
private val entryNames = ArraySet<String>()
|
||||
private var isClosed = false
|
||||
private val isClosed = AtomicBoolean(false)
|
||||
private val output = ZipOutputStream(file.outputStream()).apply {
|
||||
setLevel(compressionLevel)
|
||||
}
|
||||
@@ -72,9 +73,8 @@ class ZipOutput(
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (!isClosed) {
|
||||
if (isClosed.compareAndSet(false, true)) {
|
||||
output.close()
|
||||
isClosed = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,8 +91,10 @@ class ZipOutput(
|
||||
}
|
||||
putNextEntry(entry)
|
||||
closeEntry()
|
||||
fileToZip.children().forEach { childFile ->
|
||||
appendFile(childFile, "$name/${childFile.name}")
|
||||
fileToZip.withChildren { children ->
|
||||
children.forEach { childFile ->
|
||||
appendFile(childFile, "$name/${childFile.name}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FileInputStream(fileToZip).use { fis ->
|
||||
|
||||
@@ -12,7 +12,7 @@ import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
class ChaptersAdapter(
|
||||
private val onItemClickListener: OnListItemClickListener<ChapterListItem>,
|
||||
onItemClickListener: OnListItemClickListener<ChapterListItem>,
|
||||
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
|
||||
|
||||
private var hasVolumes = false
|
||||
|
||||
@@ -168,6 +168,7 @@ abstract class ChaptersPagesViewModel(
|
||||
downloadScheduler.schedule(
|
||||
manga = requireManga(),
|
||||
chaptersIds = chaptersIds,
|
||||
isPaused = false,
|
||||
isSilent = false,
|
||||
)
|
||||
onDownloadStarted.call(Unit)
|
||||
|
||||
@@ -29,6 +29,7 @@ import org.koitharu.kotatsu.local.data.isZipUri
|
||||
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.util.mimeType
|
||||
import org.koitharu.kotatsu.parsers.util.requireBody
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import java.util.zip.ZipFile
|
||||
import javax.inject.Inject
|
||||
@@ -98,9 +99,7 @@ class MangaPageFetcher(
|
||||
if (!response.isSuccessful) {
|
||||
throw HttpException(response)
|
||||
}
|
||||
val body = checkNotNull(response.body) {
|
||||
"Null response"
|
||||
}
|
||||
val body = response.requireBody()
|
||||
val mimeType = response.mimeType
|
||||
val file = body.use {
|
||||
pagesCache.put(pageUrl, it.source())
|
||||
|
||||
@@ -124,6 +124,7 @@ fun downloadItemAD(
|
||||
binding.buttonCancel.isVisible = true
|
||||
binding.buttonResume.isVisible = false
|
||||
binding.buttonSkip.isVisible = false
|
||||
binding.buttonSkipAll.isVisible = false
|
||||
binding.buttonPause.isVisible = false
|
||||
}
|
||||
|
||||
@@ -147,6 +148,7 @@ fun downloadItemAD(
|
||||
binding.buttonResume.isVisible = item.isPaused
|
||||
binding.buttonResume.setText(if (item.error == null) R.string.resume else R.string.retry)
|
||||
binding.buttonSkip.isVisible = item.isPaused && item.error != null
|
||||
binding.buttonSkipAll.isVisible = item.isPaused && item.error != null
|
||||
binding.buttonPause.isVisible = item.canPause
|
||||
}
|
||||
|
||||
@@ -169,6 +171,7 @@ fun downloadItemAD(
|
||||
binding.buttonCancel.isVisible = false
|
||||
binding.buttonResume.isVisible = false
|
||||
binding.buttonSkip.isVisible = false
|
||||
binding.buttonSkipAll.isVisible = false
|
||||
binding.buttonPause.isVisible = false
|
||||
}
|
||||
|
||||
@@ -182,6 +185,7 @@ fun downloadItemAD(
|
||||
binding.buttonCancel.isVisible = false
|
||||
binding.buttonResume.isVisible = false
|
||||
binding.buttonSkip.isVisible = false
|
||||
binding.buttonSkipAll.isVisible = false
|
||||
binding.buttonPause.isVisible = false
|
||||
}
|
||||
|
||||
@@ -195,6 +199,7 @@ fun downloadItemAD(
|
||||
binding.buttonCancel.isVisible = false
|
||||
binding.buttonResume.isVisible = false
|
||||
binding.buttonSkip.isVisible = false
|
||||
binding.buttonSkipAll.isVisible = false
|
||||
binding.buttonPause.isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,13 +213,15 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
||||
builder.setWhen(System.currentTimeMillis())
|
||||
builder.setStyle(NotificationCompat.BigTextStyle().bigText(state.errorMessage))
|
||||
if (state.error.isReportable()) {
|
||||
builder.addAction(
|
||||
NotificationCompat.Action(
|
||||
0,
|
||||
context.getString(R.string.report),
|
||||
ErrorReporterReceiver.getPendingIntent(context, state.error),
|
||||
),
|
||||
)
|
||||
ErrorReporterReceiver.getPendingIntent(context, state.error)?.let { reportIntent ->
|
||||
builder.addAction(
|
||||
NotificationCompat.Action(
|
||||
0,
|
||||
context.getString(R.string.report),
|
||||
reportIntent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,6 @@ import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.local.data.TempFileFilter
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
|
||||
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
|
||||
import org.koitharu.kotatsu.local.domain.MangaLock
|
||||
@@ -81,6 +80,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.requireBody
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import java.io.File
|
||||
@@ -128,7 +128,11 @@ class DownloadWorker @AssistedInject constructor(
|
||||
val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
|
||||
val downloadedIds = getDoneChapters(manga)
|
||||
return try {
|
||||
withContext(PausingHandle()) {
|
||||
val pausingHandle = PausingHandle()
|
||||
if (inputData.getBoolean(START_PAUSED, false)) {
|
||||
pausingHandle.pause()
|
||||
}
|
||||
withContext(pausingHandle) {
|
||||
downloadMangaImpl(manga, chaptersIds, downloadedIds)
|
||||
}
|
||||
Result.success(currentState.toWorkData())
|
||||
@@ -359,7 +363,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
.use { response ->
|
||||
val file = File(destination, UUID.randomUUID().toString() + ".tmp")
|
||||
try {
|
||||
checkNotNull(response.body).use { body ->
|
||||
response.requireBody().use { body ->
|
||||
file.sink(append = false).buffer().use {
|
||||
it.writeAllCancellable(body.source())
|
||||
}
|
||||
@@ -431,10 +435,16 @@ class DownloadWorker @AssistedInject constructor(
|
||||
private val settings: AppSettings,
|
||||
) {
|
||||
|
||||
suspend fun schedule(manga: Manga, chaptersIds: Collection<Long>?, isSilent: Boolean) {
|
||||
suspend fun schedule(
|
||||
manga: Manga,
|
||||
chaptersIds: Collection<Long>?,
|
||||
isPaused: Boolean,
|
||||
isSilent: Boolean,
|
||||
) {
|
||||
dataRepository.storeManga(manga)
|
||||
val data = Data.Builder()
|
||||
.putLong(MANGA_ID, manga.id)
|
||||
.putBoolean(START_PAUSED, isPaused)
|
||||
.putBoolean(IS_SILENT, isSilent)
|
||||
if (!chaptersIds.isNullOrEmpty()) {
|
||||
data.putLongArray(CHAPTERS_IDS, chaptersIds.toLongArray())
|
||||
@@ -442,11 +452,15 @@ class DownloadWorker @AssistedInject constructor(
|
||||
scheduleImpl(listOf(data.build()))
|
||||
}
|
||||
|
||||
suspend fun schedule(manga: Collection<Manga>) {
|
||||
suspend fun schedule(
|
||||
manga: Collection<Manga>,
|
||||
isPaused: Boolean,
|
||||
) {
|
||||
val data = manga.map {
|
||||
dataRepository.storeManga(it)
|
||||
Data.Builder()
|
||||
.putLong(MANGA_ID, it.id)
|
||||
.putBoolean(START_PAUSED, isPaused)
|
||||
.build()
|
||||
}
|
||||
scheduleImpl(data)
|
||||
@@ -556,6 +570,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
const val MANGA_ID = "manga_id"
|
||||
const val CHAPTERS_IDS = "chapters"
|
||||
const val IS_SILENT = "silent"
|
||||
const val START_PAUSED = "paused"
|
||||
const val TAG = "download"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,22 +10,27 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
||||
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.list.ui.model.toErrorFooter
|
||||
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
|
||||
@HiltViewModel(assistedFactory = TagsCatalogViewModel.Factory::class)
|
||||
class TagsCatalogViewModel @AssistedInject constructor(
|
||||
@Assisted private val filter: FilterCoordinator,
|
||||
@Assisted private val isExcluded: Boolean,
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val searchQuery = MutableStateFlow("")
|
||||
@@ -33,23 +38,13 @@ class TagsCatalogViewModel @AssistedInject constructor(
|
||||
private val filterProperty: StateFlow<FilterProperty<MangaTag>>
|
||||
get() = if (isExcluded) filter.tagsExcluded else filter.tags
|
||||
|
||||
@Suppress("RemoveExplicitTypeArguments")
|
||||
private val tags: StateFlow<List<ListModel>> = combine(
|
||||
filter.getAllTags(),
|
||||
flow<Collection<MangaTag>> { emit(emptyList()); emit(mangaDataRepository.findTags(filter.mangaSource)) },
|
||||
filterProperty.map { it.selectedItems },
|
||||
) { all, selected ->
|
||||
all.fold(
|
||||
onSuccess = {
|
||||
it.map { tag ->
|
||||
TagCatalogItem(
|
||||
tag = tag,
|
||||
isChecked = tag in selected,
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = {
|
||||
listOf(it.toErrorState(false))
|
||||
},
|
||||
)
|
||||
) { available, cached, selected ->
|
||||
buildList(available, cached, selected)
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
|
||||
|
||||
val content = combine(tags, searchQuery) { raw, query ->
|
||||
@@ -66,6 +61,50 @@ class TagsCatalogViewModel @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildList(
|
||||
available: Result<List<MangaTag>>,
|
||||
cached: Collection<MangaTag>,
|
||||
selected: Set<MangaTag>,
|
||||
): List<ListModel> {
|
||||
val capacity = (available.getOrNull()?.size ?: 1) + cached.size
|
||||
val result = ArrayList<ListModel>(capacity)
|
||||
val added = HashSet<String>(capacity)
|
||||
available.getOrNull()?.forEach { tag ->
|
||||
if (added.add(tag.title)) {
|
||||
result.add(
|
||||
TagCatalogItem(
|
||||
tag = tag,
|
||||
isChecked = tag in selected,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
cached.forEach { tag ->
|
||||
if (added.add(tag.title)) {
|
||||
result.add(
|
||||
TagCatalogItem(
|
||||
tag = tag,
|
||||
isChecked = tag in selected,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (result.isNotEmpty()) {
|
||||
val locale = (filter.mangaSource as? MangaParserSource)?.locale
|
||||
result.sortWith(compareBy(TagTitleComparator(locale)) { (it as TagCatalogItem).tag })
|
||||
}
|
||||
available.exceptionOrNull()?.let { error ->
|
||||
result.add(
|
||||
if (result.isEmpty()) {
|
||||
error.toErrorState(canRetry = false)
|
||||
} else {
|
||||
error.toErrorFooter()
|
||||
},
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(filter: FilterCoordinator, isExcludeTag: Boolean): TagsCatalogViewModel
|
||||
|
||||
@@ -27,6 +27,7 @@ import org.koitharu.kotatsu.core.model.isLocal
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.ui.dialog.CommonAlertDialogs
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.ui.list.FitHeightGridLayoutManager
|
||||
import org.koitharu.kotatsu.core.ui.list.FitHeightLinearLayoutManager
|
||||
@@ -238,6 +239,7 @@ abstract class MangaListFragment :
|
||||
}
|
||||
|
||||
override fun onFilterOptionClick(option: ListFilterOption) {
|
||||
selectionController?.clear()
|
||||
(viewModel as? QuickFilterListener)?.toggleFilterOption(option)
|
||||
}
|
||||
|
||||
@@ -322,8 +324,11 @@ abstract class MangaListFragment :
|
||||
}
|
||||
|
||||
R.id.action_save -> {
|
||||
viewModel.download(selectedItems)
|
||||
mode?.finish()
|
||||
val itemsSnapshot = selectedItems
|
||||
CommonAlertDialogs.showDownloadConfirmation(context ?: return false) { startPaused ->
|
||||
mode?.finish()
|
||||
viewModel.download(itemsSnapshot, isPaused = startPaused)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
|
||||
abstract class MangaListViewModel(
|
||||
private val settings: AppSettings,
|
||||
@@ -47,9 +46,9 @@ abstract class MangaListViewModel(
|
||||
|
||||
abstract fun onRetry()
|
||||
|
||||
fun download(items: Set<Manga>) {
|
||||
fun download(items: Set<Manga>, isPaused: Boolean) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
downloadScheduler.schedule(items)
|
||||
downloadScheduler.schedule(items, isPaused)
|
||||
onDownloadStarted.call(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package org.koitharu.kotatsu.list.ui.model
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
|
||||
data class ErrorFooter(
|
||||
val exception: Throwable,
|
||||
) : ListModel {
|
||||
|
||||
@@ -15,10 +15,9 @@ import org.koitharu.kotatsu.core.model.isLocal
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.AlphanumComparator
|
||||
import org.koitharu.kotatsu.core.util.ext.children
|
||||
import org.koitharu.kotatsu.core.util.ext.deleteAwait
|
||||
import org.koitharu.kotatsu.core.util.ext.filterWith
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.withChildren
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
|
||||
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
|
||||
@@ -77,7 +76,9 @@ class LocalMangaRepository @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun getFilterOptions() = MangaListFilterOptions(
|
||||
availableTags = localMangaIndex.getAvailableTags().mapToSet { MangaTag(title = it, key = it, source = source) },
|
||||
availableTags = localMangaIndex.getAvailableTags(
|
||||
skipNsfw = settings.isNsfwContentDisabled,
|
||||
).mapToSet { MangaTag(title = it, key = it, source = source) },
|
||||
availableContentRating = if (!settings.isNsfwContentDisabled) {
|
||||
EnumSet.of(ContentRating.SAFE, ContentRating.ADULT)
|
||||
} else {
|
||||
@@ -216,10 +217,15 @@ class LocalMangaRepository @Inject constructor(
|
||||
}
|
||||
val dirs = storageManager.getWriteableDirs()
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
dirs.flatMap { dir ->
|
||||
dir.children().filterWith(TempFileFilter())
|
||||
}.forEach { file ->
|
||||
file.deleteRecursively()
|
||||
val filter = TempFileFilter()
|
||||
dirs.forEach { dir ->
|
||||
dir.withChildren { children ->
|
||||
children.forEach { child ->
|
||||
if (filter.accept(child)) {
|
||||
child.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
@@ -230,9 +236,12 @@ class LocalMangaRepository @Inject constructor(
|
||||
val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM)
|
||||
for (file in files) {
|
||||
launch(dispatcher) {
|
||||
val m = LocalMangaInput.ofOrNull(file)?.getManga()
|
||||
if (m != null) {
|
||||
send(m)
|
||||
runCatchingCancellable {
|
||||
LocalMangaInput.ofOrNull(file)?.getManga()
|
||||
}.onFailure { e ->
|
||||
e.printStackTraceDebug()
|
||||
}.onSuccess { m ->
|
||||
if (m != null) send(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,7 +252,7 @@ class LocalMangaRepository @Inject constructor(
|
||||
private suspend fun getAllFiles() = storageManager.getReadableDirs()
|
||||
.asSequence()
|
||||
.flatMap { dir ->
|
||||
dir.children().filterNot { it.isHidden }
|
||||
dir.withChildren { children -> children.filterNot { it.isHidden }.toList() }
|
||||
}
|
||||
|
||||
private fun Collection<LocalManga>.unwrap(): List<Manga> = map { it.manga }
|
||||
|
||||
@@ -83,8 +83,13 @@ class LocalMangaIndex @Inject constructor(
|
||||
db.getLocalMangaIndexDao().delete(mangaId)
|
||||
}
|
||||
|
||||
suspend fun getAvailableTags(): List<String> {
|
||||
return db.getLocalMangaIndexDao().findTags()
|
||||
suspend fun getAvailableTags(skipNsfw: Boolean): List<String> {
|
||||
val dao = db.getLocalMangaIndexDao()
|
||||
return if (skipNsfw) {
|
||||
dao.findTags(isNsfw = false)
|
||||
} else {
|
||||
dao.findTags()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun upsert(manga: LocalManga) {
|
||||
|
||||
@@ -13,6 +13,9 @@ interface LocalMangaIndexDao {
|
||||
@Query("SELECT title FROM local_index LEFT JOIN manga_tags ON manga_tags.manga_id = local_index.manga_id LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id WHERE title IS NOT NULL GROUP BY title")
|
||||
suspend fun findTags(): List<String>
|
||||
|
||||
@Query("SELECT title FROM local_index LEFT JOIN manga_tags ON manga_tags.manga_id = local_index.manga_id LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id WHERE (SELECT nsfw FROM manga WHERE manga.manga_id = local_index.manga_id) = :isNsfw AND title IS NOT NULL GROUP BY title")
|
||||
suspend fun findTags(isNsfw: Boolean): List<String>
|
||||
|
||||
@Upsert
|
||||
suspend fun upsert(entity: LocalMangaIndexEntity)
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||
import org.koitharu.kotatsu.core.util.AlphanumComparator
|
||||
import org.koitharu.kotatsu.core.util.ext.children
|
||||
import org.koitharu.kotatsu.core.util.ext.creationTime
|
||||
import org.koitharu.kotatsu.core.util.ext.longHashCode
|
||||
import org.koitharu.kotatsu.core.util.ext.toListSorted
|
||||
import org.koitharu.kotatsu.core.util.ext.walkCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.withChildren
|
||||
import org.koitharu.kotatsu.local.data.MangaIndex
|
||||
import org.koitharu.kotatsu.local.data.hasCbzExtension
|
||||
import org.koitharu.kotatsu.local.data.hasImageExtension
|
||||
@@ -101,13 +101,14 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.IO) {
|
||||
val file = chapter.url.toUri().toFile()
|
||||
if (file.isDirectory) {
|
||||
file.children()
|
||||
.filter { it.isFile && hasImageExtension(it) }
|
||||
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
|
||||
.map {
|
||||
val pageUri = it.toUri().toString()
|
||||
MangaPage(pageUri.longHashCode(), pageUri, null, LocalMangaSource)
|
||||
}
|
||||
file.withChildren { children ->
|
||||
children
|
||||
.filter { it.isFile && hasImageExtension(it) }
|
||||
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
|
||||
}.map {
|
||||
val pageUri = it.toUri().toString()
|
||||
MangaPage(pageUri.longHashCode(), pageUri, null, LocalMangaSource)
|
||||
}
|
||||
} else {
|
||||
ZipFile(file).use { zip ->
|
||||
zip.entries()
|
||||
@@ -153,6 +154,6 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
|
||||
}
|
||||
|
||||
private fun File.isChapterDirectory(): Boolean {
|
||||
return isDirectory && children().any { hasImageExtension(it) }
|
||||
return isDirectory && withChildren { children -> children.any { hasImageExtension(it) } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ class LocalMangaDirOutput(
|
||||
index.getChapterFileName(chapter.value.id)?.let {
|
||||
return it
|
||||
}
|
||||
val baseName = "${chapter.index}_${chapter.value.name.toFileNameSafe()}".take(18)
|
||||
val baseName = "${chapter.index}_${chapter.value.name.toFileNameSafe()}".take(32)
|
||||
var i = 0
|
||||
while (true) {
|
||||
val name = (if (i == 0) baseName else baseName + "_$i") + ".cbz"
|
||||
|
||||
@@ -4,7 +4,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.internal.format
|
||||
import okio.Closeable
|
||||
import org.koitharu.kotatsu.core.prefs.DownloadFormat
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
|
||||
@@ -139,11 +139,13 @@ class ImportService : CoroutineIntentService() {
|
||||
notification.setContentTitle(applicationContext.getString(R.string.error_occurred))
|
||||
.setContentText(error.getDisplayMessage(applicationContext.resources))
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.addAction(
|
||||
ErrorReporterReceiver.getPendingIntent(applicationContext, error)?.let { reportIntent ->
|
||||
notification.addAction(
|
||||
R.drawable.ic_alert_outline,
|
||||
applicationContext.getString(R.string.report),
|
||||
ErrorReporterReceiver.getPendingIntent(applicationContext, error),
|
||||
reportIntent,
|
||||
)
|
||||
}
|
||||
}
|
||||
return notification.build()
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ import org.jsoup.HttpStatusException
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
||||
import org.koitharu.kotatsu.core.model.findById
|
||||
import org.koitharu.kotatsu.core.model.isLocal
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||
@@ -70,10 +70,10 @@ class CoverRestoreInterceptor @Inject constructor(
|
||||
}
|
||||
|
||||
private suspend fun restoreMangaImpl(manga: Manga): Boolean {
|
||||
if (dataRepository.findMangaById(manga.id) == null) {
|
||||
if (dataRepository.findMangaById(manga.id) == null || manga.isLocal) {
|
||||
return false
|
||||
}
|
||||
val repo = repositoryFactory.create(manga.source) as? ParserMangaRepository ?: return false
|
||||
val repo = repositoryFactory.create(manga.source)
|
||||
val fixed = repo.find(manga) ?: return false
|
||||
return if (fixed != manga) {
|
||||
dataRepository.storeManga(fixed)
|
||||
@@ -100,7 +100,10 @@ class CoverRestoreInterceptor @Inject constructor(
|
||||
}
|
||||
|
||||
private suspend fun restoreBookmarkImpl(bookmark: Bookmark): Boolean {
|
||||
val repo = repositoryFactory.create(bookmark.manga.source) as? ParserMangaRepository ?: return false
|
||||
if (bookmark.manga.isLocal) {
|
||||
return false
|
||||
}
|
||||
val repo = repositoryFactory.create(bookmark.manga.source)
|
||||
val chapter = repo.getDetails(bookmark.manga).chapters?.findById(bookmark.chapterId) ?: return false
|
||||
val page = repo.getPages(chapter)[bookmark.page]
|
||||
val imageUrl = page.preview.ifNullOrEmpty { page.url }
|
||||
|
||||
@@ -55,7 +55,7 @@ import org.koitharu.kotatsu.details.service.MangaPrefetchService
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.favourites.ui.container.FavouritesContainerFragment
|
||||
import org.koitharu.kotatsu.history.ui.HistoryListFragment
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||
import org.koitharu.kotatsu.local.ui.LocalIndexUpdateService
|
||||
import org.koitharu.kotatsu.local.ui.LocalStorageCleanupWorker
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
|
||||
@@ -352,7 +352,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
|
||||
MangaPrefetchService.prefetchLast(this@MainActivity)
|
||||
requestNotificationsPermission()
|
||||
}
|
||||
startService(Intent(this@MainActivity, LocalMangaIndex::class.java))
|
||||
startService(Intent(this@MainActivity, LocalIndexUpdateService::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
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.parsers.util.isNumeric
|
||||
import org.koitharu.kotatsu.parsers.util.md5
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ import org.koitharu.kotatsu.local.data.isFileUri
|
||||
import org.koitharu.kotatsu.local.data.isZipUri
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.requireBody
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
import java.util.LinkedList
|
||||
@@ -233,8 +234,7 @@ class PageLoader @Inject constructor(
|
||||
else -> {
|
||||
val request = createPageRequest(pageUrl, page.source)
|
||||
imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response ->
|
||||
val body = checkNotNull(response.body) { "Null response body" }
|
||||
body.withProgress(progress).use {
|
||||
response.requireBody().withProgress(progress).use {
|
||||
cache.put(pageUrl, it.source())
|
||||
}
|
||||
}.toUri()
|
||||
|
||||
@@ -19,7 +19,7 @@ import java.util.EnumMap
|
||||
class ReaderManager(
|
||||
private val fragmentManager: FragmentManager,
|
||||
private val container: FragmentContainerView,
|
||||
private val settings: AppSettings,
|
||||
settings: AppSettings,
|
||||
) {
|
||||
|
||||
private val modeMap = EnumMap<ReaderMode, Class<out BaseReaderFragment<*>>>(ReaderMode::class.java)
|
||||
|
||||
@@ -13,7 +13,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
|
||||
@@ -7,7 +7,6 @@ import androidx.viewbinding.ViewBinding
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderAnimation
|
||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ZoomControl
|
||||
import org.koitharu.kotatsu.core.util.ext.getParcelableCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
|
||||
@@ -17,6 +17,7 @@ import org.koitharu.kotatsu.core.model.getTitle
|
||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
|
||||
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
|
||||
import org.koitharu.kotatsu.core.util.ext.getCauseUrl
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
@@ -72,24 +73,23 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner {
|
||||
if (filterCoordinator.isFilterApplied) {
|
||||
filterCoordinator.reset()
|
||||
} else {
|
||||
openInBrowser()
|
||||
openInBrowser(null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSecondaryErrorActionClick(error: Throwable) {
|
||||
openInBrowser()
|
||||
openInBrowser(error.getCauseUrl())
|
||||
}
|
||||
|
||||
private fun openInBrowser() {
|
||||
val browserUrl = viewModel.browserUrl
|
||||
if (browserUrl.isNullOrEmpty()) {
|
||||
private fun openInBrowser(url: String?) {
|
||||
if (url.isNullOrEmpty()) {
|
||||
Snackbar.make(requireViewBinding().recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT)
|
||||
.show()
|
||||
} else {
|
||||
startActivity(
|
||||
BrowserActivity.newIntent(
|
||||
requireContext(),
|
||||
browserUrl,
|
||||
url,
|
||||
viewModel.source,
|
||||
viewModel.source.getTitle(requireContext()),
|
||||
),
|
||||
|
||||
@@ -21,10 +21,10 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.model.distinctById
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.getCauseUrl
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.sizeOrZero
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||
@@ -39,7 +39,6 @@ import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.list.ui.model.toErrorFooter
|
||||
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -68,9 +67,6 @@ open class RemoteListViewModel @Inject constructor(
|
||||
private var loadingJob: Job? = null
|
||||
private var randomJob: Job? = null
|
||||
|
||||
val browserUrl: String?
|
||||
get() = (repository as? ParserMangaRepository)?.domain?.let { "https://$it" }
|
||||
|
||||
override val content = combine(
|
||||
mangaList.map { it?.skipNsfwIfNeeded() },
|
||||
observeListModeWithTriggers(),
|
||||
@@ -82,7 +78,7 @@ open class RemoteListViewModel @Inject constructor(
|
||||
list.isNullOrEmpty() && error != null -> add(
|
||||
error.toErrorState(
|
||||
canRetry = true,
|
||||
secondaryAction = if (error !is NotFoundException && browserUrl != null) R.string.open_in_browser else 0,
|
||||
secondaryAction = if (error.getCauseUrl().isNullOrEmpty()) 0 else R.string.open_in_browser,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
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.ifZero
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.require
|
||||
import org.koitharu.kotatsu.core.util.ext.requireValue
|
||||
|
||||
@@ -17,6 +17,7 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.dialog.CommonAlertDialogs
|
||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.widgets.TipView
|
||||
@@ -184,8 +185,11 @@ class SearchActivity :
|
||||
}
|
||||
|
||||
R.id.action_save -> {
|
||||
viewModel.download(collectSelectedItems())
|
||||
mode?.finish()
|
||||
val itemsSnapshot = collectSelectedItems()
|
||||
CommonAlertDialogs.showDownloadConfirmation(this) { startPaused ->
|
||||
mode?.finish()
|
||||
viewModel.download(itemsSnapshot, isPaused = startPaused)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
@@ -109,9 +109,9 @@ class SearchViewModel @Inject constructor(
|
||||
retryCounter.value += 1
|
||||
}
|
||||
|
||||
fun download(items: Set<Manga>) {
|
||||
fun download(items: Set<Manga>, isPaused: Boolean) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
downloadScheduler.schedule(items)
|
||||
downloadScheduler.schedule(items, isPaused)
|
||||
onDownloadStarted.call(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.DownloadFormat
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderAnimation
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.resolveFile
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentFactory
|
||||
import androidx.fragment.app.FragmentTransaction
|
||||
import androidx.fragment.app.commit
|
||||
import androidx.preference.Preference
|
||||
@@ -70,10 +71,12 @@ class SettingsActivity :
|
||||
caller: PreferenceFragmentCompat,
|
||||
pref: Preference,
|
||||
): Boolean {
|
||||
val fm = supportFragmentManager
|
||||
val fragment = fm.fragmentFactory.instantiate(classLoader, pref.fragment ?: return false)
|
||||
fragment.arguments = pref.extras
|
||||
openFragment(fragment, isFromRoot = caller is RootSettingsFragment)
|
||||
val fragmentName = pref.fragment ?: return false
|
||||
openFragment(
|
||||
fragmentClass = FragmentFactory.loadFragmentClass(classLoader, fragmentName),
|
||||
args = pref.peekExtras(),
|
||||
isFromRoot = caller is RootSettingsFragment,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -93,11 +96,11 @@ class SettingsActivity :
|
||||
} ?: setTitle(title ?: getString(R.string.settings))
|
||||
}
|
||||
|
||||
fun openFragment(fragment: Fragment, isFromRoot: Boolean) {
|
||||
fun openFragment(fragmentClass: Class<out Fragment>, args: Bundle?, isFromRoot: Boolean) {
|
||||
val hasFragment = supportFragmentManager.findFragmentById(R.id.container) != null
|
||||
supportFragmentManager.commit {
|
||||
setReorderingAllowed(true)
|
||||
replace(R.id.container, fragment)
|
||||
replace(R.id.container, fragmentClass, args)
|
||||
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN)
|
||||
if (!isMasterDetails || (hasFragment && !isFromRoot)) {
|
||||
addToBackStack(null)
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.settings.about
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.net.toUri
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.preference.Preference
|
||||
@@ -14,23 +15,16 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.github.AppVersion
|
||||
import org.koitharu.kotatsu.core.github.VersionId
|
||||
import org.koitharu.kotatsu.core.github.isStable
|
||||
import org.koitharu.kotatsu.core.logs.FileLogger
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.util.ShareHelper
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.tracker.ui.debug.TrackerDebugActivity
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
|
||||
|
||||
private val viewModel by viewModels<AboutSettingsViewModel>()
|
||||
|
||||
@Inject
|
||||
lateinit var loggers: Set<@JvmSuppressWildcards FileLogger>
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_about)
|
||||
findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run {
|
||||
@@ -41,12 +35,6 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
|
||||
isEnabled = VersionId(BuildConfig.VERSION_NAME).isStable
|
||||
if (!isEnabled) isChecked = true
|
||||
}
|
||||
if (!settings.isTrackerEnabled) {
|
||||
findPreference<Preference>(AppSettings.KEY_TRACKER_DEBUG)?.run {
|
||||
isEnabled = false
|
||||
setSummary(R.string.check_for_new_chapters_disabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
@@ -64,21 +52,27 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_APP_TRANSLATION -> {
|
||||
openLink(getString(R.string.url_weblate), preference.title)
|
||||
AppSettings.KEY_LINK_WEBLATE -> {
|
||||
openLink(R.string.url_weblate, preference.title)
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_LOGS_SHARE -> {
|
||||
ShareHelper(preference.context).shareLogs(loggers)
|
||||
AppSettings.KEY_LINK_GITHUB -> {
|
||||
openLink(R.string.url_github, preference.title)
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_TRACKER_DEBUG -> {
|
||||
startActivity(Intent(preference.context, TrackerDebugActivity::class.java))
|
||||
AppSettings.KEY_LINK_MANUAL -> {
|
||||
openLink(R.string.url_user_manual, preference.title)
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_LINK_TELEGRAM -> {
|
||||
if (!openLink(R.string.url_telegram, null)) {
|
||||
openLink(R.string.url_telegram_web, preference.title)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
@@ -87,15 +81,15 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
|
||||
private fun onUpdateAvailable(version: AppVersion?) {
|
||||
if (version == null) {
|
||||
Snackbar.make(listView, R.string.no_update_available, Snackbar.LENGTH_SHORT).show()
|
||||
return
|
||||
} else {
|
||||
startActivity(Intent(requireContext(), AppUpdateActivity::class.java))
|
||||
}
|
||||
startActivity(Intent(requireContext(), AppUpdateActivity::class.java))
|
||||
}
|
||||
|
||||
private fun openLink(url: String, title: CharSequence?) {
|
||||
private fun openLink(@StringRes url: Int, title: CharSequence?): Boolean {
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.data = url.toUri()
|
||||
startActivitySafe(
|
||||
intent.data = getString(url).toUri()
|
||||
return startActivitySafe(
|
||||
if (title != null) {
|
||||
Intent.createChooser(intent, title)
|
||||
} else {
|
||||
|
||||
@@ -15,7 +15,6 @@ import org.koitharu.kotatsu.core.backup.BackupZipOutput
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import java.io.File
|
||||
import java.io.FileDescriptor
|
||||
import java.io.FileInputStream
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package org.koitharu.kotatsu.settings.backup
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
@@ -13,8 +11,6 @@ import org.koitharu.kotatsu.core.backup.BackupEntry
|
||||
import org.koitharu.kotatsu.core.backup.BackupRepository
|
||||
import org.koitharu.kotatsu.core.backup.BackupZipInput
|
||||
import org.koitharu.kotatsu.core.backup.CompositeResult
|
||||
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.settings.sources.catalog
|
||||
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePaddingRelative
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
@@ -16,12 +17,14 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.crossfade
|
||||
import org.koitharu.kotatsu.core.util.ext.drawableStart
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeDimensionPixelOffset
|
||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
|
||||
import org.koitharu.kotatsu.core.util.ext.source
|
||||
import org.koitharu.kotatsu.databinding.ItemEmptyHintBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemSourceCatalogBinding
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
fun sourceCatalogItemSourceAD(
|
||||
coil: ImageLoader,
|
||||
@@ -39,6 +42,13 @@ fun sourceCatalogItemSourceAD(
|
||||
binding.root.setOnClickListener { v ->
|
||||
listener.onItemClick(item, v)
|
||||
}
|
||||
val basePadding = context.getThemeDimensionPixelOffset(
|
||||
materialR.attr.listPreferredItemPaddingEnd,
|
||||
binding.root.paddingStart,
|
||||
)
|
||||
binding.root.updatePaddingRelative(
|
||||
end = (basePadding - context.resources.getDimensionPixelOffset(R.dimen.margin_small)).coerceAtLeast(0),
|
||||
)
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.text = item.source.getTitle(context)
|
||||
|
||||
@@ -13,7 +13,7 @@ data class SourceCatalogPage(
|
||||
return other is SourceCatalogPage && other.type == type
|
||||
}
|
||||
|
||||
override fun getChangePayload(previousState: ListModel): Any? {
|
||||
override fun getChangePayload(previousState: ListModel): Any {
|
||||
return ListModelDiffCallback.PAYLOAD_NESTED_LIST_CHANGED
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,13 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.db.TABLE_SOURCES
|
||||
import org.koitharu.kotatsu.core.model.getTitle
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.model.unwrap
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.lifecycleScope
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -63,8 +66,8 @@ class SourcesListProducer @Inject constructor(
|
||||
}
|
||||
|
||||
private suspend fun buildList(): List<SourceConfigItem> {
|
||||
val enabledSources = repository.getEnabledSources()
|
||||
val pinned = repository.getPinnedSources()
|
||||
val enabledSources = repository.getEnabledSources().filter { it.unwrap() is MangaParserSource }
|
||||
val pinned = repository.getPinnedSources().mapToSet { it.name }
|
||||
val isNsfwDisabled = settings.isNsfwContentDisabled
|
||||
val isReorderAvailable = settings.sourcesSortOrder == SourcesSortOrder.MANUAL
|
||||
val withTip = isReorderAvailable && settings.isTipEnabled(TIP_REORDER)
|
||||
@@ -79,7 +82,7 @@ class SourcesListProducer @Inject constructor(
|
||||
isEnabled = it in enabledSet,
|
||||
isDraggable = false,
|
||||
isAvailable = !isNsfwDisabled || !it.isNsfw(),
|
||||
isPinned = it in pinned,
|
||||
isPinned = it.name in pinned,
|
||||
)
|
||||
}.ifEmpty {
|
||||
listOf(SourceConfigItem.EmptySearchResult)
|
||||
@@ -100,7 +103,7 @@ class SourcesListProducer @Inject constructor(
|
||||
isEnabled = true,
|
||||
isDraggable = isReorderAvailable,
|
||||
isAvailable = false,
|
||||
isPinned = it in pinned,
|
||||
isPinned = it.name in pinned,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,8 +106,11 @@ class SourcesManageFragment :
|
||||
}
|
||||
|
||||
override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) {
|
||||
val fragment = SourceSettingsFragment.newInstance(item.source)
|
||||
(activity as? SettingsActivity)?.openFragment(fragment, false)
|
||||
(activity as? SettingsActivity)?.openFragment(
|
||||
fragmentClass = SourceSettingsFragment::class.java,
|
||||
args = Bundle(1).apply { putString(SourceSettingsFragment.EXTRA_SOURCE, item.source.name) },
|
||||
isFromRoot = false,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onItemLiftClick(item: SourceConfigItem.SourceItem) {
|
||||
|
||||
@@ -25,6 +25,7 @@ import org.koitharu.kotatsu.parsers.util.names
|
||||
import org.koitharu.kotatsu.settings.tracker.categories.TrackerCategoriesConfigSheet
|
||||
import org.koitharu.kotatsu.settings.utils.DozeHelper
|
||||
import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider
|
||||
import org.koitharu.kotatsu.tracker.ui.debug.TrackerDebugActivity
|
||||
import org.koitharu.kotatsu.tracker.work.TrackerNotificationHelper
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -116,6 +117,11 @@ class TrackerSettingsFragment :
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_TRACKER_DEBUG -> {
|
||||
startActivity(Intent(preference.context, TrackerDebugActivity::class.java))
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
package org.koitharu.kotatsu.settings.utils
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.TooltipCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.forEach
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceViewHolder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.PreferenceAboutLinksBinding
|
||||
|
||||
class AboutLinksPreference @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
) : Preference(context, attrs), View.OnClickListener {
|
||||
|
||||
init {
|
||||
layoutResource = R.layout.preference_about_links
|
||||
isSelectable = false
|
||||
isPersistent = false
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: PreferenceViewHolder) {
|
||||
super.onBindViewHolder(holder)
|
||||
|
||||
val binding = PreferenceAboutLinksBinding.bind(holder.itemView)
|
||||
binding.root.forEach { button ->
|
||||
TooltipCompat.setTooltipText(button, button.contentDescription)
|
||||
button.setOnClickListener(this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
val urlResId = when (v.id) {
|
||||
R.id.btn_discord -> R.string.url_discord
|
||||
R.id.btn_telegram -> R.string.url_telegram
|
||||
R.id.btn_github -> R.string.url_github
|
||||
else -> return
|
||||
}
|
||||
openLink(v, v.context.getString(urlResId), v.contentDescription)
|
||||
}
|
||||
|
||||
private fun openLink(v: View, url: String, title: CharSequence?) {
|
||||
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||
try {
|
||||
context.startActivity(
|
||||
if (title != null) {
|
||||
Intent.createChooser(intent, title)
|
||||
} else {
|
||||
intent
|
||||
},
|
||||
)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
Snackbar.make(v, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ class PercentSummaryProvider : Preference.SummaryProvider<SliderPreference> {
|
||||
|
||||
private var percentPattern: String? = null
|
||||
|
||||
override fun provideSummary(preference: SliderPreference): CharSequence? {
|
||||
override fun provideSummary(preference: SliderPreference): CharSequence {
|
||||
val pattern = percentPattern ?: preference.context.getString(R.string.percent_string_pattern).also {
|
||||
percentPattern = it
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.stats.data
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||
|
||||
@Entity(
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package org.koitharu.kotatsu.stats.data
|
||||
|
||||
import androidx.collection.LongIntMap
|
||||
import androidx.collection.MutableLongIntMap
|
||||
import androidx.room.withTransaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
@@ -13,7 +10,6 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.combine
|
||||
import org.koitharu.kotatsu.stats.domain.StatsPeriod
|
||||
import org.koitharu.kotatsu.stats.domain.StatsRecord
|
||||
import java.util.NavigableMap
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
package org.koitharu.kotatsu.stats.domain
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import com.google.android.material.R
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.details.data.ReadingTime
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
data class StatsRecord(
|
||||
val manga: Manga?,
|
||||
|
||||
@@ -21,7 +21,7 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class StatsViewModel @Inject constructor(
|
||||
private val repository: StatsRepository,
|
||||
private val favouritesRepository: FavouritesRepository,
|
||||
favouritesRepository: FavouritesRepository,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val period = MutableStateFlow(StatsPeriod.WEEK)
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package org.koitharu.kotatsu.stats.ui.sheet
|
||||
|
||||
import androidx.collection.IntList
|
||||
import androidx.collection.LongIntMap
|
||||
import androidx.collection.MutableIntList
|
||||
import androidx.collection.emptyIntList
|
||||
import androidx.collection.emptyLongIntMap
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -15,7 +13,6 @@ import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
|
||||
import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo
|
||||
import org.koitharu.kotatsu.core.util.ext.require
|
||||
import org.koitharu.kotatsu.stats.data.StatsRepository
|
||||
import org.koitharu.kotatsu.stats.domain.StatsRecord
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.core.graphics.ColorUtils
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.resolveDp
|
||||
import org.koitharu.kotatsu.parsers.util.replaceWith
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.sqrt
|
||||
|
||||
class PieChartView @JvmOverloads constructor(
|
||||
@@ -130,7 +131,7 @@ class PieChartView @JvmOverloads constructor(
|
||||
if (distance < chartBounds.height() / 4f || distance > chartBounds.centerX()) {
|
||||
return -1
|
||||
}
|
||||
var touchAngle = Math.toDegrees(Math.atan2(dy, dx)).toFloat()
|
||||
var touchAngle = Math.toDegrees(atan2(dy, dx)).toFloat()
|
||||
if (touchAngle < 0) {
|
||||
touchAngle += 360
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.koitharu.kotatsu.core.network.BaseHttpClient
|
||||
import org.koitharu.kotatsu.core.util.ext.toRequestBody
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.parsers.util.parseJson
|
||||
import org.koitharu.kotatsu.parsers.util.parseRaw
|
||||
import org.koitharu.kotatsu.parsers.util.removeSurrounding
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -30,7 +31,7 @@ class SyncAuthApi @Inject constructor(
|
||||
return response.parseJson().getString("token")
|
||||
} else {
|
||||
val code = response.code
|
||||
val message = response.use { checkNotNull(it.body).string() }.removeSurrounding('"')
|
||||
val message = response.parseRaw().removeSurrounding('"')
|
||||
throw SyncApiException(message, code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import dagger.Reusable
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||
@@ -30,11 +29,11 @@ class SyncSettings(
|
||||
@set:WorkerThread
|
||||
var syncURL: String
|
||||
get() = account?.let {
|
||||
val sync_url = accountManager.getUserData(it, KEY_SYNC_URL)
|
||||
if ( !sync_url.startsWith("http://") && !sync_url.startsWith("https://") ) {
|
||||
return "http://$sync_url"
|
||||
val result = accountManager.getUserData(it, KEY_SYNC_URL)
|
||||
if (!result.startsWith("http://") && !result.startsWith("https://")) {
|
||||
return "http://$result"
|
||||
}
|
||||
return sync_url
|
||||
return result
|
||||
}.ifNullOrEmpty { defaultSyncUrl }
|
||||
set(value) {
|
||||
account?.let {
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.content.SyncResult
|
||||
import android.content.SyncStats
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.content.contentValuesOf
|
||||
import dagger.assisted.Assisted
|
||||
@@ -18,9 +19,9 @@ import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
|
||||
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES
|
||||
@@ -28,10 +29,9 @@ import org.koitharu.kotatsu.core.db.TABLE_HISTORY
|
||||
import org.koitharu.kotatsu.core.db.TABLE_MANGA
|
||||
import org.koitharu.kotatsu.core.db.TABLE_MANGA_TAGS
|
||||
import org.koitharu.kotatsu.core.db.TABLE_TAGS
|
||||
import org.koitharu.kotatsu.core.logs.FileLogger
|
||||
import org.koitharu.kotatsu.core.logs.SyncLogger
|
||||
import org.koitharu.kotatsu.core.network.BaseHttpClient
|
||||
import org.koitharu.kotatsu.core.util.ext.parseJsonOrNull
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.toContentValues
|
||||
import org.koitharu.kotatsu.core.util.ext.toJson
|
||||
import org.koitharu.kotatsu.core.util.ext.toRequestBody
|
||||
@@ -50,7 +50,6 @@ class SyncHelper @AssistedInject constructor(
|
||||
@Assisted private val account: Account,
|
||||
@Assisted private val provider: ContentProviderClient,
|
||||
private val settings: SyncSettings,
|
||||
@SyncLogger private val logger: FileLogger,
|
||||
) {
|
||||
|
||||
private val authorityHistory = context.getString(R.string.sync_authority_history)
|
||||
@@ -75,7 +74,7 @@ class SyncHelper @AssistedInject constructor(
|
||||
.url("$baseUrl/resource/$TABLE_FAVOURITES")
|
||||
.post(data.toRequestBody())
|
||||
.build()
|
||||
val response = httpClient.newCall(request).execute().log().parseJsonOrNull()
|
||||
val response = httpClient.newCall(request).execute().parseJsonOrNull()
|
||||
if (response != null) {
|
||||
val categoriesResult = upsertFavouriteCategories(response.getJSONArray(TABLE_FAVOURITE_CATEGORIES))
|
||||
stats.numDeletes += categoriesResult.first().count?.toLong() ?: 0L
|
||||
@@ -97,7 +96,7 @@ class SyncHelper @AssistedInject constructor(
|
||||
.url("$baseUrl/resource/$TABLE_HISTORY")
|
||||
.post(data.toRequestBody())
|
||||
.build()
|
||||
val response = httpClient.newCall(request).execute().log().parseJsonOrNull()
|
||||
val response = httpClient.newCall(request).execute().parseJsonOrNull()
|
||||
if (response != null) {
|
||||
val result = upsertHistory(
|
||||
json = response.getJSONArray(TABLE_HISTORY),
|
||||
@@ -110,15 +109,12 @@ class SyncHelper @AssistedInject constructor(
|
||||
}
|
||||
|
||||
fun onError(e: Throwable) {
|
||||
if (logger.isEnabled) {
|
||||
logger.log("Sync error", e)
|
||||
}
|
||||
e.printStackTraceDebug()
|
||||
}
|
||||
|
||||
fun onSyncComplete(result: SyncResult) {
|
||||
if (logger.isEnabled) {
|
||||
logger.log("Sync finished: ${result.toDebugString()}")
|
||||
logger.flushBlocking()
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.i("Sync", "Sync finished: ${result.toDebugString()}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,12 +294,6 @@ class SyncHelper @AssistedInject constructor(
|
||||
|
||||
private fun JSONObject.removeJSONArray(name: String) = remove(name) as JSONArray
|
||||
|
||||
private fun Response.log() = apply {
|
||||
if (logger.isEnabled) {
|
||||
logger.log("$code ${request.url}")
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
||||
|
||||
@@ -43,17 +43,17 @@ fun trackDebugAD(
|
||||
}
|
||||
binding.textViewTitle.text = item.manga.title
|
||||
binding.textViewSummary.text = buildSpannedString {
|
||||
item.lastCheckTime?.let {
|
||||
append(
|
||||
append(
|
||||
item.lastCheckTime?.let {
|
||||
DateUtils.getRelativeDateTimeString(
|
||||
context,
|
||||
it.toEpochMilli(),
|
||||
DateUtils.MINUTE_IN_MILLIS,
|
||||
DateUtils.WEEK_IN_MILLIS,
|
||||
0,
|
||||
),
|
||||
)
|
||||
}
|
||||
)
|
||||
} ?: getString(R.string.never),
|
||||
)
|
||||
if (item.lastResult == TrackEntity.RESULT_FAILED) {
|
||||
append(" - ")
|
||||
bold {
|
||||
|
||||
@@ -16,7 +16,7 @@ import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class TrackerDebugViewModel @Inject constructor(
|
||||
private val db: MangaDatabase
|
||||
db: MangaDatabase
|
||||
) : BaseViewModel() {
|
||||
|
||||
val content = db.getTracksDao().observeAll()
|
||||
|
||||
@@ -45,13 +45,12 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.logs.FileLogger
|
||||
import org.koitharu.kotatsu.core.logs.TrackerLogger
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.TrackerDownloadStrategy
|
||||
import org.koitharu.kotatsu.core.util.ext.awaitUniqueWorkInfoByName
|
||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||
import org.koitharu.kotatsu.core.util.ext.onEachIndexed
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.trySetForeground
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
@@ -80,7 +79,6 @@ class TrackWorker @AssistedInject constructor(
|
||||
private val getTracksUseCase: GetTracksUseCase,
|
||||
private val checkNewChaptersUseCase: CheckNewChaptersUseCase,
|
||||
private val workManager: WorkManager,
|
||||
@TrackerLogger private val logger: FileLogger,
|
||||
private val localRepositoryLazy: Lazy<LocalMangaRepository>,
|
||||
private val downloadSchedulerLazy: Lazy<DownloadWorker.Scheduler>,
|
||||
) : CoroutineWorker(context, workerParams) {
|
||||
@@ -90,17 +88,15 @@ class TrackWorker @AssistedInject constructor(
|
||||
override suspend fun doWork(): Result {
|
||||
notificationHelper.updateChannels()
|
||||
val isForeground = trySetForeground()
|
||||
logger.log("doWork(): attempt $runAttemptCount")
|
||||
return try {
|
||||
doWorkImpl(isFullRun = isForeground && TAG_ONESHOT in tags)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
logger.log("fatal", e)
|
||||
e.printStackTraceDebug()
|
||||
Result.failure()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
logger.flush()
|
||||
notificationManager.cancel(WORKER_NOTIFICATION_ID)
|
||||
}
|
||||
}
|
||||
@@ -111,7 +107,6 @@ class TrackWorker @AssistedInject constructor(
|
||||
return Result.success()
|
||||
}
|
||||
val tracks = getTracksUseCase(if (isFullRun) Int.MAX_VALUE else BATCH_SIZE)
|
||||
logger.log("Total ${tracks.size} tracks")
|
||||
if (tracks.isEmpty()) {
|
||||
return Result.success()
|
||||
}
|
||||
@@ -154,7 +149,6 @@ class TrackWorker @AssistedInject constructor(
|
||||
when (it) {
|
||||
is MangaUpdates.Failure -> {
|
||||
val e = it.error
|
||||
logger.log("checkUpdatesAsync", e)
|
||||
if (e is CloudFlareProtectedException) {
|
||||
CaptchaNotifier(applicationContext).notify(e)
|
||||
}
|
||||
@@ -260,6 +254,7 @@ class TrackWorker @AssistedInject constructor(
|
||||
downloadSchedulerLazy.get().schedule(
|
||||
manga = mangaUpdates.manga,
|
||||
chaptersIds = mangaUpdates.newChapters.mapToSet { it.id },
|
||||
isPaused = false,
|
||||
isSilent = true,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- 34% of 12% = ~4% -->
|
||||
<item android:alpha="0.34" android:color="?attr/colorPrimary" />
|
||||
</selector>
|
||||
@@ -1,29 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="@color/selector_overlay">
|
||||
<item
|
||||
android:left="@dimen/list_spacing_large"
|
||||
android:right="@dimen/list_spacing_large">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="?colorSurfaceContainerHighest" />
|
||||
<corners
|
||||
android:bottomLeftRadius="@dimen/m3_card_corner"
|
||||
android:bottomRightRadius="@dimen/m3_card_corner" />
|
||||
<padding
|
||||
android:left="@dimen/list_spacing_small"
|
||||
android:right="@dimen/list_spacing_small" />
|
||||
</shape>
|
||||
</item>
|
||||
<item
|
||||
android:id="@android:id/mask"
|
||||
android:left="@dimen/list_spacing_large"
|
||||
android:right="@dimen/list_spacing_large">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/selector_overlay" />
|
||||
<corners
|
||||
android:bottomLeftRadius="@dimen/m3_card_corner"
|
||||
android:bottomRightRadius="@dimen/m3_card_corner" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
@@ -1,25 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="@color/selector_overlay">
|
||||
<item
|
||||
android:left="@dimen/list_spacing_large"
|
||||
android:right="@dimen/list_spacing_large">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="?colorSurfaceContainerHighest" />
|
||||
<corners android:radius="@dimen/m3_card_corner" />
|
||||
<padding
|
||||
android:left="@dimen/list_spacing_small"
|
||||
android:right="@dimen/list_spacing_small" />
|
||||
</shape>
|
||||
</item>
|
||||
<item
|
||||
android:id="@android:id/mask"
|
||||
android:left="@dimen/list_spacing_large"
|
||||
android:right="@dimen/list_spacing_large">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/selector_overlay" />
|
||||
<corners android:radius="@dimen/m3_card_corner" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
@@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="@color/selector_overlay">
|
||||
<item
|
||||
android:left="@dimen/list_spacing_large"
|
||||
android:right="@dimen/list_spacing_large">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="?colorSurfaceContainerHighest" />
|
||||
<padding
|
||||
android:left="@dimen/list_spacing_small"
|
||||
android:right="@dimen/list_spacing_small" />
|
||||
</shape>
|
||||
</item>
|
||||
<item
|
||||
android:id="@android:id/mask"
|
||||
android:left="@dimen/list_spacing_large"
|
||||
android:right="@dimen/list_spacing_large">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/selector_overlay" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
@@ -1,29 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="@color/selector_overlay">
|
||||
<item
|
||||
android:left="@dimen/list_spacing_large"
|
||||
android:right="@dimen/list_spacing_large">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="?colorSurfaceContainerHighest" />
|
||||
<corners
|
||||
android:topLeftRadius="@dimen/m3_card_corner"
|
||||
android:topRightRadius="@dimen/m3_card_corner" />
|
||||
<padding
|
||||
android:left="@dimen/list_spacing_small"
|
||||
android:right="@dimen/list_spacing_small" />
|
||||
</shape>
|
||||
</item>
|
||||
<item
|
||||
android:id="@android:id/mask"
|
||||
android:left="@dimen/list_spacing_large"
|
||||
android:right="@dimen/list_spacing_large">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/selector_overlay" />
|
||||
<corners
|
||||
android:topLeftRadius="@dimen/m3_card_corner"
|
||||
android:topRightRadius="@dimen/m3_card_corner" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
@@ -1,11 +0,0 @@
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M19.27,5.33C17.94,4.71 16.5,4.26 15,4c-0.03,0 -0.05,0.01 -0.07,0.03c-0.18,0.33 -0.39,0.76 -0.53,1.09c-1.61,-0.24 -3.22,-0.24 -4.8,0C9.46,4.78 9.25,4.36 9.06,4.03C9.05,4.01 9.02,4 8.99,4c-1.5,0.26 -2.93,0.71 -4.27,1.33c-0.01,0 -0.02,0.01 -0.03,0.02c-2.72,4.07 -3.47,8.03 -3.1,11.95c0,0.02 0.01,0.04 0.03,0.05c1.8,1.32 3.53,2.12 5.24,2.65c0.03,0.01 0.06,0 0.07,-0.02c0.4,-0.55 0.76,-1.13 1.07,-1.74c0.02,-0.04 0,-0.08 -0.04,-0.09c-0.57,-0.22 -1.11,-0.48 -1.64,-0.78c-0.04,-0.02 -0.04,-0.08 -0.01,-0.11c0.11,-0.08 0.22,-0.17 0.33,-0.25c0.02,-0.02 0.05,-0.02 0.07,-0.01c3.44,1.57 7.15,1.57 10.55,0c0.02,-0.01 0.05,-0.01 0.07,0.01c0.11,0.09 0.22,0.17 0.33,0.26c0.04,0.03 0.04,0.09 -0.01,0.11c-0.52,0.31 -1.07,0.56 -1.64,0.78c-0.04,0.01 -0.05,0.06 -0.04,0.09c0.32,0.61 0.68,1.19 1.07,1.74C17.07,20 17.1,20.01 17.13,20c1.72,-0.53 3.45,-1.33 5.25,-2.65c0.02,-0.01 0.03,-0.03 0.03,-0.05c0.44,-4.53 -0.73,-8.46 -3.1,-11.95C19.3,5.34 19.29,5.33 19.27,5.33zM8.52,14.91c-1.03,0 -1.89,-0.95 -1.89,-2.12s0.84,-2.12 1.89,-2.12c1.06,0 1.9,0.96 1.89,2.12C10.41,13.96 9.57,14.91 8.52,14.91zM15.49,14.91c-1.03,0 -1.89,-0.95 -1.89,-2.12s0.84,-2.12 1.89,-2.12c1.06,0 1.9,0.96 1.89,2.12C17.38,13.96 16.55,14.91 15.49,14.91z" />
|
||||
</vector>
|
||||
@@ -1,11 +0,0 @@
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,2A10,10 0 0,0 2,12C2,16.42 4.87,20.17 8.84,21.5C9.34,21.58 9.5,21.27 9.5,21C9.5,20.77 9.5,20.14 9.5,19.31C6.73,19.91 6.14,17.97 6.14,17.97C5.68,16.81 5.03,16.5 5.03,16.5C4.12,15.88 5.1,15.9 5.1,15.9C6.1,15.97 6.63,16.93 6.63,16.93C7.5,18.45 8.97,18 9.54,17.76C9.63,17.11 9.89,16.67 10.17,16.42C7.95,16.17 5.62,15.31 5.62,11.5C5.62,10.39 6,9.5 6.65,8.79C6.55,8.54 6.2,7.5 6.75,6.15C6.75,6.15 7.59,5.88 9.5,7.17C10.29,6.95 11.15,6.84 12,6.84C12.85,6.84 13.71,6.95 14.5,7.17C16.41,5.88 17.25,6.15 17.25,6.15C17.8,7.5 17.45,8.54 17.35,8.79C18,9.5 18.38,10.39 18.38,11.5C18.38,15.32 16.04,16.16 13.81,16.41C14.17,16.72 14.5,17.33 14.5,18.26C14.5,19.6 14.5,20.68 14.5,21C14.5,21.27 14.66,21.59 15.17,21.5C19.14,20.16 22,16.42 22,12A10,10 0 0,0 12,2Z" />
|
||||
</vector>
|
||||
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M17 7V9H15V7H17M13 7V9H7V7H13M13 11H7V13H13V11M15 11V13H17V11H15M21 22L18 20L15 22L12 20L9 22L6 20L3 22V3H21V22M19 18.26V5H5V18.26L6 17.6L9 19.6L12 17.6L15 19.6L18 17.6L19 18.26Z" />
|
||||
</vector>
|
||||
@@ -1,10 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<path
|
||||
android:pathData="m196.03,328.68 l162,118.8c16.8,10.8 31.2,4.8 36,-16.8l66,-309.6c6,-26.4 -10.8,-38.4 -28.8,-30L46.03,239.88c-25.2,9.6 -25.2,25.2 -4.8,31.2l99.6,31.2 228,-145.2c10.8,-6 20.4,-3.6 13.2,4.8"
|
||||
android:strokeWidth="1.2"
|
||||
android:fillColor="#000000"/>
|
||||
</vector>
|
||||
@@ -12,8 +12,8 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fitsSystemWindows="true"
|
||||
app:liftOnScrollColor="@null"
|
||||
app:liftOnScroll="false">
|
||||
app:liftOnScroll="false"
|
||||
app:liftOnScrollColor="@null">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@id/toolbar"
|
||||
@@ -50,6 +50,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
app:bubbleSize="normal"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" />
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="?attr/dialogPreferredPadding"
|
||||
android:paddingVertical="16dp"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
tools:text="@string/onboard_text" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:scrollIndicators="top|bottom"
|
||||
android:scrollbars="vertical"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
tools:ignore="UnusedAttribute"
|
||||
tools:listitem="@layout/item_source_locale" />
|
||||
|
||||
</LinearLayout>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user