diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 83f3f5b..612ffe8 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,3 +1,4 @@
+import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
import java.io.ByteArrayOutputStream
import java.text.SimpleDateFormat
import java.util.Date
@@ -13,6 +14,9 @@ plugins {
id("dagger.hilt.android.plugin")
}
+val acraAuthLogin: String = gradleLocalProperties(rootDir).getProperty("authLogin") ?: "\"acra_login\""
+val acraAuthPassword: String = gradleLocalProperties(rootDir).getProperty("authPassword") ?: "\"acra_password\""
+
android {
namespace = "org.xtimms.tokusho"
compileSdk = 34
@@ -28,6 +32,10 @@ android {
buildConfigField("String", "COMMIT_SHA", "\"${getGitSha()}\"")
buildConfigField("String", "BUILD_TIME", "\"${getBuildTime()}\"")
+ buildConfigField("String", "ACRA_URI", "\"https://bugs.kotatsu.app/report\"")
+ buildConfigField("String", "ACRA_AUTH_LOGIN", acraAuthLogin)
+ buildConfigField("String", "ACRA_AUTH_PASSWORD", acraAuthPassword)
+
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
@@ -43,6 +51,9 @@ android {
}
buildTypes {
+ debug {
+ applicationIdSuffix = ".debug"
+ }
release {
isMinifyEnabled = true
proguardFiles(
@@ -93,6 +104,7 @@ dependencies {
implementation("androidx.room:room-ktx:2.6.1")
implementation("androidx.work:work-runtime-ktx:2.9.0")
ksp("androidx.room:room-compiler:2.6.1")
+ implementation("ch.acra:acra-http:5.9.7")
implementation("com.google.android.material:material:1.11.0")
implementation("com.google.accompanist:accompanist-flowlayout:0.32.0")
implementation("com.google.accompanist:accompanist-systemuicontroller:0.32.0")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d5bbaf2..1e98635 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,12 @@
+
+
diff --git a/app/src/main/java/org/xtimms/tokusho/App.kt b/app/src/main/java/org/xtimms/tokusho/App.kt
index add6052..2bf027e 100644
--- a/app/src/main/java/org/xtimms/tokusho/App.kt
+++ b/app/src/main/java/org/xtimms/tokusho/App.kt
@@ -11,11 +11,15 @@ import com.tencent.mmkv.MMKV
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import org.acra.ReportField
+import org.acra.config.httpSender
+import org.acra.data.StringFormat
+import org.acra.ktx.initAcra
+import org.acra.sender.HttpSender
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.xtimms.tokusho.core.database.MangaDatabase
+import org.xtimms.tokusho.core.prefs.AppSettings
import org.xtimms.tokusho.core.updates.Updater
-import org.xtimms.tokusho.crash.CrashActivity
-import org.xtimms.tokusho.crash.GlobalExceptionHandler
import org.xtimms.tokusho.utils.lang.processLifecycleScope
import javax.inject.Inject
import javax.inject.Provider
@@ -44,7 +48,30 @@ class App : Application() {
}
}
- GlobalExceptionHandler.initialize(applicationContext, CrashActivity::class.java)
+ // GlobalExceptionHandler.initialize(applicationContext, CrashActivity::class.java)
+ if (AppSettings.isACRAEnabled()) {
+ initAcra {
+ buildConfigClass = BuildConfig::class.java
+ reportFormat = StringFormat.JSON
+ httpSender {
+ uri = BuildConfig.ACRA_URI
+ basicAuthLogin = BuildConfig.ACRA_AUTH_LOGIN
+ basicAuthPassword = BuildConfig.ACRA_AUTH_PASSWORD
+ httpMethod = HttpSender.Method.POST
+ }
+ reportContent = listOf(
+ ReportField.PACKAGE_NAME,
+ ReportField.INSTALLATION_ID,
+ ReportField.APP_VERSION_CODE,
+ ReportField.APP_VERSION_NAME,
+ ReportField.ANDROID_VERSION,
+ ReportField.PHONE_MODEL,
+ ReportField.STACK_TRACE,
+ ReportField.CRASH_CONFIGURATION,
+ ReportField.CUSTOM_DATA,
+ )
+ }
+ }
}
override fun attachBaseContext(base: Context?) {
diff --git a/app/src/main/java/org/xtimms/tokusho/MainActivity.kt b/app/src/main/java/org/xtimms/tokusho/MainActivity.kt
index fa270cd..487bdcb 100644
--- a/app/src/main/java/org/xtimms/tokusho/MainActivity.kt
+++ b/app/src/main/java/org/xtimms/tokusho/MainActivity.kt
@@ -39,6 +39,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat
+import androidx.core.view.WindowCompat
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import coil.ImageLoader
@@ -48,6 +49,7 @@ import kotlinx.coroutines.launch
import org.xtimms.tokusho.core.Navigation
import org.xtimms.tokusho.core.components.BottomNavBar
import org.xtimms.tokusho.core.components.TopAppBar
+import org.xtimms.tokusho.core.logs.FileLogger
import org.xtimms.tokusho.ui.theme.TokushoTheme
import org.xtimms.tokusho.utils.lang.processLifecycleScope
import javax.inject.Inject
@@ -59,9 +61,18 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var coil: ImageLoader
+ @Inject
+ lateinit var loggers: Set<@JvmSuppressWildcards FileLogger>
+
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
+
+ if (!isTaskRoot) {
+ finish()
+ return
+ }
+
setContent {
val navController = rememberNavController()
val windowSizeClass = calculateWindowSizeClass(this)
@@ -74,6 +85,7 @@ class MainActivity : ComponentActivity() {
) {
MainView(
coil = coil,
+ loggers = loggers,
isCompactScreen = isCompactScreen,
navController = navController
)
@@ -112,6 +124,7 @@ class MainActivity : ComponentActivity() {
@Composable
fun MainView(
coil: ImageLoader,
+ loggers: Set,
isCompactScreen: Boolean,
navController: NavHostController,
) {
@@ -161,6 +174,7 @@ fun MainView(
) {
Navigation(
coil = coil,
+ loggers = loggers,
navController = navController,
isCompactScreen = false,
modifier = Modifier,
@@ -180,6 +194,7 @@ fun MainView(
}
Navigation(
coil = coil,
+ loggers = loggers,
navController = navController,
isCompactScreen = true,
modifier = Modifier.padding(
diff --git a/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt b/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt
index 55cb9f3..003e9e9 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt
@@ -18,6 +18,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.xtimms.tokusho.core.logs.FileLogger
import org.xtimms.tokusho.core.model.ShelfCategory
import org.xtimms.tokusho.core.motion.materialSharedAxisXIn
import org.xtimms.tokusho.core.motion.materialSharedAxisXOut
@@ -62,6 +63,7 @@ fun PathInterpolator.toEasing(): Easing {
@Composable
fun Navigation(
coil: ImageLoader,
+ loggers: Set,
navController: NavHostController,
isCompactScreen: Boolean,
modifier: Modifier,
@@ -180,6 +182,7 @@ fun Navigation(
composable(ADVANCED_DESTINATION) {
AdvancedView(
+ loggers = loggers,
navigateBack = navigateBack,
)
}
@@ -220,13 +223,16 @@ fun Navigation(
)
}
- // TODO
composable(
- route = DETAILS_DESTINATION
+ route = DETAILS_DESTINATION,
+ arguments = listOf(
+ navArgument(MANGA_ID_ARGUMENT.removeFirstAndLast()) {
+ type = NavType.LongType
+ }
+ )
) { navEntry ->
DetailsView(
coil = coil,
- mangaId = 0L,
navigateBack = navigateBack,
)
}
diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/AnimatedNumber.kt b/app/src/main/java/org/xtimms/tokusho/core/components/AnimatedNumber.kt
new file mode 100644
index 0000000..f060163
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/components/AnimatedNumber.kt
@@ -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>(emptyList()) }
+ var blocks by remember { mutableStateOf>(emptyList()) }
+
+ DisposableEffect(value) {
+ var splittedValue = emptyList().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 = emptyList().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)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/PreferenceItem.kt b/app/src/main/java/org/xtimms/tokusho/core/components/PreferenceItem.kt
index 68b2fd9..281eb4c 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/components/PreferenceItem.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/components/PreferenceItem.kt
@@ -1,5 +1,13 @@
package org.xtimms.tokusho.core.components
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.SizeTransform
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -27,6 +35,7 @@ import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
@@ -186,14 +195,28 @@ internal fun PreferenceItemDescription(
color: Color = MaterialTheme.colorScheme.onSurfaceVariant,
overflow: TextOverflow = TextOverflow.Ellipsis
) {
- Text(
+ AnimatedContent(
+ targetState = text,
+ transitionSpec = {
+ if (targetState > initialState) {
+ (slideInVertically { height -> -height } + fadeIn()).togetherWith(
+ slideOutVertically { height -> height } + fadeOut())
+ } else {
+ (slideInVertically { height -> height } + fadeIn()).togetherWith(
+ slideOutVertically { height -> -height } + fadeOut())
+ }.using(SizeTransform(clip = false))
+ },
modifier = modifier.padding(top = 2.dp),
- text = text,
- maxLines = maxLines,
- style = style,
- color = color.applyOpacity(enabled),
- overflow = overflow
- )
+ label = "Preference desc"
+ ) { targetText ->
+ Text(
+ text = targetText,
+ maxLines = maxLines,
+ style = style,
+ color = color.applyOpacity(enabled),
+ overflow = overflow
+ )
+ }
}
@Composable
@@ -552,9 +575,16 @@ fun PreferencesHintCard(
@Composable
fun PreferenceStorageHeader(
- used: Long = 4L,
- total: Long = 128L
+ used: Float = 40F,
+ total: Float = 128F
) {
+
+ val animatedProgress = animateFloatAsState(
+ targetValue = 1 - ((total - used) / total),
+ animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
+ label = "Progress"
+ ).value
+
Column {
Row(
modifier = Modifier
@@ -562,27 +592,47 @@ fun PreferenceStorageHeader(
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
- Text(
- text = FileSize.BYTES.formatWithoutUnits(used),
- modifier = Modifier.padding(end = 4.dp),
- style = MaterialTheme.typography.displayLarge
+ AnimatedNumber(
+ value = FileSize.BYTES.formatWithoutUnits(used)
)
- Text(
- text = FileSize.BYTES.showUnit(LocalContext.current, used),
+ AnimatedContent(
+ targetState = used,
+ transitionSpec = {
+ if (targetState > initialState) {
+ (fadeIn()).togetherWith(fadeOut())
+ } else {
+ (fadeIn()).togetherWith(fadeOut())
+ }.using(SizeTransform(clip = false))
+ },
modifier = Modifier
.weight(1f)
.align(Alignment.Bottom)
- .padding(PaddingValues(bottom = 8.dp))
- )
- Text(
- text = FileSize.BYTES.totalFormat(LocalContext.current, total),
+ .padding(PaddingValues(start = 4.dp, bottom = 8.dp)),
+ label = "Unit"
+ ) { targetUsed ->
+ Text(text = FileSize.BYTES.showUnit(LocalContext.current, targetUsed))
+ }
+ AnimatedContent(
+ targetState = total,
+ transitionSpec = {
+ if (targetState > initialState) {
+ (fadeIn()).togetherWith(fadeOut())
+ } else {
+ (fadeIn()).togetherWith(fadeOut())
+ }.using(SizeTransform(clip = false))
+ },
modifier = Modifier
.align(Alignment.Bottom)
- .padding(PaddingValues(bottom = 8.dp))
- )
+ .padding(PaddingValues(bottom = 8.dp)),
+ label = "Total used"
+ ) { targetTotal ->
+ Text(
+ text = FileSize.BYTES.totalFormat(LocalContext.current, targetTotal),
+ )
+ }
}
LinearProgressIndicator(
- progress = { (1 - ((total - used) / total.toFloat())) },
+ progress = { animatedProgress },
modifier = Modifier
.padding(PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp))
.height(16.dp)
@@ -598,13 +648,20 @@ fun PreferenceStorageHeader(
@Composable
fun PreferenceStorageItem(
title: String,
- used: Long? = 0L,
- total: Long?,
+ used: Float = 0F,
+ total: Float = 0F,
icon: Any? = null,
leadingIcon: (@Composable () -> Unit)? = null,
trailingIcon: (@Composable () -> Unit)? = null,
onClick: () -> Unit = {},
) {
+
+ val animatedProgress = animateFloatAsState(
+ targetValue = 1 - ((total - used) / total),
+ animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
+ label = "Progress"
+ ).value
+
Surface(
modifier = Modifier.combinedClickable(
onClick = onClick,
@@ -658,20 +715,30 @@ fun PreferenceStorageItem(
text = title,
enabled = true
)
- Text(text = FileSize.BYTES.format(LocalContext.current, used ?: 0L))
- }
- if (total != null) {
- LinearProgressIndicator(
- progress = { (1 - ((total - used!!) / total.toFloat())) },
- modifier = Modifier
- .padding(PaddingValues(top = 12.dp))
- .height(5.dp)
- .fillMaxWidth(),
- color = MaterialTheme.colorScheme.primary,
- trackColor = MaterialTheme.colorScheme.primaryContainer,
- strokeCap = StrokeCap.Round,
- )
+ AnimatedContent(
+ targetState = used,
+ transitionSpec = {
+ if (targetState > initialState) {
+ (fadeIn()).togetherWith(fadeOut())
+ } else {
+ (fadeIn()).togetherWith(fadeOut())
+ }.using(SizeTransform(clip = false))
+ },
+ label = "Total used"
+ ) { targetTotal ->
+ Text(text = FileSize.BYTES.format(LocalContext.current, targetTotal))
+ }
}
+ 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 {
VerticalDivider(
@@ -700,7 +767,7 @@ fun PreferenceStorageHeaderPreview() {
@Preview(showBackground = true)
fun PreferenceStorageItemPreview() {
TokushoTheme {
- PreferenceStorageItem(title = "Saved manga", icon = Icons.Outlined.Save, total = 0L)
+ PreferenceStorageItem(title = "Saved manga", icon = Icons.Outlined.Save, total = 0F)
}
}
diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/ScaffoldWithTopAppBar.kt b/app/src/main/java/org/xtimms/tokusho/core/components/ScaffoldWithTopAppBar.kt
index ce7c689..d4fdd43 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/components/ScaffoldWithTopAppBar.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/components/ScaffoldWithTopAppBar.kt
@@ -3,7 +3,9 @@ package org.xtimms.tokusho.core.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.systemBars
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -29,7 +31,8 @@ fun ScaffoldWithTopAppBar(
Scaffold(
modifier = Modifier
.fillMaxSize()
- .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection),
+ .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection)
+ .consumeWindowInsets(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)),
topBar = {
DefaultTopAppBar(
title = title,
@@ -38,8 +41,7 @@ fun ScaffoldWithTopAppBar(
)
},
floatingActionButton = floatingActionButton,
- contentWindowInsets = WindowInsets.systemBars
- .only(WindowInsetsSides.Horizontal),
+ contentWindowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal),
content = content
)
}
diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/SettingItem.kt b/app/src/main/java/org/xtimms/tokusho/core/components/SettingItem.kt
index b9b7ee2..94985b6 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/components/SettingItem.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/components/SettingItem.kt
@@ -1,5 +1,10 @@
package org.xtimms.tokusho.core.components
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.SizeTransform
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -60,12 +65,24 @@ fun SettingItem(title: String, description: String, icon: ImageVector?, onClick:
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
- Text(
- text = description,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- maxLines = 1,
- style = MaterialTheme.typography.bodyMedium,
- )
+ AnimatedContent(
+ targetState = description,
+ transitionSpec = {
+ if (targetState > initialState) {
+ (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,
+ )
+ }
}
}
}
diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/Tables.kt b/app/src/main/java/org/xtimms/tokusho/core/database/Tables.kt
index 88649c9..5998141 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/database/Tables.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/database/Tables.kt
@@ -3,4 +3,5 @@ package org.xtimms.tokusho.core.database
const val TABLE_MANGA = "manga"
const val TABLE_TAGS = "tags"
const val TABLE_MANGA_TAGS = "manga_tags"
-const val TABLE_SOURCES = "sources"
\ No newline at end of file
+const val TABLE_SOURCES = "sources"
+const val TABLE_HISTORY = "history"
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/TokushoDatabase.kt b/app/src/main/java/org/xtimms/tokusho/core/database/TokushoDatabase.kt
index f641725..1f93f47 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/database/TokushoDatabase.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/database/TokushoDatabase.kt
@@ -2,14 +2,23 @@ package org.xtimms.tokusho.core.database
import android.content.Context
import androidx.room.Database
+import androidx.room.InvalidationTracker
import androidx.room.Room
import androidx.room.RoomDatabase
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import org.xtimms.tokusho.core.database.dao.HistoryDao
import org.xtimms.tokusho.core.database.dao.MangaDao
import org.xtimms.tokusho.core.database.dao.MangaSourcesDao
+import org.xtimms.tokusho.core.database.entity.HistoryEntity
import org.xtimms.tokusho.core.database.entity.MangaEntity
import org.xtimms.tokusho.core.database.entity.MangaSourceEntity
import org.xtimms.tokusho.core.database.entity.MangaTagsEntity
import org.xtimms.tokusho.core.database.entity.TagEntity
+import org.xtimms.tokusho.utils.lang.processLifecycleScope
const val DATABASE_VERSION = 1
@@ -18,12 +27,15 @@ const val DATABASE_VERSION = 1
MangaEntity::class,
TagEntity::class,
MangaTagsEntity::class,
- MangaSourceEntity::class
+ MangaSourceEntity::class,
+ HistoryEntity::class
],
version = DATABASE_VERSION
)
abstract class MangaDatabase : RoomDatabase() {
+ abstract fun getHistoryDao(): HistoryDao
+
abstract fun getMangaDao(): MangaDao
abstract fun getSourcesDao(): MangaSourcesDao
@@ -32,4 +44,14 @@ abstract class MangaDatabase : RoomDatabase() {
fun MangaDatabase(context: Context): MangaDatabase = Room
.databaseBuilder(context, MangaDatabase::class.java, "tokusho-db")
- .build()
\ No newline at end of file
+ .build()
+
+@OptIn(ExperimentalCoroutinesApi::class)
+fun InvalidationTracker.removeObserverAsync(observer: InvalidationTracker.Observer) {
+ val scope = processLifecycleScope
+ if (scope.isActive) {
+ processLifecycleScope.launch(Dispatchers.Default, CoroutineStart.ATOMIC) {
+ removeObserver(observer)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/dao/HistoryDao.kt b/app/src/main/java/org/xtimms/tokusho/core/database/dao/HistoryDao.kt
new file mode 100644
index 0000000..ccae10a
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/database/dao/HistoryDao.kt
@@ -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
+
+ @Transaction
+ @Query("SELECT * FROM history WHERE deleted_at = 0 AND manga_id IN (:ids)")
+ abstract suspend fun findAll(ids: Collection): List
+
+ @Transaction
+ @Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC")
+ abstract fun observeAll(): Flow>
+
+ @Transaction
+ @Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit")
+ abstract fun observeAll(limit: Int): Flow>
+
+ fun observeAll(order: ListSortOrder): Flow> {
+ 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
+
+ @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
+
+ @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
+
+ @Query("SELECT COUNT(*) FROM history WHERE deleted_at = 0")
+ abstract fun observeCount(): Flow
+
+ @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) {
+ 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>
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/entity/EntityMapping.kt b/app/src/main/java/org/xtimms/tokusho/core/database/entity/EntityMapping.kt
index 56126ef..26f79f8 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/database/entity/EntityMapping.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/database/entity/EntityMapping.kt
@@ -6,8 +6,10 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
+import org.xtimms.tokusho.core.model.MangaHistory
import org.xtimms.tokusho.core.model.MangaSource
import org.xtimms.tokusho.utils.lang.longHashCode
+import java.time.Instant
// Entity to model
@@ -73,4 +75,13 @@ fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
fun MangaState(name: String): MangaState? = runCatching {
MangaState.valueOf(name)
-}.getOrNull()
\ No newline at end of file
+}.getOrNull()
+
+fun HistoryEntity.toMangaHistory() = MangaHistory(
+ createdAt = Instant.ofEpochMilli(createdAt),
+ updatedAt = Instant.ofEpochMilli(updatedAt),
+ chapterId = chapterId,
+ page = page,
+ scroll = scroll.toInt(),
+ percent = percent,
+)
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/entity/HistoryEntity.kt b/app/src/main/java/org/xtimms/tokusho/core/database/entity/HistoryEntity.kt
new file mode 100644
index 0000000..a4702f4
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/database/entity/HistoryEntity.kt
@@ -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,
+)
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/entity/HistoryWithManga.kt b/app/src/main/java/org/xtimms/tokusho/core/database/entity/HistoryWithManga.kt
new file mode 100644
index 0000000..87c727d
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/database/entity/HistoryWithManga.kt
@@ -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,
+)
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/exceptions/TooManyRequestExceptions.kt b/app/src/main/java/org/xtimms/tokusho/core/exceptions/TooManyRequestExceptions.kt
new file mode 100644
index 0000000..351963f
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/exceptions/TooManyRequestExceptions.kt
@@ -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
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/logs/FileLogger.kt b/app/src/main/java/org/xtimms/tokusho/core/logs/FileLogger.kt
new file mode 100644
index 0000000..8ebbaf7
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/logs/FileLogger.kt
@@ -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()
+ 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) {
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/logs/Loggers.kt b/app/src/main/java/org/xtimms/tokusho/core/logs/Loggers.kt
new file mode 100644
index 0000000..1478083
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/logs/Loggers.kt
@@ -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
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/logs/LoggersModule.kt b/app/src/main/java/org/xtimms/tokusho/core/logs/LoggersModule.kt
new file mode 100644
index 0000000..9f21107
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/logs/LoggersModule.kt
@@ -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,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/model/Manga.kt b/app/src/main/java/org/xtimms/tokusho/core/model/Manga.kt
index 3029d33..7c1333d 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/model/Manga.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/model/Manga.kt
@@ -1,5 +1,8 @@
package org.xtimms.tokusho.core.model
import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaChapter
-fun Collection.distinctById() = distinctBy { it.id }
\ No newline at end of file
+fun Collection.distinctById() = distinctBy { it.id }
+
+fun Collection.findById(id: Long) = find { x -> x.id == id }
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/model/MangaHistory.kt b/app/src/main/java/org/xtimms/tokusho/core/model/MangaHistory.kt
new file mode 100644
index 0000000..91f6450
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/model/MangaHistory.kt
@@ -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
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/motion/MotionConstants.kt b/app/src/main/java/org/xtimms/tokusho/core/motion/MotionConstants.kt
index 4a1e3a5..9a60d23 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/motion/MotionConstants.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/motion/MotionConstants.kt
@@ -3,9 +3,9 @@ package org.xtimms.tokusho.core.motion
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
-public object MotionConstants {
- public const val DefaultMotionDuration: Int = 300
- public const val DefaultFadeInDuration: Int = 150
- public const val DefaultFadeOutDuration: Int = 75
- public val DefaultSlideDistance: Dp = 30.dp
+object MotionConstants {
+ const val DefaultMotionDuration: Int = 300
+ const val DefaultFadeInDuration: Int = 150
+ const val DefaultFadeOutDuration: Int = 75
+ val DefaultSlideDistance: Dp = 30.dp
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/network/CommonHeaders.kt b/app/src/main/java/org/xtimms/tokusho/core/network/CommonHeaders.kt
new file mode 100644
index 0000000..ef44c9b
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/network/CommonHeaders.kt
@@ -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()
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/network/NetworkModule.kt b/app/src/main/java/org/xtimms/tokusho/core/network/NetworkModule.kt
index 076f9a8..96bdc6a 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/network/NetworkModule.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/network/NetworkModule.kt
@@ -14,6 +14,10 @@ import okhttp3.OkHttpClient
import org.xtimms.tokusho.core.network.cookies.AndroidCookieJar
import org.xtimms.tokusho.core.network.cookies.MutableCookieJar
import org.xtimms.tokusho.core.network.cookies.PreferencesCookieJar
+import org.xtimms.tokusho.core.network.interceptors.CacheLimitInterceptor
+import org.xtimms.tokusho.core.network.interceptors.CommonHeadersInterceptor
+import org.xtimms.tokusho.core.network.interceptors.GZipInterceptor
+import org.xtimms.tokusho.core.network.interceptors.RateLimitInterceptor
import org.xtimms.tokusho.data.LocalStorageManager
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@@ -55,6 +59,8 @@ interface NetworkModule {
writeTimeout(20, TimeUnit.SECONDS)
cookieJar(cookieJar)
cache(cache)
+ addInterceptor(GZipInterceptor())
+ addInterceptor(RateLimitInterceptor())
}.build()
@Provides
@@ -62,7 +68,11 @@ interface NetworkModule {
@MangaHttpClient
fun provideMangaHttpClient(
@BaseHttpClient baseClient: OkHttpClient,
- ): OkHttpClient = baseClient.newBuilder().build()
+ commonHeadersInterceptor: CommonHeadersInterceptor,
+ ): OkHttpClient = baseClient.newBuilder().apply {
+ addNetworkInterceptor(CacheLimitInterceptor())
+ addInterceptor(commonHeadersInterceptor)
+ }.build()
}
diff --git a/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/CacheLimitInterceptor.kt b/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/CacheLimitInterceptor.kt
new file mode 100644
index 0000000..c48e8e8
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/CacheLimitInterceptor.kt
@@ -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()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/CommonHeadersInterceptor.kt b/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/CommonHeadersInterceptor.kt
new file mode 100644
index 0000000..705e66b
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/CommonHeadersInterceptor.kt
@@ -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,
+) : 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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/GZipInterceptor.kt b/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/GZipInterceptor.kt
new file mode 100644
index 0000000..d7e4796
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/GZipInterceptor.kt
@@ -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)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/RateLimitInterceptor.kt b/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/RateLimitInterceptor.kt
new file mode 100644
index 0000000..524f589
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/RateLimitInterceptor.kt
@@ -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()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/RemoteMangaRepository.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/RemoteMangaRepository.kt
index e746119..746e5a8 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/parser/RemoteMangaRepository.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/parser/RemoteMangaRepository.kt
@@ -8,6 +8,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.currentCoroutineContext
+import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.parsers.InternalParsersApi
@@ -60,6 +61,9 @@ class RemoteMangaRepository(
val domains: Array
get() = parser.configKeyDomain.presetValues
+ val headers: Headers
+ get() = parser.headers
+
override fun intercept(chain: Interceptor.Chain): Response {
return if (parser is Interceptor) {
parser.intercept(chain)
diff --git a/app/src/main/java/org/xtimms/tokusho/core/prefs/AppSettings.kt b/app/src/main/java/org/xtimms/tokusho/core/prefs/AppSettings.kt
index 16039ae..b53b542 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/prefs/AppSettings.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/prefs/AppSettings.kt
@@ -26,12 +26,16 @@ const val UPDATE_CHANNEL = "update_channel"
private const val THEME_COLOR = "theme_color"
const val PALETTE_STYLE = "palette_style"
const val LANGUAGE = "language"
+const val READING_TIME = "reading_time"
const val SYSTEM_DEFAULT = 0
const val STABLE = 0
const val PRE_RELEASE = 1
+const val ACRA = "acra"
+const val LOGGING = "logging"
+
val paletteStyles = listOf(
PaletteStyle.TonalSpot,
PaletteStyle.Spritz,
@@ -87,6 +91,12 @@ object AppSettings {
fun isAutoUpdateEnabled() = AUTO_UPDATE.getBoolean(false)
+ fun isACRAEnabled() = ACRA.getBoolean(false)
+
+ fun isLoggingEnabled() = LOGGING.getBoolean(false)
+
+ fun isReadingTimeEstimationEnabled() = READING_TIME.getBoolean(true)
+
fun getLanguageConfiguration(languageNumber: Int = kv.decodeInt(LANGUAGE)) =
languageMap.getOrElse(languageNumber) { "" }
@@ -112,13 +122,10 @@ object AppSettings {
private val mutableAppSettingsStateFlow = MutableStateFlow(
Settings(
DarkThemePreference(
- darkThemeValue = kv.decodeInt(
- DARK_THEME_VALUE, DarkThemePreference.FOLLOW_SYSTEM
- ), isHighContrastModeEnabled = kv.decodeBool(HIGH_CONTRAST, false)
- ),
- isDynamicColorEnabled = kv.decodeBool(
- DYNAMIC_COLOR, DynamicColors.isDynamicColorAvailable()
+ darkThemeValue = kv.decodeInt(DARK_THEME_VALUE, DarkThemePreference.FOLLOW_SYSTEM),
+ isHighContrastModeEnabled = kv.decodeBool(HIGH_CONTRAST, false)
),
+ isDynamicColorEnabled = kv.decodeBool(DYNAMIC_COLOR, DynamicColors.isDynamicColorAvailable()),
seedColor = kv.decodeInt(THEME_COLOR, SEED),
paletteStyleIndex = kv.decodeInt(PALETTE_STYLE, 0)
)
diff --git a/app/src/main/java/org/xtimms/tokusho/data/LocalStorageManager.kt b/app/src/main/java/org/xtimms/tokusho/data/LocalStorageManager.kt
index a55acc9..6238d32 100644
--- a/app/src/main/java/org/xtimms/tokusho/data/LocalStorageManager.kt
+++ b/app/src/main/java/org/xtimms/tokusho/data/LocalStorageManager.kt
@@ -19,7 +19,7 @@ private const val DIR_NAME = "manga"
private const val NOMEDIA = ".nomedia"
private const val CACHE_DISK_PERCENTAGE = 0.02
private const val CACHE_SIZE_MIN: Long = 10 * 1024 * 1024 // 10MB
-private const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB
+const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB
@Reusable
class LocalStorageManager @Inject constructor(
diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/HistoryRepository.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/HistoryRepository.kt
new file mode 100644
index 0000000..59ce425
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/data/repository/HistoryRepository.kt
@@ -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 {
+ 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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsUiState.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsUiState.kt
index 1eef7c2..f1b396d 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsUiState.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsUiState.kt
@@ -1,11 +1,10 @@
package org.xtimms.tokusho.sections.details
-import coil.ImageLoader
-import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.tokusho.core.base.state.UiState
+import org.xtimms.tokusho.sections.details.data.MangaDetails
data class DetailsUiState(
- val manga: Manga? = null,
+ val details: MangaDetails? = null,
override val isLoading: Boolean = false,
override val message: String? = null,
) : UiState() {
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsView.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsView.kt
index c282e60..895aed8 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsView.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsView.kt
@@ -7,39 +7,41 @@ import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Timelapse
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.ImageLoader
-import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaState
+import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.DetailsToolbar
+import org.xtimms.tokusho.core.components.PreferenceItem
+import org.xtimms.tokusho.core.prefs.AppSettings
const val MANGA_ID_ARGUMENT = "{mangaId}"
-const val DETAILS_DESTINATION = "details/$MANGA_ID_ARGUMENT"
+const val DETAILS_DESTINATION = "details/?mangaId=$MANGA_ID_ARGUMENT"
@Composable
fun DetailsView(
coil: ImageLoader,
- mangaId: Long,
navigateBack: () -> Unit,
) {
+
+ val context = LocalContext.current
val viewModel: DetailsViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val chapterListState = rememberLazyListState()
- LaunchedEffect(mangaId) {
- viewModel.getDetails(mangaId)
- }
-
Scaffold(
topBar = {
val isFirstItemVisible by remember {
@@ -57,7 +59,7 @@ fun DetailsView(
label = "Top Bar Background",
)
DetailsToolbar(
- title = "Test",
+ title = uiState.details?.toManga()?.title ?: "Unknown",
titleAlphaProvider = { animatedTitleAlpha },
backgroundAlphaProvider = { animatedBgAlpha },
onBackClicked = { navigateBack() }
@@ -84,26 +86,41 @@ fun DetailsView(
) {
DetailsInfoBox(
coil = coil,
- imageUrl = uiState.manga?.largeCoverUrl ?: "",
- title = uiState.manga?.title ?: "",
- author = uiState.manga?.author ?: "",
+ imageUrl = uiState.details?.toManga()?.largeCoverUrl ?: "",
+ title = uiState.details?.toManga()?.title ?: "",
+ author = uiState.details?.toManga()?.author ?: "",
artist = "",
- state = uiState.manga?.state ?: MangaState.FINISHED,
+ state = uiState.details?.toManga()?.state ?: MangaState.FINISHED,
isTabletUi = false,
appBarPadding = topPadding,
)
}
+ val time = viewModel.readingTime.value
+ if (AppSettings.isReadingTimeEstimationEnabled() || time == null) {
+ item {
+ if (time != null) {
+ PreferenceItem(
+ title = if (time.isContinue) stringResource(id = R.string.approximate_remaining_time) else stringResource(
+ id = R.string.approximate_reading_time
+ ),
+ description = time.format(context.resources),
+ icon = Icons.Outlined.Timelapse
+ )
+ }
+ }
+ }
+
item(
key = DetailsViewItem.DESCRIPTION_WITH_TAG,
contentType = DetailsViewItem.DESCRIPTION_WITH_TAG,
) {
ExpandableMangaDescription(
defaultExpandState = true,
- description = uiState.manga?.description ?: "",
- tagsProvider = { uiState.manga?.tags?.toList() },
- onTagSearch = { },
- onCopyTagToClipboard = { },
+ description = uiState.details?.toManga()?.description ?: "",
+ tagsProvider = { uiState.details?.toManga()?.tags?.toList() },
+ onTagSearch = { },
+ onCopyTagToClipboard = { },
)
}
}
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsViewModel.kt
index 095485e..9156e55 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsViewModel.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsViewModel.kt
@@ -4,9 +4,11 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
@@ -14,42 +16,80 @@ import kotlinx.coroutines.plus
import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel
import org.xtimms.tokusho.core.parser.MangaIntent
+import org.xtimms.tokusho.data.repository.HistoryRepository
import org.xtimms.tokusho.sections.details.data.MangaDetails
import org.xtimms.tokusho.sections.details.domain.DetailsLoadUseCase
+import org.xtimms.tokusho.sections.details.domain.ReadingTimeUseCase
import org.xtimms.tokusho.utils.lang.onEachWhile
import javax.inject.Inject
@HiltViewModel
class DetailsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
+ private val historyRepository: HistoryRepository,
private val detailsLoadUseCase: DetailsLoadUseCase,
+ private val readingTimeUseCase: ReadingTimeUseCase,
) : BaseViewModel(), DetailsEvent {
+ override val mutableUiState = MutableStateFlow(DetailsUiState())
+
private val intent = MangaIntent(savedStateHandle)
+ private val mangaId = intent.id
+
+ private var loadingJob: Job
+
val details = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, false) })
+ val mangaD = details.map { x -> x?.toManga() }
+ .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
- val manga = details.map { x -> x?.toManga() }
+ val history = historyRepository.observeOne(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
- override val mutableUiState = MutableStateFlow(DetailsUiState())
+ val remoteManga = MutableStateFlow(null)
- fun getDetails(mangaId: Long) {
- launchLoadingJob(Dispatchers.Default) {
- detailsLoadUseCase.invoke(intent)
- .onEachWhile {
- if (it.allChapters.isEmpty()) {
- return@onEachWhile false
- }
- true
- }.collect {
- mutableUiState.update {
- val manga = details.firstOrNull { it != null } ?: return@collect
- it.copy(
- manga = manga.toManga()
- )
- }
- }
- }
+ private val chaptersQuery = MutableStateFlow("")
+ val selectedBranch = MutableStateFlow(null)
+
+ @Deprecated("")
+ val description = details
+ .map { it?.description }
+ .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, null)
+
+ val isChaptersEmpty: StateFlow = details.map {
+ it != null && it.isLoaded && it.allChapters.isEmpty()
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
+
+ val readingTime = combine(
+ details,
+ selectedBranch,
+ history,
+ ) { m, b, h ->
+ readingTimeUseCase.invoke(m, b, h)
+ }.stateIn(viewModelScope, SharingStarted.Lazily, null)
+
+ init {
+ loadingJob = doLoad()
+ }
+
+ fun reload() {
+ loadingJob.cancel()
+ loadingJob = doLoad()
}
+ private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
+ detailsLoadUseCase.invoke(mangaId ?: 0L)
+ .onEachWhile {
+ if (it.allChapters.isEmpty()) {
+ return@onEachWhile false
+ }
+ true
+ }.collect {
+ //details.value = it
+ mutableUiState.update {
+ it.copy(
+ details = details.value
+ )
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/data/ReadingTime.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/data/ReadingTime.kt
new file mode 100644
index 0000000..1a06321
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/details/data/ReadingTime.kt
@@ -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),
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/domain/DetailsLoadUseCase.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/domain/DetailsLoadUseCase.kt
index d676a87..d9f8881 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/details/domain/DetailsLoadUseCase.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/details/domain/DetailsLoadUseCase.kt
@@ -7,16 +7,12 @@ import android.text.style.ForegroundColorSpan
import androidx.core.text.getSpans
import androidx.core.text.parseAsHtml
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.runInterruptible
-import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
-import org.koitharu.kotatsu.parsers.util.recoverNotNull
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xtimms.tokusho.core.parser.MangaDataRepository
-import org.xtimms.tokusho.core.parser.MangaIntent
import org.xtimms.tokusho.core.parser.MangaRepository
import org.xtimms.tokusho.sections.details.data.MangaDetails
import org.xtimms.tokusho.utils.lang.sanitize
@@ -29,9 +25,9 @@ class DetailsLoadUseCase @Inject constructor(
private val imageGetter: Html.ImageGetter,
) {
- operator fun invoke(intent: MangaIntent): Flow = channelFlow {
- val manga = requireNotNull(mangaDataRepository.resolveIntent(intent)) {
- "Cannot resolve intent $intent"
+ operator fun invoke(mangaId: Long): Flow = channelFlow {
+ val manga = requireNotNull(mangaDataRepository.findMangaById(mangaId)) {
+ "Cannot resolve id $mangaId"
}
send(MangaDetails(manga, null, false))
try {
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/domain/ReadingTimeUseCase.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/domain/ReadingTimeUseCase.kt
new file mode 100644
index 0000000..9bc04d6
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/details/domain/ReadingTimeUseCase.kt
@@ -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,
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreView.kt b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreView.kt
index 4bc40b2..a83e42b 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreView.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreView.kt
@@ -36,7 +36,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.tokusho.R
-import org.xtimms.tokusho.core.collapsable
import org.xtimms.tokusho.core.components.ExploreButton
import org.xtimms.tokusho.core.components.SourceItem
import org.xtimms.tokusho.core.components.icons.Dice
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListView.kt b/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListView.kt
index b9894b7..9698d5c 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListView.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListView.kt
@@ -18,7 +18,6 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
-import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -49,7 +48,7 @@ fun MangaListView(
val viewModel: MangaListViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
- MangaListView(
+ MangaListViewContent(
coil = coil,
source = source,
uiState = uiState,
@@ -60,7 +59,7 @@ fun MangaListView(
}
@Composable
-private fun MangaListView(
+private fun MangaListViewContent(
coil: ImageLoader,
source: MangaSource,
uiState: MangaListUiState,
@@ -69,7 +68,6 @@ private fun MangaListView(
navigateToDetails: (Long) -> Unit,
) {
val context = LocalContext.current
- val scrollState = rememberScrollState()
if (uiState.message != null) {
LaunchedEffect(uiState.message) {
@@ -85,7 +83,7 @@ private fun MangaListView(
.only(WindowInsetsSides.Horizontal)
) { padding ->
val listState = rememberLazyGridState()
- listState.onBottomReached(buffer = 3) {
+ listState.onBottomReached(buffer = 5) {
event?.loadMore()
}
Column(
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsView.kt
index 90d5525..f3cd6df 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsView.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsView.kt
@@ -13,6 +13,10 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
@@ -22,6 +26,7 @@ import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material.icons.outlined.Storage
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -32,16 +37,17 @@ import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.xtimms.tokusho.R
-import org.xtimms.tokusho.core.components.PreferenceSubtitle
import org.xtimms.tokusho.core.components.PreferencesHintCard
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
import org.xtimms.tokusho.core.components.SettingItem
+import org.xtimms.tokusho.sections.settings.storage.StorageEvent
+import org.xtimms.tokusho.sections.settings.storage.StorageUiState
import org.xtimms.tokusho.sections.settings.storage.StorageViewModel
import org.xtimms.tokusho.utils.FileSize
+import org.xtimms.tokusho.utils.system.toast
const val SETTINGS_DESTINATION = "settings"
-@SuppressLint("BatteryLife")
@Composable
fun SettingsView(
navigateBack: () -> Unit,
@@ -51,10 +57,41 @@ fun SettingsView(
navigateToStorage: () -> Unit
) {
- val context = LocalContext.current
val viewModel: StorageViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ SettingsViewContent(
+ uiState = uiState,
+ event = viewModel,
+ navigateBack = navigateBack,
+ navigateToAppearance = navigateToAppearance,
+ navigateToAbout = navigateToAbout,
+ navigateToAdvanced = navigateToAdvanced,
+ navigateToStorage = navigateToStorage
+ )
+}
+
+@SuppressLint("BatteryLife")
+@Composable
+private fun SettingsViewContent(
+ uiState: StorageUiState,
+ event: StorageEvent?,
+ navigateBack: () -> Unit,
+ navigateToAppearance: () -> Unit,
+ navigateToAbout: () -> Unit,
+ navigateToAdvanced: () -> Unit,
+ navigateToStorage: () -> Unit
+) {
+
+ val context = LocalContext.current
+
+ if (uiState.message != null) {
+ LaunchedEffect(uiState.message) {
+ context.toast(uiState.message)
+ event?.onMessageDisplayed()
+ }
+ }
+
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
var showBatteryHint by remember {
mutableStateOf(!pm.isIgnoringBatteryOptimizations(context.packageName))
@@ -85,8 +122,10 @@ fun SettingsView(
navigateBack = navigateBack
) { padding ->
LazyColumn(
- modifier = Modifier
- .padding(padding)
+ modifier = Modifier.padding(padding),
+ contentPadding = PaddingValues(
+ bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+ )
) {
item {
AnimatedVisibility(
@@ -123,16 +162,16 @@ fun SettingsView(
append(
FileSize.BYTES.freeFormat(
context,
- uiState.availableSpace -
+ (uiState.availableSpace -
uiState.httpCacheSize -
uiState.pagesCache -
- uiState.thumbnailsCache
+ uiState.thumbnailsCache).toFloat()
)
)
}
SettingItem(
title = stringResource(id = R.string.storage),
- description = desc,
+ description = if (uiState.isLoading) context.getString(R.string.calculating_) else desc,
icon = Icons.Outlined.Storage,
onClick = navigateToStorage
)
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/about/AboutView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/about/AboutView.kt
index 6b2deeb..58547d9 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/settings/about/AboutView.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/about/AboutView.kt
@@ -1,5 +1,9 @@
package org.xtimms.tokusho.sections.settings.about
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
@@ -56,8 +60,10 @@ fun AboutView(
navigateBack = navigateBack
) { padding ->
LazyColumn(
- modifier = Modifier
- .padding(padding)
+ modifier = Modifier.padding(padding),
+ contentPadding = PaddingValues(
+ bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+ )
) {
item {
PreferenceItem(
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/about/UpdateView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/about/UpdateView.kt
index 149ca7c..4ac91ec 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/settings/about/UpdateView.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/about/UpdateView.kt
@@ -4,7 +4,10 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
@@ -70,8 +73,10 @@ fun UpdateView(
navigateBack = navigateBack
) { padding ->
LazyColumn(
- modifier = Modifier
- .padding(padding)
+ modifier = Modifier.padding(padding),
+ contentPadding = PaddingValues(
+ bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+ )
) {
item {
PreferenceSwitchWithContainer(
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/advanced/AdvancedView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/advanced/AdvancedView.kt
index 9b1c58d..8ee402f 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/settings/advanced/AdvancedView.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/advanced/AdvancedView.kt
@@ -1,12 +1,27 @@
package org.xtimms.tokusho.sections.settings.advanced
import android.os.Build
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Print
+import androidx.compose.material.icons.outlined.PrintDisabled
+import androidx.compose.material.icons.outlined.Report
+import androidx.compose.material.icons.outlined.ReportOff
+import androidx.compose.material.icons.outlined.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@@ -16,10 +31,17 @@ import org.xtimms.tokusho.BuildConfig
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.PreferenceItem
import org.xtimms.tokusho.core.components.PreferenceSubtitle
+import org.xtimms.tokusho.core.components.PreferenceSwitch
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
+import org.xtimms.tokusho.core.logs.FileLogger
+import org.xtimms.tokusho.core.prefs.ACRA
+import org.xtimms.tokusho.core.prefs.AppSettings
+import org.xtimms.tokusho.core.prefs.LOGGING
import org.xtimms.tokusho.utils.DeviceUtil
+import org.xtimms.tokusho.utils.ShareHelper
import org.xtimms.tokusho.utils.WebViewUtil
import org.xtimms.tokusho.utils.lang.toDateTimestampString
+import org.xtimms.tokusho.utils.system.toast
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Locale
@@ -29,17 +51,65 @@ const val ADVANCED_DESTINATION = "advanced"
@Composable
fun AdvancedView(
+ loggers: Set,
navigateBack: () -> Unit,
) {
+ val context = LocalContext.current
+
+ var isAcraEnabled by remember {
+ mutableStateOf(AppSettings.isACRAEnabled())
+ }
+
+ var isLoggingEnabled by remember {
+ mutableStateOf(AppSettings.isLoggingEnabled())
+ }
+
ScaffoldWithTopAppBar(
title = stringResource(R.string.advanced),
navigateBack = navigateBack
) { padding ->
LazyColumn(
- modifier = Modifier
- .padding(padding)
+ modifier = Modifier.padding(padding),
+ contentPadding = PaddingValues(
+ bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+ )
) {
+ item {
+ PreferenceSwitch(
+ title = stringResource(id = R.string.send_crash_reports),
+ description = stringResource(id = R.string.send_crash_reports_desc),
+ icon = if (isAcraEnabled) Icons.Outlined.Report else Icons.Outlined.ReportOff,
+ isChecked = isAcraEnabled,
+ onClick = {
+ isAcraEnabled = !isAcraEnabled
+ AppSettings.updateValue(ACRA, isAcraEnabled)
+ context.toast(R.string.restart_required)
+ }
+ )
+ }
+ item {
+ PreferenceSwitch(
+ title = stringResource(id = R.string.enable_logging),
+ description = stringResource(id = R.string.enable_logging_desc),
+ icon = if (isLoggingEnabled) Icons.Outlined.Print else Icons.Outlined.PrintDisabled,
+ isChecked = isLoggingEnabled,
+ onClick = {
+ isLoggingEnabled = !isLoggingEnabled
+ AppSettings.updateValue(LOGGING, isLoggingEnabled)
+ }
+ )
+ }
+ item {
+ PreferenceItem(
+ title = stringResource(id = R.string.share_logs),
+ icon = Icons.Outlined.Share,
+ enabled = isLoggingEnabled,
+ onClick = {
+ ShareHelper(context).shareLogs(loggers)
+ }
+ )
+ }
item {
PreferenceSubtitle(text = stringResource(id = R.string.app_info))
}
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/AppearanceView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/AppearanceView.kt
index 3f53523..7127008 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/AppearanceView.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/AppearanceView.kt
@@ -26,6 +26,7 @@ import androidx.compose.material.icons.outlined.ColorLens
import androidx.compose.material.icons.outlined.DarkMode
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.LightMode
+import androidx.compose.material.icons.outlined.Timelapse
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -35,6 +36,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -53,12 +55,14 @@ import org.xtimms.tokusho.LocalPaletteStyleIndex
import org.xtimms.tokusho.LocalSeedColor
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.PreferenceItem
+import org.xtimms.tokusho.core.components.PreferenceSubtitle
import org.xtimms.tokusho.core.components.PreferenceSwitch
import org.xtimms.tokusho.core.components.PreferenceSwitchWithDivider
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
import org.xtimms.tokusho.core.prefs.AppSettings
import org.xtimms.tokusho.core.prefs.DarkThemePreference.Companion.OFF
import org.xtimms.tokusho.core.prefs.DarkThemePreference.Companion.ON
+import org.xtimms.tokusho.core.prefs.READING_TIME
import org.xtimms.tokusho.core.prefs.STYLE_MONOCHROME
import org.xtimms.tokusho.core.prefs.STYLE_TONAL_SPOT
import org.xtimms.tokusho.core.prefs.paletteStyles
@@ -92,6 +96,10 @@ fun AppearanceView(
)
}
+ var isReadingTimeEstimationEnabled by remember {
+ mutableStateOf(AppSettings.isReadingTimeEstimationEnabled())
+ }
+
ScaffoldWithTopAppBar(
title = stringResource(R.string.appearance),
navigateBack = navigateBack
@@ -99,7 +107,7 @@ fun AppearanceView(
Column(
Modifier
.padding(padding)
- .verticalScroll(rememberScrollState())
+ .verticalScroll(rememberScrollState()),
) {
MangaCard(
modifier = Modifier.padding(18.dp),
@@ -180,6 +188,16 @@ fun AppearanceView(
icon = Icons.Outlined.Language,
description = getLanguageDesc(),
onClick = { navigateToLanguages() })
+ PreferenceSubtitle(text = stringResource(id = R.string.details))
+ PreferenceSwitch(
+ title = stringResource(id = R.string.show_estimated_read_time),
+ description = stringResource(id = R.string.show_estimated_read_time_desc),
+ icon = Icons.Outlined.Timelapse,
+ isChecked = isReadingTimeEstimationEnabled,
+ onClick = {
+ isReadingTimeEstimationEnabled = !isReadingTimeEstimationEnabled
+ AppSettings.updateValue(READING_TIME, isReadingTimeEstimationEnabled)
+ })
}
}
}
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/DarkThemeView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/DarkThemeView.kt
index b02292b..3936de3 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/DarkThemeView.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/DarkThemeView.kt
@@ -1,6 +1,10 @@
package org.xtimms.tokusho.sections.settings.appearance
import android.os.Build
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
@@ -34,7 +38,11 @@ fun DarkThemeView(
navigateBack = navigateBack
) { padding ->
LazyColumn(
- modifier = Modifier.padding(padding)) {
+ modifier = Modifier.padding(padding),
+ contentPadding = PaddingValues(
+ bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+ )
+ ) {
if (Build.VERSION.SDK_INT >= 29)
item {
PreferenceSingleChoiceItem(
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/LanguagesView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/LanguagesView.kt
index b971360..15bcd51 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/LanguagesView.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/LanguagesView.kt
@@ -8,7 +8,10 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
@@ -108,8 +111,10 @@ private fun LanguageViewImpl(
navigateBack = navigateBack
) { padding ->
LazyColumn(
- modifier = Modifier
- .padding(padding)
+ modifier = Modifier.padding(padding),
+ contentPadding = PaddingValues(
+ bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+ )
) {
item {
PreferencesHintCard(
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/CleanDialog.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/CleanDialog.kt
index d812e34..d918e23 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/CleanDialog.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/CleanDialog.kt
@@ -96,7 +96,12 @@ fun CleanDialog(
HorizontalDivider(modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp))
Spacer(modifier = Modifier.height(4.dp))
val summary = StringBuilder().run {
- append(FileSize.BYTES.format(LocalContext.current, uiState.pagesCache + uiState.thumbnailsCache + uiState.httpCacheSize))
+ append(
+ FileSize.BYTES.format(
+ LocalContext.current,
+ (uiState.pagesCache + uiState.thumbnailsCache + uiState.httpCacheSize).toFloat()
+ )
+ )
append("")
}
Text(
@@ -104,7 +109,6 @@ fun CleanDialog(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
-// style = MaterialTheme.typography.labelMedium,
)
}
})
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageEvent.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageEvent.kt
new file mode 100644
index 0000000..f3d0235
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageEvent.kt
@@ -0,0 +1,5 @@
+package org.xtimms.tokusho.sections.settings.storage
+
+import org.xtimms.tokusho.core.base.event.UiEvent
+
+interface StorageEvent : UiEvent
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageView.kt
index bca7864..1ff8224 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageView.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageView.kt
@@ -1,7 +1,9 @@
package org.xtimms.tokusho.sections.settings.storage
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
@@ -10,13 +12,11 @@ import androidx.compose.material.icons.outlined.CleaningServices
import androidx.compose.material.icons.outlined.Image
import androidx.compose.material.icons.outlined.NetworkWifi
import androidx.compose.material.icons.outlined.SdStorage
-import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
@@ -27,6 +27,7 @@ import org.xtimms.tokusho.core.components.PreferenceStorageHeader
import org.xtimms.tokusho.core.components.PreferenceStorageItem
import org.xtimms.tokusho.core.components.PreferencesHintCard
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
+import org.xtimms.tokusho.data.CACHE_SIZE_MAX
const val STORAGE_DESTINATION = "storage"
@@ -44,14 +45,16 @@ fun StorageView(
title = stringResource(R.string.storage),
navigateBack = navigateBack
) { padding ->
- if (!uiState.isLoading) LazyColumn(
- modifier = Modifier
- .padding(padding)
+ LazyColumn(
+ modifier = Modifier.padding(padding),
+ contentPadding = PaddingValues(
+ bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+ )
) {
item {
PreferenceStorageHeader(
- used = uiState.httpCacheSize + uiState.thumbnailsCache + uiState.pagesCache,
- total = uiState.availableSpace
+ used = (uiState.httpCacheSize + uiState.thumbnailsCache + uiState.pagesCache).toFloat(),
+ total = uiState.availableSpace.toFloat()
)
}
item {
@@ -65,37 +68,35 @@ fun StorageView(
}
item {
PreferenceStorageItem(
- total = uiState.availableSpace,
+ total = uiState.availableSpace.toFloat(),
title = stringResource(id = R.string.saved_manga),
icon = Icons.Outlined.SdStorage
)
}
item {
PreferenceStorageItem(
- total = uiState.availableSpace,
+ total = uiState.availableSpace.toFloat(),
title = stringResource(id = R.string.pages_cache),
icon = Icons.Outlined.AutoStories,
- used = uiState.pagesCache
+ used = uiState.pagesCache.toFloat()
)
}
item {
PreferenceStorageItem(
- total = uiState.availableSpace,
+ total = uiState.availableSpace.toFloat(),
title = stringResource(id = R.string.thumbnails_cache),
icon = Icons.Outlined.Image,
- used = uiState.thumbnailsCache
+ used = uiState.thumbnailsCache.toFloat()
)
}
item {
PreferenceStorageItem(
- total = uiState.availableSpace,
+ total = CACHE_SIZE_MAX.toFloat(),
title = stringResource(id = R.string.network_cache),
icon = Icons.Outlined.NetworkWifi,
- used = uiState.httpCacheSize
+ used = uiState.httpCacheSize.toFloat()
)
}
- } else Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- CircularProgressIndicator()
}
}
if (showCleanDialog) {
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageViewModel.kt
index 146d2e7..ec01b23 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageViewModel.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageViewModel.kt
@@ -3,7 +3,6 @@ package org.xtimms.tokusho.sections.settings.storage
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
-import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runInterruptible
@@ -11,32 +10,29 @@ import okhttp3.Cache
import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel
import org.xtimms.tokusho.core.cache.CacheDir
import org.xtimms.tokusho.data.LocalStorageManager
-import java.util.EnumMap
import javax.inject.Inject
@HiltViewModel
class StorageViewModel @Inject constructor(
private val storageManager: LocalStorageManager,
private val httpCache: Cache,
-) : BaseViewModel() {
-
- val httpCacheSize = MutableStateFlow(-1L)
- val cacheSizes = EnumMap>(CacheDir::class.java)
+) : BaseViewModel(), StorageEvent {
private var storageUsageJob: Job? = null
init {
- val prevJob = storageUsageJob
storageUsageJob = launchJob(Dispatchers.Default) {
- prevJob?.cancelAndJoin()
+ setLoading(true)
mutableUiState.update {
it.copy(
availableSpace = storageManager.computeAvailableSize(),
pagesCache = storageManager.computeCacheSize(CacheDir.PAGES),
thumbnailsCache = storageManager.computeCacheSize(CacheDir.THUMBS),
- httpCacheSize = runInterruptible { httpCache.size() }
+ httpCacheSize = runInterruptible { httpCache.size() },
+ isLoading = false
)
}
+ setLoading(false)
}
}
@@ -51,7 +47,7 @@ class StorageViewModel @Inject constructor(
pagesCache = storageManager.computeCacheSize(CacheDir.PAGES),
thumbnailsCache = storageManager.computeCacheSize(CacheDir.THUMBS),
httpCacheSize = runInterruptible { httpCache.size() },
- isLoading = false
+ isLoading = false,
)
}
} catch (_: Exception) {
diff --git a/app/src/main/java/org/xtimms/tokusho/utils/FileSize.kt b/app/src/main/java/org/xtimms/tokusho/utils/FileSize.kt
index 580a7cd..238e9b9 100644
--- a/app/src/main/java/org/xtimms/tokusho/utils/FileSize.kt
+++ b/app/src/main/java/org/xtimms/tokusho/utils/FileSize.kt
@@ -12,7 +12,7 @@ enum class FileSize(private val multiplier: Int) {
fun convert(amount: Long, target: FileSize): Long = amount * multiplier / target.multiplier
- fun freeFormat(context: Context, amount: Long): String {
+ fun freeFormat(context: Context, amount: Float): String {
val bytes = amount * multiplier
val units = context.getString(R.string.text_file_sizes_free).split('|')
if (bytes <= 0) {
@@ -33,7 +33,7 @@ enum class FileSize(private val multiplier: Int) {
}
}
- fun totalFormat(context: Context, amount: Long): String {
+ fun totalFormat(context: Context, amount: Float): String {
val bytes = amount * multiplier
val units = context.getString(R.string.text_file_sizes_total).split('|')
if (bytes <= 0) {
@@ -54,7 +54,7 @@ enum class FileSize(private val multiplier: Int) {
}
}
- fun showUnit(context: Context, amount: Long): String {
+ fun showUnit(context: Context, amount: Float): String {
val bytes = amount * multiplier
val units = context.getString(R.string.text_file_sizes_used).split('|')
if (bytes <= 0) {
@@ -70,7 +70,7 @@ enum class FileSize(private val multiplier: Int) {
}
}
- fun format(context: Context, amount: Long): String {
+ fun format(context: Context, amount: Float): String {
val bytes = amount * multiplier
val units = context.getString(R.string.text_file_sizes).split('|')
if (bytes <= 0) {
@@ -91,7 +91,7 @@ enum class FileSize(private val multiplier: Int) {
}
}
- fun formatWithoutUnits(amount: Long): String {
+ fun formatWithoutUnits(amount: Float): String {
val bytes = amount * multiplier
if (bytes <= 0) {
return "0"
diff --git a/app/src/main/java/org/xtimms/tokusho/utils/ShareHelper.kt b/app/src/main/java/org/xtimms/tokusho/utils/ShareHelper.kt
new file mode 100644
index 0000000..4de873b
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/utils/ShareHelper.kt
@@ -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) {
+ 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()
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/File.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/File.kt
index 4f7d172..ef662af 100644
--- a/app/src/main/java/org/xtimms/tokusho/utils/system/File.kt
+++ b/app/src/main/java/org/xtimms/tokusho/utils/system/File.kt
@@ -10,6 +10,10 @@ import java.io.File
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.walk
+fun File.subdir(name: String) = File(this, name).also {
+ if (!it.exists()) it.mkdirs()
+}
+
fun File.getUriCompat(context: Context): Uri {
return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", this)
}
diff --git a/app/src/main/res/values/plurals.xml b/app/src/main/res/values/plurals.xml
new file mode 100644
index 0000000..4c62d31
--- /dev/null
+++ b/app/src/main/res/values/plurals.xml
@@ -0,0 +1,11 @@
+
+
+
+ - %1$d hour
+ - %1$d hours
+
+
+ - %1$d minute
+ - %1$d minutes
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 48d3407..9a7c8dc 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -99,4 +99,17 @@
Helps with background updates checks
Recommended action
% used
+ Calculating...
+ Send crash reports
+ Helps fix any bugs. No sensitive data will be sent
+ To apply the setting, you need to restart the application
+ Enable logging
+ Record some actions for debug purposes. Don\'t turn it on if you\'re not sure what you\'re doing
+ Share logs
+ %1$s %2$s
+ Details
+ Show estimated reading time
+ The time estimation value may be inaccurate
+ Approximate remaining time
+ Approximate reading time
\ No newline at end of file