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
|
||||
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
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<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