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">
|
<component name="InspectionProjectProfileManager">
|
||||||
<profile version="1.0">
|
<profile version="1.0">
|
||||||
<option name="myName" value="Project Default" />
|
<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="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="TrailingComma" enabled="true" level="INFORMATION" enabled_by_default="true" />
|
||||||
<inspection_tool class="ZeroLengthArrayInitialization" enabled="true" level="WARNING" enabled_by_default="true" />
|
<inspection_tool class="ZeroLengthArrayInitialization" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
</profile>
|
</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
|
package org.koitharu.kotatsu.favourites.ui
|
||||||
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
|
||||||
|
|
||||||
fun interface FavouritesTabLongClickListener {
|
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