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 0000000..429e98b Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_stat_suggestion.png differ 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 0000000..10db585 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_stat_suggestion.png differ 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 0000000..9d1898a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_stat_suggestion.png differ 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 0000000..1774942 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_stat_suggestion.png differ 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 0000000..8b1e8dc Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_stat_suggestion.png differ 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