Merge branch 'devel' into feature/shikimori
commit
7f3c46942d
@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: ⚠️ Source issue
|
||||
url: https://github.com/nv95/kotatsu-parsers/issues/new
|
||||
about: Issues and requests for sources should be opened in the kotatsu-parsers repository instead
|
||||
@ -0,0 +1,93 @@
|
||||
name: 🐞 Issue report
|
||||
description: Report an issue in Kotatsu
|
||||
labels: [bug]
|
||||
body:
|
||||
|
||||
- type: textarea
|
||||
id: reproduce-steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Provide an example of the issue.
|
||||
placeholder: |
|
||||
Example:
|
||||
1. First step
|
||||
2. Second step
|
||||
3. Issue here
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: Explain what you should expect to happen.
|
||||
placeholder: |
|
||||
Example:
|
||||
"This should happen..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual-behavior
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
description: Explain what actually happens.
|
||||
placeholder: |
|
||||
Example:
|
||||
"This happened instead..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: kotatsu-version
|
||||
attributes:
|
||||
label: Kotatsu version
|
||||
description: You can find your Kotatsu version in **Settings → About**.
|
||||
placeholder: |
|
||||
Example: "3.2.2"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: android-version
|
||||
attributes:
|
||||
label: Android version
|
||||
description: You can find this somewhere in your Android settings.
|
||||
placeholder: |
|
||||
Example: "Android 12"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: device
|
||||
attributes:
|
||||
label: Device
|
||||
description: List your device and model.
|
||||
placeholder: |
|
||||
Example: "LG Nexus 5X"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: other-details
|
||||
attributes:
|
||||
label: Other details
|
||||
placeholder: |
|
||||
Additional details and attachments.
|
||||
|
||||
- type: checkboxes
|
||||
id: acknowledgements
|
||||
attributes:
|
||||
label: Acknowledgements
|
||||
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
|
||||
options:
|
||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||
required: true
|
||||
- label: I have written a short but informative title.
|
||||
required: true
|
||||
- label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new).
|
||||
required: true
|
||||
- label: I have updated the app to version **[3.2.2](https://github.com/nv95/Kotatsu/releases/latest)**.
|
||||
required: true
|
||||
- label: I will fill out all of the requested information in this form.
|
||||
required: true
|
||||
@ -0,0 +1,39 @@
|
||||
name: ⭐ Feature request
|
||||
description: Suggest a feature to improve Kotatsu
|
||||
labels: [feature request]
|
||||
body:
|
||||
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
attributes:
|
||||
label: Describe your suggested feature
|
||||
description: How can Kotatsu be improved?
|
||||
placeholder: |
|
||||
Example:
|
||||
"It should work like this..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: other-details
|
||||
attributes:
|
||||
label: Other details
|
||||
placeholder: |
|
||||
Additional details and attachments.
|
||||
|
||||
- type: checkboxes
|
||||
id: acknowledgements
|
||||
attributes:
|
||||
label: Acknowledgements
|
||||
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
|
||||
options:
|
||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||
required: true
|
||||
- label: I have written a short but informative title.
|
||||
required: true
|
||||
- label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new).
|
||||
required: true
|
||||
- label: I have updated the app to version **[3.2.2](https://github.com/nv95/Kotatsu/releases/latest)**.
|
||||
required: true
|
||||
- label: I will fill out all of the requested information in this form.
|
||||
required: true
|
||||
@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="deploymentTargetDropDown">
|
||||
<targetSelectedWithDropDown>
|
||||
<Target>
|
||||
<type value="QUICK_BOOT_TARGET" />
|
||||
<deviceKey>
|
||||
<Key>
|
||||
<type value="VIRTUAL_DEVICE_PATH" />
|
||||
<value value="$USER_HOME$/.android/avd/Pixel_API_S.avd" />
|
||||
</Key>
|
||||
</deviceKey>
|
||||
</Target>
|
||||
</targetSelectedWithDropDown>
|
||||
<timeTargetWasSelectedWithDropDown value="2021-02-19T19:02:37.198775Z" />
|
||||
</component>
|
||||
</project>
|
||||
@ -1,8 +1,10 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="BooleanLiteralArgument" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="BooleanLiteralArgument" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="Destructure" enabled="true" level="INFO" enabled_by_default="true" />
|
||||
<inspection_tool class="KeySetIterationMayUseEntrySet" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="ReplaceCollectionCountWithSize" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="TrailingComma" enabled="true" level="INFORMATION" enabled_by_default="true" />
|
||||
<inspection_tool class="ZeroLengthArrayInitialization" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
</profile>
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
language: android
|
||||
dist: trusty
|
||||
android:
|
||||
components:
|
||||
- android-30
|
||||
- build-tools-30.0.3
|
||||
- platform-tools-30.0.5
|
||||
- tools
|
||||
before_install:
|
||||
- yes | sdkmanager "platforms;android-30"
|
||||
script: ./gradlew -Dorg.gradle.jvmargs=-Xmx1536m assembleDebug lintDebug
|
||||
@ -0,0 +1,40 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import java.util.*
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaParser
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.model.*
|
||||
|
||||
/**
|
||||
* This parser is just for parser development, it should not be used in releases
|
||||
*/
|
||||
class DummyParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.DUMMY) {
|
||||
|
||||
override val configKeyDomain: ConfigKey.Domain
|
||||
get() = ConfigKey.Domain("", null)
|
||||
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = EnumSet.allOf(SortOrder::class.java)
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun getList(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder?
|
||||
): List<Manga> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaParser
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.newParser
|
||||
|
||||
fun MangaParser(source: MangaSource, loaderContext: MangaLoaderContext): MangaParser {
|
||||
return if (source == MangaSource.DUMMY) {
|
||||
DummyParser(loaderContext)
|
||||
} else {
|
||||
source.newParser(loaderContext)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
package org.koitharu.kotatsu.base.ui
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
abstract class CoroutineIntentService : BaseService() {
|
||||
|
||||
private val mutex = Mutex()
|
||||
protected open val dispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
launchCoroutine(intent, startId)
|
||||
return Service.START_REDELIVER_INTENT
|
||||
}
|
||||
|
||||
private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch {
|
||||
mutex.withLock {
|
||||
try {
|
||||
withContext(dispatcher) {
|
||||
processIntent(intent)
|
||||
}
|
||||
} finally {
|
||||
stopSelf(startId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract suspend fun processIntent(intent: Intent?)
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
package org.koitharu.kotatsu.base.ui.dialog
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.view.View
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
|
||||
class AppBottomSheetDialog(context: Context, theme: Int) : BottomSheetDialog(context, theme) {
|
||||
|
||||
/**
|
||||
* https://github.com/material-components/material-components-android/issues/2582
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onAttachedToWindow() {
|
||||
val window = window
|
||||
val initialSystemUiVisibility = window?.decorView?.systemUiVisibility ?: 0
|
||||
super.onAttachedToWindow()
|
||||
if (window != null) {
|
||||
// If the navigation bar is translucent at all, the BottomSheet should be edge to edge
|
||||
val drawEdgeToEdge = edgeToEdgeEnabled && Color.alpha(window.navigationBarColor) < 0xFF
|
||||
if (drawEdgeToEdge) {
|
||||
// Copied from super.onAttachedToWindow:
|
||||
val edgeToEdgeFlags = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
// Fix super-class's window flag bug by respecting the intial system UI visibility:
|
||||
window.decorView.systemUiVisibility = edgeToEdgeFlags or initialSystemUiVisibility
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
package org.koitharu.kotatsu.base.ui.list.decor
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.util.SparseIntArray
|
||||
import android.view.View
|
||||
import androidx.core.util.getOrDefault
|
||||
import androidx.core.util.set
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
class TypedSpacingItemDecoration(
|
||||
vararg spacingMapping: Pair<Int, Int>,
|
||||
private val fallbackSpacing: Int = 0,
|
||||
) : RecyclerView.ItemDecoration() {
|
||||
|
||||
private val mapping = SparseIntArray(spacingMapping.size)
|
||||
|
||||
init {
|
||||
spacingMapping.forEach { (k, v) -> mapping[k] = v }
|
||||
}
|
||||
|
||||
override fun getItemOffsets(
|
||||
outRect: Rect,
|
||||
view: View,
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State
|
||||
) {
|
||||
val itemType = parent.getChildViewHolder(view)?.itemViewType
|
||||
val spacing = if (itemType == null) {
|
||||
fallbackSpacing
|
||||
} else {
|
||||
mapping.getOrDefault(itemType, fallbackSpacing)
|
||||
}
|
||||
outRect.set(spacing, spacing, spacing, spacing)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package org.koitharu.kotatsu.base.ui.util
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
|
||||
class CountedBooleanLiveData : MutableLiveData<Boolean>(false) {
|
||||
|
||||
private var counter = 0
|
||||
|
||||
override fun setValue(value: Boolean) {
|
||||
if (value) {
|
||||
counter++
|
||||
} else {
|
||||
counter--
|
||||
}
|
||||
val newValue = counter > 0
|
||||
if (newValue != this.value) {
|
||||
super.setValue(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
package org.koitharu.kotatsu.base.ui.widgets
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
|
||||
class WindowInsetHolder @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0,
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
private var desiredHeight = 0
|
||||
private var desiredWidth = 0
|
||||
|
||||
@SuppressLint("RtlHardcoded")
|
||||
override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets {
|
||||
val barsInsets = WindowInsetsCompat.toWindowInsetsCompat(insets, this)
|
||||
.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
val gravity = getLayoutGravity()
|
||||
val newWidth = when (gravity and Gravity.HORIZONTAL_GRAVITY_MASK) {
|
||||
Gravity.LEFT -> barsInsets.left
|
||||
Gravity.RIGHT -> barsInsets.right
|
||||
else -> 0
|
||||
}
|
||||
val newHeight = when (gravity and Gravity.VERTICAL_GRAVITY_MASK) {
|
||||
Gravity.TOP -> barsInsets.top
|
||||
Gravity.BOTTOM -> barsInsets.bottom
|
||||
else -> 0
|
||||
}
|
||||
if (newWidth != desiredWidth || newHeight != desiredHeight) {
|
||||
desiredWidth = newWidth
|
||||
desiredHeight = newHeight
|
||||
requestLayout()
|
||||
}
|
||||
return super.dispatchApplyWindowInsets(insets)
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
|
||||
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
|
||||
super.onMeasure(
|
||||
if (desiredWidth == 0 || widthMode == MeasureSpec.EXACTLY) {
|
||||
widthMeasureSpec
|
||||
} else {
|
||||
MeasureSpec.makeMeasureSpec(desiredWidth, widthMode)
|
||||
},
|
||||
if (desiredHeight == 0 || heightMode == MeasureSpec.EXACTLY) {
|
||||
heightMeasureSpec
|
||||
} else {
|
||||
MeasureSpec.makeMeasureSpec(desiredHeight, heightMode)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun getLayoutGravity(): Int {
|
||||
return when (val lp = layoutParams) {
|
||||
is FrameLayout.LayoutParams -> lp.gravity
|
||||
is LinearLayout.LayoutParams -> lp.gravity
|
||||
is CoordinatorLayout.LayoutParams -> lp.gravity
|
||||
else -> Gravity.NO_GRAVITY
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.utils.MutableZipFile
|
||||
import org.koitharu.kotatsu.utils.ext.format
|
||||
|
||||
class BackupArchive(file: File) : MutableZipFile(file) {
|
||||
|
||||
init {
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun put(entry: BackupEntry) {
|
||||
put(entry.name, entry.data.toString(2))
|
||||
}
|
||||
|
||||
suspend fun getEntry(name: String): BackupEntry {
|
||||
val json = withContext(Dispatchers.Default) {
|
||||
JSONArray(getContent(name))
|
||||
}
|
||||
return BackupEntry(name, json)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val DIR_BACKUPS = "backups"
|
||||
|
||||
suspend fun createNew(context: Context): BackupArchive = runInterruptible(Dispatchers.IO) {
|
||||
val dir = context.run {
|
||||
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
||||
}
|
||||
dir.mkdirs()
|
||||
val filename = buildString {
|
||||
append(context.getString(R.string.app_name).lowercase(Locale.ROOT))
|
||||
append('_')
|
||||
append(Date().format("ddMMyyyy"))
|
||||
append(".bak")
|
||||
}
|
||||
BackupArchive(File(dir, filename))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.Closeable
|
||||
import org.json.JSONArray
|
||||
import java.io.File
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
class BackupZipInput(val file: File) : Closeable {
|
||||
|
||||
private val zipFile = ZipFile(file)
|
||||
|
||||
suspend fun getEntry(name: String): BackupEntry = runInterruptible(Dispatchers.IO) {
|
||||
val entry = zipFile.getEntry(name)
|
||||
val json = zipFile.getInputStream(entry).use {
|
||||
JSONArray(it.bufferedReader().readText())
|
||||
}
|
||||
BackupEntry(name, json)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
zipFile.close()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.Closeable
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.zip.ZipOutput
|
||||
import org.koitharu.kotatsu.utils.ext.format
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import java.util.zip.Deflater
|
||||
|
||||
class BackupZipOutput(val file: File) : Closeable {
|
||||
|
||||
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
|
||||
|
||||
suspend fun put(entry: BackupEntry) {
|
||||
output.put(entry.name, entry.data.toString(2))
|
||||
}
|
||||
|
||||
suspend fun finish() {
|
||||
output.finish()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
output.close()
|
||||
}
|
||||
}
|
||||
|
||||
private const val DIR_BACKUPS = "backups"
|
||||
|
||||
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
|
||||
val dir = context.run {
|
||||
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
||||
}
|
||||
dir.mkdirs()
|
||||
val filename = buildString {
|
||||
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
||||
append('_')
|
||||
append(Date().format("ddMMyyyy"))
|
||||
append(".bk.zip")
|
||||
}
|
||||
BackupZipOutput(File(dir, filename))
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration9To10 : Migration(9, 10) {
|
||||
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `track` INTEGER NOT NULL DEFAULT 1")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,118 @@
|
||||
package org.koitharu.kotatsu.core.zip
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.collection.ArraySet
|
||||
import okio.Closeable
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.util.zip.Deflater
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
class ZipOutput(
|
||||
val file: File,
|
||||
compressionLevel: Int = Deflater.DEFAULT_COMPRESSION,
|
||||
) : Closeable {
|
||||
|
||||
private val entryNames = ArraySet<String>()
|
||||
private var isClosed = false
|
||||
private val output = ZipOutputStream(file.outputStream()).apply {
|
||||
setLevel(compressionLevel)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun put(name: String, file: File): Boolean {
|
||||
return output.appendFile(file, name)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun put(name: String, content: String): Boolean {
|
||||
return output.appendText(content, name)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun addDirectory(name: String): Boolean {
|
||||
val entry = if (name.endsWith("/")) {
|
||||
ZipEntry(name)
|
||||
} else {
|
||||
ZipEntry("$name/")
|
||||
}
|
||||
return if (entryNames.add(entry.name)) {
|
||||
output.putNextEntry(entry)
|
||||
output.closeEntry()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun copyEntryFrom(other: ZipFile, entry: ZipEntry): Boolean {
|
||||
return if (entryNames.add(entry.name)) {
|
||||
val zipEntry = ZipEntry(entry.name)
|
||||
output.putNextEntry(zipEntry)
|
||||
other.getInputStream(entry).use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
output.closeEntry()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun finish() {
|
||||
output.finish()
|
||||
output.flush()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (!isClosed) {
|
||||
output.close()
|
||||
isClosed = true
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun ZipOutputStream.appendFile(fileToZip: File, name: String): Boolean {
|
||||
if (fileToZip.isDirectory) {
|
||||
val entry = if (name.endsWith("/")) {
|
||||
ZipEntry(name)
|
||||
} else {
|
||||
ZipEntry("$name/")
|
||||
}
|
||||
if (!entryNames.add(entry.name)) {
|
||||
return false
|
||||
}
|
||||
putNextEntry(entry)
|
||||
closeEntry()
|
||||
fileToZip.listFiles()?.forEach { childFile ->
|
||||
appendFile(childFile, "$name/${childFile.name}")
|
||||
}
|
||||
} else {
|
||||
FileInputStream(fileToZip).use { fis ->
|
||||
if (!entryNames.add(name)) {
|
||||
return false
|
||||
}
|
||||
val zipEntry = ZipEntry(name)
|
||||
putNextEntry(zipEntry)
|
||||
fis.copyTo(this)
|
||||
closeEntry()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun ZipOutputStream.appendText(content: String, name: String): Boolean {
|
||||
if (!entryNames.add(name)) {
|
||||
return false
|
||||
}
|
||||
val zipEntry = ZipEntry(name)
|
||||
putNextEntry(zipEntry)
|
||||
content.byteInputStream().copyTo(this)
|
||||
closeEntry()
|
||||
return true
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
package org.koitharu.kotatsu.favourites.ui
|
||||
|
||||
import android.view.View
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
|
||||
|
||||
fun interface FavouritesTabLongClickListener {
|
||||
|
||||
fun onTabLongClick(tabView: View, category: FavouriteCategory): Boolean
|
||||
fun onTabLongClick(tabView: View, item: CategoryListModel): Boolean
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package org.koitharu.kotatsu.favourites.ui.categories
|
||||
|
||||
interface AllCategoriesToggleListener {
|
||||
|
||||
fun onAllCategoriesToggle(isVisible: Boolean)
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package org.koitharu.kotatsu.favourites.ui.categories.adapter
|
||||
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemCategoriesAllBinding
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.AllCategoriesToggleListener
|
||||
|
||||
fun allCategoriesAD(
|
||||
allCategoriesToggleListener: AllCategoriesToggleListener,
|
||||
) = adapterDelegateViewBinding<CategoryListModel.All, CategoryListModel, ItemCategoriesAllBinding>(
|
||||
{ inflater, parent -> ItemCategoriesAllBinding.inflate(inflater, parent, false) }
|
||||
) {
|
||||
|
||||
binding.imageViewToggle.setOnClickListener {
|
||||
allCategoriesToggleListener.onAllCategoriesToggle(!item.isVisible)
|
||||
}
|
||||
|
||||
bind {
|
||||
binding.imageViewToggle.isChecked = item.isVisible
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
package org.koitharu.kotatsu.favourites.ui.categories.adapter
|
||||
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
sealed interface CategoryListModel : ListModel {
|
||||
|
||||
val id: Long
|
||||
|
||||
class All(
|
||||
val isVisible: Boolean,
|
||||
) : CategoryListModel {
|
||||
|
||||
override val id: Long = 0L
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as All
|
||||
|
||||
if (isVisible != other.isVisible) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return isVisible.hashCode()
|
||||
}
|
||||
}
|
||||
|
||||
class CategoryItem(
|
||||
val category: FavouriteCategory,
|
||||
) : CategoryListModel {
|
||||
|
||||
override val id: Long
|
||||
get() = category.id
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as CategoryItem
|
||||
|
||||
if (category.id != other.category.id) return false
|
||||
if (category.title != other.category.title) return false
|
||||
if (category.order != other.category.order) return false
|
||||
if (category.isTrackingEnabled != other.category.isTrackingEnabled) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = category.id.hashCode()
|
||||
result = 31 * result + category.title.hashCode()
|
||||
result = 31 * result + category.order.hashCode()
|
||||
result = 31 * result + category.isTrackingEnabled.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,147 @@
|
||||
package org.koitharu.kotatsu.favourites.ui.categories.edit
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.ui.titleRes
|
||||
import org.koitharu.kotatsu.databinding.ActivityCategoryEditBinding
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
|
||||
class FavouritesCategoryEditActivity : BaseActivity<ActivityCategoryEditBinding>(), AdapterView.OnItemClickListener {
|
||||
|
||||
private val viewModel by viewModel<FavouritesCategoryEditViewModel> {
|
||||
parametersOf(intent.getLongExtra(EXTRA_ID, NO_ID))
|
||||
}
|
||||
private var selectedSortOrder: SortOrder? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityCategoryEditBinding.inflate(layoutInflater))
|
||||
supportActionBar?.run {
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setHomeAsUpIndicator(com.google.android.material.R.drawable.abc_ic_clear_material)
|
||||
}
|
||||
initSortSpinner()
|
||||
|
||||
viewModel.onSaved.observe(this) { finishAfterTransition() }
|
||||
viewModel.category.observe(this, ::onCategoryChanged)
|
||||
viewModel.isLoading.observe(this, ::onLoadingStateChanged)
|
||||
viewModel.onError.observe(this, ::onError)
|
||||
viewModel.isTrackerEnabled.observe(this) {
|
||||
binding.switchTracker.isVisible = it
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putSerializable(KEY_SORT_ORDER, selectedSortOrder)
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
super.onRestoreInstanceState(savedInstanceState)
|
||||
val order = savedInstanceState.getSerializable(KEY_SORT_ORDER)
|
||||
if (order != null && order is SortOrder) {
|
||||
selectedSortOrder = order
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.opt_config, menu)
|
||||
menu.findItem(R.id.action_done)?.setTitle(R.string.save)
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||
R.id.action_done -> {
|
||||
viewModel.save(
|
||||
title = binding.editName.text?.toString().orEmpty(),
|
||||
sortOrder = getSelectedSortOrder(),
|
||||
isTrackerEnabled = binding.switchTracker.isChecked,
|
||||
)
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
binding.scrollView.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
bottom = insets.bottom,
|
||||
)
|
||||
binding.toolbar.updatePadding(
|
||||
top = insets.top,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onItemClick(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
selectedSortOrder = CategoriesActivity.SORT_ORDERS.getOrNull(position)
|
||||
}
|
||||
|
||||
private fun onCategoryChanged(category: FavouriteCategory?) {
|
||||
setTitle(if (category == null) R.string.create_category else R.string.edit_category)
|
||||
if (selectedSortOrder != null) {
|
||||
return
|
||||
}
|
||||
binding.editName.setText(category?.title)
|
||||
selectedSortOrder = category?.order
|
||||
val sortText = getString((category?.order ?: SortOrder.NEWEST).titleRes)
|
||||
binding.editSort.setText(sortText, false)
|
||||
binding.switchTracker.isChecked = category?.isTrackingEnabled ?: true
|
||||
}
|
||||
|
||||
private fun onError(e: Throwable) {
|
||||
binding.textViewError.text = e.getDisplayMessage(resources)
|
||||
binding.textViewError.isVisible = true
|
||||
}
|
||||
|
||||
private fun onLoadingStateChanged(isLoading: Boolean) {
|
||||
binding.editSort.isEnabled = !isLoading
|
||||
binding.editName.isEnabled = !isLoading
|
||||
binding.switchTracker.isEnabled = !isLoading
|
||||
if (isLoading) {
|
||||
binding.textViewError.isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun initSortSpinner() {
|
||||
val entries = CategoriesActivity.SORT_ORDERS.map { getString(it.titleRes) }
|
||||
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, entries)
|
||||
binding.editSort.setAdapter(adapter)
|
||||
binding.editSort.onItemClickListener = this
|
||||
}
|
||||
|
||||
private fun getSelectedSortOrder(): SortOrder {
|
||||
selectedSortOrder?.let { return it }
|
||||
val entries = CategoriesActivity.SORT_ORDERS.map { getString(it.titleRes) }
|
||||
val index = entries.indexOf(binding.editSort.text.toString())
|
||||
return CategoriesActivity.SORT_ORDERS.getOrNull(index) ?: SortOrder.NEWEST
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val EXTRA_ID = "id"
|
||||
private const val KEY_SORT_ORDER = "sort"
|
||||
private const val NO_ID = -1L
|
||||
|
||||
fun newIntent(context: Context, id: Long = NO_ID): Intent {
|
||||
return Intent(context, FavouritesCategoryEditActivity::class.java)
|
||||
.putExtra(EXTRA_ID, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
package org.koitharu.kotatsu.favourites.ui.categories.edit
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.liveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
|
||||
private const val NO_ID = -1L
|
||||
|
||||
class FavouritesCategoryEditViewModel(
|
||||
private val categoryId: Long,
|
||||
private val repository: FavouritesRepository,
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val onSaved = SingleLiveEvent<Unit>()
|
||||
val category = MutableLiveData<FavouriteCategory?>()
|
||||
|
||||
val isTrackerEnabled = liveData(viewModelScope.coroutineContext + Dispatchers.Default) {
|
||||
emit(settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources)
|
||||
}
|
||||
|
||||
init {
|
||||
launchLoadingJob {
|
||||
category.value = if (categoryId != NO_ID) {
|
||||
repository.getCategory(categoryId)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun save(
|
||||
title: String,
|
||||
sortOrder: SortOrder,
|
||||
isTrackerEnabled: Boolean,
|
||||
) {
|
||||
launchLoadingJob {
|
||||
if (categoryId == NO_ID) {
|
||||
repository.createCategory(title, sortOrder, isTrackerEnabled)
|
||||
} else {
|
||||
repository.updateCategory(categoryId, title, sortOrder, isTrackerEnabled)
|
||||
}
|
||||
onSaved.call(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,70 +0,0 @@
|
||||
package org.koitharu.kotatsu.local.data
|
||||
|
||||
import androidx.annotation.CheckResult
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
|
||||
import org.koitharu.kotatsu.utils.ext.takeIfReadable
|
||||
import java.io.File
|
||||
|
||||
@WorkerThread
|
||||
class MangaZip(val file: File) {
|
||||
|
||||
private val writableCbz = WritableCbzFile(file)
|
||||
|
||||
private var index = MangaIndex(null)
|
||||
|
||||
suspend fun prepare(manga: Manga) {
|
||||
writableCbz.prepare(overwrite = true)
|
||||
index = MangaIndex(writableCbz[INDEX_ENTRY].takeIfReadable()?.readText())
|
||||
index.setMangaInfo(manga, append = true)
|
||||
}
|
||||
|
||||
suspend fun cleanup() {
|
||||
writableCbz.cleanup()
|
||||
}
|
||||
|
||||
@CheckResult
|
||||
suspend fun compress(): Boolean {
|
||||
writableCbz[INDEX_ENTRY].writeText(index.toString())
|
||||
return writableCbz.flush()
|
||||
}
|
||||
|
||||
suspend fun addCover(file: File, ext: String) {
|
||||
val name = buildString {
|
||||
append(FILENAME_PATTERN.format(0, 0))
|
||||
if (ext.isNotEmpty() && ext.length <= 4) {
|
||||
append('.')
|
||||
append(ext)
|
||||
}
|
||||
}
|
||||
writableCbz.put(name, file)
|
||||
index.setCoverEntry(name)
|
||||
}
|
||||
|
||||
suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) {
|
||||
val name = buildString {
|
||||
append(FILENAME_PATTERN.format(chapter.number, pageNumber))
|
||||
if (ext.isNotEmpty() && ext.length <= 4) {
|
||||
append('.')
|
||||
append(ext)
|
||||
}
|
||||
}
|
||||
writableCbz.put(name, file)
|
||||
index.addChapter(chapter)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val FILENAME_PATTERN = "%03d%03d"
|
||||
|
||||
const val INDEX_ENTRY = "index.json"
|
||||
|
||||
fun findInDir(root: File, manga: Manga): MangaZip {
|
||||
val name = manga.title.toFileNameSafe() + ".cbz"
|
||||
val file = File(root, name)
|
||||
return MangaZip(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package org.koitharu.kotatsu.local.data
|
||||
|
||||
import java.io.File
|
||||
import java.io.FilenameFilter
|
||||
|
||||
class TempFileFilter : FilenameFilter {
|
||||
|
||||
override fun accept(dir: File, name: String): Boolean {
|
||||
return name.endsWith(".tmp", ignoreCase = true)
|
||||
}
|
||||
}
|
||||
@ -1,99 +0,0 @@
|
||||
package org.koitharu.kotatsu.local.data
|
||||
|
||||
import androidx.annotation.CheckResult
|
||||
import kotlinx.coroutines.*
|
||||
import org.koitharu.kotatsu.utils.ext.deleteAwait
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
class WritableCbzFile(private val file: File) {
|
||||
|
||||
private val dir = File(file.parentFile, file.nameWithoutExtension)
|
||||
|
||||
suspend fun prepare(overwrite: Boolean) = withContext(Dispatchers.IO) {
|
||||
if (!dir.list().isNullOrEmpty()) {
|
||||
if (overwrite) {
|
||||
dir.deleteRecursively()
|
||||
} else {
|
||||
throw IllegalStateException("Dir ${dir.name} is not empty")
|
||||
}
|
||||
}
|
||||
if (!dir.exists()) {
|
||||
dir.mkdir()
|
||||
}
|
||||
if (!file.exists()) {
|
||||
return@withContext
|
||||
}
|
||||
ZipInputStream(FileInputStream(file)).use { zip ->
|
||||
var entry = zip.nextEntry
|
||||
while (entry != null && currentCoroutineContext().isActive) {
|
||||
val target = File(dir.path + File.separator + entry.name)
|
||||
runInterruptible {
|
||||
target.parentFile?.mkdirs()
|
||||
target.outputStream().use { out ->
|
||||
zip.copyTo(out)
|
||||
}
|
||||
}
|
||||
zip.closeEntry()
|
||||
entry = zip.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun cleanup() = withContext(Dispatchers.IO) {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
|
||||
@CheckResult
|
||||
suspend fun flush() = withContext(Dispatchers.IO) {
|
||||
val tempFile = File(file.path + ".tmp")
|
||||
if (tempFile.exists()) {
|
||||
tempFile.deleteAwait()
|
||||
}
|
||||
try {
|
||||
runInterruptible {
|
||||
ZipOutputStream(FileOutputStream(tempFile)).use { zip ->
|
||||
dir.listFiles()?.forEach {
|
||||
zipFile(it, it.name, zip)
|
||||
}
|
||||
zip.flush()
|
||||
}
|
||||
}
|
||||
tempFile.renameTo(file)
|
||||
} finally {
|
||||
if (tempFile.exists()) {
|
||||
tempFile.deleteAwait()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
operator fun get(name: String) = File(dir, name)
|
||||
|
||||
suspend fun put(name: String, file: File) = runInterruptible(Dispatchers.IO) {
|
||||
file.copyTo(this[name], overwrite = true)
|
||||
}
|
||||
|
||||
private fun zipFile(fileToZip: File, fileName: String, zipOut: ZipOutputStream) {
|
||||
if (fileToZip.isDirectory) {
|
||||
if (fileName.endsWith("/")) {
|
||||
zipOut.putNextEntry(ZipEntry(fileName))
|
||||
} else {
|
||||
zipOut.putNextEntry(ZipEntry("$fileName/"))
|
||||
}
|
||||
zipOut.closeEntry()
|
||||
fileToZip.listFiles()?.forEach { childFile ->
|
||||
zipFile(childFile, "$fileName/${childFile.name}", zipOut)
|
||||
}
|
||||
} else {
|
||||
FileInputStream(fileToZip).use { fis ->
|
||||
val zipEntry = ZipEntry(fileName)
|
||||
zipOut.putNextEntry(zipEntry)
|
||||
fis.copyTo(zipOut)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue