I'm always forget to commit
parent
dea934155d
commit
47321c7ae6
@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AndroidTestResultsUserPreferences">
|
||||||
|
<option name="androidTestResultsTableState">
|
||||||
|
<map>
|
||||||
|
<entry key="-639820289">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_API_34" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AppInsightsSettings">
|
||||||
|
<option name="selectedTabId" value="Firebase Crashlytics" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ScreenshotViewer">
|
||||||
|
<option name="frameScreenshot" value="true" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"title": "Read later",
|
||||||
|
"sortKey": 1,
|
||||||
|
"order": "NEWEST",
|
||||||
|
"createdAt": 1335906000000,
|
||||||
|
"isTrackingEnabled": true,
|
||||||
|
"isVisibleInLibrary": true
|
||||||
|
}
|
||||||
Binary file not shown.
@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"id": -2096681732556647985,
|
||||||
|
"title": "Странствия Эманон",
|
||||||
|
"url": "/stranstviia_emanon",
|
||||||
|
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
||||||
|
"rating": 0.9400894,
|
||||||
|
"isNsfw": true,
|
||||||
|
"coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"title": "Сверхъестественное",
|
||||||
|
"key": "supernatural",
|
||||||
|
"source": "READMANGA_RU"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Сэйнэн",
|
||||||
|
"key": "seinen",
|
||||||
|
"source": "READMANGA_RU"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Повседневность",
|
||||||
|
"key": "slice_of_life",
|
||||||
|
"source": "READMANGA_RU"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Приключения",
|
||||||
|
"key": "adventure",
|
||||||
|
"source": "READMANGA_RU"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"state": "FINISHED",
|
||||||
|
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
||||||
|
"description": null,
|
||||||
|
"source": "READMANGA_RU"
|
||||||
|
}
|
||||||
@ -1,24 +0,0 @@
|
|||||||
package org.xtimms.tokusho
|
|
||||||
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
|
|
||||||
import org.junit.Assert.*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Instrumented test, which will execute on an Android device.
|
|
||||||
*
|
|
||||||
* See [testing documentation](http://d.android.com/tools/testing).
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class ExampleInstrumentedTest {
|
|
||||||
@Test
|
|
||||||
fun useAppContext() {
|
|
||||||
// Context of the app under test.
|
|
||||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
|
||||||
assertEquals("org.xtimms.tokusho", appContext.packageName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package org.xtimms.tokusho
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.runner.AndroidJUnitRunner
|
||||||
|
import dagger.hilt.android.testing.HiltTestApplication
|
||||||
|
|
||||||
|
class HiltTestRunner : AndroidJUnitRunner() {
|
||||||
|
|
||||||
|
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
|
||||||
|
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package org.xtimms.tokusho
|
||||||
|
|
||||||
|
import android.app.Instrumentation
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
|
suspend fun Instrumentation.awaitForIdle() = suspendCoroutine<Unit> { cont ->
|
||||||
|
waitForIdle { cont.resume(Unit) }
|
||||||
|
}
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
package org.xtimms.tokusho
|
||||||
|
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.squareup.moshi.FromJson
|
||||||
|
import com.squareup.moshi.JsonAdapter
|
||||||
|
import com.squareup.moshi.JsonReader
|
||||||
|
import com.squareup.moshi.JsonWriter
|
||||||
|
import com.squareup.moshi.Moshi
|
||||||
|
import com.squareup.moshi.ToJson
|
||||||
|
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||||
|
import okio.buffer
|
||||||
|
import okio.source
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.xtimms.tokusho.core.model.FavouriteCategory
|
||||||
|
import java.util.Date
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
object SampleData {
|
||||||
|
|
||||||
|
private val moshi = Moshi.Builder()
|
||||||
|
.add(DateAdapter())
|
||||||
|
.add(KotlinJsonAdapterFactory())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val manga: Manga = loadAsset("manga/header.json", Manga::class)
|
||||||
|
|
||||||
|
val mangaDetails: Manga = loadAsset("manga/full.json", Manga::class)
|
||||||
|
|
||||||
|
val tag = mangaDetails.tags.elementAt(2)
|
||||||
|
|
||||||
|
val chapter = checkNotNull(mangaDetails.chapters)[2]
|
||||||
|
|
||||||
|
val favouriteCategory: FavouriteCategory = loadAsset("categories/simple.json", FavouriteCategory::class)
|
||||||
|
|
||||||
|
fun <T : Any> loadAsset(name: String, cls: KClass<T>): T {
|
||||||
|
val assets = InstrumentationRegistry.getInstrumentation().context.assets
|
||||||
|
return assets.open(name).use {
|
||||||
|
moshi.adapter(cls.java).fromJson(it.source().buffer())
|
||||||
|
} ?: throw RuntimeException("Cannot read asset from json \"$name\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
private class DateAdapter : JsonAdapter<Date>() {
|
||||||
|
|
||||||
|
@FromJson
|
||||||
|
override fun fromJson(reader: JsonReader): Date? {
|
||||||
|
val ms = reader.nextLong()
|
||||||
|
return if (ms == 0L) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
Date(ms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ToJson
|
||||||
|
override fun toJson(writer: JsonWriter, value: Date?) {
|
||||||
|
writer.value(value?.time ?: 0L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
package org.xtimms.tokusho.sections.settings.backup
|
||||||
|
|
||||||
|
import android.content.res.AssetManager
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import dagger.hilt.android.testing.HiltAndroidRule
|
||||||
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.xtimms.tokusho.SampleData
|
||||||
|
import org.xtimms.tokusho.core.database.TokushoDatabase
|
||||||
|
import org.xtimms.tokusho.core.database.entity.toMangaTags
|
||||||
|
import org.xtimms.tokusho.data.repository.FavouritesRepository
|
||||||
|
import org.xtimms.tokusho.data.repository.HistoryRepository
|
||||||
|
import org.xtimms.tokusho.data.repository.backup.BackupRepository
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltAndroidTest
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class AppBackupAgentTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
var hiltRule = HiltAndroidRule(this)
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var historyRepository: HistoryRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var favouritesRepository: FavouritesRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var backupRepository: BackupRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var database: TokushoDatabase
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
hiltRule.inject()
|
||||||
|
database.clearAllTables()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun backupAndRestore() = runTest {
|
||||||
|
val category = favouritesRepository.createCategory(
|
||||||
|
title = SampleData.favouriteCategory.title,
|
||||||
|
sortOrder = SampleData.favouriteCategory.order,
|
||||||
|
isTrackerEnabled = SampleData.favouriteCategory.isTrackingEnabled,
|
||||||
|
isVisibleOnShelf = SampleData.favouriteCategory.isVisibleInLibrary,
|
||||||
|
)
|
||||||
|
favouritesRepository.addToCategory(categoryId = category.id, mangas = listOf(SampleData.manga))
|
||||||
|
historyRepository.addOrUpdate(
|
||||||
|
manga = SampleData.mangaDetails,
|
||||||
|
chapterId = SampleData.mangaDetails.chapters!![2].id,
|
||||||
|
page = 3,
|
||||||
|
scroll = 40,
|
||||||
|
percent = 0.2f,
|
||||||
|
)
|
||||||
|
val history = checkNotNull(historyRepository.getOne(SampleData.manga))
|
||||||
|
|
||||||
|
val agent = AppBackupAgent()
|
||||||
|
val backup = agent.createBackupFile(
|
||||||
|
context = InstrumentationRegistry.getInstrumentation().targetContext,
|
||||||
|
repository = backupRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
database.clearAllTables()
|
||||||
|
assertTrue(favouritesRepository.getAllManga().isEmpty())
|
||||||
|
assertNull(historyRepository.getLastOrNull())
|
||||||
|
|
||||||
|
backup.inputStream().use {
|
||||||
|
agent.restoreBackupFile(it.fd, backup.length(), backupRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(category, favouritesRepository.getCategory(category.id))
|
||||||
|
assertEquals(history, historyRepository.getOne(SampleData.manga))
|
||||||
|
assertEquals(listOf(SampleData.manga), favouritesRepository.getManga(category.id))
|
||||||
|
|
||||||
|
val allTags = database.getTagsDao().findTags(SampleData.tag.source.name).toMangaTags()
|
||||||
|
assertTrue(SampleData.tag in allTags)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun restoreOldBackup() {
|
||||||
|
val agent = AppBackupAgent()
|
||||||
|
val backup = File.createTempFile("backup_", ".tmp")
|
||||||
|
InstrumentationRegistry.getInstrumentation().context.assets
|
||||||
|
.open("kotatsu_test.bak", AssetManager.ACCESS_STREAMING)
|
||||||
|
.use { input ->
|
||||||
|
backup.outputStream().use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
backup.inputStream().use {
|
||||||
|
agent.restoreBackupFile(it.fd, backup.length(), backupRepository)
|
||||||
|
}
|
||||||
|
runTest {
|
||||||
|
assertEquals(6, historyRepository.observeAll().first().size)
|
||||||
|
assertEquals(2, favouritesRepository.observeCategories().first().size)
|
||||||
|
assertEquals(15, favouritesRepository.getAllManga().size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@ -0,0 +1,79 @@
|
|||||||
|
package org.xtimms.tokusho.core.base.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.CoroutineStart
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onCompletion
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.xtimms.tokusho.utils.lang.EventFlow
|
||||||
|
import org.xtimms.tokusho.utils.lang.MutableEventFlow
|
||||||
|
import org.xtimms.tokusho.utils.lang.call
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
import kotlin.coroutines.EmptyCoroutineContext
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
|
abstract class KotatsuBaseViewModel : ViewModel() {
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
protected val loadingCounter = MutableStateFlow(0)
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
protected val errorEvent = MutableEventFlow<Throwable>()
|
||||||
|
|
||||||
|
val onError: EventFlow<Throwable>
|
||||||
|
get() = errorEvent
|
||||||
|
|
||||||
|
val isLoading: StateFlow<Boolean> = loadingCounter.map { it > 0 }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.Lazily, loadingCounter.value > 0)
|
||||||
|
|
||||||
|
protected fun launchJob(
|
||||||
|
context: CoroutineContext = EmptyCoroutineContext,
|
||||||
|
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||||
|
block: suspend CoroutineScope.() -> Unit
|
||||||
|
): Job = viewModelScope.launch(context + createErrorHandler(), start, block)
|
||||||
|
|
||||||
|
protected fun launchLoadingJob(
|
||||||
|
context: CoroutineContext = EmptyCoroutineContext,
|
||||||
|
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||||
|
block: suspend CoroutineScope.() -> Unit
|
||||||
|
): Job = viewModelScope.launch(context + createErrorHandler(), start) {
|
||||||
|
loadingCounter.increment()
|
||||||
|
try {
|
||||||
|
block()
|
||||||
|
} finally {
|
||||||
|
loadingCounter.decrement()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun <T> Flow<T>.withLoading() = onStart {
|
||||||
|
loadingCounter.increment()
|
||||||
|
}.onCompletion {
|
||||||
|
loadingCounter.decrement()
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun <T> Flow<T>.withErrorHandling() = catch { error ->
|
||||||
|
errorEvent.call(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun MutableStateFlow<Int>.increment() = update { it + 1 }
|
||||||
|
|
||||||
|
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }
|
||||||
|
|
||||||
|
private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
|
||||||
|
if (throwable !is CancellationException) {
|
||||||
|
errorEvent.call(throwable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,114 @@
|
|||||||
|
package org.xtimms.tokusho.core.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Edit
|
||||||
|
import androidx.compose.material.ripple.rememberRipple
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.ripple
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.min
|
||||||
|
import org.xtimms.tokusho.ui.theme.TokushoTheme
|
||||||
|
import java.lang.Integer.MAX_VALUE
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
enum class ButtonType { PRIMARY, SECONDARY, TERTIARY, DELETE }
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun AnimatedButton(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
type: ButtonType,
|
||||||
|
icon: ImageVector? = null,
|
||||||
|
onClick: (() -> Unit) = {},
|
||||||
|
onLongClick: (() -> Unit) = {},
|
||||||
|
) {
|
||||||
|
val localDensity = LocalDensity.current
|
||||||
|
var minSize by remember { mutableStateOf(MAX_VALUE.dp) }
|
||||||
|
var minSizeFloat by remember { mutableStateOf(MAX_VALUE.toFloat()) }
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
val isPressed = interactionSource.collectIsPressedAsState()
|
||||||
|
val radius = animateDpAsState(targetValue = if (isPressed.value) 12.dp else minSize / 2)
|
||||||
|
|
||||||
|
val color = when (type) {
|
||||||
|
ButtonType.PRIMARY -> MaterialTheme.colorScheme.primaryContainer
|
||||||
|
ButtonType.SECONDARY -> MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
ButtonType.TERTIARY -> MaterialTheme.colorScheme.tertiaryContainer
|
||||||
|
ButtonType.DELETE -> MaterialTheme.colorScheme.errorContainer
|
||||||
|
}
|
||||||
|
|
||||||
|
val contentColor = when (type) {
|
||||||
|
ButtonType.PRIMARY -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
ButtonType.SECONDARY -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
ButtonType.TERTIARY -> MaterialTheme.colorScheme.onTertiaryContainer
|
||||||
|
ButtonType.DELETE -> MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
tonalElevation = 10.dp,
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.onGloballyPositioned {
|
||||||
|
minSize = with(localDensity) { min(it.size.height, it.size.width).toDp() }
|
||||||
|
minSizeFloat = min(it.size.height, it.size.width).toFloat()
|
||||||
|
}
|
||||||
|
.clip(RoundedCornerShape(radius.value))
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(color = color)
|
||||||
|
.fillMaxSize()
|
||||||
|
.clip(RoundedCornerShape(radius.value))
|
||||||
|
.combinedClickable(
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
indication = ripple(),
|
||||||
|
onClick = { onClick.invoke() },
|
||||||
|
onLongClick = { onLongClick.invoke() },
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (icon !== null) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
tint = contentColor,
|
||||||
|
modifier = Modifier.size(min(minSize * 0.5f, 154.dp)),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "Icon")
|
||||||
|
@Composable
|
||||||
|
private fun PreviewWithIcon() {
|
||||||
|
TokushoTheme {
|
||||||
|
AnimatedButton(
|
||||||
|
type = ButtonType.PRIMARY,
|
||||||
|
icon = Icons.Outlined.Edit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
package org.xtimms.tokusho.core.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.FloatTweenSpec
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.TweenSpec
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.PathEffect
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.times
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.xtimms.tokusho.core.components.shape.WavyShape
|
||||||
|
import org.xtimms.tokusho.utils.lang.clamp
|
||||||
|
import org.xtimms.tokusho.utils.material.HarmonizedColorPalette
|
||||||
|
import kotlin.math.ceil
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BackgroundProgress(
|
||||||
|
color: Color,
|
||||||
|
) {
|
||||||
|
|
||||||
|
val percentWithNewSpent = 0.3f
|
||||||
|
|
||||||
|
val percentWithNewSpentAnimated = animateFloatAsState(
|
||||||
|
label = "percentWithNewSpentAnimated",
|
||||||
|
targetValue = percentWithNewSpent,
|
||||||
|
animationSpec = TweenSpec(300),
|
||||||
|
).value
|
||||||
|
|
||||||
|
val shift = remember { Animatable(0f) }
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
fun anim() {
|
||||||
|
coroutineScope.launch {
|
||||||
|
shift.animateTo(
|
||||||
|
1f,
|
||||||
|
animationSpec = FloatTweenSpec(4000, 0, LinearEasing)
|
||||||
|
)
|
||||||
|
shift.snapTo(0f)
|
||||||
|
anim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
anim()
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
color.copy(alpha = 0.33f),
|
||||||
|
shape = WavyShape(
|
||||||
|
period = 30.dp,
|
||||||
|
amplitude = percentWithNewSpentAnimated.clamp(0.96f, 1f) * 2.dp,
|
||||||
|
shift = shift.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.fillMaxWidth(percentWithNewSpentAnimated),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
package org.xtimms.tokusho.core.components
|
||||||
|
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DotSeparatorText(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = " • ",
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DotSeparatorNoSpaceText(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "•",
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,125 @@
|
|||||||
|
package org.xtimms.tokusho.core.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.CubicBezierEasing
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.expandHorizontally
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkHorizontally
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.sizeIn
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.FloatingActionButtonDefaults
|
||||||
|
import androidx.compose.material3.FloatingActionButtonElevation
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.contentColorFor
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExtendedFloatingActionButton with custom transition between collapsed/expanded state.
|
||||||
|
*
|
||||||
|
* @see androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ExtendedFloatingActionButton(
|
||||||
|
text: @Composable () -> Unit,
|
||||||
|
icon: @Composable () -> Unit,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
expanded: Boolean = true,
|
||||||
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
shape: Shape = MaterialTheme.shapes.large,
|
||||||
|
containerColor: Color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
contentColor: Color = contentColorFor(containerColor),
|
||||||
|
elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(),
|
||||||
|
) {
|
||||||
|
val minWidth by animateDpAsState(
|
||||||
|
targetValue = if (expanded) ExtendedFabMinimumWidth else FabContainerWidth,
|
||||||
|
label = "minWidth",
|
||||||
|
)
|
||||||
|
FloatingActionButton(
|
||||||
|
modifier = modifier.sizeIn(minWidth = minWidth),
|
||||||
|
onClick = onClick,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
shape = shape,
|
||||||
|
containerColor = containerColor,
|
||||||
|
contentColor = contentColor,
|
||||||
|
elevation = elevation,
|
||||||
|
) {
|
||||||
|
val startPadding by animateDpAsState(
|
||||||
|
targetValue = if (expanded) ExtendedFabIconSize / 2 else 0.dp,
|
||||||
|
label = "startPadding",
|
||||||
|
)
|
||||||
|
val endPadding by animateDpAsState(
|
||||||
|
targetValue = if (expanded) ExtendedFabTextPadding else 0.dp,
|
||||||
|
label = "endPadding",
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(start = startPadding, end = endPadding),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
icon()
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = expanded,
|
||||||
|
enter = ExtendedFabExpandAnimation,
|
||||||
|
exit = ExtendedFabCollapseAnimation,
|
||||||
|
) {
|
||||||
|
Row {
|
||||||
|
Spacer(Modifier.width(ExtendedFabIconPadding))
|
||||||
|
text()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val EasingLinearCubicBezier = CubicBezierEasing(0.0f, 0.0f, 1.0f, 1.0f)
|
||||||
|
private val EasingEmphasizedCubicBezier = CubicBezierEasing(0.2f, 0.0f, 0.0f, 1.0f)
|
||||||
|
|
||||||
|
private val ExtendedFabMinimumWidth = 80.dp
|
||||||
|
private val ExtendedFabIconSize = 24.0.dp
|
||||||
|
private val ExtendedFabIconPadding = 12.dp
|
||||||
|
private val ExtendedFabTextPadding = 20.dp
|
||||||
|
|
||||||
|
private val ExtendedFabCollapseAnimation = fadeOut(
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 100,
|
||||||
|
easing = EasingLinearCubicBezier,
|
||||||
|
),
|
||||||
|
) + shrinkHorizontally(
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 500,
|
||||||
|
easing = EasingEmphasizedCubicBezier,
|
||||||
|
),
|
||||||
|
shrinkTowards = Alignment.Start,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val ExtendedFabExpandAnimation = fadeIn(
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 200,
|
||||||
|
delayMillis = 100,
|
||||||
|
easing = EasingLinearCubicBezier,
|
||||||
|
),
|
||||||
|
) + expandHorizontally(
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 500,
|
||||||
|
easing = EasingEmphasizedCubicBezier,
|
||||||
|
),
|
||||||
|
expandFrom = Alignment.Start,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val FabContainerWidth = 56.0.dp
|
||||||
@ -0,0 +1,290 @@
|
|||||||
|
package org.xtimms.tokusho.core.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animate
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.Saver
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import androidx.compose.ui.unit.Velocity
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.pow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param refreshing Whether the layout is currently refreshing
|
||||||
|
* @param onRefresh Lambda which is invoked when a swipe to refresh gesture is completed.
|
||||||
|
* @param enabled Whether the the layout should react to swipe gestures or not.
|
||||||
|
* @param indicatorPadding Content padding for the indicator, to inset the indicator in if required.
|
||||||
|
* @param content The content containing a vertically scrollable composable.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun PullRefresh(
|
||||||
|
refreshing: Boolean,
|
||||||
|
enabled: () -> Boolean,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
indicatorPadding: PaddingValues = PaddingValues(0.dp),
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val state = rememberPullToRefreshState(
|
||||||
|
isRefreshing = refreshing,
|
||||||
|
extraVerticalOffset = indicatorPadding.calculateTopPadding(),
|
||||||
|
enabled = enabled,
|
||||||
|
onRefresh = onRefresh,
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(modifier.nestedScroll(state.nestedScrollConnection)) {
|
||||||
|
content()
|
||||||
|
|
||||||
|
val contentPadding = remember(indicatorPadding) {
|
||||||
|
object : PaddingValues {
|
||||||
|
override fun calculateLeftPadding(layoutDirection: LayoutDirection): Dp =
|
||||||
|
indicatorPadding.calculateLeftPadding(layoutDirection)
|
||||||
|
|
||||||
|
override fun calculateTopPadding(): Dp = 0.dp
|
||||||
|
|
||||||
|
override fun calculateRightPadding(layoutDirection: LayoutDirection): Dp =
|
||||||
|
indicatorPadding.calculateRightPadding(layoutDirection)
|
||||||
|
|
||||||
|
override fun calculateBottomPadding(): Dp =
|
||||||
|
indicatorPadding.calculateBottomPadding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PullToRefreshContainer(
|
||||||
|
state = state,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.padding(contentPadding),
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberPullToRefreshState(
|
||||||
|
isRefreshing: Boolean,
|
||||||
|
extraVerticalOffset: Dp,
|
||||||
|
positionalThreshold: Dp = 64.dp,
|
||||||
|
enabled: () -> Boolean = { true },
|
||||||
|
onRefresh: () -> Unit,
|
||||||
|
): PullToRefreshStateImpl {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val extraVerticalOffsetPx = with(density) { extraVerticalOffset.toPx() }
|
||||||
|
val positionalThresholdPx = with(density) { positionalThreshold.toPx() }
|
||||||
|
return rememberSaveable(
|
||||||
|
extraVerticalOffset,
|
||||||
|
positionalThresholdPx,
|
||||||
|
enabled,
|
||||||
|
onRefresh,
|
||||||
|
saver = PullToRefreshStateImpl.Saver(
|
||||||
|
extraVerticalOffset = extraVerticalOffsetPx,
|
||||||
|
positionalThreshold = positionalThresholdPx,
|
||||||
|
enabled = enabled,
|
||||||
|
onRefresh = onRefresh,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
PullToRefreshStateImpl(
|
||||||
|
initialRefreshing = isRefreshing,
|
||||||
|
extraVerticalOffset = extraVerticalOffsetPx,
|
||||||
|
positionalThreshold = positionalThresholdPx,
|
||||||
|
enabled = enabled,
|
||||||
|
onRefresh = onRefresh,
|
||||||
|
)
|
||||||
|
}.also {
|
||||||
|
LaunchedEffect(isRefreshing) {
|
||||||
|
if (isRefreshing && !it.isRefreshing) {
|
||||||
|
it.startRefreshAnimated()
|
||||||
|
} else if (!isRefreshing && it.isRefreshing) {
|
||||||
|
it.endRefreshAnimated()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a [PullToRefreshState].
|
||||||
|
*
|
||||||
|
* @param positionalThreshold The positional threshold, in pixels, in which a refresh is triggered
|
||||||
|
* @param extraVerticalOffset Extra vertical offset, in pixels, for the "refreshing" state
|
||||||
|
* @param initialRefreshing The initial refreshing value of [PullToRefreshState]
|
||||||
|
* @param enabled a callback used to determine whether scroll events are to be handled by this
|
||||||
|
* @param onRefresh a callback to run when pull-to-refresh action is triggered by user
|
||||||
|
* [PullToRefreshState]
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
private class PullToRefreshStateImpl(
|
||||||
|
initialRefreshing: Boolean,
|
||||||
|
private val extraVerticalOffset: Float,
|
||||||
|
override val positionalThreshold: Float,
|
||||||
|
enabled: () -> Boolean,
|
||||||
|
private val onRefresh: () -> Unit,
|
||||||
|
) : PullToRefreshState {
|
||||||
|
|
||||||
|
override val progress get() = adjustedDistancePulled / positionalThreshold
|
||||||
|
override var verticalOffset by mutableFloatStateOf(if (initialRefreshing) refreshingVerticalOffset else 0f)
|
||||||
|
|
||||||
|
override var isRefreshing by mutableStateOf(initialRefreshing)
|
||||||
|
|
||||||
|
private val refreshingVerticalOffset: Float
|
||||||
|
get() = positionalThreshold + extraVerticalOffset
|
||||||
|
|
||||||
|
override fun startRefresh() {
|
||||||
|
isRefreshing = true
|
||||||
|
verticalOffset = refreshingVerticalOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun startRefreshAnimated() {
|
||||||
|
isRefreshing = true
|
||||||
|
animateTo(refreshingVerticalOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun endRefresh() {
|
||||||
|
verticalOffset = 0f
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun endRefreshAnimated() {
|
||||||
|
animateTo(0f)
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override var nestedScrollConnection = object : NestedScrollConnection {
|
||||||
|
override fun onPreScroll(
|
||||||
|
available: Offset,
|
||||||
|
source: NestedScrollSource,
|
||||||
|
): Offset = when {
|
||||||
|
!enabled() -> Offset.Zero
|
||||||
|
// Swiping up
|
||||||
|
source == NestedScrollSource.Drag && available.y < 0 -> {
|
||||||
|
consumeAvailableOffset(available)
|
||||||
|
}
|
||||||
|
else -> Offset.Zero
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPostScroll(
|
||||||
|
consumed: Offset,
|
||||||
|
available: Offset,
|
||||||
|
source: NestedScrollSource,
|
||||||
|
): Offset = when {
|
||||||
|
!enabled() -> Offset.Zero
|
||||||
|
// Swiping down
|
||||||
|
source == NestedScrollSource.Drag && available.y > 0 -> {
|
||||||
|
consumeAvailableOffset(available)
|
||||||
|
}
|
||||||
|
else -> Offset.Zero
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||||
|
return Velocity(0f, onRelease(available.y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper method for nested scroll connection */
|
||||||
|
fun consumeAvailableOffset(available: Offset): Offset {
|
||||||
|
val y = if (isRefreshing) {
|
||||||
|
0f
|
||||||
|
} else {
|
||||||
|
val newOffset = (distancePulled + available.y).coerceAtLeast(0f)
|
||||||
|
val dragConsumed = newOffset - distancePulled
|
||||||
|
distancePulled = newOffset
|
||||||
|
verticalOffset = calculateVerticalOffset() + (extraVerticalOffset * progress.coerceIn(0f, 1f))
|
||||||
|
dragConsumed
|
||||||
|
}
|
||||||
|
return Offset(0f, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper method for nested scroll connection. Calls onRefresh callback when triggered */
|
||||||
|
suspend fun onRelease(velocity: Float): Float {
|
||||||
|
if (isRefreshing) return 0f // Already refreshing, do nothing
|
||||||
|
// Trigger refresh
|
||||||
|
if (adjustedDistancePulled > positionalThreshold) {
|
||||||
|
onRefresh()
|
||||||
|
startRefreshAnimated()
|
||||||
|
} else {
|
||||||
|
animateTo(0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
val consumed = when {
|
||||||
|
// We are flinging without having dragged the pull refresh (for example a fling inside
|
||||||
|
// a list) - don't consume
|
||||||
|
distancePulled == 0f -> 0f
|
||||||
|
// If the velocity is negative, the fling is upwards, and we don't want to prevent the
|
||||||
|
// the list from scrolling
|
||||||
|
velocity < 0f -> 0f
|
||||||
|
// We are showing the indicator, and the fling is downwards - consume everything
|
||||||
|
else -> velocity
|
||||||
|
}
|
||||||
|
distancePulled = 0f
|
||||||
|
return consumed
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun animateTo(offset: Float) {
|
||||||
|
animate(initialValue = verticalOffset, targetValue = offset) { value, _ ->
|
||||||
|
verticalOffset = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provides custom vertical offset behavior for [PullToRefreshContainer] */
|
||||||
|
fun calculateVerticalOffset(): Float = when {
|
||||||
|
// If drag hasn't gone past the threshold, the position is the adjustedDistancePulled.
|
||||||
|
adjustedDistancePulled <= positionalThreshold -> adjustedDistancePulled
|
||||||
|
else -> {
|
||||||
|
// How far beyond the threshold pull has gone, as a percentage of the threshold.
|
||||||
|
val overshootPercent = abs(progress) - 1.0f
|
||||||
|
// Limit the overshoot to 200%. Linear between 0 and 200.
|
||||||
|
val linearTension = overshootPercent.coerceIn(0f, 2f)
|
||||||
|
// Non-linear tension. Increases with linearTension, but at a decreasing rate.
|
||||||
|
val tensionPercent = linearTension - linearTension.pow(2) / 4
|
||||||
|
// The additional offset beyond the threshold.
|
||||||
|
val extraOffset = positionalThreshold * tensionPercent
|
||||||
|
positionalThreshold + extraOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The default [Saver] for [PullToRefreshStateImpl]. */
|
||||||
|
fun Saver(
|
||||||
|
extraVerticalOffset: Float,
|
||||||
|
positionalThreshold: Float,
|
||||||
|
enabled: () -> Boolean,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
|
) = Saver<PullToRefreshStateImpl, Boolean>(
|
||||||
|
save = { it.isRefreshing },
|
||||||
|
restore = { isRefreshing ->
|
||||||
|
PullToRefreshStateImpl(
|
||||||
|
initialRefreshing = isRefreshing,
|
||||||
|
extraVerticalOffset = extraVerticalOffset,
|
||||||
|
positionalThreshold = positionalThreshold,
|
||||||
|
enabled = enabled,
|
||||||
|
onRefresh = onRefresh,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var distancePulled by mutableFloatStateOf(0f)
|
||||||
|
private val adjustedDistancePulled: Float get() = distancePulled * 0.5f
|
||||||
|
}
|
||||||
@ -0,0 +1,187 @@
|
|||||||
|
package org.xtimms.tokusho.core.components
|
||||||
|
|
||||||
|
import android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.FloatTweenSpec
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.TweenSpec
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.BlendMode
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
|
||||||
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.xtimms.tokusho.ui.theme.TokushoTheme
|
||||||
|
import org.xtimms.tokusho.utils.material.combineColors
|
||||||
|
import org.xtimms.tokusho.utils.material.harmonize
|
||||||
|
import org.xtimms.tokusho.utils.material.toPalette
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RowScope.ReadButton() {
|
||||||
|
|
||||||
|
val shift = remember { Animatable(0f) }
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
fun anim() {
|
||||||
|
coroutineScope.launch {
|
||||||
|
shift.animateTo(
|
||||||
|
1f,
|
||||||
|
animationSpec = FloatTweenSpec(4000, 0, LinearEasing)
|
||||||
|
)
|
||||||
|
shift.snapTo(0f)
|
||||||
|
anim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
anim()
|
||||||
|
}
|
||||||
|
val percentWithNewSpentAnimated = animateFloatAsState(
|
||||||
|
label = "percentWithNewSpentAnimated",
|
||||||
|
targetValue = 0.3f,
|
||||||
|
animationSpec = TweenSpec(300),
|
||||||
|
).value
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1F)
|
||||||
|
.height(54.dp),
|
||||||
|
shape = CircleShape,
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
onClick = {
|
||||||
|
// appViewModel.openSheet(PathState(WALLET_SHEET))
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxHeight(),
|
||||||
|
contentAlignment = Alignment.CenterEnd,
|
||||||
|
) {
|
||||||
|
BackgroundProgress(MaterialTheme.colorScheme.primary)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.drawWithLayer {
|
||||||
|
drawContent()
|
||||||
|
val leftOffset = size.width - 20.dp.toPx()
|
||||||
|
drawRect(
|
||||||
|
topLeft = Offset(leftOffset, 0f),
|
||||||
|
size = Size(
|
||||||
|
20.dp.toPx(),
|
||||||
|
size.height,
|
||||||
|
),
|
||||||
|
blendMode = BlendMode.SrcIn,
|
||||||
|
brush = Brush.horizontalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color.Black,
|
||||||
|
Color.Black.copy(alpha = 0f),
|
||||||
|
),
|
||||||
|
startX = leftOffset,
|
||||||
|
endX = leftOffset + 14.dp.toPx()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Start,
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Text(text = "Continue reading", color = MaterialTheme.colorScheme.onPrimaryContainer)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ContentDrawScope.drawWithLayer(block: ContentDrawScope.() -> Unit) {
|
||||||
|
with(drawContext.canvas.nativeCanvas) {
|
||||||
|
val checkPoint = saveLayer(null, null)
|
||||||
|
block()
|
||||||
|
restoreToCount(checkPoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Modifier.drawWithLayer(block: ContentDrawScope.() -> Unit) = this.then(
|
||||||
|
Modifier.drawWithContent {
|
||||||
|
drawWithLayer {
|
||||||
|
block()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@Preview(name = "The budget is almost completely spent")
|
||||||
|
@Composable
|
||||||
|
private fun Preview() {
|
||||||
|
TokushoTheme {
|
||||||
|
Row {
|
||||||
|
ReadButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "Budget half spent")
|
||||||
|
@Composable
|
||||||
|
private fun PreviewHalf() {
|
||||||
|
TokushoTheme {
|
||||||
|
Row {
|
||||||
|
ReadButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "Almost no budget")
|
||||||
|
@Composable
|
||||||
|
private fun PreviewFull() {
|
||||||
|
TokushoTheme {
|
||||||
|
Row {
|
||||||
|
ReadButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "Overspending budget")
|
||||||
|
@Composable
|
||||||
|
private fun PreviewOverspending() {
|
||||||
|
TokushoTheme {
|
||||||
|
Row {
|
||||||
|
ReadButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "Night mode", uiMode = UI_MODE_NIGHT_YES)
|
||||||
|
@Composable
|
||||||
|
private fun PreviewNightMode() {
|
||||||
|
TokushoTheme {
|
||||||
|
Row {
|
||||||
|
ReadButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
package org.xtimms.tokusho.core.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.StarOutline
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import org.xtimms.tokusho.R
|
||||||
|
import org.xtimms.tokusho.ui.theme.TokushoTheme
|
||||||
|
import org.xtimms.tokusho.utils.lang.toStringPositiveValueOrUnknown
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SmallScoreIndicator(
|
||||||
|
score: Float?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
fontSize: TextUnit = 16.sp,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.StarOutline,
|
||||||
|
contentDescription = stringResource(R.string.mean_score),
|
||||||
|
tint = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
if (score != null) {
|
||||||
|
Text(
|
||||||
|
text = (score.times(5.0F)).toStringPositiveValueOrUnknown(),
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
fontSize = fontSize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun SmallScoreIndicatorPreview() {
|
||||||
|
TokushoTheme {
|
||||||
|
SmallScoreIndicator(score = 1f)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
package org.xtimms.tokusho.core.components.effects
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
data class Snowflake(
|
||||||
|
var x: Float,
|
||||||
|
var y: Float,
|
||||||
|
var radius: Float,
|
||||||
|
var speed: Float
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SnowfallEffect() {
|
||||||
|
val snowflakes = remember { List(100) { generateRandomSnowflake() } }
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "")
|
||||||
|
|
||||||
|
val offsetY by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 100f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 10000, easing = LinearEasing)
|
||||||
|
), label = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
Canvas(modifier = Modifier.fillMaxSize().background(Color.Transparent)) {
|
||||||
|
snowflakes.forEach { snowflake ->
|
||||||
|
drawSnowflake(snowflake, offsetY % size.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun generateRandomSnowflake(): Snowflake {
|
||||||
|
return Snowflake(
|
||||||
|
x = Random.nextFloat(),
|
||||||
|
y = Random.nextFloat() * 1000f,
|
||||||
|
radius = Random.nextFloat() * 2f + 2f, // Snowflake size
|
||||||
|
speed = Random.nextFloat() * 0.5f + 1f // Falling speed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun DrawScope.drawSnowflake(snowflake: Snowflake, offsetY: Float) {
|
||||||
|
val newY = (snowflake.y + offsetY * snowflake.speed) % size.height
|
||||||
|
drawCircle(Color.White, radius = snowflake.radius, center = Offset(snowflake.x * size.width, newY))
|
||||||
|
}
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
package org.xtimms.tokusho.core.components.icons
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.materialIcon
|
||||||
|
import androidx.compose.material.icons.materialPath
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
|
||||||
|
public val Icons.Outlined.ArrowDecisionOutline: ImageVector
|
||||||
|
get() {
|
||||||
|
if (_arrow_decision_outline != null) {
|
||||||
|
return _arrow_decision_outline!!
|
||||||
|
}
|
||||||
|
_arrow_decision_outline = materialIcon(name = "Outlined.ArrowDecisionOutline") {
|
||||||
|
materialPath {
|
||||||
|
moveTo(9.64f, 13.4f)
|
||||||
|
curveTo(8.63f, 12.5f, 7.34f, 12.03f, 6.0f, 12.0f)
|
||||||
|
verticalLineTo(15.0f)
|
||||||
|
lineTo(2.0f, 11.0f)
|
||||||
|
lineTo(6.0f, 7.0f)
|
||||||
|
verticalLineTo(10.0f)
|
||||||
|
curveTo(7.67f, 10.0f, 9.3f, 10.57f, 10.63f, 11.59f)
|
||||||
|
curveTo(10.22f, 12.15f, 9.89f, 12.76f, 9.64f, 13.4f)
|
||||||
|
moveTo(18.0f, 15.0f)
|
||||||
|
verticalLineTo(12.0f)
|
||||||
|
curveTo(17.5f, 12.0f, 13.5f, 12.16f, 13.05f, 16.2f)
|
||||||
|
curveTo(14.61f, 16.75f, 15.43f, 18.47f, 14.88f, 20.03f)
|
||||||
|
curveTo(14.33f, 21.59f, 12.61f, 22.41f, 11.05f, 21.86f)
|
||||||
|
curveTo(9.5f, 21.3f, 8.67f, 19.59f, 9.22f, 18.03f)
|
||||||
|
curveTo(9.5f, 17.17f, 10.2f, 16.5f, 11.05f, 16.2f)
|
||||||
|
curveTo(11.34f, 12.61f, 14.4f, 9.88f, 18.0f, 10.0f)
|
||||||
|
verticalLineTo(7.0f)
|
||||||
|
lineTo(22.0f, 11.0f)
|
||||||
|
lineTo(18.0f, 15.0f)
|
||||||
|
moveTo(13.0f, 19.0f)
|
||||||
|
arcTo(1.0f, 1.0f, 0.0f, false, false, 12.0f, 18.0f)
|
||||||
|
arcTo(1.0f, 1.0f, 0.0f, false, false, 11.0f, 19.0f)
|
||||||
|
arcTo(1.0f, 1.0f, 0.0f, false, false, 12.0f, 20.0f)
|
||||||
|
arcTo(1.0f, 1.0f, 0.0f, false, false, 13.0f, 19.0f)
|
||||||
|
moveTo(11.0f, 11.12f)
|
||||||
|
curveTo(11.58f, 10.46f, 12.25f, 9.89f, 13.0f, 9.43f)
|
||||||
|
verticalLineTo(5.0f)
|
||||||
|
horizontalLineTo(16.0f)
|
||||||
|
lineTo(12.0f, 1.0f)
|
||||||
|
lineTo(8.0f, 5.0f)
|
||||||
|
horizontalLineTo(11.0f)
|
||||||
|
verticalLineTo(11.12f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _arrow_decision_outline!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private var _arrow_decision_outline: ImageVector? = null
|
||||||
@ -0,0 +1,293 @@
|
|||||||
|
package org.xtimms.tokusho.core.components.icons
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.PathFillType.Companion.NonZero
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap.Companion.Butt
|
||||||
|
import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector.Builder
|
||||||
|
import androidx.compose.ui.graphics.vector.path
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
public val Icons.Filled.Kotatsu: ImageVector
|
||||||
|
get() {
|
||||||
|
if (_kotatsu != null) {
|
||||||
|
return _kotatsu!!
|
||||||
|
}
|
||||||
|
_kotatsu = Builder(name = "Kotatsu", defaultWidth = 1406.2.dp, defaultHeight = 1406.2.dp,
|
||||||
|
viewportWidth = 1406.2f, viewportHeight = 1406.2f).apply {
|
||||||
|
path(fill = SolidColor(Color(0xFF0058C9)), stroke = null, strokeLineWidth = 0.0f,
|
||||||
|
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
|
||||||
|
pathFillType = NonZero) {
|
||||||
|
moveTo(391.7f, 270.7f)
|
||||||
|
curveToRelative(-51.6f, 18.6f, -96.2f, 88.4f, -117.9f, 183.6f)
|
||||||
|
curveToRelative(-7.8f, 34.8f, -15.1f, 93.5f, -15.1f, 121.7f)
|
||||||
|
verticalLineToRelative(19.7f)
|
||||||
|
lineToRelative(-23.3f, 36.6f)
|
||||||
|
curveToRelative(-65.0f, 101.3f, -124.6f, 206.8f, -180.5f, 319.2f)
|
||||||
|
curveTo(5.1f, 1051.0f, 0.0f, 1063.2f, 0.0f, 1080.9f)
|
||||||
|
curveToRelative(0.0f, 7.8f, 2.0f, 18.0f, 4.0f, 22.2f)
|
||||||
|
curveToRelative(6.6f, 12.0f, 22.2f, 24.4f, 39.7f, 31.0f)
|
||||||
|
lineToRelative(16.0f, 6.2f)
|
||||||
|
lineToRelative(651.1f, -0.2f)
|
||||||
|
curveToRelative(633.1f, 0.0f, 651.1f, -0.4f, 661.2f, -5.3f)
|
||||||
|
curveToRelative(32.6f, -16.9f, 43.0f, -51.7f, 26.2f, -88.0f)
|
||||||
|
curveToRelative(-63.8f, -139.0f, -150.5f, -296.8f, -229.6f, -418.5f)
|
||||||
|
lineToRelative(-19.1f, -29.5f)
|
||||||
|
lineToRelative(-2.2f, -33.7f)
|
||||||
|
curveToRelative(-8.7f, -129.4f, -36.1f, -208.6f, -92.0f, -266.5f)
|
||||||
|
curveToRelative(-24.2f, -24.8f, -33.5f, -30.4f, -50.6f, -30.6f)
|
||||||
|
curveToRelative(-23.9f, 0.0f, -39.9f, 10.9f, -75.6f, 52.1f)
|
||||||
|
curveToRelative(-35.2f, 40.4f, -42.4f, 50.1f, -66.9f, 86.4f)
|
||||||
|
curveToRelative(-12.0f, 17.7f, -27.0f, 38.3f, -33.2f, 45.5f)
|
||||||
|
lineToRelative(-11.3f, 13.5f)
|
||||||
|
horizontalLineToRelative(-117.0f)
|
||||||
|
lineToRelative(-117.0f, -0.2f)
|
||||||
|
lineTo(560.2f, 429.0f)
|
||||||
|
curveTo(515.0f, 359.2f, 440.7f, 274.5f, 419.6f, 268.7f)
|
||||||
|
curveTo(406.8f, 264.9f, 409.0f, 264.5f, 391.7f, 270.7f)
|
||||||
|
close()
|
||||||
|
moveTo(466.2f, 666.4f)
|
||||||
|
curveToRelative(8.9f, 6.2f, 11.3f, 11.8f, 14.4f, 37.7f)
|
||||||
|
curveToRelative(4.0f, 30.6f, 7.7f, 34.8f, 27.5f, 32.4f)
|
||||||
|
curveToRelative(18.0f, -2.2f, 32.6f, 3.6f, 40.8f, 16.6f)
|
||||||
|
curveToRelative(16.0f, 25.9f, -11.5f, 80.2f, -50.6f, 99.3f)
|
||||||
|
curveToRelative(-14.0f, 7.1f, -19.1f, 7.8f, -42.8f, 7.8f)
|
||||||
|
curveToRelative(-22.8f, 0.0f, -28.8f, -1.1f, -39.4f, -6.7f)
|
||||||
|
curveToRelative(-31.2f, -16.4f, -50.3f, -40.3f, -58.3f, -71.8f)
|
||||||
|
curveToRelative(-4.0f, -16.6f, -4.2f, -21.7f, -1.1f, -36.3f)
|
||||||
|
curveToRelative(4.2f, -21.1f, 11.5f, -35.2f, 24.8f, -50.1f)
|
||||||
|
curveTo(404.8f, 669.5f, 449.1f, 654.4f, 466.2f, 666.4f)
|
||||||
|
close()
|
||||||
|
moveTo(964.0f, 669.0f)
|
||||||
|
curveToRelative(8.7f, 7.3f, 9.3f, 9.7f, 13.5f, 43.4f)
|
||||||
|
curveToRelative(2.6f, 20.6f, 8.7f, 26.8f, 25.3f, 24.2f)
|
||||||
|
curveToRelative(16.0f, -2.2f, 29.9f, 2.2f, 39.2f, 12.9f)
|
||||||
|
curveToRelative(15.1f, 18.2f, 6.2f, 53.4f, -20.8f, 82.2f)
|
||||||
|
curveToRelative(-21.7f, 23.1f, -35.2f, 28.4f, -69.2f, 28.8f)
|
||||||
|
curveToRelative(-25.9f, 0.0f, -29.1f, -0.5f, -42.8f, -8.2f)
|
||||||
|
curveToRelative(-20.2f, -11.3f, -38.4f, -29.9f, -47.7f, -49.0f)
|
||||||
|
curveToRelative(-6.2f, -12.8f, -8.2f, -20.8f, -8.9f, -39.2f)
|
||||||
|
curveToRelative(-0.9f, -21.1f, 0.0f, -25.0f, 7.7f, -41.0f)
|
||||||
|
curveToRelative(14.0f, -30.4f, 35.5f, -49.2f, 65.0f, -57.0f)
|
||||||
|
curveTo(945.2f, 660.2f, 954.7f, 661.1f, 964.0f, 669.0f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
|
||||||
|
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
|
||||||
|
pathFillType = NonZero) {
|
||||||
|
moveTo(1806.5f, 939.2f)
|
||||||
|
verticalLineTo(464.0f)
|
||||||
|
horizontalLineToRelative(88.3f)
|
||||||
|
verticalLineToRelative(475.3f)
|
||||||
|
lineTo(1806.5f, 939.2f)
|
||||||
|
lineTo(1806.5f, 939.2f)
|
||||||
|
close()
|
||||||
|
moveTo(1885.3f, 827.2f)
|
||||||
|
lineToRelative(-4.8f, -104.5f)
|
||||||
|
lineTo(2129.7f, 464.0f)
|
||||||
|
horizontalLineToRelative(99.1f)
|
||||||
|
lineToRelative(-207.1f, 220.0f)
|
||||||
|
lineToRelative(-48.9f, 53.6f)
|
||||||
|
lineTo(1885.3f, 827.2f)
|
||||||
|
close()
|
||||||
|
moveTo(2137.8f, 939.2f)
|
||||||
|
lineToRelative(-181.9f, -216.6f)
|
||||||
|
lineToRelative(58.4f, -64.5f)
|
||||||
|
lineTo(2241.0f, 939.2f)
|
||||||
|
horizontalLineTo(2137.8f)
|
||||||
|
lineTo(2137.8f, 939.2f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
|
||||||
|
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
|
||||||
|
pathFillType = NonZero) {
|
||||||
|
moveTo(2440.6f, 944.0f)
|
||||||
|
curveToRelative(-37.1f, 0.0f, -70.2f, -8.0f, -99.1f, -24.1f)
|
||||||
|
curveToRelative(-29.0f, -16.1f, -51.8f, -38.1f, -68.6f, -66.2f)
|
||||||
|
curveToRelative(-16.8f, -28.1f, -25.1f, -60.0f, -25.1f, -95.7f)
|
||||||
|
curveToRelative(0.0f, -36.2f, 8.4f, -68.2f, 25.1f, -96.1f)
|
||||||
|
curveToRelative(16.7f, -27.8f, 39.6f, -49.7f, 68.6f, -65.5f)
|
||||||
|
reflectiveCurveToRelative(62.0f, -23.8f, 99.1f, -23.8f)
|
||||||
|
curveToRelative(37.6f, 0.0f, 70.9f, 7.9f, 100.1f, 23.8f)
|
||||||
|
curveToRelative(29.2f, 15.9f, 52.1f, 37.6f, 68.6f, 65.2f)
|
||||||
|
curveToRelative(16.5f, 27.6f, 24.8f, 59.8f, 24.8f, 96.4f)
|
||||||
|
curveToRelative(0.0f, 35.8f, -8.3f, 67.7f, -24.8f, 95.7f)
|
||||||
|
curveToRelative(-16.5f, 28.1f, -39.4f, 50.1f, -68.6f, 66.2f)
|
||||||
|
curveTo(2511.6f, 936.0f, 2478.2f, 944.0f, 2440.6f, 944.0f)
|
||||||
|
close()
|
||||||
|
moveTo(2440.6f, 871.3f)
|
||||||
|
curveToRelative(20.8f, 0.0f, 39.4f, -4.5f, 55.7f, -13.6f)
|
||||||
|
curveToRelative(16.3f, -9.0f, 29.1f, -22.2f, 38.4f, -39.4f)
|
||||||
|
curveToRelative(9.3f, -17.2f, 13.9f, -37.3f, 13.9f, -60.4f)
|
||||||
|
curveToRelative(0.0f, -23.5f, -4.6f, -43.8f, -13.9f, -60.8f)
|
||||||
|
curveToRelative(-9.3f, -17.0f, -22.1f, -30.0f, -38.4f, -39.0f)
|
||||||
|
curveToRelative(-16.3f, -9.0f, -34.6f, -13.6f, -55.0f, -13.6f)
|
||||||
|
curveToRelative(-20.8f, 0.0f, -39.3f, 4.5f, -55.3f, 13.6f)
|
||||||
|
curveToRelative(-16.1f, 9.0f, -28.8f, 22.1f, -38.3f, 39.0f)
|
||||||
|
curveToRelative(-9.5f, 17.0f, -14.3f, 37.2f, -14.3f, 60.8f)
|
||||||
|
curveToRelative(0.0f, 23.1f, 4.8f, 43.2f, 14.3f, 60.4f)
|
||||||
|
reflectiveCurveToRelative(22.3f, 30.3f, 38.3f, 39.4f)
|
||||||
|
curveTo(2402.0f, 866.8f, 2420.3f, 871.3f, 2440.6f, 871.3f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
|
||||||
|
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
|
||||||
|
pathFillType = NonZero) {
|
||||||
|
moveTo(2667.4f, 647.3f)
|
||||||
|
verticalLineToRelative(-67.9f)
|
||||||
|
horizontalLineToRelative(241.7f)
|
||||||
|
verticalLineToRelative(67.9f)
|
||||||
|
horizontalLineTo(2667.4f)
|
||||||
|
close()
|
||||||
|
moveTo(2852.1f, 944.0f)
|
||||||
|
curveToRelative(-39.8f, 0.0f, -70.6f, -10.3f, -92.3f, -30.9f)
|
||||||
|
curveToRelative(-21.7f, -20.6f, -32.6f, -51.0f, -32.6f, -91.3f)
|
||||||
|
verticalLineTo(496.6f)
|
||||||
|
horizontalLineToRelative(84.9f)
|
||||||
|
verticalLineToRelative(323.2f)
|
||||||
|
curveToRelative(0.0f, 17.2f, 4.4f, 30.5f, 13.2f, 40.1f)
|
||||||
|
curveToRelative(8.8f, 9.5f, 21.1f, 14.3f, 37.0f, 14.3f)
|
||||||
|
curveToRelative(19.0f, 0.0f, 34.9f, -5.0f, 47.5f, -14.9f)
|
||||||
|
lineToRelative(23.8f, 60.4f)
|
||||||
|
curveToRelative(-9.9f, 8.2f, -22.2f, 14.3f, -36.7f, 18.3f)
|
||||||
|
curveTo(2882.4f, 941.9f, 2867.4f, 944.0f, 2852.1f, 944.0f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
|
||||||
|
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
|
||||||
|
pathFillType = NonZero) {
|
||||||
|
moveTo(3112.8f, 944.0f)
|
||||||
|
curveToRelative(-27.2f, 0.0f, -50.9f, -4.6f, -71.3f, -13.9f)
|
||||||
|
curveToRelative(-20.4f, -9.3f, -36.1f, -22.2f, -47.2f, -38.7f)
|
||||||
|
curveToRelative(-11.1f, -16.5f, -16.6f, -35.2f, -16.6f, -56.0f)
|
||||||
|
curveToRelative(0.0f, -20.4f, 4.9f, -38.7f, 14.6f, -55.0f)
|
||||||
|
curveToRelative(9.7f, -16.3f, 25.7f, -29.2f, 47.9f, -38.7f)
|
||||||
|
reflectiveCurveToRelative(51.6f, -14.3f, 88.3f, -14.3f)
|
||||||
|
horizontalLineToRelative(105.2f)
|
||||||
|
verticalLineToRelative(56.3f)
|
||||||
|
horizontalLineToRelative(-99.1f)
|
||||||
|
curveToRelative(-29.0f, 0.0f, -48.4f, 4.7f, -58.4f, 13.9f)
|
||||||
|
curveToRelative(-10.0f, 9.3f, -14.9f, 20.7f, -14.9f, 34.3f)
|
||||||
|
curveToRelative(0.0f, 15.4f, 6.1f, 27.6f, 18.3f, 36.7f)
|
||||||
|
curveToRelative(12.2f, 9.1f, 29.2f, 13.6f, 50.9f, 13.6f)
|
||||||
|
curveToRelative(20.8f, 0.0f, 39.5f, -4.8f, 56.0f, -14.3f)
|
||||||
|
reflectiveCurveToRelative(28.4f, -23.5f, 35.6f, -42.1f)
|
||||||
|
lineToRelative(14.3f, 50.9f)
|
||||||
|
curveToRelative(-8.1f, 21.3f, -22.7f, 37.8f, -43.8f, 49.6f)
|
||||||
|
reflectiveCurveTo(3144.9f, 944.0f, 3112.8f, 944.0f)
|
||||||
|
close()
|
||||||
|
moveTo(3226.8f, 939.2f)
|
||||||
|
verticalLineToRelative(-73.3f)
|
||||||
|
lineToRelative(-4.8f, -15.6f)
|
||||||
|
verticalLineTo(722.0f)
|
||||||
|
curveToRelative(0.0f, -24.9f, -7.5f, -44.2f, -22.4f, -58.0f)
|
||||||
|
reflectiveCurveToRelative(-37.6f, -20.7f, -67.9f, -20.7f)
|
||||||
|
curveToRelative(-20.4f, 0.0f, -40.4f, 3.2f, -60.1f, 9.5f)
|
||||||
|
curveToRelative(-19.7f, 6.3f, -36.3f, 15.2f, -49.9f, 26.5f)
|
||||||
|
lineToRelative(-33.3f, -61.8f)
|
||||||
|
curveToRelative(19.4f, -14.9f, 42.6f, -26.1f, 69.6f, -33.6f)
|
||||||
|
curveToRelative(26.9f, -7.5f, 54.9f, -11.2f, 83.8f, -11.2f)
|
||||||
|
curveToRelative(52.5f, 0.0f, 93.1f, 12.6f, 121.9f, 37.7f)
|
||||||
|
curveToRelative(28.7f, 25.1f, 43.1f, 63.9f, 43.1f, 116.4f)
|
||||||
|
verticalLineToRelative(212.5f)
|
||||||
|
lineTo(3226.8f, 939.2f)
|
||||||
|
lineTo(3226.8f, 939.2f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
|
||||||
|
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
|
||||||
|
pathFillType = NonZero) {
|
||||||
|
moveTo(3367.4f, 647.3f)
|
||||||
|
verticalLineToRelative(-67.9f)
|
||||||
|
horizontalLineToRelative(241.7f)
|
||||||
|
verticalLineToRelative(67.9f)
|
||||||
|
horizontalLineTo(3367.4f)
|
||||||
|
close()
|
||||||
|
moveTo(3552.0f, 944.0f)
|
||||||
|
curveToRelative(-39.8f, 0.0f, -70.6f, -10.3f, -92.3f, -30.9f)
|
||||||
|
curveToRelative(-21.7f, -20.6f, -32.6f, -51.0f, -32.6f, -91.3f)
|
||||||
|
verticalLineTo(496.6f)
|
||||||
|
horizontalLineToRelative(84.9f)
|
||||||
|
verticalLineToRelative(323.2f)
|
||||||
|
curveToRelative(0.0f, 17.2f, 4.4f, 30.5f, 13.2f, 40.1f)
|
||||||
|
curveToRelative(8.8f, 9.5f, 21.1f, 14.3f, 37.0f, 14.3f)
|
||||||
|
curveToRelative(19.0f, 0.0f, 34.9f, -5.0f, 47.5f, -14.9f)
|
||||||
|
lineToRelative(23.8f, 60.4f)
|
||||||
|
curveToRelative(-9.9f, 8.2f, -22.2f, 14.3f, -36.7f, 18.3f)
|
||||||
|
curveTo(3582.3f, 941.9f, 3567.4f, 944.0f, 3552.0f, 944.0f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
|
||||||
|
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
|
||||||
|
pathFillType = NonZero) {
|
||||||
|
moveTo(3815.5f, 944.0f)
|
||||||
|
curveToRelative(-30.3f, 0.0f, -59.4f, -4.0f, -87.2f, -11.9f)
|
||||||
|
reflectiveCurveToRelative(-49.9f, -17.5f, -66.2f, -28.9f)
|
||||||
|
lineToRelative(32.6f, -64.5f)
|
||||||
|
curveToRelative(16.3f, 10.4f, 35.8f, 19.0f, 58.4f, 25.8f)
|
||||||
|
curveToRelative(22.6f, 6.8f, 45.3f, 10.2f, 67.9f, 10.2f)
|
||||||
|
curveToRelative(26.7f, 0.0f, 46.0f, -3.6f, 58.0f, -10.9f)
|
||||||
|
curveToRelative(12.0f, -7.2f, 18.0f, -17.0f, 18.0f, -29.2f)
|
||||||
|
curveToRelative(0.0f, -10.0f, -4.1f, -17.5f, -12.2f, -22.8f)
|
||||||
|
curveToRelative(-8.1f, -5.2f, -18.8f, -9.2f, -31.9f, -11.9f)
|
||||||
|
curveToRelative(-13.1f, -2.7f, -27.7f, -5.2f, -43.8f, -7.5f)
|
||||||
|
reflectiveCurveToRelative(-32.1f, -5.3f, -48.2f, -9.2f)
|
||||||
|
curveToRelative(-16.1f, -3.8f, -30.7f, -9.5f, -43.8f, -17.0f)
|
||||||
|
reflectiveCurveToRelative(-23.8f, -17.5f, -31.9f, -30.2f)
|
||||||
|
curveToRelative(-8.1f, -12.7f, -12.2f, -29.4f, -12.2f, -50.2f)
|
||||||
|
curveToRelative(0.0f, -23.1f, 6.6f, -43.1f, 19.7f, -60.1f)
|
||||||
|
curveToRelative(13.1f, -17.0f, 31.6f, -30.1f, 55.3f, -39.4f)
|
||||||
|
curveToRelative(23.8f, -9.3f, 51.9f, -13.9f, 84.5f, -13.9f)
|
||||||
|
curveToRelative(24.4f, 0.0f, 49.1f, 2.7f, 74.0f, 8.1f)
|
||||||
|
curveToRelative(24.9f, 5.4f, 45.5f, 13.1f, 61.8f, 23.1f)
|
||||||
|
lineToRelative(-32.6f, 64.5f)
|
||||||
|
curveToRelative(-17.2f, -10.4f, -34.5f, -17.5f, -51.9f, -21.4f)
|
||||||
|
curveToRelative(-17.4f, -3.8f, -34.7f, -5.8f, -51.9f, -5.8f)
|
||||||
|
curveToRelative(-25.8f, 0.0f, -44.9f, 3.9f, -57.4f, 11.5f)
|
||||||
|
curveToRelative(-12.4f, 7.7f, -18.7f, 17.4f, -18.7f, 29.2f)
|
||||||
|
curveToRelative(0.0f, 10.9f, 4.1f, 19.0f, 12.2f, 24.4f)
|
||||||
|
curveToRelative(8.1f, 5.4f, 18.8f, 9.7f, 31.9f, 12.9f)
|
||||||
|
curveToRelative(13.1f, 3.2f, 27.7f, 5.8f, 43.8f, 7.8f)
|
||||||
|
reflectiveCurveToRelative(32.0f, 5.1f, 47.9f, 9.2f)
|
||||||
|
curveToRelative(15.8f, 4.1f, 30.4f, 9.6f, 43.8f, 16.6f)
|
||||||
|
curveToRelative(13.3f, 7.0f, 24.1f, 16.9f, 32.3f, 29.5f)
|
||||||
|
curveToRelative(8.1f, 12.7f, 12.2f, 29.2f, 12.2f, 49.6f)
|
||||||
|
curveToRelative(0.0f, 22.6f, -6.7f, 42.3f, -20.0f, 59.1f)
|
||||||
|
curveToRelative(-13.4f, 16.8f, -32.3f, 29.8f, -56.7f, 39.0f)
|
||||||
|
curveTo(3878.6f, 939.3f, 3849.4f, 944.0f, 3815.5f, 944.0f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
|
||||||
|
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
|
||||||
|
pathFillType = NonZero) {
|
||||||
|
moveTo(4206.5f, 944.0f)
|
||||||
|
curveToRelative(-30.8f, 0.0f, -57.9f, -5.8f, -81.5f, -17.3f)
|
||||||
|
curveToRelative(-23.5f, -11.5f, -41.9f, -29.2f, -55.0f, -53.0f)
|
||||||
|
reflectiveCurveToRelative(-19.7f, -53.7f, -19.7f, -90.0f)
|
||||||
|
verticalLineToRelative(-207.0f)
|
||||||
|
horizontalLineToRelative(84.9f)
|
||||||
|
verticalLineToRelative(195.5f)
|
||||||
|
curveToRelative(0.0f, 32.6f, 7.4f, 56.9f, 22.1f, 73.0f)
|
||||||
|
reflectiveCurveToRelative(35.6f, 24.1f, 62.8f, 24.1f)
|
||||||
|
curveToRelative(19.9f, 0.0f, 37.2f, -4.1f, 51.9f, -12.2f)
|
||||||
|
curveToRelative(14.7f, -8.1f, 26.2f, -20.4f, 34.6f, -36.7f)
|
||||||
|
reflectiveCurveToRelative(12.6f, -36.4f, 12.6f, -60.4f)
|
||||||
|
verticalLineTo(576.7f)
|
||||||
|
horizontalLineToRelative(84.9f)
|
||||||
|
verticalLineToRelative(362.5f)
|
||||||
|
horizontalLineToRelative(-80.8f)
|
||||||
|
verticalLineToRelative(-97.8f)
|
||||||
|
lineToRelative(14.3f, 29.9f)
|
||||||
|
curveToRelative(-12.2f, 23.5f, -30.1f, 41.5f, -53.6f, 54.0f)
|
||||||
|
curveTo(4260.4f, 937.8f, 4234.6f, 944.0f, 4206.5f, 944.0f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
return _kotatsu!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private var _kotatsu: ImageVector? = null
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
package org.xtimms.tokusho.core.components.shape
|
||||||
|
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Outline
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.PathOperation
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import kotlin.math.ceil
|
||||||
|
|
||||||
|
class WavyShape(
|
||||||
|
private val period: Dp,
|
||||||
|
private val amplitude: Dp,
|
||||||
|
private val shift: Float,
|
||||||
|
) : Shape {
|
||||||
|
override fun createOutline(
|
||||||
|
size: Size,
|
||||||
|
layoutDirection: LayoutDirection,
|
||||||
|
density: Density,
|
||||||
|
) = Outline.Generic(Path().apply {
|
||||||
|
val halfPeriod = with(density) { period.toPx() } / 2
|
||||||
|
val amplitude = with(density) { amplitude.toPx() }
|
||||||
|
|
||||||
|
val wavyPath = Path().apply {
|
||||||
|
moveTo(x = 0f, y = 0f)
|
||||||
|
lineTo(size.width - amplitude, -halfPeriod * 2.5f + halfPeriod * 2 * shift)
|
||||||
|
repeat(ceil(size.height / halfPeriod + 3).toInt()) { i ->
|
||||||
|
relativeQuadraticBezierTo(
|
||||||
|
dx1 = 2 * amplitude * (if (i % 2 == 0) 1 else -1),
|
||||||
|
dy1 = halfPeriod / 2,
|
||||||
|
dx2 = 0f,
|
||||||
|
dy2 = halfPeriod,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
lineTo(0f, size.height)
|
||||||
|
}
|
||||||
|
val boundsPath = Path().apply {
|
||||||
|
addRect(Rect(offset = Offset.Zero, size = size))
|
||||||
|
}
|
||||||
|
op(wavyPath, boundsPath, PathOperation.Intersect)
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
package org.xtimms.tokusho.core.database.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Delete
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
|
import androidx.room.Upsert
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import org.xtimms.tokusho.core.database.entity.BookmarkEntity
|
||||||
|
import org.xtimms.tokusho.core.database.entity.MangaWithTags
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
abstract class BookmarksDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
|
||||||
|
abstract suspend fun find(mangaId: Long, pageId: Long): BookmarkEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM bookmarks WHERE page_id = :pageId")
|
||||||
|
abstract suspend fun find(pageId: Long): BookmarkEntity?
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query(
|
||||||
|
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent",
|
||||||
|
)
|
||||||
|
abstract suspend fun findAll(): Map<MangaWithTags, List<BookmarkEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page ORDER BY percent")
|
||||||
|
abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow<BookmarkEntity?>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId ORDER BY percent")
|
||||||
|
abstract fun observe(mangaId: Long): Flow<List<BookmarkEntity>>
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query(
|
||||||
|
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent",
|
||||||
|
)
|
||||||
|
abstract fun observe(): Flow<Map<MangaWithTags, List<BookmarkEntity>>>
|
||||||
|
|
||||||
|
@Insert
|
||||||
|
abstract suspend fun insert(entity: BookmarkEntity)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
abstract suspend fun delete(entity: BookmarkEntity)
|
||||||
|
|
||||||
|
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
|
||||||
|
abstract suspend fun delete(mangaId: Long, pageId: Long): Int
|
||||||
|
|
||||||
|
@Query("DELETE FROM bookmarks WHERE page_id = :pageId")
|
||||||
|
abstract suspend fun delete(pageId: Long): Int
|
||||||
|
|
||||||
|
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page")
|
||||||
|
abstract suspend fun delete(mangaId: Long, chapterId: Long, page: Int): Int
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
abstract suspend fun upsert(bookmarks: Collection<BookmarkEntity>)
|
||||||
|
}
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
package org.xtimms.tokusho.core.database.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Upsert
|
||||||
|
import org.xtimms.tokusho.core.database.entity.TagEntity
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
abstract class TagsDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM tags WHERE source = :source")
|
||||||
|
abstract suspend fun findTags(source: String): List<TagEntity>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""SELECT tags.* FROM tags
|
||||||
|
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||||
|
WHERE manga_tags.manga_id IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites)
|
||||||
|
GROUP BY tags.title
|
||||||
|
ORDER BY COUNT(manga_id) DESC
|
||||||
|
LIMIT :limit""",
|
||||||
|
)
|
||||||
|
abstract suspend fun findPopularTags(limit: Int): List<TagEntity>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""SELECT tags.* FROM tags
|
||||||
|
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||||
|
WHERE tags.source = :source
|
||||||
|
GROUP BY tags.title
|
||||||
|
ORDER BY COUNT(manga_id) DESC
|
||||||
|
LIMIT :limit""",
|
||||||
|
)
|
||||||
|
abstract suspend fun findPopularTags(source: String, limit: Int): List<TagEntity>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""SELECT tags.* FROM tags
|
||||||
|
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||||
|
WHERE tags.source = :source
|
||||||
|
GROUP BY tags.title
|
||||||
|
ORDER BY COUNT(manga_id) ASC
|
||||||
|
LIMIT :limit""",
|
||||||
|
)
|
||||||
|
abstract suspend fun findRareTags(source: String, limit: Int): List<TagEntity>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""SELECT tags.* FROM tags
|
||||||
|
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||||
|
WHERE tags.source = :source AND title LIKE :query
|
||||||
|
GROUP BY tags.title
|
||||||
|
ORDER BY COUNT(manga_id) DESC
|
||||||
|
LIMIT :limit""",
|
||||||
|
)
|
||||||
|
abstract suspend fun findTags(source: String, query: String, limit: Int): List<TagEntity>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""SELECT tags.* FROM tags
|
||||||
|
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||||
|
WHERE title LIKE :query AND manga_tags.manga_id IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites)
|
||||||
|
GROUP BY tags.title
|
||||||
|
ORDER BY COUNT(manga_id) DESC
|
||||||
|
LIMIT :limit""",
|
||||||
|
)
|
||||||
|
abstract suspend fun findTags(query: String, limit: Int): List<TagEntity>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT tags.* FROM manga_tags
|
||||||
|
LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id
|
||||||
|
WHERE manga_tags.manga_id IN (SELECT manga_id FROM manga_tags WHERE tag_id = :tagId)
|
||||||
|
GROUP BY tags.tag_id
|
||||||
|
ORDER BY COUNT(manga_id) DESC;
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
abstract suspend fun findRelatedTags(tagId: Long): List<TagEntity>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT tags.* FROM manga_tags
|
||||||
|
LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id
|
||||||
|
WHERE manga_tags.manga_id IN (SELECT manga_id FROM manga_tags WHERE tag_id IN (:ids))
|
||||||
|
GROUP BY tags.tag_id
|
||||||
|
ORDER BY COUNT(manga_id) DESC;
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
abstract suspend fun findRelatedTags(ids: Set<Long>): List<TagEntity>
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
abstract suspend fun upsert(tags: Iterable<TagEntity>)
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
package org.xtimms.tokusho.core.database.entity
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "bookmarks",
|
||||||
|
primaryKeys = ["manga_id", "page_id"],
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = MangaEntity::class,
|
||||||
|
parentColumns = ["manga_id"],
|
||||||
|
childColumns = ["manga_id"],
|
||||||
|
onDelete = ForeignKey.CASCADE
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
data class BookmarkEntity(
|
||||||
|
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
||||||
|
@ColumnInfo(name = "page_id", index = true) val pageId: Long,
|
||||||
|
@ColumnInfo(name = "chapter_id") val chapterId: Long,
|
||||||
|
@ColumnInfo(name = "page") val page: Int,
|
||||||
|
@ColumnInfo(name = "scroll") val scroll: Int,
|
||||||
|
@ColumnInfo(name = "image") val imageUrl: String,
|
||||||
|
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||||
|
@ColumnInfo(name = "percent") val percent: Float,
|
||||||
|
)
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package org.xtimms.tokusho.core.exceptions
|
||||||
|
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okio.IOException
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
|
class CloudflareProtectedException(
|
||||||
|
val url: String,
|
||||||
|
val source: MangaSource?,
|
||||||
|
@Transient val headers: Headers,
|
||||||
|
) : IOException("Protected by Cloudflare")
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
package org.xtimms.tokusho.core.model
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
import org.xtimms.tokusho.utils.hasImageExtension
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
data class Bookmark(
|
||||||
|
val manga: Manga,
|
||||||
|
val pageId: Long,
|
||||||
|
val chapterId: Long,
|
||||||
|
val page: Int,
|
||||||
|
val scroll: Int,
|
||||||
|
val imageUrl: String,
|
||||||
|
val createdAt: Instant,
|
||||||
|
val percent: Float,
|
||||||
|
) : ListModel {
|
||||||
|
|
||||||
|
val directImageUrl: String?
|
||||||
|
get() = if (isImageUrlDirect()) imageUrl else null
|
||||||
|
|
||||||
|
val imageLoadData: Any
|
||||||
|
get() = if (isImageUrlDirect()) imageUrl else toMangaPage()
|
||||||
|
|
||||||
|
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||||
|
return other is Bookmark &&
|
||||||
|
manga.id == other.manga.id &&
|
||||||
|
chapterId == other.chapterId &&
|
||||||
|
page == other.page
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toMangaPage() = MangaPage(
|
||||||
|
id = pageId,
|
||||||
|
url = imageUrl,
|
||||||
|
preview = null,
|
||||||
|
source = manga.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun isImageUrlDirect(): Boolean {
|
||||||
|
return hasImageExtension(imageUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
package org.xtimms.tokusho.core.model
|
||||||
|
|
||||||
|
import androidx.core.net.toFile
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
|
import org.xtimms.tokusho.utils.system.creationTime
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
data class LocalManga(
|
||||||
|
val manga: Manga,
|
||||||
|
val file: File = manga.url.toUri().toFile(),
|
||||||
|
) {
|
||||||
|
|
||||||
|
var createdAt: Long = -1L
|
||||||
|
private set
|
||||||
|
get() {
|
||||||
|
if (field == -1L) {
|
||||||
|
field = file.creationTime
|
||||||
|
}
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isMatchesQuery(query: String): Boolean {
|
||||||
|
return manga.title.contains(query, ignoreCase = true) ||
|
||||||
|
manga.altTitle?.contains(query, ignoreCase = true) == true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun containsTags(tags: Set<MangaTag>): Boolean {
|
||||||
|
return manga.tags.containsAll(tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun containsAnyTag(tags: Set<MangaTag>): Boolean {
|
||||||
|
return tags.any { tag ->
|
||||||
|
manga.tags.contains(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String {
|
||||||
|
return "LocalManga(${file.path}: ${manga.title})"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,64 @@
|
|||||||
package org.xtimms.tokusho.core.model
|
package org.xtimms.tokusho.core.model
|
||||||
|
|
||||||
|
import androidx.core.os.LocaleListCompat
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.xtimms.tokusho.utils.system.iterator
|
||||||
|
import java.text.DecimalFormat
|
||||||
|
import java.text.DecimalFormatSymbols
|
||||||
|
|
||||||
fun Collection<Manga>.distinctById() = distinctBy { it.id }
|
fun Collection<Manga>.distinctById() = distinctBy { it.id }
|
||||||
|
|
||||||
fun Collection<MangaChapter>.findById(id: Long) = find { x -> x.id == id }
|
fun Collection<MangaChapter>.findById(id: Long) = find { x -> x.id == id }
|
||||||
|
|
||||||
|
fun Manga.getPreferredBranch(history: MangaHistory?): String? {
|
||||||
|
val ch = chapters
|
||||||
|
if (ch.isNullOrEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (history != null) {
|
||||||
|
val currentChapter = ch.findById(history.chapterId)
|
||||||
|
if (currentChapter != null) {
|
||||||
|
return currentChapter.branch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val groups = ch.groupBy { it.branch }
|
||||||
|
if (groups.size == 1) {
|
||||||
|
return groups.keys.first()
|
||||||
|
}
|
||||||
|
for (locale in LocaleListCompat.getAdjustedDefault()) {
|
||||||
|
val displayLanguage = locale.getDisplayLanguage(locale)
|
||||||
|
val displayName = locale.getDisplayName(locale)
|
||||||
|
val candidates = HashMap<String?, List<MangaChapter>>(3)
|
||||||
|
for (branch in groups.keys) {
|
||||||
|
if (branch != null && (
|
||||||
|
branch.contains(displayLanguage, ignoreCase = true) ||
|
||||||
|
branch.contains(displayName, ignoreCase = true)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
candidates[branch] = groups[branch] ?: continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidates.isNotEmpty()) {
|
||||||
|
return candidates.maxBy { it.value.size }.key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups.maxByOrNull { it.value.size }?.key
|
||||||
|
}
|
||||||
|
|
||||||
|
private val chaptersNumberFormat = DecimalFormat("#.#").also { f ->
|
||||||
|
f.decimalFormatSymbols = DecimalFormatSymbols.getInstance().also {
|
||||||
|
it.decimalSeparator = '.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MangaChapter.formatNumber(): String? {
|
||||||
|
if (number <= 0f) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return chaptersNumberFormat.format(number.toDouble())
|
||||||
|
}
|
||||||
|
|
||||||
|
val Manga.isLocal: Boolean
|
||||||
|
get() = source == MangaSource.LOCAL
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
package org.xtimms.tokusho.core.network
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import javax.net.ssl.SSLContext
|
||||||
|
import javax.net.ssl.SSLSocketFactory
|
||||||
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
|
@SuppressLint("CustomX509TrustManager")
|
||||||
|
fun OkHttpClient.Builder.bypassSSLErrors() = also { builder ->
|
||||||
|
runCatching {
|
||||||
|
val trustAllCerts = object : X509TrustManager {
|
||||||
|
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) = Unit
|
||||||
|
|
||||||
|
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) = Unit
|
||||||
|
|
||||||
|
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
|
||||||
|
}
|
||||||
|
val sslContext = SSLContext.getInstance("SSL")
|
||||||
|
sslContext.init(null, arrayOf(trustAllCerts), SecureRandom())
|
||||||
|
val sslSocketFactory: SSLSocketFactory = sslContext.socketFactory
|
||||||
|
builder.sslSocketFactory(sslSocketFactory, trustAllCerts)
|
||||||
|
builder.hostnameVerifier { _, _ -> true }
|
||||||
|
}.onFailure {
|
||||||
|
it.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
package org.xtimms.tokusho.core.network.interceptors
|
||||||
|
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.internal.closeQuietly
|
||||||
|
import org.jsoup.Jsoup
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.xtimms.tokusho.core.exceptions.CloudflareProtectedException
|
||||||
|
import java.net.HttpURLConnection.HTTP_FORBIDDEN
|
||||||
|
import java.net.HttpURLConnection.HTTP_UNAVAILABLE
|
||||||
|
|
||||||
|
class CloudflareInterceptor : Interceptor {
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val response = chain.proceed(chain.request())
|
||||||
|
if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) {
|
||||||
|
val content = response.body?.let { response.peekBody(Long.MAX_VALUE) }?.byteStream()?.use {
|
||||||
|
Jsoup.parse(it, Charsets.UTF_8.name(), response.request.url.toString())
|
||||||
|
} ?: return response
|
||||||
|
if (content.getElementById("challenge-error-title") != null) {
|
||||||
|
val request = response.request
|
||||||
|
response.closeQuietly()
|
||||||
|
throw CloudflareProtectedException(
|
||||||
|
url = request.url.toString(),
|
||||||
|
source = request.tag(MangaSource::class.java),
|
||||||
|
headers = request.headers,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package org.xtimms.tokusho.core.parser.local
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import org.xtimms.tokusho.utils.system.URI_SCHEME_ZIP
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
private fun isCbzExtension(ext: String?): Boolean {
|
||||||
|
return ext.equals("cbz", ignoreCase = true) || ext.equals("zip", ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasCbzExtension(string: String): Boolean {
|
||||||
|
val ext = string.substringAfterLast('.', "")
|
||||||
|
return isCbzExtension(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun File.hasCbzExtension() = isCbzExtension(extension)
|
||||||
|
|
||||||
|
fun Uri.isZipUri() = scheme.let {
|
||||||
|
it == URI_SCHEME_ZIP || it == "cbz" || it == "zip"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Uri.isFileUri() = scheme == "file"
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
package org.xtimms.tokusho.core.parser.local
|
||||||
|
|
||||||
|
enum class DownloadFormat {
|
||||||
|
|
||||||
|
AUTOMATIC,
|
||||||
|
SINGLE_CBZ,
|
||||||
|
MULTIPLE_CBZ,
|
||||||
|
}
|
||||||
@ -0,0 +1,220 @@
|
|||||||
|
package org.xtimms.tokusho.core.parser.local
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.net.toFile
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
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.runCatchingCancellable
|
||||||
|
import org.xtimms.tokusho.core.model.LocalManga
|
||||||
|
import org.xtimms.tokusho.core.model.isLocal
|
||||||
|
import org.xtimms.tokusho.core.parser.MangaRepository
|
||||||
|
import org.xtimms.tokusho.core.parser.local.input.LocalMangaInput
|
||||||
|
import org.xtimms.tokusho.core.parser.local.output.LocalMangaOutput
|
||||||
|
import org.xtimms.tokusho.core.parser.local.output.LocalMangaUtil
|
||||||
|
import org.xtimms.tokusho.data.LocalStorageManager
|
||||||
|
import org.xtimms.tokusho.utils.AlphanumComparator
|
||||||
|
import org.xtimms.tokusho.utils.CompositeMutex2
|
||||||
|
import org.xtimms.tokusho.utils.system.children
|
||||||
|
import org.xtimms.tokusho.utils.system.deleteAwait
|
||||||
|
import org.xtimms.tokusho.utils.system.filterWith
|
||||||
|
import java.io.File
|
||||||
|
import java.util.EnumSet
|
||||||
|
import java.util.Locale
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
private const val MAX_PARALLELISM = 4
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class LocalMangaRepository @Inject constructor(
|
||||||
|
private val storageManager: LocalStorageManager,
|
||||||
|
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
|
||||||
|
) : MangaRepository {
|
||||||
|
|
||||||
|
override val source = MangaSource.LOCAL
|
||||||
|
private val locks = CompositeMutex2<Long>()
|
||||||
|
|
||||||
|
override val isMultipleTagsSupported: Boolean = true
|
||||||
|
override val isTagsExclusionSupported: Boolean = true
|
||||||
|
override val isSearchSupported: Boolean = true
|
||||||
|
override val sortOrders: Set<SortOrder> = EnumSet.of(SortOrder.ALPHABETICAL, SortOrder.RATING, SortOrder.NEWEST)
|
||||||
|
override val states = emptySet<MangaState>()
|
||||||
|
override val contentRatings = emptySet<ContentRating>()
|
||||||
|
|
||||||
|
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
|
||||||
|
if (offset > 0) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
val list = getRawList()
|
||||||
|
when (filter) {
|
||||||
|
is MangaListFilter.Search -> {
|
||||||
|
list.retainAll { x -> x.isMatchesQuery(filter.query) }
|
||||||
|
}
|
||||||
|
|
||||||
|
is MangaListFilter.Advanced -> {
|
||||||
|
if (filter.tags.isNotEmpty()) {
|
||||||
|
list.retainAll { x -> x.containsTags(filter.tags) }
|
||||||
|
}
|
||||||
|
if (filter.tagsExclude.isNotEmpty()) {
|
||||||
|
list.removeAll { x -> x.containsAnyTag(filter.tags) }
|
||||||
|
}
|
||||||
|
when (filter.sortOrder) {
|
||||||
|
SortOrder.ALPHABETICAL -> list.sortWith(compareBy(AlphanumComparator()) { x -> x.manga.title })
|
||||||
|
SortOrder.RATING -> list.sortByDescending { it.manga.rating }
|
||||||
|
SortOrder.NEWEST,
|
||||||
|
SortOrder.UPDATED,
|
||||||
|
-> list.sortByDescending { it.createdAt }
|
||||||
|
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
null -> Unit
|
||||||
|
}
|
||||||
|
return list.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getDetails(manga: Manga): Manga = when {
|
||||||
|
manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)?.manga) {
|
||||||
|
"Manga is not local or saved"
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> LocalMangaInput.of(manga).getManga().manga
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
|
return LocalMangaInput.of(chapter).getPages(chapter)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun delete(manga: Manga): Boolean {
|
||||||
|
val file = Uri.parse(manga.url).toFile()
|
||||||
|
val result = file.deleteAwait()
|
||||||
|
if (result) {
|
||||||
|
localStorageChanges.emit(null)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteChapters(manga: Manga, ids: Set<Long>) {
|
||||||
|
lockManga(manga.id)
|
||||||
|
try {
|
||||||
|
val subject = if (manga.isLocal) manga else checkNotNull(findSavedManga(manga)) {
|
||||||
|
"Manga is not stored on local storage"
|
||||||
|
}.manga
|
||||||
|
LocalMangaUtil(subject).deleteChapters(ids)
|
||||||
|
localStorageChanges.emit(LocalManga(subject))
|
||||||
|
} finally {
|
||||||
|
unlockManga(manga.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getRemoteManga(localManga: Manga): Manga? {
|
||||||
|
return runCatchingCancellable {
|
||||||
|
LocalMangaInput.of(localManga).getMangaInfo()?.takeUnless { it.isLocal }
|
||||||
|
}.onFailure {
|
||||||
|
it.printStackTrace()
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun findSavedManga(remoteManga: Manga): LocalManga? = runCatchingCancellable {
|
||||||
|
// fast path
|
||||||
|
LocalMangaInput.find(storageManager.getReadableDirs(), remoteManga)?.let {
|
||||||
|
return it.getManga()
|
||||||
|
}
|
||||||
|
// slow path
|
||||||
|
val files = getAllFiles()
|
||||||
|
return channelFlow {
|
||||||
|
for (file in files) {
|
||||||
|
launch {
|
||||||
|
val mangaInput = LocalMangaInput.of(file)
|
||||||
|
runCatchingCancellable {
|
||||||
|
val mangaInfo = mangaInput.getMangaInfo()
|
||||||
|
if (mangaInfo != null && mangaInfo.id == remoteManga.id) {
|
||||||
|
send(mangaInput)
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
it.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.firstOrNull()?.getManga()
|
||||||
|
}.onFailure {
|
||||||
|
it.printStackTrace()
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
override suspend fun getPageUrl(page: MangaPage) = page.url
|
||||||
|
|
||||||
|
override suspend fun getTags() = emptySet<MangaTag>()
|
||||||
|
|
||||||
|
override suspend fun getLocales() = emptySet<Locale>()
|
||||||
|
|
||||||
|
override suspend fun getRelated(seed: Manga): List<Manga> = emptyList()
|
||||||
|
|
||||||
|
suspend fun getOutputDir(manga: Manga): File? {
|
||||||
|
val defaultDir = storageManager.getDefaultWriteableDir()
|
||||||
|
if (defaultDir != null && LocalMangaOutput.get(defaultDir, manga) != null) {
|
||||||
|
return defaultDir
|
||||||
|
}
|
||||||
|
return storageManager.getWriteableDirs()
|
||||||
|
.firstOrNull {
|
||||||
|
LocalMangaOutput.get(it, manga) != null
|
||||||
|
} ?: defaultDir
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun cleanup(): Boolean {
|
||||||
|
if (locks.isNotEmpty()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val dirs = storageManager.getWriteableDirs()
|
||||||
|
runInterruptible(Dispatchers.IO) {
|
||||||
|
dirs.flatMap { dir ->
|
||||||
|
dir.children().filterWith(TempFileFilter())
|
||||||
|
}.forEach { file ->
|
||||||
|
file.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun lockManga(id: Long) {
|
||||||
|
locks.lock(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unlockManga(id: Long) {
|
||||||
|
locks.unlock(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getRawList(): ArrayList<LocalManga> {
|
||||||
|
val files = getAllFiles().toList() // TODO remove toList()
|
||||||
|
return coroutineScope {
|
||||||
|
val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM)
|
||||||
|
files.map { file ->
|
||||||
|
async(dispatcher) {
|
||||||
|
runCatchingCancellable { LocalMangaInput.ofOrNull(file)?.getManga() }.getOrNull()
|
||||||
|
}
|
||||||
|
}.awaitAll()
|
||||||
|
}.filterNotNullTo(ArrayList(files.size))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getAllFiles() = storageManager.getReadableDirs().asSequence().flatMap { dir ->
|
||||||
|
dir.children()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Collection<LocalManga>.unwrap(): List<Manga> = map { it.manga }
|
||||||
|
}
|
||||||
@ -0,0 +1,200 @@
|
|||||||
|
package org.xtimms.tokusho.core.parser.local
|
||||||
|
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
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.model.MangaState
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
|
import org.koitharu.kotatsu.parsers.util.find
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet
|
||||||
|
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||||
|
import org.xtimms.tokusho.BuildConfig
|
||||||
|
import org.xtimms.tokusho.core.model.isLocal
|
||||||
|
import org.xtimms.tokusho.utils.AlphanumComparator
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class MangaIndex(source: String?) {
|
||||||
|
|
||||||
|
private val json: JSONObject = source?.let(::JSONObject) ?: JSONObject()
|
||||||
|
|
||||||
|
fun setMangaInfo(manga: Manga) {
|
||||||
|
require(!manga.isLocal) { "Local manga information cannot be stored" }
|
||||||
|
json.put("id", manga.id)
|
||||||
|
json.put("title", manga.title)
|
||||||
|
json.put("title_alt", manga.altTitle)
|
||||||
|
json.put("url", manga.url)
|
||||||
|
json.put("public_url", manga.publicUrl)
|
||||||
|
json.put("author", manga.author)
|
||||||
|
json.put("cover", manga.coverUrl)
|
||||||
|
json.put("description", manga.description)
|
||||||
|
json.put("rating", manga.rating)
|
||||||
|
json.put("nsfw", manga.isNsfw)
|
||||||
|
json.put("state", manga.state?.name)
|
||||||
|
json.put("source", manga.source.name)
|
||||||
|
json.put("cover_large", manga.largeCoverUrl)
|
||||||
|
json.put(
|
||||||
|
"tags",
|
||||||
|
JSONArray().also { a ->
|
||||||
|
for (tag in manga.tags) {
|
||||||
|
val jo = JSONObject()
|
||||||
|
jo.put("key", tag.key)
|
||||||
|
jo.put("title", tag.title)
|
||||||
|
a.put(jo)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!json.has("chapters")) {
|
||||||
|
json.put("chapters", JSONObject())
|
||||||
|
}
|
||||||
|
json.put("app_id", BuildConfig.APPLICATION_ID)
|
||||||
|
json.put("app_version", BuildConfig.VERSION_CODE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMangaInfo(): Manga? = if (json.length() == 0) null else runCatching {
|
||||||
|
val source = MangaSource.valueOf(json.getString("source"))
|
||||||
|
Manga(
|
||||||
|
id = json.getLong("id"),
|
||||||
|
title = json.getString("title"),
|
||||||
|
altTitle = json.getStringOrNull("title_alt"),
|
||||||
|
url = json.getString("url"),
|
||||||
|
publicUrl = json.getStringOrNull("public_url").orEmpty(),
|
||||||
|
author = json.getStringOrNull("author"),
|
||||||
|
largeCoverUrl = json.getStringOrNull("cover_large"),
|
||||||
|
source = source,
|
||||||
|
rating = json.getDouble("rating").toFloat(),
|
||||||
|
isNsfw = json.getBooleanOrDefault("nsfw", false),
|
||||||
|
coverUrl = json.getString("cover"),
|
||||||
|
state = json.getStringOrNull("state")?.let { stateString ->
|
||||||
|
MangaState.entries.find(stateString)
|
||||||
|
},
|
||||||
|
description = json.getStringOrNull("description"),
|
||||||
|
tags = json.getJSONArray("tags").mapJSONToSet { x ->
|
||||||
|
MangaTag(
|
||||||
|
title = x.getString("title").toTitleCase(),
|
||||||
|
key = x.getString("key"),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
chapters = getChapters(json.getJSONObject("chapters"), source),
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
fun getCoverEntry(): String? = json.getStringOrNull("cover_entry")
|
||||||
|
|
||||||
|
fun addChapter(chapter: IndexedValue<MangaChapter>, filename: String?) {
|
||||||
|
val chapters = json.getJSONObject("chapters")
|
||||||
|
if (!chapters.has(chapter.value.id.toString())) {
|
||||||
|
val jo = JSONObject()
|
||||||
|
jo.put("number", chapter.value.number)
|
||||||
|
jo.put("volume", chapter.value.volume)
|
||||||
|
jo.put("url", chapter.value.url)
|
||||||
|
jo.put("name", chapter.value.name)
|
||||||
|
jo.put("uploadDate", chapter.value.uploadDate)
|
||||||
|
jo.put("scanlator", chapter.value.scanlator)
|
||||||
|
jo.put("branch", chapter.value.branch)
|
||||||
|
jo.put("entries", "%08d_%03d\\d{3}".format(chapter.value.branch.hashCode(), chapter.index + 1))
|
||||||
|
jo.put("file", filename)
|
||||||
|
chapters.put(chapter.value.id.toString(), jo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeChapter(id: Long): Boolean {
|
||||||
|
return json.getJSONObject("chapters").remove(id.toString()) != null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getChapterFileName(chapterId: Long): String? {
|
||||||
|
return json.optJSONObject("chapters")?.optJSONObject(chapterId.toString())?.getStringOrNull("file")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCoverEntry(name: String) {
|
||||||
|
json.put("cover_entry", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getChapterNamesPattern(chapter: MangaChapter) = Regex(
|
||||||
|
json.getJSONObject("chapters")
|
||||||
|
.getJSONObject(chapter.id.toString())
|
||||||
|
.getString("entries"),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun sortChaptersByName() {
|
||||||
|
val jo = json.getJSONObject("chapters")
|
||||||
|
val list = ArrayList<JSONObject>(jo.length())
|
||||||
|
jo.keys().forEach { id ->
|
||||||
|
val item = jo.getJSONObject(id)
|
||||||
|
item.put("id", id)
|
||||||
|
list.add(item)
|
||||||
|
}
|
||||||
|
val comparator = AlphanumComparator()
|
||||||
|
list.sortWith(compareBy(comparator) { it.getString("name") })
|
||||||
|
val newJo = JSONObject()
|
||||||
|
list.forEachIndexed { i, obj ->
|
||||||
|
obj.put("number", i + 1)
|
||||||
|
val id = obj.remove("id") as String
|
||||||
|
newJo.put(id, obj)
|
||||||
|
}
|
||||||
|
json.put("chapters", newJo)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
val keys = json.keys()
|
||||||
|
while (keys.hasNext()) {
|
||||||
|
json.remove(keys.next())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setFrom(other: MangaIndex) {
|
||||||
|
clear()
|
||||||
|
other.json.keys().forEach { key ->
|
||||||
|
json.putOpt(key, other.json.opt(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getChapters(json: JSONObject, source: MangaSource): List<MangaChapter> {
|
||||||
|
val chapters = ArrayList<MangaChapter>(json.length())
|
||||||
|
for (k in json.keys()) {
|
||||||
|
val v = json.getJSONObject(k)
|
||||||
|
chapters.add(
|
||||||
|
MangaChapter(
|
||||||
|
id = k.toLong(),
|
||||||
|
name = v.getString("name"),
|
||||||
|
url = v.getString("url"),
|
||||||
|
number = v.getFloatOrDefault("number", 0f),
|
||||||
|
volume = v.getIntOrDefault("volume", 0),
|
||||||
|
uploadDate = v.getLongOrDefault("uploadDate", 0L),
|
||||||
|
scanlator = v.getStringOrNull("scanlator"),
|
||||||
|
branch = v.getStringOrNull("branch"),
|
||||||
|
source = source,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return chapters.sortedBy { it.number }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String = if (BuildConfig.DEBUG) {
|
||||||
|
json.toString(4)
|
||||||
|
} else {
|
||||||
|
json.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
fun read(file: File): MangaIndex? {
|
||||||
|
if (file.exists() && file.canRead()) {
|
||||||
|
val text = file.readText()
|
||||||
|
if (text.length > 2) {
|
||||||
|
return MangaIndex(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
package org.xtimms.tokusho.core.parser.local
|
||||||
|
|
||||||
|
import javax.inject.Qualifier
|
||||||
|
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class LocalStorageChanges
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package org.xtimms.tokusho.core.parser.local
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileFilter
|
||||||
|
|
||||||
|
class TempFileFilter : FileFilter {
|
||||||
|
|
||||||
|
override fun accept(file: File): Boolean {
|
||||||
|
return file.name.endsWith(".tmp", ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,158 @@
|
|||||||
|
package org.xtimms.tokusho.core.parser.local.input
|
||||||
|
|
||||||
|
import androidx.core.net.toFile
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.util.toCamelCase
|
||||||
|
import org.xtimms.tokusho.core.model.LocalManga
|
||||||
|
import org.xtimms.tokusho.core.parser.local.MangaIndex
|
||||||
|
import org.xtimms.tokusho.core.parser.local.hasCbzExtension
|
||||||
|
import org.xtimms.tokusho.core.parser.local.output.LocalMangaOutput
|
||||||
|
import org.xtimms.tokusho.utils.AlphanumComparator
|
||||||
|
import org.xtimms.tokusho.utils.hasImageExtension
|
||||||
|
import org.xtimms.tokusho.utils.lang.longHashCode
|
||||||
|
import org.xtimms.tokusho.utils.lang.toListSorted
|
||||||
|
import org.xtimms.tokusho.utils.system.children
|
||||||
|
import org.xtimms.tokusho.utils.system.creationTime
|
||||||
|
import org.xtimms.tokusho.utils.system.walkCompat
|
||||||
|
import java.io.File
|
||||||
|
import java.util.TreeMap
|
||||||
|
import java.util.zip.ZipFile
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manga {Folder}
|
||||||
|
* |--- index.json (optional)
|
||||||
|
* |--- Chapter 1.cbz
|
||||||
|
* |--- Page 1.png
|
||||||
|
* :
|
||||||
|
* L--- Page x.png
|
||||||
|
* |--- Chapter 2.cbz
|
||||||
|
* :
|
||||||
|
* L--- Chapter x.cbz
|
||||||
|
*/
|
||||||
|
class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
|
||||||
|
|
||||||
|
override suspend fun getManga(): LocalManga = runInterruptible(Dispatchers.IO) {
|
||||||
|
val index = MangaIndex.read(File(root, LocalMangaOutput.ENTRY_NAME_INDEX))
|
||||||
|
val mangaUri = root.toUri().toString()
|
||||||
|
val chapterFiles = getChaptersFiles()
|
||||||
|
val info = index?.getMangaInfo()
|
||||||
|
val cover = fileUri(
|
||||||
|
root,
|
||||||
|
index?.getCoverEntry() ?: findFirstImageEntry().orEmpty(),
|
||||||
|
)
|
||||||
|
val manga = info?.copy2(
|
||||||
|
source = MangaSource.LOCAL,
|
||||||
|
url = mangaUri,
|
||||||
|
coverUrl = cover,
|
||||||
|
largeCoverUrl = cover,
|
||||||
|
chapters = info.chapters?.mapIndexedNotNull { i, c ->
|
||||||
|
val fileName = index.getChapterFileName(c.id)
|
||||||
|
val file = if (fileName != null) {
|
||||||
|
chapterFiles[fileName]
|
||||||
|
} else {
|
||||||
|
// old downloads
|
||||||
|
chapterFiles.values.elementAtOrNull(i)
|
||||||
|
} ?: return@mapIndexedNotNull null
|
||||||
|
c.copy(url = file.toUri().toString(), source = MangaSource.LOCAL)
|
||||||
|
},
|
||||||
|
) ?: Manga(
|
||||||
|
id = root.absolutePath.longHashCode(),
|
||||||
|
title = root.name.toHumanReadable(),
|
||||||
|
url = mangaUri,
|
||||||
|
publicUrl = mangaUri,
|
||||||
|
source = MangaSource.LOCAL,
|
||||||
|
coverUrl = findFirstImageEntry().orEmpty(),
|
||||||
|
chapters = chapterFiles.values.mapIndexed { i, f ->
|
||||||
|
MangaChapter(
|
||||||
|
id = "$i${f.name}".longHashCode(),
|
||||||
|
name = f.nameWithoutExtension.toHumanReadable(),
|
||||||
|
number = 0f,
|
||||||
|
volume = 0,
|
||||||
|
source = MangaSource.LOCAL,
|
||||||
|
uploadDate = f.creationTime,
|
||||||
|
url = f.toUri().toString(),
|
||||||
|
scanlator = null,
|
||||||
|
branch = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
altTitle = null,
|
||||||
|
rating = -1f,
|
||||||
|
isNsfw = false,
|
||||||
|
tags = setOf(),
|
||||||
|
state = null,
|
||||||
|
author = null,
|
||||||
|
largeCoverUrl = null,
|
||||||
|
description = null,
|
||||||
|
)
|
||||||
|
LocalManga(manga, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) {
|
||||||
|
val index = MangaIndex.read(File(root, LocalMangaOutput.ENTRY_NAME_INDEX))
|
||||||
|
index?.getMangaInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
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, MangaSource.LOCAL)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ZipFile(file).use { zip ->
|
||||||
|
zip.entries()
|
||||||
|
.asSequence()
|
||||||
|
.filter { x -> !x.isDirectory }
|
||||||
|
.map { it.name }
|
||||||
|
.toListSorted(AlphanumComparator())
|
||||||
|
.map {
|
||||||
|
val pageUri = zipUri(file, it)
|
||||||
|
MangaPage(
|
||||||
|
id = pageUri.longHashCode(),
|
||||||
|
url = pageUri,
|
||||||
|
preview = null,
|
||||||
|
source = MangaSource.LOCAL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toHumanReadable() = replace("_", " ").toCamelCase()
|
||||||
|
|
||||||
|
private fun getChaptersFiles() = root.walkCompat(includeDirectories = true)
|
||||||
|
.filter { it != root && it.isChapterDirectory() || it.hasCbzExtension() }
|
||||||
|
.associateByTo(TreeMap(AlphanumComparator())) { it.name }
|
||||||
|
|
||||||
|
private fun findFirstImageEntry(): String? {
|
||||||
|
return root.walkCompat(includeDirectories = false)
|
||||||
|
.firstOrNull { hasImageExtension(it) }?.toUri()?.toString()
|
||||||
|
?: run {
|
||||||
|
val cbz = root.walkCompat(includeDirectories = false)
|
||||||
|
.firstOrNull { it.hasCbzExtension() } ?: return null
|
||||||
|
ZipFile(cbz).use { zip ->
|
||||||
|
zip.entries().asSequence()
|
||||||
|
.firstOrNull { !it.isDirectory && hasImageExtension(it.name) }
|
||||||
|
?.let { zipUri(cbz, it.name) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fileUri(base: File, name: String): String {
|
||||||
|
return File(base, name).toUri().toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.isChapterDirectory(): Boolean {
|
||||||
|
return isDirectory && children().any { hasImageExtension(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,111 @@
|
|||||||
|
package org.xtimms.tokusho.core.parser.local.input
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.net.toFile
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
|
||||||
|
import org.xtimms.tokusho.core.model.LocalManga
|
||||||
|
import org.xtimms.tokusho.core.parser.local.hasCbzExtension
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
sealed class LocalMangaInput(
|
||||||
|
protected val root: File,
|
||||||
|
) {
|
||||||
|
|
||||||
|
abstract suspend fun getManga(): LocalManga
|
||||||
|
|
||||||
|
abstract suspend fun getMangaInfo(): Manga?
|
||||||
|
|
||||||
|
abstract suspend fun getPages(chapter: MangaChapter): List<MangaPage>
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun of(manga: Manga): LocalMangaInput = of(Uri.parse(manga.url).toFile())
|
||||||
|
|
||||||
|
fun of(chapter: MangaChapter): LocalMangaInput = of(Uri.parse(chapter.url).toFile())
|
||||||
|
|
||||||
|
fun of(file: File): LocalMangaInput = when {
|
||||||
|
file.isDirectory -> LocalMangaDirInput(file)
|
||||||
|
else -> LocalMangaZipInput(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ofOrNull(file: File): LocalMangaInput? = when {
|
||||||
|
file.isDirectory -> LocalMangaDirInput(file)
|
||||||
|
hasCbzExtension(file.name) -> LocalMangaZipInput(file)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun find(roots: Iterable<File>, manga: Manga): LocalMangaInput? = channelFlow {
|
||||||
|
val fileName = manga.title.toFileNameSafe()
|
||||||
|
for (root in roots) {
|
||||||
|
launch {
|
||||||
|
val dir = File(root, fileName)
|
||||||
|
val zip = File(root, "$fileName.cbz")
|
||||||
|
val input = when {
|
||||||
|
dir.isDirectory -> LocalMangaDirInput(dir)
|
||||||
|
zip.isFile -> LocalMangaZipInput(zip)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val info = runCatchingCancellable { input?.getMangaInfo() }.getOrNull()
|
||||||
|
if (info?.id == manga.id) {
|
||||||
|
send(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.flowOn(Dispatchers.Default).firstOrNull()
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
protected fun zipUri(file: File, entryName: String): String =
|
||||||
|
Uri.fromParts("cbz", file.path, entryName).toString()
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
protected fun Manga.copy2(
|
||||||
|
url: String,
|
||||||
|
coverUrl: String,
|
||||||
|
largeCoverUrl: String,
|
||||||
|
chapters: List<MangaChapter>?,
|
||||||
|
source: MangaSource,
|
||||||
|
) = Manga(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
altTitle = altTitle,
|
||||||
|
url = url,
|
||||||
|
publicUrl = publicUrl,
|
||||||
|
rating = rating,
|
||||||
|
isNsfw = isNsfw,
|
||||||
|
coverUrl = coverUrl,
|
||||||
|
tags = tags,
|
||||||
|
state = state,
|
||||||
|
author = author,
|
||||||
|
largeCoverUrl = largeCoverUrl,
|
||||||
|
description = description,
|
||||||
|
chapters = chapters,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
protected fun MangaChapter.copy(
|
||||||
|
url: String,
|
||||||
|
source: MangaSource,
|
||||||
|
) = MangaChapter(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
number = number,
|
||||||
|
volume = volume,
|
||||||
|
url = url,
|
||||||
|
scanlator = scanlator,
|
||||||
|
uploadDate = uploadDate,
|
||||||
|
branch = branch,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,154 @@
|
|||||||
|
package org.xtimms.tokusho.core.parser.local.input
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import androidx.collection.ArraySet
|
||||||
|
import androidx.core.net.toFile
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.util.toCamelCase
|
||||||
|
import org.xtimms.tokusho.core.model.LocalManga
|
||||||
|
import org.xtimms.tokusho.core.parser.local.MangaIndex
|
||||||
|
import org.xtimms.tokusho.core.parser.local.output.LocalMangaOutput
|
||||||
|
import org.xtimms.tokusho.utils.AlphanumComparator
|
||||||
|
import org.xtimms.tokusho.utils.lang.longHashCode
|
||||||
|
import org.xtimms.tokusho.utils.lang.toListSorted
|
||||||
|
import org.xtimms.tokusho.utils.system.readText
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Enumeration
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipFile
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manga archive {.cbz or .zip file}
|
||||||
|
* |--- index.json (optional)
|
||||||
|
* |--- Page 1.png
|
||||||
|
* |--- Page 2.png
|
||||||
|
* :
|
||||||
|
* L--- Page x.png
|
||||||
|
*/
|
||||||
|
class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
|
||||||
|
|
||||||
|
override suspend fun getManga(): LocalManga {
|
||||||
|
val manga = runInterruptible(Dispatchers.IO) {
|
||||||
|
ZipFile(root).use { zip ->
|
||||||
|
val fileUri = root.toUri().toString()
|
||||||
|
val entry = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)
|
||||||
|
val index = entry?.let(zip::readText)?.let(::MangaIndex)
|
||||||
|
val info = index?.getMangaInfo()
|
||||||
|
if (info != null) {
|
||||||
|
val cover = zipUri(
|
||||||
|
root,
|
||||||
|
entryName = index.getCoverEntry() ?: findFirstImageEntry(zip.entries())?.name.orEmpty(),
|
||||||
|
)
|
||||||
|
return@use info.copy2(
|
||||||
|
source = MangaSource.LOCAL,
|
||||||
|
url = fileUri,
|
||||||
|
coverUrl = cover,
|
||||||
|
largeCoverUrl = cover,
|
||||||
|
chapters = info.chapters?.map { c ->
|
||||||
|
c.copy(url = fileUri, source = MangaSource.LOCAL)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// fallback
|
||||||
|
val title = root.nameWithoutExtension.replace("_", " ").toCamelCase()
|
||||||
|
val chapters = ArraySet<String>()
|
||||||
|
for (x in zip.entries()) {
|
||||||
|
if (!x.isDirectory) {
|
||||||
|
chapters += x.name.substringBeforeLast(File.separatorChar, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val uriBuilder = root.toUri().buildUpon()
|
||||||
|
Manga(
|
||||||
|
id = root.absolutePath.longHashCode(),
|
||||||
|
title = title,
|
||||||
|
url = fileUri,
|
||||||
|
publicUrl = fileUri,
|
||||||
|
source = MangaSource.LOCAL,
|
||||||
|
coverUrl = zipUri(root, findFirstImageEntry(zip.entries())?.name.orEmpty()),
|
||||||
|
chapters = chapters.sortedWith(AlphanumComparator())
|
||||||
|
.mapIndexed { i, s ->
|
||||||
|
MangaChapter(
|
||||||
|
id = "$i$s".longHashCode(),
|
||||||
|
name = s.ifEmpty { title },
|
||||||
|
number = 0f,
|
||||||
|
volume = 0,
|
||||||
|
source = MangaSource.LOCAL,
|
||||||
|
uploadDate = 0L,
|
||||||
|
url = uriBuilder.fragment(s).build().toString(),
|
||||||
|
scanlator = null,
|
||||||
|
branch = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
altTitle = null,
|
||||||
|
rating = -1f,
|
||||||
|
isNsfw = false,
|
||||||
|
tags = setOf(),
|
||||||
|
state = null,
|
||||||
|
author = null,
|
||||||
|
largeCoverUrl = null,
|
||||||
|
description = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return LocalManga(manga, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) {
|
||||||
|
ZipFile(root).use { zip ->
|
||||||
|
val entry = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)
|
||||||
|
val index = entry?.let(zip::readText)?.let(::MangaIndex)
|
||||||
|
index?.getMangaInfo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
|
return runInterruptible(Dispatchers.IO) {
|
||||||
|
val uri = Uri.parse(chapter.url)
|
||||||
|
val file = uri.toFile()
|
||||||
|
val zip = ZipFile(file)
|
||||||
|
val index = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)?.let(zip::readText)?.let(::MangaIndex)
|
||||||
|
var entries = zip.entries().asSequence()
|
||||||
|
entries = if (index != null) {
|
||||||
|
val pattern = index.getChapterNamesPattern(chapter)
|
||||||
|
entries.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) }
|
||||||
|
} else {
|
||||||
|
val parent = uri.fragment.orEmpty()
|
||||||
|
entries.filter { x ->
|
||||||
|
!x.isDirectory && x.name.substringBeforeLast(
|
||||||
|
File.separatorChar,
|
||||||
|
"",
|
||||||
|
) == parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries
|
||||||
|
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
|
||||||
|
.map { x ->
|
||||||
|
val entryUri = zipUri(file, x.name)
|
||||||
|
MangaPage(
|
||||||
|
id = entryUri.longHashCode(),
|
||||||
|
url = entryUri,
|
||||||
|
preview = null,
|
||||||
|
source = MangaSource.LOCAL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findFirstImageEntry(entries: Enumeration<out ZipEntry>): ZipEntry? {
|
||||||
|
val list = entries.toList()
|
||||||
|
.filterNot { it.isDirectory }
|
||||||
|
.sortedWith(compareBy(AlphanumComparator()) { x -> x.name })
|
||||||
|
val map = MimeTypeMap.getSingleton()
|
||||||
|
return list.firstOrNull {
|
||||||
|
map.getMimeTypeFromExtension(it.name.substringAfterLast('.'))
|
||||||
|
?.startsWith("image/") == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,136 @@
|
|||||||
|
package org.xtimms.tokusho.core.parser.local.output
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
|
||||||
|
import org.xtimms.tokusho.core.model.isLocal
|
||||||
|
import org.xtimms.tokusho.core.parser.local.MangaIndex
|
||||||
|
import org.xtimms.tokusho.core.zip.ZipOutput
|
||||||
|
import org.xtimms.tokusho.utils.system.deleteAwait
|
||||||
|
import org.xtimms.tokusho.utils.system.takeIfReadable
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class LocalMangaDirOutput(
|
||||||
|
rootFile: File,
|
||||||
|
manga: Manga,
|
||||||
|
) : LocalMangaOutput(rootFile) {
|
||||||
|
|
||||||
|
private val chaptersOutput = HashMap<MangaChapter, ZipOutput>()
|
||||||
|
private val index = MangaIndex(File(rootFile, ENTRY_NAME_INDEX).takeIfReadable()?.readText())
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (!manga.isLocal) {
|
||||||
|
index.setMangaInfo(manga)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun mergeWithExisting() = Unit
|
||||||
|
|
||||||
|
override suspend fun addCover(file: File, ext: String) = mutex.withLock {
|
||||||
|
val name = buildString {
|
||||||
|
append("cover")
|
||||||
|
if (ext.isNotEmpty() && ext.length <= 4) {
|
||||||
|
append('.')
|
||||||
|
append(ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runInterruptible(Dispatchers.IO) {
|
||||||
|
file.copyTo(File(rootFile, name), overwrite = true)
|
||||||
|
}
|
||||||
|
index.setCoverEntry(name)
|
||||||
|
flushIndex()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun addPage(chapter: IndexedValue<MangaChapter>, file: File, pageNumber: Int, ext: String) = mutex.withLock {
|
||||||
|
val output = chaptersOutput.getOrPut(chapter.value) {
|
||||||
|
ZipOutput(File(rootFile, chapterFileName(chapter) + SUFFIX_TMP))
|
||||||
|
}
|
||||||
|
val name = buildString {
|
||||||
|
append(FILENAME_PATTERN.format(chapter.value.branch.hashCode(), chapter.index + 1, pageNumber))
|
||||||
|
if (ext.isNotEmpty() && ext.length <= 4) {
|
||||||
|
append('.')
|
||||||
|
append(ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runInterruptible(Dispatchers.IO) {
|
||||||
|
output.put(name, file)
|
||||||
|
}
|
||||||
|
index.addChapter(chapter, chapterFileName(chapter))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun flushChapter(chapter: MangaChapter): Boolean = mutex.withLock {
|
||||||
|
val output = chaptersOutput.remove(chapter) ?: return@withLock false
|
||||||
|
output.flushAndFinish()
|
||||||
|
flushIndex()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun finish() = mutex.withLock {
|
||||||
|
flushIndex()
|
||||||
|
for (output in chaptersOutput.values) {
|
||||||
|
output.flushAndFinish()
|
||||||
|
}
|
||||||
|
chaptersOutput.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun cleanup() = mutex.withLock {
|
||||||
|
for (output in chaptersOutput.values) {
|
||||||
|
output.file.deleteAwait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
for (output in chaptersOutput.values) {
|
||||||
|
output.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteChapter(chapterId: Long) = mutex.withLock {
|
||||||
|
val chapter = checkNotNull(index.getMangaInfo()?.chapters?.withIndex()) {
|
||||||
|
"No chapters found"
|
||||||
|
}.find { x -> x.value.id == chapterId } ?: error("Chapter not found")
|
||||||
|
val chapterDir = File(rootFile, chapterFileName(chapter))
|
||||||
|
chapterDir.deleteAwait()
|
||||||
|
index.removeChapter(chapterId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setIndex(newIndex: MangaIndex) {
|
||||||
|
index.setFrom(newIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ZipOutput.flushAndFinish() = runInterruptible(Dispatchers.IO) {
|
||||||
|
finish()
|
||||||
|
close()
|
||||||
|
val resFile = File(file.absolutePath.removeSuffix(SUFFIX_TMP))
|
||||||
|
file.renameTo(resFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chapterFileName(chapter: IndexedValue<MangaChapter>): String {
|
||||||
|
index.getChapterFileName(chapter.value.id)?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
val baseName = "${chapter.index}_${chapter.value.name.toFileNameSafe()}".take(18)
|
||||||
|
var i = 0
|
||||||
|
while (true) {
|
||||||
|
val name = (if (i == 0) baseName else baseName + "_$i") + ".cbz"
|
||||||
|
if (!File(rootFile, name).exists()) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun flushIndex() = runInterruptible(Dispatchers.IO) {
|
||||||
|
File(rootFile, ENTRY_NAME_INDEX).writeText(index.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val FILENAME_PATTERN = "%08d_%03d%03d"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
package org.xtimms.tokusho.core.parser.local.output
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okio.Closeable
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
|
||||||
|
import org.xtimms.tokusho.core.parser.local.DownloadFormat
|
||||||
|
import org.xtimms.tokusho.core.parser.local.input.LocalMangaInput
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
sealed class LocalMangaOutput(
|
||||||
|
val rootFile: File,
|
||||||
|
) : Closeable {
|
||||||
|
|
||||||
|
abstract suspend fun mergeWithExisting()
|
||||||
|
|
||||||
|
abstract suspend fun addCover(file: File, ext: String)
|
||||||
|
|
||||||
|
abstract suspend fun addPage(chapter: IndexedValue<MangaChapter>, file: File, pageNumber: Int, ext: String)
|
||||||
|
|
||||||
|
abstract suspend fun flushChapter(chapter: MangaChapter): Boolean
|
||||||
|
|
||||||
|
abstract suspend fun finish()
|
||||||
|
|
||||||
|
abstract suspend fun cleanup()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
const val ENTRY_NAME_INDEX = "index.json"
|
||||||
|
const val SUFFIX_TMP = ".tmp"
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
suspend fun getOrCreate(
|
||||||
|
root: File,
|
||||||
|
manga: Manga,
|
||||||
|
format: DownloadFormat,
|
||||||
|
): LocalMangaOutput = withContext(Dispatchers.IO) {
|
||||||
|
val targetFormat = if (format == DownloadFormat.AUTOMATIC) {
|
||||||
|
if (manga.chapters.let { it != null && it.size <= 3 }) {
|
||||||
|
DownloadFormat.SINGLE_CBZ
|
||||||
|
} else {
|
||||||
|
DownloadFormat.MULTIPLE_CBZ
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
format
|
||||||
|
}
|
||||||
|
checkNotNull(getImpl(root, manga, onlyIfExists = false, format = targetFormat))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun get(root: File, manga: Manga): LocalMangaOutput? = withContext(Dispatchers.IO) {
|
||||||
|
getImpl(root, manga, onlyIfExists = true, format = DownloadFormat.AUTOMATIC)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getImpl(
|
||||||
|
root: File,
|
||||||
|
manga: Manga,
|
||||||
|
onlyIfExists: Boolean,
|
||||||
|
format: DownloadFormat,
|
||||||
|
): LocalMangaOutput? {
|
||||||
|
mutex.withLock {
|
||||||
|
var i = 0
|
||||||
|
val baseName = manga.title.toFileNameSafe()
|
||||||
|
while (true) {
|
||||||
|
val fileName = if (i == 0) baseName else baseName + "_$i"
|
||||||
|
val dir = File(root, fileName)
|
||||||
|
val zip = File(root, "$fileName.cbz")
|
||||||
|
i++
|
||||||
|
return when {
|
||||||
|
dir.isDirectory -> {
|
||||||
|
if (canWriteTo(dir, manga)) {
|
||||||
|
LocalMangaDirOutput(dir, manga)
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
zip.isFile -> if (canWriteTo(zip, manga)) {
|
||||||
|
LocalMangaZipOutput(zip, manga)
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
!onlyIfExists -> when (format) {
|
||||||
|
DownloadFormat.AUTOMATIC -> null
|
||||||
|
DownloadFormat.SINGLE_CBZ -> LocalMangaZipOutput(zip, manga)
|
||||||
|
DownloadFormat.MULTIPLE_CBZ -> LocalMangaDirOutput(dir, manga)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun canWriteTo(file: File, manga: Manga): Boolean {
|
||||||
|
val info = runCatchingCancellable {
|
||||||
|
LocalMangaInput.of(file).getMangaInfo()
|
||||||
|
}.onFailure {
|
||||||
|
it.printStackTrace()
|
||||||
|
}.getOrNull() ?: return false
|
||||||
|
return info.id == manga.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
package org.xtimms.tokusho.core.parser.local.output
|
||||||
|
|
||||||
|
import androidx.core.net.toFile
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
|
class LocalMangaUtil(
|
||||||
|
private val manga: Manga,
|
||||||
|
) {
|
||||||
|
|
||||||
|
init {
|
||||||
|
require(manga.source == MangaSource.LOCAL) {
|
||||||
|
"Expected LOCAL source but ${manga.source} found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteChapters(ids: Set<Long>) {
|
||||||
|
newOutput().use { output ->
|
||||||
|
when (output) {
|
||||||
|
is LocalMangaZipOutput -> runInterruptible(Dispatchers.IO) {
|
||||||
|
LocalMangaZipOutput.filterChapters(output, ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
is LocalMangaDirOutput -> {
|
||||||
|
for (id in ids) {
|
||||||
|
output.deleteChapter(id)
|
||||||
|
}
|
||||||
|
output.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun newOutput(): LocalMangaOutput = runInterruptible(Dispatchers.IO) {
|
||||||
|
val file = manga.url.toUri().toFile()
|
||||||
|
if (file.isDirectory) {
|
||||||
|
LocalMangaDirOutput(file, manga)
|
||||||
|
} else {
|
||||||
|
LocalMangaZipOutput(file, manga)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,156 @@
|
|||||||
|
package org.xtimms.tokusho.core.parser.local.output
|
||||||
|
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.xtimms.tokusho.core.model.isLocal
|
||||||
|
import org.xtimms.tokusho.core.parser.local.MangaIndex
|
||||||
|
import org.xtimms.tokusho.core.zip.ZipOutput
|
||||||
|
import org.xtimms.tokusho.utils.system.deleteAwait
|
||||||
|
import org.xtimms.tokusho.utils.system.readText
|
||||||
|
import java.io.File
|
||||||
|
import java.util.zip.ZipFile
|
||||||
|
|
||||||
|
class LocalMangaZipOutput(
|
||||||
|
rootFile: File,
|
||||||
|
manga: Manga,
|
||||||
|
) : LocalMangaOutput(rootFile) {
|
||||||
|
|
||||||
|
private val output = ZipOutput(File(rootFile.path + ".tmp"))
|
||||||
|
private val index = MangaIndex(null)
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (!manga.isLocal) {
|
||||||
|
index.setMangaInfo(manga)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun mergeWithExisting() = mutex.withLock {
|
||||||
|
if (rootFile.exists()) {
|
||||||
|
runInterruptible(Dispatchers.IO) {
|
||||||
|
mergeWith(rootFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun addCover(file: File, ext: String) = mutex.withLock {
|
||||||
|
val name = buildString {
|
||||||
|
append(FILENAME_PATTERN.format(0, 0, 0))
|
||||||
|
if (ext.isNotEmpty() && ext.length <= 4) {
|
||||||
|
append('.')
|
||||||
|
append(ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runInterruptible(Dispatchers.IO) {
|
||||||
|
output.put(name, file)
|
||||||
|
}
|
||||||
|
index.setCoverEntry(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun addPage(chapter: IndexedValue<MangaChapter>, file: File, pageNumber: Int, ext: String) = mutex.withLock {
|
||||||
|
val name = buildString {
|
||||||
|
append(FILENAME_PATTERN.format(chapter.value.branch.hashCode(), chapter.index + 1, pageNumber))
|
||||||
|
if (ext.isNotEmpty() && ext.length <= 4) {
|
||||||
|
append('.')
|
||||||
|
append(ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runInterruptible(Dispatchers.IO) {
|
||||||
|
output.put(name, file)
|
||||||
|
}
|
||||||
|
index.addChapter(chapter, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun flushChapter(chapter: MangaChapter): Boolean = false
|
||||||
|
|
||||||
|
override suspend fun finish() = mutex.withLock {
|
||||||
|
runInterruptible(Dispatchers.IO) {
|
||||||
|
output.put(ENTRY_NAME_INDEX, index.toString())
|
||||||
|
output.finish()
|
||||||
|
output.close()
|
||||||
|
}
|
||||||
|
rootFile.deleteAwait()
|
||||||
|
output.file.renameTo(rootFile)
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun cleanup() = mutex.withLock {
|
||||||
|
output.file.deleteAwait()
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
output.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
private fun mergeWith(other: File) {
|
||||||
|
var otherIndex: MangaIndex? = null
|
||||||
|
ZipFile(other).use { zip ->
|
||||||
|
for (entry in zip.entries()) {
|
||||||
|
if (entry.name == ENTRY_NAME_INDEX) {
|
||||||
|
otherIndex = MangaIndex(
|
||||||
|
zip.getInputStream(entry).use {
|
||||||
|
it.reader().readText()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
output.copyEntryFrom(zip, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
otherIndex?.getMangaInfo()?.chapters?.withIndex()?.let { chapters ->
|
||||||
|
for (chapter in chapters) {
|
||||||
|
index.addChapter(chapter, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val FILENAME_PATTERN = "%08d_%03d%03d"
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
fun filterChapters(subject: LocalMangaZipOutput, idsToRemove: Set<Long>) {
|
||||||
|
ZipFile(subject.rootFile).use { zip ->
|
||||||
|
val index = MangaIndex(zip.readText(zip.getEntry(ENTRY_NAME_INDEX)))
|
||||||
|
idsToRemove.forEach { id -> index.removeChapter(id) }
|
||||||
|
val patterns = requireNotNull(index.getMangaInfo()?.chapters).map {
|
||||||
|
index.getChapterNamesPattern(it)
|
||||||
|
}
|
||||||
|
val coverEntryName = index.getCoverEntry()
|
||||||
|
for (entry in zip.entries()) {
|
||||||
|
when {
|
||||||
|
entry.name == ENTRY_NAME_INDEX -> {
|
||||||
|
subject.output.put(ENTRY_NAME_INDEX, index.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.isDirectory -> {
|
||||||
|
subject.output.addDirectory(entry.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.name == coverEntryName -> {
|
||||||
|
subject.output.copyEntryFrom(zip, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val name = entry.name.substringBefore('.')
|
||||||
|
if (patterns.any { it.matches(name) }) {
|
||||||
|
subject.output.copyEntryFrom(zip, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subject.output.finish()
|
||||||
|
subject.output.close()
|
||||||
|
subject.rootFile.delete()
|
||||||
|
subject.output.file.renameTo(subject.rootFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
package org.xtimms.tokusho.core.prefs
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
import kotlinx.coroutines.channels.trySendBlocking
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.transform
|
||||||
|
import org.xtimms.tokusho.sections.explore.data.SourcesSortOrder
|
||||||
|
import org.xtimms.tokusho.utils.system.getEnumValue
|
||||||
|
import org.xtimms.tokusho.utils.system.putEnumValue
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class KotatsuAppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||||
|
|
||||||
|
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
|
||||||
|
var isNsfwContentDisabled: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_DISABLE_NSFW, false)
|
||||||
|
set(value) = prefs.edit { putBoolean(KEY_DISABLE_NSFW, value) }
|
||||||
|
|
||||||
|
val isNewSourcesTipEnabled: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_SOURCES_NEW, true)
|
||||||
|
|
||||||
|
var sourcesSortOrder: SourcesSortOrder
|
||||||
|
get() = prefs.getEnumValue(KEY_SOURCES_ORDER, SourcesSortOrder.MANUAL)
|
||||||
|
set(value) = prefs.edit { putEnumValue(KEY_SOURCES_ORDER, value) }
|
||||||
|
|
||||||
|
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
|
||||||
|
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unsubscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
|
||||||
|
prefs.unregisterOnSharedPreferenceChangeListener(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observe() = prefs.observe()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KEY_DISABLE_NSFW = "no_nsfw"
|
||||||
|
const val KEY_SOURCES_NEW = "sources_new"
|
||||||
|
const val KEY_SOURCES_ORDER = "sources_sort_order"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> KotatsuAppSettings.observeAsFlow(key: String, valueProducer: KotatsuAppSettings.() -> T) = flow {
|
||||||
|
var lastValue: T = valueProducer()
|
||||||
|
emit(lastValue)
|
||||||
|
observe().collect {
|
||||||
|
if (it == key) {
|
||||||
|
val value = valueProducer()
|
||||||
|
if (value != lastValue) {
|
||||||
|
emit(value)
|
||||||
|
}
|
||||||
|
lastValue = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> KotatsuAppSettings.observeAsStateFlow(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
key: String,
|
||||||
|
valueProducer: KotatsuAppSettings.() -> T,
|
||||||
|
): StateFlow<T> = observe().transform {
|
||||||
|
if (it == key) {
|
||||||
|
emit(valueProducer())
|
||||||
|
}
|
||||||
|
}.stateIn(scope, SharingStarted.Eagerly, valueProducer())
|
||||||
|
|
||||||
|
fun SharedPreferences.observe(): Flow<String?> = callbackFlow {
|
||||||
|
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||||
|
trySendBlocking(key)
|
||||||
|
}
|
||||||
|
registerOnSharedPreferenceChangeListener(listener)
|
||||||
|
awaitClose {
|
||||||
|
unregisterOnSharedPreferenceChangeListener(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
package org.xtimms.tokusho.core.zip
|
||||||
|
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
|
import androidx.collection.ArraySet
|
||||||
|
import okio.Closeable
|
||||||
|
import org.xtimms.tokusho.utils.system.children
|
||||||
|
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)
|
||||||
|
try {
|
||||||
|
other.getInputStream(entry).use { input ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
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.children().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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
package org.xtimms.tokusho.data.repository
|
||||||
|
|
||||||
|
import android.database.SQLException
|
||||||
|
import androidx.room.withTransaction
|
||||||
|
import dagger.Reusable
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.xtimms.tokusho.core.database.TokushoDatabase
|
||||||
|
import org.xtimms.tokusho.core.database.entity.BookmarkEntity
|
||||||
|
import org.xtimms.tokusho.core.database.entity.toBookmark
|
||||||
|
import org.xtimms.tokusho.core.database.entity.toBookmarks
|
||||||
|
import org.xtimms.tokusho.core.database.entity.toEntities
|
||||||
|
import org.xtimms.tokusho.core.database.entity.toEntity
|
||||||
|
import org.xtimms.tokusho.core.database.entity.toManga
|
||||||
|
import org.xtimms.tokusho.core.model.Bookmark
|
||||||
|
import org.xtimms.tokusho.utils.ReversibleHandle
|
||||||
|
import org.xtimms.tokusho.utils.lang.mapItems
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@Reusable
|
||||||
|
class BookmarksRepository @Inject constructor(
|
||||||
|
private val db: TokushoDatabase,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun observeBookmark(manga: Manga, chapterId: Long, page: Int): Flow<Bookmark?> {
|
||||||
|
return db.getBookmarksDao().observe(manga.id, chapterId, page).map { it?.toBookmark(manga) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeBookmarks(manga: Manga): Flow<List<Bookmark>> {
|
||||||
|
return db.getBookmarksDao().observe(manga.id).mapItems { it.toBookmark(manga) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeBookmarks(): Flow<Map<Manga, List<Bookmark>>> {
|
||||||
|
return db.getBookmarksDao().observe().map { map ->
|
||||||
|
val res = LinkedHashMap<Manga, List<Bookmark>>(map.size)
|
||||||
|
for ((k, v) in map) {
|
||||||
|
val manga = k.toManga()
|
||||||
|
res[manga] = v.toBookmarks(manga)
|
||||||
|
}
|
||||||
|
res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addBookmark(bookmark: Bookmark) {
|
||||||
|
db.withTransaction {
|
||||||
|
val tags = bookmark.manga.tags.toEntities()
|
||||||
|
db.getTagsDao().upsert(tags)
|
||||||
|
db.getMangaDao().upsert(bookmark.manga.toEntity(), tags)
|
||||||
|
db.getBookmarksDao().insert(bookmark.toEntity())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateBookmark(bookmark: Bookmark, imageUrl: String) {
|
||||||
|
val entity = bookmark.toEntity().copy(
|
||||||
|
imageUrl = imageUrl,
|
||||||
|
)
|
||||||
|
db.getBookmarksDao().upsert(listOf(entity))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeBookmark(mangaId: Long, chapterId: Long, page: Int) {
|
||||||
|
check(db.getBookmarksDao().delete(mangaId, chapterId, page) != 0) {
|
||||||
|
"Bookmark not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeBookmark(bookmark: Bookmark) {
|
||||||
|
removeBookmark(bookmark.manga.id, bookmark.chapterId, bookmark.page)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeBookmarks(ids: Set<Long>): ReversibleHandle {
|
||||||
|
val entities = ArrayList<BookmarkEntity>(ids.size)
|
||||||
|
db.withTransaction {
|
||||||
|
val dao = db.getBookmarksDao()
|
||||||
|
for (pageId in ids) {
|
||||||
|
val e = dao.find(pageId)
|
||||||
|
if (e != null) {
|
||||||
|
entities.add(e)
|
||||||
|
}
|
||||||
|
dao.delete(pageId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return BookmarksRestorer(entities)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class BookmarksRestorer(
|
||||||
|
private val entities: Collection<BookmarkEntity>,
|
||||||
|
) : ReversibleHandle {
|
||||||
|
|
||||||
|
override suspend fun reverse() {
|
||||||
|
db.withTransaction {
|
||||||
|
for (e in entities) {
|
||||||
|
try {
|
||||||
|
db.getBookmarksDao().insert(e)
|
||||||
|
} catch (e: SQLException) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
package org.xtimms.tokusho.data.repository
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.util.almostEquals
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import org.xtimms.tokusho.core.parser.MangaRepository
|
||||||
|
import org.xtimms.tokusho.utils.lang.asArrayList
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class ExploreRepository @Inject constructor(
|
||||||
|
private val sourcesRepository: MangaSourcesRepository,
|
||||||
|
private val historyRepository: HistoryRepository,
|
||||||
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private suspend fun getList(
|
||||||
|
source: MangaSource,
|
||||||
|
tags: List<String>,
|
||||||
|
): List<Manga> = runCatchingCancellable {
|
||||||
|
val repository = mangaRepositoryFactory.create(source)
|
||||||
|
val order = repository.sortOrders.random()
|
||||||
|
val availableTags = repository.getTags()
|
||||||
|
val tag = tags.firstNotNullOfOrNull { title ->
|
||||||
|
availableTags.find { x -> x.title.almostEquals(title, 0.4f) }
|
||||||
|
}
|
||||||
|
val list = repository.getList(
|
||||||
|
offset = 0,
|
||||||
|
filter = MangaListFilter.Advanced.Builder(order)
|
||||||
|
.tags(setOfNotNull(tag))
|
||||||
|
.build(),
|
||||||
|
).asArrayList()
|
||||||
|
list.shuffle()
|
||||||
|
list
|
||||||
|
}.onFailure {
|
||||||
|
// TODO
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
package org.xtimms.tokusho.data.repository.backup
|
||||||
|
|
||||||
|
import org.json.JSONArray
|
||||||
|
|
||||||
|
class BackupEntry(
|
||||||
|
val name: Name,
|
||||||
|
val data: JSONArray
|
||||||
|
) {
|
||||||
|
|
||||||
|
enum class Name(
|
||||||
|
val key: String,
|
||||||
|
) {
|
||||||
|
|
||||||
|
INDEX("index"),
|
||||||
|
HISTORY("history"),
|
||||||
|
CATEGORIES("categories"),
|
||||||
|
FAVOURITES("favourites"),
|
||||||
|
BOOKMARKS("bookmarks"),
|
||||||
|
SOURCES("sources"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,200 @@
|
|||||||
|
package org.xtimms.tokusho.data.repository.backup
|
||||||
|
|
||||||
|
import androidx.room.withTransaction
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.JSONIterator
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import org.xtimms.tokusho.BuildConfig
|
||||||
|
import org.xtimms.tokusho.core.database.TokushoDatabase
|
||||||
|
import java.util.Date
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val PAGE_SIZE = 10
|
||||||
|
|
||||||
|
class BackupRepository @Inject constructor(
|
||||||
|
private val db: TokushoDatabase,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun dumpHistory(): BackupEntry {
|
||||||
|
var offset = 0
|
||||||
|
val entry = BackupEntry(BackupEntry.Name.HISTORY, JSONArray())
|
||||||
|
while (true) {
|
||||||
|
val history = db.getHistoryDao().findAll(offset, PAGE_SIZE)
|
||||||
|
if (history.isEmpty()) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
offset += history.size
|
||||||
|
for (item in history) {
|
||||||
|
val manga = JsonSerializer(item.manga).toJson()
|
||||||
|
val tags = JSONArray()
|
||||||
|
item.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
|
||||||
|
manga.put("tags", tags)
|
||||||
|
val json = JsonSerializer(item.history).toJson()
|
||||||
|
json.put("manga", manga)
|
||||||
|
entry.data.put(json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun dumpCategories(): BackupEntry {
|
||||||
|
val entry = BackupEntry(BackupEntry.Name.CATEGORIES, JSONArray())
|
||||||
|
val categories = db.getFavouriteCategoriesDao().findAll()
|
||||||
|
for (item in categories) {
|
||||||
|
entry.data.put(JsonSerializer(item).toJson())
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun dumpFavourites(): BackupEntry {
|
||||||
|
var offset = 0
|
||||||
|
val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray())
|
||||||
|
while (true) {
|
||||||
|
val favourites = db.getFavouritesDao().findAllRaw(offset, PAGE_SIZE)
|
||||||
|
if (favourites.isEmpty()) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
offset += favourites.size
|
||||||
|
for (item in favourites) {
|
||||||
|
val manga = JsonSerializer(item.manga).toJson()
|
||||||
|
val tags = JSONArray()
|
||||||
|
item.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
|
||||||
|
manga.put("tags", tags)
|
||||||
|
val json = JsonSerializer(item.favourite).toJson()
|
||||||
|
json.put("manga", manga)
|
||||||
|
entry.data.put(json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun dumpBookmarks(): BackupEntry {
|
||||||
|
val entry = BackupEntry(BackupEntry.Name.BOOKMARKS, JSONArray())
|
||||||
|
val all = db.getBookmarksDao().findAll()
|
||||||
|
for ((m, b) in all) {
|
||||||
|
val json = JSONObject()
|
||||||
|
val manga = JsonSerializer(m.manga).toJson()
|
||||||
|
json.put("manga", manga)
|
||||||
|
val tags = JSONArray()
|
||||||
|
m.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
|
||||||
|
json.put("tags", tags)
|
||||||
|
val bookmarks = JSONArray()
|
||||||
|
b.forEach { bookmarks.put(JsonSerializer(it).toJson()) }
|
||||||
|
json.put("bookmarks", bookmarks)
|
||||||
|
entry.data.put(json)
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun dumpSources(): BackupEntry {
|
||||||
|
val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray())
|
||||||
|
val all = db.getSourcesDao().findAll()
|
||||||
|
for (source in all) {
|
||||||
|
val json = JsonSerializer(source).toJson()
|
||||||
|
entry.data.put(json)
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createIndex(): BackupEntry {
|
||||||
|
val entry = BackupEntry(BackupEntry.Name.INDEX, JSONArray())
|
||||||
|
val json = JSONObject()
|
||||||
|
json.put("app_id", BuildConfig.APPLICATION_ID)
|
||||||
|
json.put("app_version", BuildConfig.VERSION_CODE)
|
||||||
|
json.put("created_at", System.currentTimeMillis())
|
||||||
|
entry.data.put(json)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getBackupDate(entry: BackupEntry?): Date? {
|
||||||
|
val timestamp = entry?.data?.optJSONObject(0)?.getLongOrDefault("created_at", 0) ?: 0
|
||||||
|
return if (timestamp == 0L) null else Date(timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
|
||||||
|
val result = CompositeResult()
|
||||||
|
for (item in entry.data.JSONIterator()) {
|
||||||
|
val mangaJson = item.getJSONObject("manga")
|
||||||
|
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
||||||
|
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
||||||
|
JsonDeserializer(it).toTagEntity()
|
||||||
|
}
|
||||||
|
val history = JsonDeserializer(item).toHistoryEntity()
|
||||||
|
result += runCatchingCancellable {
|
||||||
|
db.withTransaction {
|
||||||
|
db.getTagsDao().upsert(tags)
|
||||||
|
db.getMangaDao().upsert(manga, tags)
|
||||||
|
db.getHistoryDao().upsert(history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun restoreCategories(entry: BackupEntry): CompositeResult {
|
||||||
|
val result = CompositeResult()
|
||||||
|
for (item in entry.data.JSONIterator()) {
|
||||||
|
val category = JsonDeserializer(item).toFavouriteCategoryEntity()
|
||||||
|
result += runCatchingCancellable {
|
||||||
|
db.getFavouriteCategoriesDao().upsert(category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun restoreFavourites(entry: BackupEntry): CompositeResult {
|
||||||
|
val result = CompositeResult()
|
||||||
|
for (item in entry.data.JSONIterator()) {
|
||||||
|
val mangaJson = item.getJSONObject("manga")
|
||||||
|
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
||||||
|
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
||||||
|
JsonDeserializer(it).toTagEntity()
|
||||||
|
}
|
||||||
|
val favourite = JsonDeserializer(item).toFavouriteEntity()
|
||||||
|
result += runCatchingCancellable {
|
||||||
|
db.withTransaction {
|
||||||
|
db.getTagsDao().upsert(tags)
|
||||||
|
db.getMangaDao().upsert(manga, tags)
|
||||||
|
db.getFavouritesDao().upsert(favourite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun restoreBookmarks(entry: BackupEntry): CompositeResult {
|
||||||
|
val result = CompositeResult()
|
||||||
|
for (item in entry.data.JSONIterator()) {
|
||||||
|
val mangaJson = item.getJSONObject("manga")
|
||||||
|
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
||||||
|
val tags = item.getJSONArray("tags").mapJSON {
|
||||||
|
JsonDeserializer(it).toTagEntity()
|
||||||
|
}
|
||||||
|
val bookmarks = item.getJSONArray("bookmarks").mapJSON {
|
||||||
|
JsonDeserializer(it).toBookmarkEntity()
|
||||||
|
}
|
||||||
|
result += runCatchingCancellable {
|
||||||
|
db.withTransaction {
|
||||||
|
db.getTagsDao().upsert(tags)
|
||||||
|
db.getMangaDao().upsert(manga, tags)
|
||||||
|
db.getBookmarksDao().upsert(bookmarks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun restoreSources(entry: BackupEntry): CompositeResult {
|
||||||
|
val result = CompositeResult()
|
||||||
|
for (item in entry.data.JSONIterator()) {
|
||||||
|
val source = JsonDeserializer(item).toMangaSourceEntity()
|
||||||
|
result += runCatchingCancellable {
|
||||||
|
db.getSourcesDao().upsert(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
package org.xtimms.tokusho.data.repository.backup
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineStart
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import okio.Closeable
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.xtimms.tokusho.utils.lang.processLifecycleScope
|
||||||
|
import java.io.File
|
||||||
|
import java.util.EnumSet
|
||||||
|
import java.util.zip.ZipFile
|
||||||
|
|
||||||
|
class BackupZipInput(val file: File) : Closeable {
|
||||||
|
|
||||||
|
private val zipFile = ZipFile(file)
|
||||||
|
|
||||||
|
suspend fun getEntry(name: BackupEntry.Name): BackupEntry? = runInterruptible(Dispatchers.IO) {
|
||||||
|
val entry = zipFile.getEntry(name.key) ?: return@runInterruptible null
|
||||||
|
val json = zipFile.getInputStream(entry).use {
|
||||||
|
JSONArray(it.bufferedReader().readText())
|
||||||
|
}
|
||||||
|
BackupEntry(name, json)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun entries(): Set<BackupEntry.Name> = runInterruptible(Dispatchers.IO) {
|
||||||
|
zipFile.entries().toList().mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { ze ->
|
||||||
|
BackupEntry.Name.entries.find { it.key == ze.name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
zipFile.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cleanupAsync() {
|
||||||
|
processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) {
|
||||||
|
runCatching {
|
||||||
|
close()
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
package org.xtimms.tokusho.data.repository.backup
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import okio.Closeable
|
||||||
|
import org.xtimms.tokusho.R
|
||||||
|
import org.xtimms.tokusho.core.zip.ZipOutput
|
||||||
|
import java.io.File
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.zip.Deflater
|
||||||
|
|
||||||
|
class BackupZipOutput(val file: File) : Closeable {
|
||||||
|
|
||||||
|
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
|
||||||
|
|
||||||
|
suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) {
|
||||||
|
output.put(entry.name.key, entry.data.toString(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun finish() = runInterruptible(Dispatchers.IO) {
|
||||||
|
output.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
output.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(LocalDate.now().format(DateTimeFormatter.ofPattern("ddMMyyyy")))
|
||||||
|
append(".bk.zip")
|
||||||
|
}
|
||||||
|
BackupZipOutput(File(dir, filename))
|
||||||
|
}
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
package org.xtimms.tokusho.data.repository.backup
|
||||||
|
|
||||||
|
class CompositeResult {
|
||||||
|
|
||||||
|
private var successCount: Int = 0
|
||||||
|
private val errors = ArrayList<Throwable?>()
|
||||||
|
|
||||||
|
val size: Int
|
||||||
|
get() = successCount + errors.size
|
||||||
|
|
||||||
|
val failures: List<Throwable>
|
||||||
|
get() = errors.filterNotNull()
|
||||||
|
|
||||||
|
val isEmpty: Boolean
|
||||||
|
get() = errors.isEmpty() && successCount == 0
|
||||||
|
|
||||||
|
val isAllSuccess: Boolean
|
||||||
|
get() = errors.none { it != null }
|
||||||
|
|
||||||
|
val isAllFailed: Boolean
|
||||||
|
get() = successCount == 0 && errors.isNotEmpty()
|
||||||
|
|
||||||
|
operator fun plusAssign(result: Result<*>) {
|
||||||
|
when {
|
||||||
|
result.isSuccess -> successCount++
|
||||||
|
result.isFailure -> errors.add(result.exceptionOrNull())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
operator fun plusAssign(other: CompositeResult) {
|
||||||
|
this.successCount += other.successCount
|
||||||
|
this.errors += other.errors
|
||||||
|
}
|
||||||
|
|
||||||
|
operator fun plus(other: CompositeResult): CompositeResult {
|
||||||
|
val result = CompositeResult()
|
||||||
|
result.successCount = this.successCount + other.successCount
|
||||||
|
result.errors.addAll(this.errors)
|
||||||
|
result.errors.addAll(other.errors)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
package org.xtimms.tokusho.data.repository.backup
|
||||||
|
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
||||||
|
import org.xtimms.tokusho.core.database.entity.BookmarkEntity
|
||||||
|
import org.xtimms.tokusho.core.database.entity.FavouriteCategoryEntity
|
||||||
|
import org.xtimms.tokusho.core.database.entity.FavouriteEntity
|
||||||
|
import org.xtimms.tokusho.core.database.entity.HistoryEntity
|
||||||
|
import org.xtimms.tokusho.core.database.entity.MangaEntity
|
||||||
|
import org.xtimms.tokusho.core.database.entity.MangaSourceEntity
|
||||||
|
import org.xtimms.tokusho.core.database.entity.TagEntity
|
||||||
|
|
||||||
|
class JsonDeserializer(private val json: JSONObject) {
|
||||||
|
|
||||||
|
fun toFavouriteEntity() = FavouriteEntity(
|
||||||
|
mangaId = json.getLong("manga_id"),
|
||||||
|
categoryId = json.getLong("category_id"),
|
||||||
|
sortKey = json.getIntOrDefault("sort_key", 0),
|
||||||
|
createdAt = json.getLong("created_at"),
|
||||||
|
deletedAt = 0L,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toMangaEntity() = MangaEntity(
|
||||||
|
id = json.getLong("id"),
|
||||||
|
title = json.getString("title"),
|
||||||
|
altTitle = json.getStringOrNull("alt_title"),
|
||||||
|
url = json.getString("url"),
|
||||||
|
publicUrl = json.getStringOrNull("public_url").orEmpty(),
|
||||||
|
rating = json.getDouble("rating").toFloat(),
|
||||||
|
isNsfw = json.getBooleanOrDefault("nsfw", false),
|
||||||
|
coverUrl = json.getString("cover_url"),
|
||||||
|
largeCoverUrl = json.getStringOrNull("large_cover_url"),
|
||||||
|
state = json.getStringOrNull("state"),
|
||||||
|
author = json.getStringOrNull("author"),
|
||||||
|
source = json.getString("source"),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toTagEntity() = TagEntity(
|
||||||
|
id = json.getLong("id"),
|
||||||
|
title = json.getString("title"),
|
||||||
|
key = json.getString("key"),
|
||||||
|
source = json.getString("source"),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toHistoryEntity() = HistoryEntity(
|
||||||
|
mangaId = json.getLong("manga_id"),
|
||||||
|
createdAt = json.getLong("created_at"),
|
||||||
|
updatedAt = json.getLong("updated_at"),
|
||||||
|
chapterId = json.getLong("chapter_id"),
|
||||||
|
page = json.getInt("page"),
|
||||||
|
scroll = json.getDouble("scroll").toFloat(),
|
||||||
|
percent = json.getFloatOrDefault("percent", -1f),
|
||||||
|
deletedAt = 0L,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toFavouriteCategoryEntity() = FavouriteCategoryEntity(
|
||||||
|
categoryId = json.getInt("category_id"),
|
||||||
|
createdAt = json.getLong("created_at"),
|
||||||
|
sortKey = json.getInt("sort_key"),
|
||||||
|
title = json.getString("title"),
|
||||||
|
order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name,
|
||||||
|
track = json.getBooleanOrDefault("track", true),
|
||||||
|
isVisibleInLibrary = json.getBooleanOrDefault("show_in_lib", true),
|
||||||
|
deletedAt = 0L,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toBookmarkEntity() = BookmarkEntity(
|
||||||
|
mangaId = json.getLong("manga_id"),
|
||||||
|
pageId = json.getLong("page_id"),
|
||||||
|
chapterId = json.getLong("chapter_id"),
|
||||||
|
page = json.getInt("page"),
|
||||||
|
scroll = json.getInt("scroll"),
|
||||||
|
imageUrl = json.getString("image_url"),
|
||||||
|
createdAt = json.getLong("created_at"),
|
||||||
|
percent = json.getDouble("percent").toFloat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toMangaSourceEntity() = MangaSourceEntity(
|
||||||
|
source = json.getString("source"),
|
||||||
|
isEnabled = json.getBoolean("enabled"),
|
||||||
|
sortKey = json.getInt("sort_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toMap(): Map<String, Any?> {
|
||||||
|
val map = mutableMapOf<String, Any?>()
|
||||||
|
val keys = json.keys()
|
||||||
|
|
||||||
|
while (keys.hasNext()) {
|
||||||
|
val key = keys.next()
|
||||||
|
val value = json.get(key)
|
||||||
|
map[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
package org.xtimms.tokusho.data.repository.backup
|
||||||
|
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.xtimms.tokusho.core.database.entity.BookmarkEntity
|
||||||
|
import org.xtimms.tokusho.core.database.entity.FavouriteCategoryEntity
|
||||||
|
import org.xtimms.tokusho.core.database.entity.FavouriteEntity
|
||||||
|
import org.xtimms.tokusho.core.database.entity.HistoryEntity
|
||||||
|
import org.xtimms.tokusho.core.database.entity.MangaEntity
|
||||||
|
import org.xtimms.tokusho.core.database.entity.MangaSourceEntity
|
||||||
|
import org.xtimms.tokusho.core.database.entity.TagEntity
|
||||||
|
|
||||||
|
class JsonSerializer private constructor(private val json: JSONObject) {
|
||||||
|
|
||||||
|
constructor(e: FavouriteEntity) : this(
|
||||||
|
JSONObject().apply {
|
||||||
|
put("manga_id", e.mangaId)
|
||||||
|
put("category_id", e.categoryId)
|
||||||
|
put("sort_key", e.sortKey)
|
||||||
|
put("created_at", e.createdAt)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
constructor(e: FavouriteCategoryEntity) : this(
|
||||||
|
JSONObject().apply {
|
||||||
|
put("category_id", e.categoryId)
|
||||||
|
put("created_at", e.createdAt)
|
||||||
|
put("sort_key", e.sortKey)
|
||||||
|
put("title", e.title)
|
||||||
|
put("order", e.order)
|
||||||
|
put("track", e.track)
|
||||||
|
put("show_in_lib", e.isVisibleInLibrary)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
constructor(e: HistoryEntity) : this(
|
||||||
|
JSONObject().apply {
|
||||||
|
put("manga_id", e.mangaId)
|
||||||
|
put("created_at", e.createdAt)
|
||||||
|
put("updated_at", e.updatedAt)
|
||||||
|
put("chapter_id", e.chapterId)
|
||||||
|
put("page", e.page)
|
||||||
|
put("scroll", e.scroll)
|
||||||
|
put("percent", e.percent)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
constructor(e: TagEntity) : this(
|
||||||
|
JSONObject().apply {
|
||||||
|
put("id", e.id)
|
||||||
|
put("title", e.title)
|
||||||
|
put("key", e.key)
|
||||||
|
put("source", e.source)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
constructor(e: MangaEntity) : this(
|
||||||
|
JSONObject().apply {
|
||||||
|
put("id", e.id)
|
||||||
|
put("title", e.title)
|
||||||
|
put("alt_title", e.altTitle)
|
||||||
|
put("url", e.url)
|
||||||
|
put("public_url", e.publicUrl)
|
||||||
|
put("rating", e.rating)
|
||||||
|
put("nsfw", e.isNsfw)
|
||||||
|
put("cover_url", e.coverUrl)
|
||||||
|
put("large_cover_url", e.largeCoverUrl)
|
||||||
|
put("state", e.state)
|
||||||
|
put("author", e.author)
|
||||||
|
put("source", e.source)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
constructor(e: BookmarkEntity) : this(
|
||||||
|
JSONObject().apply {
|
||||||
|
put("manga_id", e.mangaId)
|
||||||
|
put("page_id", e.pageId)
|
||||||
|
put("chapter_id", e.chapterId)
|
||||||
|
put("page", e.page)
|
||||||
|
put("scroll", e.scroll)
|
||||||
|
put("image_url", e.imageUrl)
|
||||||
|
put("created_at", e.createdAt)
|
||||||
|
put("percent", e.percent)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
constructor(e: MangaSourceEntity) : this(
|
||||||
|
JSONObject().apply {
|
||||||
|
put("source", e.source)
|
||||||
|
put("enabled", e.isEnabled)
|
||||||
|
put("sort_key", e.sortKey)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
constructor(m: Map<String, *>) : this(
|
||||||
|
JSONObject(m),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toJson(): JSONObject = json
|
||||||
|
}
|
||||||
@ -0,0 +1,127 @@
|
|||||||
|
package org.xtimms.tokusho.sections.details
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.sizeIn
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Bookmark
|
||||||
|
import androidx.compose.material.icons.filled.Circle
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ProvideTextStyle
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.xtimms.tokusho.R
|
||||||
|
import org.xtimms.tokusho.core.components.DotSeparatorText
|
||||||
|
import org.xtimms.tokusho.utils.composable.selectedBackground
|
||||||
|
import org.xtimms.tokusho.utils.material.SecondaryItemAlpha
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ChapterListItem(
|
||||||
|
title: String,
|
||||||
|
date: Long?,
|
||||||
|
scanlator: String?,
|
||||||
|
read: Boolean,
|
||||||
|
bookmark: Boolean,
|
||||||
|
selected: Boolean,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
|
||||||
|
val textAlpha = if (read) .38f else 1f
|
||||||
|
val textSubtitleAlpha = if (read) .38f else SecondaryItemAlpha
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.clipToBounds()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.selectedBackground(selected)
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
)
|
||||||
|
.padding(start = 16.dp, top = 12.dp, end = 8.dp, bottom = 12.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
var textHeight by remember { mutableIntStateOf(0) }
|
||||||
|
if (!read) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Circle,
|
||||||
|
contentDescription = stringResource(R.string.unread),
|
||||||
|
modifier = Modifier
|
||||||
|
.height(8.dp)
|
||||||
|
.padding(end = 4.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (bookmark) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Bookmark,
|
||||||
|
contentDescription = stringResource(R.string.action_filter_bookmarked),
|
||||||
|
modifier = Modifier
|
||||||
|
.sizeIn(maxHeight = with(LocalDensity.current) { textHeight.toDp() - 2.dp }),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = LocalContentColor.current.copy(alpha = textAlpha),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
onTextLayout = { textHeight = it.size.height },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(modifier = Modifier.alpha(textSubtitleAlpha)) {
|
||||||
|
ProvideTextStyle(value = MaterialTheme.typography.bodySmall) {
|
||||||
|
if (date != null) {
|
||||||
|
Text(
|
||||||
|
text = date.toString(),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (scanlator != null) DotSeparatorText()
|
||||||
|
}
|
||||||
|
if (scanlator != null) {
|
||||||
|
Text(
|
||||||
|
text = scanlator,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
package org.xtimms.tokusho.sections.details
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
|
import org.xtimms.tokusho.core.model.Bookmark
|
||||||
|
import org.xtimms.tokusho.core.model.MangaHistory
|
||||||
|
import org.xtimms.tokusho.sections.details.data.MangaDetails
|
||||||
|
import org.xtimms.tokusho.sections.details.model.ChapterItem
|
||||||
|
import org.xtimms.tokusho.sections.details.model.toListItem
|
||||||
|
|
||||||
|
fun MangaDetails.mapChapters(
|
||||||
|
history: MangaHistory?,
|
||||||
|
newCount: Int,
|
||||||
|
branch: String?,
|
||||||
|
bookmarks: List<Bookmark>,
|
||||||
|
): List<ChapterItem> {
|
||||||
|
val remoteChapters = chapters[branch].orEmpty()
|
||||||
|
val localChapters = local?.manga?.getChapters(branch).orEmpty()
|
||||||
|
if (remoteChapters.isEmpty() && localChapters.isEmpty()) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
val bookmarked = bookmarks.mapToSet { it.chapterId }
|
||||||
|
val currentId = history?.chapterId ?: 0L
|
||||||
|
val newFrom = if (newCount == 0 || remoteChapters.isEmpty()) Int.MAX_VALUE else remoteChapters.size - newCount
|
||||||
|
val ids = buildSet(maxOf(remoteChapters.size, localChapters.size)) {
|
||||||
|
remoteChapters.mapTo(this) { it.id }
|
||||||
|
localChapters.mapTo(this) { it.id }
|
||||||
|
}
|
||||||
|
val result = ArrayList<ChapterItem>(ids.size)
|
||||||
|
val localMap = if (localChapters.isNotEmpty()) {
|
||||||
|
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
var isUnread = currentId !in ids
|
||||||
|
for (chapter in remoteChapters) {
|
||||||
|
val local = localMap?.remove(chapter.id)
|
||||||
|
if (chapter.id == currentId) {
|
||||||
|
isUnread = true
|
||||||
|
}
|
||||||
|
result += (local ?: chapter).toListItem(
|
||||||
|
isCurrent = chapter.id == currentId,
|
||||||
|
isUnread = isUnread,
|
||||||
|
isNew = isUnread && result.size >= newFrom,
|
||||||
|
isDownloaded = local != null,
|
||||||
|
isBookmarked = chapter.id in bookmarked,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!localMap.isNullOrEmpty()) {
|
||||||
|
for (chapter in localMap.values) {
|
||||||
|
if (chapter.id == currentId) {
|
||||||
|
isUnread = true
|
||||||
|
}
|
||||||
|
result += chapter.toListItem(
|
||||||
|
isCurrent = chapter.id == currentId,
|
||||||
|
isUnread = isUnread,
|
||||||
|
isNew = false,
|
||||||
|
isDownloaded = !isLocal,
|
||||||
|
isBookmarked = chapter.id in bookmarked,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue