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