From 9e269a9e0397f6c2b1d685a8f2008313c5470154 Mon Sep 17 00:00:00 2001 From: Zakhar Timoshenko Date: Mon, 25 Mar 2024 14:57:08 +0300 Subject: [PATCH] Suggestions --- app/build.gradle.kts | 2 +- app/src/main/AndroidManifest.xml | 14 + app/src/main/java/org/xtimms/tokusho/App.kt | 23 +- .../java/org/xtimms/tokusho/MainActivity.kt | 11 - .../java/org/xtimms/tokusho/TokushoModule.kt | 6 + .../org/xtimms/tokusho/core/Navigation.kt | 38 +- .../tokusho/core/components/MangaGridItem.kt | 18 +- .../tokusho/core/components/PreferenceItem.kt | 4 +- .../tokusho/core/database/TokushoDatabase.kt | 7 +- .../core/database/dao/SuggestionDao.kt | 49 +++ .../core/database/entity/SuggestionEntity.kt | 26 ++ .../database/entity/SuggestionWithManga.kt | 20 + .../tokusho/core/model/MangaSuggestion.kt | 10 + .../tokusho/core/model/TagsBlacklist.kt | 31 ++ .../xtimms/tokusho/core/prefs/AppSettings.kt | 4 +- .../tokusho/core/prefs/SourceSettings.kt | 2 + .../data/repository/SuggestionRepository.kt | 63 +++ .../tokusho/sections/details/DetailsView.kt | 24 +- .../tokusho/sections/explore/ExploreView.kt | 47 ++- .../sections/explore/ExploreViewModel.kt | 31 +- .../tokusho/sections/list/MangaListView.kt | 9 +- .../suggestions/SuggestionMangaModel.kt | 13 + .../sections/suggestions/SuggestionsView.kt | 116 ++++++ .../suggestions/SuggestionsViewModel.kt | 38 ++ .../org/xtimms/tokusho/utils/lang/Coil.kt | 16 + .../xtimms/tokusho/utils/lang/Collections.kt | 16 + .../org/xtimms/tokusho/utils/lang/Flow.kt | 9 + .../xtimms/tokusho/utils/system/Android.kt | 73 ++++ .../tokusho/work/WorkScheduleManager.kt | 30 ++ .../work/suggestions/SuggestionsWorker.kt | 373 ++++++++++++++++++ .../res/drawable-hdpi/ic_stat_suggestion.png | Bin 0 -> 1007 bytes .../res/drawable-mdpi/ic_stat_suggestion.png | Bin 0 -> 667 bytes .../res/drawable-xhdpi/ic_stat_suggestion.png | Bin 0 -> 1531 bytes .../drawable-xxhdpi/ic_stat_suggestion.png | Bin 0 -> 2101 bytes .../drawable-xxxhdpi/ic_stat_suggestion.png | Bin 0 -> 3270 bytes app/src/main/res/values/strings.xml | 15 + 36 files changed, 1055 insertions(+), 83 deletions(-) create mode 100644 app/src/main/java/org/xtimms/tokusho/core/database/dao/SuggestionDao.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/database/entity/SuggestionEntity.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/database/entity/SuggestionWithManga.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/model/MangaSuggestion.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/model/TagsBlacklist.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/data/repository/SuggestionRepository.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/suggestions/SuggestionMangaModel.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/suggestions/SuggestionsView.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/suggestions/SuggestionsViewModel.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/lang/Coil.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/system/Android.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/work/WorkScheduleManager.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/work/suggestions/SuggestionsWorker.kt create mode 100644 app/src/main/res/drawable-hdpi/ic_stat_suggestion.png create mode 100644 app/src/main/res/drawable-mdpi/ic_stat_suggestion.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_stat_suggestion.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_stat_suggestion.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_stat_suggestion.png diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4e4f3c3..120ebed 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -118,7 +118,7 @@ dependencies { kapt("com.google.dagger:hilt-compiler:2.50") implementation("androidx.hilt:hilt-work:1.2.0") kapt("androidx.hilt:hilt-compiler:1.2.0") - implementation("com.github.KotatsuApp:kotatsu-parsers:3ff028c4e9") { + implementation("com.github.KotatsuApp:kotatsu-parsers:fec60955ed") { exclude(group = "org.json", module = "json") } implementation("com.mikepenz:aboutlibraries-compose-m3:10.10.0") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4c8c5f6..f86e5fc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,9 @@ + + + @@ -68,6 +71,17 @@ android:resource="@xml/provider_paths" /> + + + + diff --git a/app/src/main/java/org/xtimms/tokusho/App.kt b/app/src/main/java/org/xtimms/tokusho/App.kt index a53cc62..dcac073 100644 --- a/app/src/main/java/org/xtimms/tokusho/App.kt +++ b/app/src/main/java/org/xtimms/tokusho/App.kt @@ -6,8 +6,9 @@ import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.os.Build import android.os.StrictMode -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.ProcessLifecycleOwner +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration +import androidx.work.WorkManager import com.google.android.material.color.DynamicColors import com.tencent.mmkv.MMKV import dagger.hilt.android.HiltAndroidApp @@ -24,11 +25,12 @@ import org.xtimms.tokusho.core.prefs.AppSettings import org.xtimms.tokusho.core.prefs.KotatsuAppSettings import org.xtimms.tokusho.core.updates.Updater import org.xtimms.tokusho.utils.lang.processLifecycleScope +import org.xtimms.tokusho.work.WorkScheduleManager import javax.inject.Inject import javax.inject.Provider @HiltAndroidApp -class App : Application() { +class App : Application(), Configuration.Provider { @Inject lateinit var database: Provider @@ -36,6 +38,20 @@ class App : Application() { @Inject lateinit var settings: KotatsuAppSettings + @Inject + lateinit var workerFactory: HiltWorkerFactory + + @Inject + lateinit var workScheduleManager: WorkScheduleManager + + @Inject + lateinit var workManagerProvider: Provider + + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() + override fun onCreate() { super.onCreate() MMKV.initialize(this) @@ -78,6 +94,7 @@ class App : Application() { ) } } + workScheduleManager.init() } 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 d3e6b9d..ebcecd4 100644 --- a/app/src/main/java/org/xtimms/tokusho/MainActivity.kt +++ b/app/src/main/java/org/xtimms/tokusho/MainActivity.kt @@ -121,17 +121,6 @@ class MainActivity : ComponentActivity() { companion object { private const val TAG = "MainActivity" const val EXTRA_DATA = "data" - - fun setLanguage(locale: String) { - Log.d(TAG, "setLanguage: $locale") - val localeListCompat = - if (locale.isEmpty()) LocaleListCompat.getEmptyLocaleList() - else LocaleListCompat.forLanguageTags(locale) - processLifecycleScope.launch(Dispatchers.Main) { - AppCompatDelegate.setApplicationLocales(localeListCompat) - } - } - } } diff --git a/app/src/main/java/org/xtimms/tokusho/TokushoModule.kt b/app/src/main/java/org/xtimms/tokusho/TokushoModule.kt index 43feba9..23aaec6 100644 --- a/app/src/main/java/org/xtimms/tokusho/TokushoModule.kt +++ b/app/src/main/java/org/xtimms/tokusho/TokushoModule.kt @@ -3,6 +3,7 @@ package org.xtimms.tokusho import android.app.Application import android.content.Context import android.text.Html +import androidx.work.WorkManager import coil.ComponentRegistry import coil.ImageLoader import coil.disk.DiskCache @@ -113,6 +114,11 @@ interface TokushoModule { fun provideLocalStorageChangesFlow( @LocalStorageChanges flow: MutableSharedFlow, ): SharedFlow = flow.asSharedFlow() + + @Provides + fun provideWorkManager( + @ApplicationContext context: Context, + ): WorkManager = WorkManager.getInstance(context) } } \ No newline at end of file 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 23fba18..ea98fe2 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt @@ -77,6 +77,8 @@ import org.xtimms.tokusho.sections.settings.storage.StorageView import org.xtimms.tokusho.sections.shelf.ShelfView import org.xtimms.tokusho.sections.stats.STATS_DESTINATION import org.xtimms.tokusho.sections.stats.StatsView +import org.xtimms.tokusho.sections.suggestions.SUGGESTIONS_DESTINATION +import org.xtimms.tokusho.sections.suggestions.SuggestionsView import org.xtimms.tokusho.utils.StringArrayNavType import org.xtimms.tokusho.utils.lang.removeFirstAndLast @@ -102,6 +104,12 @@ fun Navigation( val navigateBack: () -> Unit = { navController.popBackStack() } + val navigateToDetails: (Long) -> Unit = { + navController.navigate( + DETAILS_DESTINATION.replace(MANGA_ID_ARGUMENT, it.toString()) + ) + } + val navigateToLicense: (String, String?, String?) -> Unit = { name, website, content -> navController.navigate( LICENSE_DESTINATION @@ -142,11 +150,7 @@ fun Navigation( showPageTabs = true, padding = padding, topBarHeightPx = topBarHeightPx, - navigateToDetails = { - navController.navigate( - DETAILS_DESTINATION.replace(MANGA_ID_ARGUMENT, it.toString()) - ) - }, + navigateToDetails = navigateToDetails, onRefresh = { true } ) } @@ -161,11 +165,13 @@ fun Navigation( composable(BottomNavDestination.Explore.route) { ExploreView( coil = coil, + navigateToDetails = navigateToDetails, navigateToSource = { navController.navigate( LIST_DESTINATION.replace(PROVIDER_ARGUMENT, it.name) ) }, + navigateToSuggestions = { navController.navigate(SUGGESTIONS_DESTINATION) }, padding = padding, topBarHeightPx = topBarHeightPx, listState = listState @@ -187,6 +193,14 @@ fun Navigation( ) } + composable(SUGGESTIONS_DESTINATION) { + SuggestionsView( + coil = coil, + navigateBack = navigateBack, + navigateToDetails = navigateToDetails + ) + } + composable(SETTINGS_DESTINATION) { SettingsView( navigateBack = navigateBack, @@ -314,13 +328,7 @@ fun Navigation( source = navEntry.arguments?.getString(PROVIDER_ARGUMENT.removeFirstAndLast()) ?.let { source -> MangaSource.valueOf(source) } ?: MangaSource.DUMMY, navigateBack = navigateBack, - navigateToDetails = { - navController.navigate( - DETAILS_DESTINATION.replace( - MANGA_ID_ARGUMENT, it.toString() - ) - ) - } + navigateToDetails = navigateToDetails ) } @@ -387,11 +395,7 @@ fun Navigation( FULL_POSTER_DESTINATION.replace(PICTURES_ARGUMENT, pictures) ) }, - navigateToDetails = { - navController.navigate( - DETAILS_DESTINATION.replace(MANGA_ID_ARGUMENT, it.toString()) - ) - }, + navigateToDetails = navigateToDetails, navigateToSource = { navController.navigate( LIST_DESTINATION.replace(PROVIDER_ARGUMENT, it.name) diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/MangaGridItem.kt b/app/src/main/java/org/xtimms/tokusho/core/components/MangaGridItem.kt index 377207c..db84f61 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/components/MangaGridItem.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/components/MangaGridItem.kt @@ -13,8 +13,6 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.LocalContentColor @@ -29,16 +27,13 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shadow -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.ImageLoader import org.koitharu.kotatsu.parsers.model.Manga import org.xtimms.tokusho.core.AsyncImageImpl -import org.xtimms.tokusho.ui.theme.TokushoTheme private const val GridSelectedCoverAlpha = 0.76f @@ -46,11 +41,12 @@ private const val GridSelectedCoverAlpha = 0.76f fun MangaGridItem( coil: ImageLoader, manga: Manga, - onClick: () -> Unit, + onClick: (Manga) -> Unit, onLongClick: () -> Unit, isSelected: Boolean = false, ) { GridItemSelectable( + manga = manga, isSelected = isSelected, onClick = onClick, onLongClick = onLongClick, @@ -87,13 +83,14 @@ fun MangaGridItem( fun MangaHorizontalItem( coil: ImageLoader, manga: Manga, - onClick: () -> Unit, + onClick: (Manga) -> Unit, onLongClick: () -> Unit, isSelected: Boolean = false, ) { GridItemSelectable( + manga = manga, isSelected = isSelected, - onClick = onClick, + onClick = { onClick(manga) }, onLongClick = onLongClick, modifier = Modifier.width(IntrinsicSize.Min) ) { @@ -211,8 +208,9 @@ private fun GridItemTitle( @OptIn(ExperimentalFoundationApi::class) @Composable private fun GridItemSelectable( + manga: Manga, isSelected: Boolean, - onClick: () -> Unit, + onClick: (Manga) -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier, content: @Composable () -> Unit, @@ -221,7 +219,7 @@ private fun GridItemSelectable( modifier = modifier .clip(MaterialTheme.shapes.small) .combinedClickable( - onClick = onClick, + onClick = { onClick(manga) }, onLongClick = onLongClick, ) .selectedOutline(isSelected = isSelected, color = MaterialTheme.colorScheme.secondary) 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 e99cc19..b3f1b92 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 @@ -603,7 +603,7 @@ fun PreferenceStorageHeader( modifier = Modifier .weight(1f) .align(Alignment.Bottom) - .padding(PaddingValues(start = 4.dp, bottom = 8.dp)), + .padding(PaddingValues(start = 4.dp, bottom = 12.dp)), label = "Unit" ) { targetUsed -> Text(text = FileSize.BYTES.showUnit(LocalContext.current, targetUsed)) @@ -619,7 +619,7 @@ fun PreferenceStorageHeader( }, modifier = Modifier .align(Alignment.Bottom) - .padding(PaddingValues(bottom = 8.dp)), + .padding(PaddingValues(bottom = 12.dp)), label = "Total used" ) { targetTotal -> Text( 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 b811731..cd69353 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 @@ -16,6 +16,7 @@ import org.xtimms.tokusho.core.database.dao.FavouritesDao 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.dao.SuggestionDao import org.xtimms.tokusho.core.database.dao.TagsDao import org.xtimms.tokusho.core.database.entity.BookmarkEntity import org.xtimms.tokusho.core.database.entity.FavouriteCategoryEntity @@ -24,6 +25,7 @@ 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.SuggestionEntity import org.xtimms.tokusho.core.database.entity.TagEntity import org.xtimms.tokusho.utils.lang.processLifecycleScope @@ -38,7 +40,8 @@ const val DATABASE_VERSION = 1 HistoryEntity::class, FavouriteEntity::class, FavouriteCategoryEntity::class, - BookmarkEntity::class + BookmarkEntity::class, + SuggestionEntity::class ], version = DATABASE_VERSION ) @@ -58,6 +61,8 @@ abstract class TokushoDatabase : RoomDatabase() { abstract fun getBookmarksDao(): BookmarksDao + abstract fun getSuggestionDao(): SuggestionDao + } fun TokushoDatabase(context: Context): TokushoDatabase = Room diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/dao/SuggestionDao.kt b/app/src/main/java/org/xtimms/tokusho/core/database/dao/SuggestionDao.kt new file mode 100644 index 0000000..0c5b711 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/database/dao/SuggestionDao.kt @@ -0,0 +1,49 @@ +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.Transaction +import androidx.room.Update +import kotlinx.coroutines.flow.Flow +import org.xtimms.tokusho.core.database.entity.SuggestionEntity +import org.xtimms.tokusho.core.database.entity.SuggestionWithManga + +@Dao +abstract class SuggestionDao { + + @Transaction + @Query("SELECT * FROM suggestions ORDER BY relevance DESC") + abstract fun observeAll(): Flow> + + @Transaction + @Query("SELECT * FROM suggestions ORDER BY relevance DESC LIMIT :limit") + abstract fun observeAll(limit: Int): Flow> + + @Transaction + @Query("SELECT * FROM suggestions ORDER BY RANDOM() LIMIT 1") + abstract suspend fun getRandom(): SuggestionWithManga? + + @Query("SELECT COUNT(*) FROM suggestions") + abstract suspend fun count(): Int + + @Query("SELECT manga.title FROM suggestions LEFT JOIN manga ON suggestions.manga_id = manga.manga_id WHERE manga.title LIKE :query") + abstract suspend fun getTitles(query: String): List + + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract suspend fun insert(entity: SuggestionEntity): Long + + @Update + abstract suspend fun update(entity: SuggestionEntity): Int + + @Query("DELETE FROM suggestions") + abstract suspend fun deleteAll() + + @Transaction + open suspend fun upsert(entity: SuggestionEntity) { + if (update(entity) == 0) { + insert(entity) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/entity/SuggestionEntity.kt b/app/src/main/java/org/xtimms/tokusho/core/database/entity/SuggestionEntity.kt new file mode 100644 index 0000000..d36c538 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/database/entity/SuggestionEntity.kt @@ -0,0 +1,26 @@ +package org.xtimms.tokusho.core.database.entity + +import androidx.annotation.FloatRange +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey + +@Entity( + tableName = "suggestions", + foreignKeys = [ + ForeignKey( + entity = MangaEntity::class, + parentColumns = ["manga_id"], + childColumns = ["manga_id"], + onDelete = ForeignKey.CASCADE + ) + ] +) +class SuggestionEntity( + @PrimaryKey(autoGenerate = false) + @ColumnInfo(name = "manga_id", index = true) val mangaId: Long, + @FloatRange(from = 0.0, to = 1.0) + @ColumnInfo(name = "relevance") val relevance: Float, + @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(), +) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/entity/SuggestionWithManga.kt b/app/src/main/java/org/xtimms/tokusho/core/database/entity/SuggestionWithManga.kt new file mode 100644 index 0000000..972733f --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/database/entity/SuggestionWithManga.kt @@ -0,0 +1,20 @@ +package org.xtimms.tokusho.core.database.entity + +import androidx.room.Embedded +import androidx.room.Junction +import androidx.room.Relation + +data class SuggestionWithManga( + @Embedded val suggestion: SuggestionEntity, + @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/model/MangaSuggestion.kt b/app/src/main/java/org/xtimms/tokusho/core/model/MangaSuggestion.kt new file mode 100644 index 0000000..fa0fb56 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/model/MangaSuggestion.kt @@ -0,0 +1,10 @@ +package org.xtimms.tokusho.core.model + +import androidx.annotation.FloatRange +import org.koitharu.kotatsu.parsers.model.Manga + +data class MangaSuggestion( + val manga: Manga, + @FloatRange(from = 0.0, to = 1.0) + val relevance: Float, +) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/model/TagsBlacklist.kt b/app/src/main/java/org/xtimms/tokusho/core/model/TagsBlacklist.kt new file mode 100644 index 0000000..222566e --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/model/TagsBlacklist.kt @@ -0,0 +1,31 @@ +package org.xtimms.tokusho.core.model + +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.util.almostEquals + +class TagsBlacklist( + private val tags: Set, + private val threshold: Float, +) { + + fun isNotEmpty() = tags.isNotEmpty() + + operator fun contains(manga: Manga): Boolean { + if (tags.isEmpty()) { + return false + } + for (mangaTag in manga.tags) { + for (tagTitle in tags) { + if (mangaTag.title.almostEquals(tagTitle, threshold)) { + return true + } + } + } + return false + } + + operator fun contains(tag: MangaTag): Boolean = tags.any { + it.almostEquals(tag.title, threshold) + } +} \ No newline at end of file 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 78f2cc2..7668057 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 @@ -39,6 +39,7 @@ const val LOGGING = "logging" const val SSL_BYPASS = "ssl_bypass" const val NSFW = "nsfw" const val TABS_MANGA_COUNT = "tabs_manga_count" +const val SUGGESTIONS = "suggestions" val paletteStyles = listOf( PaletteStyle.TonalSpot, @@ -107,8 +108,7 @@ object AppSettings { fun isMangaCountInTabsEnabled() = TABS_MANGA_COUNT.getBoolean(false) - fun getLanguageConfiguration(languageNumber: Int = kv.decodeInt(LANGUAGE)) = - languageMap.getOrElse(languageNumber) { "" } + fun isSuggestionsEnabled() = SUGGESTIONS.getBoolean(true) private fun getLanguageNumberByCode(languageCode: String): Int = diff --git a/app/src/main/java/org/xtimms/tokusho/core/prefs/SourceSettings.kt b/app/src/main/java/org/xtimms/tokusho/core/prefs/SourceSettings.kt index 1822c34..bf53b1f 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/prefs/SourceSettings.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/prefs/SourceSettings.kt @@ -30,6 +30,7 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig is ConfigKey.UserAgent -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue } is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue } is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue) + is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue) } as T } @@ -38,6 +39,7 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig is ConfigKey.Domain -> putString(key.key, value as String?) is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean) is ConfigKey.UserAgent -> putString(key.key, value as String?) + is ConfigKey.SplitByTranslations -> putBoolean(key.key, value as Boolean) } } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/SuggestionRepository.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/SuggestionRepository.kt new file mode 100644 index 0000000..e0656b4 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/data/repository/SuggestionRepository.kt @@ -0,0 +1,63 @@ +package org.xtimms.tokusho.data.repository + +import androidx.room.withTransaction +import kotlinx.coroutines.flow.Flow +import org.koitharu.kotatsu.parsers.model.Manga +import org.xtimms.tokusho.core.database.TokushoDatabase +import org.xtimms.tokusho.core.database.entity.SuggestionEntity +import org.xtimms.tokusho.core.database.entity.toEntities +import org.xtimms.tokusho.core.database.entity.toEntity +import org.xtimms.tokusho.core.database.entity.toManga +import org.xtimms.tokusho.core.database.entity.toMangaTags +import org.xtimms.tokusho.core.model.MangaSuggestion +import org.xtimms.tokusho.utils.lang.mapItems +import javax.inject.Inject + +class SuggestionRepository @Inject constructor( + private val db: TokushoDatabase, +) { + + fun observeAll(): Flow> { + return db.getSuggestionDao().observeAll().mapItems { + it.manga.toManga(it.tags.toMangaTags()) + } + } + + fun observeAll(limit: Int): Flow> { + return db.getSuggestionDao().observeAll(limit).mapItems { + it.manga.toManga(it.tags.toMangaTags()) + } + } + + suspend fun getRandom(): Manga? { + return db.getSuggestionDao().getRandom()?.let { + it.manga.toManga(it.tags.toMangaTags()) + } + } + + suspend fun clear() { + db.getSuggestionDao().deleteAll() + } + + suspend fun isEmpty(): Boolean { + return db.getSuggestionDao().count() == 0 + } + + suspend fun replace(suggestions: Iterable) { + db.withTransaction { + db.getSuggestionDao().deleteAll() + suggestions.forEach { (manga, relevance) -> + val tags = manga.tags.toEntities() + db.getTagsDao().upsert(tags) + db.getMangaDao().upsert(manga.toEntity(), tags) + db.getSuggestionDao().upsert( + SuggestionEntity( + mangaId = manga.id, + relevance = relevance, + createdAt = System.currentTimeMillis(), + ), + ) + } + } + } +} \ No newline at end of file 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 89e84c2..e8c46df 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 @@ -83,6 +83,7 @@ fun DetailsView( val relatedManga by viewModel.relatedManga.collectAsStateWithLifecycle(emptyList()) val readingTime by viewModel.readingTime.collectAsStateWithLifecycle(null) val favouriteCategories by viewModel.favouriteCategories.collectAsStateWithLifecycle() + val details by viewModel.details.collectAsStateWithLifecycle(null) LaunchedEffect(mangaId) { if (viewModel.details.value == null) viewModel.doLoad(mangaId) @@ -140,29 +141,30 @@ fun DetailsView( bottom = contentPadding.calculateBottomPadding(), ), ) { + val manga = details?.toManga() item( key = DetailsViewItem.INFO_BOX, contentType = DetailsViewItem.INFO_BOX ) { DetailsInfoBox( coil = coil, - imageUrl = viewModel.details.value?.toManga()?.largeCoverUrl.orEmpty(), - favicon = viewModel.details.value?.toManga()?.source?.faviconUri() ?: Uri.EMPTY, - title = viewModel.details.value?.toManga()?.title.orEmpty(), - altTitle = viewModel.details.value?.toManga()?.altTitle.orEmpty(), - score = viewModel.details.value?.toManga()?.rating ?: RATING_UNKNOWN, - author = viewModel.details.value?.toManga()?.author.orEmpty(), + imageUrl = manga?.largeCoverUrl ?: manga?.coverUrl.orEmpty(), + favicon = manga?.source?.faviconUri() ?: Uri.EMPTY, + title = manga?.title.orEmpty(), + altTitle = manga?.altTitle.orEmpty(), + score = manga?.rating ?: RATING_UNKNOWN, + author = manga?.author.orEmpty(), artist = "", - isNsfw = viewModel.details.value?.toManga()?.isNsfw ?: true, - state = viewModel.details.value?.toManga()?.state ?: MangaState.FINISHED, - source = viewModel.details.value?.toManga()?.source ?: MangaSource.DUMMY, + isNsfw = manga?.isNsfw ?: true, + state = manga?.state ?: MangaState.FINISHED, + source = manga?.source ?: MangaSource.DUMMY, chapters = chapters.size.toString(), isTabletUi = false, appBarPadding = topPadding, onCoverClick = { navigateToFullImage( arrayOf( - viewModel.details.value?.toManga()?.largeCoverUrl.orEmpty() + manga?.largeCoverUrl ?: manga?.coverUrl.orEmpty(), ).toNavArgument() ) }, @@ -172,7 +174,7 @@ fun DetailsView( }, onSourceClicked = { navigateToSource( - viewModel.details.value?.toManga()?.source ?: MangaSource.DUMMY + manga?.source ?: MangaSource.DUMMY ) } ) 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 ca30fc7..0db1d8c 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 @@ -1,8 +1,9 @@ package org.xtimms.tokusho.sections.explore +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationVector1D -import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -24,7 +25,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Bookmarks import androidx.compose.material.icons.outlined.Download -import androidx.compose.material.icons.outlined.ExtensionOff import androidx.compose.material.icons.outlined.SdStorage import androidx.compose.material3.Button import androidx.compose.material3.Card @@ -34,6 +34,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -43,16 +44,16 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.ImageLoader import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.AsyncImageImpl import org.xtimms.tokusho.core.components.ExploreButton -import org.xtimms.tokusho.core.components.PreferencesHintCard import org.xtimms.tokusho.core.components.SourceItem import org.xtimms.tokusho.core.components.icons.Dice import org.xtimms.tokusho.ui.theme.TokushoTheme @@ -63,7 +64,9 @@ const val EXPLORE_DESTINATION = "explore" fun ExploreView( viewModel: ExploreViewModel = hiltViewModel(), coil: ImageLoader, + navigateToDetails: (Long) -> Unit, navigateToSource: (SourceItemModel) -> Unit, + navigateToSuggestions: () -> Unit, nestedScrollConnection: NestedScrollConnection? = null, topBarHeightPx: Float = 0f, topBarOffsetY: Animatable = Animatable(0f), @@ -75,6 +78,7 @@ fun ExploreView( val layoutDirection = LocalLayoutDirection.current val sources = viewModel.content.collectAsStateWithLifecycle(emptyList()) + val recommendation by viewModel.getSuggestionFlow().collectAsStateWithLifecycle(null) Box( modifier = Modifier @@ -137,7 +141,7 @@ fun ExploreView( text = stringResource(R.string.downloads), icon = Icons.Outlined.Download, modifier = Modifier.weight(1f), - onClick = { throw IllegalAccessException() }, + onClick = { }, ) } } @@ -148,7 +152,9 @@ fun ExploreView( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp) - .clip(MaterialTheme.shapes.extraLarge), + .clip(MaterialTheme.shapes.extraLarge) + .clickable { recommendation?.id?.let { navigateToDetails(it) } } + .animateContentSize(), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp) ) @@ -167,13 +173,14 @@ fun ExploreView( .padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { - Image( + AsyncImageImpl( + coil = coil, modifier = Modifier .size(40.dp) .clip(RoundedCornerShape(72.dp)) .aspectRatio(1f), contentScale = ContentScale.Crop, - painter = painterResource(id = R.drawable.ookami), + model = recommendation?.coverUrl, contentDescription = "" ) Column( @@ -182,20 +189,26 @@ fun ExploreView( .padding(horizontal = 16.dp), ) { Text( - text = "Text", + text = recommendation?.title ?: "", style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = "Text", - modifier = Modifier.padding(vertical = 2.dp), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurface, + overflow = TextOverflow.Ellipsis, + maxLines = 2 ) + recommendation?.tags?.joinToString(", ") { it.title }?.let { + Text( + text = it, + modifier = Modifier.padding(vertical = 2.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + } } } Button( - onClick = {}, + onClick = { navigateToSuggestions() }, modifier = Modifier.fillMaxWidth() ) { Text(text = "More") diff --git a/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreViewModel.kt index 429bec5..1b4b67d 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreViewModel.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreViewModel.kt @@ -4,28 +4,36 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus +import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.xtimms.tokusho.core.base.viewmodel.KotatsuBaseViewModel import org.xtimms.tokusho.core.parser.favicon.faviconUri +import org.xtimms.tokusho.core.prefs.AppSettings import org.xtimms.tokusho.data.repository.ExploreRepository import org.xtimms.tokusho.data.repository.MangaSourcesRepository +import org.xtimms.tokusho.data.repository.SuggestionRepository import org.xtimms.tokusho.utils.lang.mapItems import javax.inject.Inject @HiltViewModel class ExploreViewModel @Inject constructor( + private val suggestionRepository: SuggestionRepository, private val exploreRepository: ExploreRepository, private val mangaSourcesRepository: MangaSourcesRepository, ) : KotatsuBaseViewModel() { + private val isSuggestionsEnabled = MutableStateFlow(AppSettings.isSuggestionsEnabled()).asStateFlow() + private val sourcesStateFlow = mangaSourcesRepository.observeEnabledSources() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) @@ -37,17 +45,32 @@ class ExploreViewModel @Inject constructor( private fun createContentFlow() = combine( mangaSourcesRepository.observeEnabledSources(), + getSuggestionFlow(), mangaSourcesRepository.observeNewSources(), - ) { content, newSources -> - buildList(content, newSources) + ) { content, suggestions, newSources -> + buildList(content, suggestions, newSources) } private fun buildList( sources: List, + recommendation: Manga?, newSources: Set, ): List { val result = ArrayList(sources.size + 3) + if (recommendation != null) { + + } return result } + @OptIn(ExperimentalCoroutinesApi::class) + fun getSuggestionFlow() = isSuggestionsEnabled.mapLatest { isEnabled -> + if (isEnabled) { + runCatchingCancellable { + suggestionRepository.getRandom() + }.getOrNull() + } else { + null + } + } } \ No newline at end of file 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 00a827d..24bc4ad 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 @@ -5,7 +5,6 @@ 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.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -33,6 +32,7 @@ import androidx.compose.ui.unit.dp 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.MangaSource import org.xtimms.tokusho.core.components.MangaGridItem import org.xtimms.tokusho.core.components.ScaffoldWithSmallTopAppBarWithChips @@ -142,12 +142,13 @@ private fun MangaListViewContent( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter ) { + val onClickManga = { manga: Manga -> + navigateToDetails(manga.id) + } MangaGridItem( coil = coil, manga = item, - onClick = { - navigateToDetails(item.id) - }, + onClick = onClickManga, onLongClick = { }, ) } diff --git a/app/src/main/java/org/xtimms/tokusho/sections/suggestions/SuggestionMangaModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/suggestions/SuggestionMangaModel.kt new file mode 100644 index 0000000..b6332f7 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/suggestions/SuggestionMangaModel.kt @@ -0,0 +1,13 @@ +package org.xtimms.tokusho.sections.suggestions + +import org.koitharu.kotatsu.parsers.model.Manga +import org.xtimms.tokusho.core.model.ListModel + +data class SuggestionMangaModel( + val manga: Manga +) : ListModel { + + override fun areItemsTheSame(other: ListModel): Boolean { + return other is SuggestionMangaModel && other.manga.id == manga.id + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/suggestions/SuggestionsView.kt b/app/src/main/java/org/xtimms/tokusho/sections/suggestions/SuggestionsView.kt new file mode 100644 index 0000000..c962ad2 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/suggestions/SuggestionsView.kt @@ -0,0 +1,116 @@ +package org.xtimms.tokusho.sections.suggestions + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +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.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.ImageLoader +import org.koitharu.kotatsu.parsers.model.Manga +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.components.MangaGridItem +import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar + +const val SUGGESTIONS_DESTINATION = "suggestions" + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SuggestionsView( + viewModel: SuggestionsViewModel = hiltViewModel(), + coil: ImageLoader, + navigateBack: () -> Unit, + navigateToDetails: (Long) -> Unit +) { + + val suggestions by viewModel.content.collectAsStateWithLifecycle(emptyList()) + + ScaffoldWithTopAppBar( + title = stringResource(id = R.string.suggestions), + navigateBack = navigateBack, + actions = { + IconButton(onClick = { viewModel.updateSuggestions() }) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = "Refresh" + ) + } + }, + ) { padding -> + val listState = rememberLazyGridState() + Box( + modifier = Modifier + .padding(padding), + contentAlignment = Alignment.Center + ) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 100.dp), + state = listState, + modifier = Modifier.fillMaxHeight(), + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = WindowInsets.navigationBars.asPaddingValues() + .calculateBottomPadding() + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy( + 8.dp, + Alignment.CenterHorizontally + ), + ) { + items( + items = suggestions, + key = { it.manga.id }, + contentType = { it } + ) { item -> + Box( + modifier = Modifier.fillMaxWidth().animateItemPlacement( + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMedium / 4, + visibilityThreshold = IntOffset.VisibilityThreshold + ) + ), + contentAlignment = Alignment.TopCenter + ) { + val onClickManga = { manga: Manga -> + navigateToDetails(manga.id) + } + MangaGridItem( + coil = coil, + manga = item.manga, + onClick = onClickManga, + onLongClick = { }, + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/suggestions/SuggestionsViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/suggestions/SuggestionsViewModel.kt new file mode 100644 index 0000000..af8b701 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/suggestions/SuggestionsViewModel.kt @@ -0,0 +1,38 @@ +package org.xtimms.tokusho.sections.suggestions + +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus +import org.koitharu.kotatsu.parsers.model.Manga +import org.xtimms.tokusho.core.base.viewmodel.KotatsuBaseViewModel +import org.xtimms.tokusho.data.repository.SuggestionRepository +import org.xtimms.tokusho.sections.history.HistoryItemModel +import org.xtimms.tokusho.utils.lang.mapItems +import org.xtimms.tokusho.work.suggestions.SuggestionsWorker +import javax.inject.Inject + +@HiltViewModel +class SuggestionsViewModel @Inject constructor( + repository: SuggestionRepository, + private val suggestionsScheduler: SuggestionsWorker.Scheduler, +) : KotatsuBaseViewModel() { + + private val suggestionsStateFlow = repository.observeAll() + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) + + val content = suggestionsStateFlow + .filterNotNull() + .mapItems { SuggestionMangaModel(it) } + .distinctUntilChanged() + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) + + + fun updateSuggestions() { + suggestionsScheduler.startNow() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/Coil.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/Coil.kt new file mode 100644 index 0000000..59bda79 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/Coil.kt @@ -0,0 +1,16 @@ +package org.xtimms.tokusho.utils.lang + +import androidx.core.graphics.drawable.toBitmap +import coil.request.ErrorResult +import coil.request.ImageResult +import coil.request.SuccessResult + +fun ImageResult.toBitmapOrNull() = when (this) { + is SuccessResult -> try { + drawable.toBitmap() + } catch (_: Throwable) { + null + } + + is ErrorResult -> null +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/Collections.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/Collections.kt index d4dbf9f..9670d45 100644 --- a/app/src/main/java/org/xtimms/tokusho/utils/lang/Collections.kt +++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/Collections.kt @@ -1,5 +1,7 @@ package org.xtimms.tokusho.utils.lang +import androidx.collection.ArrayMap + fun Collection.asArrayList(): ArrayList = if (this is ArrayList<*>) { this as ArrayList } else { @@ -8,4 +10,18 @@ fun Collection.asArrayList(): ArrayList = if (this is ArrayList<*>) { fun Sequence.toListSorted(comparator: Comparator): List { return toMutableList().apply { sortWith(comparator) } +} + +fun List.takeMostFrequent(limit: Int): List { + val map = ArrayMap(size) + for (item in this) { + map[item] = map.getOrDefault(item, 0) + 1 + } + val entries = map.entries.sortedByDescending { it.value } + val count = minOf(limit, entries.size) + return buildList(count) { + repeat(count) { i -> + add(entries[i].key) + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/Flow.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/Flow.kt index d668e5a..cc846c5 100644 --- a/app/src/main/java/org/xtimms/tokusho/utils/lang/Flow.kt +++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/Flow.kt @@ -1,6 +1,7 @@ package org.xtimms.tokusho.utils.lang import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach @@ -18,4 +19,12 @@ fun Flow.onEachWhile(action: suspend (T) -> Boolean): Flow { inline fun Flow>.mapItems(crossinline transform: (T) -> R): Flow> { return map { list -> list.map(transform) } +} + +fun Flow>.flatten(): Flow = flow { + collect { value -> + for (item in value) { + emit(item) + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/Android.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/Android.kt new file mode 100644 index 0000000..1e0a434 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/Android.kt @@ -0,0 +1,73 @@ +package org.xtimms.tokusho.utils.system + +import android.Manifest +import android.app.ActivityManager +import android.content.Context +import android.content.Context.POWER_SERVICE +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import android.os.PowerManager +import androidx.activity.result.ActivityResultLauncher +import androidx.annotation.WorkerThread +import androidx.core.app.ActivityOptionsCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.work.CoroutineWorker +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import java.io.File +import java.io.IOException + +fun ActivityResultLauncher.tryLaunch( + input: I, + options: ActivityOptionsCompat? = null, +): Boolean = runCatching { + launch(input, options) +}.onFailure { e -> + e.printStackTrace() +}.isSuccess + +fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this) + +val Context.powerManager: PowerManager? + get() = getSystemService(POWER_SERVICE) as? PowerManager + +suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatchingCancellable { + val info = getForegroundInfo() + setForeground(info) +}.isSuccess + +fun Context.checkNotificationPermission(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED +} else { + NotificationManagerCompat.from(this).areNotificationsEnabled() +} + +@WorkerThread +suspend fun Bitmap.compressToPNG(output: File) = runInterruptible(Dispatchers.IO) { + output.outputStream().use { os -> + if (!compress(Bitmap.CompressFormat.PNG, 100, os)) { + throw IOException("Failed to encode bitmap into PNG format") + } + } +} + +val Context.ramAvailable: Long + get() { + val result = ActivityManager.MemoryInfo() + activityManager?.getMemoryInfo(result) + return result.availMem + } + +fun Context.ensureRamAtLeast(requiredSize: Long) { + if (ramAvailable < requiredSize) { + throw IllegalStateException("Not enough free memory") + } +} + +fun Context.isPowerSaveMode(): Boolean { + return powerManager?.isPowerSaveMode == true +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/work/WorkScheduleManager.kt b/app/src/main/java/org/xtimms/tokusho/work/WorkScheduleManager.kt new file mode 100644 index 0000000..35db37f --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/work/WorkScheduleManager.kt @@ -0,0 +1,30 @@ +package org.xtimms.tokusho.work + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.xtimms.tokusho.utils.lang.processLifecycleScope +import org.xtimms.tokusho.work.suggestions.SuggestionsWorker +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WorkScheduleManager @Inject constructor( + private val suggestionScheduler: SuggestionsWorker.Scheduler, +) { + + fun init() { + processLifecycleScope.launch(Dispatchers.Default) { + updateWorkerImpl(suggestionScheduler, isEnabled = true, force = false) + } + } + + private suspend fun updateWorkerImpl(scheduler: PeriodicWorkScheduler, isEnabled: Boolean, force: Boolean) { + if (force || scheduler.isScheduled() != isEnabled) { + if (isEnabled) { + scheduler.schedule() + } else { + scheduler.unschedule() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/work/suggestions/SuggestionsWorker.kt b/app/src/main/java/org/xtimms/tokusho/work/suggestions/SuggestionsWorker.kt new file mode 100644 index 0000000..ab86cca --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/work/suggestions/SuggestionsWorker.kt @@ -0,0 +1,373 @@ +package org.xtimms.tokusho.work.suggestions + +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.annotation.FloatRange +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.PendingIntentCompat +import androidx.core.text.HtmlCompat +import androidx.core.text.bold +import androidx.core.text.buildSpannedString +import androidx.core.text.parseAsHtml +import androidx.hilt.work.HiltWorker +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.await +import androidx.work.workDataOf +import coil.ImageLoader +import coil.request.ImageRequest +import dagger.Reusable +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.util.almostEquals +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.model.MangaSuggestion +import org.xtimms.tokusho.core.model.TagsBlacklist +import org.xtimms.tokusho.core.model.distinctById +import org.xtimms.tokusho.core.parser.MangaRepository +import org.xtimms.tokusho.data.repository.FavouritesRepository +import org.xtimms.tokusho.data.repository.HistoryRepository +import org.xtimms.tokusho.data.repository.MangaSourcesRepository +import org.xtimms.tokusho.data.repository.SuggestionRepository +import org.xtimms.tokusho.utils.lang.asArrayList +import org.xtimms.tokusho.utils.lang.awaitUniqueWorkInfoByName +import org.xtimms.tokusho.utils.lang.flatten +import org.xtimms.tokusho.utils.lang.sanitize +import org.xtimms.tokusho.utils.lang.takeMostFrequent +import org.xtimms.tokusho.utils.lang.toBitmapOrNull +import org.xtimms.tokusho.utils.system.checkNotificationPermission +import org.xtimms.tokusho.utils.system.trySetForeground +import org.xtimms.tokusho.work.PeriodicWorkScheduler +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlin.math.pow +import kotlin.random.Random + +@HiltWorker +class SuggestionsWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted params: WorkerParameters, + private val coil: ImageLoader, + private val suggestionRepository: SuggestionRepository, + private val historyRepository: HistoryRepository, + private val favouritesRepository: FavouritesRepository, + private val workManager: WorkManager, + private val mangaRepositoryFactory: MangaRepository.Factory, + private val sourcesRepository: MangaSourcesRepository, +) : CoroutineWorker(appContext, params) { + + private val notificationManager by lazy { NotificationManagerCompat.from(appContext) } + + override suspend fun doWork(): Result { + trySetForeground() + val count = doWorkImpl() + val outputData = workDataOf(DATA_COUNT to count) + return Result.success(outputData) + } + + override suspend fun getForegroundInfo(): ForegroundInfo { + val title = applicationContext.getString(R.string.suggestions_updating) + val channel = NotificationChannelCompat.Builder(WORKER_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW) + .setName(title) + .setShowBadge(true) + .setVibrationEnabled(false) + .setSound(null, null) + .setLightsEnabled(true) + .build() + notificationManager.createNotificationChannel(channel) + + val notification = NotificationCompat.Builder(applicationContext, WORKER_CHANNEL_ID) + .setContentTitle(title) + .addAction( + com.google.android.material.R.drawable.material_ic_clear_black_24dp, + applicationContext.getString(android.R.string.cancel), + workManager.createCancelPendingIntent(id), + ) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setDefaults(0) + .setOngoing(false) + .setSilent(true) + .setProgress(0, 0, true) + .setSmallIcon(android.R.drawable.stat_notify_sync) + .setForegroundServiceBehavior( + if (TAG_ONESHOT in tags) { + NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE + } else { + NotificationCompat.FOREGROUND_SERVICE_DEFERRED + }, + ) + .build() + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo(WORKER_NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + ForegroundInfo(WORKER_NOTIFICATION_ID, notification) + } + } + + private suspend fun doWorkImpl(): Int { + val seed = ( + historyRepository.getList(0, 20) + + favouritesRepository.getLastManga(20) + ).distinctById() + val sources = sourcesRepository.getEnabledSources() + if (seed.isEmpty() || sources.isEmpty()) { + return 0 + } + val tagsBlacklist = TagsBlacklist(setOf(""), TAG_EQ_THRESHOLD) + val tags = seed.flatMap { it.tags.map { x -> x.title } }.takeMostFrequent(10) + + val semaphore = Semaphore(MAX_PARALLELISM) + val producer = channelFlow { + for (it in sources.shuffled()) { + launch { + semaphore.withPermit { + send(getList(it, tags, tagsBlacklist)) + } + } + } + } + val suggestions = producer + .flatten() + .take(MAX_RAW_RESULTS) + .map { manga -> + MangaSuggestion( + manga = manga, + relevance = computeRelevance(manga.tags, tags), + ) + }.toList() + .sortedBy { it.relevance } + .take(MAX_RESULTS) + suggestionRepository.replace(suggestions) + if (applicationContext.checkNotificationPermission()) { + for (i in 0..3) { + try { + val manga = suggestions[Random.nextInt(0, suggestions.size / 3)] + val details = mangaRepositoryFactory.create(manga.manga.source) + .getDetails(manga.manga) + if (details.chapters.isNullOrEmpty()) { + continue + } + if (details.rating > 0 && details.rating < RATING_MIN) { + continue + } + if (details.isNsfw) { + continue + } + if (details in tagsBlacklist) { + continue + } + showNotification(details) + break + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + e.printStackTrace() + } + } + } + return suggestions.size + } + + private suspend fun getList( + source: MangaSource, + tags: List, + blacklist: TagsBlacklist, + ): List = runCatchingCancellable { + val repository = mangaRepositoryFactory.create(source) + val availableOrders = repository.sortOrders + val order = preferredSortOrders.first { it in availableOrders } + val availableTags = repository.getTags() + val tag = tags.firstNotNullOfOrNull { title -> + availableTags.find { x -> x !in blacklist && x.title.almostEquals(title, TAG_EQ_THRESHOLD) } + } + val list = repository.getList( + offset = 0, + filter = MangaListFilter.Advanced.Builder(order) + .tags(setOfNotNull(tag)) + .build(), + ).asArrayList() + if (blacklist.isNotEmpty()) { + list.removeAll { manga -> manga in blacklist } + } + list.shuffle() + list.take(MAX_SOURCE_RESULTS) + }.onFailure { e -> + e.printStackTrace() + }.getOrDefault(emptyList()) + + @SuppressLint("MissingPermission") + private suspend fun showNotification(manga: Manga) { + val channel = NotificationChannelCompat.Builder(MANGA_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT) + .setName(applicationContext.getString(R.string.suggestions)) + .setDescription(applicationContext.getString(R.string.suggestions_summary)) + .setLightsEnabled(true) + .setShowBadge(true) + .build() + notificationManager.createNotificationChannel(channel) + + val id = manga.url.hashCode() + val title = applicationContext.getString(R.string.suggestion_manga, manga.title) + val builder = NotificationCompat.Builder(applicationContext, MANGA_CHANNEL_ID) + val tagsText = manga.tags.joinToString(", ") { it.title } + with(builder) { + setContentText(tagsText) + setContentTitle(title) + setLargeIcon( + coil.execute( + ImageRequest.Builder(applicationContext) + .data(manga.coverUrl) + .tag(manga.source) + .build(), + ).toBitmapOrNull(), + ) + setSmallIcon(R.drawable.ic_stat_suggestion) + val description = manga.description?.parseAsHtml(HtmlCompat.FROM_HTML_MODE_COMPACT)?.sanitize() + if (!description.isNullOrBlank()) { + val style = NotificationCompat.BigTextStyle() + style.bigText( + buildSpannedString { + append(tagsText) + val chaptersCount = manga.chapters?.size ?: 0 + appendLine() + bold { + append( + applicationContext.resources.getQuantityString( + R.plurals.chapters, + chaptersCount, + chaptersCount, + ), + ) + } + appendLine() + append(description) + }, + ) + style.setBigContentTitle(title) + setStyle(style) + } + setAutoCancel(true) + setCategory(NotificationCompat.CATEGORY_RECOMMENDATION) + setVisibility(if (manga.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC) + setShortcutId(manga.id.toString()) + priority = NotificationCompat.PRIORITY_DEFAULT + } + notificationManager.notify(TAG, id, builder.build()) + } + + @FloatRange(from = 0.0, to = 1.0) + private fun computeRelevance(mangaTags: Set, allTags: List): Float { + val maxWeight = (allTags.size + allTags.size + 1 - mangaTags.size) * mangaTags.size / 2.0 + val weight = mangaTags.sumOf { tag -> + val index = allTags.inexactIndexOf(tag.title, TAG_EQ_THRESHOLD) + if (index < 0) 0 else allTags.size - index + } + return (weight / maxWeight).pow(2.0).toFloat() + } + + private fun Iterable.inexactIndexOf(element: String, threshold: Float): Int { + forEachIndexed { i, t -> + if (t.almostEquals(element, threshold)) { + return i + } + } + return -1 + } + + @Reusable + class Scheduler @Inject constructor( + private val workManager: WorkManager, + ) : PeriodicWorkScheduler { + + override suspend fun schedule() { + val request = PeriodicWorkRequestBuilder(6, TimeUnit.HOURS) + .setConstraints(createConstraints()) + .addTag(TAG) + .setBackoffCriteria(BackoffPolicy.LINEAR, 1, TimeUnit.HOURS) + .build() + workManager + .enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, request) + .await() + } + + override suspend fun unschedule() { + workManager + .cancelUniqueWork(TAG) + .await() + } + + override suspend fun isScheduled(): Boolean { + return workManager + .awaitUniqueWorkInfoByName(TAG) + .any { !it.state.isFinished } + } + + fun startNow() { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val request = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .addTag(TAG_ONESHOT) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + workManager.enqueue(request) + } + + private fun createConstraints() = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiresBatteryNotLow(true) + .build() + } + + private companion object { + + const val TAG = "suggestions" + const val TAG_ONESHOT = "suggestions_oneshot" + const val DATA_COUNT = "count" + const val WORKER_CHANNEL_ID = "suggestion_worker" + const val MANGA_CHANNEL_ID = "suggestions" + const val WORKER_NOTIFICATION_ID = 36 + const val MAX_RESULTS = 80 + const val MAX_PARALLELISM = 3 + const val MAX_SOURCE_RESULTS = 14 + const val MAX_RAW_RESULTS = 200 + const val TAG_EQ_THRESHOLD = 0.4f + const val RATING_MIN = 0.5f + + val preferredSortOrders = listOf( + SortOrder.UPDATED, + SortOrder.NEWEST, + SortOrder.POPULARITY, + SortOrder.RATING, + ) + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable-hdpi/ic_stat_suggestion.png b/app/src/main/res/drawable-hdpi/ic_stat_suggestion.png new file mode 100644 index 0000000000000000000000000000000000000000..429e98bd0968ff6a8d6e60956450027c7cbbca98 GIT binary patch literal 1007 zcmVPx&sYygZR9HvNmu<*ja~Q`zU&NNDJj`PXnbK|ZYK}# zf7f*#v6Gp0G9JbXWP2CjQb{YD0x@o&g}@46rlhTPP~!$#1DpyhlJxA90@J1N;pfBkAk5 z)Y)DH+zK3>YYn^%TrcVSkyB=S*W!(T0`^TO&lFm^vJEJn+nx!m2X-scxK7gS9Tv6i zrNBKEM_VQB-UTS3-UZG87D`&ZLqN8t1HTqRjVM*^F-g~UfwFyWzR-lE(9b06tRdTH z18=2=uR=fh`;U@_-J8C$X){(iu8^HeQVF9oNI6eQqyU@m+k~VjV)UY*; z5lR7{3EY(BzV!18a3By@{<#6?w-r}>W!IwFDP^`pq^AL^DiJC7{s}CQ^j=18Eielv zO%2h_@0Ap{s+VnxknJdBmjjQ~c-WGa^oqe}(MEtzfGC)Q5#Iz%M5$;J@u0sdP>3`f z?_C4*gQUZnUe$K=sradrz?0F-dIM#h2!~OyqSl->01(&oW8eZw8*=^gz&zlffwjLE z9sR;+ppt&>le1x>KCY;&7ZuL#7;v~=(uD1}c`;5@?W0WjDoLTY-qu=&Y{!kd5xBYH z{X}3fa9RE@`cg#ewXUgYB5egKH`)%U`ziqErDiru+N*0QY#%wSA@>2AQHXP6PJGq# z-#vpS-_K>GkuTgna} zLt3P+liB_ah+3yr0q%zCVB` zqKQk|GAhs|l3r}fQ#Rm;=mWsX#aHVdl|3;EzXU9k6x#aFMgnRBse5iazRe!ZGdD_# zbWXBy0>xQh=A`+Qq^qVBD76>u=N8~_Nipb6U4#-SMw{i5ZfHvT*iu#k#AqxjJ~C@< d>_BZ7;$I#=E;LQ=Hst^S002ovPDHLkV1niw-rxWL literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi/ic_stat_suggestion.png b/app/src/main/res/drawable-mdpi/ic_stat_suggestion.png new file mode 100644 index 0000000000000000000000000000000000000000..10db5851edb84971f90a9bdc3b71a32c9f65c41c GIT binary patch literal 667 zcmV;M0%ZM(P)Px%RY^oaR7gv;l}(6FaTLaX&ybH|Bw}40ia9Lj|xcP1Q9V6ST5;7HJuT$0N4!d16BY(fos4m zU{KPhG61W9y+F65u{=mb90ZO79aZ4ZfIX5%ngT?`WZ)$*GwW=Tq*ryOn47(w01f~j zfiu8aNuGuxfG*%>D%vk;dp*}B8lMAqfJ2gg7itg@Q-FS;6Zn;yX_xf10mxS1v!r{4 zE{cdrfIqPH3}kog0PZGk-_@b24QQ)SQ+I-hFlFn3i@-u)8gLaj1>6U`Oz(l~K%b=V zMT`1ZgNU#TR$H7@*w3TDkfb9i$5ylkE3>?Iz{PujC%~{I(^wM~fO)`f;B+qIX~t=< zq@e_0+U)N&3Bb?R=p68{DL|!YvgOGHX8XB;W5DH1?8m^IWJTMxQ=`hP2 zrw#oE+yD+I+C1rqq$n!hbPpoJJbMQ=0KP4zs6$eXgA#>{(=|1<<59F|y-9d~Fd%7D zqpl;u6b=GM^V1apOb5QC36@HF(O}8CfeY!PZp`PEId)rs{lL!ZEl$_;cIK{SxwB(! ztXYqww`Bl4#acl^t!Z7%XD6p^9R^e))|-riv|Dy002ovPDHLkV1nTV BDX;(l literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi/ic_stat_suggestion.png b/app/src/main/res/drawable-xhdpi/ic_stat_suggestion.png new file mode 100644 index 0000000000000000000000000000000000000000..9d1898aa84d9940630f08d24b706c6122b1ae1c9 GIT binary patch literal 1531 zcmVPx)wMj%lRA@uhnR(1zQxwI2%RH8prWzWNCL%E*d{d-}EK#JAb}+?zwxNz4qQ~?<DxLGg9u=2;1OVfq%(&jfD?i10pI>^l9sjt zF^C>`8`u)~8<+{qkrY)R1_nSxtPD&C?kpMmG;oBZ?}i*eM2wX5Lmg+E{T~sV0fzx+ z0i#QM{sbOM9=RWQO44tQ&-cXx5wS{o@lA=hr%3Wk+gcH^5pZ!fem%hFG+Q?O-3;($ zNq-fadIk^?KEr``6);KCEA3bx5mSL%fRXL&&wCsT4+E!5`rN~^iSUbTu=So`XMoI2kGEWDa6dtscUWt{?8 zo^m4Y2F{W6W!>{#c_1RT2Oa}lh>A3KM@pwhCF(dBy)4GYwBm0I8jY3oX`S)@zas#d z_r7%-yG_#V*6v81O&Fhj*->%_c7nHH7Abkzo z12jvamKk#srvX1*9p3 za#3+XIU|+t5rCy`<8q+7tWTBneeq{RY?WwqZ`Sn9FuPit-|n^+c9Uw$hpqsS@~=zy zcwqMw84jiAEoCF zeqZ2rV4Vu;{hHcEYy1O&*SayhIJYv5MuaKk0JhXMpd!DQZL&t01Du3^Q3vp77p#b|wzn*9T=vj;S@^m|>*bn}1P07@KpN}ZB9?iA}~Y8b#3 zIqmv4(`ysg1xf#QV_ZAWbPOO;1-j?V$v z%s{t+n@jq!Q|lvQ9Pp_1mZWRyp6>;Kvy{hvV;WxK3hg*?p{wkqstk*W$-o0yTTPH; zja9k229UjA;dWO8bvzcI-cF%wW>o_4dKQeH{kRC;Fl3{}JW;c|%Z-gQ&-l7ds>1mF zGkuBym*x31NtK;luO4vF-Ub|7GRPBUE6$EqrjCnTd9-LRxUS5u0?`wIOe6QkzI98G zMUu*Ope2ZiuvFQ_@zT&BH9qGC-%XTbV{v&t)DHmmf_2Z4z`^ZuKO#Ib@mR+6YbH|~ zK{z>B2|w>IIFfv6owbxLGKVwKgTUdEJQu265wWHlci;q1^BQJLjnBIfIu1O-JS_{r zcsG3?|%yP*+7+%E-C;3002ovPDHLkV1m-I%n|?q literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/ic_stat_suggestion.png b/app/src/main/res/drawable-xxhdpi/ic_stat_suggestion.png new file mode 100644 index 0000000000000000000000000000000000000000..1774942b812d38ea1f6b94a500ef8e386edc4f31 GIT binary patch literal 2101 zcmV-52+H?~P)Px+?@2^KRCr$PTW825MG$?@oHHsOX3U3}6)|8y^vt3n1{B2{&NG~z0dvj)F@cx` zF^gHz6C*~<8FThf%;`Dr?2&PAcDH+WW(ElAEN$Zh(u&OH=XbX}w1%TH9Y)SH!Dj*FyK%)Vi3*bhQ+YSM0K|s=L0G0$W z0l+FG-%bT-U@b_}iU6JhFl7McLX!Ir0RkY&pu7QK(E=b7NX{_?=x=})0`L}qsagOT zOY)2qkOuCEN%Eeaw9hF4;#&Z7ll(Onq=5oR(o6tO0kB^O0A2*J0m(07K%t|6@B!GjRLI9Yx(j@;LWXpLLz@8*uDE`zhfb1Xk zEj;)xk{kEBv5;$>9OkxzP8Gkbhe&SJZeYIv`WV1`g+=@ntmymH9hK=znjXMT08Rxk zd*qx~02tHFk{bs|lEeG+TNW(5@0dQG|0eb+rgXCv1lQs&Fq^SWo z-k7aqF}9u&B)^MUXsk|1GtLHZK*^ZND^Qf=TjntUuFI6k79?+r-F&Q`qys~*v6^j3 z-WZ{mq#|hqfDa3y%&FOn7?(6jlW~wFADzu27W62|wIcMARV4WZ`9;~^%}(<7)JYn# zAW18Pz$<1wzX7l>x+_LGMMsh&_PYUGL2}oWvAJ$j0J-LJNWy}y&B~$9scIKVj*v%@ z{JhHKwF4w+o-Am4CyR4tELqUS0QM^R|IYyCC;3&CO>`O~X-Sf1HY!OI03DO9lmk~nV^kaw3{t4hsC!pEw_RxO;O7WSM0Sd*c;{hxiu?Z)I zCh#Uo7<+4%alYLg$2flJlv_nSzPd^hK*0e!*>mZ_&Nx<_bA3_(Xu6Q2%^W#_BW@Sm z&dj*2uOr9Qq80^^qy+#tD^Iz)y}*|MTuJh**40XK)_rIQJ8_64 z`y|saODE3Z;+NAEbMduG=@FAOLx@Mlv3Q+!ZNegGq04HWG zC1wC(3*#RCu8&NyC>9D@K1wb{#EMH6_KU7%zaKlOSG{O}3P8BOMEiCYc*Rd5znje6jYdguW?U$=m0DA$WY;4Z}6^@M=1b1{@0$`1j zhn=4#tD_~21#nu)@9rPm!Ij9#C6o6WYXhhN)cOGKF9E>*V;unQS#}uFDig~gbz4BM z^ECmGD~b2Cu_7NIvkk5C5lJ7t+C|bqBuyk%lG*_Ze#zOs8x~^alq^j+B33z8FEm!U z2)94U8)C;5^_l|6ofa-j#9ZS}Zs#fX&X&NBkOm>oPa4M7KNV+YzR_V}HlKfFi(*U{c z#mwe%B?x>$GNrB^1!Axs=XuT z8q+U;@-B;fE&c+q8!y&zui+>i9x95aw5y7l60haxtOI0{B)TsE1uJqZ-s(PXRXV+j zyO(6T$TwtJPFEEuA2+8+`xQGu`Uy}TIG#?=)ui)B6lI!Fbnf}T6d|OyMN#gJN z3Q+B+v`iR2rt{}eU$%Lct;WQp{b1A$I#2-RptR}k_s{+cdSnV!J*WWL7daPeZ39eB zj0Y7UNA0JFPo{FiGIzgB>MKzbzm9?VVZuaNBY zNkm)F1|1-OKW{5Kl;oL7rW2G08lYfB*Er(NzD;v-nk@eZfTruEHD=%eN;a960YiYg fAUFi53qXGXlm>lgN^}C+00000NkvXXu0mjfTK2Qm literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_stat_suggestion.png b/app/src/main/res/drawable-xxxhdpi/ic_stat_suggestion.png new file mode 100644 index 0000000000000000000000000000000000000000..8b1e8dc7650136b5940b32c27ab79c55e1959c64 GIT binary patch literal 3270 zcmV;%3_0_OP)Px>fJsC_RCr$Pod>WjMG=PoHYdzkQOqc)D4+tOf+&g+lmbyeF_Huk0bi+r3CsaO z#fXXoF(9R=n7{xAkf#)2q70Ndi+~u~{jPmp?OV_8&W5vltlF-kF7@ur>`ecYy8G|B zrb$NCfQ|a~>EoIJ7HJ`$^j_ zOj}SvbMAf(Ne`67?C0KJC09xMoTS&8*;#EsSULdQ{l+YLGy7wk0ky5;?$?xbXGsTR z?KW)Z-;%x{Y45D=Pt6Qql4KbH_`0Omo7u;bM$oa4yWcFkWE_Y9U8zO=hzJRK-$Gf7vguhC11uSt3n6U@x6Tsi>a z=D{p^e(uiA!M359UDS&Tq7iq$ddz&byEjU@XP@mC+QLZNKep8?&5Z47Em?{c;O^^5 z;_$sqp?~j?v|p23MH6L)o0-FRPf7QYbjv37OUkm}GviM*vk%p^w=4j>M$$oZI{W=F zbTEh(ChmTNxcl!cX$MI+ZSk?oC4H2rnWWVzHz2+-Ish>X*V-3zgTF!&w;={Kh`2jj z(u*PjmU1OU@mxI*GP6sXc5)d2cx?>0tnbXGjhYUuOL=!k@x*?Qm4uy8CK!vmni;pZ zRx&C8?tW`YpNQpOHh_Ohdc2u2%LbEhMRNvKS;b$@wwvv%?)h~c9vc8G&kst%2B;Fw zfQ`(oa|EhRA^G3Om6dov*_p7LnbH5&GByC8mh|X^-s4+gsbeP$CgG+a9( zA!f!I&{{?Vz}+7t>8qJV%Dp^A2!cP;%>I^AxQ{YHM|pvyH_T~o2QvelPBIDr?vCH( z^O67|RiJ@D2Z0tcn7I3mveozF!eNZfvW=N>OY0C{x3g~VFAnZlGkb2Q5x1!9 z?z?8L>JLO!E(O>uZ=Pq_Hf?@z0Nfq_%Ui>D-n74Pb2jS|fol3e%EyYuySYC8eo5P! z8Af7f83Ta3OFB~$SI{={PBVL6n~H7g#H3-?zZ{l?nc=$VMaBSNKbCKY-v5A`QyZ(Z z!#7z4_m}j^%*Vfhnc*()MFs^R4m3Q3x2WhnX9JG0yOxw8NW;zKm_GL4R4v9f#P-MK zp*I;CfENeNqiO&spDl*}%KY_b$1SAO9lSR!c4%lY0D@mn;F^Q1OpeH0&#T;hb4h1Z z#AorVKgZ18-C^uYQFWmJge8f0k)Xu3S!fQoZ<*lO+T6@84)-ut@;nPPU;jH1X*#DX zLv1WH76L#h5N{#}E&x<{6lY_HNw9~R5zyw)yIo_=h-Ga8n#V^x>S)zse%L z0T5<8i42gM2rq8ZpmQh@Uk@Oxk<4B3nOHfgO<^Yx6gdj()yy!OmRfoNz}>gV0?wNT zlMIecQ~73Q8*Ei{Y`OcB!dnVWu!;Ok5?k13Ax!l_BX9EYCS^MT5O-tPV8m5nGo(7! zihac2e_HV$;|w8Q@Zctc%9Vw|41bg4y7#qgW1-a=0}!Ou4J7SOxUggI;QerbGSlaZ zyFW$J%Yr1E5+a<}4h#z%Xm5$1zg;xxD^)Ma00ePr8%Zbx@>E+_0(~REg29D5_x!rO z+TCvw47SHb&^&c~#d5f^@!_zlPVUQ})3!6zO9H^%nSq4nP- zeO$|Rk~gHz8=5~_*SWgiF9d+Xfz8&0x0D%9ytiyS!E?E#nXQhy{_isO1UbWH!l4bl z1y@3+_ZsSyw8-Bt9Dv+`jWRhP&Mwn0)IU6~JQFrgpsMdV zWZF_p9{O~EnPCw2CW8VH1HrA`CFhG+!WL#m0`y?w?&L8ORolhP2=4SEV*r5hMnI%V z&^|xd74A?kqfcxk{7}R&c7vYS$4gnWPmcir&%wMh67!lGSOj$7W)403^LfnJ8zg6t z3*1=-2Ozc~*m2)#;X}e-e@y35H*6q_gXi;@F%*x;9JGfP-+gG<>`L)b00gA zKW8xC!=C8-?dCeAmE0){)e4$k12MEN8eZ zkNI#jhcw3q zfbWxmcwEI4I?K!mgAOV&`(a#H=?EtGv&{_azLks$06mWICIK0q5-SRO|L#TBbgPlp zREqE?Z-f$ehH?qtC`CWG!@m*l!=2gGGn?{(MT~c?HiHE{?~fvoip0aHt(SO8=OcRu z9u&`+wLNQyJ(EC)vys3O|6eQ10sxuwvpn2^wE(maJ_0RWf8EDx9XG6HjYlPoiyr{a5)cnjze86A-f99~Z(@V_X56hKsu z*LBaGXSgi|0F2(LLc7dbkyzr+4mE5~@5 z0AL%!$3j4#4zITe;#53QjB6S8#>PRVY3dYA#g4G*g;{*#$g&5gmIi=W1+&DwBPFo{ z)_UYLb<`>4+RfR5lQB>p{;2wwU zi`9#3gk+Gm(m4NNsQ`#o0RQx|F?c=h4lSRjD~U<7J*8|-o$?=+4uG~(udjM>J>>ye zwxzn92FSfoDo<-_fX(_l0Z@UQyYns_7IB#nR3bXoI>l)Mpc(+SA|h~QdCCWx*}V7U zk{9FzKs5k$1cG&DPb(yAyijlgpc(+UK>SIQd66%obHs`}$pk=+`r<8VjH$ekSH|N_ zo&UhZ1VB9i=;(m35oV>a*fA87^PI~*GXapWfw=o!f<}XphOx!X2UmG#nE*&oVFYX5 z7Fjn0=?O!2ezcWOPXHtUKoNK6uPu>pwzZj&iPMWr03@n09xK_`%t<807*qoM6N<$ Ef*c4o#sB~S literal 0 HcmV?d00001 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a2767e5..e5f9081 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -204,4 +204,19 @@ In favourites In shelf Add to shelf + Suggestions + Suggest manga based on your preferences + Suggestion: %s + Just now + Today + Yesterday + This month + Long ago + Are you sure you want to remove %s from your reading history? + Continue + Updating suggestions + Images optimization proxy + Use the wsrv.nl service to reduce traffic usage and speed up image loading if possible + Grid columns count + %d column(s) would be used for manga grids \ No newline at end of file