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.io.ByteArrayOutputStream
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@ -13,6 +14,9 @@ plugins {
id("dagger.hilt.android.plugin") id("dagger.hilt.android.plugin")
} }
val acraAuthLogin: String = gradleLocalProperties(rootDir).getProperty("authLogin") ?: "\"acra_login\""
val acraAuthPassword: String = gradleLocalProperties(rootDir).getProperty("authPassword") ?: "\"acra_password\""
android { android {
namespace = "org.xtimms.tokusho" namespace = "org.xtimms.tokusho"
compileSdk = 34 compileSdk = 34
@ -28,6 +32,10 @@ android {
buildConfigField("String", "COMMIT_SHA", "\"${getGitSha()}\"") buildConfigField("String", "COMMIT_SHA", "\"${getGitSha()}\"")
buildConfigField("String", "BUILD_TIME", "\"${getBuildTime()}\"") 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" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
@ -43,6 +51,9 @@ android {
} }
buildTypes { buildTypes {
debug {
applicationIdSuffix = ".debug"
}
release { release {
isMinifyEnabled = true isMinifyEnabled = true
proguardFiles( proguardFiles(
@ -93,6 +104,7 @@ dependencies {
implementation("androidx.room:room-ktx:2.6.1") implementation("androidx.room:room-ktx:2.6.1")
implementation("androidx.work:work-runtime-ktx:2.9.0") implementation("androidx.work:work-runtime-ktx:2.9.0")
ksp("androidx.room:room-compiler:2.6.1") 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.android.material:material:1.11.0")
implementation("com.google.accompanist:accompanist-flowlayout:0.32.0") implementation("com.google.accompanist:accompanist-flowlayout:0.32.0")
implementation("com.google.accompanist:accompanist-systemuicontroller: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.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <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.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> <queries>
<intent> <intent>

@ -11,11 +11,15 @@ import com.tencent.mmkv.MMKV
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch 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.koitharu.kotatsu.parsers.MangaLoaderContext
import org.xtimms.tokusho.core.database.MangaDatabase 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.core.updates.Updater
import org.xtimms.tokusho.crash.CrashActivity
import org.xtimms.tokusho.crash.GlobalExceptionHandler
import org.xtimms.tokusho.utils.lang.processLifecycleScope import org.xtimms.tokusho.utils.lang.processLifecycleScope
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider 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?) { 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.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import androidx.core.view.WindowCompat
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import coil.ImageLoader import coil.ImageLoader
@ -48,6 +49,7 @@ import kotlinx.coroutines.launch
import org.xtimms.tokusho.core.Navigation import org.xtimms.tokusho.core.Navigation
import org.xtimms.tokusho.core.components.BottomNavBar import org.xtimms.tokusho.core.components.BottomNavBar
import org.xtimms.tokusho.core.components.TopAppBar 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.ui.theme.TokushoTheme
import org.xtimms.tokusho.utils.lang.processLifecycleScope import org.xtimms.tokusho.utils.lang.processLifecycleScope
import javax.inject.Inject import javax.inject.Inject
@ -59,9 +61,18 @@ class MainActivity : ComponentActivity() {
@Inject @Inject
lateinit var coil: ImageLoader lateinit var coil: ImageLoader
@Inject
lateinit var loggers: Set<@JvmSuppressWildcards FileLogger>
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (!isTaskRoot) {
finish()
return
}
setContent { setContent {
val navController = rememberNavController() val navController = rememberNavController()
val windowSizeClass = calculateWindowSizeClass(this) val windowSizeClass = calculateWindowSizeClass(this)
@ -74,6 +85,7 @@ class MainActivity : ComponentActivity() {
) { ) {
MainView( MainView(
coil = coil, coil = coil,
loggers = loggers,
isCompactScreen = isCompactScreen, isCompactScreen = isCompactScreen,
navController = navController navController = navController
) )
@ -112,6 +124,7 @@ class MainActivity : ComponentActivity() {
@Composable @Composable
fun MainView( fun MainView(
coil: ImageLoader, coil: ImageLoader,
loggers: Set<FileLogger>,
isCompactScreen: Boolean, isCompactScreen: Boolean,
navController: NavHostController, navController: NavHostController,
) { ) {
@ -161,6 +174,7 @@ fun MainView(
) { ) {
Navigation( Navigation(
coil = coil, coil = coil,
loggers = loggers,
navController = navController, navController = navController,
isCompactScreen = false, isCompactScreen = false,
modifier = Modifier, modifier = Modifier,
@ -180,6 +194,7 @@ fun MainView(
} }
Navigation( Navigation(
coil = coil, coil = coil,
loggers = loggers,
navController = navController, navController = navController,
isCompactScreen = true, isCompactScreen = true,
modifier = Modifier.padding( modifier = Modifier.padding(

@ -18,6 +18,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.navArgument import androidx.navigation.navArgument
import coil.ImageLoader import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.MangaSource 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.model.ShelfCategory
import org.xtimms.tokusho.core.motion.materialSharedAxisXIn import org.xtimms.tokusho.core.motion.materialSharedAxisXIn
import org.xtimms.tokusho.core.motion.materialSharedAxisXOut import org.xtimms.tokusho.core.motion.materialSharedAxisXOut
@ -62,6 +63,7 @@ fun PathInterpolator.toEasing(): Easing {
@Composable @Composable
fun Navigation( fun Navigation(
coil: ImageLoader, coil: ImageLoader,
loggers: Set<FileLogger>,
navController: NavHostController, navController: NavHostController,
isCompactScreen: Boolean, isCompactScreen: Boolean,
modifier: Modifier, modifier: Modifier,
@ -180,6 +182,7 @@ fun Navigation(
composable(ADVANCED_DESTINATION) { composable(ADVANCED_DESTINATION) {
AdvancedView( AdvancedView(
loggers = loggers,
navigateBack = navigateBack, navigateBack = navigateBack,
) )
} }
@ -220,13 +223,16 @@ fun Navigation(
) )
} }
// TODO
composable( composable(
route = DETAILS_DESTINATION route = DETAILS_DESTINATION,
arguments = listOf(
navArgument(MANGA_ID_ARGUMENT.removeFirstAndLast()) {
type = NavType.LongType
}
)
) { navEntry -> ) { navEntry ->
DetailsView( DetailsView(
coil = coil, coil = coil,
mangaId = 0L,
navigateBack = navigateBack, 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 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.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.Icon
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
@ -186,14 +195,28 @@ internal fun PreferenceItemDescription(
color: Color = MaterialTheme.colorScheme.onSurfaceVariant, color: Color = MaterialTheme.colorScheme.onSurfaceVariant,
overflow: TextOverflow = TextOverflow.Ellipsis 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), modifier = modifier.padding(top = 2.dp),
text = text, label = "Preference desc"
maxLines = maxLines, ) { targetText ->
style = style, Text(
color = color.applyOpacity(enabled), text = targetText,
overflow = overflow maxLines = maxLines,
) style = style,
color = color.applyOpacity(enabled),
overflow = overflow
)
}
} }
@Composable @Composable
@ -552,9 +575,16 @@ fun PreferencesHintCard(
@Composable @Composable
fun PreferenceStorageHeader( fun PreferenceStorageHeader(
used: Long = 4L, used: Float = 40F,
total: Long = 128L total: Float = 128F
) { ) {
val animatedProgress = animateFloatAsState(
targetValue = 1 - ((total - used) / total),
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
label = "Progress"
).value
Column { Column {
Row( Row(
modifier = Modifier modifier = Modifier
@ -562,27 +592,47 @@ fun PreferenceStorageHeader(
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( AnimatedNumber(
text = FileSize.BYTES.formatWithoutUnits(used), value = FileSize.BYTES.formatWithoutUnits(used)
modifier = Modifier.padding(end = 4.dp),
style = MaterialTheme.typography.displayLarge
) )
Text( AnimatedContent(
text = FileSize.BYTES.showUnit(LocalContext.current, used), targetState = used,
transitionSpec = {
if (targetState > initialState) {
(fadeIn()).togetherWith(fadeOut())
} else {
(fadeIn()).togetherWith(fadeOut())
}.using(SizeTransform(clip = false))
},
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.align(Alignment.Bottom) .align(Alignment.Bottom)
.padding(PaddingValues(bottom = 8.dp)) .padding(PaddingValues(start = 4.dp, bottom = 8.dp)),
) label = "Unit"
Text( ) { targetUsed ->
text = FileSize.BYTES.totalFormat(LocalContext.current, total), 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 modifier = Modifier
.align(Alignment.Bottom) .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( LinearProgressIndicator(
progress = { (1 - ((total - used) / total.toFloat())) }, progress = { animatedProgress },
modifier = Modifier modifier = Modifier
.padding(PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp)) .padding(PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp))
.height(16.dp) .height(16.dp)
@ -598,13 +648,20 @@ fun PreferenceStorageHeader(
@Composable @Composable
fun PreferenceStorageItem( fun PreferenceStorageItem(
title: String, title: String,
used: Long? = 0L, used: Float = 0F,
total: Long?, total: Float = 0F,
icon: Any? = null, icon: Any? = null,
leadingIcon: (@Composable () -> Unit)? = null, leadingIcon: (@Composable () -> Unit)? = null,
trailingIcon: (@Composable () -> Unit)? = null, trailingIcon: (@Composable () -> Unit)? = null,
onClick: () -> Unit = {}, onClick: () -> Unit = {},
) { ) {
val animatedProgress = animateFloatAsState(
targetValue = 1 - ((total - used) / total),
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
label = "Progress"
).value
Surface( Surface(
modifier = Modifier.combinedClickable( modifier = Modifier.combinedClickable(
onClick = onClick, onClick = onClick,
@ -658,20 +715,30 @@ fun PreferenceStorageItem(
text = title, text = title,
enabled = true enabled = true
) )
Text(text = FileSize.BYTES.format(LocalContext.current, used ?: 0L)) AnimatedContent(
} targetState = used,
if (total != null) { transitionSpec = {
LinearProgressIndicator( if (targetState > initialState) {
progress = { (1 - ((total - used!!) / total.toFloat())) }, (fadeIn()).togetherWith(fadeOut())
modifier = Modifier } else {
.padding(PaddingValues(top = 12.dp)) (fadeIn()).togetherWith(fadeOut())
.height(5.dp) }.using(SizeTransform(clip = false))
.fillMaxWidth(), },
color = MaterialTheme.colorScheme.primary, label = "Total used"
trackColor = MaterialTheme.colorScheme.primaryContainer, ) { targetTotal ->
strokeCap = StrokeCap.Round, Text(text = FileSize.BYTES.format(LocalContext.current, targetTotal))
) }
} }
LinearProgressIndicator(
progress = { animatedProgress },
modifier = Modifier
.padding(PaddingValues(top = 12.dp))
.height(5.dp)
.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.primaryContainer,
strokeCap = StrokeCap.Round,
)
} }
trailingIcon?.let { trailingIcon?.let {
VerticalDivider( VerticalDivider(
@ -700,7 +767,7 @@ fun PreferenceStorageHeaderPreview() {
@Preview(showBackground = true) @Preview(showBackground = true)
fun PreferenceStorageItemPreview() { fun PreferenceStorageItemPreview() {
TokushoTheme { 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.PaddingValues
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.systemBars
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -29,7 +31,8 @@ fun ScaffoldWithTopAppBar(
Scaffold( Scaffold(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection), .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection)
.consumeWindowInsets(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)),
topBar = { topBar = {
DefaultTopAppBar( DefaultTopAppBar(
title = title, title = title,
@ -38,8 +41,7 @@ fun ScaffoldWithTopAppBar(
) )
}, },
floatingActionButton = floatingActionButton, floatingActionButton = floatingActionButton,
contentWindowInsets = WindowInsets.systemBars contentWindowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal),
.only(WindowInsetsSides.Horizontal),
content = content content = content
) )
} }

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

@ -4,3 +4,4 @@ const val TABLE_MANGA = "manga"
const val TABLE_TAGS = "tags" const val TABLE_TAGS = "tags"
const val TABLE_MANGA_TAGS = "manga_tags" const val TABLE_MANGA_TAGS = "manga_tags"
const val TABLE_SOURCES = "sources" 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 android.content.Context
import androidx.room.Database import androidx.room.Database
import androidx.room.InvalidationTracker
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase 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.MangaDao
import org.xtimms.tokusho.core.database.dao.MangaSourcesDao 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.MangaEntity
import org.xtimms.tokusho.core.database.entity.MangaSourceEntity import org.xtimms.tokusho.core.database.entity.MangaSourceEntity
import org.xtimms.tokusho.core.database.entity.MangaTagsEntity import org.xtimms.tokusho.core.database.entity.MangaTagsEntity
import org.xtimms.tokusho.core.database.entity.TagEntity import org.xtimms.tokusho.core.database.entity.TagEntity
import org.xtimms.tokusho.utils.lang.processLifecycleScope
const val DATABASE_VERSION = 1 const val DATABASE_VERSION = 1
@ -18,12 +27,15 @@ const val DATABASE_VERSION = 1
MangaEntity::class, MangaEntity::class,
TagEntity::class, TagEntity::class,
MangaTagsEntity::class, MangaTagsEntity::class,
MangaSourceEntity::class MangaSourceEntity::class,
HistoryEntity::class
], ],
version = DATABASE_VERSION version = DATABASE_VERSION
) )
abstract class MangaDatabase : RoomDatabase() { abstract class MangaDatabase : RoomDatabase() {
abstract fun getHistoryDao(): HistoryDao
abstract fun getMangaDao(): MangaDao abstract fun getMangaDao(): MangaDao
abstract fun getSourcesDao(): MangaSourcesDao abstract fun getSourcesDao(): MangaSourcesDao
@ -33,3 +45,13 @@ abstract class MangaDatabase : RoomDatabase() {
fun MangaDatabase(context: Context): MangaDatabase = Room fun MangaDatabase(context: Context): MangaDatabase = Room
.databaseBuilder(context, MangaDatabase::class.java, "tokusho-db") .databaseBuilder(context, MangaDatabase::class.java, "tokusho-db")
.build() .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.model.SortOrder
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase 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.core.model.MangaSource
import org.xtimms.tokusho.utils.lang.longHashCode import org.xtimms.tokusho.utils.lang.longHashCode
import java.time.Instant
// Entity to model // Entity to model
@ -74,3 +76,12 @@ fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
fun MangaState(name: String): MangaState? = runCatching { fun MangaState(name: String): MangaState? = runCatching {
MangaState.valueOf(name) MangaState.valueOf(name)
}.getOrNull() }.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 package org.xtimms.tokusho.core.model
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
fun Collection<Manga>.distinctById() = distinctBy { it.id } fun Collection<Manga>.distinctById() = distinctBy { it.id }
fun Collection<MangaChapter>.findById(id: Long) = find { x -> x.id == id }

@ -0,0 +1,15 @@
package org.xtimms.tokusho.core.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import java.time.Instant
@Parcelize
data class MangaHistory(
val createdAt: Instant,
val updatedAt: Instant,
val chapterId: Long,
val page: Int,
val scroll: Int,
val percent: Float,
) : Parcelable

@ -3,9 +3,9 @@ package org.xtimms.tokusho.core.motion
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
public object MotionConstants { object MotionConstants {
public const val DefaultMotionDuration: Int = 300 const val DefaultMotionDuration: Int = 300
public const val DefaultFadeInDuration: Int = 150 const val DefaultFadeInDuration: Int = 150
public const val DefaultFadeOutDuration: Int = 75 const val DefaultFadeOutDuration: Int = 75
public val DefaultSlideDistance: Dp = 30.dp 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.AndroidCookieJar
import org.xtimms.tokusho.core.network.cookies.MutableCookieJar import org.xtimms.tokusho.core.network.cookies.MutableCookieJar
import org.xtimms.tokusho.core.network.cookies.PreferencesCookieJar 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 org.xtimms.tokusho.data.LocalStorageManager
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Singleton import javax.inject.Singleton
@ -55,6 +59,8 @@ interface NetworkModule {
writeTimeout(20, TimeUnit.SECONDS) writeTimeout(20, TimeUnit.SECONDS)
cookieJar(cookieJar) cookieJar(cookieJar)
cache(cache) cache(cache)
addInterceptor(GZipInterceptor())
addInterceptor(RateLimitInterceptor())
}.build() }.build()
@Provides @Provides
@ -62,7 +68,11 @@ interface NetworkModule {
@MangaHttpClient @MangaHttpClient
fun provideMangaHttpClient( fun provideMangaHttpClient(
@BaseHttpClient baseClient: OkHttpClient, @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.MainCoroutineDispatcher
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import okhttp3.Headers
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import org.koitharu.kotatsu.parsers.InternalParsersApi import org.koitharu.kotatsu.parsers.InternalParsersApi
@ -60,6 +61,9 @@ class RemoteMangaRepository(
val domains: Array<out String> val domains: Array<out String>
get() = parser.configKeyDomain.presetValues get() = parser.configKeyDomain.presetValues
val headers: Headers
get() = parser.headers
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
return if (parser is Interceptor) { return if (parser is Interceptor) {
parser.intercept(chain) parser.intercept(chain)

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

@ -19,7 +19,7 @@ private const val DIR_NAME = "manga"
private const val NOMEDIA = ".nomedia" private const val NOMEDIA = ".nomedia"
private const val CACHE_DISK_PERCENTAGE = 0.02 private const val CACHE_DISK_PERCENTAGE = 0.02
private const val CACHE_SIZE_MIN: Long = 10 * 1024 * 1024 // 10MB 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 @Reusable
class LocalStorageManager @Inject constructor( 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 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.core.base.state.UiState
import org.xtimms.tokusho.sections.details.data.MangaDetails
data class DetailsUiState( data class DetailsUiState(
val manga: Manga? = null, val details: MangaDetails? = null,
override val isLoading: Boolean = false, override val isLoading: Boolean = false,
override val message: String? = null, override val message: String? = null,
) : UiState() { ) : UiState() {

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

@ -4,9 +4,11 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted 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.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@ -14,42 +16,80 @@ import kotlinx.coroutines.plus
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel
import org.xtimms.tokusho.core.parser.MangaIntent 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.data.MangaDetails
import org.xtimms.tokusho.sections.details.domain.DetailsLoadUseCase import org.xtimms.tokusho.sections.details.domain.DetailsLoadUseCase
import org.xtimms.tokusho.sections.details.domain.ReadingTimeUseCase
import org.xtimms.tokusho.utils.lang.onEachWhile import org.xtimms.tokusho.utils.lang.onEachWhile
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class DetailsViewModel @Inject constructor( class DetailsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val historyRepository: HistoryRepository,
private val detailsLoadUseCase: DetailsLoadUseCase, private val detailsLoadUseCase: DetailsLoadUseCase,
private val readingTimeUseCase: ReadingTimeUseCase,
) : BaseViewModel<DetailsUiState>(), DetailsEvent { ) : BaseViewModel<DetailsUiState>(), DetailsEvent {
override val mutableUiState = MutableStateFlow(DetailsUiState())
private val intent = MangaIntent(savedStateHandle) 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 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) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
override val mutableUiState = MutableStateFlow(DetailsUiState()) val remoteManga = MutableStateFlow<Manga?>(null)
fun getDetails(mangaId: Long) { private val chaptersQuery = MutableStateFlow("")
launchLoadingJob(Dispatchers.Default) { val selectedBranch = MutableStateFlow<String?>(null)
detailsLoadUseCase.invoke(intent)
.onEachWhile { @Deprecated("")
if (it.allChapters.isEmpty()) { val description = details
return@onEachWhile false .map { it?.description }
} .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, null)
true
}.collect { val isChaptersEmpty: StateFlow<Boolean> = details.map {
mutableUiState.update { it != null && it.isLoaded && it.allChapters.isEmpty()
val manga = details.firstOrNull { it != null } ?: return@collect }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
it.copy(
manga = manga.toManga() 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 {
it.copy(
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.getSpans
import androidx.core.text.parseAsHtml import androidx.core.text.parseAsHtml
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.recoverNotNull
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xtimms.tokusho.core.parser.MangaDataRepository 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.core.parser.MangaRepository
import org.xtimms.tokusho.sections.details.data.MangaDetails import org.xtimms.tokusho.sections.details.data.MangaDetails
import org.xtimms.tokusho.utils.lang.sanitize import org.xtimms.tokusho.utils.lang.sanitize
@ -29,9 +25,9 @@ class DetailsLoadUseCase @Inject constructor(
private val imageGetter: Html.ImageGetter, private val imageGetter: Html.ImageGetter,
) { ) {
operator fun invoke(intent: MangaIntent): Flow<MangaDetails> = channelFlow { operator fun invoke(mangaId: Long): Flow<MangaDetails> = channelFlow {
val manga = requireNotNull(mangaDataRepository.resolveIntent(intent)) { val manga = requireNotNull(mangaDataRepository.findMangaById(mangaId)) {
"Cannot resolve intent $intent" "Cannot resolve id $mangaId"
} }
send(MangaDetails(manga, null, false)) send(MangaDetails(manga, null, false))
try { 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 coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.tokusho.R import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.collapsable
import org.xtimms.tokusho.core.components.ExploreButton import org.xtimms.tokusho.core.components.ExploreButton
import org.xtimms.tokusho.core.components.SourceItem import org.xtimms.tokusho.core.components.SourceItem
import org.xtimms.tokusho.core.components.icons.Dice 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.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -49,7 +48,7 @@ fun MangaListView(
val viewModel: MangaListViewModel = hiltViewModel() val viewModel: MangaListViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
MangaListView( MangaListViewContent(
coil = coil, coil = coil,
source = source, source = source,
uiState = uiState, uiState = uiState,
@ -60,7 +59,7 @@ fun MangaListView(
} }
@Composable @Composable
private fun MangaListView( private fun MangaListViewContent(
coil: ImageLoader, coil: ImageLoader,
source: MangaSource, source: MangaSource,
uiState: MangaListUiState, uiState: MangaListUiState,
@ -69,7 +68,6 @@ private fun MangaListView(
navigateToDetails: (Long) -> Unit, navigateToDetails: (Long) -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scrollState = rememberScrollState()
if (uiState.message != null) { if (uiState.message != null) {
LaunchedEffect(uiState.message) { LaunchedEffect(uiState.message) {
@ -85,7 +83,7 @@ private fun MangaListView(
.only(WindowInsetsSides.Horizontal) .only(WindowInsetsSides.Horizontal)
) { padding -> ) { padding ->
val listState = rememberLazyGridState() val listState = rememberLazyGridState()
listState.onBottomReached(buffer = 3) { listState.onBottomReached(buffer = 5) {
event?.loadMore() event?.loadMore()
} }
Column( Column(

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

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

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

@ -1,12 +1,27 @@
package org.xtimms.tokusho.sections.settings.advanced package org.xtimms.tokusho.sections.settings.advanced
import android.os.Build 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.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn 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.Composable
import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -16,10 +31,17 @@ import org.xtimms.tokusho.BuildConfig
import org.xtimms.tokusho.R import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.PreferenceItem import org.xtimms.tokusho.core.components.PreferenceItem
import org.xtimms.tokusho.core.components.PreferenceSubtitle 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.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.DeviceUtil
import org.xtimms.tokusho.utils.ShareHelper
import org.xtimms.tokusho.utils.WebViewUtil import org.xtimms.tokusho.utils.WebViewUtil
import org.xtimms.tokusho.utils.lang.toDateTimestampString import org.xtimms.tokusho.utils.lang.toDateTimestampString
import org.xtimms.tokusho.utils.system.toast
import java.text.DateFormat import java.text.DateFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
@ -29,17 +51,65 @@ const val ADVANCED_DESTINATION = "advanced"
@Composable @Composable
fun AdvancedView( fun AdvancedView(
loggers: Set<FileLogger>,
navigateBack: () -> Unit, navigateBack: () -> Unit,
) { ) {
val context = LocalContext.current
var isAcraEnabled by remember {
mutableStateOf(AppSettings.isACRAEnabled())
}
var isLoggingEnabled by remember {
mutableStateOf(AppSettings.isLoggingEnabled())
}
ScaffoldWithTopAppBar( ScaffoldWithTopAppBar(
title = stringResource(R.string.advanced), title = stringResource(R.string.advanced),
navigateBack = navigateBack navigateBack = navigateBack
) { padding -> ) { padding ->
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier.padding(padding),
.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 { item {
PreferenceSubtitle(text = stringResource(id = R.string.app_info)) 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.DarkMode
import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.LightMode import androidx.compose.material.icons.outlined.LightMode
import androidx.compose.material.icons.outlined.Timelapse
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@ -35,6 +36,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -53,12 +55,14 @@ import org.xtimms.tokusho.LocalPaletteStyleIndex
import org.xtimms.tokusho.LocalSeedColor import org.xtimms.tokusho.LocalSeedColor
import org.xtimms.tokusho.R import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.PreferenceItem 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.PreferenceSwitch
import org.xtimms.tokusho.core.components.PreferenceSwitchWithDivider import org.xtimms.tokusho.core.components.PreferenceSwitchWithDivider
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
import org.xtimms.tokusho.core.prefs.AppSettings import org.xtimms.tokusho.core.prefs.AppSettings
import org.xtimms.tokusho.core.prefs.DarkThemePreference.Companion.OFF import org.xtimms.tokusho.core.prefs.DarkThemePreference.Companion.OFF
import org.xtimms.tokusho.core.prefs.DarkThemePreference.Companion.ON 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_MONOCHROME
import org.xtimms.tokusho.core.prefs.STYLE_TONAL_SPOT import org.xtimms.tokusho.core.prefs.STYLE_TONAL_SPOT
import org.xtimms.tokusho.core.prefs.paletteStyles import org.xtimms.tokusho.core.prefs.paletteStyles
@ -92,6 +96,10 @@ fun AppearanceView(
) )
} }
var isReadingTimeEstimationEnabled by remember {
mutableStateOf(AppSettings.isReadingTimeEstimationEnabled())
}
ScaffoldWithTopAppBar( ScaffoldWithTopAppBar(
title = stringResource(R.string.appearance), title = stringResource(R.string.appearance),
navigateBack = navigateBack navigateBack = navigateBack
@ -99,7 +107,7 @@ fun AppearanceView(
Column( Column(
Modifier Modifier
.padding(padding) .padding(padding)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState()),
) { ) {
MangaCard( MangaCard(
modifier = Modifier.padding(18.dp), modifier = Modifier.padding(18.dp),
@ -180,6 +188,16 @@ fun AppearanceView(
icon = Icons.Outlined.Language, icon = Icons.Outlined.Language,
description = getLanguageDesc(), description = getLanguageDesc(),
onClick = { navigateToLanguages() }) 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 package org.xtimms.tokusho.sections.settings.appearance
import android.os.Build 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.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -34,7 +38,11 @@ fun DarkThemeView(
navigateBack = navigateBack navigateBack = navigateBack
) { padding -> ) { padding ->
LazyColumn( LazyColumn(
modifier = Modifier.padding(padding)) { modifier = Modifier.padding(padding),
contentPadding = PaddingValues(
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
)
) {
if (Build.VERSION.SDK_INT >= 29) if (Build.VERSION.SDK_INT >= 29)
item { item {
PreferenceSingleChoiceItem( PreferenceSingleChoiceItem(

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

@ -96,7 +96,12 @@ fun CleanDialog(
HorizontalDivider(modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp)) HorizontalDivider(modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp))
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
val summary = StringBuilder().run { 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("") append("")
} }
Text( Text(
@ -104,7 +109,6 @@ fun CleanDialog(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 24.dp), .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 package org.xtimms.tokusho.sections.settings.storage
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize 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.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons 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.Image
import androidx.compose.material.icons.outlined.NetworkWifi import androidx.compose.material.icons.outlined.NetworkWifi
import androidx.compose.material.icons.outlined.SdStorage import androidx.compose.material.icons.outlined.SdStorage
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel 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.PreferenceStorageItem
import org.xtimms.tokusho.core.components.PreferencesHintCard import org.xtimms.tokusho.core.components.PreferencesHintCard
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
import org.xtimms.tokusho.data.CACHE_SIZE_MAX
const val STORAGE_DESTINATION = "storage" const val STORAGE_DESTINATION = "storage"
@ -44,14 +45,16 @@ fun StorageView(
title = stringResource(R.string.storage), title = stringResource(R.string.storage),
navigateBack = navigateBack navigateBack = navigateBack
) { padding -> ) { padding ->
if (!uiState.isLoading) LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier.padding(padding),
.padding(padding) contentPadding = PaddingValues(
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
)
) { ) {
item { item {
PreferenceStorageHeader( PreferenceStorageHeader(
used = uiState.httpCacheSize + uiState.thumbnailsCache + uiState.pagesCache, used = (uiState.httpCacheSize + uiState.thumbnailsCache + uiState.pagesCache).toFloat(),
total = uiState.availableSpace total = uiState.availableSpace.toFloat()
) )
} }
item { item {
@ -65,37 +68,35 @@ fun StorageView(
} }
item { item {
PreferenceStorageItem( PreferenceStorageItem(
total = uiState.availableSpace, total = uiState.availableSpace.toFloat(),
title = stringResource(id = R.string.saved_manga), title = stringResource(id = R.string.saved_manga),
icon = Icons.Outlined.SdStorage icon = Icons.Outlined.SdStorage
) )
} }
item { item {
PreferenceStorageItem( PreferenceStorageItem(
total = uiState.availableSpace, total = uiState.availableSpace.toFloat(),
title = stringResource(id = R.string.pages_cache), title = stringResource(id = R.string.pages_cache),
icon = Icons.Outlined.AutoStories, icon = Icons.Outlined.AutoStories,
used = uiState.pagesCache used = uiState.pagesCache.toFloat()
) )
} }
item { item {
PreferenceStorageItem( PreferenceStorageItem(
total = uiState.availableSpace, total = uiState.availableSpace.toFloat(),
title = stringResource(id = R.string.thumbnails_cache), title = stringResource(id = R.string.thumbnails_cache),
icon = Icons.Outlined.Image, icon = Icons.Outlined.Image,
used = uiState.thumbnailsCache used = uiState.thumbnailsCache.toFloat()
) )
} }
item { item {
PreferenceStorageItem( PreferenceStorageItem(
total = uiState.availableSpace, total = CACHE_SIZE_MAX.toFloat(),
title = stringResource(id = R.string.network_cache), title = stringResource(id = R.string.network_cache),
icon = Icons.Outlined.NetworkWifi, icon = Icons.Outlined.NetworkWifi,
used = uiState.httpCacheSize used = uiState.httpCacheSize.toFloat()
) )
} }
} else Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
} }
} }
if (showCleanDialog) { if (showCleanDialog) {

@ -3,7 +3,6 @@ package org.xtimms.tokusho.sections.settings.storage
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
@ -11,32 +10,29 @@ import okhttp3.Cache
import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel
import org.xtimms.tokusho.core.cache.CacheDir import org.xtimms.tokusho.core.cache.CacheDir
import org.xtimms.tokusho.data.LocalStorageManager import org.xtimms.tokusho.data.LocalStorageManager
import java.util.EnumMap
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class StorageViewModel @Inject constructor( class StorageViewModel @Inject constructor(
private val storageManager: LocalStorageManager, private val storageManager: LocalStorageManager,
private val httpCache: Cache, private val httpCache: Cache,
) : BaseViewModel<StorageUiState>() { ) : BaseViewModel<StorageUiState>(), StorageEvent {
val httpCacheSize = MutableStateFlow(-1L)
val cacheSizes = EnumMap<CacheDir, MutableStateFlow<Long>>(CacheDir::class.java)
private var storageUsageJob: Job? = null private var storageUsageJob: Job? = null
init { init {
val prevJob = storageUsageJob
storageUsageJob = launchJob(Dispatchers.Default) { storageUsageJob = launchJob(Dispatchers.Default) {
prevJob?.cancelAndJoin() setLoading(true)
mutableUiState.update { mutableUiState.update {
it.copy( it.copy(
availableSpace = storageManager.computeAvailableSize(), availableSpace = storageManager.computeAvailableSize(),
pagesCache = storageManager.computeCacheSize(CacheDir.PAGES), pagesCache = storageManager.computeCacheSize(CacheDir.PAGES),
thumbnailsCache = storageManager.computeCacheSize(CacheDir.THUMBS), 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), pagesCache = storageManager.computeCacheSize(CacheDir.PAGES),
thumbnailsCache = storageManager.computeCacheSize(CacheDir.THUMBS), thumbnailsCache = storageManager.computeCacheSize(CacheDir.THUMBS),
httpCacheSize = runInterruptible { httpCache.size() }, httpCacheSize = runInterruptible { httpCache.size() },
isLoading = false isLoading = false,
) )
} }
} catch (_: Exception) { } 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 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 bytes = amount * multiplier
val units = context.getString(R.string.text_file_sizes_free).split('|') val units = context.getString(R.string.text_file_sizes_free).split('|')
if (bytes <= 0) { 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 bytes = amount * multiplier
val units = context.getString(R.string.text_file_sizes_total).split('|') val units = context.getString(R.string.text_file_sizes_total).split('|')
if (bytes <= 0) { 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 bytes = amount * multiplier
val units = context.getString(R.string.text_file_sizes_used).split('|') val units = context.getString(R.string.text_file_sizes_used).split('|')
if (bytes <= 0) { 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 bytes = amount * multiplier
val units = context.getString(R.string.text_file_sizes).split('|') val units = context.getString(R.string.text_file_sizes).split('|')
if (bytes <= 0) { 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 val bytes = amount * multiplier
if (bytes <= 0) { if (bytes <= 0) {
return "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.ExperimentalPathApi
import kotlin.io.path.walk 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 { fun File.getUriCompat(context: Context): Uri {
return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", this) 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="disable_battery_optimization_summary">Helps with background updates checks</string>
<string name="recommended_action">Recommended action</string> <string name="recommended_action">Recommended action</string>
<string name="space_used">% used</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> </resources>
Loading…
Cancel
Save