Suggestions

master
Zakhar Timoshenko 2 years ago
parent 2746b71575
commit 9e269a9e03
Signed by: Xtimms
SSH Key Fingerprint: SHA256:wH6spYepK/A5erBh7ZyAnr1ru9H4eaMVBEuiw6DSpxI

@ -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()
}
}

@ -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 {
@ -9,3 +11,17 @@ 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
@ -19,3 +20,11 @@ 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)
}
}
}

@ -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,
)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1007 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 667 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

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>
Loading…
Cancel
Save