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
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
|
||||
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