Some work
parent
859ebd208e
commit
11022d0d04
@ -0,0 +1,101 @@
|
|||||||
|
package org.xtimms.tokusho.core.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.SizeTransform
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import java.text.BreakIterator
|
||||||
|
|
||||||
|
data class CharState(val preview: String, val current: String)
|
||||||
|
|
||||||
|
// https://github.com/danilkinkin/buckwheat/blob/master/app/src/main/java/com/danilkinkin/buckwheat/base/AnimatedNumber.kt
|
||||||
|
@Composable
|
||||||
|
fun AnimatedNumber(
|
||||||
|
value: String = "",
|
||||||
|
style: TextStyle = MaterialTheme.typography.displayLarge,
|
||||||
|
) {
|
||||||
|
|
||||||
|
var previewsValue by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
|
var blocks by remember { mutableStateOf<List<CharState>>(emptyList()) }
|
||||||
|
|
||||||
|
DisposableEffect(value) {
|
||||||
|
var splittedValue = emptyList<String>().toMutableList()
|
||||||
|
val it = BreakIterator.getCharacterInstance()
|
||||||
|
it.setText(value)
|
||||||
|
var count = 0
|
||||||
|
|
||||||
|
var start = 0
|
||||||
|
var end = it.next()
|
||||||
|
while (end != BreakIterator.DONE) {
|
||||||
|
splittedValue.add(value.substring(start, end))
|
||||||
|
|
||||||
|
start = end
|
||||||
|
end = it.next()
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
val length = splittedValue.size.coerceAtLeast(previewsValue.size)
|
||||||
|
|
||||||
|
var newBlocks: MutableList<CharState> = emptyList<CharState>().toMutableList()
|
||||||
|
|
||||||
|
for (i in 0..length) {
|
||||||
|
newBlocks.add(
|
||||||
|
CharState(
|
||||||
|
preview = previewsValue.getOrElse(previewsValue.size - i) { "" },
|
||||||
|
current = splittedValue.getOrElse(splittedValue.size - i) { "" },
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
newBlocks = newBlocks.asReversed()
|
||||||
|
|
||||||
|
blocks = newBlocks
|
||||||
|
previewsValue = splittedValue
|
||||||
|
|
||||||
|
onDispose { }
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
blocks.forEach {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = it.current,
|
||||||
|
transitionSpec = {
|
||||||
|
if (targetState > initialState) {
|
||||||
|
(slideInVertically(tween(durationMillis = 300)) { height -> height } + fadeIn(
|
||||||
|
tween(durationMillis = 300)
|
||||||
|
)).togetherWith(
|
||||||
|
slideOutVertically(tween(durationMillis = 300)) { height -> -height } + fadeOut(
|
||||||
|
tween(durationMillis = 300)
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
(slideInVertically(tween(durationMillis = 300)) { height -> -height } + fadeIn(
|
||||||
|
tween(durationMillis = 300)
|
||||||
|
)).togetherWith(
|
||||||
|
slideOutVertically(tween(durationMillis = 300)) { height -> height } + fadeOut(
|
||||||
|
tween(durationMillis = 300)
|
||||||
|
))
|
||||||
|
}.using(
|
||||||
|
SizeTransform(clip = false)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = ""
|
||||||
|
) { targetCount ->
|
||||||
|
Text(text = targetCount, style = style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,141 @@
|
|||||||
|
package org.xtimms.tokusho.core.database.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.RawQuery
|
||||||
|
import androidx.room.Transaction
|
||||||
|
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||||
|
import androidx.sqlite.db.SupportSQLiteQuery
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import org.intellij.lang.annotations.Language
|
||||||
|
import org.xtimms.tokusho.core.database.entity.HistoryEntity
|
||||||
|
import org.xtimms.tokusho.core.database.entity.HistoryWithManga
|
||||||
|
import org.xtimms.tokusho.core.database.entity.MangaEntity
|
||||||
|
import org.xtimms.tokusho.core.database.entity.TagEntity
|
||||||
|
import org.xtimms.tokusho.core.model.ListSortOrder
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
abstract class HistoryDao {
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit OFFSET :offset")
|
||||||
|
abstract suspend fun findAll(offset: Int, limit: Int): List<HistoryWithManga>
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM history WHERE deleted_at = 0 AND manga_id IN (:ids)")
|
||||||
|
abstract suspend fun findAll(ids: Collection<Long>): List<HistoryEntity>
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC")
|
||||||
|
abstract fun observeAll(): Flow<List<HistoryWithManga>>
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit")
|
||||||
|
abstract fun observeAll(limit: Int): Flow<List<HistoryWithManga>>
|
||||||
|
|
||||||
|
fun observeAll(order: ListSortOrder): Flow<List<HistoryWithManga>> {
|
||||||
|
val orderBy = when (order) {
|
||||||
|
ListSortOrder.NEWEST -> "history.created_at DESC"
|
||||||
|
ListSortOrder.PROGRESS -> "history.percent DESC"
|
||||||
|
ListSortOrder.ALPHABETIC -> "manga.title"
|
||||||
|
else -> throw IllegalArgumentException("Sort order $order is not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Language("RoomSql")
|
||||||
|
val query = SimpleSQLiteQuery(
|
||||||
|
"SELECT * FROM history LEFT JOIN manga ON history.manga_id = manga.manga_id " +
|
||||||
|
"WHERE history.deleted_at = 0 GROUP BY history.manga_id ORDER BY $orderBy",
|
||||||
|
)
|
||||||
|
return observeAllImpl(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history WHERE deleted_at = 0)")
|
||||||
|
abstract suspend fun findAllManga(): List<MangaEntity>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""SELECT tags.* FROM tags
|
||||||
|
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||||
|
INNER JOIN history ON history.manga_id = manga_tags.manga_id
|
||||||
|
WHERE history.deleted_at = 0
|
||||||
|
GROUP BY manga_tags.tag_id
|
||||||
|
ORDER BY COUNT(manga_tags.manga_id) DESC
|
||||||
|
LIMIT :limit""",
|
||||||
|
)
|
||||||
|
abstract suspend fun findPopularTags(limit: Int): List<TagEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM history WHERE manga_id = :id AND deleted_at = 0")
|
||||||
|
abstract suspend fun find(id: Long): HistoryEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM history WHERE manga_id = :id AND deleted_at = 0")
|
||||||
|
abstract fun observe(id: Long): Flow<HistoryEntity?>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM history WHERE deleted_at = 0")
|
||||||
|
abstract fun observeCount(): Flow<Int>
|
||||||
|
|
||||||
|
@Query("SELECT percent FROM history WHERE manga_id = :id AND deleted_at = 0")
|
||||||
|
abstract suspend fun findProgress(id: Long): Float?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
abstract suspend fun insert(entity: HistoryEntity): Long
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"UPDATE history SET page = :page, chapter_id = :chapterId, scroll = :scroll, percent = :percent, updated_at = :updatedAt, deleted_at = 0 WHERE manga_id = :mangaId",
|
||||||
|
)
|
||||||
|
abstract suspend fun update(
|
||||||
|
mangaId: Long,
|
||||||
|
page: Int,
|
||||||
|
chapterId: Long,
|
||||||
|
scroll: Float,
|
||||||
|
percent: Float,
|
||||||
|
updatedAt: Long,
|
||||||
|
): Int
|
||||||
|
|
||||||
|
suspend fun delete(mangaId: Long) = setDeletedAt(mangaId, System.currentTimeMillis())
|
||||||
|
|
||||||
|
suspend fun recover(mangaId: Long) = setDeletedAt(mangaId, 0L)
|
||||||
|
|
||||||
|
@Query("DELETE FROM history WHERE deleted_at != 0 AND deleted_at < :maxDeletionTime")
|
||||||
|
abstract suspend fun gc(maxDeletionTime: Long)
|
||||||
|
|
||||||
|
suspend fun deleteAfter(minDate: Long) = setDeletedAtAfter(minDate, System.currentTimeMillis())
|
||||||
|
|
||||||
|
suspend fun clear() = setDeletedAtAfter(0L, System.currentTimeMillis())
|
||||||
|
|
||||||
|
suspend fun update(entity: HistoryEntity) = update(
|
||||||
|
mangaId = entity.mangaId,
|
||||||
|
page = entity.page,
|
||||||
|
chapterId = entity.chapterId,
|
||||||
|
scroll = entity.scroll,
|
||||||
|
percent = entity.percent,
|
||||||
|
updatedAt = entity.updatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
open suspend fun upsert(entity: HistoryEntity): Boolean {
|
||||||
|
return if (update(entity) == 0) {
|
||||||
|
insert(entity)
|
||||||
|
true
|
||||||
|
} else false
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
open suspend fun upsert(entities: Iterable<HistoryEntity>) {
|
||||||
|
for (e in entities) {
|
||||||
|
if (update(e) == 0) {
|
||||||
|
insert(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query("UPDATE history SET deleted_at = :deletedAt WHERE manga_id = :mangaId")
|
||||||
|
protected abstract suspend fun setDeletedAt(mangaId: Long, deletedAt: Long)
|
||||||
|
|
||||||
|
@Query("UPDATE history SET deleted_at = :deletedAt WHERE created_at >= :minDate AND deleted_at = 0")
|
||||||
|
protected abstract suspend fun setDeletedAtAfter(minDate: Long, deletedAt: Long)
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@RawQuery(observedEntities = [HistoryEntity::class])
|
||||||
|
protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<HistoryWithManga>>
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
package org.xtimms.tokusho.core.database.entity
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import org.xtimms.tokusho.core.database.TABLE_HISTORY
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = TABLE_HISTORY,
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = MangaEntity::class,
|
||||||
|
parentColumns = ["manga_id"],
|
||||||
|
childColumns = ["manga_id"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
data class HistoryEntity(
|
||||||
|
@PrimaryKey(autoGenerate = false)
|
||||||
|
@ColumnInfo(name = "manga_id") val mangaId: Long,
|
||||||
|
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||||
|
@ColumnInfo(name = "updated_at") val updatedAt: Long,
|
||||||
|
@ColumnInfo(name = "chapter_id") val chapterId: Long,
|
||||||
|
@ColumnInfo(name = "page") val page: Int,
|
||||||
|
@ColumnInfo(name = "scroll") val scroll: Float,
|
||||||
|
@ColumnInfo(name = "percent") val percent: Float,
|
||||||
|
@ColumnInfo(name = "deleted_at") val deletedAt: Long,
|
||||||
|
)
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package org.xtimms.tokusho.core.database.entity
|
||||||
|
|
||||||
|
import androidx.room.Embedded
|
||||||
|
import androidx.room.Junction
|
||||||
|
import androidx.room.Relation
|
||||||
|
|
||||||
|
class HistoryWithManga(
|
||||||
|
@Embedded val history: HistoryEntity,
|
||||||
|
@Relation(
|
||||||
|
parentColumn = "manga_id",
|
||||||
|
entityColumn = "manga_id"
|
||||||
|
)
|
||||||
|
val manga: MangaEntity,
|
||||||
|
@Relation(
|
||||||
|
parentColumn = "manga_id",
|
||||||
|
entityColumn = "tag_id",
|
||||||
|
associateBy = Junction(MangaTagsEntity::class)
|
||||||
|
)
|
||||||
|
val tags: List<TagEntity>,
|
||||||
|
)
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package org.xtimms.tokusho.core.exceptions
|
||||||
|
|
||||||
|
import okio.IOException
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
|
class TooManyRequestExceptions(
|
||||||
|
val url: String,
|
||||||
|
val retryAt: Instant?,
|
||||||
|
) : IOException() {
|
||||||
|
val retryAfter: Long
|
||||||
|
get() = retryAt?.until(Instant.now(), ChronoUnit.MILLIS) ?: 0
|
||||||
|
}
|
||||||
@ -0,0 +1,147 @@
|
|||||||
|
package org.xtimms.tokusho.core.logs
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import org.xtimms.tokusho.core.prefs.AppSettings
|
||||||
|
import org.xtimms.tokusho.utils.lang.processLifecycleScope
|
||||||
|
import org.xtimms.tokusho.utils.system.subdir
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
|
|
||||||
|
private const val DIR = "logs"
|
||||||
|
private const val FLUSH_DELAY = 2_000L
|
||||||
|
private const val MAX_SIZE_BYTES = 1024 * 1024 // 1 MB
|
||||||
|
|
||||||
|
class FileLogger(
|
||||||
|
context: Context,
|
||||||
|
name: String,
|
||||||
|
) {
|
||||||
|
|
||||||
|
val file by lazy {
|
||||||
|
val dir = context.getExternalFilesDir(DIR) ?: context.filesDir.subdir(DIR)
|
||||||
|
File(dir, "$name.log")
|
||||||
|
}
|
||||||
|
val isEnabled: Boolean
|
||||||
|
get() = AppSettings.isLoggingEnabled()
|
||||||
|
private val dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT).withLocale(
|
||||||
|
Locale.ROOT)
|
||||||
|
private val buffer = ConcurrentLinkedQueue<String>()
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private var flushJob: Job? = null
|
||||||
|
|
||||||
|
fun log(message: String, e: Throwable? = null) {
|
||||||
|
if (!isEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val text = buildString {
|
||||||
|
append(dateTimeFormatter.format(LocalDateTime.now()))
|
||||||
|
append(": ")
|
||||||
|
if (e != null) {
|
||||||
|
append("E!")
|
||||||
|
}
|
||||||
|
append(message)
|
||||||
|
if (e != null) {
|
||||||
|
append(' ')
|
||||||
|
append(e.stackTraceToString())
|
||||||
|
appendLine()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buffer.add(text)
|
||||||
|
postFlush()
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun log(messageProducer: () -> String) {
|
||||||
|
if (isEnabled) {
|
||||||
|
log(messageProducer())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun flush() {
|
||||||
|
if (!isEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flushJob?.cancelAndJoin()
|
||||||
|
flushImpl()
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
fun flushBlocking() {
|
||||||
|
if (!isEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runBlockingSafe { flushJob?.cancelAndJoin() }
|
||||||
|
runBlockingSafe { flushImpl() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun postFlush() {
|
||||||
|
if (flushJob?.isActive == true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flushJob = processLifecycleScope.launch(Dispatchers.Default) {
|
||||||
|
delay(FLUSH_DELAY)
|
||||||
|
runCatchingCancellable {
|
||||||
|
flushImpl()
|
||||||
|
}.onFailure {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun flushImpl() = withContext(NonCancellable) {
|
||||||
|
mutex.withLock {
|
||||||
|
if (buffer.isEmpty()) {
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
runInterruptible(Dispatchers.IO) {
|
||||||
|
if (file.length() > MAX_SIZE_BYTES) {
|
||||||
|
rotate()
|
||||||
|
}
|
||||||
|
FileOutputStream(file, true).use {
|
||||||
|
while (true) {
|
||||||
|
val message = buffer.poll() ?: break
|
||||||
|
it.write(message.toByteArray())
|
||||||
|
it.write('\n'.code)
|
||||||
|
}
|
||||||
|
it.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
private fun rotate() {
|
||||||
|
val length = file.length()
|
||||||
|
val bakFile = File(file.parentFile, file.name + ".bak")
|
||||||
|
file.renameTo(bakFile)
|
||||||
|
bakFile.inputStream().use { input ->
|
||||||
|
input.skip(length - MAX_SIZE_BYTES / 2)
|
||||||
|
file.outputStream().use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bakFile.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun runBlockingSafe(crossinline block: suspend () -> Unit) = try {
|
||||||
|
runBlocking(NonCancellable) { block() }
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package org.xtimms.tokusho.core.logs
|
||||||
|
|
||||||
|
import javax.inject.Qualifier
|
||||||
|
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class TrackerLogger
|
||||||
|
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class SyncLogger
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
package org.xtimms.tokusho.core.logs
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.collection.arraySetOf
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import dagger.multibindings.ElementsIntoSet
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object LoggersModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@TrackerLogger
|
||||||
|
fun provideTrackerLogger(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
) = FileLogger(context, "tracker")
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@SyncLogger
|
||||||
|
fun provideSyncLogger(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
) = FileLogger(context, "sync")
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@ElementsIntoSet
|
||||||
|
fun provideAllLoggers(
|
||||||
|
@TrackerLogger trackerLogger: FileLogger,
|
||||||
|
@SyncLogger syncLogger: FileLogger,
|
||||||
|
): Set<@JvmSuppressWildcards FileLogger> = arraySetOf(
|
||||||
|
trackerLogger,
|
||||||
|
syncLogger,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,5 +1,8 @@
|
|||||||
package org.xtimms.tokusho.core.model
|
package org.xtimms.tokusho.core.model
|
||||||
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
|
||||||
fun Collection<Manga>.distinctById() = distinctBy { it.id }
|
fun Collection<Manga>.distinctById() = distinctBy { it.id }
|
||||||
|
|
||||||
|
fun Collection<MangaChapter>.findById(id: Long) = find { x -> x.id == id }
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
package org.xtimms.tokusho.core.model
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class MangaHistory(
|
||||||
|
val createdAt: Instant,
|
||||||
|
val updatedAt: Instant,
|
||||||
|
val chapterId: Long,
|
||||||
|
val page: Int,
|
||||||
|
val scroll: Int,
|
||||||
|
val percent: Float,
|
||||||
|
) : Parcelable
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package org.xtimms.tokusho.core.network
|
||||||
|
|
||||||
|
import okhttp3.CacheControl
|
||||||
|
|
||||||
|
object CommonHeaders {
|
||||||
|
|
||||||
|
const val REFERER = "Referer"
|
||||||
|
const val USER_AGENT = "User-Agent"
|
||||||
|
const val ACCEPT = "Accept"
|
||||||
|
const val CONTENT_TYPE = "Content-Type"
|
||||||
|
const val CONTENT_DISPOSITION = "Content-Disposition"
|
||||||
|
const val COOKIE = "Cookie"
|
||||||
|
const val CONTENT_ENCODING = "Content-Encoding"
|
||||||
|
const val ACCEPT_ENCODING = "Accept-Encoding"
|
||||||
|
const val AUTHORIZATION = "Authorization"
|
||||||
|
const val CACHE_CONTROL = "Cache-Control"
|
||||||
|
const val PROXY_AUTHORIZATION = "Proxy-Authorization"
|
||||||
|
const val RETRY_AFTER = "Retry-After"
|
||||||
|
|
||||||
|
val CACHE_CONTROL_NO_STORE: CacheControl
|
||||||
|
get() = CacheControl.Builder().noStore().build()
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
package org.xtimms.tokusho.core.network.interceptors
|
||||||
|
|
||||||
|
import okhttp3.CacheControl
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.xtimms.tokusho.core.network.CommonHeaders
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class CacheLimitInterceptor : Interceptor {
|
||||||
|
|
||||||
|
private val defaultMaxAge = TimeUnit.HOURS.toSeconds(1)
|
||||||
|
private val defaultCacheControl = CacheControl.Builder()
|
||||||
|
.maxAge(defaultMaxAge.toInt(), TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val response = chain.proceed(chain.request())
|
||||||
|
val responseCacheControl = CacheControl.parse(response.headers)
|
||||||
|
if (responseCacheControl.noStore || responseCacheControl.maxAgeSeconds <= defaultMaxAge) {
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
return response.newBuilder()
|
||||||
|
.header(CommonHeaders.CACHE_CONTROL, defaultCacheControl)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
package org.xtimms.tokusho.core.network.interceptors
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import dagger.Lazy
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mergeWith
|
||||||
|
import org.xtimms.tokusho.BuildConfig
|
||||||
|
import org.xtimms.tokusho.core.network.CommonHeaders
|
||||||
|
import org.xtimms.tokusho.core.parser.MangaRepository
|
||||||
|
import org.xtimms.tokusho.core.parser.RemoteMangaRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class CommonHeadersInterceptor @Inject constructor(
|
||||||
|
private val mangaRepositoryFactoryLazy: Lazy<MangaRepository.Factory>,
|
||||||
|
) : Interceptor {
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
|
val source = request.tag(MangaSource::class.java)
|
||||||
|
val repository = if (source != null) {
|
||||||
|
mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository
|
||||||
|
} else {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Log.w("Http", "Request without source tag: ${request.url}")
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val headersBuilder = request.headers.newBuilder()
|
||||||
|
repository?.headers?.let {
|
||||||
|
headersBuilder.mergeWith(it, replaceExisting = false)
|
||||||
|
}
|
||||||
|
if (headersBuilder[CommonHeaders.USER_AGENT] == null) {
|
||||||
|
headersBuilder[CommonHeaders.USER_AGENT] = UserAgents.CHROME_MOBILE
|
||||||
|
}
|
||||||
|
val newRequest = request.newBuilder().headers(headersBuilder.build()).build()
|
||||||
|
return repository?.intercept(ProxyChain(chain, newRequest)) ?: chain.proceed(newRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Headers.Builder.trySet(name: String, value: String) = try {
|
||||||
|
set(name, value)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ProxyChain(
|
||||||
|
private val delegate: Interceptor.Chain,
|
||||||
|
private val request: Request,
|
||||||
|
) : Interceptor.Chain by delegate {
|
||||||
|
|
||||||
|
override fun request(): Request = request
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package org.xtimms.tokusho.core.network.interceptors
|
||||||
|
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import okio.IOException
|
||||||
|
import org.xtimms.tokusho.core.network.CommonHeaders.CONTENT_ENCODING
|
||||||
|
|
||||||
|
class GZipInterceptor : Interceptor {
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val newRequest = chain.request().newBuilder()
|
||||||
|
newRequest.addHeader(CONTENT_ENCODING, "gzip")
|
||||||
|
return try {
|
||||||
|
chain.proceed(newRequest.build())
|
||||||
|
} catch (e: NullPointerException) {
|
||||||
|
throw IOException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package org.xtimms.tokusho.core.network.interceptors
|
||||||
|
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.internal.closeQuietly
|
||||||
|
import org.xtimms.tokusho.core.exceptions.TooManyRequestExceptions
|
||||||
|
import org.xtimms.tokusho.core.network.CommonHeaders
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
class RateLimitInterceptor : Interceptor {
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val response = chain.proceed(chain.request())
|
||||||
|
if (response.code == 429) {
|
||||||
|
val retryDate = response.header(CommonHeaders.RETRY_AFTER)?.parseRetryDate()
|
||||||
|
val request = response.request
|
||||||
|
response.closeQuietly()
|
||||||
|
throw TooManyRequestExceptions(
|
||||||
|
url = request.url.toString(),
|
||||||
|
retryAt = retryDate,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.parseRetryDate(): Instant? {
|
||||||
|
return toLongOrNull()?.let { Instant.now().plusSeconds(it) }
|
||||||
|
?: ZonedDateTime.parse(this, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
package org.xtimms.tokusho.data.repository
|
||||||
|
|
||||||
|
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.MangaDatabase
|
||||||
|
import org.xtimms.tokusho.core.database.entity.HistoryEntity
|
||||||
|
import org.xtimms.tokusho.core.database.entity.toMangaHistory
|
||||||
|
import org.xtimms.tokusho.core.model.MangaHistory
|
||||||
|
import org.xtimms.tokusho.core.model.findById
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
const val PROGRESS_NONE = -1f
|
||||||
|
|
||||||
|
@Reusable
|
||||||
|
class HistoryRepository @Inject constructor(
|
||||||
|
private val db: MangaDatabase,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun getOne(manga: Manga): MangaHistory? {
|
||||||
|
return db.getHistoryDao().find(manga.id)?.recoverIfNeeded(manga)?.toMangaHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeOne(id: Long): Flow<MangaHistory?> {
|
||||||
|
return db.getHistoryDao().observe(id).map {
|
||||||
|
it?.toMangaHistory()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun HistoryEntity.recoverIfNeeded(manga: Manga): HistoryEntity {
|
||||||
|
val chapters = manga.chapters
|
||||||
|
if (chapters.isNullOrEmpty() || chapters.findById(chapterId) != null) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
val newChapterId = chapters.getOrNull(
|
||||||
|
(chapters.size * percent).toInt(),
|
||||||
|
)?.id ?: return this
|
||||||
|
val newEntity = copy(chapterId = newChapterId)
|
||||||
|
db.getHistoryDao().update(newEntity)
|
||||||
|
return newEntity
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
package org.xtimms.tokusho.sections.details.data
|
||||||
|
|
||||||
|
import android.content.res.Resources
|
||||||
|
import org.xtimms.tokusho.R
|
||||||
|
|
||||||
|
data class ReadingTime(
|
||||||
|
val minutes: Int,
|
||||||
|
val hours: Int,
|
||||||
|
val isContinue: Boolean,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun format(resources: Resources): String = when {
|
||||||
|
hours == 0 -> resources.getQuantityString(R.plurals.minutes, minutes, minutes)
|
||||||
|
minutes == 0 -> resources.getQuantityString(R.plurals.hours, hours, hours)
|
||||||
|
else -> resources.getString(
|
||||||
|
R.string.remaining_time_pattern,
|
||||||
|
resources.getQuantityString(R.plurals.hours, hours, hours),
|
||||||
|
resources.getQuantityString(R.plurals.minutes, minutes, minutes),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
package org.xtimms.tokusho.sections.details.domain
|
||||||
|
|
||||||
|
import org.xtimms.tokusho.core.model.MangaHistory
|
||||||
|
import org.xtimms.tokusho.core.model.findById
|
||||||
|
import org.xtimms.tokusho.core.prefs.AppSettings
|
||||||
|
import org.xtimms.tokusho.sections.details.data.MangaDetails
|
||||||
|
import org.xtimms.tokusho.sections.details.data.ReadingTime
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
class ReadingTimeUseCase @Inject constructor() {
|
||||||
|
|
||||||
|
fun invoke(manga: MangaDetails?, branch: String?, history: MangaHistory?): ReadingTime? {
|
||||||
|
if (!AppSettings.isReadingTimeEstimationEnabled()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// FIXME MAXIMUM HARDCODE!!! To do calculation with user's page read speed and his favourites/history mangas average pages in chapter
|
||||||
|
val chapters = manga?.chapters?.get(branch)
|
||||||
|
if (chapters.isNullOrEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val isOnHistoryBranch = history != null && chapters.findById(history.chapterId) != null
|
||||||
|
// Impossible task, I guess. Good luck on this.
|
||||||
|
var averageTimeSec: Int = 20 * 10 * chapters.size // 20 pages, 10 seconds per page
|
||||||
|
if (isOnHistoryBranch) {
|
||||||
|
averageTimeSec = (averageTimeSec * (1f - checkNotNull(history).percent)).roundToInt()
|
||||||
|
}
|
||||||
|
if (averageTimeSec < 60) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return ReadingTime(
|
||||||
|
minutes = (averageTimeSec / 60) % 60,
|
||||||
|
hours = averageTimeSec / 3600,
|
||||||
|
isContinue = isOnHistoryBranch,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package org.xtimms.tokusho.sections.settings.storage
|
||||||
|
|
||||||
|
import org.xtimms.tokusho.core.base.event.UiEvent
|
||||||
|
|
||||||
|
interface StorageEvent : UiEvent
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
package org.xtimms.tokusho.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.app.ShareCompat
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import org.xtimms.tokusho.BuildConfig
|
||||||
|
import org.xtimms.tokusho.R
|
||||||
|
import org.xtimms.tokusho.core.logs.FileLogger
|
||||||
|
|
||||||
|
private const val TYPE_TEXT = "text/plain"
|
||||||
|
|
||||||
|
class ShareHelper(private val context: Context) {
|
||||||
|
|
||||||
|
fun shareLogs(loggers: Collection<FileLogger>) {
|
||||||
|
val intentBuilder = ShareCompat.IntentBuilder(context)
|
||||||
|
.setType(TYPE_TEXT)
|
||||||
|
var hasLogs = false
|
||||||
|
for (logger in loggers) {
|
||||||
|
val logFile = logger.file
|
||||||
|
if (!logFile.exists()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", logFile)
|
||||||
|
intentBuilder.addStream(uri)
|
||||||
|
hasLogs = true
|
||||||
|
}
|
||||||
|
if (hasLogs) {
|
||||||
|
intentBuilder.setChooserTitle(R.string.share_logs)
|
||||||
|
intentBuilder.startChooser()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, R.string.nothing_here, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<plurals name="hours">
|
||||||
|
<item quantity="one">%1$d hour</item>
|
||||||
|
<item quantity="other">%1$d hours</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="minutes">
|
||||||
|
<item quantity="one">%1$d minute</item>
|
||||||
|
<item quantity="other">%1$d minutes</item>
|
||||||
|
</plurals>
|
||||||
|
</resources>
|
||||||
Loading…
Reference in New Issue