Some work

master
Zakhar Timoshenko 2 years ago
parent 859ebd208e
commit 11022d0d04
Signed by: Xtimms
SSH Key Fingerprint: SHA256:wH6spYepK/A5erBh7ZyAnr1ru9H4eaMVBEuiw6DSpxI

@ -1,3 +1,4 @@
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
import java.io.ByteArrayOutputStream
import java.text.SimpleDateFormat
import java.util.Date
@ -13,6 +14,9 @@ plugins {
id("dagger.hilt.android.plugin")
}
val acraAuthLogin: String = gradleLocalProperties(rootDir).getProperty("authLogin") ?: "\"acra_login\""
val acraAuthPassword: String = gradleLocalProperties(rootDir).getProperty("authPassword") ?: "\"acra_password\""
android {
namespace = "org.xtimms.tokusho"
compileSdk = 34
@ -28,6 +32,10 @@ android {
buildConfigField("String", "COMMIT_SHA", "\"${getGitSha()}\"")
buildConfigField("String", "BUILD_TIME", "\"${getBuildTime()}\"")
buildConfigField("String", "ACRA_URI", "\"https://bugs.kotatsu.app/report\"")
buildConfigField("String", "ACRA_AUTH_LOGIN", acraAuthLogin)
buildConfigField("String", "ACRA_AUTH_PASSWORD", acraAuthPassword)
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
@ -43,6 +51,9 @@ android {
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
}
release {
isMinifyEnabled = true
proguardFiles(
@ -93,6 +104,7 @@ dependencies {
implementation("androidx.room:room-ktx:2.6.1")
implementation("androidx.work:work-runtime-ktx:2.9.0")
ksp("androidx.room:room-compiler:2.6.1")
implementation("ch.acra:acra-http:5.9.7")
implementation("com.google.android.material:material:1.11.0")
implementation("com.google.accompanist:accompanist-flowlayout:0.32.0")
implementation("com.google.accompanist:accompanist-systemuicontroller:0.32.0")

@ -5,6 +5,12 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<queries>
<intent>

@ -11,11 +11,15 @@ import com.tencent.mmkv.MMKV
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.acra.ReportField
import org.acra.config.httpSender
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.acra.sender.HttpSender
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.xtimms.tokusho.core.database.MangaDatabase
import org.xtimms.tokusho.core.prefs.AppSettings
import org.xtimms.tokusho.core.updates.Updater
import org.xtimms.tokusho.crash.CrashActivity
import org.xtimms.tokusho.crash.GlobalExceptionHandler
import org.xtimms.tokusho.utils.lang.processLifecycleScope
import javax.inject.Inject
import javax.inject.Provider
@ -44,7 +48,30 @@ class App : Application() {
}
}
GlobalExceptionHandler.initialize(applicationContext, CrashActivity::class.java)
// GlobalExceptionHandler.initialize(applicationContext, CrashActivity::class.java)
if (AppSettings.isACRAEnabled()) {
initAcra {
buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON
httpSender {
uri = BuildConfig.ACRA_URI
basicAuthLogin = BuildConfig.ACRA_AUTH_LOGIN
basicAuthPassword = BuildConfig.ACRA_AUTH_PASSWORD
httpMethod = HttpSender.Method.POST
}
reportContent = listOf(
ReportField.PACKAGE_NAME,
ReportField.INSTALLATION_ID,
ReportField.APP_VERSION_CODE,
ReportField.APP_VERSION_NAME,
ReportField.ANDROID_VERSION,
ReportField.PHONE_MODEL,
ReportField.STACK_TRACE,
ReportField.CRASH_CONFIGURATION,
ReportField.CUSTOM_DATA,
)
}
}
}
override fun attachBaseContext(base: Context?) {

@ -39,6 +39,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat
import androidx.core.view.WindowCompat
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import coil.ImageLoader
@ -48,6 +49,7 @@ import kotlinx.coroutines.launch
import org.xtimms.tokusho.core.Navigation
import org.xtimms.tokusho.core.components.BottomNavBar
import org.xtimms.tokusho.core.components.TopAppBar
import org.xtimms.tokusho.core.logs.FileLogger
import org.xtimms.tokusho.ui.theme.TokushoTheme
import org.xtimms.tokusho.utils.lang.processLifecycleScope
import javax.inject.Inject
@ -59,9 +61,18 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var loggers: Set<@JvmSuppressWildcards FileLogger>
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
if (!isTaskRoot) {
finish()
return
}
setContent {
val navController = rememberNavController()
val windowSizeClass = calculateWindowSizeClass(this)
@ -74,6 +85,7 @@ class MainActivity : ComponentActivity() {
) {
MainView(
coil = coil,
loggers = loggers,
isCompactScreen = isCompactScreen,
navController = navController
)
@ -112,6 +124,7 @@ class MainActivity : ComponentActivity() {
@Composable
fun MainView(
coil: ImageLoader,
loggers: Set<FileLogger>,
isCompactScreen: Boolean,
navController: NavHostController,
) {
@ -161,6 +174,7 @@ fun MainView(
) {
Navigation(
coil = coil,
loggers = loggers,
navController = navController,
isCompactScreen = false,
modifier = Modifier,
@ -180,6 +194,7 @@ fun MainView(
}
Navigation(
coil = coil,
loggers = loggers,
navController = navController,
isCompactScreen = true,
modifier = Modifier.padding(

@ -18,6 +18,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.tokusho.core.logs.FileLogger
import org.xtimms.tokusho.core.model.ShelfCategory
import org.xtimms.tokusho.core.motion.materialSharedAxisXIn
import org.xtimms.tokusho.core.motion.materialSharedAxisXOut
@ -62,6 +63,7 @@ fun PathInterpolator.toEasing(): Easing {
@Composable
fun Navigation(
coil: ImageLoader,
loggers: Set<FileLogger>,
navController: NavHostController,
isCompactScreen: Boolean,
modifier: Modifier,
@ -180,6 +182,7 @@ fun Navigation(
composable(ADVANCED_DESTINATION) {
AdvancedView(
loggers = loggers,
navigateBack = navigateBack,
)
}
@ -220,13 +223,16 @@ fun Navigation(
)
}
// TODO
composable(
route = DETAILS_DESTINATION
route = DETAILS_DESTINATION,
arguments = listOf(
navArgument(MANGA_ID_ARGUMENT.removeFirstAndLast()) {
type = NavType.LongType
}
)
) { navEntry ->
DetailsView(
coil = coil,
mangaId = 0L,
navigateBack = navigateBack,
)
}

@ -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)
}
}
}
}

@ -1,5 +1,13 @@
package org.xtimms.tokusho.core.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.animateFloatAsState
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.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -27,6 +35,7 @@ import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
@ -186,15 +195,29 @@ internal fun PreferenceItemDescription(
color: Color = MaterialTheme.colorScheme.onSurfaceVariant,
overflow: TextOverflow = TextOverflow.Ellipsis
) {
Text(
AnimatedContent(
targetState = text,
transitionSpec = {
if (targetState > initialState) {
(slideInVertically { height -> -height } + fadeIn()).togetherWith(
slideOutVertically { height -> height } + fadeOut())
} else {
(slideInVertically { height -> height } + fadeIn()).togetherWith(
slideOutVertically { height -> -height } + fadeOut())
}.using(SizeTransform(clip = false))
},
modifier = modifier.padding(top = 2.dp),
text = text,
label = "Preference desc"
) { targetText ->
Text(
text = targetText,
maxLines = maxLines,
style = style,
color = color.applyOpacity(enabled),
overflow = overflow
)
}
}
@Composable
fun PreferenceSwitchWithDivider(
@ -552,9 +575,16 @@ fun PreferencesHintCard(
@Composable
fun PreferenceStorageHeader(
used: Long = 4L,
total: Long = 128L
used: Float = 40F,
total: Float = 128F
) {
val animatedProgress = animateFloatAsState(
targetValue = 1 - ((total - used) / total),
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
label = "Progress"
).value
Column {
Row(
modifier = Modifier
@ -562,27 +592,47 @@ fun PreferenceStorageHeader(
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = FileSize.BYTES.formatWithoutUnits(used),
modifier = Modifier.padding(end = 4.dp),
style = MaterialTheme.typography.displayLarge
AnimatedNumber(
value = FileSize.BYTES.formatWithoutUnits(used)
)
Text(
text = FileSize.BYTES.showUnit(LocalContext.current, used),
AnimatedContent(
targetState = used,
transitionSpec = {
if (targetState > initialState) {
(fadeIn()).togetherWith(fadeOut())
} else {
(fadeIn()).togetherWith(fadeOut())
}.using(SizeTransform(clip = false))
},
modifier = Modifier
.weight(1f)
.align(Alignment.Bottom)
.padding(PaddingValues(bottom = 8.dp))
)
Text(
text = FileSize.BYTES.totalFormat(LocalContext.current, total),
.padding(PaddingValues(start = 4.dp, bottom = 8.dp)),
label = "Unit"
) { targetUsed ->
Text(text = FileSize.BYTES.showUnit(LocalContext.current, targetUsed))
}
AnimatedContent(
targetState = total,
transitionSpec = {
if (targetState > initialState) {
(fadeIn()).togetherWith(fadeOut())
} else {
(fadeIn()).togetherWith(fadeOut())
}.using(SizeTransform(clip = false))
},
modifier = Modifier
.align(Alignment.Bottom)
.padding(PaddingValues(bottom = 8.dp))
.padding(PaddingValues(bottom = 8.dp)),
label = "Total used"
) { targetTotal ->
Text(
text = FileSize.BYTES.totalFormat(LocalContext.current, targetTotal),
)
}
}
LinearProgressIndicator(
progress = { (1 - ((total - used) / total.toFloat())) },
progress = { animatedProgress },
modifier = Modifier
.padding(PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp))
.height(16.dp)
@ -598,13 +648,20 @@ fun PreferenceStorageHeader(
@Composable
fun PreferenceStorageItem(
title: String,
used: Long? = 0L,
total: Long?,
used: Float = 0F,
total: Float = 0F,
icon: Any? = null,
leadingIcon: (@Composable () -> Unit)? = null,
trailingIcon: (@Composable () -> Unit)? = null,
onClick: () -> Unit = {},
) {
val animatedProgress = animateFloatAsState(
targetValue = 1 - ((total - used) / total),
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
label = "Progress"
).value
Surface(
modifier = Modifier.combinedClickable(
onClick = onClick,
@ -658,11 +715,22 @@ fun PreferenceStorageItem(
text = title,
enabled = true
)
Text(text = FileSize.BYTES.format(LocalContext.current, used ?: 0L))
AnimatedContent(
targetState = used,
transitionSpec = {
if (targetState > initialState) {
(fadeIn()).togetherWith(fadeOut())
} else {
(fadeIn()).togetherWith(fadeOut())
}.using(SizeTransform(clip = false))
},
label = "Total used"
) { targetTotal ->
Text(text = FileSize.BYTES.format(LocalContext.current, targetTotal))
}
}
if (total != null) {
LinearProgressIndicator(
progress = { (1 - ((total - used!!) / total.toFloat())) },
progress = { animatedProgress },
modifier = Modifier
.padding(PaddingValues(top = 12.dp))
.height(5.dp)
@ -672,7 +740,6 @@ fun PreferenceStorageItem(
strokeCap = StrokeCap.Round,
)
}
}
trailingIcon?.let {
VerticalDivider(
modifier = Modifier
@ -700,7 +767,7 @@ fun PreferenceStorageHeaderPreview() {
@Preview(showBackground = true)
fun PreferenceStorageItemPreview() {
TokushoTheme {
PreferenceStorageItem(title = "Saved manga", icon = Icons.Outlined.Save, total = 0L)
PreferenceStorageItem(title = "Saved manga", icon = Icons.Outlined.Save, total = 0F)
}
}

@ -3,7 +3,9 @@ package org.xtimms.tokusho.core.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.systemBars
import androidx.compose.material3.ExperimentalMaterial3Api
@ -29,7 +31,8 @@ fun ScaffoldWithTopAppBar(
Scaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection),
.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection)
.consumeWindowInsets(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)),
topBar = {
DefaultTopAppBar(
title = title,
@ -38,8 +41,7 @@ fun ScaffoldWithTopAppBar(
)
},
floatingActionButton = floatingActionButton,
contentWindowInsets = WindowInsets.systemBars
.only(WindowInsetsSides.Horizontal),
contentWindowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal),
content = content
)
}

@ -1,5 +1,10 @@
package org.xtimms.tokusho.core.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -60,8 +65,19 @@ fun SettingItem(title: String, description: String, icon: ImageVector?, onClick:
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
AnimatedContent(
targetState = description,
transitionSpec = {
if (targetState > initialState) {
(fadeIn()).togetherWith(fadeOut())
} else {
(fadeIn()).togetherWith(fadeOut())
}.using(SizeTransform(clip = false))
},
label = "Total used"
) { targetDescription ->
Text(
text = description,
text = targetDescription,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
style = MaterialTheme.typography.bodyMedium,
@ -70,3 +86,4 @@ fun SettingItem(title: String, description: String, icon: ImageVector?, onClick:
}
}
}
}

@ -4,3 +4,4 @@ const val TABLE_MANGA = "manga"
const val TABLE_TAGS = "tags"
const val TABLE_MANGA_TAGS = "manga_tags"
const val TABLE_SOURCES = "sources"
const val TABLE_HISTORY = "history"

@ -2,14 +2,23 @@ package org.xtimms.tokusho.core.database
import android.content.Context
import androidx.room.Database
import androidx.room.InvalidationTracker
import androidx.room.Room
import androidx.room.RoomDatabase
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.xtimms.tokusho.core.database.dao.HistoryDao
import org.xtimms.tokusho.core.database.dao.MangaDao
import org.xtimms.tokusho.core.database.dao.MangaSourcesDao
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.MangaTagsEntity
import org.xtimms.tokusho.core.database.entity.TagEntity
import org.xtimms.tokusho.utils.lang.processLifecycleScope
const val DATABASE_VERSION = 1
@ -18,12 +27,15 @@ const val DATABASE_VERSION = 1
MangaEntity::class,
TagEntity::class,
MangaTagsEntity::class,
MangaSourceEntity::class
MangaSourceEntity::class,
HistoryEntity::class
],
version = DATABASE_VERSION
)
abstract class MangaDatabase : RoomDatabase() {
abstract fun getHistoryDao(): HistoryDao
abstract fun getMangaDao(): MangaDao
abstract fun getSourcesDao(): MangaSourcesDao
@ -33,3 +45,13 @@ abstract class MangaDatabase : RoomDatabase() {
fun MangaDatabase(context: Context): MangaDatabase = Room
.databaseBuilder(context, MangaDatabase::class.java, "tokusho-db")
.build()
@OptIn(ExperimentalCoroutinesApi::class)
fun InvalidationTracker.removeObserverAsync(observer: InvalidationTracker.Observer) {
val scope = processLifecycleScope
if (scope.isActive) {
processLifecycleScope.launch(Dispatchers.Default, CoroutineStart.ATOMIC) {
removeObserver(observer)
}
}
}

@ -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>>
}

@ -6,8 +6,10 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.xtimms.tokusho.core.model.MangaHistory
import org.xtimms.tokusho.core.model.MangaSource
import org.xtimms.tokusho.utils.lang.longHashCode
import java.time.Instant
// Entity to model
@ -74,3 +76,12 @@ fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
fun MangaState(name: String): MangaState? = runCatching {
MangaState.valueOf(name)
}.getOrNull()
fun HistoryEntity.toMangaHistory() = MangaHistory(
createdAt = Instant.ofEpochMilli(createdAt),
updatedAt = Instant.ofEpochMilli(updatedAt),
chapterId = chapterId,
page = page,
scroll = scroll.toInt(),
percent = percent,
)

@ -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

@ -3,9 +3,9 @@ package org.xtimms.tokusho.core.motion
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
public object MotionConstants {
public const val DefaultMotionDuration: Int = 300
public const val DefaultFadeInDuration: Int = 150
public const val DefaultFadeOutDuration: Int = 75
public val DefaultSlideDistance: Dp = 30.dp
object MotionConstants {
const val DefaultMotionDuration: Int = 300
const val DefaultFadeInDuration: Int = 150
const val DefaultFadeOutDuration: Int = 75
val DefaultSlideDistance: Dp = 30.dp
}

@ -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()
}

@ -14,6 +14,10 @@ import okhttp3.OkHttpClient
import org.xtimms.tokusho.core.network.cookies.AndroidCookieJar
import org.xtimms.tokusho.core.network.cookies.MutableCookieJar
import org.xtimms.tokusho.core.network.cookies.PreferencesCookieJar
import org.xtimms.tokusho.core.network.interceptors.CacheLimitInterceptor
import org.xtimms.tokusho.core.network.interceptors.CommonHeadersInterceptor
import org.xtimms.tokusho.core.network.interceptors.GZipInterceptor
import org.xtimms.tokusho.core.network.interceptors.RateLimitInterceptor
import org.xtimms.tokusho.data.LocalStorageManager
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@ -55,6 +59,8 @@ interface NetworkModule {
writeTimeout(20, TimeUnit.SECONDS)
cookieJar(cookieJar)
cache(cache)
addInterceptor(GZipInterceptor())
addInterceptor(RateLimitInterceptor())
}.build()
@Provides
@ -62,7 +68,11 @@ interface NetworkModule {
@MangaHttpClient
fun provideMangaHttpClient(
@BaseHttpClient baseClient: OkHttpClient,
): OkHttpClient = baseClient.newBuilder().build()
commonHeadersInterceptor: CommonHeadersInterceptor,
): OkHttpClient = baseClient.newBuilder().apply {
addNetworkInterceptor(CacheLimitInterceptor())
addInterceptor(commonHeadersInterceptor)
}.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()
}
}

@ -8,6 +8,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.currentCoroutineContext
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.parsers.InternalParsersApi
@ -60,6 +61,9 @@ class RemoteMangaRepository(
val domains: Array<out String>
get() = parser.configKeyDomain.presetValues
val headers: Headers
get() = parser.headers
override fun intercept(chain: Interceptor.Chain): Response {
return if (parser is Interceptor) {
parser.intercept(chain)

@ -26,12 +26,16 @@ const val UPDATE_CHANNEL = "update_channel"
private const val THEME_COLOR = "theme_color"
const val PALETTE_STYLE = "palette_style"
const val LANGUAGE = "language"
const val READING_TIME = "reading_time"
const val SYSTEM_DEFAULT = 0
const val STABLE = 0
const val PRE_RELEASE = 1
const val ACRA = "acra"
const val LOGGING = "logging"
val paletteStyles = listOf(
PaletteStyle.TonalSpot,
PaletteStyle.Spritz,
@ -87,6 +91,12 @@ object AppSettings {
fun isAutoUpdateEnabled() = AUTO_UPDATE.getBoolean(false)
fun isACRAEnabled() = ACRA.getBoolean(false)
fun isLoggingEnabled() = LOGGING.getBoolean(false)
fun isReadingTimeEstimationEnabled() = READING_TIME.getBoolean(true)
fun getLanguageConfiguration(languageNumber: Int = kv.decodeInt(LANGUAGE)) =
languageMap.getOrElse(languageNumber) { "" }
@ -112,13 +122,10 @@ object AppSettings {
private val mutableAppSettingsStateFlow = MutableStateFlow(
Settings(
DarkThemePreference(
darkThemeValue = kv.decodeInt(
DARK_THEME_VALUE, DarkThemePreference.FOLLOW_SYSTEM
), isHighContrastModeEnabled = kv.decodeBool(HIGH_CONTRAST, false)
),
isDynamicColorEnabled = kv.decodeBool(
DYNAMIC_COLOR, DynamicColors.isDynamicColorAvailable()
darkThemeValue = kv.decodeInt(DARK_THEME_VALUE, DarkThemePreference.FOLLOW_SYSTEM),
isHighContrastModeEnabled = kv.decodeBool(HIGH_CONTRAST, false)
),
isDynamicColorEnabled = kv.decodeBool(DYNAMIC_COLOR, DynamicColors.isDynamicColorAvailable()),
seedColor = kv.decodeInt(THEME_COLOR, SEED),
paletteStyleIndex = kv.decodeInt(PALETTE_STYLE, 0)
)

@ -19,7 +19,7 @@ private const val DIR_NAME = "manga"
private const val NOMEDIA = ".nomedia"
private const val CACHE_DISK_PERCENTAGE = 0.02
private const val CACHE_SIZE_MIN: Long = 10 * 1024 * 1024 // 10MB
private const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB
const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB
@Reusable
class LocalStorageManager @Inject constructor(

@ -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
}
}

@ -1,11 +1,10 @@
package org.xtimms.tokusho.sections.details
import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.tokusho.core.base.state.UiState
import org.xtimms.tokusho.sections.details.data.MangaDetails
data class DetailsUiState(
val manga: Manga? = null,
val details: MangaDetails? = null,
override val isLoading: Boolean = false,
override val message: String? = null,
) : UiState() {

@ -7,39 +7,41 @@ import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Timelapse
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaState
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.DetailsToolbar
import org.xtimms.tokusho.core.components.PreferenceItem
import org.xtimms.tokusho.core.prefs.AppSettings
const val MANGA_ID_ARGUMENT = "{mangaId}"
const val DETAILS_DESTINATION = "details/$MANGA_ID_ARGUMENT"
const val DETAILS_DESTINATION = "details/?mangaId=$MANGA_ID_ARGUMENT"
@Composable
fun DetailsView(
coil: ImageLoader,
mangaId: Long,
navigateBack: () -> Unit,
) {
val context = LocalContext.current
val viewModel: DetailsViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val chapterListState = rememberLazyListState()
LaunchedEffect(mangaId) {
viewModel.getDetails(mangaId)
}
Scaffold(
topBar = {
val isFirstItemVisible by remember {
@ -57,7 +59,7 @@ fun DetailsView(
label = "Top Bar Background",
)
DetailsToolbar(
title = "Test",
title = uiState.details?.toManga()?.title ?: "Unknown",
titleAlphaProvider = { animatedTitleAlpha },
backgroundAlphaProvider = { animatedBgAlpha },
onBackClicked = { navigateBack() }
@ -84,24 +86,39 @@ fun DetailsView(
) {
DetailsInfoBox(
coil = coil,
imageUrl = uiState.manga?.largeCoverUrl ?: "",
title = uiState.manga?.title ?: "",
author = uiState.manga?.author ?: "",
imageUrl = uiState.details?.toManga()?.largeCoverUrl ?: "",
title = uiState.details?.toManga()?.title ?: "",
author = uiState.details?.toManga()?.author ?: "",
artist = "",
state = uiState.manga?.state ?: MangaState.FINISHED,
state = uiState.details?.toManga()?.state ?: MangaState.FINISHED,
isTabletUi = false,
appBarPadding = topPadding,
)
}
val time = viewModel.readingTime.value
if (AppSettings.isReadingTimeEstimationEnabled() || time == null) {
item {
if (time != null) {
PreferenceItem(
title = if (time.isContinue) stringResource(id = R.string.approximate_remaining_time) else stringResource(
id = R.string.approximate_reading_time
),
description = time.format(context.resources),
icon = Icons.Outlined.Timelapse
)
}
}
}
item(
key = DetailsViewItem.DESCRIPTION_WITH_TAG,
contentType = DetailsViewItem.DESCRIPTION_WITH_TAG,
) {
ExpandableMangaDescription(
defaultExpandState = true,
description = uiState.manga?.description ?: "",
tagsProvider = { uiState.manga?.tags?.toList() },
description = uiState.details?.toManga()?.description ?: "",
tagsProvider = { uiState.details?.toManga()?.tags?.toList() },
onTagSearch = { },
onCopyTagToClipboard = { },
)

@ -4,9 +4,11 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
@ -14,42 +16,80 @@ import kotlinx.coroutines.plus
import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel
import org.xtimms.tokusho.core.parser.MangaIntent
import org.xtimms.tokusho.data.repository.HistoryRepository
import org.xtimms.tokusho.sections.details.data.MangaDetails
import org.xtimms.tokusho.sections.details.domain.DetailsLoadUseCase
import org.xtimms.tokusho.sections.details.domain.ReadingTimeUseCase
import org.xtimms.tokusho.utils.lang.onEachWhile
import javax.inject.Inject
@HiltViewModel
class DetailsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val historyRepository: HistoryRepository,
private val detailsLoadUseCase: DetailsLoadUseCase,
private val readingTimeUseCase: ReadingTimeUseCase,
) : BaseViewModel<DetailsUiState>(), DetailsEvent {
override val mutableUiState = MutableStateFlow(DetailsUiState())
private val intent = MangaIntent(savedStateHandle)
private val mangaId = intent.id
private var loadingJob: Job
val details = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, false) })
val mangaD = details.map { x -> x?.toManga() }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val manga = details.map { x -> x?.toManga() }
val history = historyRepository.observeOne(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
override val mutableUiState = MutableStateFlow(DetailsUiState())
val remoteManga = MutableStateFlow<Manga?>(null)
private val chaptersQuery = MutableStateFlow("")
val selectedBranch = MutableStateFlow<String?>(null)
@Deprecated("")
val description = details
.map { it?.description }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, null)
fun getDetails(mangaId: Long) {
launchLoadingJob(Dispatchers.Default) {
detailsLoadUseCase.invoke(intent)
val isChaptersEmpty: StateFlow<Boolean> = details.map {
it != null && it.isLoaded && it.allChapters.isEmpty()
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
val readingTime = combine(
details,
selectedBranch,
history,
) { m, b, h ->
readingTimeUseCase.invoke(m, b, h)
}.stateIn(viewModelScope, SharingStarted.Lazily, null)
init {
loadingJob = doLoad()
}
fun reload() {
loadingJob.cancel()
loadingJob = doLoad()
}
private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
detailsLoadUseCase.invoke(mangaId ?: 0L)
.onEachWhile {
if (it.allChapters.isEmpty()) {
return@onEachWhile false
}
true
}.collect {
//details.value = it
mutableUiState.update {
val manga = details.firstOrNull { it != null } ?: return@collect
it.copy(
manga = manga.toManga()
details = details.value
)
}
}
}
}
}

@ -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),
)
}
}

@ -7,16 +7,12 @@ import android.text.style.ForegroundColorSpan
import androidx.core.text.getSpans
import androidx.core.text.parseAsHtml
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.recoverNotNull
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xtimms.tokusho.core.parser.MangaDataRepository
import org.xtimms.tokusho.core.parser.MangaIntent
import org.xtimms.tokusho.core.parser.MangaRepository
import org.xtimms.tokusho.sections.details.data.MangaDetails
import org.xtimms.tokusho.utils.lang.sanitize
@ -29,9 +25,9 @@ class DetailsLoadUseCase @Inject constructor(
private val imageGetter: Html.ImageGetter,
) {
operator fun invoke(intent: MangaIntent): Flow<MangaDetails> = channelFlow {
val manga = requireNotNull(mangaDataRepository.resolveIntent(intent)) {
"Cannot resolve intent $intent"
operator fun invoke(mangaId: Long): Flow<MangaDetails> = channelFlow {
val manga = requireNotNull(mangaDataRepository.findMangaById(mangaId)) {
"Cannot resolve id $mangaId"
}
send(MangaDetails(manga, null, false))
try {

@ -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,
)
}
}

@ -36,7 +36,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.collapsable
import org.xtimms.tokusho.core.components.ExploreButton
import org.xtimms.tokusho.core.components.SourceItem
import org.xtimms.tokusho.core.components.icons.Dice

@ -18,7 +18,6 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -49,7 +48,7 @@ fun MangaListView(
val viewModel: MangaListViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
MangaListView(
MangaListViewContent(
coil = coil,
source = source,
uiState = uiState,
@ -60,7 +59,7 @@ fun MangaListView(
}
@Composable
private fun MangaListView(
private fun MangaListViewContent(
coil: ImageLoader,
source: MangaSource,
uiState: MangaListUiState,
@ -69,7 +68,6 @@ private fun MangaListView(
navigateToDetails: (Long) -> Unit,
) {
val context = LocalContext.current
val scrollState = rememberScrollState()
if (uiState.message != null) {
LaunchedEffect(uiState.message) {
@ -85,7 +83,7 @@ private fun MangaListView(
.only(WindowInsetsSides.Horizontal)
) { padding ->
val listState = rememberLazyGridState()
listState.onBottomReached(buffer = 3) {
listState.onBottomReached(buffer = 5) {
event?.loadMore()
}
Column(

@ -13,6 +13,10 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
@ -22,6 +26,7 @@ import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material.icons.outlined.Storage
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -32,16 +37,17 @@ import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.PreferenceSubtitle
import org.xtimms.tokusho.core.components.PreferencesHintCard
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
import org.xtimms.tokusho.core.components.SettingItem
import org.xtimms.tokusho.sections.settings.storage.StorageEvent
import org.xtimms.tokusho.sections.settings.storage.StorageUiState
import org.xtimms.tokusho.sections.settings.storage.StorageViewModel
import org.xtimms.tokusho.utils.FileSize
import org.xtimms.tokusho.utils.system.toast
const val SETTINGS_DESTINATION = "settings"
@SuppressLint("BatteryLife")
@Composable
fun SettingsView(
navigateBack: () -> Unit,
@ -51,10 +57,41 @@ fun SettingsView(
navigateToStorage: () -> Unit
) {
val context = LocalContext.current
val viewModel: StorageViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SettingsViewContent(
uiState = uiState,
event = viewModel,
navigateBack = navigateBack,
navigateToAppearance = navigateToAppearance,
navigateToAbout = navigateToAbout,
navigateToAdvanced = navigateToAdvanced,
navigateToStorage = navigateToStorage
)
}
@SuppressLint("BatteryLife")
@Composable
private fun SettingsViewContent(
uiState: StorageUiState,
event: StorageEvent?,
navigateBack: () -> Unit,
navigateToAppearance: () -> Unit,
navigateToAbout: () -> Unit,
navigateToAdvanced: () -> Unit,
navigateToStorage: () -> Unit
) {
val context = LocalContext.current
if (uiState.message != null) {
LaunchedEffect(uiState.message) {
context.toast(uiState.message)
event?.onMessageDisplayed()
}
}
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
var showBatteryHint by remember {
mutableStateOf(!pm.isIgnoringBatteryOptimizations(context.packageName))
@ -85,8 +122,10 @@ fun SettingsView(
navigateBack = navigateBack
) { padding ->
LazyColumn(
modifier = Modifier
.padding(padding)
modifier = Modifier.padding(padding),
contentPadding = PaddingValues(
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
)
) {
item {
AnimatedVisibility(
@ -123,16 +162,16 @@ fun SettingsView(
append(
FileSize.BYTES.freeFormat(
context,
uiState.availableSpace -
(uiState.availableSpace -
uiState.httpCacheSize -
uiState.pagesCache -
uiState.thumbnailsCache
uiState.thumbnailsCache).toFloat()
)
)
}
SettingItem(
title = stringResource(id = R.string.storage),
description = desc,
description = if (uiState.isLoading) context.getString(R.string.calculating_) else desc,
icon = Icons.Outlined.Storage,
onClick = navigateToStorage
)

@ -1,5 +1,9 @@
package org.xtimms.tokusho.sections.settings.about
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
@ -56,8 +60,10 @@ fun AboutView(
navigateBack = navigateBack
) { padding ->
LazyColumn(
modifier = Modifier
.padding(padding)
modifier = Modifier.padding(padding),
contentPadding = PaddingValues(
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
)
) {
item {
PreferenceItem(

@ -4,7 +4,10 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
@ -70,8 +73,10 @@ fun UpdateView(
navigateBack = navigateBack
) { padding ->
LazyColumn(
modifier = Modifier
.padding(padding)
modifier = Modifier.padding(padding),
contentPadding = PaddingValues(
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
)
) {
item {
PreferenceSwitchWithContainer(

@ -1,12 +1,27 @@
package org.xtimms.tokusho.sections.settings.advanced
import android.os.Build
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Print
import androidx.compose.material.icons.outlined.PrintDisabled
import androidx.compose.material.icons.outlined.Report
import androidx.compose.material.icons.outlined.ReportOff
import androidx.compose.material.icons.outlined.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@ -16,10 +31,17 @@ import org.xtimms.tokusho.BuildConfig
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.PreferenceItem
import org.xtimms.tokusho.core.components.PreferenceSubtitle
import org.xtimms.tokusho.core.components.PreferenceSwitch
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
import org.xtimms.tokusho.core.logs.FileLogger
import org.xtimms.tokusho.core.prefs.ACRA
import org.xtimms.tokusho.core.prefs.AppSettings
import org.xtimms.tokusho.core.prefs.LOGGING
import org.xtimms.tokusho.utils.DeviceUtil
import org.xtimms.tokusho.utils.ShareHelper
import org.xtimms.tokusho.utils.WebViewUtil
import org.xtimms.tokusho.utils.lang.toDateTimestampString
import org.xtimms.tokusho.utils.system.toast
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Locale
@ -29,17 +51,65 @@ const val ADVANCED_DESTINATION = "advanced"
@Composable
fun AdvancedView(
loggers: Set<FileLogger>,
navigateBack: () -> Unit,
) {
val context = LocalContext.current
var isAcraEnabled by remember {
mutableStateOf(AppSettings.isACRAEnabled())
}
var isLoggingEnabled by remember {
mutableStateOf(AppSettings.isLoggingEnabled())
}
ScaffoldWithTopAppBar(
title = stringResource(R.string.advanced),
navigateBack = navigateBack
) { padding ->
LazyColumn(
modifier = Modifier
.padding(padding)
modifier = Modifier.padding(padding),
contentPadding = PaddingValues(
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
)
) {
item {
PreferenceSwitch(
title = stringResource(id = R.string.send_crash_reports),
description = stringResource(id = R.string.send_crash_reports_desc),
icon = if (isAcraEnabled) Icons.Outlined.Report else Icons.Outlined.ReportOff,
isChecked = isAcraEnabled,
onClick = {
isAcraEnabled = !isAcraEnabled
AppSettings.updateValue(ACRA, isAcraEnabled)
context.toast(R.string.restart_required)
}
)
}
item {
PreferenceSwitch(
title = stringResource(id = R.string.enable_logging),
description = stringResource(id = R.string.enable_logging_desc),
icon = if (isLoggingEnabled) Icons.Outlined.Print else Icons.Outlined.PrintDisabled,
isChecked = isLoggingEnabled,
onClick = {
isLoggingEnabled = !isLoggingEnabled
AppSettings.updateValue(LOGGING, isLoggingEnabled)
}
)
}
item {
PreferenceItem(
title = stringResource(id = R.string.share_logs),
icon = Icons.Outlined.Share,
enabled = isLoggingEnabled,
onClick = {
ShareHelper(context).shareLogs(loggers)
}
)
}
item {
PreferenceSubtitle(text = stringResource(id = R.string.app_info))
}

@ -26,6 +26,7 @@ import androidx.compose.material.icons.outlined.ColorLens
import androidx.compose.material.icons.outlined.DarkMode
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.LightMode
import androidx.compose.material.icons.outlined.Timelapse
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@ -35,6 +36,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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
@ -53,12 +55,14 @@ import org.xtimms.tokusho.LocalPaletteStyleIndex
import org.xtimms.tokusho.LocalSeedColor
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.PreferenceItem
import org.xtimms.tokusho.core.components.PreferenceSubtitle
import org.xtimms.tokusho.core.components.PreferenceSwitch
import org.xtimms.tokusho.core.components.PreferenceSwitchWithDivider
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
import org.xtimms.tokusho.core.prefs.AppSettings
import org.xtimms.tokusho.core.prefs.DarkThemePreference.Companion.OFF
import org.xtimms.tokusho.core.prefs.DarkThemePreference.Companion.ON
import org.xtimms.tokusho.core.prefs.READING_TIME
import org.xtimms.tokusho.core.prefs.STYLE_MONOCHROME
import org.xtimms.tokusho.core.prefs.STYLE_TONAL_SPOT
import org.xtimms.tokusho.core.prefs.paletteStyles
@ -92,6 +96,10 @@ fun AppearanceView(
)
}
var isReadingTimeEstimationEnabled by remember {
mutableStateOf(AppSettings.isReadingTimeEstimationEnabled())
}
ScaffoldWithTopAppBar(
title = stringResource(R.string.appearance),
navigateBack = navigateBack
@ -99,7 +107,7 @@ fun AppearanceView(
Column(
Modifier
.padding(padding)
.verticalScroll(rememberScrollState())
.verticalScroll(rememberScrollState()),
) {
MangaCard(
modifier = Modifier.padding(18.dp),
@ -180,6 +188,16 @@ fun AppearanceView(
icon = Icons.Outlined.Language,
description = getLanguageDesc(),
onClick = { navigateToLanguages() })
PreferenceSubtitle(text = stringResource(id = R.string.details))
PreferenceSwitch(
title = stringResource(id = R.string.show_estimated_read_time),
description = stringResource(id = R.string.show_estimated_read_time_desc),
icon = Icons.Outlined.Timelapse,
isChecked = isReadingTimeEstimationEnabled,
onClick = {
isReadingTimeEstimationEnabled = !isReadingTimeEstimationEnabled
AppSettings.updateValue(READING_TIME, isReadingTimeEstimationEnabled)
})
}
}
}

@ -1,6 +1,10 @@
package org.xtimms.tokusho.sections.settings.appearance
import android.os.Build
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
@ -34,7 +38,11 @@ fun DarkThemeView(
navigateBack = navigateBack
) { padding ->
LazyColumn(
modifier = Modifier.padding(padding)) {
modifier = Modifier.padding(padding),
contentPadding = PaddingValues(
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
)
) {
if (Build.VERSION.SDK_INT >= 29)
item {
PreferenceSingleChoiceItem(

@ -8,7 +8,10 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
@ -108,8 +111,10 @@ private fun LanguageViewImpl(
navigateBack = navigateBack
) { padding ->
LazyColumn(
modifier = Modifier
.padding(padding)
modifier = Modifier.padding(padding),
contentPadding = PaddingValues(
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
)
) {
item {
PreferencesHintCard(

@ -96,7 +96,12 @@ fun CleanDialog(
HorizontalDivider(modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp))
Spacer(modifier = Modifier.height(4.dp))
val summary = StringBuilder().run {
append(FileSize.BYTES.format(LocalContext.current, uiState.pagesCache + uiState.thumbnailsCache + uiState.httpCacheSize))
append(
FileSize.BYTES.format(
LocalContext.current,
(uiState.pagesCache + uiState.thumbnailsCache + uiState.httpCacheSize).toFloat()
)
)
append("")
}
Text(
@ -104,7 +109,6 @@ fun CleanDialog(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
// style = MaterialTheme.typography.labelMedium,
)
}
})

@ -0,0 +1,5 @@
package org.xtimms.tokusho.sections.settings.storage
import org.xtimms.tokusho.core.base.event.UiEvent
interface StorageEvent : UiEvent

@ -1,7 +1,9 @@
package org.xtimms.tokusho.sections.settings.storage
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
@ -10,13 +12,11 @@ import androidx.compose.material.icons.outlined.CleaningServices
import androidx.compose.material.icons.outlined.Image
import androidx.compose.material.icons.outlined.NetworkWifi
import androidx.compose.material.icons.outlined.SdStorage
import androidx.compose.material3.CircularProgressIndicator
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.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
@ -27,6 +27,7 @@ import org.xtimms.tokusho.core.components.PreferenceStorageHeader
import org.xtimms.tokusho.core.components.PreferenceStorageItem
import org.xtimms.tokusho.core.components.PreferencesHintCard
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
import org.xtimms.tokusho.data.CACHE_SIZE_MAX
const val STORAGE_DESTINATION = "storage"
@ -44,14 +45,16 @@ fun StorageView(
title = stringResource(R.string.storage),
navigateBack = navigateBack
) { padding ->
if (!uiState.isLoading) LazyColumn(
modifier = Modifier
.padding(padding)
LazyColumn(
modifier = Modifier.padding(padding),
contentPadding = PaddingValues(
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
)
) {
item {
PreferenceStorageHeader(
used = uiState.httpCacheSize + uiState.thumbnailsCache + uiState.pagesCache,
total = uiState.availableSpace
used = (uiState.httpCacheSize + uiState.thumbnailsCache + uiState.pagesCache).toFloat(),
total = uiState.availableSpace.toFloat()
)
}
item {
@ -65,37 +68,35 @@ fun StorageView(
}
item {
PreferenceStorageItem(
total = uiState.availableSpace,
total = uiState.availableSpace.toFloat(),
title = stringResource(id = R.string.saved_manga),
icon = Icons.Outlined.SdStorage
)
}
item {
PreferenceStorageItem(
total = uiState.availableSpace,
total = uiState.availableSpace.toFloat(),
title = stringResource(id = R.string.pages_cache),
icon = Icons.Outlined.AutoStories,
used = uiState.pagesCache
used = uiState.pagesCache.toFloat()
)
}
item {
PreferenceStorageItem(
total = uiState.availableSpace,
total = uiState.availableSpace.toFloat(),
title = stringResource(id = R.string.thumbnails_cache),
icon = Icons.Outlined.Image,
used = uiState.thumbnailsCache
used = uiState.thumbnailsCache.toFloat()
)
}
item {
PreferenceStorageItem(
total = uiState.availableSpace,
total = CACHE_SIZE_MAX.toFloat(),
title = stringResource(id = R.string.network_cache),
icon = Icons.Outlined.NetworkWifi,
used = uiState.httpCacheSize
used = uiState.httpCacheSize.toFloat()
)
}
} else Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
if (showCleanDialog) {

@ -3,7 +3,6 @@ package org.xtimms.tokusho.sections.settings.storage
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runInterruptible
@ -11,32 +10,29 @@ import okhttp3.Cache
import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel
import org.xtimms.tokusho.core.cache.CacheDir
import org.xtimms.tokusho.data.LocalStorageManager
import java.util.EnumMap
import javax.inject.Inject
@HiltViewModel
class StorageViewModel @Inject constructor(
private val storageManager: LocalStorageManager,
private val httpCache: Cache,
) : BaseViewModel<StorageUiState>() {
val httpCacheSize = MutableStateFlow(-1L)
val cacheSizes = EnumMap<CacheDir, MutableStateFlow<Long>>(CacheDir::class.java)
) : BaseViewModel<StorageUiState>(), StorageEvent {
private var storageUsageJob: Job? = null
init {
val prevJob = storageUsageJob
storageUsageJob = launchJob(Dispatchers.Default) {
prevJob?.cancelAndJoin()
setLoading(true)
mutableUiState.update {
it.copy(
availableSpace = storageManager.computeAvailableSize(),
pagesCache = storageManager.computeCacheSize(CacheDir.PAGES),
thumbnailsCache = storageManager.computeCacheSize(CacheDir.THUMBS),
httpCacheSize = runInterruptible { httpCache.size() }
httpCacheSize = runInterruptible { httpCache.size() },
isLoading = false
)
}
setLoading(false)
}
}
@ -51,7 +47,7 @@ class StorageViewModel @Inject constructor(
pagesCache = storageManager.computeCacheSize(CacheDir.PAGES),
thumbnailsCache = storageManager.computeCacheSize(CacheDir.THUMBS),
httpCacheSize = runInterruptible { httpCache.size() },
isLoading = false
isLoading = false,
)
}
} catch (_: Exception) {

@ -12,7 +12,7 @@ enum class FileSize(private val multiplier: Int) {
fun convert(amount: Long, target: FileSize): Long = amount * multiplier / target.multiplier
fun freeFormat(context: Context, amount: Long): String {
fun freeFormat(context: Context, amount: Float): String {
val bytes = amount * multiplier
val units = context.getString(R.string.text_file_sizes_free).split('|')
if (bytes <= 0) {
@ -33,7 +33,7 @@ enum class FileSize(private val multiplier: Int) {
}
}
fun totalFormat(context: Context, amount: Long): String {
fun totalFormat(context: Context, amount: Float): String {
val bytes = amount * multiplier
val units = context.getString(R.string.text_file_sizes_total).split('|')
if (bytes <= 0) {
@ -54,7 +54,7 @@ enum class FileSize(private val multiplier: Int) {
}
}
fun showUnit(context: Context, amount: Long): String {
fun showUnit(context: Context, amount: Float): String {
val bytes = amount * multiplier
val units = context.getString(R.string.text_file_sizes_used).split('|')
if (bytes <= 0) {
@ -70,7 +70,7 @@ enum class FileSize(private val multiplier: Int) {
}
}
fun format(context: Context, amount: Long): String {
fun format(context: Context, amount: Float): String {
val bytes = amount * multiplier
val units = context.getString(R.string.text_file_sizes).split('|')
if (bytes <= 0) {
@ -91,7 +91,7 @@ enum class FileSize(private val multiplier: Int) {
}
}
fun formatWithoutUnits(amount: Long): String {
fun formatWithoutUnits(amount: Float): String {
val bytes = amount * multiplier
if (bytes <= 0) {
return "0"

@ -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()
}
}
}

@ -10,6 +10,10 @@ import java.io.File
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.walk
fun File.subdir(name: String) = File(this, name).also {
if (!it.exists()) it.mkdirs()
}
fun File.getUriCompat(context: Context): Uri {
return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", this)
}

@ -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>

@ -99,4 +99,17 @@
<string name="disable_battery_optimization_summary">Helps with background updates checks</string>
<string name="recommended_action">Recommended action</string>
<string name="space_used">% used</string>
<string name="calculating_">Calculating...</string>
<string name="send_crash_reports">Send crash reports</string>
<string name="send_crash_reports_desc">Helps fix any bugs. No sensitive data will be sent</string>
<string name="restart_required">To apply the setting, you need to restart the application</string>
<string name="enable_logging">Enable logging</string>
<string name="enable_logging_desc">Record some actions for debug purposes. Don\'t turn it on if you\'re not sure what you\'re doing</string>
<string name="share_logs">Share logs</string>
<string name="remaining_time_pattern">%1$s %2$s</string>
<string name="details">Details</string>
<string name="show_estimated_read_time">Show estimated reading time</string>
<string name="show_estimated_read_time_desc">The time estimation value may be inaccurate</string>
<string name="approximate_remaining_time">Approximate remaining time</string>
<string name="approximate_reading_time">Approximate reading time</string>
</resources>
Loading…
Cancel
Save