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