diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt index 3ff54e9a0..1483e7edf 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt @@ -19,6 +19,7 @@ import eu.kanade.presentation.library.components.MangaComfortableGridItem import eu.kanade.tachiyomi.R import exh.metadata.metadata.MangaDexSearchMetadata import exh.metadata.metadata.RaisedSearchMetadata +import exh.metadata.metadata.RankedSearchMetadata import kotlinx.coroutines.flow.StateFlow import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.MangaCover @@ -119,6 +120,14 @@ private fun BrowseSourceComfortableGridItem( textColor = MaterialTheme.colorScheme.onTertiary, ) } + } else if (metadata is RankedSearchMetadata) { + metadata.rank?.let { + Badge( + text = "+$it", + color = MaterialTheme.colorScheme.tertiary, + textColor = MaterialTheme.colorScheme.onTertiary, + ) + } } }, // SY <-- diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt index 8d6a07ab0..7598a2e34 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt @@ -19,6 +19,7 @@ import eu.kanade.presentation.library.components.MangaCompactGridItem import eu.kanade.tachiyomi.R import exh.metadata.metadata.MangaDexSearchMetadata import exh.metadata.metadata.RaisedSearchMetadata +import exh.metadata.metadata.RankedSearchMetadata import kotlinx.coroutines.flow.StateFlow import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.MangaCover @@ -119,6 +120,14 @@ private fun BrowseSourceCompactGridItem( textColor = MaterialTheme.colorScheme.onTertiary, ) } + } else if (metadata is RankedSearchMetadata) { + metadata.rank?.let { + Badge( + text = "+$it", + color = MaterialTheme.colorScheme.tertiary, + textColor = MaterialTheme.colorScheme.onTertiary, + ) + } } }, // SY <-- diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt index 8d301047a..4069ba590 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt @@ -16,6 +16,7 @@ import eu.kanade.presentation.library.components.MangaListItem import eu.kanade.tachiyomi.R import exh.metadata.metadata.MangaDexSearchMetadata import exh.metadata.metadata.RaisedSearchMetadata +import exh.metadata.metadata.RankedSearchMetadata import kotlinx.coroutines.flow.StateFlow import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.MangaCover @@ -110,6 +111,14 @@ private fun BrowseSourceListItem( textColor = MaterialTheme.colorScheme.onTertiary, ) } + } else if (metadata is RankedSearchMetadata) { + metadata.rank?.let { + Badge( + text = "+$it", + color = MaterialTheme.colorScheme.tertiary, + textColor = MaterialTheme.colorScheme.onTertiary, + ) + } } // SY <-- }, diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt index 4ff793623..e51bfe969 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt @@ -28,7 +28,6 @@ import androidx.compose.material.icons.outlined.BookmarkRemove import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.DoneAll import androidx.compose.material.icons.outlined.Download -import androidx.compose.material.icons.outlined.Label import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.RemoveDone import androidx.compose.material.icons.outlined.SwapCalls @@ -237,6 +236,7 @@ fun LibraryBottomActionMenu( // SY --> onClickCleanTitles: (() -> Unit)?, onClickMigrate: (() -> Unit)?, + onClickCollectRecommendations: (() -> Unit)?, onClickAddToMangaDex: (() -> Unit)?, onClickResetInfo: (() -> Unit)?, // SY <-- @@ -267,7 +267,10 @@ fun LibraryBottomActionMenu( } } // SY --> - val showOverflow = onClickCleanTitles != null || onClickAddToMangaDex != null || onClickResetInfo != null + val showOverflow = onClickCleanTitles != null || + onClickAddToMangaDex != null || + onClickResetInfo != null || + onClickCollectRecommendations != null val configuration = LocalConfiguration.current val moveMarkPrev = remember { !configuration.isTabletUi() } var overFlowOpen by remember { mutableStateOf(false) } @@ -358,6 +361,12 @@ fun LibraryBottomActionMenu( onClick = onClickMigrate, ) } + if (onClickCollectRecommendations != null) { + DropdownMenuItem( + text = { Text(stringResource(SYMR.strings.rec_search_short)) }, + onClick = onClickCollectRecommendations, + ) + } if (onClickAddToMangaDex != null) { DropdownMenuItem( text = { Text(stringResource(SYMR.strings.mangadex_add_to_follows)) }, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/process/MigrationListScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/process/MigrationListScreenModel.kt index 50c28f02b..69df04d0f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/process/MigrationListScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/process/MigrationListScreenModel.kt @@ -17,9 +17,9 @@ import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.MigrationType import eu.kanade.tachiyomi.ui.browse.migration.advanced.process.MigratingManga.SearchResult import eu.kanade.tachiyomi.util.system.toast -import exh.eh.EHentaiThrottleManager -import exh.smartsearch.SmartSearchEngine +import exh.smartsearch.SmartSourceSearchEngine import exh.source.MERGED_SOURCE_ID +import exh.util.ThrottleManager import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException @@ -84,8 +84,8 @@ class MigrationListScreenModel( private val deleteTrack: DeleteTrack = Injekt.get(), ) : ScreenModel { - private val smartSearchEngine = SmartSearchEngine(config.extraSearchParams) - private val throttleManager = EHentaiThrottleManager() + private val smartSearchEngine = SmartSourceSearchEngine(config.extraSearchParams) + private val throttleManager = ThrottleManager() val migratingItems = MutableStateFlow?>(null) val migrationDone = MutableStateFlow(false) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt index ecf813882..5573f96e4 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt @@ -42,6 +42,7 @@ import exh.md.utils.FollowStatus import exh.md.utils.MdUtil import exh.metadata.sql.models.SearchTag import exh.metadata.sql.models.SearchTitle +import exh.recs.batch.RecommendationSearchHelper import exh.search.Namespace import exh.search.QueryComponent import exh.search.SearchEngine @@ -61,6 +62,7 @@ import kotlinx.collections.immutable.mutate import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.collectLatest @@ -160,6 +162,9 @@ class LibraryScreenModel( // SY --> val favoritesSync = FavoritesSyncHelper(preferences.context) + val recommendationSearch = RecommendationSearchHelper(preferences.context) + + private var recommendationSearchJob: Job? = null // SY <-- init { @@ -898,6 +903,10 @@ class LibraryScreenModel( } // SY --> + fun showRecommendationSearchDialog() { + val mangaList = state.value.selection.map { it.manga } + mutableState.update { it.copy(dialog = Dialog.RecommendationSearchSheet(mangaList)) } + } fun getCategoryName( context: Context, @@ -1222,8 +1231,12 @@ class LibraryScreenModel( val initialSelection: ImmutableList>, ) : Dialog data class DeleteManga(val manga: List) : Dialog + + // SY --> data object SyncFavoritesWarning : Dialog data object SyncFavoritesConfirm : Dialog + data class RecommendationSearchSheet(val manga: List) : Dialog + // SY <-- } // SY --> @@ -1316,6 +1329,16 @@ class LibraryScreenModel( }.toSortedMap(compareBy { it.order }) } + fun runRecommendationSearch(selection: List) { + recommendationSearch.runSearch(screenModelScope, selection)?.let { + recommendationSearchJob = it + } + } + + fun cancelRecommendationSearch() { + recommendationSearchJob?.cancel() + } + fun runSync() { favoritesSync.runSync(screenModelScope) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt index 647b7caa2..7af6adc55 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt @@ -51,6 +51,10 @@ import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.util.system.toast import exh.favorites.FavoritesSyncStatus +import exh.recs.RecommendsScreen +import exh.recs.batch.RecommendationSearchBottomSheetDialog +import exh.recs.batch.RecommendationSearchProgressDialog +import exh.recs.batch.SearchStatus import exh.source.MERGED_SOURCE_ID import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.channels.Channel @@ -205,6 +209,7 @@ data object LibraryTab : Tab { context.toast(SYMR.strings.no_valid_entry) } }, + onClickCollectRecommendations = screenModel::showRecommendationSearchDialog.takeIf { state.selection.size > 1 }, onClickAddToMangaDex = screenModel::syncMangaToDex.takeIf { state.showAddToMangadex }, onClickResetInfo = screenModel::resetInfo.takeIf { state.showResetInfo }, // SY <-- @@ -310,6 +315,7 @@ data object LibraryTab : Tab { }, ) } + // SY --> LibraryScreenModel.Dialog.SyncFavoritesWarning -> { SyncFavoritesWarningDialog( onDismissRequest = onDismissRequest, @@ -328,6 +334,17 @@ data object LibraryTab : Tab { }, ) } + is LibraryScreenModel.Dialog.RecommendationSearchSheet -> { + RecommendationSearchBottomSheetDialog( + onDismissRequest = onDismissRequest, + onSearchRequest = { + onDismissRequest() + screenModel.clearSelection() + screenModel.runRecommendationSearch(dialog.manga) + }, + ) + } + // SY <-- null -> {} } @@ -337,6 +354,12 @@ data object LibraryTab : Tab { setStatusIdle = { screenModel.favoritesSync.status.value = FavoritesSyncStatus.Idle }, openManga = { navigator.push(MangaScreen(it)) }, ) + + RecommendationSearchProgressDialog( + status = screenModel.recommendationSearch.status.collectAsState().value, + setStatusIdle = { screenModel.recommendationSearch.status.value = SearchStatus.Idle }, + setStatusCancelling = { screenModel.recommendationSearch.status.value = SearchStatus.Cancelling }, + ) // SY <-- BackHandler(enabled = state.selectionMode || state.searchQuery != null) { @@ -356,6 +379,30 @@ data object LibraryTab : Tab { } } + // SY --> + val recSearchState by screenModel.recommendationSearch.status.collectAsState() + LaunchedEffect(recSearchState) { + when (val current = recSearchState) { + is SearchStatus.Finished.WithResults -> { + RecommendsScreen.Args.MergedSourceMangas(current.results) + .let(::RecommendsScreen) + .let(navigator::push) + + screenModel.recommendationSearch.status.value = SearchStatus.Idle + } + is SearchStatus.Finished.WithoutResults -> { + context.toast(SYMR.strings.rec_no_results) + screenModel.recommendationSearch.status.value = SearchStatus.Idle + } + is SearchStatus.Cancelling -> { + screenModel.cancelRecommendationSearch() + screenModel.recommendationSearch.status.value = SearchStatus.Idle + } + else -> {} + } + } + // SY <-- + LaunchedEffect(Unit) { launch { queryEvent.receiveAsFlow().collect(screenModel::search) } launch { requestSettingsSheetEvent.receiveAsFlow().collectLatest { screenModel.showSettingsDialog() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt index 25c347f35..78bf597c3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt @@ -548,7 +548,9 @@ class MangaScreen( // AZ --> private fun openRecommends(navigator: Navigator, source: Source?, manga: Manga) { source ?: return - navigator.push(RecommendsScreen(manga.id, source.id)) + RecommendsScreen.Args.SingleSourceManga(manga.id, source.id) + .let(::RecommendsScreen) + .let(navigator::push) } // AZ <-- } diff --git a/app/src/main/java/exh/debug/DebugFunctions.kt b/app/src/main/java/exh/debug/DebugFunctions.kt index 09186d238..95c077cf4 100644 --- a/app/src/main/java/exh/debug/DebugFunctions.kt +++ b/app/src/main/java/exh/debug/DebugFunctions.kt @@ -10,12 +10,12 @@ import eu.kanade.tachiyomi.data.sync.SyncDataJob import eu.kanade.tachiyomi.source.AndroidSourceManager import eu.kanade.tachiyomi.source.online.all.NHentai import eu.kanade.tachiyomi.util.system.workManager -import exh.eh.EHentaiThrottleManager import exh.eh.EHentaiUpdateWorker import exh.metadata.metadata.EHentaiSearchMetadata import exh.source.EH_SOURCE_ID import exh.source.EXH_SOURCE_ID import exh.source.nHentaiSourceIds +import exh.util.ThrottleManager import exh.util.jobScheduler import kotlinx.coroutines.runBlocking import kotlinx.serialization.protobuf.schema.ProtoBufSchemaGenerator @@ -76,7 +76,7 @@ object DebugFunctions { } } } - private val throttleManager = EHentaiThrottleManager() + private val throttleManager = ThrottleManager() fun getDelegatedSourceList(): String = AndroidSourceManager.currentDelegatedSources.map { it.value.sourceName + " : " + it.value.sourceId + " : " + it.value.factory diff --git a/app/src/main/java/exh/favorites/FavoritesSyncHelper.kt b/app/src/main/java/exh/favorites/FavoritesSyncHelper.kt index 39238d2b5..3348735b4 100644 --- a/app/src/main/java/exh/favorites/FavoritesSyncHelper.kt +++ b/app/src/main/java/exh/favorites/FavoritesSyncHelper.kt @@ -2,24 +2,23 @@ package exh.favorites import android.content.Context import android.net.wifi.WifiManager -import android.os.Build import android.os.PowerManager import eu.kanade.domain.manga.interactor.UpdateManga import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.source.online.all.EHentai -import eu.kanade.tachiyomi.util.system.powerManager import eu.kanade.tachiyomi.util.system.toast import exh.GalleryAddEvent import exh.GalleryAdder -import exh.eh.EHentaiThrottleManager import exh.eh.EHentaiUpdateWorker import exh.log.xLog import exh.source.EH_SOURCE_ID import exh.source.EXH_SOURCE_ID import exh.source.isEhBasedManga +import exh.util.ThrottleManager +import exh.util.createPartialWakeLock +import exh.util.createWifiLock import exh.util.ignore -import exh.util.wifiManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -69,7 +68,7 @@ class FavoritesSyncHelper(val context: Context) { private val galleryAdder by lazy { GalleryAdder() } - private val throttleManager by lazy { EHentaiThrottleManager() } + private val throttleManager by lazy { ThrottleManager() } private var wifiLock: WifiManager.WifiLock? = null private var wakeLock: PowerManager.WakeLock? = null @@ -130,27 +129,9 @@ class FavoritesSyncHelper(val context: Context) { try { // Take wake + wifi locks ignore { wakeLock?.release() } - wakeLock = ignore { - context.powerManager.newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, - "teh:ExhFavoritesSyncWakelock", - ) - } + wakeLock = ignore { context.createPartialWakeLock("teh:ExhFavoritesSyncWakelock") } ignore { wifiLock?.release() } - wifiLock = ignore { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - context.wifiManager.createWifiLock( - WifiManager.WIFI_MODE_FULL_LOW_LATENCY, - "teh:ExhFavoritesSyncWifi", - ) - } else { - @Suppress("DEPRECATION") - context.wifiManager.createWifiLock( - WifiManager.WIFI_MODE_FULL_HIGH_PERF, - "teh:ExhFavoritesSyncWifi", - ) - } - } + wifiLock = ignore { context.createWifiLock("teh:ExhFavoritesSyncWifi") } // Do not update galleries while syncing favorites EHentaiUpdateWorker.cancelBackground(context) diff --git a/app/src/main/java/exh/md/similar/MangaDexSimilarPagingSource.kt b/app/src/main/java/exh/md/similar/MangaDexSimilarPagingSource.kt index 7458ecfc2..731d2a31e 100644 --- a/app/src/main/java/exh/md/similar/MangaDexSimilarPagingSource.kt +++ b/app/src/main/java/exh/md/similar/MangaDexSimilarPagingSource.kt @@ -2,6 +2,7 @@ package exh.md.similar import dev.icerock.moko.resources.StringResource import eu.kanade.domain.manga.model.toSManga +import eu.kanade.tachiyomi.network.HttpException import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MetadataMangasPage import eu.kanade.tachiyomi.source.online.all.MangaDex @@ -19,7 +20,7 @@ import tachiyomi.i18n.sy.SYMR class MangaDexSimilarPagingSource( manga: Manga, private val mangaDex: MangaDex, -) : RecommendationPagingSource(mangaDex, manga) { +) : RecommendationPagingSource(manga, mangaDex) { override val name: String get() = "MangaDex" @@ -32,16 +33,23 @@ class MangaDexSimilarPagingSource( override suspend fun requestNextPage(currentPage: Int): MangasPage { val mangasPage = coroutineScope { - val similarPageDef = async { mangaDex.getMangaSimilar(manga.toSManga()) } - val relatedPageDef = async { mangaDex.getMangaRelated(manga.toSManga()) } - val similarPage = similarPageDef.await() - val relatedPage = relatedPageDef.await() + try { + val similarPageDef = async { mangaDex.getMangaSimilar(manga.toSManga()) } + val relatedPageDef = async { mangaDex.getMangaRelated(manga.toSManga()) } + val similarPage = similarPageDef.await() + val relatedPage = relatedPageDef.await() - MetadataMangasPage( - relatedPage.mangas + similarPage.mangas, - false, - relatedPage.mangasMetadata + similarPage.mangasMetadata, - ) + MetadataMangasPage( + relatedPage.mangas + similarPage.mangas, + false, + relatedPage.mangasMetadata + similarPage.mangasMetadata, + ) + } catch (e: HttpException) { + when (e.code) { + 404 -> throw NoResultsException() + else -> throw e + } + } } return mangasPage.takeIf { it.mangas.isNotEmpty() } ?: throw NoResultsException() diff --git a/app/src/main/java/exh/recs/BrowseRecommendsScreen.kt b/app/src/main/java/exh/recs/BrowseRecommendsScreen.kt index a22a549e3..5a9a9604e 100644 --- a/app/src/main/java/exh/recs/BrowseRecommendsScreen.kt +++ b/app/src/main/java/exh/recs/BrowseRecommendsScreen.kt @@ -15,19 +15,28 @@ import eu.kanade.presentation.util.Screen import eu.kanade.tachiyomi.ui.browse.source.SourcesScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.webview.WebViewActivity +import exh.recs.batch.RankedSearchResults import exh.ui.ifSourcesLoaded import mihon.presentation.core.util.collectAsLazyPagingItems import tachiyomi.domain.manga.model.Manga import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.screens.LoadingScreen +import java.io.Serializable class BrowseRecommendsScreen( - private val mangaId: Long, - private val sourceId: Long, - private val recommendationSourceName: String, + private val args: Args, private val isExternalSource: Boolean, ) : Screen() { + sealed interface Args : Serializable { + data class SingleSourceManga( + val mangaId: Long, + val sourceId: Long, + val recommendationSourceName: String, + ) : Args + data class MergedSourceMangas(val results: RankedSearchResults) : Args + } + @Composable override fun Content() { if (!ifSourcesLoaded()) { @@ -38,9 +47,7 @@ class BrowseRecommendsScreen( val context = LocalContext.current val navigator = LocalNavigator.currentOrThrow - val screenModel = rememberScreenModel { - BrowseRecommendsScreenModel(mangaId, sourceId, recommendationSourceName) - } + val screenModel = rememberScreenModel { BrowseRecommendsScreenModel(args) } val snackbarHostState = remember { SnackbarHostState() } val onClickItem = { manga: Manga -> diff --git a/app/src/main/java/exh/recs/BrowseRecommendsScreenModel.kt b/app/src/main/java/exh/recs/BrowseRecommendsScreenModel.kt index b0b5074de..49706c0d9 100644 --- a/app/src/main/java/exh/recs/BrowseRecommendsScreenModel.kt +++ b/app/src/main/java/exh/recs/BrowseRecommendsScreenModel.kt @@ -3,29 +3,47 @@ package exh.recs import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel +import exh.metadata.metadata.RaisedSearchMetadata import exh.recs.sources.RecommendationPagingSource +import exh.recs.sources.StaticResultPagingSource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.update import kotlinx.coroutines.runBlocking import tachiyomi.domain.manga.interactor.GetManga +import tachiyomi.domain.manga.model.Manga import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get class BrowseRecommendsScreenModel( - val mangaId: Long, - sourceId: Long, - private val recommendationSourceName: String, + private val args: BrowseRecommendsScreen.Args, private val getManga: GetManga = Injekt.get(), -) : BrowseSourceScreenModel(sourceId, null) { - - val manga = runBlocking { getManga.await(mangaId) }!! - +) : BrowseSourceScreenModel( + sourceId = when (args) { + is BrowseRecommendsScreen.Args.SingleSourceManga -> args.sourceId + is BrowseRecommendsScreen.Args.MergedSourceMangas -> args.results.recAssociatedSourceId ?: -1 + }, + listingQuery = null, +) { val recommendationSource: RecommendationPagingSource - get() = RecommendationPagingSource.createSources(manga, source as CatalogueSource).first { - it::class.qualifiedName == recommendationSourceName + get() = when (args) { + is BrowseRecommendsScreen.Args.MergedSourceMangas -> StaticResultPagingSource(args.results) + is BrowseRecommendsScreen.Args.SingleSourceManga -> RecommendationPagingSource.createSources( + runBlocking { getManga.await(args.mangaId)!! }, + source as CatalogueSource, + ).first { + it::class.qualifiedName == args.recommendationSourceName + } } override fun createSourcePagingSource(query: String, filters: FilterList) = recommendationSource + override fun Flow.combineMetadata(metadata: RaisedSearchMetadata?): Flow> { + // Overridden to prevent our custom metadata from being replaced from a cache + return flatMapLatest { manga -> flowOf(manga to metadata) } + } + init { mutableState.update { it.copy(filterable = false) } } diff --git a/app/src/main/java/exh/recs/RecommendsScreen.kt b/app/src/main/java/exh/recs/RecommendsScreen.kt index 526cdcab2..24789c178 100644 --- a/app/src/main/java/exh/recs/RecommendsScreen.kt +++ b/app/src/main/java/exh/recs/RecommendsScreen.kt @@ -11,12 +11,24 @@ import eu.kanade.presentation.util.Screen import eu.kanade.tachiyomi.ui.browse.source.SourcesScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.webview.WebViewActivity +import exh.recs.RecommendsScreen.Args.MergedSourceMangas +import exh.recs.RecommendsScreen.Args.SingleSourceManga +import exh.recs.batch.RankedSearchResults import exh.recs.components.RecommendsScreen +import exh.recs.sources.StaticResultPagingSource import exh.ui.ifSourcesLoaded import tachiyomi.domain.manga.model.Manga +import tachiyomi.i18n.sy.SYMR +import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.screens.LoadingScreen +import java.io.Serializable -class RecommendsScreen(val mangaId: Long, val sourceId: Long) : Screen() { +class RecommendsScreen(private val args: Args) : Screen() { + + sealed interface Args : Serializable { + data class SingleSourceManga(val mangaId: Long, val sourceId: Long) : Args + data class MergedSourceMangas(val mergedResults: List) : Args + } @Composable override fun Content() { @@ -28,9 +40,7 @@ class RecommendsScreen(val mangaId: Long, val sourceId: Long) : Screen() { val context = LocalContext.current val navigator = LocalNavigator.currentOrThrow - val screenModel = rememberScreenModel { - RecommendsScreenModel(mangaId = mangaId, sourceId = sourceId) - } + val screenModel = rememberScreenModel { RecommendsScreenModel(args) } val state by screenModel.state.collectAsState() val onClickItem = { manga: Manga -> @@ -50,7 +60,11 @@ class RecommendsScreen(val mangaId: Long, val sourceId: Long) : Screen() { } RecommendsScreen( - manga = state.manga, + title = if (args is SingleSourceManga) { + stringResource(SYMR.strings.similar, state.title.orEmpty()) + } else { + stringResource(SYMR.strings.rec_common_recommendations) + }, state = state, navigateUp = navigator::pop, getManga = @Composable { manga: Manga -> screenModel.getManga(manga) }, @@ -58,9 +72,18 @@ class RecommendsScreen(val mangaId: Long, val sourceId: Long) : Screen() { // Pass class name of paging source as screens need to be serializable navigator.push( BrowseRecommendsScreen( - mangaId, - sourceId, - pagingSource::class.qualifiedName!!, + when (args) { + is SingleSourceManga -> + BrowseRecommendsScreen.Args.SingleSourceManga( + args.mangaId, + args.sourceId, + pagingSource::class.qualifiedName!!, + ) + is MergedSourceMangas -> + BrowseRecommendsScreen.Args.MergedSourceMangas( + (pagingSource as StaticResultPagingSource).data, + ) + }, pagingSource.associatedSourceId == null, ), ) diff --git a/app/src/main/java/exh/recs/RecommendsScreenModel.kt b/app/src/main/java/exh/recs/RecommendsScreenModel.kt index c499e3a85..60095d8a1 100644 --- a/app/src/main/java/exh/recs/RecommendsScreenModel.kt +++ b/app/src/main/java/exh/recs/RecommendsScreenModel.kt @@ -8,6 +8,7 @@ import eu.kanade.domain.manga.model.toDomainManga import eu.kanade.presentation.util.ioCoroutineScope import eu.kanade.tachiyomi.source.CatalogueSource import exh.recs.sources.RecommendationPagingSource +import exh.recs.sources.StaticResultPagingSource import kotlinx.collections.immutable.PersistentMap import kotlinx.collections.immutable.mutate import kotlinx.collections.immutable.persistentMapOf @@ -30,15 +31,12 @@ import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get open class RecommendsScreenModel( - val mangaId: Long, - val sourceId: Long, + private val args: RecommendsScreen.Args, sourceManager: SourceManager = Injekt.get(), private val getManga: GetManga = Injekt.get(), private val networkToLocalManga: NetworkToLocalManga = Injekt.get(), ) : StateScreenModel(State()) { - val source = sourceManager.getOrStub(sourceId) as CatalogueSource - private val coroutineDispatcher = Dispatchers.IO.limitedParallelism(5) private val sortComparator = { map: Map -> @@ -51,9 +49,20 @@ open class RecommendsScreenModel( init { ioCoroutineScope.launch { - val manga = getManga.await(mangaId)!! - mutableState.update { it.copy(manga = manga) } - val recommendationSources = RecommendationPagingSource.createSources(manga, source) + val recommendationSources = when (args) { + is RecommendsScreen.Args.SingleSourceManga -> { + val manga = getManga.await(args.mangaId)!! + mutableState.update { it.copy(title = manga.title) } + + RecommendationPagingSource.createSources( + manga, + sourceManager.getOrStub(args.sourceId) as CatalogueSource, + ) + } + is RecommendsScreen.Args.MergedSourceMangas -> { + args.mergedResults.map(::StaticResultPagingSource) + } + } updateItems( recommendationSources @@ -118,15 +127,17 @@ open class RecommendsScreenModel( } private fun updateItem(source: RecommendationPagingSource, result: RecommendationItemResult) { - val newItems = state.value.items.mutate { - it[source] = result + synchronized(state.value.items) { + val newItems = state.value.items.mutate { + it[source] = result + } + updateItems(newItems) } - updateItems(newItems) } @Immutable data class State( - val manga: Manga? = null, + val title: String? = null, val items: PersistentMap = persistentMapOf(), ) { val progress: Int = items.count { it.value !is RecommendationItemResult.Loading } diff --git a/app/src/main/java/exh/recs/batch/RecommendationSearchBottomSheetDialog.kt b/app/src/main/java/exh/recs/batch/RecommendationSearchBottomSheetDialog.kt new file mode 100644 index 000000000..535bed570 --- /dev/null +++ b/app/src/main/java/exh/recs/batch/RecommendationSearchBottomSheetDialog.kt @@ -0,0 +1,65 @@ +package exh.recs.batch + +import android.view.LayoutInflater +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import eu.kanade.presentation.components.AdaptiveSheet +import eu.kanade.tachiyomi.databinding.RecommendationSearchBottomSheetBinding +import tachiyomi.domain.UnsortedPreferences +import uy.kohesive.injekt.injectLazy + +@Composable +fun RecommendationSearchBottomSheetDialog( + onDismissRequest: () -> Unit, + onSearchRequest: () -> Unit, +) { + val state = remember { RecommendationSearchBottomSheetDialogState(onSearchRequest) } + AdaptiveSheet(onDismissRequest = onDismissRequest) { + AndroidView( + factory = { factoryContext -> + val binding = RecommendationSearchBottomSheetBinding.inflate(LayoutInflater.from(factoryContext)) + state.initPreferences(binding) + binding.root + }, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +class RecommendationSearchBottomSheetDialogState(private val onSearchRequest: () -> Unit) { + private val preferences: UnsortedPreferences by injectLazy() + + fun initPreferences(binding: RecommendationSearchBottomSheetBinding) { + val flags = preferences.recommendationSearchFlags().get() + + binding.recSources.isChecked = SearchFlags.hasIncludeSources(flags) + binding.recTrackers.isChecked = SearchFlags.hasIncludeTrackers(flags) + binding.recHideLibraryEntries.isChecked = SearchFlags.hasHideLibraryResults(flags) + + binding.recSources.setOnCheckedChangeListener { _, _ -> setFlags(binding) } + binding.recTrackers.setOnCheckedChangeListener { _, _ -> setFlags(binding) } + binding.recHideLibraryEntries.setOnCheckedChangeListener { _, _ -> setFlags(binding) } + + binding.recSearchBtn.setOnClickListener { _ -> onSearchRequest() } + + validate(binding) + } + + private fun setFlags(binding: RecommendationSearchBottomSheetBinding) { + var flags = 0 + if (binding.recSources.isChecked) flags = flags or SearchFlags.INCLUDE_SOURCES + if (binding.recTrackers.isChecked) flags = flags or SearchFlags.INCLUDE_TRACKERS + if (binding.recHideLibraryEntries.isChecked) flags = flags or SearchFlags.HIDE_LIBRARY_RESULTS + preferences.recommendationSearchFlags().set(flags) + + validate(binding) + } + + private fun validate(binding: RecommendationSearchBottomSheetBinding) { + // Only enable search button if at least one of the checkboxes is checked + binding.recSearchBtn.isEnabled = binding.recSources.isChecked || binding.recTrackers.isChecked + } +} diff --git a/app/src/main/java/exh/recs/batch/RecommendationSearchHelper.kt b/app/src/main/java/exh/recs/batch/RecommendationSearchHelper.kt new file mode 100644 index 000000000..adb183b90 --- /dev/null +++ b/app/src/main/java/exh/recs/batch/RecommendationSearchHelper.kt @@ -0,0 +1,248 @@ +package exh.recs.batch + +import android.content.Context +import android.net.wifi.WifiManager +import android.os.PowerManager +import androidx.annotation.StringRes +import androidx.core.net.toUri +import eu.kanade.domain.manga.model.toDomainManga +import eu.kanade.domain.manga.model.toSManga +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.model.SManga +import exh.log.xLog +import exh.recs.sources.RecommendationPagingSource +import exh.recs.sources.TrackerRecommendationPagingSource +import exh.smartsearch.SmartLibrarySearchEngine +import exh.util.ThrottleManager +import exh.util.createPartialWakeLock +import exh.util.createWifiLock +import exh.util.ignore +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import tachiyomi.data.source.NoResultsException +import tachiyomi.domain.UnsortedPreferences +import tachiyomi.domain.library.model.LibraryManga +import tachiyomi.domain.manga.interactor.GetLibraryManga +import tachiyomi.domain.manga.interactor.NetworkToLocalManga +import tachiyomi.domain.manga.model.Manga +import tachiyomi.domain.source.service.SourceManager +import tachiyomi.domain.track.interactor.GetTracks +import tachiyomi.domain.track.model.Track +import uy.kohesive.injekt.injectLazy +import java.io.Serializable +import java.util.Collections +import kotlin.coroutines.coroutineContext +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class RecommendationSearchHelper(val context: Context) { + private val getLibraryManga: GetLibraryManga by injectLazy() + private val getTracks: GetTracks by injectLazy() + private val networkToLocalManga: NetworkToLocalManga by injectLazy() + private val sourceManager: SourceManager by injectLazy() + private val prefs: UnsortedPreferences by injectLazy() + + private var wifiLock: WifiManager.WifiLock? = null + private var wakeLock: PowerManager.WakeLock? = null + + private val smartSearchEngine by lazy { SmartLibrarySearchEngine() } + + private val logger by lazy { xLog() } + + val status: MutableStateFlow = MutableStateFlow(SearchStatus.Idle) + + @Synchronized + fun runSearch(scope: CoroutineScope, mangaList: List): Job? { + if (status.value !is SearchStatus.Idle) { + return null + } + + status.value = SearchStatus.Initializing + + return scope.launch(Dispatchers.IO) { beginSearch(mangaList) } + } + + private suspend fun beginSearch(mangaList: List) { + val flags = prefs.recommendationSearchFlags().get() + val libraryManga = getLibraryManga.await() + val tracks = getTracks.await() + + // Trackers such as MAL need to be throttled more strictly + val stricterThrottling = SearchFlags.hasIncludeTrackers(flags) + + val throttleManager = + ThrottleManager( + max = 3.seconds, + inc = 50.milliseconds, + initial = if (stricterThrottling) 2.seconds else 0.seconds, + ) + + try { + // Take wake + wifi locks + ignore { wakeLock?.release() } + wakeLock = ignore { context.createPartialWakeLock("tsy:RecommendationSearchWakelock") } + ignore { wifiLock?.release() } + wifiLock = ignore { context.createWifiLock("tsy:RecommendationSearchWifiLock") } + + // Map of results grouped by recommendation source + val resultsMap = Collections.synchronizedMap(mutableMapOf()) + + mangaList.forEachIndexed { index, sourceManga -> + // Check if the job has been cancelled + coroutineContext.ensureActive() + + status.value = SearchStatus.Processing(sourceManga.toSManga(), index + 1, mangaList.size) + + val jobs = RecommendationPagingSource.createSources( + sourceManga, + sourceManager.get(sourceManga.source) as CatalogueSource, + ).mapNotNull { source -> + // Apply source filters + if (source is TrackerRecommendationPagingSource && !SearchFlags.hasIncludeTrackers(flags)) { + return@mapNotNull null + } + + if (source.associatedSourceId != null && !SearchFlags.hasIncludeSources(flags)) { + return@mapNotNull null + } + + // Parallelize fetching recommendations from all sources in the current context + CoroutineScope(coroutineContext).async(Dispatchers.IO) { + val recSourceId = source::class.qualifiedName!! + + try { + val page = source.requestNextPage(1) + + // Try to filter out mangas that are already in the library + val mangas = page.mangas + .filterLibraryItemsIfEnabled(source, libraryManga, tracks) + + // Add or update the result collection for the current source + resultsMap.getOrPut(recSourceId) { + SearchResults( + recSourceName = source.name, + recSourceCategoryResId = source.category.resourceId, + recAssociatedSourceId = source.associatedSourceId, + results = mutableListOf(), + ) + }.results.addAll(mangas) + } catch (_: NoResultsException) { + } catch (e: Exception) { + logger.e("Error while fetching recommendations for $recSourceId", e) + } + } + } + jobs.awaitAll() + + // Continuously slow down the search to avoid hitting rate limits + throttleManager.throttle() + } + + val rankedMap = resultsMap.map { + RankedSearchResults( + recSourceName = it.value.recSourceName, + recSourceCategoryResId = it.value.recSourceCategoryResId, + recAssociatedSourceId = it.value.recAssociatedSourceId, + results = it.value.results + // Group by URL and count occurrences + .groupingBy(SManga::url) + .eachCount() + .entries + // Sort by occurrences desc + .sortedByDescending(Map.Entry::value) + // Resolve SManga instances from URL keys + .associate { (url, count) -> + val manga = it.value.results.first { manga -> manga.url == url } + manga to count + }, + ) + } + + status.value = when { + rankedMap.isNotEmpty() -> SearchStatus.Finished.WithResults(rankedMap) + else -> SearchStatus.Finished.WithoutResults + } + } catch (_: CancellationException) { + } catch (e: Exception) { + status.value = SearchStatus.Error(e.message.orEmpty()) + logger.e("Error during recommendation search", e) + return + } finally { + // Release wake + wifi locks + ignore { + wakeLock?.release() + wakeLock = null + } + ignore { + wifiLock?.release() + wifiLock = null + } + } + } + + private suspend fun List.filterLibraryItemsIfEnabled( + recSource: RecommendationPagingSource, + libraryManga: List, + tracks: List, + ): List { + val flags = prefs.recommendationSearchFlags().get() + + if (!SearchFlags.hasHideLibraryResults(flags)) { + return this + } + + return filterNot { manga -> + // Source recommendations can be directly resolved, if the recommendation is from the same source + recSource.associatedSourceId?.let { srcId -> + return@filterNot networkToLocalManga + .await(manga.toDomainManga(srcId)) + .let { local -> libraryManga.any { it.id == local.id } } + } + + // Tracker recommendations can be resolved by checking if the tracker is attached to the recommendation + if (recSource is TrackerRecommendationPagingSource) { + recSource.associatedTrackerId?.let { trackerId -> + return@filterNot tracks.any { + it.trackerId == trackerId && it.remoteUrl.toUri().path == manga.url.toUri().path + } + } + } + + // Fallback to smart search otherwise + smartSearchEngine.smartSearch(libraryManga, manga.title) != null + } + } +} + +// Contains the search results for a single source +private typealias SearchResults = Results> + +// Contains the ranked search results for a single source +typealias RankedSearchResults = Results> + +data class Results( + val recSourceName: String, + @StringRes val recSourceCategoryResId: Int, + val recAssociatedSourceId: Long?, + val results: T, +) : Serializable + +sealed interface SearchStatus { + data object Idle : SearchStatus + data object Initializing : SearchStatus + data class Processing(val manga: SManga, val current: Int, val total: Int) : SearchStatus + data class Error(val message: String) : SearchStatus + data object Cancelling : SearchStatus + + sealed interface Finished : SearchStatus { + data class WithResults(val results: List) : Finished + data object WithoutResults : Finished + } +} diff --git a/app/src/main/java/exh/recs/batch/RecommendationSearchProgressDialog.kt b/app/src/main/java/exh/recs/batch/RecommendationSearchProgressDialog.kt new file mode 100644 index 000000000..92716fa61 --- /dev/null +++ b/app/src/main/java/exh/recs/batch/RecommendationSearchProgressDialog.kt @@ -0,0 +1,119 @@ +package exh.recs.batch + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import tachiyomi.core.common.i18n.stringResource +import tachiyomi.i18n.MR +import tachiyomi.i18n.sy.SYMR + +data class RecommendationSearchProgressProperties( + val title: String, + val text: String, + val positiveButtonText: String? = null, + val positiveButton: (() -> Unit)? = null, + val negativeButtonText: String? = null, + val negativeButton: (() -> Unit)? = null, +) + +@Composable +fun RecommendationSearchProgressDialog( + status: SearchStatus, + setStatusIdle: () -> Unit, + setStatusCancelling: () -> Unit, +) { + val context = LocalContext.current + val currentView = LocalView.current + + DisposableEffect(Unit) { + currentView.keepScreenOn = true + onDispose { + currentView.keepScreenOn = false + } + } + + val properties by produceState(initialValue = null, status) { + value = when (status) { + is SearchStatus.Initializing -> { + RecommendationSearchProgressProperties( + title = context.stringResource(SYMR.strings.rec_collecting), + text = context.stringResource(SYMR.strings.rec_initializing), + negativeButtonText = context.stringResource(MR.strings.action_cancel), + negativeButton = setStatusCancelling, + ) + } + is SearchStatus.Error -> { + RecommendationSearchProgressProperties( + title = context.stringResource(SYMR.strings.rec_error_title), + text = context.stringResource(SYMR.strings.rec_error_string, status.message), + positiveButtonText = context.stringResource(MR.strings.action_ok), + positiveButton = setStatusIdle, + ) + } + is SearchStatus.Processing -> { + RecommendationSearchProgressProperties( + title = context.stringResource(SYMR.strings.rec_collecting), + text = context.stringResource(SYMR.strings.rec_processing_state, status.current, status.total) + "\n\n" + status.manga.title, + negativeButtonText = context.stringResource(MR.strings.action_cancel), + negativeButton = setStatusCancelling, + ) + } + else -> null + } + } + val dialog = properties + if (dialog != null) { + AlertDialog( + onDismissRequest = {}, + confirmButton = { + if (dialog.positiveButton != null && dialog.positiveButtonText != null) { + TextButton(onClick = dialog.positiveButton) { + Text(text = dialog.positiveButtonText) + } + } + }, + dismissButton = { + if (dialog.negativeButton != null && dialog.negativeButtonText != null) { + TextButton(onClick = dialog.negativeButton) { + Text(text = dialog.negativeButtonText) + } + } + }, + title = { + Text(text = dialog.title) + }, + text = { + Column( + Modifier.verticalScroll(rememberScrollState()), + ) { + Text(text = dialog.text) + if (status is SearchStatus.Processing) { + LinearProgressIndicator( + progress = { status.current.toFloat() / status.total }, + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + ) + } + } + }, + properties = DialogProperties( + dismissOnClickOutside = false, + dismissOnBackPress = false, + ), + ) + } +} diff --git a/app/src/main/java/exh/recs/batch/SearchFlags.kt b/app/src/main/java/exh/recs/batch/SearchFlags.kt new file mode 100644 index 000000000..589459cc4 --- /dev/null +++ b/app/src/main/java/exh/recs/batch/SearchFlags.kt @@ -0,0 +1,20 @@ +package exh.recs.batch + +object SearchFlags { + + const val INCLUDE_SOURCES = 0b00001 + const val INCLUDE_TRACKERS = 0b00010 + const val HIDE_LIBRARY_RESULTS = 0b00100 + + fun hasIncludeSources(value: Int): Boolean { + return value and INCLUDE_SOURCES != 0 + } + + fun hasIncludeTrackers(value: Int): Boolean { + return value and INCLUDE_TRACKERS != 0 + } + + fun hasHideLibraryResults(value: Int): Boolean { + return value and HIDE_LIBRARY_RESULTS != 0 + } +} diff --git a/app/src/main/java/exh/recs/components/RecommendsScreen.kt b/app/src/main/java/exh/recs/components/RecommendsScreen.kt index c8fcf5749..3e0e62066 100644 --- a/app/src/main/java/exh/recs/components/RecommendsScreen.kt +++ b/app/src/main/java/exh/recs/components/RecommendsScreen.kt @@ -17,13 +17,12 @@ import exh.recs.sources.RecommendationPagingSource import kotlinx.collections.immutable.ImmutableMap import nl.adaptivity.xmlutil.core.impl.multiplatform.name import tachiyomi.domain.manga.model.Manga -import tachiyomi.i18n.sy.SYMR import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.i18n.stringResource @Composable fun RecommendsScreen( - manga: Manga?, + title: String, state: RecommendsScreenModel.State, navigateUp: () -> Unit, getManga: @Composable (Manga) -> State, @@ -34,7 +33,7 @@ fun RecommendsScreen( Scaffold( topBar = { scrollBehavior -> AppBar( - title = stringResource(SYMR.strings.similar, manga?.title.orEmpty()), + title = title, scrollBehavior = scrollBehavior, navigateUp = navigateUp, ) @@ -64,7 +63,7 @@ internal fun RecommendsContent( contentPadding = contentPadding, ) { items.forEach { (source, recResult) -> - item(key = source::class.name) { + item(key = "${source::class.name}-${source.name}-${source.category.resourceId}") { GlobalSearchResultItem( title = source.name, subtitle = stringResource(source.category), diff --git a/app/src/main/java/exh/recs/sources/AniListPagingSource.kt b/app/src/main/java/exh/recs/sources/AniListPagingSource.kt index 66994cfd4..87d3df0f7 100644 --- a/app/src/main/java/exh/recs/sources/AniListPagingSource.kt +++ b/app/src/main/java/exh/recs/sources/AniListPagingSource.kt @@ -4,7 +4,6 @@ import dev.icerock.moko.resources.StringResource import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.parseAs -import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.model.SManga import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -18,11 +17,12 @@ import kotlinx.serialization.json.put import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody import tachiyomi.core.common.util.system.logcat +import tachiyomi.data.source.NoResultsException import tachiyomi.domain.manga.model.Manga import tachiyomi.i18n.sy.SYMR -class AniListPagingSource(manga: Manga, source: CatalogueSource) : TrackerRecommendationPagingSource( - "https://graphql.anilist.co/", source, manga, +class AniListPagingSource(manga: Manga) : TrackerRecommendationPagingSource( + "https://graphql.anilist.co/", manga, ) { override val name: String get() = "AniList" @@ -84,7 +84,7 @@ class AniListPagingSource(manga: Manga, source: CatalogueSource) : TrackerRecomm .jsonObject["Page"]!! .jsonObject["media"]!! .jsonArray - .ifEmpty { throw Exception("'$queryParam' not found") } + .ifEmpty { throw NoResultsException() } .filter() return media.flatMap { it.jsonObject["recommendations"]!!.jsonObject["edges"]!!.jsonArray }.map { diff --git a/app/src/main/java/exh/recs/sources/ComickPagingSource.kt b/app/src/main/java/exh/recs/sources/ComickPagingSource.kt index 84e9dd960..29f060a3f 100644 --- a/app/src/main/java/exh/recs/sources/ComickPagingSource.kt +++ b/app/src/main/java/exh/recs/sources/ComickPagingSource.kt @@ -28,7 +28,7 @@ fun CatalogueSource.isComickSource() = name == "Comick" class ComickPagingSource( manga: Manga, private val comickSource: CatalogueSource, -) : RecommendationPagingSource(comickSource, manga) { +) : RecommendationPagingSource(manga, comickSource) { override val name: String get() = "Comick" diff --git a/app/src/main/java/exh/recs/sources/MangaUpdatesPagingSource.kt b/app/src/main/java/exh/recs/sources/MangaUpdatesPagingSource.kt index bf0bdef5e..686956a27 100644 --- a/app/src/main/java/exh/recs/sources/MangaUpdatesPagingSource.kt +++ b/app/src/main/java/exh/recs/sources/MangaUpdatesPagingSource.kt @@ -5,7 +5,6 @@ import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.parseAs -import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.model.SManga import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -20,11 +19,12 @@ import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody import tachiyomi.core.common.util.system.logcat +import tachiyomi.data.source.NoResultsException import tachiyomi.domain.manga.model.Manga import tachiyomi.i18n.sy.SYMR -abstract class MangaUpdatesPagingSource(manga: Manga, source: CatalogueSource) : TrackerRecommendationPagingSource( - "https://api.mangaupdates.com/v1/", source, manga, +abstract class MangaUpdatesPagingSource(manga: Manga) : TrackerRecommendationPagingSource( + "https://api.mangaupdates.com/v1/", manga, ) { override val name: String get() = "MangaUpdates" @@ -89,7 +89,7 @@ abstract class MangaUpdatesPagingSource(manga: Manga, source: CatalogueSource) : return getRecsById( data["results"]!! .jsonArray - .ifEmpty { throw Exception("'$search' not found") } + .ifEmpty { throw NoResultsException() } .first() .jsonObject["record"]!! .jsonObject["series_id"]!! @@ -98,14 +98,14 @@ abstract class MangaUpdatesPagingSource(manga: Manga, source: CatalogueSource) : } } -class MangaUpdatesCommunityPagingSource(manga: Manga, source: CatalogueSource) : MangaUpdatesPagingSource(manga, source) { +class MangaUpdatesCommunityPagingSource(manga: Manga) : MangaUpdatesPagingSource(manga) { override val category: StringResource get() = SYMR.strings.community_recommendations override val recommendationJsonObjectName: String get() = "recommendations" } -class MangaUpdatesSimilarPagingSource(manga: Manga, source: CatalogueSource) : MangaUpdatesPagingSource(manga, source) { +class MangaUpdatesSimilarPagingSource(manga: Manga) : MangaUpdatesPagingSource(manga) { override val category: StringResource get() = SYMR.strings.similar_titles override val recommendationJsonObjectName: String diff --git a/app/src/main/java/exh/recs/sources/MyAnimeListPagingSource.kt b/app/src/main/java/exh/recs/sources/MyAnimeListPagingSource.kt index a4cb8a8e0..3a997b21a 100644 --- a/app/src/main/java/exh/recs/sources/MyAnimeListPagingSource.kt +++ b/app/src/main/java/exh/recs/sources/MyAnimeListPagingSource.kt @@ -4,7 +4,6 @@ import dev.icerock.moko.resources.StringResource import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.parseAs -import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.model.SManga import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject @@ -17,8 +16,8 @@ import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.manga.model.Manga import tachiyomi.i18n.sy.SYMR -class MyAnimeListPagingSource(manga: Manga, source: CatalogueSource) : TrackerRecommendationPagingSource( - "https://api.jikan.moe/v4/", source, manga, +class MyAnimeListPagingSource(manga: Manga) : TrackerRecommendationPagingSource( + "https://api.jikan.moe/v4/", manga, ) { override val name: String get() = "MyAnimeList" diff --git a/app/src/main/java/exh/recs/sources/RecommendationPagingSource.kt b/app/src/main/java/exh/recs/sources/RecommendationPagingSource.kt index 52bdeb98a..f0ad18529 100644 --- a/app/src/main/java/exh/recs/sources/RecommendationPagingSource.kt +++ b/app/src/main/java/exh/recs/sources/RecommendationPagingSource.kt @@ -18,6 +18,7 @@ import tachiyomi.data.source.NoResultsException import tachiyomi.data.source.SourcePagingSource import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.track.interactor.GetTracks +import tachiyomi.i18n.sy.SYMR import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy @@ -26,14 +27,14 @@ import uy.kohesive.injekt.injectLazy * General class for recommendation sources. */ abstract class RecommendationPagingSource( - source: CatalogueSource, protected val manga: Manga, + source: CatalogueSource? = null, ) : SourcePagingSource(source) { // Display name abstract val name: String // Localized category name - abstract val category: StringResource + open val category: StringResource = SYMR.strings.similar_titles /** * Recommendation sources that display results from a source extension, @@ -46,10 +47,10 @@ abstract class RecommendationPagingSource( companion object { fun createSources(manga: Manga, source: CatalogueSource): List { return buildList { - add(AniListPagingSource(manga, source)) - add(MangaUpdatesCommunityPagingSource(manga, source)) - add(MangaUpdatesSimilarPagingSource(manga, source)) - add(MyAnimeListPagingSource(manga, source)) + add(AniListPagingSource(manga)) + add(MangaUpdatesCommunityPagingSource(manga)) + add(MangaUpdatesSimilarPagingSource(manga)) + add(MyAnimeListPagingSource(manga)) // Only include MangaDex if the delegate sources are enabled and the source is MD-based if (source.isMdBasedSource() && Injekt.get().delegateSources().get()) { @@ -70,9 +71,8 @@ abstract class RecommendationPagingSource( */ abstract class TrackerRecommendationPagingSource( protected val endpoint: String, - source: CatalogueSource, manga: Manga, -) : RecommendationPagingSource(source, manga) { +) : RecommendationPagingSource(manga) { private val getTracks: GetTracks by injectLazy() protected val trackerManager: TrackerManager by injectLazy() @@ -86,7 +86,7 @@ abstract class TrackerRecommendationPagingSource( * the remote id will be used to directly identify the manga on the tracker. * Otherwise, a search will be performed using the manga title. */ - protected abstract val associatedTrackerId: Long? + abstract val associatedTrackerId: Long? abstract suspend fun getRecsBySearch(search: String): List abstract suspend fun getRecsById(id: String): List @@ -105,7 +105,10 @@ abstract class TrackerRecommendationPagingSource( results.ifEmpty { throw NoResultsException() } } catch (e: Exception) { - logcat(LogPriority.ERROR, e) { name } + // 'No results' should not be logged as it happens frequently and is expected + if (e !is NoResultsException) { + logcat(LogPriority.ERROR, e) { name } + } throw e } diff --git a/app/src/main/java/exh/recs/sources/StaticResultPagingSource.kt b/app/src/main/java/exh/recs/sources/StaticResultPagingSource.kt new file mode 100644 index 000000000..9eaa65276 --- /dev/null +++ b/app/src/main/java/exh/recs/sources/StaticResultPagingSource.kt @@ -0,0 +1,39 @@ +package exh.recs.sources + +import dev.icerock.moko.resources.StringResource +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.MetadataMangasPage +import exh.metadata.metadata.RankedSearchMetadata +import exh.recs.batch.RankedSearchResults +import tachiyomi.domain.manga.model.Manga + +class StaticResultPagingSource( + val data: RankedSearchResults, +) : RecommendationPagingSource(Manga.create()) { + + override val name: String get() = data.recSourceName + override val category: StringResource get() = StringResource(data.recSourceCategoryResId) + override val associatedSourceId: Long? get() = data.recAssociatedSourceId + + override suspend fun requestNextPage(currentPage: Int): MangasPage = + // Use virtual paging to improve performance for large lists + data.results + .entries + .chunked(PAGE_SIZE) + .getOrElse(currentPage - 1) { emptyList() } + .let { chunk -> + MetadataMangasPage( + mangas = chunk.map { it.key }, + hasNextPage = data.results.size > currentPage * PAGE_SIZE, + mangasMetadata = chunk + .map { it.value } + .map { count -> + RankedSearchMetadata().also { it.rank = count } + }, + ) + } + + companion object { + const val PAGE_SIZE = 25 + } +} diff --git a/app/src/main/java/exh/smartsearch/SmartSearchEngine.kt b/app/src/main/java/exh/smartsearch/BaseSmartSearchEngine.kt similarity index 80% rename from app/src/main/java/exh/smartsearch/SmartSearchEngine.kt rename to app/src/main/java/exh/smartsearch/BaseSmartSearchEngine.kt index 48b3c819e..1c975fbf7 100644 --- a/app/src/main/java/exh/smartsearch/SmartSearchEngine.kt +++ b/app/src/main/java/exh/smartsearch/BaseSmartSearchEngine.kt @@ -1,22 +1,22 @@ package exh.smartsearch -import eu.kanade.domain.manga.model.toDomainManga -import eu.kanade.tachiyomi.source.CatalogueSource -import eu.kanade.tachiyomi.source.model.FilterList -import eu.kanade.tachiyomi.source.model.SManga import info.debatty.java.stringsimilarity.NormalizedLevenshtein import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.supervisorScope -import tachiyomi.domain.manga.model.Manga import java.util.Locale -class SmartSearchEngine( +typealias SearchAction = suspend (String) -> List + +abstract class BaseSmartSearchEngine( private val extraSearchParams: String? = null, + private val eligibleThreshold: Double = MIN_ELIGIBLE_THRESHOLD, ) { private val normalizedLevenshtein = NormalizedLevenshtein() - suspend fun smartSearch(source: CatalogueSource, title: String): Manga? { + protected abstract fun getTitle(result: T): String + + protected suspend fun smartSearch(searchAction: SearchAction, title: String): T? { val cleanedTitle = cleanSmartSearchTitle(title) val queries = getSmartSearchQueries(cleanedTitle) @@ -30,71 +30,42 @@ class SmartSearchEngine( query } - val searchResults = source.getSearchManga(1, builtQuery, FilterList()) - - searchResults.mangas.map { - val cleanedMangaTitle = cleanSmartSearchTitle(it.originalTitle) + searchAction(builtQuery).map { + val cleanedMangaTitle = cleanSmartSearchTitle(getTitle(it)) val normalizedDistance = normalizedLevenshtein.similarity(cleanedTitle, cleanedMangaTitle) SearchEntry(it, normalizedDistance) }.filter { (_, normalizedDistance) -> - normalizedDistance >= MIN_SMART_ELIGIBLE_THRESHOLD + normalizedDistance >= eligibleThreshold } } }.flatMap { it.await() } } - return eligibleManga.maxByOrNull { it.dist }?.manga?.toDomainManga(source.id) + return eligibleManga.maxByOrNull { it.dist }?.manga } - suspend fun normalSearch(source: CatalogueSource, title: String): Manga? { + protected suspend fun normalSearch(searchAction: SearchAction, title: String): T? { val eligibleManga = supervisorScope { val searchQuery = if (extraSearchParams != null) { "$title ${extraSearchParams.trim()}" } else { title } - val searchResults = source.getSearchManga(1, searchQuery, FilterList()) + val searchResults = searchAction(searchQuery) - if (searchResults.mangas.size == 1) { - return@supervisorScope listOf(SearchEntry(searchResults.mangas.first(), 0.0)) + if (searchResults.size == 1) { + return@supervisorScope listOf(SearchEntry(searchResults.first(), 0.0)) } - searchResults.mangas.map { - val normalizedDistance = normalizedLevenshtein.similarity(title, it.originalTitle) + searchResults.map { + val normalizedDistance = normalizedLevenshtein.similarity(title, getTitle(it)) SearchEntry(it, normalizedDistance) }.filter { (_, normalizedDistance) -> - normalizedDistance >= MIN_NORMAL_ELIGIBLE_THRESHOLD + normalizedDistance >= eligibleThreshold } } - return eligibleManga.maxByOrNull { it.dist }?.manga?.toDomainManga(source.id) - } - - private fun getSmartSearchQueries(cleanedTitle: String): List { - val splitCleanedTitle = cleanedTitle.split(" ") - val splitSortedByLargest = splitCleanedTitle.sortedByDescending { it.length } - - if (splitCleanedTitle.isEmpty()) { - return emptyList() - } - - // Search cleaned title - // Search two largest words - // Search largest word - // Search first two words - // Search first word - - val searchQueries = listOf( - listOf(cleanedTitle), - splitSortedByLargest.take(2), - splitSortedByLargest.take(1), - splitCleanedTitle.take(2), - splitCleanedTitle.take(1), - ) - - return searchQueries.map { - it.joinToString(" ").trim() - }.distinct() + return eligibleManga.maxByOrNull { it.dist }?.manga } private fun cleanSmartSearchTitle(title: String): String { @@ -171,9 +142,35 @@ class SmartSearchEngine( return result.toString() } + private fun getSmartSearchQueries(cleanedTitle: String): List { + val splitCleanedTitle = cleanedTitle.split(" ") + val splitSortedByLargest = splitCleanedTitle.sortedByDescending { it.length } + + if (splitCleanedTitle.isEmpty()) { + return emptyList() + } + + // Search cleaned title + // Search two largest words + // Search largest word + // Search first two words + // Search first word + + val searchQueries = listOf( + listOf(cleanedTitle), + splitSortedByLargest.take(2), + splitSortedByLargest.take(1), + splitCleanedTitle.take(2), + splitCleanedTitle.take(1), + ) + + return searchQueries.map { + it.joinToString(" ").trim() + }.distinct() + } + companion object { - const val MIN_SMART_ELIGIBLE_THRESHOLD = 0.4 - const val MIN_NORMAL_ELIGIBLE_THRESHOLD = 0.4 + const val MIN_ELIGIBLE_THRESHOLD = 0.4 private val titleRegex = Regex("[^a-zA-Z0-9- ]") private val titleCyrillicRegex = Regex("[^\\p{L}0-9- ]") @@ -182,4 +179,4 @@ class SmartSearchEngine( } } -data class SearchEntry(val manga: SManga, val dist: Double) +data class SearchEntry(val manga: T, val dist: Double) diff --git a/app/src/main/java/exh/smartsearch/SmartLibrarySearchEngine.kt b/app/src/main/java/exh/smartsearch/SmartLibrarySearchEngine.kt new file mode 100644 index 000000000..c0e0b956e --- /dev/null +++ b/app/src/main/java/exh/smartsearch/SmartLibrarySearchEngine.kt @@ -0,0 +1,18 @@ +package exh.smartsearch + +import tachiyomi.domain.library.model.LibraryManga + +class SmartLibrarySearchEngine( + extraSearchParams: String? = null, +) : BaseSmartSearchEngine(extraSearchParams, 0.7) { + + override fun getTitle(result: LibraryManga) = result.manga.ogTitle + + suspend fun smartSearch(library: List, title: String): LibraryManga? = + smartSearch( + { query -> + library.filter { it.manga.ogTitle.contains(query, true) } + }, + title, + ) +} diff --git a/app/src/main/java/exh/smartsearch/SmartSourceSearchEngine.kt b/app/src/main/java/exh/smartsearch/SmartSourceSearchEngine.kt new file mode 100644 index 000000000..eaa8b2fb3 --- /dev/null +++ b/app/src/main/java/exh/smartsearch/SmartSourceSearchEngine.kt @@ -0,0 +1,27 @@ +package exh.smartsearch + +import eu.kanade.domain.manga.model.toDomainManga +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.SManga +import tachiyomi.domain.manga.model.Manga + +class SmartSourceSearchEngine( + extraSearchParams: String? = null, +) : BaseSmartSearchEngine(extraSearchParams) { + + override fun getTitle(result: SManga) = result.originalTitle + + suspend fun smartSearch(source: CatalogueSource, title: String): Manga? = + smartSearch(makeSearchAction(source), title).let { + it?.toDomainManga(source.id) + } + + suspend fun normalSearch(source: CatalogueSource, title: String): Manga? = + normalSearch(makeSearchAction(source), title).let { + it?.toDomainManga(source.id) + } + + private fun makeSearchAction(source: CatalogueSource): SearchAction = + { query -> source.getSearchManga(1, query, FilterList()).mangas } +} diff --git a/app/src/main/java/exh/ui/smartsearch/SmartSearchScreenModel.kt b/app/src/main/java/exh/ui/smartsearch/SmartSearchScreenModel.kt index 0672fefe2..bc8018b66 100644 --- a/app/src/main/java/exh/ui/smartsearch/SmartSearchScreenModel.kt +++ b/app/src/main/java/exh/ui/smartsearch/SmartSearchScreenModel.kt @@ -4,7 +4,7 @@ import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.screenModelScope import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.ui.browse.source.SourcesScreen -import exh.smartsearch.SmartSearchEngine +import exh.smartsearch.SmartSourceSearchEngine import kotlinx.coroutines.CancellationException import tachiyomi.core.common.util.lang.launchIO import tachiyomi.domain.manga.interactor.NetworkToLocalManga @@ -19,7 +19,7 @@ class SmartSearchScreenModel( private val networkToLocalManga: NetworkToLocalManga = Injekt.get(), private val sourceManager: SourceManager = Injekt.get(), ) : StateScreenModel(null) { - private val smartSearchEngine = SmartSearchEngine() + private val smartSearchEngine = SmartSourceSearchEngine() val source = sourceManager.get(sourceId) as CatalogueSource diff --git a/app/src/main/java/exh/util/ContextExtensions.kt b/app/src/main/java/exh/util/ContextExtensions.kt index 123e35eb1..59da5d1bd 100644 --- a/app/src/main/java/exh/util/ContextExtensions.kt +++ b/app/src/main/java/exh/util/ContextExtensions.kt @@ -5,7 +5,10 @@ import android.content.ClipboardManager import android.content.Context import android.content.res.Configuration import android.net.wifi.WifiManager +import android.os.Build +import android.os.PowerManager import androidx.core.content.getSystemService +import eu.kanade.tachiyomi.util.system.powerManager /** * Property to get the wifi manager from the context. @@ -24,3 +27,23 @@ val Context.isInNightMode: Boolean val currentNightMode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK return currentNightMode == Configuration.UI_MODE_NIGHT_YES } + +fun Context.createPartialWakeLock(tag: String): PowerManager.WakeLock = + powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + tag, + ) + +fun Context.createWifiLock(tag: String): WifiManager.WifiLock = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + wifiManager.createWifiLock( + WifiManager.WIFI_MODE_FULL_LOW_LATENCY, + tag, + ) + } else { + @Suppress("DEPRECATION") + wifiManager.createWifiLock( + WifiManager.WIFI_MODE_FULL_HIGH_PERF, + tag, + ) + } diff --git a/app/src/main/java/exh/eh/EHentaiThrottleManager.kt b/app/src/main/java/exh/util/ThrottleManager.kt similarity index 85% rename from app/src/main/java/exh/eh/EHentaiThrottleManager.kt rename to app/src/main/java/exh/util/ThrottleManager.kt index 30a5c3cb6..58fd8f769 100644 --- a/app/src/main/java/exh/eh/EHentaiThrottleManager.kt +++ b/app/src/main/java/exh/util/ThrottleManager.kt @@ -1,16 +1,18 @@ -package exh.eh +package exh.util import kotlinx.coroutines.delay import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds -class EHentaiThrottleManager( +class ThrottleManager( private val max: Duration = THROTTLE_MAX, private val inc: Duration = THROTTLE_INC, + private val initial: Duration = Duration.ZERO, ) { private var lastThrottleTime = Duration.ZERO - var throttleTime = Duration.ZERO + + var throttleTime = initial private set suspend fun throttle() { @@ -30,7 +32,7 @@ class EHentaiThrottleManager( fun resetThrottle() { lastThrottleTime = Duration.ZERO - throttleTime = Duration.ZERO + throttleTime = initial } companion object { diff --git a/app/src/main/res/layout/recommendation_search_bottom_sheet.xml b/app/src/main/res/layout/recommendation_search_bottom_sheet.xml new file mode 100644 index 000000000..12c745b8d --- /dev/null +++ b/app/src/main/res/layout/recommendation_search_bottom_sheet.xml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +