Suggestions
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29" />
|
||||
@@ -68,6 +71,17 @@
|
||||
android:resource="@xml/provider_paths" />
|
||||
</provider>
|
||||
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false"
|
||||
tools:node="remove">
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" />
|
||||
</provider>
|
||||
|
||||
<meta-data
|
||||
android:name="android.webkit.WebView.EnableSafeBrowsing"
|
||||
android:value="false" />
|
||||
|
||||
@@ -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<TokushoDatabase>
|
||||
@@ -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<WorkManager>
|
||||
|
||||
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?) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<LocalManga?>,
|
||||
): SharedFlow<LocalManga?> = flow.asSharedFlow()
|
||||
|
||||
@Provides
|
||||
fun provideWorkManager(
|
||||
@ApplicationContext context: Context,
|
||||
): WorkManager = WorkManager.getInstance(context)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<List<SuggestionWithManga>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM suggestions ORDER BY relevance DESC LIMIT :limit")
|
||||
abstract fun observeAll(limit: Int): Flow<List<SuggestionWithManga>>
|
||||
|
||||
@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<String>
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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<TagEntity>,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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<String>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<List<Manga>> {
|
||||
return db.getSuggestionDao().observeAll().mapItems {
|
||||
it.manga.toManga(it.tags.toMangaTags())
|
||||
}
|
||||
}
|
||||
|
||||
fun observeAll(limit: Int): Flow<List<Manga>> {
|
||||
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<MangaSuggestion>) {
|
||||
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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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<Float, AnimationVector1D> = 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")
|
||||
|
||||
@@ -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<MangaSource>,
|
||||
recommendation: Manga?,
|
||||
newSources: Set<MangaSource>,
|
||||
): List<MangaSource> {
|
||||
val result = ArrayList<MangaSource>(sources.size + 3)
|
||||
if (recommendation != null) {
|
||||
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun getSuggestionFlow() = isSuggestionsEnabled.mapLatest { isEnabled ->
|
||||
if (isEnabled) {
|
||||
runCatchingCancellable {
|
||||
suggestionRepository.getRandom()
|
||||
}.getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = { },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
16
app/src/main/java/org/xtimms/tokusho/utils/lang/Coil.kt
Normal file
16
app/src/main/java/org/xtimms/tokusho/utils/lang/Coil.kt
Normal file
@@ -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
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.xtimms.tokusho.utils.lang
|
||||
|
||||
import androidx.collection.ArrayMap
|
||||
|
||||
fun <T> Collection<T>.asArrayList(): ArrayList<T> = if (this is ArrayList<*>) {
|
||||
this as ArrayList<T>
|
||||
} else {
|
||||
@@ -8,4 +10,18 @@ fun <T> Collection<T>.asArrayList(): ArrayList<T> = if (this is ArrayList<*>) {
|
||||
|
||||
fun <T> Sequence<T>.toListSorted(comparator: Comparator<T>): List<T> {
|
||||
return toMutableList().apply { sortWith(comparator) }
|
||||
}
|
||||
|
||||
fun <T> List<T>.takeMostFrequent(limit: Int): List<T> {
|
||||
val map = ArrayMap<T, Int>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <T> Flow<T>.onEachWhile(action: suspend (T) -> Boolean): Flow<T> {
|
||||
|
||||
inline fun <T, R> Flow<List<T>>.mapItems(crossinline transform: (T) -> R): Flow<List<R>> {
|
||||
return map { list -> list.map(transform) }
|
||||
}
|
||||
|
||||
fun <T> Flow<Collection<T>>.flatten(): Flow<T> = flow {
|
||||
collect { value ->
|
||||
for (item in value) {
|
||||
emit(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
73
app/src/main/java/org/xtimms/tokusho/utils/system/Android.kt
Normal file
73
app/src/main/java/org/xtimms/tokusho/utils/system/Android.kt
Normal file
@@ -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 <I> ActivityResultLauncher<I>.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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
blacklist: TagsBlacklist,
|
||||
): List<Manga> = 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<MangaTag>, allTags: List<String>): 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<String>.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<SuggestionsWorker>(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<SuggestionsWorker>()
|
||||
.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
BIN
app/src/main/res/drawable-hdpi/ic_stat_suggestion.png
Normal file
BIN
app/src/main/res/drawable-hdpi/ic_stat_suggestion.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1007 B |
BIN
app/src/main/res/drawable-mdpi/ic_stat_suggestion.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_stat_suggestion.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 667 B |
BIN
app/src/main/res/drawable-xhdpi/ic_stat_suggestion.png
Normal file
BIN
app/src/main/res/drawable-xhdpi/ic_stat_suggestion.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
BIN
app/src/main/res/drawable-xxhdpi/ic_stat_suggestion.png
Normal file
BIN
app/src/main/res/drawable-xxhdpi/ic_stat_suggestion.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
app/src/main/res/drawable-xxxhdpi/ic_stat_suggestion.png
Normal file
BIN
app/src/main/res/drawable-xxxhdpi/ic_stat_suggestion.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -204,4 +204,19 @@
|
||||
<string name="in_favourites">In favourites</string>
|
||||
<string name="in_shelf">In shelf</string>
|
||||
<string name="add_to_shelf">Add to shelf</string>
|
||||
<string name="suggestions">Suggestions</string>
|
||||
<string name="suggestions_summary">Suggest manga based on your preferences</string>
|
||||
<string name="suggestion_manga">Suggestion: %s</string>
|
||||
<string name="just_now">Just now</string>
|
||||
<string name="today">Today</string>
|
||||
<string name="yesterday">Yesterday</string>
|
||||
<string name="this_month">This month</string>
|
||||
<string name="long_ago">Long ago</string>
|
||||
<string name="delete_from_history_summary">Are you sure you want to remove %s from your reading history?</string>
|
||||
<string name="continue_reading">Continue</string>
|
||||
<string name="suggestions_updating">Updating suggestions</string>
|
||||
<string name="images_optimization_proxy">Images optimization proxy</string>
|
||||
<string name="images_optimization_proxy_desc">Use the wsrv.nl service to reduce traffic usage and speed up image loading if possible</string>
|
||||
<string name="grid_columns_count">Grid columns count</string>
|
||||
<string name="grid_columns_count_desc">%d column(s) would be used for manga grids</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user