diff --git a/.gitignore b/.gitignore index c0c1efb56f..9520a03a90 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,8 @@ replay_pid*.log *.keystore .idea/ gradle.properties +.claude/ +.claude.local.md +.superpowers/ +docs/superpowers/ +.playwright-mcp/ diff --git a/app/build.gradle b/app/build.gradle index 821e70bdec..b6c9b76a9b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -198,6 +198,7 @@ dependencies { implementation platform(libs.okhttp.bom) implementation libs.okhttp + implementation libs.okhttp.brotli implementation libs.okhttp.tls implementation libs.okhttp.dnsoverhttps implementation libs.okio diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2a9fcae178..d585c154b5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -137,6 +137,21 @@ android:name="com.samsung.android.support.REMOTE_ACTION" android:resource="@xml/remote_action" /> + + + true + is io.github.landwarderer.futon.novel.data.source.NovelParserSource -> true + is MangaParserSource -> s.contentType == org.koitharu.kotatsu.parsers.model.ContentType.NOVEL + is io.github.landwarderer.futon.mihon.model.MihonMangaSource -> s.contentType == io.github.landwarderer.futon.mihon.parsers.model.ContentType.NOVEL + else -> io.github.landwarderer.futon.novel.data.source.NovelParserSource.fromName(s.name) != null + } + +val Manga.isNovel: Boolean + get() = source.isNovelSource val Manga.isBroken: Boolean get() = source == UnknownMangaSource diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/model/MangaSource.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/model/MangaSource.kt index 3d92db8a7d..1c3a020593 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/model/MangaSource.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/model/MangaSource.kt @@ -33,6 +33,11 @@ data object LocalMangaSource : MangaSource { override val name = "LOCAL" } +data object LocalNovelSource : MangaSource { + override val name = "LOCAL_NOVEL" + val contentType = ContentType.NOVEL +} + data object UnknownMangaSource : MangaSource { override val name = "UNKNOWN" } @@ -45,6 +50,7 @@ fun MangaSource(name: String?, title: String? = null): MangaSource { when (name ?: return UnknownMangaSource) { UnknownMangaSource.name -> return UnknownMangaSource LocalMangaSource.name -> return LocalMangaSource + LocalNovelSource.name -> return LocalNovelSource TestMangaSource.name -> return TestMangaSource } if (name.startsWith("content:")) { @@ -57,6 +63,9 @@ fun MangaSource(name: String?, title: String? = null): MangaSource { MangaParserSource.entries.forEach { if (it.name == name) return it } + io.github.landwarderer.futon.novel.data.source.NovelParserSource.entries.forEach { + if (it.name == name) return it + } return UnknownMangaSource } @@ -97,7 +106,11 @@ tailrec fun MangaSource.unwrap(): MangaSource = if (this is MangaSourceInfo) { this } -fun MangaSource.getLocale(): Locale? = (unwrap() as? MangaParserSource)?.locale?.toLocaleOrNull() +fun MangaSource.getLocale(): Locale? = when (val source = unwrap()) { + is MangaParserSource -> source.locale.toLocaleOrNull() + is io.github.landwarderer.futon.novel.data.source.NovelParserSource -> source.locale.toLocaleOrNull() + else -> null +} fun MangaSource.getSummary(context: Context): String? = when (val source = unwrap()) { is MangaParserSource -> { @@ -106,6 +119,12 @@ fun MangaSource.getSummary(context: Context): String? = when (val source = unwra context.getString(R.string.source_summary_pattern, type, locale) } + is io.github.landwarderer.futon.novel.data.source.NovelParserSource -> { + val type = context.getString(source.contentType.titleResId) + val locale = source.locale.toLocale().getDisplayName(context) + context.getString(R.string.source_summary_pattern, type, locale) + } + is ExternalMangaSource -> context.getString(R.string.external_source) is MihonMangaSource -> { @@ -134,7 +153,9 @@ fun MangaSource.getSummary(context: Context): String? = when (val source = unwra fun MangaSource.getTitle(context: Context): String = when (val source = unwrap()) { is MangaParserSource -> source.title + is io.github.landwarderer.futon.novel.data.source.NovelParserSource -> source.title LocalMangaSource -> context.getString(R.string.local_storage) + LocalNovelSource -> context.getString(R.string.light_novels) TestMangaSource -> context.getString(R.string.test_parser) is ExternalMangaSource -> source.resolveName(context) is MihonMangaSource -> source.displayName.also { updateMihonTitle(source.name, it) } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/nav/AppRouter.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/nav/AppRouter.kt index 99154ee171..0940c8520c 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/nav/AppRouter.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/nav/AppRouter.kt @@ -77,6 +77,7 @@ import io.github.landwarderer.futon.local.ui.ImportDialogFragment import io.github.landwarderer.futon.local.ui.info.LocalInfoDialog import io.github.landwarderer.futon.main.ui.MainActivity import io.github.landwarderer.futon.main.ui.welcome.WelcomeSheet +import io.github.landwarderer.futon.onboarding.ui.OnboardingActivity import io.github.landwarderer.futon.reader.ui.colorfilter.ColorFilterConfigActivity import io.github.landwarderer.futon.reader.ui.config.ReaderConfigSheet import io.github.landwarderer.futon.scrobbling.common.domain.model.ScrobblerService @@ -207,6 +208,8 @@ class AppRouter private constructor( fun openSourcesCatalog() = startActivity(SourcesCatalogActivity::class.java) + fun openOnboarding() = startActivity(OnboardingActivity::class.java) + fun openExtensionDownloader() = startActivity(ExtensionDownloaderActivity::class.java) fun openDownloads() = startActivity(DownloadsActivity::class.java) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/nav/ReaderIntent.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/nav/ReaderIntent.kt index b0f33fc1da..1b965969a8 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/nav/ReaderIntent.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/nav/ReaderIntent.kt @@ -2,10 +2,13 @@ package io.github.landwarderer.futon.core.nav import android.content.Context import android.content.Intent +import org.koitharu.kotatsu.parsers.model.Manga import io.github.landwarderer.futon.BuildConfig import io.github.landwarderer.futon.bookmarks.domain.Bookmark +import io.github.landwarderer.futon.core.model.isNovel import io.github.landwarderer.futon.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.parsers.model.Manga +import io.github.landwarderer.futon.novel.ui.reader.NovelReaderActivity +import io.github.landwarderer.futon.novel.ui.reader.pdf.PdfReaderActivity import io.github.landwarderer.futon.reader.ui.ReaderActivity import io.github.landwarderer.futon.reader.ui.ReaderState @@ -14,7 +17,7 @@ value class ReaderIntent private constructor( val intent: Intent, ) { - class Builder(context: Context) { + class Builder(private val context: Context) { private val intent = Intent(context, ReaderActivity::class.java) .setAction(ACTION_MANGA_READ) @@ -22,6 +25,11 @@ value class ReaderIntent private constructor( fun manga(manga: Manga) = apply { intent.putExtra(AppRouter.KEY_MANGA, ParcelableManga(manga)) intent.setData(AppRouter.shortMangaUrl(manga.id)) + if (manga.url.endsWith(".pdf", ignoreCase = true) || manga.url.startsWith("pdf://")) { + intent.setClass(context, PdfReaderActivity::class.java) + } else if (manga.isNovel || manga.url.endsWith(".epub", ignoreCase = true)) { + intent.setClass(context, NovelReaderActivity::class.java) + } } fun mangaId(mangaId: Long) = apply { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/network/CloudFlareInterceptor.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/network/CloudFlareInterceptor.kt index 79a01d1176..fc10810930 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/network/CloudFlareInterceptor.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/network/CloudFlareInterceptor.kt @@ -49,15 +49,30 @@ class CloudFlareInterceptor : Interceptor { } } else { // Standard path for Android 7.0+ - try { + val detected = try { CloudFlareHelper.checkResponseForProtection(response) } catch (e: IllegalArgumentException) { if (e.message?.contains("Bad position") == true) { CloudFlareHelper.PROTECTION_NOT_DETECTED } else { e.printStackTraceDebug("CloudFlareInterceptor") + CloudFlareHelper.PROTECTION_NOT_DETECTED } } + if (detected != CloudFlareHelper.PROTECTION_NOT_DETECTED) { + detected + } else if (response.code == 403 || response.code == 503) { + val cfMitigated = response.header("cf-mitigated") + val server = response.header("server") + val isCf = response.header("cf-ray") != null || server?.contains("cloudflare", ignoreCase = true) == true + if (isCf && cfMitigated?.contains("challenge", ignoreCase = true) == true) { + CloudFlareHelper.PROTECTION_CAPTCHA + } else { + CloudFlareHelper.PROTECTION_NOT_DETECTED + } + } else { + CloudFlareHelper.PROTECTION_NOT_DETECTED + } } return when (protectionType) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/network/NetworkModule.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/network/NetworkModule.kt index 39c0643bac..1da02b1229 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/network/NetworkModule.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/network/NetworkModule.kt @@ -22,6 +22,7 @@ import kotlinx.serialization.json.Json import okhttp3.Cache import okhttp3.CookieJar import okhttp3.OkHttpClient +import okhttp3.brotli.BrotliInterceptor import java.util.concurrent.TimeUnit import javax.inject.Provider import javax.inject.Singleton @@ -67,6 +68,7 @@ interface NetworkModule { proxyProvider: ProxyProvider, ): OkHttpClient = OkHttpClient.Builder().apply { assertNotInMainThread() + fastFallback(true) connectTimeout(20, TimeUnit.SECONDS) readTimeout(60, TimeUnit.SECONDS) writeTimeout(20, TimeUnit.SECONDS) @@ -80,6 +82,7 @@ interface NetworkModule { installExtraCertificates(contextProvider.get()) } cache(cache) + addInterceptor(BrotliInterceptor) //addInterceptor(GZipInterceptor()) addInterceptor(CloudFlareInterceptor()) addInterceptor(RateLimitInterceptor()) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/MangaRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/MangaRepository.kt index c05d4a114d..8b7799a5e0 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/MangaRepository.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/MangaRepository.kt @@ -60,6 +60,7 @@ interface MangaRepository { class Factory @Inject constructor( @ApplicationContext private val context: Context, private val localMangaRepository: LocalMangaRepository, + private val localNovelRepository: javax.inject.Provider, private val loaderContext: MangaLoaderContext, private val contentCache: MemoryContentCache, private val mirrorSwitcher: MirrorSwitcher, @@ -73,6 +74,7 @@ interface MangaRepository { when (source) { is MangaSourceInfo -> return create(source.mangaSource) LocalMangaSource -> return localMangaRepository + io.github.landwarderer.futon.core.model.LocalNovelSource -> return localNovelRepository.get() UnknownMangaSource -> return EmptyMangaRepository(source) } cache[source]?.get()?.let { return it } @@ -95,6 +97,23 @@ interface MangaRepository { mirrorSwitcher = mirrorSwitcher, ) + is io.github.landwarderer.futon.novel.data.source.NovelParserSource -> when (source) { + io.github.landwarderer.futon.novel.data.source.NovelParserSource.FREEWEBNOVEL -> + io.github.landwarderer.futon.novel.data.source.FreeWebNovelRepository(contentCache, loaderContext.httpClient) + io.github.landwarderer.futon.novel.data.source.NovelParserSource.LNORI -> + io.github.landwarderer.futon.novel.data.source.LnoriRepository(contentCache, loaderContext.httpClient) + io.github.landwarderer.futon.novel.data.source.NovelParserSource.ROYALROAD -> + io.github.landwarderer.futon.novel.data.source.RoyalRoadRepository(contentCache, loaderContext.httpClient) + io.github.landwarderer.futon.novel.data.source.NovelParserSource.SCRIBBLEHUB -> + io.github.landwarderer.futon.novel.data.source.ScribbleHubRepository(contentCache, loaderContext.httpClient) + io.github.landwarderer.futon.novel.data.source.NovelParserSource.RANOBES -> + io.github.landwarderer.futon.novel.data.source.RanobesRepository(contentCache, loaderContext.httpClient) + io.github.landwarderer.futon.novel.data.source.NovelParserSource.WUXIAWORLD -> + io.github.landwarderer.futon.novel.data.source.WuxiaworldRepository(contentCache, loaderContext.httpClient) + io.github.landwarderer.futon.novel.data.source.NovelParserSource.LNCRAWLER -> + io.github.landwarderer.futon.novel.data.source.LNCrawlerRepository(contentCache, loaderContext.httpClient) + } + TestMangaSource -> TestMangaRepository( loaderContext = loaderContext, cache = contentCache, diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/favicon/FaviconFetcher.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/favicon/FaviconFetcher.kt index 1978c76d44..fd7bba9b6f 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/favicon/FaviconFetcher.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/favicon/FaviconFetcher.kt @@ -34,6 +34,8 @@ import io.github.landwarderer.futon.local.data.FaviconCache import io.github.landwarderer.futon.local.data.LocalMangaRepository import io.github.landwarderer.futon.local.data.LocalStorageCache import io.github.landwarderer.futon.mihon.MihonMangaRepository +import io.github.landwarderer.futon.novel.data.LocalNovelRepository +import io.github.landwarderer.futon.novel.data.source.BaseNovelRepository import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.runInterruptible @@ -66,9 +68,40 @@ class FaviconFetcher( ) is LocalMangaRepository -> imageLoader.fetch(R.drawable.ic_storage, options) + is LocalNovelRepository -> imageLoader.fetch(R.drawable.ic_novel, options) + is BaseNovelRepository -> fetchNovelFavicon(repo) is MihonMangaRepository -> fetchMihonIcon(repo) - else -> throw IllegalArgumentException("Unsupported repo ${repo.javaClass.simpleName}") + else -> imageLoader.fetch(R.drawable.ic_novel, options) + } + } + + private suspend fun fetchNovelFavicon(repository: BaseNovelRepository): FetchResult? { + val domain = repository.source.domain + val faviconUrl = "https://www.google.com/s2/favicons?domain=$domain&sz=128" + val cacheKey = options.diskCacheKey ?: "${repository.source.name}_favicon" + if (options.diskCachePolicy.readEnabled) { + localStorageCache[cacheKey]?.let { file -> + return SourceFetchResult( + source = ImageSource(file.toOkioPath(), FileSystem.SYSTEM), + mimeType = MimeTypes.probeMimeType(file)?.toString(), + dataSource = DataSource.DISK, + ) + } + } + val result = try { + imageLoader.fetch(faviconUrl, options) + } catch (_: Exception) { + null + } + return if (result != null) { + if (options.diskCachePolicy.writeEnabled) { + writeToCache(cacheKey, result) + } else { + result + } + } else { + imageLoader.fetch(R.drawable.ic_novel, options) } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppMode.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppMode.kt new file mode 100644 index 0000000000..169879af3b --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppMode.kt @@ -0,0 +1,20 @@ +package io.github.landwarderer.futon.core.prefs + +import androidx.annotation.Keep +import androidx.annotation.StringRes +import io.github.landwarderer.futon.R + +@Keep +enum class AppMode( + @StringRes val titleRes: Int, +) { + MANGA(R.string.content_type_manga), + NOVEL(R.string.content_type_novel), + ; + + val isNovel: Boolean + get() = this == NOVEL + + val isManga: Boolean + get() = this == MANGA +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt index f5a2582aaf..240f4d9ee5 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt @@ -46,11 +46,32 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class AppSettings @Inject constructor(@ApplicationContext context: Context) { +class AppSettings internal constructor( + @ApplicationContext context: Context, + private val prefs: SharedPreferences, +) { - private val prefs = PreferenceManager.getDefaultSharedPreferences(context) - private val connectivityManager = context.connectivityManager - private val mangaListBadgesDefault = ArraySet(context.resources.getStringArray(R.array.values_list_badges)) + @Inject + constructor(@ApplicationContext context: Context) : this( + context, + PreferenceManager.getDefaultSharedPreferences(context), + ) + + private val connectivityManager by lazy { context.connectivityManager } + private val mangaListBadgesDefault by lazy { ArraySet(context.resources.getStringArray(R.array.values_list_badges)) } + + var appMode: AppMode + get() = prefs.getEnumValue(KEY_APP_MODE, AppMode.MANGA) + set(value) = prefs.edit { putEnumValue(KEY_APP_MODE, value) } + + fun observeAppMode(): Flow = observeAsFlow(KEY_APP_MODE) { appMode } + + var homeBackgroundFont: HomeBackgroundFont + get() = prefs.getEnumValue(KEY_HOME_BACKGROUND_FONT, HomeBackgroundFont.DEFAULT) + set(value) = prefs.edit { putEnumValue(KEY_HOME_BACKGROUND_FONT, value) } + + fun observeHomeBackgroundFont(): Flow = + observeAsFlow(KEY_HOME_BACKGROUND_FONT) { homeBackgroundFont } var listMode: ListMode get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.GRID) @@ -334,6 +355,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { get() = prefs.getInt(KEY_SOURCES_VERSION, 0) set(value) = prefs.edit { putInt(KEY_SOURCES_VERSION, value) } + var onboardingVersion: Int + get() = prefs.getInt(KEY_ONBOARDING_VERSION, 0) + set(value) = prefs.edit { putInt(KEY_ONBOARDING_VERSION, value) } + + var isModeSwitcherSpotlightShown: Boolean + get() = prefs.getBoolean(KEY_MODE_SWITCHER_SPOTLIGHT_SHOWN, false) + set(value) = prefs.edit { putBoolean(KEY_MODE_SWITCHER_SPOTLIGHT_SHOWN, value) } + var isAllSourcesEnabled: Boolean get() = prefs.getBoolean(KEY_SOURCES_ENABLED_ALL, false) set(value) = prefs.edit { putBoolean(KEY_SOURCES_ENABLED_ALL, value) } @@ -357,6 +386,16 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { prefs.edit { putStringSet(KEY_LOCAL_MANGA_DIRS, set) } } + var userSpecifiedNovelDirectories: Set + get() { + val set = prefs.getStringSet(KEY_LOCAL_NOVEL_DIRS, emptySet()).orEmpty() + return set.mapNotNullToSet { File(it).takeIfReadable() } + } + set(value) { + val set = value.mapToSet { it.absolutePath } + prefs.edit { putStringSet(KEY_LOCAL_NOVEL_DIRS, set) } + } + var mangaStorageDir: File? get() = prefs.getString(KEY_LOCAL_STORAGE, null)?.let { File(it) @@ -855,8 +894,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_PROXY_PASSWORD = "proxy_password" const val KEY_IMAGES_PROXY = "images_proxy_2" const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs" + const val KEY_LOCAL_NOVEL_DIRS = "local_novel_dirs" const val KEY_DISABLE_NSFW = "no_nsfw" const val KEY_RELATED_MANGA = "related_manga" + const val KEY_APP_MODE = "app_mode" + const val KEY_HOME_BACKGROUND_FONT = "home_background_font" const val KEY_NAV_MAIN = "nav_main" const val KEY_NAV_LABELS = "nav_labels" const val KEY_NAV_PINNED = "nav_pinned" @@ -880,6 +922,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_FEED_HEADER = "feed_header" const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types" const val KEY_SOURCES_VERSION = "sources_version" + const val KEY_ONBOARDING_VERSION = "onboarding_version" + const val CURRENT_ONBOARDING_VERSION = 1 + const val KEY_MODE_SWITCHER_SPOTLIGHT_SHOWN = "mode_switcher_spotlight_shown" + const val KEY_ABOUT_ONBOARDING = "about_onboarding" const val KEY_SOURCES_ENABLED_ALL = "sources_enabled_all" const val KEY_QUICK_FILTER = "quick_filter" const val KEY_COLLAPSE_DESCRIPTION = "description_collapse" diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/HomeBackgroundFont.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/HomeBackgroundFont.kt new file mode 100644 index 0000000000..f78f31f27e --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/HomeBackgroundFont.kt @@ -0,0 +1,21 @@ +package io.github.landwarderer.futon.core.prefs + +import androidx.annotation.Keep +import androidx.annotation.StringRes +import io.github.landwarderer.futon.R + +@Keep +enum class HomeBackgroundFont( + @StringRes val titleRes: Int, + val fontAssetPath: String?, +) { + LIGHT_THIN(R.string.home_background_font_light_thin, "fonts/MojiwTegami-Standard.ttf"), + BOLD(R.string.home_background_font_bold, "fonts/ShimbashiGothic-Regular.otf"), + EXTRA_BOLD(R.string.home_background_font_extra_bold, "fonts/GenEiKiwamiGo.ttf"), + NONE(R.string.home_background_font_none, null), + ; + + companion object { + val DEFAULT = BOLD + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/ui/util/FontCache.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/ui/util/FontCache.kt new file mode 100644 index 0000000000..61a1573e1a --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/ui/util/FontCache.kt @@ -0,0 +1,18 @@ +package io.github.landwarderer.futon.core.ui.util + +import android.content.Context +import android.graphics.Typeface +import java.util.concurrent.ConcurrentHashMap + +object FontCache { + + private val cache = ConcurrentHashMap() + + fun getTypeface(context: Context, assetPath: String): Typeface? { + return cache.getOrPut(assetPath) { + runCatching { + Typeface.createFromAsset(context.assets, assetPath) + }.getOrNull() ?: return null + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/details/data/MangaDetails.kt b/app/src/main/kotlin/io/github/landwarderer/futon/details/data/MangaDetails.kt index 34f5f03989..e35a02c8f7 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/details/data/MangaDetails.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/details/data/MangaDetails.kt @@ -2,6 +2,7 @@ package io.github.landwarderer.futon.details.data import io.github.landwarderer.futon.core.model.getLocale import io.github.landwarderer.futon.core.model.isLocal +import io.github.landwarderer.futon.core.model.isNovel import io.github.landwarderer.futon.core.model.withOverride import io.github.landwarderer.futon.core.ui.model.MangaOverride import io.github.landwarderer.futon.local.domain.model.LocalManga @@ -54,6 +55,9 @@ data class MangaDetails( val isRestricted: Boolean get() = manga.state == MangaState.RESTRICTED + val isNovel: Boolean + get() = manga.isNovel + private val mergedManga by lazy { if (localManga == null) { // fast path diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/pager/ChaptersPagesSheet.kt b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/pager/ChaptersPagesSheet.kt index 7e4c854bdb..f4c1feebf3 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/pager/ChaptersPagesSheet.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/pager/ChaptersPagesSheet.kt @@ -11,6 +11,7 @@ import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import dagger.hilt.android.AndroidEntryPoint import io.github.landwarderer.futon.core.exceptions.resolve.SnackbarErrorObserver +import io.github.landwarderer.futon.core.model.isNovel import io.github.landwarderer.futon.core.nav.AppRouter import io.github.landwarderer.futon.core.nav.router import io.github.landwarderer.futon.core.prefs.AppSettings @@ -58,7 +59,9 @@ class ChaptersPagesSheet : BaseAdaptiveSheet(), val args = arguments ?: Bundle.EMPTY var defaultTab = args.getInt(AppRouter.KEY_TAB, settings.defaultDetailsTab) - val adapter = ChaptersPagesAdapter(this, settings.isPagesTabEnabled) + val isNovel = viewModel.mangaDetails.value?.isNovel == true || viewModel.manga.value?.isNovel == true + val isPagesTabEnabled = settings.isPagesTabEnabled && !isNovel + val adapter = ChaptersPagesAdapter(this, isPagesTabEnabled) if (!adapter.isPagesTabEnabled) { defaultTab = (defaultTab - 1).coerceAtLeast(TAB_CHAPTERS) } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/pager/pages/MangaPageFetcher.kt b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/pager/pages/MangaPageFetcher.kt index ae55a0f23d..e279e64f40 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/pager/pages/MangaPageFetcher.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/pager/pages/MangaPageFetcher.kt @@ -17,6 +17,7 @@ import okhttp3.OkHttpClient import okhttp3.Response import okio.FileSystem import okio.Path.Companion.toOkioPath +import io.github.landwarderer.futon.core.model.isNovelSource import io.github.landwarderer.futon.core.network.MangaHttpClient import io.github.landwarderer.futon.core.network.imageproxy.ImageProxyInterceptor import io.github.landwarderer.futon.core.parser.MangaRepository @@ -44,6 +45,9 @@ class MangaPageFetcher( ) : Fetcher { override suspend fun fetch(): FetchResult? { + if (page.source.isNovelSource) { + return null + } if (!page.preview.isNullOrEmpty()) { runCatchingCancellable { imageLoader.fetch(checkNotNull(page.preview), options) @@ -53,6 +57,9 @@ class MangaPageFetcher( } val repo = mangaRepositoryFactory.create(page.source) val pageUrl = repo.getPageUrl(page) + if (pageUrl.startsWith("<") || pageUrl.isBlank()) { + return null + } if (options.diskCachePolicy.readEnabled) { pagesCache[pageUrl]?.let { file -> return SourceFetchResult( diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/pager/pages/PagesFragment.kt b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/pager/pages/PagesFragment.kt index 2772b71c13..a0966572cf 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/pager/pages/PagesFragment.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/pager/pages/PagesFragment.kt @@ -87,7 +87,7 @@ class PagesFragment : parentViewModel.readingState, parentViewModel.selectedBranch, ) { details, readingState, branch -> - if (details != null && (details.isLoaded || details.chapters.isNotEmpty())) { + if (details != null && !details.isNovel && (details.isLoaded || details.chapters.isNotEmpty())) { PagesViewModel.State(details.filterChapters(branch), readingState, branch) } else { null diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/explore/data/MangaSourcesRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/explore/data/MangaSourcesRepository.kt index 7ea2ec1180..0d8bde41c5 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/explore/data/MangaSourcesRepository.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/explore/data/MangaSourcesRepository.kt @@ -24,6 +24,7 @@ import io.github.landwarderer.futon.core.ui.util.ReversibleHandle import io.github.landwarderer.futon.core.util.ext.flattenLatest import io.github.landwarderer.futon.mihon.MihonExtensionManager import io.github.landwarderer.futon.mihon.model.MihonMangaSource +import io.github.landwarderer.futon.novel.data.source.NovelParserSource import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -111,7 +112,9 @@ class MangaSourcesRepository @Inject constructor( if (settings.isAllSourcesEnabled) { return emptySet() } - val result = EnumSet.copyOf(allMangaSources) + val result = HashSet(allMangaSources) + result.addAll(NovelParserSource.entries) + result.addAll(mihonExtensionManager.getMihonMangaSources()) val enabled = dao.findAllEnabledNames() for (name in enabled) { val source = name.toMangaSourceOrNull() ?: continue @@ -156,6 +159,7 @@ class MangaSourcesRepository @Inject constructor( sources.retainAll { when (it) { is MangaParserSource -> it.locale == locale + is NovelParserSource -> it.locale == locale is io.github.landwarderer.futon.mihon.parsers.model.ContentSource -> it.locale == locale else -> true } @@ -168,6 +172,7 @@ class MangaSourcesRepository @Inject constructor( sources.retainAll { when (it) { is MangaParserSource -> it.contentType in types + is NovelParserSource -> it.contentType in types is io.github.landwarderer.futon.mihon.model.MihonMangaSource -> { val mihonType = it.contentType types.any { kotatsuType -> @@ -253,7 +258,7 @@ class MangaSourcesRepository @Inject constructor( val result = ArrayList>(entities.size) for (entity in entities) { val source = entity.toMangaSource() ?: continue - if (source in allMangaSources || source is AnonymousMangaSource || source is MihonMangaSource) { + if (source in allMangaSources || source is io.github.landwarderer.futon.novel.data.source.NovelParserSource || source is AnonymousMangaSource || source is MihonMangaSource) { result.add(source to entity.isEnabled) } } @@ -331,7 +336,7 @@ class MangaSourcesRepository @Inject constructor( val entities = new.map { x -> MangaSourceEntity( source = x.name, - isEnabled = if (x is MihonMangaSource) true else isAllEnabled, + isEnabled = if (x is MihonMangaSource || x is NovelParserSource) true else isAllEnabled, sortKey = ++maxSortKey, addedIn = BuildConfig.VERSION_CODE, lastUsedAt = 0, @@ -341,6 +346,28 @@ class MangaSourcesRepository @Inject constructor( ) } dao.insertIfAbsent(entities) + + // Always ensure all NovelParserSource entries are enabled + for (novelSource in NovelParserSource.entries) { + val existing = dao.findAll().find { it.source == novelSource.name } + if (existing == null || !existing.isEnabled) { + dao.setEnabled(novelSource.name, true) + } + } + + // Ensure default MangaParserSource entries are enabled if no manga sources are enabled yet + val enabledNames = dao.findAllEnabledNames() + val hasEnabledManga = allMangaSources.any { it.name in enabledNames } + if (!hasEnabledManga) { + val defaultLocales = setOf(java.util.Locale.getDefault().language, "en", "all") + val defaultMangaSources = allMangaSources.filter { + it.locale in defaultLocales && it.contentType == ContentType.MANGA && !it.isBroken + }.take(15) + for (s in defaultMangaSources) { + dao.setEnabled(s.name, true) + } + } + updateMihonTitles() return new.isNotEmpty() } @@ -385,6 +412,7 @@ class MangaSourcesRepository @Inject constructor( val entities = dao.findAll() val result = HashSet() result.addAll(MangaParserSource.entries) + result.addAll(io.github.landwarderer.futon.novel.data.source.NovelParserSource.entries) result.addAll(mihonExtensionManager.getMihonMangaSources()) for (e in entities) { result.remove(e.toMangaSource() ?: continue) @@ -468,7 +496,7 @@ class MangaSourcesRepository @Inject constructor( if (source.isBroken) { continue } - if (source is MangaParserSource || source is MihonMangaSource) { + if (source is MangaParserSource || source is MihonMangaSource || source is NovelParserSource) { result.add( MangaSourceInfo( mangaSource = source, @@ -501,7 +529,9 @@ class MangaSourcesRepository @Inject constructor( return mihonExtensionManager.getMihonMangaSourceByName(source) ?: io.github.landwarderer.futon.core.model.MangaSource(source, title) } - return MangaParserSource.entries.find { it.name == source } + val parserSource = MangaParserSource.entries.find { it.name == source } + if (parserSource != null) return parserSource + return io.github.landwarderer.futon.novel.data.source.NovelParserSource.fromName(source) } private fun String.toMangaSourceOrNull(): MangaSource? { @@ -509,6 +539,8 @@ class MangaSourcesRepository @Inject constructor( return mihonExtensionManager.getMihonMangaSourceByName(this) ?: io.github.landwarderer.futon.core.model.MangaSource(this) } - return MangaParserSource.entries.find { it.name == this } + val parserSource = MangaParserSource.entries.find { it.name == this } + if (parserSource != null) return parserSource + return io.github.landwarderer.futon.novel.data.source.NovelParserSource.fromName(this) } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreFragment.kt b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreFragment.kt index 32a1f5d018..ce9eed0b7f 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreFragment.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreFragment.kt @@ -21,6 +21,7 @@ import dagger.hilt.android.AndroidEntryPoint import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.exceptions.resolve.SnackbarErrorObserver import io.github.landwarderer.futon.core.model.LocalMangaSource +import io.github.landwarderer.futon.core.model.LocalNovelSource import io.github.landwarderer.futon.core.model.getTitle import io.github.landwarderer.futon.core.nav.router import io.github.landwarderer.futon.core.parser.external.ExternalMangaSource @@ -123,7 +124,7 @@ class ExploreFragment : override fun onClick(v: View) { when (v.id) { - R.id.button_local -> router.openList(LocalMangaSource, null, null) + R.id.button_local -> router.openList(if (viewModel.appMode.value.isNovel) LocalNovelSource else LocalMangaSource, null, null) R.id.button_bookmarks -> router.openBookmarks() R.id.button_more -> router.openSuggestions() R.id.button_downloads -> router.openDownloads() diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreViewModel.kt index ac699ba666..a2ba0bc24b 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.model.MangaSourceInfo +import io.github.landwarderer.futon.core.model.isNovelSource import io.github.landwarderer.futon.core.os.AppShortcutManager import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.core.prefs.observeAsFlow @@ -26,9 +27,11 @@ import io.github.landwarderer.futon.list.ui.model.LoadingState import io.github.landwarderer.futon.list.ui.model.MangaCompactListModel import io.github.landwarderer.futon.suggestions.domain.SuggestionRepository import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine as combineFlows import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.mapLatest @@ -60,6 +63,12 @@ class ExploreViewModel @Inject constructor( valueProducer = { isAllSourcesEnabled }, ) + val appMode = settings.observeAsStateFlow( + scope = viewModelScope + Dispatchers.IO, + key = AppSettings.KEY_APP_MODE, + valueProducer = { appMode }, + ) + private val isSuggestionsEnabled = settings.observeAsFlow( key = AppSettings.KEY_SUGGESTIONS, valueProducer = { isSuggestionsEnabled }, @@ -138,15 +147,23 @@ class ExploreViewModel @Inject constructor( } } - private fun createContentFlow() = combine( + private fun observeFilteredSources(): Flow> = combineFlows( sourcesRepository.observeEnabledSources(), + appMode, + ) { sources, mode -> + val isNovel = mode.isNovel + sources.filter { it.mangaSource.isNovelSource == isNovel } + } + + private fun createContentFlow(): Flow> = combine( + observeFilteredSources(), getSuggestionFlow(), isGrid, isRandomLoading, isAllSourcesEnabled, sourcesRepository.observeHasNewSourcesForBadge(), - ) { content, suggestions, grid, randomLoading, allSourcesEnabled, newSources -> - buildList(content, suggestions, grid, randomLoading, allSourcesEnabled, newSources) + ) { sources, suggestions, grid, randomLoading, allSourcesEnabled, newSources -> + buildList(sources, suggestions, grid, randomLoading, allSourcesEnabled, newSources) }.withErrorHandling() private fun buildList( diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/favourites/ui/list/FavouritesListViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/favourites/ui/list/FavouritesListViewModel.kt index eb2ebb7ab8..1de40532f9 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/favourites/ui/list/FavouritesListViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/favourites/ui/list/FavouritesListViewModel.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.core.model.isNovel import io.github.landwarderer.futon.core.nav.AppRouter import io.github.landwarderer.futon.core.parser.MangaDataRepository import io.github.landwarderer.futon.core.prefs.AppSettings @@ -82,8 +83,11 @@ class FavouritesListViewModel @Inject constructor( quickFilter.appliedOptions, observeListModeWithTriggers(), refreshTrigger, - ) { list, filters, mode, _ -> - list.mapList(mode, filters) + settings.observeAppMode(), + ) { list, filters, mode, _, appMode -> + val isNovel = appMode.isNovel + val filteredList = list.filter { it.isNovel == isNovel } + filteredList.mapList(mode, filters) }.distinctUntilChanged().onEach { isPaginationReady.set(true) }.catch { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/history/ui/HistoryListViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/history/ui/HistoryListViewModel.kt index f1f0bcdac5..b3e05a0138 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/history/ui/HistoryListViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/history/ui/HistoryListViewModel.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.model.MangaHistory +import io.github.landwarderer.futon.core.model.isNovel import io.github.landwarderer.futon.core.parser.MangaDataRepository import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.core.prefs.ListMode @@ -52,7 +53,7 @@ private const val PAGE_SIZE = 16 @HiltViewModel class HistoryListViewModel @Inject constructor( private val repository: HistoryRepository, - settings: AppSettings, + private val settings: AppSettings, private val mangaListMapper: MangaListMapper, private val markAsReadUseCase: MarkAsReadUseCase, private val quickFilter: HistoryListQuickFilter, @@ -88,9 +89,17 @@ class HistoryListViewModel @Inject constructor( valueProducer = { isStatsEnabled }, ) + private fun observeFilteredHistory() = combine( + observeHistory(), + settings.observeAppMode(), + ) { list, appMode -> + val isNovel = appMode.isNovel + list.filter { it.manga.isNovel == isNovel } + } + override val content = combine( quickFilter.appliedOptions, - observeHistory(), + observeFilteredHistory(), isGroupingEnabled, observeListModeWithTriggers(), settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled }, diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/MainActivity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/MainActivity.kt index d9f5c20648..f506cab279 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/MainActivity.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/MainActivity.kt @@ -6,7 +6,9 @@ import android.content.pm.PackageManager.PERMISSION_GRANTED import android.os.Build import android.os.Bundle import android.view.View +import android.view.ViewGroup import android.view.ViewGroup.MarginLayoutParams +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.view.ActionMode import androidx.core.app.ActivityCompat @@ -19,7 +21,9 @@ import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding +import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope @@ -39,9 +43,11 @@ import io.github.landwarderer.futon.core.exceptions.resolve.SnackbarErrorObserve import io.github.landwarderer.futon.core.nav.router import io.github.landwarderer.futon.core.os.VoiceInputContract import io.github.landwarderer.futon.core.prefs.AppSettings +import io.github.landwarderer.futon.core.prefs.HomeBackgroundFont import io.github.landwarderer.futon.core.prefs.NavItem import io.github.landwarderer.futon.core.ui.BaseActivity import io.github.landwarderer.futon.core.ui.util.FadingAppbarMediator +import io.github.landwarderer.futon.core.ui.util.FontCache import io.github.landwarderer.futon.core.ui.util.MenuInvalidator import io.github.landwarderer.futon.core.ui.widgets.SlidingBottomNavigationView import io.github.landwarderer.futon.core.util.ext.consume @@ -58,6 +64,8 @@ import io.github.landwarderer.futon.local.ui.LocalIndexUpdateService import io.github.landwarderer.futon.local.ui.LocalStorageCleanupWorker import io.github.landwarderer.futon.main.ui.owners.AppBarOwner import io.github.landwarderer.futon.main.ui.owners.BottomNavOwner +import io.github.landwarderer.futon.main.ui.widgets.ModeSwitcherSpotlightOverlay +import io.github.landwarderer.futon.onboarding.ui.OnboardingActivity import org.koitharu.kotatsu.parsers.model.Manga import io.github.landwarderer.futon.remotelist.ui.MangaSearchMenuProvider import io.github.landwarderer.futon.search.ui.suggestion.SearchSuggestionItemCallback @@ -96,6 +104,11 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav viewBinding.searchView.setText(result) } } + private val onboardingLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + viewModel.onOnboardingFinished() + checkAndShowModeSwitcherSpotlight() + } + private var modeSwitcherSpotlight: ModeSwitcherSpotlightOverlay? = null private lateinit var navigationDelegate: MainNavigationDelegate private lateinit var fadingAppbarMediator: FadingAppbarMediator @@ -109,6 +122,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav super.onCreate(savedInstanceState) setContentView(ActivityMainBinding.inflate(layoutInflater)) setSupportActionBar(viewBinding.searchBar) + updateHomeWatermark() viewBinding.fab?.setOnClickListener(this) viewBinding.navRail?.headerView?.findViewById(R.id.railFab)?.setOnClickListener(this) @@ -146,6 +160,16 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav viewBinding.searchView.addTransitionListener(this) viewBinding.searchView.addTransitionListener(exitCallback) + supportFragmentManager.registerFragmentLifecycleCallbacks(object : FragmentManager.FragmentLifecycleCallbacks() { + override fun onFragmentDestroyed(fm: FragmentManager, f: Fragment) { + if (f is DialogFragment) { + viewBinding.root.post { + checkAndShowModeSwitcherSpotlight() + } + } + } + }, false) + // Defer heavy initialization to after window is shown to prevent ANR on slow devices lifecycleScope.launch { lifecycle.withResumed { @@ -154,9 +178,21 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav viewModel.isLoading.observe(this@MainActivity, this@MainActivity::onLoadingStateChanged) viewModel.isResumeEnabled.observe(this@MainActivity, this@MainActivity::onResumeEnabledChanged) viewModel.feedCounter.observe(this@MainActivity, ::onFeedCounterChanged) + viewModel.onShowOnboarding.observeEvent(this@MainActivity) { + onboardingLauncher.launch(Intent(this@MainActivity, OnboardingActivity::class.java)) + } viewModel.onFirstStart.observeEvent(this@MainActivity) { router.showWelcomeSheet() } + checkAndShowModeSwitcherSpotlight() viewModel.isBottomNavPinned.observe(this@MainActivity, ::setNavbarPinned) searchSuggestionViewModel.isIncognitoModeEnabled.observe(this@MainActivity, this@MainActivity::onIncognitoModeChanged) + viewModel.appMode.observe(this@MainActivity) { + invalidateOptionsMenu() + navigationDelegate.reloadCurrentFragment() + updateHomeWatermark() + } + viewModel.homeBackgroundFont.observe(this@MainActivity) { + updateHomeWatermark() + } initSearch() } } @@ -433,6 +469,61 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav } } + private fun updateHomeWatermark() { + val tv = viewBinding.textViewWatermark ?: return + val font = viewModel.homeBackgroundFont.value + if (font == HomeBackgroundFont.NONE) { + tv.isVisible = false + return + } + tv.isVisible = true + tv.alpha = 0.10f + val isNovel = viewModel.appMode.value.isNovel + tv.text = if (isNovel) "ラ\nノ\nベ" else "漫\n画" + font.fontAssetPath?.let { assetPath -> + FontCache.getTypeface(this, assetPath)?.let { typeface -> + tv.typeface = typeface + } + } + } + + override fun onDestroy() { + super.onDestroy() + modeSwitcherSpotlight = null + } + + private fun checkAndShowModeSwitcherSpotlight() { + if (settings.isModeSwitcherSpotlightShown || settings.onboardingVersion < AppSettings.CURRENT_ONBOARDING_VERSION) { + return + } + if (supportFragmentManager.fragments.any { it is DialogFragment && it.isVisible }) { + return + } + viewBinding.searchBar.post { + if (isFinishing || isDestroyed) return@post + if (settings.isModeSwitcherSpotlightShown) return@post + val target = viewBinding.searchBar.findViewById(R.id.action_app_mode) + if (target == null) { + viewBinding.searchBar.postDelayed({ + checkAndShowModeSwitcherSpotlight() + }, 300) + return@post + } + if (modeSwitcherSpotlight != null) return@post + + val contentRoot = findViewById(android.R.id.content) ?: return@post + val overlay = ModeSwitcherSpotlightOverlay(this, target) { demoToggle -> + settings.isModeSwitcherSpotlightShown = true + modeSwitcherSpotlight = null + if (demoToggle) { + viewModel.toggleAppMode() + } + } + modeSwitcherSpotlight = overlay + overlay.showIn(contentRoot) + } + } + private fun SearchView.observeState() = callbackFlow { val listener = SearchView.TransitionListener { _, _, state -> trySendBlocking(state) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/MainMenuProvider.kt b/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/MainMenuProvider.kt index 6900ad03a7..a096e54e15 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/MainMenuProvider.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/MainMenuProvider.kt @@ -20,9 +20,20 @@ class MainMenuProvider( menu.findItem(R.id.action_incognito)?.isChecked = viewModel.isIncognitoModeEnabled.value menu.findItem(R.id.action_app_update)?.isVisible = false + val isNovel = viewModel.appMode.value.isNovel + val modeItem = menu.findItem(R.id.action_app_mode) + if (modeItem != null) { + modeItem.setIcon(if (isNovel) R.drawable.ic_read else R.drawable.ic_novel) + modeItem.setTitle(if (isNovel) R.string.content_type_manga else R.string.content_type_novel) + } } override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { + R.id.action_app_mode -> { + viewModel.toggleAppMode() + true + } + R.id.action_settings -> { router.openSettings() true diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/MainNavigationDelegate.kt b/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/MainNavigationDelegate.kt index adaa043b16..eb24562f93 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/MainNavigationDelegate.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/MainNavigationDelegate.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.onEach import io.github.landwarderer.futon.R import io.github.landwarderer.futon.bookmarks.ui.AllBookmarksFragment import io.github.landwarderer.futon.core.nav.AppRouter +import io.github.landwarderer.futon.core.prefs.AppMode import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.core.prefs.NavItem import io.github.landwarderer.futon.core.ui.util.RecyclerViewOwner @@ -41,6 +42,7 @@ import io.github.landwarderer.futon.explore.ui.ExploreFragment import io.github.landwarderer.futon.favourites.ui.container.FavouritesContainerFragment import io.github.landwarderer.futon.history.ui.HistoryListFragment import io.github.landwarderer.futon.local.ui.LocalListFragment +import io.github.landwarderer.futon.novel.ui.local.NovelLocalListFragment import io.github.landwarderer.futon.suggestions.ui.SuggestionsFragment import io.github.landwarderer.futon.tracker.ui.feed.FeedFragment import io.github.landwarderer.futon.tracker.ui.updates.UpdatesFragment @@ -189,7 +191,7 @@ class MainNavigationDelegate( R.id.nav_favorites -> FavouritesContainerFragment::class.java R.id.nav_explore -> ExploreFragment::class.java R.id.nav_feed -> FeedFragment::class.java - R.id.nav_local -> LocalListFragment::class.java + R.id.nav_local -> if (settings.appMode == AppMode.NOVEL) NovelLocalListFragment::class.java else LocalListFragment::class.java R.id.nav_suggestions -> SuggestionsFragment::class.java R.id.nav_bookmarks -> AllBookmarksFragment::class.java R.id.nav_updated -> UpdatesFragment::class.java @@ -202,12 +204,34 @@ class MainNavigationDelegate( return true } + fun reloadCurrentFragment() { + val itemId = navBar.selectedItemId + if (itemId == R.id.nav_local) { + val fragmentClass = if (settings.appMode == AppMode.NOVEL) { + NovelLocalListFragment::class.java + } else { + LocalListFragment::class.java + } + if (fragmentManager.isStateSaved || fragmentClass.isInstance(primaryFragment)) return + val fragment = instantiateFragment(fragmentClass) + val args = buildBundle(1) { + putBoolean(AppRouter.KEY_IS_BOTTOMTAB, true) + } + fragment.enterTransition = MaterialFadeThrough() + fragmentManager.beginTransaction() + .setReorderingAllowed(true) + .replace(R.id.container, fragmentClass, args, TAG_PRIMARY) + .runOnCommit { onFragmentChanged(fragment, fromUser = false) } + .commit() + } + } + private fun getItemId(fragment: Fragment) = when (fragment) { is HistoryListFragment -> R.id.nav_history is FavouritesContainerFragment -> R.id.nav_favorites is ExploreFragment -> R.id.nav_explore is FeedFragment -> R.id.nav_feed - is LocalListFragment -> R.id.nav_local + is LocalListFragment, is NovelLocalListFragment -> R.id.nav_local is SuggestionsFragment -> R.id.nav_suggestions is AllBookmarksFragment -> R.id.nav_bookmarks is UpdatesFragment -> R.id.nav_updated diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/MainViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/MainViewModel.kt index 38e10c10a8..a6c8b1b769 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/MainViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/MainViewModel.kt @@ -32,6 +32,7 @@ class MainViewModel @Inject constructor( val onOpenReader = MutableEventFlow() val onFirstStart = MutableEventFlow() + val onShowOnboarding = MutableEventFlow() val isResumeEnabled = readingResumeEnabledUseCase() .withErrorHandling() @@ -58,7 +59,29 @@ class MainViewModel @Inject constructor( valueProducer = { isIncognitoModeEnabled }, ) + val appMode = settings.observeAsStateFlow( + scope = viewModelScope + Dispatchers.IO, + key = AppSettings.KEY_APP_MODE, + valueProducer = { appMode }, + ) + + val homeBackgroundFont = settings.observeAsStateFlow( + scope = viewModelScope + Dispatchers.IO, + key = AppSettings.KEY_HOME_BACKGROUND_FONT, + valueProducer = { homeBackgroundFont }, + ) + init { + launchJob(Dispatchers.IO) { + if (settings.onboardingVersion < AppSettings.CURRENT_ONBOARDING_VERSION) { + onShowOnboarding.call(Unit) + } else if (sourcesRepository.isSetupRequired()) { + onFirstStart.call(Unit) + } + } + } + + fun onOnboardingFinished() { launchJob(Dispatchers.IO) { if (sourcesRepository.isSetupRequired()) { onFirstStart.call(Unit) @@ -76,4 +99,12 @@ class MainViewModel @Inject constructor( fun setIncognitoMode(isEnabled: Boolean) { settings.isIncognitoModeEnabled = isEnabled } + + fun toggleAppMode() { + settings.appMode = if (settings.appMode == io.github.landwarderer.futon.core.prefs.AppMode.NOVEL) { + io.github.landwarderer.futon.core.prefs.AppMode.MANGA + } else { + io.github.landwarderer.futon.core.prefs.AppMode.NOVEL + } + } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/widgets/ModeSwitcherSpotlightOverlay.kt b/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/widgets/ModeSwitcherSpotlightOverlay.kt new file mode 100644 index 0000000000..6b6fc5dfb7 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/widgets/ModeSwitcherSpotlightOverlay.kt @@ -0,0 +1,150 @@ +package io.github.landwarderer.futon.main.ui.widgets + +import android.animation.ObjectAnimator +import android.animation.PropertyValuesHolder +import android.animation.ValueAnimator +import android.content.Context +import android.view.HapticFeedbackConstants +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.animation.AccelerateDecelerateInterpolator +import android.view.animation.OvershootInterpolator +import android.widget.FrameLayout +import androidx.activity.OnBackPressedCallback +import androidx.activity.OnBackPressedDispatcherOwner +import androidx.core.view.doOnLayout +import io.github.landwarderer.futon.databinding.LayoutModeSwitcherSpotlightBinding +import kotlin.math.max +import kotlin.math.min + +class ModeSwitcherSpotlightOverlay( + context: Context, + private val targetView: View, + private val onDismiss: (demoToggle: Boolean) -> Unit, +) : FrameLayout(context) { + + private val binding = LayoutModeSwitcherSpotlightBinding.inflate(LayoutInflater.from(context), this, true) + private var haloAnimator: ValueAnimator? = null + private var backCallback: OnBackPressedCallback? = null + + init { + alpha = 0f + setOnClickListener { + dismiss(demoToggle = true) + } + binding.buttonGotIt.setOnClickListener { + dismiss(demoToggle = true) + } + binding.cardCallout.setOnClickListener { + dismiss(demoToggle = true) + } + } + + fun showIn(parent: ViewGroup) { + parent.addView(this, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + animate().alpha(1f).setDuration(250).start() + + (context as? OnBackPressedDispatcherOwner)?.let { owner -> + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + dismiss(demoToggle = false) + } + } + owner.onBackPressedDispatcher.addCallback(owner, callback) + backCallback = callback + } + + doOnLayout { + positionElements() + startHaloPulseAnimation() + animateCalloutEntrance() + } + } + + private fun positionElements() { + val targetLoc = IntArray(2) + targetView.getLocationInWindow(targetLoc) + val overlayLoc = IntArray(2) + getLocationInWindow(overlayLoc) + + val targetCenterX = (targetLoc[0] - overlayLoc[0] + targetView.width / 2f) + val targetCenterY = (targetLoc[1] - overlayLoc[1] + targetView.height / 2f) + + val ringWidth = binding.viewSpotlightRing.width.toFloat() + val ringHeight = binding.viewSpotlightRing.height.toFloat() + binding.viewSpotlightRing.x = targetCenterX - ringWidth / 2f + binding.viewSpotlightRing.y = targetCenterY - ringHeight / 2f + + val haloWidth = binding.viewHaloRing.width.toFloat() + val haloHeight = binding.viewHaloRing.height.toFloat() + binding.viewHaloRing.x = targetCenterX - haloWidth / 2f + binding.viewHaloRing.y = targetCenterY - haloHeight / 2f + + binding.cardCallout.doOnLayout { card -> + val cardWidth = card.width.toFloat() + val margin = 16f * resources.displayMetrics.density + + var cardX = targetCenterX - cardWidth + (ringWidth / 2f) + cardX = max(margin, min(cardX, width - cardWidth - margin)) + + val cardY = targetCenterY + (ringHeight / 2f) + (12f * resources.displayMetrics.density) + + card.x = cardX + card.y = cardY + } + } + + private fun startHaloPulseAnimation() { + haloAnimator?.cancel() + val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 1.0f, 1.45f) + val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 1.0f, 1.45f) + val alpha = PropertyValuesHolder.ofFloat(View.ALPHA, 0.9f, 0.0f) + + haloAnimator = ObjectAnimator.ofPropertyValuesHolder(binding.viewHaloRing, scaleX, scaleY, alpha).apply { + duration = 1300 + repeatCount = ValueAnimator.INFINITE + repeatMode = ValueAnimator.RESTART + interpolator = AccelerateDecelerateInterpolator() + start() + } + + val ringScaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 0.95f, 1.08f) + val ringScaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 0.95f, 1.08f) + ObjectAnimator.ofPropertyValuesHolder(binding.viewSpotlightRing, ringScaleX, ringScaleY).apply { + duration = 900 + repeatCount = ValueAnimator.INFINITE + repeatMode = ValueAnimator.REVERSE + interpolator = AccelerateDecelerateInterpolator() + start() + } + } + + private fun animateCalloutEntrance() { + binding.cardCallout.scaleX = 0.7f + binding.cardCallout.scaleY = 0.7f + binding.cardCallout.alpha = 0f + binding.cardCallout.animate() + .scaleX(1.0f) + .scaleY(1.0f) + .alpha(1.0f) + .setDuration(400) + .setInterpolator(OvershootInterpolator(1.3f)) + .start() + } + + fun dismiss(demoToggle: Boolean) { + performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + haloAnimator?.cancel() + backCallback?.remove() + backCallback = null + animate() + .alpha(0f) + .setDuration(200) + .withEndAction { + (parent as? ViewGroup)?.removeView(this) + onDismiss(demoToggle) + } + .start() + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/LocalNovelRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/LocalNovelRepository.kt new file mode 100644 index 0000000000..84332c268c --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/LocalNovelRepository.kt @@ -0,0 +1,68 @@ +package io.github.landwarderer.futon.novel.data + +import io.github.landwarderer.futon.core.model.LocalNovelSource +import io.github.landwarderer.futon.core.parser.MangaRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities +import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.model.SortOrder +import java.util.EnumSet +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class LocalNovelRepository @Inject constructor( + private val novelStorageManager: NovelStorageManager, +) : MangaRepository { + + override val source: MangaSource = LocalNovelSource + + override val sortOrders: Set = EnumSet.of( + SortOrder.ALPHABETICAL, + SortOrder.NEWEST, + SortOrder.RELEVANCE, + ) + + override var defaultSortOrder: SortOrder = SortOrder.NEWEST + + override val filterCapabilities = MangaListFilterCapabilities( + isSearchSupported = true, + isSearchWithFiltersSupported = true, + ) + + override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List = withContext(Dispatchers.IO) { + val all = novelStorageManager.getAllNovels() + val query = filter?.query + val filtered = if (!query.isNullOrBlank()) { + all.filter { it.title.contains(query, ignoreCase = true) } + } else { + all + } + filtered.drop(offset) + } + + override suspend fun getDetails(manga: Manga): Manga = manga + + override suspend fun getPages(chapter: MangaChapter): List { + return listOf( + MangaPage( + id = chapter.id, + url = chapter.url, + preview = null, + source = LocalNovelSource, + ) + ) + } + + override suspend fun getPageUrl(page: MangaPage): String = page.url + + override suspend fun getFilterOptions(): MangaListFilterOptions = MangaListFilterOptions() + + override suspend fun getRelated(seed: Manga): List = emptyList() +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/NovelStorageManager.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/NovelStorageManager.kt new file mode 100644 index 0000000000..e56a5c16bb --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/NovelStorageManager.kt @@ -0,0 +1,216 @@ +package io.github.landwarderer.futon.novel.data + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import io.github.landwarderer.futon.core.model.LocalNovelSource +import io.github.landwarderer.futon.core.parser.MangaDataRepository +import io.github.landwarderer.futon.core.prefs.AppSettings +import io.github.landwarderer.futon.core.util.ext.isFileUri +import io.github.landwarderer.futon.core.util.ext.isReadable +import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug +import io.github.landwarderer.futon.core.util.ext.resolveFile +import io.github.landwarderer.futon.core.util.ext.toFileOrNull +import io.github.landwarderer.futon.novel.data.epub.EpubParser +import io.github.landwarderer.futon.novel.data.pdf.PdfRendererHelper +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.util.longHashCode +import java.io.File +import java.io.FileOutputStream +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NovelStorageManager @Inject constructor( + @ApplicationContext private val context: Context, + private val mangaRepository: MangaDataRepository, + private val settings: AppSettings, +) { + + val novelsDir: File + get() { + val dir = File(context.getExternalFilesDir(null) ?: context.filesDir, "novels") + if (!dir.exists()) dir.mkdirs() + return dir + } + + fun getNovelDirectories(): List { + return (listOf(novelsDir) + settings.userSpecifiedNovelDirectories.toList()).distinct() + } + + fun addNovelDirectory(dir: File) { + settings.userSpecifiedNovelDirectories = settings.userSpecifiedNovelDirectories + dir + } + + fun removeNovelDirectory(dir: File) { + settings.userSpecifiedNovelDirectories = settings.userSpecifiedNovelDirectories - dir + } + + suspend fun importNovel(uri: Uri): Manga? = withContext(Dispatchers.IO) { + try { + val resolvedFile = (if (uri.isFileUri()) uri.toFileOrNull() else uri.resolveFile(context)) + ?.takeIf { it.exists() && it.isReadable() } + if (resolvedFile != null) { + return@withContext parseFileToManga(resolvedFile) + } + + val fileName = queryDisplayName(uri) ?: "novel_${System.currentTimeMillis()}" + val destFile = File(novelsDir, fileName) + + context.contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(destFile).use { output -> + input.copyTo(output) + } + } ?: return@withContext null + + parseFileToManga(destFile) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + e.printStackTraceDebug() + null + } + } + + suspend fun getAllNovels(): List = withContext(Dispatchers.IO) { + val directories = getNovelDirectories() + val novels = mutableListOf() + val seenPaths = mutableSetOf() + + for (dir in directories) { + try { + if (!dir.exists() || !dir.isDirectory || !dir.isReadable()) continue + val files = dir.listFiles() ?: continue + for (file in files) { + if (file.isHidden || file.name.startsWith(".")) continue + if (!file.isFile || !file.isReadable()) continue + if (!isSupportedFile(file)) continue + if (!seenPaths.add(file.absolutePath)) continue + + parseFileToManga(file)?.let { novels.add(it) } + } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + e.printStackTraceDebug() + } + } + novels + } + + private suspend fun parseFileToManga(file: File): Manga? { + return try { + val isEpub = file.extension.equals("epub", ignoreCase = true) + val isPdf = file.extension.equals("pdf", ignoreCase = true) + if (!isEpub && !isPdf) return null + + val title = file.nameWithoutExtension + val mangaId = file.absolutePath.longHashCode() + + val manga = if (isEpub) { + val epubParser = EpubParser(file) + val book = epubParser.parseBook() + val chapters = book?.chapters?.map { ec -> + MangaChapter( + id = ec.id, + title = ec.title, + number = ec.index.toFloat(), + volume = 0, + url = ec.href, + uploadDate = 0L, + source = LocalNovelSource, + scanlator = null, + branch = null, + ) + } ?: emptyList() + + Manga( + id = mangaId, + title = book?.title ?: title, + altTitle = null, + url = file.absolutePath, + publicUrl = file.absolutePath, + rating = -1f, + isNsfw = false, + coverUrl = "", + tags = emptySet(), + state = null, + author = book?.author, + largeCoverUrl = null, + description = book?.description, + chapters = chapters, + source = LocalNovelSource, + ) + } else { + val pdfHelper = PdfRendererHelper(file) + val count = pdfHelper.pageCount + val chapters = (0 until count).map { pageIdx -> + MangaChapter( + id = pageIdx.toLong(), + title = "Page ${pageIdx + 1}", + number = (pageIdx + 1).toFloat(), + volume = 0, + url = "pdf://page/$pageIdx", + uploadDate = 0L, + source = LocalNovelSource, + scanlator = null, + branch = null, + ) + } + pdfHelper.close() + + Manga( + id = mangaId, + title = title, + altTitle = null, + url = file.absolutePath, + publicUrl = file.absolutePath, + rating = -1f, + isNsfw = false, + coverUrl = "", + tags = emptySet(), + state = null, + author = null, + largeCoverUrl = null, + description = "PDF Document ($count pages)", + chapters = chapters, + source = LocalNovelSource, + ) + } + + mangaRepository.storeManga(manga, replaceExisting = true) + manga + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + e.printStackTraceDebug() + null + } + } + + private fun queryDisplayName(uri: Uri): String? { + return try { + context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (idx != -1) cursor.getString(idx) else null + } else null + } + } catch (e: Exception) { + null + } + } + + companion object { + fun isSupportedFile(file: File): Boolean { + return file.extension.equals("epub", ignoreCase = true) || + file.extension.equals("pdf", ignoreCase = true) + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/epub/EpubBook.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/epub/EpubBook.kt new file mode 100644 index 0000000000..089dcf7141 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/epub/EpubBook.kt @@ -0,0 +1,18 @@ +package io.github.landwarderer.futon.novel.data.epub + +data class EpubChapter( + val id: Long, + val title: String, + val href: String, + val index: Int, +) + +data class EpubBook( + val id: Long, + val title: String, + val author: String?, + val description: String?, + val coverHref: String?, + val chapters: List, + val opfDir: String, +) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/epub/EpubParser.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/epub/EpubParser.kt new file mode 100644 index 0000000000..bf7775acdd --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/epub/EpubParser.kt @@ -0,0 +1,228 @@ +package io.github.landwarderer.futon.novel.data.epub + +import android.util.Xml +import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug +import okio.FileSystem +import okio.Path +import okio.Path.Companion.toOkioPath +import okio.Path.Companion.toPath +import okio.openZip +import okio.buffer +import org.koitharu.kotatsu.parsers.util.longHashCode +import org.xmlpull.v1.XmlPullParser +import java.io.File +import java.io.InputStream + +class EpubParser(private val file: File) { + + fun parseBook(): EpubBook? { + return try { + val zipFs = FileSystem.SYSTEM.openZip(file.toOkioPath()) + val opfPathStr = getOpfPath(zipFs) ?: return null + val opfPath = opfPathStr.toPath() + val opfDir = opfPath.parent?.toString()?.removePrefix("/") ?: "" + + var title = file.nameWithoutExtension + var author: String? = null + var description: String? = null + var coverHref: String? = null + + val manifest = mutableMapOf() + val spine = mutableListOf() + var tocHref: String? = null + + zipFs.read(opfPath) { + val parser = Xml.newPullParser().apply { + setInput(inputStream(), "UTF-8") + } + var eventType = parser.eventType + var currentTag = "" + + while (eventType != XmlPullParser.END_DOCUMENT) { + when (eventType) { + XmlPullParser.START_TAG -> { + currentTag = parser.name.lowercase() + when (currentTag) { + "item" -> { + val id = parser.getAttributeValue(null, "id").orEmpty() + val href = parser.getAttributeValue(null, "href").orEmpty() + val mediaType = parser.getAttributeValue(null, "media-type").orEmpty() + val properties = parser.getAttributeValue(null, "properties").orEmpty() + manifest[id] = ManifestItem(id, href, mediaType, properties) + if (properties.contains("cover-image") || id.contains("cover", ignoreCase = true)) { + coverHref = resolveHref(opfDir, href) + } + } + "itemref" -> { + val idref = parser.getAttributeValue(null, "idref").orEmpty() + spine.add(idref) + } + "spine" -> { + val tocId = parser.getAttributeValue(null, "toc") + if (tocId != null) { + manifest[tocId]?.let { tocHref = resolveHref(opfDir, it.href) } + } + } + } + } + XmlPullParser.TEXT -> { + val text = parser.text?.trim().orEmpty() + if (text.isNotEmpty()) { + when { + currentTag.endsWith("title") && title == file.nameWithoutExtension -> title = text + currentTag.endsWith("creator") && author == null -> author = text + currentTag.endsWith("description") && description == null -> description = text + } + } + } + } + eventType = parser.next() + } + } + + // Read table of contents for chapter titles if available + val tocTitles = mutableMapOf() + if (tocHref != null) { + val tocPath = tocHref?.toPath() + if (tocPath != null && zipFs.exists(tocPath)) { + zipFs.read(tocPath) { + parseTocNcx(inputStream(), tocTitles) + } + } + } + + val chapters = spine.mapNotNull { idref -> + val item = manifest[idref] ?: return@mapNotNull null + val resolved = resolveHref(opfDir, item.href) + val chapterTitle = tocTitles[resolved] + ?: tocTitles[item.href] + ?: item.href.substringAfterLast('/').substringBeforeLast('.') + .replace('-', ' ') + .replace('_', ' ') + .replaceFirstChar { it.uppercase() } + EpubChapter( + id = resolved.longHashCode(), + title = chapterTitle, + href = resolved, + index = spine.indexOf(idref), + ) + } + + EpubBook( + id = file.absolutePath.longHashCode(), + title = title, + author = author, + description = description, + coverHref = coverHref, + chapters = chapters, + opfDir = opfDir, + ) + } catch (e: Exception) { + e.printStackTraceDebug() + null + } + } + + fun readEntry(path: String): InputStream? { + return try { + val zipFs = FileSystem.SYSTEM.openZip(file.toOkioPath()) + val okioPath = path.removePrefix("/").toPath() + if (zipFs.exists(okioPath)) { + zipFs.source(okioPath).buffer().inputStream() + } else { + null + } + } catch (e: Exception) { + null + } + } + + fun getChapterHtml(href: String): String? { + return try { + val zipFs = FileSystem.SYSTEM.openZip(file.toOkioPath()) + val okioPath = href.removePrefix("/").toPath() + if (!zipFs.exists(okioPath)) return null + zipFs.read(okioPath) { + readUtf8() + } + } catch (e: Exception) { + e.printStackTraceDebug() + null + } + } + + private fun getOpfPath(zipFs: FileSystem): String? { + val containerPath = "META-INF/container.xml".toPath() + if (!zipFs.exists(containerPath)) return null + return zipFs.read(containerPath) { + val parser = Xml.newPullParser().apply { + setInput(inputStream(), "UTF-8") + } + var eventType = parser.eventType + var opfPath: String? = null + while (eventType != XmlPullParser.END_DOCUMENT) { + if (eventType == XmlPullParser.START_TAG && parser.name.equals("rootfile", ignoreCase = true)) { + opfPath = parser.getAttributeValue(null, "full-path") + break + } + eventType = parser.next() + } + opfPath + } + } + + private fun parseTocNcx(input: InputStream, result: MutableMap) { + try { + val parser = Xml.newPullParser().apply { + setInput(input, "UTF-8") + } + var eventType = parser.eventType + var currentLabel = "" + var inNavLabel = false + + while (eventType != XmlPullParser.END_DOCUMENT) { + when (eventType) { + XmlPullParser.START_TAG -> { + val tag = parser.name.lowercase() + if (tag == "navlabel") inNavLabel = true + if (tag == "content") { + val src = parser.getAttributeValue(null, "src").orEmpty().substringBefore('#') + if (currentLabel.isNotEmpty() && src.isNotEmpty()) { + result[src] = currentLabel + } + } + } + XmlPullParser.TEXT -> { + if (inNavLabel) { + val text = parser.text?.trim().orEmpty() + if (text.isNotEmpty()) currentLabel = text + } + } + XmlPullParser.END_TAG -> { + if (parser.name.equals("navlabel", ignoreCase = true)) { + inNavLabel = false + } + } + } + eventType = parser.next() + } + } catch (e: Exception) { + e.printStackTraceDebug() + } + } + + private fun resolveHref(baseDir: String, href: String): String { + return if (baseDir.isEmpty()) { + href.removePrefix("/") + } else { + "$baseDir/${href.removePrefix("/")}" + } + } + + private data class ManifestItem( + val id: String, + val href: String, + val mediaType: String, + val properties: String, + ) +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/model/NovelReadingMode.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/model/NovelReadingMode.kt new file mode 100644 index 0000000000..d86f70ca8d --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/model/NovelReadingMode.kt @@ -0,0 +1,11 @@ +package io.github.landwarderer.futon.novel.data.model + +import androidx.annotation.StringRes +import io.github.landwarderer.futon.R + +enum class NovelReadingMode( + @StringRes val titleRes: Int, +) { + CONTINUOUS_SCROLL(R.string.novel_scroll_continuous), + PAGED(R.string.novel_scroll_paged), +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/model/NovelTextAlign.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/model/NovelTextAlign.kt new file mode 100644 index 0000000000..3c13ef80e5 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/model/NovelTextAlign.kt @@ -0,0 +1,12 @@ +package io.github.landwarderer.futon.novel.data.model + +import androidx.annotation.StringRes +import io.github.landwarderer.futon.R + +enum class NovelTextAlign( + @StringRes val titleRes: Int, + val cssValue: String, +) { + LEFT(R.string.novel_align_left, "left"), + JUSTIFY(R.string.novel_align_justify, "justify"), +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/model/NovelTheme.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/model/NovelTheme.kt new file mode 100644 index 0000000000..3d5a1d1539 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/model/NovelTheme.kt @@ -0,0 +1,18 @@ +package io.github.landwarderer.futon.novel.data.model + +import android.graphics.Color +import androidx.annotation.ColorInt +import androidx.annotation.StringRes +import io.github.landwarderer.futon.R + +enum class NovelTheme( + @StringRes val titleRes: Int, + @ColorInt val backgroundColor: Int, + @ColorInt val textColor: Int, +) { + DARK(R.string.novel_theme_dark, Color.parseColor("#141414"), Color.parseColor("#E0E0E0")), + LIGHT(R.string.novel_theme_light, Color.parseColor("#FFFFFF"), Color.parseColor("#212121")), + OLED(R.string.novel_theme_oled, Color.parseColor("#000000"), Color.parseColor("#FFFFFF")), + SEPIA(R.string.novel_theme_sepia, Color.parseColor("#FBF0D9"), Color.parseColor("#5F4B32")), + CUSTOM(R.string.novel_theme_custom, Color.parseColor("#141414"), Color.parseColor("#E0E0E0")), +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/pdf/PdfRendererHelper.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/pdf/PdfRendererHelper.kt new file mode 100644 index 0000000000..bc5ddc9812 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/pdf/PdfRendererHelper.kt @@ -0,0 +1,103 @@ +package io.github.landwarderer.futon.novel.data.pdf + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.pdf.PdfRenderer +import android.net.Uri +import android.os.ParcelFileDescriptor +import androidx.collection.LruCache +import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug +import java.io.File + +class PdfRendererHelper : AutoCloseable { + + private var pfd: ParcelFileDescriptor? = null + private var renderer: PdfRenderer? = null + private val lock = Any() + + private val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt() + private val cacheSize = (maxMemory / 8).coerceAtLeast(1024) // 12.5% of max heap + private val bitmapCache = object : LruCache(cacheSize) { + override fun sizeOf(key: Int, value: Bitmap): Int { + return try { + (value.byteCount / 1024).coerceAtLeast(1) + } catch (e: Throwable) { + 1 + } + } + } + + val pageCount: Int + get() = synchronized(lock) { renderer?.pageCount ?: 0 } + + val isValid: Boolean + get() = synchronized(lock) { renderer != null && pageCount > 0 } + + constructor(file: File) { + try { + val descriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) + pfd = descriptor + renderer = PdfRenderer(descriptor) + } catch (e: Exception) { + e.printStackTraceDebug() + } + } + + constructor(context: Context, uri: Uri) { + try { + val descriptor = context.contentResolver.openFileDescriptor(uri, "r") + pfd = descriptor + if (descriptor != null) { + renderer = PdfRenderer(descriptor) + } + } catch (e: Exception) { + e.printStackTraceDebug() + } + } + + fun renderPage(pageIndex: Int, targetWidth: Int = 1080): Bitmap? { + val cached = bitmapCache.get(pageIndex) + if (cached != null && cached.width >= targetWidth) { + return cached + } + return synchronized(lock) { + val secondCheck = bitmapCache.get(pageIndex) + if (secondCheck != null && secondCheck.width >= targetWidth) { + return@synchronized secondCheck + } + val r = renderer ?: return@synchronized null + if (pageIndex < 0 || pageIndex >= r.pageCount) return@synchronized null + try { + val page = r.openPage(pageIndex) + val scale = targetWidth.toFloat() / page.width.toFloat() + val targetHeight = (page.height * scale).toInt().coerceAtLeast(1) + val bitmap = Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888) + page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + page.close() + bitmapCache.put(pageIndex, bitmap) + bitmap + } catch (e: Exception) { + e.printStackTraceDebug() + null + } + } + } + + fun renderThumbnail(pageIndex: Int = 0, thumbWidth: Int = 300): Bitmap? { + return renderPage(pageIndex, thumbWidth) + } + + override fun close() { + synchronized(lock) { + try { + bitmapCache.evictAll() + renderer?.close() + renderer = null + pfd?.close() + pfd = null + } catch (e: Exception) { + e.printStackTraceDebug() + } + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/BaseNovelRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/BaseNovelRepository.kt new file mode 100644 index 0000000000..6dffc00a60 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/BaseNovelRepository.kt @@ -0,0 +1,126 @@ +package io.github.landwarderer.futon.novel.data.source + +import io.github.landwarderer.futon.core.cache.MemoryContentCache +import io.github.landwarderer.futon.core.parser.CachingMangaRepository +import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities +import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable + +abstract class BaseNovelRepository( + cache: MemoryContentCache, + protected val httpClient: OkHttpClient, + override val source: NovelParserSource, +) : CachingMangaRepository(cache) { + + override val sortOrders: Set = setOf( + SortOrder.POPULARITY, + SortOrder.UPDATED, + SortOrder.NEWEST, + SortOrder.RELEVANCE, + ) + + override var defaultSortOrder: SortOrder = SortOrder.POPULARITY + + override val filterCapabilities: MangaListFilterCapabilities = MangaListFilterCapabilities( + isSearchSupported = true, + isSearchWithFiltersSupported = true, + ) + + override suspend fun getPageUrl(page: MangaPage): String = page.url + + override suspend fun getFilterOptions(): MangaListFilterOptions = MangaListFilterOptions() + + override suspend fun getRelatedMangaImpl(seed: Manga): List = emptyList() + + protected suspend fun fetchDocument( + url: String, + headers: Map = emptyMap(), + ): Document = withContext(Dispatchers.IO) { + val requestBuilder = Request.Builder() + .url(url) + .tag(MangaSource::class.java, source) + .header("User-Agent", DEFAULT_USER_AGENT) + .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") + .header("Accept-Language", "en-US,en;q=0.9") + + for ((key, value) in headers) { + requestBuilder.header(key, value) + } + + httpClient.newCall(requestBuilder.build()).execute().use { response -> + if (!response.isSuccessful) { + throw RuntimeException("HTTP error ${response.code} fetching $url") + } + val body = response.body?.string().orEmpty() + Jsoup.parse(body, url) + } + } + + protected suspend fun fetchString( + url: String, + headers: Map = emptyMap(), + ): String = withContext(Dispatchers.IO) { + val requestBuilder = Request.Builder() + .url(url) + .tag(MangaSource::class.java, source) + .header("User-Agent", DEFAULT_USER_AGENT) + .header("Accept", "*/*") + + for ((key, value) in headers) { + requestBuilder.header(key, value) + } + + httpClient.newCall(requestBuilder.build()).execute().use { response -> + if (!response.isSuccessful) { + throw RuntimeException("HTTP error ${response.code} fetching $url") + } + response.body?.string().orEmpty() + } + } + + protected suspend fun postJson( + url: String, + jsonBody: String, + headers: Map = emptyMap(), + ): String = withContext(Dispatchers.IO) { + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = jsonBody.toRequestBody(mediaType) + val requestBuilder = Request.Builder() + .url(url) + .tag(MangaSource::class.java, source) + .post(requestBody) + .header("User-Agent", DEFAULT_USER_AGENT) + .header("Accept", "application/json, text/javascript, */*; q=0.01") + .header("Content-Type", "application/json;charset=UTF-8") + + for ((key, value) in headers) { + requestBuilder.header(key, value) + } + + httpClient.newCall(requestBuilder.build()).execute().use { response -> + if (!response.isSuccessful) { + throw RuntimeException("HTTP error ${response.code} posting to $url") + } + response.body?.string().orEmpty() + } + } + + companion object { + const val DEFAULT_USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/FreeWebNovelRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/FreeWebNovelRepository.kt new file mode 100644 index 0000000000..a1fe08caf0 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/FreeWebNovelRepository.kt @@ -0,0 +1,291 @@ +package io.github.landwarderer.futon.novel.data.source + +import io.github.landwarderer.futon.core.cache.MemoryContentCache +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import okhttp3.OkHttpClient +import org.json.JSONObject +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.model.MangaState +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.util.longHashCode +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import java.net.URLEncoder + +class FreeWebNovelRepository( + cache: MemoryContentCache, + httpClient: OkHttpClient, +) : BaseNovelRepository(cache, httpClient, NovelParserSource.FREEWEBNOVEL) { + + private val baseUrl = source.defaultUrl + + override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List { + val page = (offset / 20) + 1 + val query = filter?.query?.trim().orEmpty() + + val url = if (query.isNotEmpty()) { + val encoded = URLEncoder.encode(query, "UTF-8") + if (page > 1) { + "$baseUrl/search?keyword=$encoded&page=$page" + } else { + "$baseUrl/search?keyword=$encoded" + } + } else { + val sortPath = when (order) { + SortOrder.NEWEST -> "latest-novel" + SortOrder.UPDATED -> "latest-release" + else -> "most-popular" + } + if (page > 1) { + "$baseUrl/sort/$sortPath/$page" + } else { + "$baseUrl/sort/$sortPath" + } + } + + val doc = fetchDocument(url) + val items = doc.select("div.li-row") + return items.mapNotNull { element -> + val titleEl = element.selectFirst("h3.tit a, .tit a") ?: return@mapNotNull null + val title = titleEl.attr("title").ifEmpty { titleEl.text().trim() } + if (title.isEmpty()) return@mapNotNull null + val href = titleEl.absUrl("href").ifEmpty { + val rel = titleEl.attr("href") + if (rel.startsWith("/")) "$baseUrl$rel" else "$baseUrl/$rel" + } + val coverEl = element.selectFirst("div.pic img, .pic img") + val coverUrl = coverEl?.absUrl("src")?.ifEmpty { null } ?: coverEl?.attr("src")?.let { + if (it.startsWith("/")) "$baseUrl$it" else "$baseUrl/$it" + }.orEmpty() + val desc = element.selectFirst("div.desc, .desc")?.text()?.trim() + val rating = element.selectFirst("div.core span, .core span")?.text()?.toFloatOrNull() ?: 0f + val tags = element.select("a[href*='/genre/']").mapNotNull { + val tagText = it.text().trim() + if (tagText.isNotEmpty()) MangaTag(tagText, tagText, source) else null + }.toSet() + + Manga( + id = "${source.name}_$href".longHashCode(), + title = title, + altTitles = emptySet(), + url = href, + publicUrl = href, + rating = rating, + contentRating = null, + coverUrl = coverUrl, + tags = tags, + state = null, + authors = emptySet(), + largeCoverUrl = coverUrl, + description = desc, + chapters = null, + source = source, + ) + } + } + + public override suspend fun getDetailsImpl(manga: Manga): Manga { + val doc = fetchDocument(manga.url) + val desc = doc.selectFirst("div.inner, .inner")?.text()?.trim() ?: manga.description + val coverEl = doc.selectFirst("div.pic img, .pic img") + val cover = coverEl?.absUrl("src")?.ifEmpty { null } + ?: coverEl?.attr("src")?.let { if (it.startsWith("/")) "$baseUrl$it" else "$baseUrl/$it" } + ?: manga.coverUrl + val author = doc.selectFirst("meta[property=og:novel:author]")?.attr("content") + ?: doc.selectFirst("a[href^=/author/], a[href*='/author/']")?.text()?.trim() + val authors = if (!author.isNullOrBlank()) setOf(author) else manga.authors + + val genres = doc.selectFirst("meta[property=og:novel:genre]")?.attr("content") + val tags = if (!genres.isNullOrBlank()) { + genres.split(",").map { it.trim() }.filter { it.isNotEmpty() }.map { + MangaTag(it, it, source) + }.toSet() + } else { + doc.select("a[href*='/genre/']").mapNotNull { + val tagText = it.text().trim() + if (tagText.isNotEmpty()) MangaTag(tagText, tagText, source) else null + }.toSet().ifEmpty { manga.tags } + } + + val statusStr = doc.selectFirst("meta[property=og:novel:status]")?.attr("content").orEmpty() + val state = when { + statusStr.contains("completed", ignoreCase = true) || statusStr.contains("full", ignoreCase = true) -> + MangaState.FINISHED + statusStr.contains("ongoing", ignoreCase = true) -> + MangaState.ONGOING + else -> manga.state + } + + val chapters = loadAllChapters(manga.url, doc) + + return manga.copy( + description = desc, + coverUrl = cover, + largeCoverUrl = cover, + authors = authors, + tags = tags, + state = state, + chapters = chapters, + ) + } + + private suspend fun loadAllChapters(novelUrl: String, doc: Document): List { + val indexListPage = doc.selectFirst("#indexListPage") + val totalChapters = indexListPage?.attr("data-total-chapters")?.toIntOrNull() ?: 0 + + // If total chapters <= 40 or indexListPage not found, try parsing from DOM first + if (totalChapters in 1..40 || indexListPage == null) { + val domChapters = parseChaptersFromDom(doc) + if (domChapters.isNotEmpty()) { + return domChapters + } + } + + // Try AJAX endpoint + val ajaxResult = runCatchingCancellable { + loadChaptersViaAjax(novelUrl) + }.getOrNull() + + return if (!ajaxResult.isNullOrEmpty()) { + ajaxResult + } else { + parseChaptersFromDom(doc) + } + } + + private suspend fun loadChaptersViaAjax(novelUrl: String): List { + val firstPageUrl = "$novelUrl?ajax=chapters&page=1&pageSize=200" + val firstJsonStr = fetchString( + url = firstPageUrl, + headers = mapOf( + "Accept" to "application/json, text/javascript, */*; q=0.01", + "X-Requested-With" to "XMLHttpRequest", + "Referer" to novelUrl, + ), + ) + val firstJson = JSONObject(firstJsonStr) + if (firstJson.optInt("code", 0) != 200) { + return emptyList() + } + + val totalPages = firstJson.optInt("totalPage", 1) + val firstHtml = firstJson.optString("html", "") + val allChapters = parseChapterElementsFromHtml(firstHtml).toMutableList() + + if (totalPages > 1) { + val semaphore = Semaphore(MAX_PARALLELISM) + val remainingPages = (2..totalPages.coerceAtMost(MAX_CHAPTER_PAGES)).toList() + val additionalPages = coroutineScope { + remainingPages.map { pageNum -> + async(Dispatchers.IO) { + semaphore.withPermit { + runCatchingCancellable { + val pageUrl = "$novelUrl?ajax=chapters&page=$pageNum&pageSize=200" + val jsonStr = fetchString( + url = pageUrl, + headers = mapOf( + "Accept" to "application/json, text/javascript, */*; q=0.01", + "X-Requested-With" to "XMLHttpRequest", + "Referer" to novelUrl, + ), + ) + val json = JSONObject(jsonStr) + val html = json.optString("html", "") + pageNum to parseChapterElementsFromHtml(html) + }.getOrNull() + } + } + }.mapNotNull { it.await() } + } + + additionalPages.sortedBy { it.first }.forEach { (_, chapters) -> + allChapters.addAll(chapters) + } + } + + return allChapters.mapIndexed { index, (chapTitle, chapUrl) -> + MangaChapter( + id = "${source.name}_$chapUrl".longHashCode(), + title = chapTitle, + number = (index + 1).toFloat(), + volume = 0, + url = chapUrl, + uploadDate = 0L, + source = source, + scanlator = null, + branch = null, + ) + } + } + + private fun parseChaptersFromDom(doc: Document): List { + val chapterElements = doc.select("#idData li a").ifEmpty { + doc.select("ul.ul-list5:last-of-type li a").ifEmpty { + doc.select("ul.ul-list5 li a") + } + } + + return chapterElements.mapIndexed { index, a -> + val chapTitle = a.attr("title").ifEmpty { a.text().trim() } + val chapUrl = a.absUrl("href").ifEmpty { + val rel = a.attr("href") + if (rel.startsWith("/")) "$baseUrl$rel" else "$baseUrl/$rel" + } + MangaChapter( + id = "${source.name}_$chapUrl".longHashCode(), + title = chapTitle, + number = (index + 1).toFloat(), + volume = 0, + url = chapUrl, + uploadDate = 0L, + source = source, + scanlator = null, + branch = null, + ) + } + } + + private fun parseChapterElementsFromHtml(html: String): List> { + if (html.isEmpty()) return emptyList() + val fragment = Jsoup.parseBodyFragment(html, baseUrl) + return fragment.select("li a, a").mapNotNull { a -> + val chapTitle = a.attr("title").ifEmpty { a.text().trim() } + val chapUrl = a.absUrl("href").ifEmpty { + val rel = a.attr("href") + if (rel.startsWith("/")) "$baseUrl$rel" else "$baseUrl/$rel" + } + if (chapUrl.isNotEmpty()) chapTitle to chapUrl else null + } + } + + public override suspend fun getPagesImpl(chapter: MangaChapter): List { + val doc = fetchDocument(chapter.url) + val article = doc.selectFirst("div#article, #article") ?: doc.body() + // Remove ads, scripts, and navigation + article.select(".ad-box, script, style, iframe, .m-page, .m-nav, a#prev_url, a#next_url, .reader-ad-skip, [id^=bg-ssp], .comments-widget, #e-comments").remove() + val contentHtml = article.html() + + return listOf( + MangaPage( + id = chapter.id, + url = contentHtml, + preview = null, + source = source, + ) + ) + } + + companion object { + private const val MAX_PARALLELISM = 4 + private const val MAX_CHAPTER_PAGES = 50 + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/LNCrawlerRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/LNCrawlerRepository.kt new file mode 100644 index 0000000000..9c46ddc084 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/LNCrawlerRepository.kt @@ -0,0 +1,333 @@ +package io.github.landwarderer.futon.novel.data.source + +import io.github.landwarderer.futon.core.cache.MemoryContentCache +import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import org.json.JSONArray +import org.json.JSONObject +import org.jsoup.Jsoup +import org.jsoup.parser.Parser +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.model.MangaState +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.util.longHashCode +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import java.net.URLEncoder + +class LNCrawlerRepository( + cache: MemoryContentCache, + httpClient: OkHttpClient, +) : BaseNovelRepository(cache, httpClient, NovelParserSource.LNCRAWLER) { + + val baseUrl = source.defaultUrl + val apiUrl = "https://api.lncrawler.monster" + val sitemapUrl = "$baseUrl/sitemap.xml" + + override val sortOrders: Set = setOf( + SortOrder.POPULARITY, + SortOrder.UPDATED, + SortOrder.NEWEST, + SortOrder.RATING, + SortOrder.ALPHABETICAL, + ) + + override var defaultSortOrder: SortOrder = SortOrder.POPULARITY + + override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List { + return runCatchingCancellable { + fetchFromApi(offset, order, filter) + }.getOrElse { error -> + error.printStackTraceDebug("LNCrawlerRepository") + // Fallback: check sitemap index if API fails + fetchFromSitemapFallback(offset) + } + } + + private suspend fun fetchFromApi(offset: Int, order: SortOrder?, filter: MangaListFilter?): List { + val page = (offset / PAGE_SIZE) + 1 + val query = filter?.query?.trim().orEmpty() + val sortKey = mapSortKey(order) + val sortDirection = mapSortOrder(order) + + val url = if (query.isNotEmpty()) { + val encoded = URLEncoder.encode(query, "UTF-8") + "$apiUrl/novels/search/?query=$encoded&sort_by=$sortKey&sort_order=$sortDirection&page=$page&page_size=$PAGE_SIZE" + } else { + "$apiUrl/novels/search/?sort_by=$sortKey&sort_order=$sortDirection&page=$page&page_size=$PAGE_SIZE" + } + + val jsonStr = fetchString(url) + val json = JSONObject(jsonStr) + val results = json.optJSONArray("results") ?: JSONArray() + val mangas = ArrayList(results.length()) + + for (i in 0 until results.length()) { + val item = results.getJSONObject(i) + val title = item.optString("title").trim() + val slug = item.optString("slug").trim() + if (title.isEmpty() || slug.isEmpty()) continue + + val novelUrl = "$baseUrl/novels/$slug/" + val prefSource = item.optJSONObject("prefered_source") + val coverUrl = prefSource?.optString("cover_min_url")?.ifEmpty { null } + ?: prefSource?.optString("cover_url")?.ifEmpty { null }.orEmpty() + val rating = item.optDouble("avg_rating", 0.0).toFloat() + + val authors = buildSet { + prefSource?.optJSONArray("authors")?.let { arr -> + for (j in 0 until arr.length()) { + val author = arr.optString(j).trim() + if (author.isNotEmpty()) add(author) + } + } + } + + val tags = buildSet { + prefSource?.optJSONArray("tags")?.let { arr -> + for (j in 0 until arr.length()) { + val tag = arr.optString(j).trim() + if (tag.isNotEmpty()) add(MangaTag(tag, tag, source)) + } + } + } + + mangas.add( + Manga( + id = "${source.name}_$novelUrl".longHashCode(), + title = title, + altTitles = emptySet(), + url = novelUrl, + publicUrl = novelUrl, + rating = rating, + contentRating = null, + coverUrl = coverUrl, + tags = tags, + state = null, + authors = authors, + largeCoverUrl = coverUrl, + description = null, + chapters = null, + source = source, + ) + ) + } + return mangas + } + + public override suspend fun getDetailsImpl(manga: Manga): Manga { + val (novelSlug, sourceSlug) = parseNovelAndSourceSlugs(manga.url) + check(novelSlug.isNotEmpty()) { "Invalid novel slug from URL: ${manga.url}" } + + val novelJson = JSONObject(fetchString("$apiUrl/novels/$novelSlug/")) + val title = novelJson.optString("title").trim().ifEmpty { manga.title } + val rating = novelJson.optDouble("avg_rating", manga.rating.toDouble()).toFloat() + + val prefSource = novelJson.optJSONObject("prefered_source") + val sourcesArr = novelJson.optJSONArray("sources") + val targetSourceSlug = sourceSlug + ?: prefSource?.optString("source_slug")?.ifEmpty { null } + ?: (if (sourcesArr != null && sourcesArr.length() > 0) sourcesArr.getJSONObject(0).optString("source_slug") else null) + + if (targetSourceSlug.isNullOrEmpty()) { + return manga.copy(title = title, rating = rating, chapters = emptyList()) + } + + val sourceDetailJson = JSONObject(fetchString("$apiUrl/novels/$novelSlug/$targetSourceSlug/")) + val rawSynopsis = sourceDetailJson.optString("synopsis") + val desc = if (rawSynopsis.isNotEmpty()) { + Jsoup.parse(rawSynopsis).text().trim() + } else { + manga.description + } + + val cover = sourceDetailJson.optString("cover_url").ifEmpty { + prefSource?.optString("cover_url")?.ifEmpty { null } ?: manga.coverUrl + } + + val authors = buildSet { + sourceDetailJson.optJSONArray("authors")?.let { arr -> + for (i in 0 until arr.length()) { + val a = arr.optString(i).trim() + if (a.isNotEmpty()) add(a) + } + } + }.ifEmpty { manga.authors } + + val tags = buildSet { + sourceDetailJson.optJSONArray("tags")?.let { arr -> + for (i in 0 until arr.length()) { + val t = arr.optString(i).trim() + if (t.isNotEmpty()) add(MangaTag(t, t, source)) + } + } + }.ifEmpty { manga.tags } + + val statusStr = sourceDetailJson.optString("status") + val state = when { + statusStr.contains("Completed", ignoreCase = true) -> MangaState.FINISHED + statusStr.contains("Ongoing", ignoreCase = true) -> MangaState.ONGOING + statusStr.contains("Hiatus", ignoreCase = true) -> MangaState.ABANDONED + else -> manga.state + } + + val sourceName = sourceDetailJson.optString("source_name").ifEmpty { targetSourceSlug } + val firstPageJson = JSONObject(fetchString("$apiUrl/novels/$novelSlug/$targetSourceSlug/chapters/?page=1&page_size=500")) + val totalPages = firstPageJson.optInt("total_pages", 1) + + val allChapterObjs = ArrayList() + firstPageJson.optJSONArray("chapters")?.let { arr -> + for (i in 0 until arr.length()) { + allChapterObjs.add(arr.getJSONObject(i)) + } + } + + if (totalPages > 1) { + val remainingPages = coroutineScope { + (2..totalPages).map { p -> + async { + runCatching { + val pJson = JSONObject(fetchString("$apiUrl/novels/$novelSlug/$targetSourceSlug/chapters/?page=$p&page_size=500")) + val pArr = pJson.optJSONArray("chapters") ?: JSONArray() + (0 until pArr.length()).map { pArr.getJSONObject(it) } + }.getOrDefault(emptyList()) + } + }.awaitAll().flatten() + } + allChapterObjs.addAll(remainingPages) + } + + val chapters = allChapterObjs.mapIndexed { index, c -> + val chapterId = c.optInt("chapter_id", index + 1) + val chapTitle = c.optString("title").trim().ifEmpty { "Chapter $chapterId" } + val chapUrl = "$apiUrl/novels/$novelSlug/$targetSourceSlug/chapter/$chapterId/" + val volume = c.optInt("volume", 0) + + MangaChapter( + id = "${source.name}_${novelSlug}_${targetSourceSlug}_$chapterId".longHashCode(), + title = chapTitle, + number = chapterId.toFloat(), + volume = volume, + url = chapUrl, + uploadDate = 0L, + source = source, + scanlator = sourceName, + branch = sourceName, + ) + } + + return manga.copy( + title = title, + rating = rating, + description = desc, + coverUrl = cover, + largeCoverUrl = cover, + authors = authors, + tags = tags, + state = state, + chapters = chapters, + ) + } + + public override suspend fun getPagesImpl(chapter: MangaChapter): List { + val json = JSONObject(fetchString(chapter.url)) + val bodyHtml = json.optString("body") + val doc = Jsoup.parseBodyFragment(bodyHtml) + doc.select("script, style, iframe").remove() + val cleanHtml = doc.body().html() + + return listOf( + MangaPage( + id = chapter.id, + url = cleanHtml, + preview = null, + source = source, + ) + ) + } + + suspend fun fetchSitemapIndex(): List = withContext(Dispatchers.IO) { + val xml = fetchString(sitemapUrl) + val doc = Jsoup.parse(xml, "", Parser.xmlParser()) + doc.select("sitemap loc").map { it.text().trim() }.filter { it.isNotEmpty() } + } + + suspend fun fetchSitemapNovels(sitemapNovelUrl: String = "$baseUrl/sitemap-novels.xml"): List = withContext(Dispatchers.IO) { + val xml = fetchString(sitemapNovelUrl) + val doc = Jsoup.parse(xml, "", Parser.xmlParser()) + doc.select("url loc").map { it.text().trim() }.filter { it.isNotEmpty() } + } + + private suspend fun fetchFromSitemapFallback(offset: Int): List { + val sitemapPage = (offset / 1000) + 1 + val sitemapNovelUrl = if (sitemapPage == 1) "$baseUrl/sitemap-novels.xml" else "$baseUrl/sitemap-novels.xml?p=$sitemapPage" + val urls = runCatchingCancellable { fetchSitemapNovels(sitemapNovelUrl) }.getOrDefault(emptyList()) + val subList = urls.drop(offset % 1000).take(PAGE_SIZE) + return subList.map { url -> + val slug = url.trimEnd('/').substringAfterLast('/') + val title = slug.replace('-', ' ').split(' ') + .joinToString(" ") { word -> word.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } } + Manga( + id = "${source.name}_$url".longHashCode(), + title = title, + altTitles = emptySet(), + url = url, + publicUrl = url, + rating = 0f, + contentRating = null, + coverUrl = "", + tags = emptySet(), + state = null, + authors = emptySet(), + largeCoverUrl = "", + description = null, + chapters = null, + source = source, + ) + } + } + + fun parseNovelAndSourceSlugs(url: String): Pair { + val path = url.substringAfter("://").substringAfter('/') + val segments = path.split('/').filter { it.isNotEmpty() } + val novelIndex = segments.indexOf("novels") + if (novelIndex != -1 && novelIndex + 1 < segments.size) { + val novelSlug = segments[novelIndex + 1] + val potentialSource = segments.getOrNull(novelIndex + 2) + val sourceSlug = if (potentialSource != null && potentialSource != "chapterlist" && potentialSource != "chapters") { + potentialSource + } else { + null + } + return novelSlug to sourceSlug + } + val cleaned = url.trimEnd('/').substringAfterLast('/') + return cleaned to null + } + + private fun mapSortKey(order: SortOrder?): String = when (order) { + SortOrder.POPULARITY -> "popularity" + SortOrder.UPDATED -> "last_updated" + SortOrder.NEWEST -> "date_added" + SortOrder.RATING -> "rating" + SortOrder.ALPHABETICAL -> "title" + else -> "popularity" + } + + private fun mapSortOrder(order: SortOrder?): String = when (order) { + SortOrder.ALPHABETICAL -> "asc" + else -> "desc" + } + + companion object { + private const val PAGE_SIZE = 20 + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/LnoriRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/LnoriRepository.kt new file mode 100644 index 0000000000..92a762d4ec --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/LnoriRepository.kt @@ -0,0 +1,495 @@ +package io.github.landwarderer.futon.novel.data.source + +import io.github.landwarderer.futon.core.cache.MemoryContentCache +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.OkHttpClient +import org.json.JSONArray +import org.json.JSONObject +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities +import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.util.longHashCode +import java.util.Locale + +class LnoriRepository( + cache: MemoryContentCache, + httpClient: OkHttpClient, +) : BaseNovelRepository(cache, httpClient, NovelParserSource.LNORI) { + + private val baseUrl = source.defaultUrl + + override val sortOrders: Set = setOf( + SortOrder.POPULARITY, + SortOrder.NEWEST, + SortOrder.ALPHABETICAL, + SortOrder.RELEVANCE, + ) + + override var defaultSortOrder: SortOrder = SortOrder.POPULARITY + + override val filterCapabilities: MangaListFilterCapabilities = MangaListFilterCapabilities( + isSearchSupported = true, + isSearchWithFiltersSupported = true, + isMultipleTagsSupported = true, + isTagsExclusionSupported = true, + ) + + private data class LnoriEntry( + val manga: Manga, + val year: Int, + val volumes: Int, + val relevance: Int, + val tags: Set, + val author: String, + ) + + companion object { + private const val CACHE_TTL_MS = 60 * 60 * 1000L // 1 hour + private val catalogMutex = Mutex() + private val fetchScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + @Volatile + private var cachedCatalog: List? = null + + @Volatile + private var lastCacheTime: Long = 0L + + @Volatile + private var inFlightFetch: Deferred>? = null + + // Exposed for unit tests + internal fun clearCache() { + cachedCatalog = null + lastCacheTime = 0L + inFlightFetch = null + } + } + + private suspend fun getOrFetchCatalog(): List { + val cached = cachedCatalog + val now = System.currentTimeMillis() + if (cached != null && (now - lastCacheTime) < CACHE_TTL_MS) { + return cached + } + val deferred = catalogMutex.withLock { + val current = cachedCatalog + if (current != null && (System.currentTimeMillis() - lastCacheTime) < CACHE_TTL_MS) { + return current + } + inFlightFetch ?: fetchScope.async { + try { + val fresh = fetchCatalogFromWeb() + cachedCatalog = fresh + lastCacheTime = System.currentTimeMillis() + fresh + } finally { + catalogMutex.withLock { + inFlightFetch = null + } + } + }.also { inFlightFetch = it } + } + return deferred.await() + } + + private suspend fun fetchCatalogFromWeb(): List { + val doc = fetchDocument("$baseUrl/library") + val cards = doc.select("article.card") + val result = mutableListOf() + + for (card in cards) { + val title = card.attr("data-t").trim().ifEmpty { + card.selectFirst(".card-title, h2, h3")?.text()?.trim().orEmpty() + } + if (title.isEmpty()) continue + + val author = card.attr("data-a").trim().ifEmpty { + card.selectFirst(".popup-author")?.text()?.substringBefore('(')?.trim().orEmpty() + } + val year = card.attr("data-d").toIntOrNull() ?: 0 + val volumes = card.attr("data-v").toIntOrNull() ?: 0 + val relevance = card.attr("data-rel").toIntOrNull() ?: 0 + + val rawTags = card.attr("data-tags") + val tagSet = if (rawTags.isNotEmpty()) { + rawTags.split(',') + .map { it.trim().lowercase(Locale.ROOT) } + .filter { it.isNotEmpty() } + .toSet() + } else { + emptySet() + } + + val mangaTags = tagSet.map { key -> + val formattedTitle = key.split(" ").joinToString(" ") { word -> + word.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() } + } + MangaTag( + title = formattedTitle, + key = key, + source = source, + ) + }.toSet() + + val linkEl = card.selectFirst("a[href*='/series/']") ?: card.selectFirst("a") + val href = linkEl?.absUrl("href")?.ifEmpty { + val rawHref = linkEl.attr("href") + if (rawHref.startsWith("/")) "$baseUrl$rawHref" else rawHref + } ?: "" + if (href.isBlank()) continue + + val coverEl = card.selectFirst("img") + val coverUrl = coverEl?.absUrl("src")?.ifEmpty { coverEl.attr("src") }.orEmpty() + val desc = card.selectFirst(".popup-description")?.text()?.trim() + + val manga = Manga( + id = "${source.name}_$href".longHashCode(), + title = title, + altTitle = null, + url = href, + publicUrl = href, + rating = 0f, + isNsfw = false, + coverUrl = coverUrl, + tags = mangaTags, + state = null, + author = author.ifBlank { null }, + largeCoverUrl = coverUrl, + description = desc, + chapters = null, + source = source, + ) + + result.add( + LnoriEntry( + manga = manga, + year = year, + volumes = volumes, + relevance = relevance, + tags = tagSet, + author = author, + ) + ) + } + return result + } + + override suspend fun getFilterOptions(): MangaListFilterOptions { + val catalog = getOrFetchCatalog() + val allTags = catalog.flatMap { it.tags }.toSet().sorted() + val mangaTags = allTags.map { key -> + val formattedTitle = key.split(" ").joinToString(" ") { word -> + word.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() } + } + MangaTag( + title = formattedTitle, + key = key, + source = source, + ) + }.toSet() + return MangaListFilterOptions( + availableTags = mangaTags, + ) + } + + override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List { + val catalog = getOrFetchCatalog() + var filtered = catalog + + // 1. Query filter + val query = filter?.query?.trim().orEmpty() + if (query.isNotEmpty()) { + filtered = filtered.filter { entry -> + entry.manga.title.contains(query, ignoreCase = true) || + entry.author.contains(query, ignoreCase = true) + } + } + + // 2. Tag filter (Include) + val filterTags = filter?.tags + if (!filterTags.isNullOrEmpty()) { + val reqKeys = filterTags.map { it.key.lowercase(Locale.ROOT) } + val reqTitles = filterTags.map { it.title.lowercase(Locale.ROOT) } + filtered = filtered.filter { entry -> + reqKeys.all { it in entry.tags } || reqTitles.all { it in entry.tags } + } + } + + // 3. Tag filter (Exclude) + val filterTagsExclude = filter?.tagsExclude + if (!filterTagsExclude.isNullOrEmpty()) { + val excKeys = filterTagsExclude.map { it.key.lowercase(Locale.ROOT) } + val excTitles = filterTagsExclude.map { it.title.lowercase(Locale.ROOT) } + filtered = filtered.filterNot { entry -> + excKeys.any { it in entry.tags } || excTitles.any { it in entry.tags } + } + } + + // 4. Stable sorting + val effectiveOrder = order ?: defaultSortOrder + val sorted = when (effectiveOrder) { + SortOrder.NEWEST, SortOrder.UPDATED -> { + filtered.sortedWith( + compareByDescending { it.year } + .thenByDescending { it.relevance } + .thenBy { it.manga.id } + ) + } + SortOrder.ALPHABETICAL -> { + filtered.sortedWith( + compareBy { it.manga.title.lowercase(Locale.ROOT) } + .thenBy { it.manga.id } + ) + } + SortOrder.RELEVANCE -> { + if (query.isNotEmpty()) { + filtered.sortedWith( + compareByDescending { it.manga.title.equals(query, ignoreCase = true) } + .thenByDescending { it.manga.title.startsWith(query, ignoreCase = true) } + .thenByDescending { it.relevance } + .thenBy { it.manga.title } + .thenBy { it.manga.id } + ) + } else { + filtered.sortedWith( + compareByDescending { it.relevance } + .thenBy { it.manga.title } + .thenBy { it.manga.id } + ) + } + } + SortOrder.POPULARITY -> { + filtered.sortedWith( + compareByDescending { it.relevance } + .thenBy { it.manga.title } + .thenBy { it.manga.id } + ) + } + else -> { + filtered.sortedWith( + compareByDescending { it.relevance } + .thenBy { it.manga.title } + .thenBy { it.manga.id } + ) + } + } + + // 5. Pagination + return sorted.drop(offset).take(20).map { it.manga } + } + + public override suspend fun getDetailsImpl(manga: Manga): Manga { + val doc = fetchDocument(manga.url) + val title = doc.selectFirst("h1.series-title, h1")?.text()?.trim() ?: manga.title + val cover = doc.selectFirst(".series-cover img, figure img, img")?.absUrl("src")?.ifEmpty { manga.coverUrl } ?: manga.coverUrl + val desc = doc.selectFirst(".series-synopsis, .series-description, p.synopsis, p.description")?.text()?.trim() ?: manga.description + val author = doc.selectFirst(".series-author a, .series-author")?.text()?.trim() ?: manga.author + + // 1. Try JSON-LD Schema.org hasPart + val scriptEls = doc.select("script[type='application/ld+json']") + var jsonChapters: List? = null + + for (scriptEl in scriptEls) { + val jsonStr = scriptEl.data().trim() + if (!jsonStr.contains("hasPart") && !jsonStr.contains("Book")) continue + try { + val root = JSONObject(jsonStr) + val hasPartObj = root.opt("hasPart") ?: continue + val bookList = mutableListOf() + if (hasPartObj is JSONArray) { + for (i in 0 until hasPartObj.length()) { + val item = hasPartObj.optJSONObject(i) + if (item != null) bookList.add(item) + } + } else if (hasPartObj is JSONObject) { + bookList.add(hasPartObj) + } + + if (bookList.isNotEmpty()) { + jsonChapters = bookList.mapIndexedNotNull { index, book -> + val bookUrl = book.optString("url").trim() + if (bookUrl.isEmpty()) return@mapIndexedNotNull null + val bookName = book.optString("name").trim().ifEmpty { "Volume ${index + 1}" } + val positionStr = book.optString("position").trim() + val posNumber = positionStr.toFloatOrNull() ?: (index + 1).toFloat() + + MangaChapter( + id = "${source.name}_$bookUrl".longHashCode(), + title = bookName, + number = posNumber, + volume = posNumber.toInt(), + url = bookUrl, + uploadDate = 0L, + source = source, + scanlator = null, + branch = null, + ) + } + if (!jsonChapters.isNullOrEmpty()) { + break + } + } + } catch (_: Exception) { + // Fall back to DOM parsing + } + } + + // 2. DOM fallback + val domChapters = mutableListOf() + val volCards = doc.select(".vol-grid article.card, section.vol-grid article.card") + if (volCards.isNotEmpty()) { + volCards.forEachIndexed { index, card -> + val linkEl = card.selectFirst("a[href*='/book/']") ?: return@forEachIndexed + val bookHref = linkEl.absUrl("href").ifEmpty { + val raw = linkEl.attr("href") + if (raw.startsWith("/")) "$baseUrl$raw" else raw + } + val volumeTitle = card.selectFirst(".card-title, h2, h3")?.text()?.trim() + ?.ifEmpty { linkEl.attr("aria-label").trim() } + ?.ifEmpty { "Volume ${index + 1}" } + ?: "Volume ${index + 1}" + val pos = (index + 1).toFloat() + domChapters.add( + MangaChapter( + id = "${source.name}_$bookHref".longHashCode(), + title = volumeTitle, + number = pos, + volume = pos.toInt(), + url = bookHref, + uploadDate = 0L, + source = source, + scanlator = null, + branch = null, + ) + ) + } + } + + if (domChapters.isEmpty()) { + val seenUrls = mutableSetOf() + doc.select("a[href*='/book/']").forEach { a -> + val bookHref = a.absUrl("href").ifEmpty { + val raw = a.attr("href") + if (raw.startsWith("/")) "$baseUrl$raw" else raw + } + if (bookHref.isBlank() || !seenUrls.add(bookHref)) return@forEach + val text = a.text().trim() + val titleText = if (text.equals("Start Reading", ignoreCase = true) || text.isEmpty()) { + a.attr("aria-label").trim().ifEmpty { "Volume ${seenUrls.size}" } + } else { + text + } + val idx = seenUrls.size + domChapters.add( + MangaChapter( + id = "${source.name}_$bookHref".longHashCode(), + title = titleText, + number = idx.toFloat(), + volume = idx, + url = bookHref, + uploadDate = 0L, + source = source, + scanlator = null, + branch = null, + ) + ) + } + } + + if (domChapters.isEmpty() && manga.url.contains("/book/")) { + domChapters.add( + MangaChapter( + id = "${source.name}_${manga.url}".longHashCode(), + title = manga.title.ifBlank { "Complete Volume" }, + number = 1f, + volume = 1, + url = manga.url, + uploadDate = 0L, + source = source, + scanlator = null, + branch = null, + ) + ) + } + + val chapters = (if (!jsonChapters.isNullOrEmpty()) jsonChapters else domChapters).ifEmpty { + listOf( + MangaChapter( + id = "${source.name}_${manga.url}".longHashCode(), + title = "Complete Volume", + number = 1f, + volume = 1, + url = manga.url, + uploadDate = 0L, + source = source, + scanlator = null, + branch = null, + ) + ) + } + + return manga.copy( + title = title, + description = desc, + coverUrl = cover, + authors = author?.takeIf { it.isNotBlank() }?.let { setOf(it) } ?: manga.authors, + chapters = chapters, + ) + } + + public override suspend fun getPagesImpl(chapter: MangaChapter): List { + val rawUrl = chapter.url.substringBefore('#') + val doc = fetchDocument(rawUrl) + + val container = doc.selectFirst("article.content-body, article.reader-content, article, main") ?: doc.body() + container.select("script, style, nav, header, footer, noscript, .ad, .ads, .advertisement").remove() + + for (img in container.select("img")) { + var src = img.attr("src") + if (src.startsWith("/")) { + src = "$baseUrl$src" + } + if (src.contains("img.lnori.com", ignoreCase = true)) { + src = src.replace(Regex("""\.(jpe?g)(\?.*)?$""", RegexOption.IGNORE_CASE)) { matchResult -> + val query = matchResult.groups[2]?.value.orEmpty() + ".avif$query" + } + } + img.attr("src", src) + } + + for (sourceTag in container.select("source")) { + var srcset = sourceTag.attr("srcset") + if (srcset.startsWith("/")) { + srcset = "$baseUrl$srcset" + } + if (srcset.contains("img.lnori.com", ignoreCase = true)) { + srcset = srcset.replace(Regex("""\.(jpe?g)(\?.*)?$""", RegexOption.IGNORE_CASE)) { matchResult -> + val query = matchResult.groups[2]?.value.orEmpty() + ".avif$query" + } + } + sourceTag.attr("srcset", srcset) + } + + return listOf( + MangaPage( + id = chapter.id, + url = container.html(), + preview = null, + source = source, + ) + ) + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/NovelParserSource.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/NovelParserSource.kt new file mode 100644 index 0000000000..5e0467c777 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/NovelParserSource.kt @@ -0,0 +1,55 @@ +package io.github.landwarderer.futon.novel.data.source + +import org.koitharu.kotatsu.parsers.model.ContentType +import org.koitharu.kotatsu.parsers.model.MangaSource + +enum class NovelParserSource( + val title: String, + val domain: String, + val defaultUrl: String, + val locale: String = "en", + val contentType: ContentType = ContentType.NOVEL, +) : MangaSource { + FREEWEBNOVEL( + title = "FreeWebNovel", + domain = "freewebnovel.com", + defaultUrl = "https://freewebnovel.com", + ), + LNORI( + title = "Lnori", + domain = "lnori.com", + defaultUrl = "https://lnori.com", + ), + ROYALROAD( + title = "Royal Road", + domain = "royalroad.com", + defaultUrl = "https://www.royalroad.com", + ), + SCRIBBLEHUB( + title = "Scribble Hub", + domain = "scribblehub.com", + defaultUrl = "https://www.scribblehub.com", + ), + RANOBES( + title = "Ranobes", + domain = "ranobes.net", + defaultUrl = "https://ranobes.net", + ), + WUXIAWORLD( + title = "Wuxiaworld", + domain = "wuxiaworld.com", + defaultUrl = "https://www.wuxiaworld.com", + ), + LNCRAWLER( + title = "LNCrawler", + domain = "lncrawler.monster", + defaultUrl = "https://lncrawler.monster", + ); + + companion object { + fun fromName(name: String?): NovelParserSource? { + if (name == null) return null + return entries.find { it.name.equals(name, ignoreCase = true) } + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/RanobesRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/RanobesRepository.kt new file mode 100644 index 0000000000..44d2aa9421 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/RanobesRepository.kt @@ -0,0 +1,340 @@ +package io.github.landwarderer.futon.novel.data.source + +import io.github.landwarderer.futon.core.cache.MemoryContentCache +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import okhttp3.OkHttpClient +import org.json.JSONObject +import org.jsoup.nodes.Element +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.util.longHashCode +import java.net.URLEncoder + +class RanobesRepository( + cache: MemoryContentCache, + httpClient: OkHttpClient, +) : BaseNovelRepository(cache, httpClient, NovelParserSource.RANOBES) { + + private val baseUrl = source.defaultUrl + + override val sortOrders: Set = setOf( + SortOrder.POPULARITY, + SortOrder.NEWEST, + SortOrder.UPDATED, + ) + + override var defaultSortOrder: SortOrder = SortOrder.POPULARITY + + override val filterCapabilities: MangaListFilterCapabilities = MangaListFilterCapabilities( + isSearchSupported = true, + isSearchWithFiltersSupported = false, + ) + + companion object { + private const val MAX_CHAPTER_PAGES = 50 + private const val MAX_PARALLELISM = 4 + } + + override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List { + val page = (offset / 20) + 1 + val query = filter?.query?.trim().orEmpty() + + val effectiveOrder = order ?: defaultSortOrder + + val url = if (query.isNotEmpty()) { + val encoded = URLEncoder.encode(query, "UTF-8") + if (page > 1) { + "$baseUrl/search/$encoded/page/$page/" + } else { + "$baseUrl/search/$encoded/" + } + } else { + when (effectiveOrder) { + SortOrder.UPDATED -> { + if (page > 1) "$baseUrl/updates/page/$page/" else "$baseUrl/updates/" + } + SortOrder.POPULARITY -> { + if (page > 1) return emptyList() + "$baseUrl/ranking/" + } + else -> { + if (page > 1) "$baseUrl/novels/page/$page/" else "$baseUrl/novels/" + } + } + } + + val doc = fetchDocument(url) + val items = doc.select(".rank-story, article.story, .shortstory, .story_line") + return items.mapNotNull { parseNovelItem(it) } + } + + private fun parseNovelItem(element: Element): Manga? { + val isStoryLine = element.hasClass("story_line") || element.hasClass("story_line-img") + if (isStoryLine) { + val linkEl = element.selectFirst("a[href]") ?: return null + val rawHref = linkEl.absUrl("href").ifEmpty { + val rel = linkEl.attr("href") + if (rel.startsWith("/")) "$baseUrl$rel" else "$baseUrl/$rel" + } + val title = element.selectFirst("h3.title, .title")?.text()?.trim() + ?: linkEl.attr("title").trim() + if (title.isEmpty()) return null + + // updates links point to chapters: /{slug}-{bookId}/{chapterId}.html + // Convert to novel link: /novels/{bookId}-{slug}.html + val chapterMatch = Regex("""/([^/]+)-(\d+)/\d+\.html""").find(rawHref) + val novelUrl = if (chapterMatch != null) { + "$baseUrl/novels/${chapterMatch.groupValues[2]}-${chapterMatch.groupValues[1]}.html" + } else { + rawHref + } + + val coverUrl = extractCoverUrl(element) + val subtitle = element.selectFirst(".subtitle")?.text()?.trim() + val time = element.selectFirst("em.small, time")?.text()?.trim() + val desc = listOfNotNull(subtitle, time).joinToString(" ").ifBlank { null } + + return Manga( + id = "${source.name}_$novelUrl".longHashCode(), + title = title, + altTitles = emptySet(), + url = novelUrl, + publicUrl = novelUrl, + rating = 0f, + contentRating = null, + coverUrl = coverUrl, + tags = emptySet(), + state = null, + authors = emptySet(), + largeCoverUrl = coverUrl, + description = desc, + chapters = null, + source = source, + ) + } + + val titleEl = element.selectFirst(".title a, h2 a, h3 a") ?: return null + val title = titleEl.text().trim() + if (title.isEmpty()) return null + + val href = titleEl.absUrl("href").ifEmpty { + val rel = titleEl.attr("href") + if (rel.startsWith("/")) "$baseUrl$rel" else "$baseUrl/$rel" + } + val coverUrl = extractCoverUrl(element) + val desc = element.selectFirst(".moreless__short, .moreless__full, .story, .description")?.text()?.trim() + + return Manga( + id = "${source.name}_$href".longHashCode(), + title = title, + altTitles = emptySet(), + url = href, + publicUrl = href, + rating = 0f, + contentRating = null, + coverUrl = coverUrl, + tags = emptySet(), + state = null, + authors = emptySet(), + largeCoverUrl = coverUrl, + description = desc, + chapters = null, + source = source, + ) + } + + private fun extractCoverUrl(element: Element): String { + val img = element.selectFirst("img[src]") + val src = img?.absUrl("src")?.ifEmpty { img.attr("src") } + if (!src.isNullOrEmpty()) { + return normalizeCoverUrl(src) + } + val styleEl = element.selectFirst("[style*='background-image'], [style*='background']") + val style = styleEl?.attr("style").orEmpty() + val match = Regex("""url\(['"]?(.*?)['"]?\)""").find(style) + if (match != null) { + val raw = match.groupValues[1] + val abs = when { + raw.startsWith("http://") || raw.startsWith("https://") -> raw + raw.startsWith("//") -> "https:$raw" + raw.startsWith("/") -> "$baseUrl$raw" + else -> "$baseUrl/$raw" + } + return normalizeCoverUrl(abs) + } + return "" + } + + private fun normalizeCoverUrl(url: String): String { + val resolved = if (url.startsWith("//")) "https:$url" else url + return resolved.replace("/thumbs/", "/") + } + + private fun sanitizeUrl(url: String): String { + return url.replace("ranobes.top", "ranobes.net") + } + + public override suspend fun getDetailsImpl(manga: Manga): Manga { + val novelUrl = sanitizeUrl(manga.url) + val doc = fetchDocument(novelUrl) + + val posterEl = doc.selectFirst(".poster, .show-poster, .r-fullstory-poster") ?: doc + val cover = extractCoverUrl(posterEl).ifEmpty { manga.coverUrl } + val desc = doc.selectFirst(".moreless__full, #fs-info .moreless, .story, #fs-info")?.text()?.trim() ?: manga.description + + val authors = doc.select("a[href*='/authors/'], a[href*='/author/'], a[href*='/translater/']") + .map { it.text().trim() } + .filter { it.isNotEmpty() } + .toSet() + + // Extract book id to load full chapters list + val bookId = Regex("""/novels/(\d+)-""").find(novelUrl)?.groupValues?.get(1) + val chaptersLink = doc.selectFirst("a[href*='/chapters/']")?.absUrl("href") + val chaptersUrl = chaptersLink ?: if (bookId != null) "$baseUrl/chapters/$bookId/" else null + + val chapters = if (chaptersUrl != null) { + loadAllChapters(chaptersUrl, doc) + } else { + parseChaptersFromDom(doc) + } + + return manga.copy( + url = novelUrl, + publicUrl = novelUrl, + description = desc, + coverUrl = cover, + largeCoverUrl = cover, + authors = authors.ifEmpty { manga.authors }, + chapters = chapters, + ) + } + + private suspend fun loadAllChapters(firstPageUrl: String, mainDoc: Element): List { + val firstDoc = runCatching { fetchDocument(firstPageUrl) }.getOrNull() ?: return parseChaptersFromDom(mainDoc) + val script = firstDoc.selectFirst("script:containsData(window.__DATA__)")?.data().orEmpty() + val match = Regex("""window\.__DATA__\s*=\s*(\{.*?\});""", RegexOption.DOT_MATCHES_ALL).find(script) + ?: return parseChaptersFromDom(firstDoc).ifEmpty { parseChaptersFromDom(mainDoc) } + + val firstJson = runCatching { JSONObject(match.groupValues[1]) }.getOrNull() + ?: return parseChaptersFromDom(firstDoc).ifEmpty { parseChaptersFromDom(mainDoc) } + + val pagesCount = firstJson.optInt("pages_count", 1) + val bookId = firstJson.optInt("book_id", 0) + + val allRawChapters = mutableListOf() + // First page chapters + allRawChapters.addAll(parseRawChaptersFromJson(firstJson)) + + if (pagesCount > 1 && bookId > 0) { + val remainingPages = (2..pagesCount.coerceAtMost(MAX_CHAPTER_PAGES)).toList() + val semaphore = Semaphore(MAX_PARALLELISM) + val additionalChapters = coroutineScope { + remainingPages.map { pageNum -> + async(Dispatchers.IO) { + semaphore.withPermit { + val pageUrl = "$baseUrl/chapters/$bookId/page/$pageNum/" + runCatching { + val pageDoc = fetchDocument(pageUrl) + val pageScript = pageDoc.selectFirst("script:containsData(window.__DATA__)")?.data().orEmpty() + val pageMatch = Regex("""window\.__DATA__\s*=\s*(\{.*?\});""", RegexOption.DOT_MATCHES_ALL).find(pageScript) + if (pageMatch != null) { + val json = JSONObject(pageMatch.groupValues[1]) + parseRawChaptersFromJson(json) + } else { + emptyList() + } + }.getOrDefault(emptyList()) + } + } + }.awaitAll() + } + additionalChapters.forEach { allRawChapters.addAll(it) } + } + + // Ranobes presents newest chapters first on lower pages down to oldest chapters on higher pages. + // Reverse the list so it is in ascending chronological order: Chapter 1 to Chapter N. + val chronological = allRawChapters.reversed() + return chronological.mapIndexed { index, raw -> + MangaChapter( + id = "${source.name}_${raw.link}".longHashCode(), + title = raw.title, + number = (index + 1).toFloat(), + volume = 0, + url = raw.link, + uploadDate = raw.date * 1000L, + source = source, + scanlator = null, + branch = null, + ) + } + } + + private data class RawChapter( + val id: Long, + val title: String, + val link: String, + val date: Long, + ) + + private fun parseRawChaptersFromJson(json: JSONObject): List { + val array = json.optJSONArray("chapters") ?: return emptyList() + val result = mutableListOf() + for (i in 0 until array.length()) { + val obj = array.optJSONObject(i) ?: continue + val link = obj.optString("link").trim() + if (link.isEmpty()) continue + val title = obj.optString("title").trim().ifEmpty { "Chapter" } + val id = obj.optLong("id", 0L) + val date = obj.optLong("date", 0L) + result.add(RawChapter(id = id, title = title, link = link, date = date)) + } + return result + } + + private fun parseChaptersFromDom(doc: Element): List { + val chapterLinks = doc.select("#chapters-list a, .chapters-scroll a, .chapters-list a, a.chapter-item") + return chapterLinks.mapIndexed { index, a -> + val rawHref = a.absUrl("href").ifEmpty { a.attr("href") } + val chapUrl = sanitizeUrl(rawHref) + val chapTitle = a.text().trim().ifEmpty { "Chapter ${index + 1}" } + MangaChapter( + id = "${source.name}_$chapUrl".longHashCode(), + title = chapTitle, + number = (index + 1).toFloat(), + volume = 0, + url = chapUrl, + uploadDate = 0L, + source = source, + scanlator = null, + branch = null, + ) + } + } + + public override suspend fun getPagesImpl(chapter: MangaChapter): List { + val url = sanitizeUrl(chapter.url) + val doc = fetchDocument(url) + val content = doc.selectFirst("#arrticle, #article, .entry-content") ?: doc.body() + content.select("script, style, iframe, .ads, .share, .free-support-top, [id^=bg-ssp]").remove() + val contentHtml = content.html() + + return listOf( + MangaPage( + id = chapter.id, + url = contentHtml, + preview = null, + source = source, + ) + ) + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/RoyalRoadRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/RoyalRoadRepository.kt new file mode 100644 index 0000000000..6150d0f7b6 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/RoyalRoadRepository.kt @@ -0,0 +1,121 @@ +package io.github.landwarderer.futon.novel.data.source + +import io.github.landwarderer.futon.core.cache.MemoryContentCache +import okhttp3.OkHttpClient +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.util.longHashCode +import java.net.URLEncoder + +class RoyalRoadRepository( + cache: MemoryContentCache, + httpClient: OkHttpClient, +) : BaseNovelRepository(cache, httpClient, NovelParserSource.ROYALROAD) { + + private val baseUrl = source.defaultUrl + + override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List { + val page = (offset / 20) + 1 + val query = filter?.query?.trim().orEmpty() + + val url = if (query.isNotEmpty()) { + "$baseUrl/fictions/search?title=${URLEncoder.encode(query, "UTF-8")}&page=$page" + } else { + val sortPath = when (order) { + SortOrder.NEWEST -> "new" + SortOrder.UPDATED -> "latest-updates" + else -> "best-rated" + } + "$baseUrl/fictions/$sortPath?page=$page" + } + + val doc = fetchDocument(url) + val items = doc.select(".fiction-list-item") + return items.mapNotNull { element -> + val titleEl = element.selectFirst("h2.fiction-title a") ?: return@mapNotNull null + val title = titleEl.text().trim() + val href = titleEl.absUrl("href") + + val coverEl = element.selectFirst("img") + val coverUrl = coverEl?.absUrl("src") ?: coverEl?.attr("src").orEmpty() + val desc = element.selectFirst(".fiction-description")?.text()?.trim() + val author = element.selectFirst("span.author, a[href*='/profile/']")?.text()?.trim() + val tags = element.select(".fiction-tag").map { MangaTag(title = it.text().trim(), key = it.text().trim(), source = source) }.toSet() + + val ratingStr = element.selectFirst(".star")?.attr("title") + val rating = ratingStr?.toFloatOrNull() ?: 0f + + Manga( + id = "${source.name}_$href".longHashCode(), + title = title, + altTitle = null, + url = href, + publicUrl = href, + rating = rating, + isNsfw = false, + coverUrl = coverUrl, + tags = tags, + state = null, + author = author, + largeCoverUrl = coverUrl, + description = desc, + chapters = null, + source = source, + ) + } + } + + override suspend fun getDetailsImpl(manga: Manga): Manga { + val doc = fetchDocument(manga.url) + val cover = doc.selectFirst(".cover-art-container img, img.cover")?.absUrl("src") ?: manga.coverUrl + val desc = doc.selectFirst(".description .portlet-body")?.text()?.trim() ?: manga.description + val author = doc.selectFirst("h4 a[href*='/profile/']")?.text()?.trim() ?: manga.author + + val chapterRows = doc.select("#chapters tbody tr") + val chapters = chapterRows.mapIndexedNotNull { index, tr -> + val a = tr.selectFirst("a[href]") ?: return@mapIndexedNotNull null + val chapUrl = a.absUrl("href") + val chapTitle = a.text().trim().ifEmpty { "Chapter ${index + 1}" } + + MangaChapter( + id = "${source.name}_$chapUrl".longHashCode(), + title = chapTitle, + number = (index + 1).toFloat(), + volume = 0, + url = chapUrl, + uploadDate = 0L, + source = source, + scanlator = null, + branch = null, + ) + } + + return manga.copy( + description = desc, + coverUrl = cover, + authors = author?.let { setOf(it) } ?: manga.authors, + chapters = chapters, + ) + } + + override suspend fun getPagesImpl(chapter: MangaChapter): List { + val doc = fetchDocument(chapter.url) + val content = doc.selectFirst(".chapter-content") ?: doc.body() + // Clean up + content.select("script, style, .portlet-title, .author-note-portlet .btn").remove() + val contentHtml = content.html() + + return listOf( + MangaPage( + id = chapter.id, + url = contentHtml, + preview = null, + source = source, + ) + ) + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/ScribbleHubRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/ScribbleHubRepository.kt new file mode 100644 index 0000000000..f4a714a0db --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/ScribbleHubRepository.kt @@ -0,0 +1,191 @@ +package io.github.landwarderer.futon.novel.data.source + +import io.github.landwarderer.futon.core.cache.MemoryContentCache +import okhttp3.OkHttpClient +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.model.MangaState +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.util.longHashCode +import java.net.URLEncoder + +class ScribbleHubRepository( + cache: MemoryContentCache, + httpClient: OkHttpClient, +) : BaseNovelRepository(cache, httpClient, NovelParserSource.SCRIBBLEHUB) { + + private val baseUrl = source.defaultUrl + + override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List { + val page = (offset / 20) + 1 + val query = filter?.query?.trim().orEmpty() + + val url = if (query.isNotEmpty()) { + val encoded = URLEncoder.encode(query, "UTF-8") + "$baseUrl/series-finder/?sf=1&sh=$encoded&sort=pageviews&order=desc&pg=$page" + } else { + val sort = when (order) { + SortOrder.NEWEST -> "dateadded" + SortOrder.UPDATED -> "lastchpdate" + SortOrder.RATING -> "ratings" + else -> "pageviews" + } + "$baseUrl/series-finder/?sf=1&sort=$sort&order=desc&pg=$page" + } + + val doc = fetchDocument(url, getScribbleHeaders(referer = "$baseUrl/")) + val items = doc.select(".search_main_box") + return items.mapNotNull { element -> + val titleEl = element.selectFirst(".search_title a") ?: return@mapNotNull null + val title = titleEl.text().trim() + if (title.isEmpty()) return@mapNotNull null + val href = titleEl.absUrl("href").ifEmpty { + val rel = titleEl.attr("href") + if (rel.startsWith("/")) "$baseUrl$rel" else "$baseUrl/$rel" + } + + val coverEl = element.selectFirst(".search_img img") + val coverUrl = coverEl?.absUrl("src")?.ifEmpty { null } ?: coverEl?.attr("src")?.let { + if (it.startsWith("/")) "$baseUrl$it" else "$baseUrl/$it" + }.orEmpty() + + val ratingText = element.selectFirst(".search_ratings")?.text()?.trim().orEmpty() + val rating = ratingText.removeSurrounding("(", ")").toFloatOrNull() ?: 0f + + val author = element.selectFirst("span[title='Author'] a, .search_stats .a_un_st a")?.text()?.trim() + val authors = if (!author.isNullOrBlank()) setOf(author) else emptySet() + + val tags = element.select(".search_genre a").mapNotNull { + val tagText = it.text().trim() + if (tagText.isNotEmpty()) MangaTag(tagText, tagText, source) else null + }.toSet() + + val bodyClone = element.selectFirst(".search_body")?.clone() + bodyClone?.select(".search_title, .search_stats, .search_genre, .testhide, .morelink, .dots")?.remove() + val desc = bodyClone?.text()?.trim() + + Manga( + id = "${source.name}_$href".longHashCode(), + title = title, + altTitles = emptySet(), + url = href, + publicUrl = href, + rating = rating, + contentRating = null, + coverUrl = coverUrl, + tags = tags, + state = null, + authors = authors, + largeCoverUrl = coverUrl, + description = desc, + chapters = null, + source = source, + ) + } + } + + public override suspend fun getDetailsImpl(manga: Manga): Manga { + val doc = fetchDocument( + manga.url, + getScribbleHeaders( + referer = "$baseUrl/", + cookie = "toc_show=10000; toc_sorder=asc", + ), + ) + val cover = doc.selectFirst(".fic_image img")?.absUrl("src")?.ifEmpty { null } + ?: doc.selectFirst(".fic_image img")?.attr("src") + ?: manga.coverUrl + val desc = doc.selectFirst(".wi_fic_desc")?.text()?.trim() ?: manga.description + val author = doc.selectFirst(".auth_name_fic")?.text()?.trim() + val authors = if (!author.isNullOrBlank()) setOf(author) else manga.authors + + val genreTags = doc.select(".fic_genre, .stag").mapNotNull { + val tagText = it.text().trim() + if (tagText.isNotEmpty()) MangaTag(tagText, tagText, source) else null + }.toSet() + val tags = genreTags.ifEmpty { manga.tags } + + val statusText = doc.select(".wi_fic_show_stats, .fic_stats").text() + val state = when { + statusText.contains("Completed", ignoreCase = true) -> MangaState.FINISHED + statusText.contains("Ongoing", ignoreCase = true) -> MangaState.ONGOING + statusText.contains("Hiatus", ignoreCase = true) -> MangaState.ABANDONED + else -> manga.state + } + + val chapterLinks = doc.select(".toc_ol li a.toc_a, .toc_ol li a, .wi_fic_table.toc a") + val chapters = chapterLinks.mapIndexed { index, a -> + val chapUrl = a.absUrl("href").ifEmpty { + val rel = a.attr("href") + if (rel.startsWith("/")) "$baseUrl$rel" else "$baseUrl/$rel" + } + val chapTitle = a.text().trim().ifEmpty { "Chapter ${index + 1}" } + MangaChapter( + id = "${source.name}_$chapUrl".longHashCode(), + title = chapTitle, + number = (index + 1).toFloat(), + volume = 0, + url = chapUrl, + uploadDate = 0L, + source = source, + scanlator = null, + branch = null, + ) + } + + return manga.copy( + description = desc, + coverUrl = cover, + largeCoverUrl = cover, + authors = authors, + tags = tags, + state = state, + chapters = chapters, + ) + } + + public override suspend fun getPagesImpl(chapter: MangaChapter): List { + val doc = fetchDocument( + chapter.url, + getScribbleHeaders(referer = "$baseUrl/"), + ) + val content = doc.selectFirst("#chp_raw, .chp_raw") ?: doc.body() + content.select("script, style, iframe, .modern-footnotes-footnote, .sp-head").remove() + val contentHtml = content.html() + + return listOf( + MangaPage( + id = chapter.id, + url = contentHtml, + preview = null, + source = source, + ) + ) + } + + private fun getScribbleHeaders( + referer: String? = null, + cookie: String? = null, + ): Map { + val headers = mutableMapOf( + "User-Agent" to SCRIBBLE_USER_AGENT, + "Accept" to "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Accept-Language" to "en-US,en;q=0.5", + ) + if (referer != null) { + headers["Referer"] = referer + } + if (cookie != null) { + headers["Cookie"] = cookie + } + return headers + } + + companion object { + private const val SCRIBBLE_USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0" + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/WuxiaworldRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/WuxiaworldRepository.kt new file mode 100644 index 0000000000..e5c41b9f0d --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/data/source/WuxiaworldRepository.kt @@ -0,0 +1,937 @@ +package io.github.landwarderer.futon.novel.data.source + +import io.github.landwarderer.futon.core.cache.MemoryContentCache +import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import org.jsoup.Jsoup +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.model.MangaState +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.util.longHashCode +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import java.io.ByteArrayOutputStream +import java.net.URLEncoder + +class WuxiaworldRepository( + cache: MemoryContentCache, + httpClient: OkHttpClient, +) : BaseNovelRepository(cache, httpClient, NovelParserSource.WUXIAWORLD) { + + private val baseUrl = source.defaultUrl + + private val catalogMutex = Mutex() + private var cachedSortKey: String? = null + private var cachedCatalog: List? = null + + override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List { + val query = filter?.query?.trim().orEmpty() + return runCatchingCancellable { + fetchNovelsViaGrpc(offset, order, query) + }.getOrElse { + it.printStackTraceDebug() + fetchNovelsViaHtml(offset, order, query) + } + } + + public override suspend fun getDetailsImpl(manga: Manga): Manga { + val slug = manga.url.substringAfterLast("/novel/").trimEnd('/').substringBefore('/') + val doc = fetchDocument(manga.url) + val html = doc.html() + + // 1. Try React Query state embedded in static SSR + val reactQueryJson = extractReactQueryJson(html) + val itemJson = findNovelItemInReactQuery(reactQueryJson, slug) + val jsonManga = itemJson?.let { parseNovelItemFromJson(it) } + val novelIdFromJson = itemJson?.optInt("id", -1)?.takeIf { it > 0 } + + // 2. Fallback to gRPC GetNovel if JSON is missing + val grpcManga = if (jsonManga == null) fetchNovelViaGrpc(slug) else null + + // 3. Fallback to DOM elements + val title = jsonManga?.title?.ifBlank { null } + ?: grpcManga?.title?.ifBlank { null } + ?: doc.selectFirst("h1, h2.font-bold")?.text()?.trim()?.takeIf { !it.equals("Ongoing", ignoreCase = true) } + ?: manga.title.takeIf { !it.equals("Ongoing", ignoreCase = true) } + ?: slug + + val cover = jsonManga?.coverUrl?.ifBlank { null } + ?: grpcManga?.coverUrl?.ifBlank { null } + ?: doc.selectFirst("img[src*='covers'], img.cover")?.absUrl("src") + ?: manga.coverUrl + + val desc = jsonManga?.description + ?: grpcManga?.description + ?: doc.selectFirst(".synopsis, .description, div[class*='synopsis']")?.text()?.trim() + ?: manga.description + + val authors = jsonManga?.authors?.takeIf { it.isNotEmpty() } + ?: grpcManga?.authors?.takeIf { it.isNotEmpty() } + ?: doc.selectFirst("span[class*='author'], div[class*='author']")?.text()?.trim()?.let { setOf(it) } + ?: manga.authors + + val state = jsonManga?.state ?: grpcManga?.state ?: manga.state + val tags = jsonManga?.tags?.takeIf { it.isNotEmpty() } + ?: grpcManga?.tags?.takeIf { it.isNotEmpty() } + ?: manga.tags + val rating = when { + jsonManga != null && jsonManga.rating > 0f -> jsonManga.rating + grpcManga != null && grpcManga.rating > 0f -> grpcManga.rating + else -> manga.rating + } + + val novelId = novelIdFromJson ?: extractNovelId(html, slug) + + val chapters = if (novelId != null) { + fetchChaptersViaGrpc(novelId, slug) + } else { + emptyList() + }.ifEmpty { + parseChapterLinksFromDoc(doc) + } + + return manga.copy( + title = title, + description = desc, + coverUrl = cover, + largeCoverUrl = cover, + authors = authors, + state = state, + tags = tags, + rating = rating, + chapters = chapters, + ) + } + + private suspend fun fetchNovelsViaGrpc(offset: Int, order: SortOrder?, query: String): List { + if (query.isNotEmpty()) { + val payload = buildSearchNovelsRequest( + query = query, + sortType = 1, + sortDirection = 1, + count = 30, + ) + val responseBytes = callGrpc("wuxiaworld.api.v2.Novels/SearchNovels", payload) + ?: return emptyList() + val (novels, _) = parseSearchNovelsWithLastId(responseBytes) + return novels.drop(offset).take(30) + } + + val sortType = when (order) { + SortOrder.POPULARITY -> 1 + SortOrder.NEWEST -> 2 + SortOrder.UPDATED -> 7 + SortOrder.ALPHABETICAL -> 4 + SortOrder.RATING -> 6 + else -> 1 + } + val sortDirection = if (order == SortOrder.ALPHABETICAL) 0 else 1 + + val allNovels = getOrFetchAllNovels(sortType, sortDirection) + return allNovels.drop(offset).take(30) + } + + private suspend fun getOrFetchAllNovels(sortType: Int, sortDirection: Int): List { + val key = "$sortType-$sortDirection" + if (cachedSortKey == key && cachedCatalog != null) { + return cachedCatalog!! + } + return catalogMutex.withLock { + if (cachedSortKey == key && cachedCatalog != null) { + return@withLock cachedCatalog!! + } + + val p1 = buildSearchNovelsRequest( + query = null, + sortType = sortType, + sortDirection = sortDirection, + count = 100, + ) + val resp1 = callGrpc("wuxiaworld.api.v2.Novels/SearchNovels", p1) + val novels = mutableListOf() + var lastId: Int? = null + + if (resp1 != null) { + val (batch1, id1) = parseSearchNovelsWithLastId(resp1) + novels.addAll(batch1) + lastId = id1 + } + + // If there are more items (full catalog is ~164 novels), fetch remaining + if (novels.size >= 100 && lastId != null) { + val p2 = buildSearchNovelsRequest( + query = null, + sortType = sortType, + sortDirection = sortDirection, + count = 100, + searchAfterId = lastId, + ) + val resp2 = callGrpc("wuxiaworld.api.v2.Novels/SearchNovels", p2) + if (resp2 != null) { + val (batch2, _) = parseSearchNovelsWithLastId(resp2) + novels.addAll(batch2) + } + } + + if (novels.isNotEmpty()) { + cachedSortKey = key + cachedCatalog = novels + } + novels + } + } + + private suspend fun fetchNovelsViaHtml(offset: Int, order: SortOrder?, query: String): List { + val page = (offset / 20) + 1 + val url = if (query.isNotEmpty()) { + "$baseUrl/novels?search=${URLEncoder.encode(query, "UTF-8")}&page=$page" + } else { + "$baseUrl/novels?page=$page" + } + + val doc = fetchDocument(url) + val reactQuery = extractReactQueryJson(doc.html()) + if (reactQuery != null) { + val novels = parseNovelsFromReactQuery(reactQuery) + if (novels.isNotEmpty()) { + val filtered = if (query.isNotEmpty()) { + novels.filter { it.title.contains(query, ignoreCase = true) } + } else { + novels + } + return filtered.drop(offset % novels.size).take(20) + } + } + + // DOM fallback + val seen = mutableSetOf() + val cards = doc.select(".grid > div, div[class*='grid-cols'] > div, div[class*='gap']") + val result = mutableListOf() + + for (card in cards) { + val links = card.select("a[href*='/novel/']") + if (links.isEmpty()) continue + val firstLink = links.firstOrNull() ?: continue + val href = firstLink.absUrl("href") + if (!href.contains("/novel/") || href.split("/novel/").getOrNull(1)?.contains('/') == true) continue + if (!seen.add(href)) continue + + val title = card.selectFirst("a strong, strong, h3, h4, h5, p.font-bold, .title")?.text()?.trim() + ?.takeIf { !it.equals("Ongoing", ignoreCase = true) && !it.equals("Completed", ignoreCase = true) } + ?: card.selectFirst("img[alt]")?.attr("alt")?.trim() + ?.takeIf { it.isNotBlank() && !it.equals("Ongoing", ignoreCase = true) } + ?: links.map { it.text().trim() }.firstOrNull { + it.isNotBlank() && !it.equals("Ongoing", ignoreCase = true) && !it.equals("Completed", ignoreCase = true) + } + ?: continue + + val coverEl = card.selectFirst("img[src*='covers'], img") + val coverUrl = coverEl?.absUrl("src") ?: coverEl?.attr("src").orEmpty() + val cardText = card.text() + val state = when { + cardText.contains("Ongoing", ignoreCase = true) -> MangaState.ONGOING + cardText.contains("Completed", ignoreCase = true) -> MangaState.FINISHED + else -> null + } + + result.add( + Manga( + id = "${source.name}_$href".longHashCode(), + title = title, + altTitle = null, + url = href, + publicUrl = href, + rating = 0f, + isNsfw = false, + coverUrl = coverUrl, + tags = emptySet(), + state = state, + author = null, + largeCoverUrl = coverUrl, + description = null, + chapters = null, + source = source, + ) + ) + } + return result + } + + private suspend fun fetchNovelViaGrpc(slug: String): Manga? = withContext(Dispatchers.IO) { + runCatchingCancellable { + val payload = buildGetNovelRequest(slug) + val responseBytes = callGrpc("wuxiaworld.api.v2.Novels/GetNovel", payload) + ?: return@runCatchingCancellable null + var framePos = 0 + while (framePos + 5 <= responseBytes.size) { + val flag = responseBytes[framePos].toInt() and 0xFF + val msgLen = ((responseBytes[framePos + 1].toInt() and 0xFF) shl 24) or + ((responseBytes[framePos + 2].toInt() and 0xFF) shl 16) or + ((responseBytes[framePos + 3].toInt() and 0xFF) shl 8) or + (responseBytes[framePos + 4].toInt() and 0xFF) + framePos += 5 + if (framePos + msgLen > responseBytes.size) break + if (flag == 0) { + val reader = ProtoReader(responseBytes, framePos, framePos + msgLen) + while (reader.hasRemaining()) { + val tag = reader.readVarint() + val fieldNum = (tag ushr 3).toInt() + val wireType = (tag and 0x07).toInt() + if (wireType == 2) { + val length = reader.readVarint().toInt() + if (fieldNum == 1) { // NovelItem + val sub = reader.subReader(length) + val (manga, _) = parseNovelItemFromProto(sub) + if (manga != null) return@runCatchingCancellable manga + } else { + reader.skip(length) + } + } else { + reader.skipByWire(wireType) + } + } + } + framePos += msgLen + } + null + }.onFailure { + it.printStackTraceDebug() + }.getOrNull() + } + + private fun extractReactQueryJson(html: String): JSONObject? { + val marker = "window.__REACT_QUERY_STATE__ =" + val idx = html.indexOf(marker) + if (idx == -1) return null + val braceStart = html.indexOf('{', idx + marker.length) + if (braceStart == -1) return null + var end = html.indexOf("};\n", braceStart) + if (end == -1) { + end = html.indexOf("};", braceStart) + } + if (end == -1) return null + val jsonString = html.substring(braceStart, end + 1) + return runCatching { JSONObject(jsonString) }.getOrNull() + } + + private fun findNovelItemInReactQuery(json: JSONObject?, slug: String): JSONObject? { + val queries = json?.optJSONArray("queries") ?: return null + for (i in 0 until queries.length()) { + val q = queries.optJSONObject(i) ?: continue + val item = q.optJSONObject("state")?.optJSONObject("data")?.optJSONObject("item") + if (item != null) { + val itemSlug = item.optString("slug").ifBlank { item.optStringValue("slug") } + if (itemSlug == null || itemSlug.equals(slug, ignoreCase = true)) { + return item + } + } + } + return null + } + + private fun parseNovelsFromReactQuery(json: JSONObject?): List { + val queries = json?.optJSONArray("queries") ?: return emptyList() + val list = mutableListOf() + for (i in 0 until queries.length()) { + val q = queries.optJSONObject(i) ?: continue + val queryKey = q.optJSONArray("queryKey")?.optString(0) + if (queryKey != "novels") continue + val pages = q.optJSONObject("state")?.optJSONObject("data")?.optJSONArray("pages") + if (pages != null) { + for (p in 0 until pages.length()) { + val items = pages.optJSONObject(p)?.optJSONArray("items") ?: continue + for (j in 0 until items.length()) { + val item = items.optJSONObject(j) ?: continue + parseNovelItemFromJson(item)?.let { list.add(it) } + } + } + } + } + return list + } + + private fun parseNovelItemFromJson(item: JSONObject): Manga? { + val name = item.optStringValue("name")?.trim().orEmpty() + val slug = item.optString("slug").trim().ifEmpty { + item.optStringValue("slug")?.trim().orEmpty() + } + if (name.isEmpty() && slug.isEmpty()) return null + + val novelUrl = "$baseUrl/novel/$slug" + val cover = item.optStringValue("coverUrl")?.trim().orEmpty() + val author = item.optStringValue("authorName")?.trim() + + val statusInt = item.optInt("status", -1) + val state = when (statusInt) { + 0 -> MangaState.FINISHED + 1 -> MangaState.ONGOING + 2 -> MangaState.PAUSED + else -> null + } + + val tags = parseTagsFromJson(item) + val rating = parseRatingFromJson(item) + + val synopsisRaw = item.optStringValue("synopsis") ?: item.optStringValue("description") + val desc = synopsisRaw?.let { Jsoup.parse(it).text().trim().ifBlank { null } } + + return Manga( + id = "${source.name}_$novelUrl".longHashCode(), + title = name.ifBlank { slug }, + altTitle = null, + url = novelUrl, + publicUrl = novelUrl, + rating = rating, + isNsfw = false, + coverUrl = cover, + tags = tags, + state = state, + author = author, + largeCoverUrl = cover, + description = desc, + chapters = null, + source = source, + ) + } + + private fun parseTagsFromJson(item: JSONObject): Set { + val tagsSet = mutableSetOf() + val genresArray = item.optJSONArray("genres") + if (genresArray != null) { + for (i in 0 until genresArray.length()) { + val g = genresArray.optString(i).trim() + if (g.isNotEmpty()) { + tagsSet.add(MangaTag(title = g, key = g, source = source)) + } + } + } + val tagsArray = item.optJSONArray("tags") + if (tagsArray != null) { + for (i in 0 until tagsArray.length()) { + val t = tagsArray.optString(i).trim() + if (t.isNotEmpty()) { + tagsSet.add(MangaTag(title = t, key = t, source = source)) + } + } + } + return tagsSet + } + + private fun parseRatingFromJson(item: JSONObject): Float { + val reviewObj = item.optJSONObject("reviewInfo") + val ratingObj = reviewObj?.optJSONObject("rating") + val ratingVal = ratingObj?.optDouble("value") ?: reviewObj?.optDouble("rating") + return if (ratingVal != null && !ratingVal.isNaN() && ratingVal > 0.0) { + ratingVal.toFloat().coerceIn(0f, 1f) + } else { + 0f + } + } + + private fun JSONObject.optStringValue(key: String): String? { + val obj = opt(key) ?: return null + return when (obj) { + is String -> obj.ifBlank { null } + is JSONObject -> obj.optString("value").ifBlank { null } + else -> null + } + } + + private fun extractNovelId(html: String, slug: String): Int? { + val patterns = listOf( + Regex(""""id":\s*(\d+)[^}]*"slug":\s*"$slug""""), + Regex(""""slug":\s*"$slug"[^}]*"id":\s*(\d+)""""), + Regex(""""novelId":\s*(\d+)"""), + Regex(""""id":\s*(\d+),\s*"name":""""), + ) + for (pattern in patterns) { + val match = pattern.find(html) + if (match != null) { + val id = match.groupValues[1].toIntOrNull() + if (id != null && id > 0) return id + } + } + return null + } + + private suspend fun callGrpc(method: String, payload: ByteArray): ByteArray? = withContext(Dispatchers.IO) { + runCatchingCancellable { + val frame = buildGrpcFrame(payload) + val request = Request.Builder() + .url("https://api2.wuxiaworld.com/$method") + .header("User-Agent", DEFAULT_USER_AGENT) + .header("Content-Type", "application/grpc-web+proto") + .header("x-grpc-web", "1") + .post(frame.toRequestBody("application/grpc-web+proto".toMediaType())) + .build() + + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) return@runCatchingCancellable null + response.body?.bytes() + } + }.onFailure { + it.printStackTraceDebug() + }.getOrNull() + } + + private fun buildGrpcFrame(payload: ByteArray): ByteArray { + val len = payload.size + val frame = ByteArray(5 + len) + frame[0] = 0x00 + frame[1] = (len ushr 24).toByte() + frame[2] = (len ushr 16).toByte() + frame[3] = (len ushr 8).toByte() + frame[4] = len.toByte() + System.arraycopy(payload, 0, frame, 5, len) + return frame + } + + private fun encodeVarint(v: Long): ByteArray { + val bos = ByteArrayOutputStream() + var value = v + while ((value and 0x7FL.inv()) != 0L) { + bos.write(((value and 0x7F) or 0x80).toInt()) + value = value ushr 7 + } + bos.write((value and 0x7F).toInt()) + return bos.toByteArray() + } + + private fun buildSearchNovelsRequest( + query: String?, + sortType: Int, + sortDirection: Int, + count: Int, + searchAfterId: Int? = null, + ): ByteArray { + val bos = ByteArrayOutputStream() + if (!query.isNullOrBlank()) { + val queryBytes = query.toByteArray(Charsets.UTF_8) + val strVal = ByteArrayOutputStream() + strVal.write(0x0A) + strVal.write(encodeVarint(queryBytes.size.toLong())) + strVal.write(queryBytes) + val strValBytes = strVal.toByteArray() + bos.write(0x0A) + bos.write(encodeVarint(strValBytes.size.toLong())) + bos.write(strValBytes) + } + // sortType (field 4, wire 0) + bos.write(0x20) + bos.write(encodeVarint(sortType.toLong())) + // sortDirection (field 5, wire 0) + bos.write(0x28) + bos.write(encodeVarint(sortDirection.toLong())) + // searchAfterId (field 6, wire 2: Int32Value: field 1 = id) + if (searchAfterId != null && searchAfterId > 0) { + val idBytes = encodeVarint(searchAfterId.toLong()) + val msg = ByteArrayOutputStream() + msg.write(0x08) + msg.write(idBytes) + val msgBytes = msg.toByteArray() + bos.write(0x32) + bos.write(encodeVarint(msgBytes.size.toLong())) + bos.write(msgBytes) + } + // count (field 7, wire 0) + bos.write(0x38) + bos.write(encodeVarint(count.toLong())) + return bos.toByteArray() + } + + private fun buildGetNovelRequest(slug: String): ByteArray { + val slugBytes = slug.toByteArray(Charsets.UTF_8) + val bos = ByteArrayOutputStream() + bos.write(0x12) // field 2, wire 2 + bos.write(encodeVarint(slugBytes.size.toLong())) + bos.write(slugBytes) + return bos.toByteArray() + } + + private fun parseSearchNovelsWithLastId(responseBytes: ByteArray): Pair, Int?> { + val novels = mutableListOf() + var lastId: Int? = null + var framePos = 0 + while (framePos + 5 <= responseBytes.size) { + val flag = responseBytes[framePos].toInt() and 0xFF + val msgLen = ((responseBytes[framePos + 1].toInt() and 0xFF) shl 24) or + ((responseBytes[framePos + 2].toInt() and 0xFF) shl 16) or + ((responseBytes[framePos + 3].toInt() and 0xFF) shl 8) or + (responseBytes[framePos + 4].toInt() and 0xFF) + framePos += 5 + if (framePos + msgLen > responseBytes.size) break + if (flag == 0) { + val reader = ProtoReader(responseBytes, framePos, framePos + msgLen) + while (reader.hasRemaining()) { + val tag = reader.readVarint() + val fieldNum = (tag ushr 3).toInt() + val wireType = (tag and 0x07).toInt() + if (wireType == 2) { + val length = reader.readVarint().toInt() + if (fieldNum == 1) { // NovelItem + val sub = reader.subReader(length) + val (manga, id) = parseNovelItemFromProto(sub) + if (manga != null) { + novels.add(manga) + } + if (id != null) { + lastId = id + } + } else { + reader.skip(length) + } + } else { + reader.skipByWire(wireType) + } + } + } + framePos += msgLen + } + return novels to lastId + } + + private fun parseNovelItemFromProto(reader: ProtoReader): Pair { + var id = 0 + var name = "" + var slug = "" + var status = -1 + var synopsis = "" + var coverUrl = "" + var author = "" + val tags = mutableSetOf() + var rating = 0f + + while (reader.hasRemaining()) { + val tag = reader.readVarint() + val fieldNum = (tag ushr 3).toInt() + val wireType = (tag and 0x07).toInt() + when (wireType) { + 0 -> { + val v = reader.readVarint().toInt() + if (fieldNum == 1) id = v + else if (fieldNum == 4) status = v + } + 1 -> reader.skip(8) + 2 -> { + val len = reader.readVarint().toInt() + when (fieldNum) { + 2 -> name = reader.readString(len) + 3 -> slug = reader.readString(len) + 8 -> if (synopsis.isEmpty()) synopsis = reader.readStringValue(len) else reader.skip(len) + 9 -> synopsis = reader.readStringValue(len) + 10 -> coverUrl = reader.readStringValue(len) + 13 -> author = reader.readStringValue(len) + 15, 16 -> { + val t = reader.readString(len).trim() + if (t.isNotEmpty() && t.length > 1) { + tags.add(MangaTag(title = t, key = t, source = source)) + } + } + 17 -> rating = reader.readReviewRating(len) + else -> reader.skip(len) + } + } + 5 -> reader.skip(4) + else -> break + } + } + + if (name.isEmpty() && slug.isEmpty()) return null to (if (id > 0) id else null) + val novelUrl = "$baseUrl/novel/$slug" + val cleanDesc = synopsis.ifEmpty { null }?.let { Jsoup.parse(it).text().trim().ifBlank { null } } + val state = when (status) { + 0 -> MangaState.FINISHED + 1 -> MangaState.ONGOING + 2 -> MangaState.PAUSED + else -> null + } + + val manga = Manga( + id = "${source.name}_$novelUrl".longHashCode(), + title = name.ifBlank { slug }, + altTitle = null, + url = novelUrl, + publicUrl = novelUrl, + rating = rating, + isNsfw = false, + coverUrl = coverUrl, + tags = tags, + state = state, + author = author.ifBlank { null }, + largeCoverUrl = coverUrl, + description = cleanDesc, + chapters = null, + source = source, + ) + return manga to (if (id > 0) id else null) + } + + private suspend fun fetchChaptersViaGrpc(novelId: Int, novelSlug: String): List = withContext(Dispatchers.IO) { + runCatchingCancellable { + val varintBytes = encodeVarint(novelId.toLong()) + val payload = byteArrayOf(0x08.toByte()) + varintBytes + val responseBytes = callGrpc("wuxiaworld.api.v2.Chapters/GetChapterList", payload) + ?: return@runCatchingCancellable emptyList() + parseChaptersProtobuf(responseBytes, novelSlug) + }.onFailure { + it.printStackTraceDebug() + }.getOrDefault(emptyList()) + } + + private fun parseChaptersProtobuf(responseBytes: ByteArray, novelSlug: String): List { + val chapters = mutableListOf() + var framePos = 0 + while (framePos + 5 <= responseBytes.size) { + val flag = responseBytes[framePos].toInt() and 0xFF + val msgLen = ((responseBytes[framePos + 1].toInt() and 0xFF) shl 24) or + ((responseBytes[framePos + 2].toInt() and 0xFF) shl 16) or + ((responseBytes[framePos + 3].toInt() and 0xFF) shl 8) or + (responseBytes[framePos + 4].toInt() and 0xFF) + framePos += 5 + if (framePos + msgLen > responseBytes.size) break + if (flag == 0) { + val reader = ProtoReader(responseBytes, framePos, framePos + msgLen) + while (reader.hasRemaining()) { + val tag = reader.readVarint() + val fieldNum = (tag ushr 3).toInt() + val wireType = (tag and 0x07).toInt() + when (wireType) { + 0 -> reader.readVarint() + 1 -> reader.skip(8) + 2 -> { + val length = reader.readVarint().toInt() + if (fieldNum == 1) { // items (ChapterGroupItem) + val groupReader = reader.subReader(length) + while (groupReader.hasRemaining()) { + val gTag = groupReader.readVarint() + val gField = (gTag ushr 3).toInt() + val gWire = (gTag and 0x07).toInt() + when (gWire) { + 0 -> groupReader.readVarint() + 1 -> groupReader.skip(8) + 2 -> { + val gLen = groupReader.readVarint().toInt() + if (gField == 6) { // chapterList (ChapterItem) + val cReader = groupReader.subReader(gLen) + var cName = "" + var cSlug = "" + while (cReader.hasRemaining()) { + val cTag = cReader.readVarint() + val cField = (cTag ushr 3).toInt() + val cWire = (cTag and 0x07).toInt() + when (cWire) { + 0 -> cReader.readVarint() + 1 -> cReader.skip(8) + 2 -> { + val cLen = cReader.readVarint().toInt() + when (cField) { + 2 -> cName = cReader.readString(cLen) + 3 -> cSlug = cReader.readString(cLen) + else -> cReader.skip(cLen) + } + } + 5 -> cReader.skip(4) + else -> break + } + } + if (cSlug.isNotEmpty()) { + val chapUrl = "$baseUrl/novel/$novelSlug/$cSlug" + val chapTitle = cName.ifEmpty { "Chapter ${chapters.size + 1}" } + chapters.add( + MangaChapter( + id = "${source.name}_$chapUrl".longHashCode(), + title = chapTitle, + number = (chapters.size + 1).toFloat(), + volume = 0, + url = chapUrl, + uploadDate = 0L, + source = source, + scanlator = null, + branch = null, + ), + ) + } + } else { + groupReader.skip(gLen) + } + } + 5 -> groupReader.skip(4) + else -> break + } + } + } else { + reader.skip(length) + } + } + 5 -> reader.skip(4) + else -> break + } + } + } + framePos += msgLen + } + return chapters + } + + private fun parseChapterLinksFromDoc(doc: org.jsoup.nodes.Document): List { + val chapterLinks = doc.select("a[href*='/chapter/'], a[href*='chapter-'], a[href*='snd-']") + val seenUrls = mutableSetOf() + return chapterLinks.mapNotNull { a -> + val chapUrl = a.absUrl("href") + if (!chapUrl.contains("/novel/") || !seenUrls.add(chapUrl)) return@mapNotNull null + val chapTitle = a.text().trim().ifEmpty { "Chapter" } + chapTitle to chapUrl + }.mapIndexed { index, (chapTitle, chapUrl) -> + MangaChapter( + id = "${source.name}_$chapUrl".longHashCode(), + title = chapTitle, + number = (index + 1).toFloat(), + volume = 0, + url = chapUrl, + uploadDate = 0L, + source = source, + scanlator = null, + branch = null, + ) + } + } + + override suspend fun getPagesImpl(chapter: MangaChapter): List { + val doc = fetchDocument(chapter.url) + val content = doc.selectFirst(".chapter-body, .fr-view, .chapter-content, #chapter-content") ?: doc.body() + content.select("script, style, iframe, button, .chapter-nav, .ad-box").remove() + val contentHtml = content.html() + + return listOf( + MangaPage( + id = chapter.id, + url = contentHtml, + preview = null, + source = source, + ), + ) + } + + private class ProtoReader( + private val bytes: ByteArray, + private var pos: Int = 0, + private val limit: Int = bytes.size, + ) { + fun hasRemaining(): Boolean = pos < limit + + fun readVarint(): Long { + var result = 0L + var shift = 0 + while (pos < limit) { + val b = bytes[pos++].toInt() + result = result or ((b and 0x7F).toLong() shl shift) + if ((b and 0x80) == 0) break + shift += 7 + } + return result + } + + fun readString(length: Int): String { + val end = (pos + length).coerceAtMost(limit) + val str = String(bytes, pos, end - pos, Charsets.UTF_8) + pos = end + return str + } + + fun skip(length: Int) { + pos = (pos + length).coerceAtMost(limit) + } + + fun skipByWire(wireType: Int) { + when (wireType) { + 0 -> readVarint() + 1 -> skip(8) + 2 -> skip(readVarint().toInt()) + 5 -> skip(4) + } + } + + fun readFixed64(): Long { + var result = 0L + for (i in 0 until 8) { + if (pos >= limit) break + result = result or ((bytes[pos++].toLong() and 0xFF) shl (i * 8)) + } + return result + } + + fun readStringValue(length: Int): String { + val sub = subReader(length) + var result = "" + while (sub.hasRemaining()) { + val tag = sub.readVarint() + val fieldNum = (tag ushr 3).toInt() + val wireType = (tag and 0x07).toInt() + if (wireType == 2) { + val strLen = sub.readVarint().toInt() + if (fieldNum == 1) { + result = sub.readString(strLen) + } else { + sub.skip(strLen) + } + } else { + sub.skipByWire(wireType) + } + } + return result + } + + fun readReviewRating(length: Int): Float { + val sub = subReader(length) + var rating = 0f + while (sub.hasRemaining()) { + val tag = sub.readVarint() + val fieldNum = (tag ushr 3).toInt() + val wireType = (tag and 0x07).toInt() + if (wireType == 2) { + val subLen = sub.readVarint().toInt() + if (fieldNum == 2) { // rating: DoubleValue + val dSub = sub.subReader(subLen) + while (dSub.hasRemaining()) { + val dTag = dSub.readVarint() + val dField = (dTag ushr 3).toInt() + val dWire = (dTag and 0x07).toInt() + if (dField == 1 && dWire == 1) { + val bits = dSub.readFixed64() + val d = java.lang.Double.longBitsToDouble(bits) + if (!d.isNaN() && d > 0.0) { + rating = d.toFloat().coerceIn(0f, 1f) + } + } else { + dSub.skipByWire(dWire) + } + } + } else { + sub.skip(subLen) + } + } else { + sub.skipByWire(wireType) + } + } + return rating + } + + fun subReader(length: Int): ProtoReader { + val end = (pos + length).coerceAtMost(limit) + val sub = ProtoReader(bytes, pos, end) + pos = end + return sub + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/local/NovelLocalListFragment.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/local/NovelLocalListFragment.kt new file mode 100644 index 0000000000..9b8d2ee125 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/local/NovelLocalListFragment.kt @@ -0,0 +1,133 @@ +package io.github.landwarderer.futon.novel.ui.local + +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.MenuProvider +import androidx.fragment.app.viewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.core.model.LocalNovelSource +import io.github.landwarderer.futon.core.nav.router +import io.github.landwarderer.futon.core.os.OpenDocumentTreeHelper +import io.github.landwarderer.futon.core.util.ext.addMenuProvider +import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug +import io.github.landwarderer.futon.core.util.ext.tryLaunch +import io.github.landwarderer.futon.core.util.ext.withArgs +import io.github.landwarderer.futon.databinding.FragmentListBinding +import io.github.landwarderer.futon.filter.ui.FilterCoordinator +import io.github.landwarderer.futon.list.ui.MangaListFragment +import io.github.landwarderer.futon.remotelist.ui.MangaSearchMenuProvider +import io.github.landwarderer.futon.remotelist.ui.RemoteListFragment + +@AndroidEntryPoint +class NovelLocalListFragment : MangaListFragment(), FilterCoordinator.Owner, MenuProvider { + + private val openDocumentLauncher = registerForActivityResult( + ActivityResultContracts.OpenMultipleDocuments(), + ) { uris -> + if (!uris.isNullOrEmpty()) { + viewModel.importFiles(uris) + } + } + + private val openDirectoryLauncher = OpenDocumentTreeHelper(this) { uri -> + if (uri != null) { + try { + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + requireContext().contentResolver.takePersistableUriPermission(uri, flags) + } catch (e: Exception) { + e.printStackTraceDebug() + } + viewModel.importFolder(uri) + } + } + + override val viewModel by viewModels() + + override val filterCoordinator: FilterCoordinator + get() = viewModel.filterCoordinator + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val args = arguments ?: Bundle(1) + args.putString(RemoteListFragment.ARG_SOURCE, LocalNovelSource.name) + arguments = args + } + + override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { + super.onViewBindingCreated(binding, savedInstanceState) + addMenuProvider(this) + addMenuProvider(MangaSearchMenuProvider(filterCoordinator, viewModel)) + } + + override fun onScrolledToEnd() = Unit + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menu.add(Menu.NONE, MENU_ID_IMPORT, Menu.NONE, R.string.novel_import_file).apply { + setIcon(R.drawable.ic_download) + setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return if (menuItem.itemId == MENU_ID_IMPORT) { + showImportOptionsDialog() + true + } else { + false + } + } + + override fun onEmptyActionClick() { + showImportOptionsDialog() + } + + override fun onFilterClick(view: View?) { + router.showFilterSheet() + } + + private fun showImportOptionsDialog() { + val options = arrayOf( + getString(R.string.add_folder), + getString(R.string.select_files), + ) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string._import) + .setItems(options) { _, which -> + when (which) { + 0 -> if (!openDirectoryLauncher.tryLaunch(null)) { + Toast.makeText(requireContext(), R.string.operation_not_supported, Toast.LENGTH_SHORT).show() + } + 1 -> openFilePicker() + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + companion object { + private const val MENU_ID_IMPORT = 1001 + + fun newInstance() = NovelLocalListFragment().withArgs(1) { + putString(RemoteListFragment.ARG_SOURCE, LocalNovelSource.name) + } + } + + private fun openFilePicker() { + openDocumentLauncher.launch( + arrayOf( + "application/epub+zip", + "application/pdf", + "application/octet-stream", + "*/*", + ) + ) + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/local/NovelLocalListViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/local/NovelLocalListViewModel.kt new file mode 100644 index 0000000000..c123971c8b --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/local/NovelLocalListViewModel.kt @@ -0,0 +1,70 @@ +package io.github.landwarderer.futon.novel.ui.local + +import android.content.Context +import android.net.Uri +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharedFlow +import io.github.landwarderer.futon.core.parser.MangaDataRepository +import io.github.landwarderer.futon.core.parser.MangaRepository +import io.github.landwarderer.futon.core.prefs.AppSettings +import io.github.landwarderer.futon.core.util.ext.isFileUri +import io.github.landwarderer.futon.core.util.ext.resolveFile +import io.github.landwarderer.futon.core.util.ext.toFileOrNull +import io.github.landwarderer.futon.explore.data.MangaSourcesRepository +import io.github.landwarderer.futon.explore.domain.ExploreRepository +import io.github.landwarderer.futon.filter.ui.FilterCoordinator +import io.github.landwarderer.futon.list.domain.MangaListMapper +import io.github.landwarderer.futon.local.data.LocalStorageChanges +import io.github.landwarderer.futon.local.domain.model.LocalManga +import io.github.landwarderer.futon.novel.data.NovelStorageManager +import io.github.landwarderer.futon.remotelist.ui.RemoteListViewModel +import javax.inject.Inject + +@HiltViewModel +class NovelLocalListViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + mangaRepositoryFactory: MangaRepository.Factory, + filterCoordinator: FilterCoordinator, + settings: AppSettings, + mangaListMapper: MangaListMapper, + exploreRepository: ExploreRepository, + sourcesRepository: MangaSourcesRepository, + mangaDataRepository: MangaDataRepository, + @LocalStorageChanges localStorageChanges: SharedFlow, + private val novelStorageManager: NovelStorageManager, + @ApplicationContext private val context: Context, +) : RemoteListViewModel( + savedStateHandle = savedStateHandle, + mangaRepositoryFactory = mangaRepositoryFactory, + filterCoordinator = filterCoordinator, + settings = settings, + mangaListMapper = mangaListMapper, + exploreRepository = exploreRepository, + sourcesRepository = sourcesRepository, + mangaDataRepository = mangaDataRepository, + localStorageChanges = localStorageChanges, +) { + + fun importFolder(uri: Uri) { + launchJob(Dispatchers.IO) { + val dir = (if (uri.isFileUri()) uri.toFileOrNull() else uri.resolveFile(context)) + ?: uri.resolveFile(context) + if (dir != null && (dir.exists() || dir.mkdirs())) { + novelStorageManager.addNovelDirectory(dir) + } + onRefresh() + } + } + + fun importFiles(uris: List) { + launchJob(Dispatchers.IO) { + for (uri in uris) { + novelStorageManager.importNovel(uri) + } + onRefresh() + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/NovelHtmlTemplate.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/NovelHtmlTemplate.kt new file mode 100644 index 0000000000..0317fc3af5 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/NovelHtmlTemplate.kt @@ -0,0 +1,232 @@ +package io.github.landwarderer.futon.novel.ui.reader + +import io.github.landwarderer.futon.novel.data.model.NovelReadingMode + +object NovelHtmlTemplate { + + fun buildDocument( + title: String, + initialChapterHtml: String, + settings: NovelReaderSettings, + ): String { + val cssVars = settings.toCssVariables() + val isPaged = settings.readingMode == NovelReadingMode.PAGED + + val pagedStyle = if (isPaged) { + """ + html, body { + height: 100vh; + overflow: hidden; + margin: 0; + padding: 0; + } + #reader-container { + width: 100vw; + height: 100vh; + overflow-x: scroll; + overflow-y: hidden; + scroll-snap-type: x mandatory; + -webkit-overflow-scrolling: touch; + } + #content { + column-width: 100vw; + column-gap: 0px; + column-fill: auto; + height: calc(100vh - 2 * var(--reader-page-margin)); + margin: var(--reader-page-margin) 0; + padding: 0 var(--reader-page-margin); + box-sizing: border-box; + } + """ + } else { + """ + html, body { + margin: 0; + padding: 0; + min-height: 100vh; + } + #content { + padding: var(--reader-page-margin); + max-width: 800px; + margin: 0 auto; + box-sizing: border-box; + } + """ + } + + return """ + + + + + + $title + + + + +
+
+ $initialChapterHtml +
+
+ + + + + """.trimIndent() + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/NovelReaderActivity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/NovelReaderActivity.kt new file mode 100644 index 0000000000..1fdd6d6e28 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/NovelReaderActivity.kt @@ -0,0 +1,332 @@ +package io.github.landwarderer.futon.novel.ui.reader + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.View +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.viewModels +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import com.google.android.material.slider.Slider +import dagger.hilt.android.AndroidEntryPoint +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.core.network.MangaHttpClient +import io.github.landwarderer.futon.core.prefs.AppSettings +import io.github.landwarderer.futon.core.ui.BaseFullscreenActivity +import io.github.landwarderer.futon.core.util.MimeTypes +import io.github.landwarderer.futon.core.util.ext.observe +import io.github.landwarderer.futon.core.util.ext.observeEvent +import io.github.landwarderer.futon.databinding.ActivityNovelReaderBinding +import io.github.landwarderer.futon.novel.data.model.NovelReadingMode +import io.github.landwarderer.futon.novel.data.source.BaseNovelRepository +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import java.io.FilterInputStream +import java.io.InputStream +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +@AndroidEntryPoint +class NovelReaderActivity : + BaseFullscreenActivity(), + NovelReaderConfigSheet.Callback, + Slider.OnSliderTouchListener, + Slider.OnChangeListener { + + @Inject + lateinit var appSettings: AppSettings + + @Inject + @MangaHttpClient + lateinit var baseOkHttpClient: OkHttpClient + + private val imageOkHttpClient by lazy { + baseOkHttpClient.newBuilder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(20, TimeUnit.SECONDS) + .build() + } + + private val viewModel by viewModels() + private lateinit var readerSettings: NovelReaderSettings + private var isUiVisible = true + private var isSliderTracking = false + @Volatile + private var defaultUserAgent: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityNovelReaderBinding.inflate(layoutInflater)) + readerSettings = NovelReaderSettings.load(this) + + setupToolbar() + setupWebView() + setupControls() + observeViewModel() + } + + override fun onDestroy() { + viewBinding.webView.destroy() + super.onDestroy() + } + + private fun setupToolbar() { + setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) + val initialTitle = viewModel.manga.value?.title ?: getString(R.string.loading_) + title = initialTitle + supportActionBar?.title = initialTitle + supportActionBar?.subtitle = null + viewBinding.toolbar.setNavigationOnClickListener { + onBackPressedDispatcher.onBackPressed() + } + } + + @SuppressLint("SetJavaScriptEnabled") + private fun setupWebView() { + with(viewBinding.webView.settings) { + javaScriptEnabled = true + domStorageEnabled = true + allowFileAccess = true + loadWithOverviewMode = true + useWideViewPort = true + displayZoomControls = false + builtInZoomControls = false + defaultUserAgent = userAgentString + } + + viewBinding.webView.setBackgroundColor(readerSettings.effectiveBackgroundColor) + + val bridge = NovelReaderBridge( + scope = lifecycleScope, + onScroll = { percent -> + if (!isSliderTracking) { + viewModel.updateScrollProgress(percent) + } + }, + onNearBottomReached = { + viewModel.loadNextChapterForInfiniteScroll() + }, + onCenterTapped = { + toggleUiVisibility() + }, + onPageChange = { cur, total -> + viewBinding.textProgress.text = "$cur / $total" + }, + ) + viewBinding.webView.addJavascriptInterface(bridge, "AndroidNovelBridge") + + viewBinding.webView.webViewClient = object : WebViewClient() { + override fun shouldInterceptRequest( + view: WebView?, + request: WebResourceRequest?, + ): WebResourceResponse? { + return try { + val url = request?.url ?: return null + val path = url.path.orEmpty() + val epubParser = viewModel.getEpubParser() + if (epubParser != null && (url.scheme == "epub" || url.host == "futon.reader")) { + val cleanPath = path.removePrefix("/") + val stream: InputStream? = epubParser.readEntry(cleanPath) + if (stream != null) { + val ext = cleanPath.substringAfterLast('.', "") + val mime = MimeTypes.getMimeTypeFromExtension(ext)?.toString() ?: "application/octet-stream" + return WebResourceResponse(mime, null, stream) + } + } + val host = url.host.orEmpty() + if (host.equals("img.lnori.com", ignoreCase = true) || host.endsWith(".lnori.com", ignoreCase = true)) { + return interceptLnoriImage(request) + } + super.shouldInterceptRequest(view, request) + } catch (e: Throwable) { + null + } + } + } + } + + override fun onApplyWindowInsets(v: View, insets: androidx.core.view.WindowInsetsCompat): androidx.core.view.WindowInsetsCompat { + val systemBars = insets.getInsets(androidx.core.view.WindowInsetsCompat.Type.systemBars()) + viewBinding.appbarTop.setPadding(0, systemBars.top, 0, 0) + viewBinding.cardBottom.setPadding(0, 0, 0, systemBars.bottom) + return insets + } + + private fun setupControls() { + viewBinding.buttonPrev.setOnClickListener { viewModel.switchChapter(-1) } + viewBinding.buttonNext.setOnClickListener { viewModel.switchChapter(1) } + viewBinding.buttonFontSettings.setOnClickListener { + NovelReaderConfigSheet.newInstance().show(supportFragmentManager, NovelReaderConfigSheet.TAG) + } + + viewBinding.sliderProgress.addOnSliderTouchListener(this) + viewBinding.sliderProgress.addOnChangeListener(this) + } + + @SuppressLint("SetTextI18n") + private fun observeViewModel() { + viewModel.manga.observe(this) { manga -> + if (manga != null) { + title = manga.title + supportActionBar?.title = manga.title + } + } + + viewModel.currentChapter.observe(this) { chapter -> + if (chapter != null) { + supportActionBar?.subtitle = chapter.title + val idx = viewModel.currentChapterIndex.value + 1 + val total = viewModel.chapters.value.size + viewBinding.textProgress.text = if (total > 0) "$idx / $total" else chapter.title + } + } + + viewModel.isReadingLoading.observe(this) { loading -> + viewBinding.progressBar.isVisible = loading + } + + viewModel.chapterContent.observe(this) { html -> + if (!html.isNullOrEmpty()) { + renderChapterContent(html) + } + } + + viewModel.onChapterAppended.observeEvent(this) { (title, html) -> + val quotedTitle = org.json.JSONObject.quote(title) + val quotedHtml = org.json.JSONObject.quote(html) + viewBinding.webView.evaluateJavascript("appendChapter($quotedTitle, $quotedHtml);", null) + } + + viewModel.scrollProgress.observe(this) { percent -> + if (!isSliderTracking) { + viewBinding.sliderProgress.value = (percent * 100f).coerceIn(0f, 100f) + } + } + } + + private fun renderChapterContent(html: String) { + val title = viewModel.currentChapter.value?.title ?: "" + val fullDoc = NovelHtmlTemplate.buildDocument(title, html, readerSettings) + val chapterUrl = viewModel.currentChapter.value?.url?.substringBefore('#') + val isOnline = !chapterUrl.isNullOrEmpty() && (chapterUrl.startsWith("http://") || chapterUrl.startsWith("https://")) + val baseUrl = if (isOnline) chapterUrl else "https://futon.reader/" + viewBinding.webView.loadDataWithBaseURL(baseUrl, fullDoc, "text/html", "UTF-8", null) + } + + private fun interceptLnoriImage(request: WebResourceRequest): WebResourceResponse? { + var response: Response? = null + return try { + val urlString = request.url.toString() + val userAgent = request.requestHeaders?.get("User-Agent") + ?: defaultUserAgent + ?: BaseNovelRepository.DEFAULT_USER_AGENT + + val okHttpRequest = Request.Builder() + .url(urlString) + .method(request.method, null) + .header("Referer", "https://lnori.com/") + .apply { + if (!userAgent.isNullOrBlank()) { + header("User-Agent", userAgent) + } + } + .build() + + response = imageOkHttpClient.newCall(okHttpRequest).execute() + if (!response.isSuccessful) { + response.close() + return null + } + + val body = response.body ?: run { + response.close() + return null + } + + val rawContentType = response.header("Content-Type")?.substringBefore(';')?.trim() + val mimeType = if (!rawContentType.isNullOrBlank() && rawContentType != "application/octet-stream") { + rawContentType + } else { + val ext = request.url.path?.substringAfterLast('.', "") ?: "avif" + MimeTypes.getMimeTypeFromExtension(ext)?.toString() ?: "image/avif" + } + + val responseHeaders = mutableMapOf() + for (i in 0 until response.headers.size) { + val name = response.headers.name(i) + if (name.equals("content-encoding", ignoreCase = true) || + name.equals("transfer-encoding", ignoreCase = true) + ) { + continue + } + responseHeaders[name] = response.headers.value(i) + } + + val wrappedStream = object : FilterInputStream(body.byteStream()) { + override fun close() { + try { + super.close() + } finally { + response.close() + } + } + } + + WebResourceResponse( + mimeType, + null, + response.code, + response.message.ifBlank { "OK" }, + responseHeaders, + wrappedStream, + ) + } catch (e: Throwable) { + response?.close() + null + } + } + + private fun toggleUiVisibility() { + isUiVisible = !isUiVisible + viewBinding.appbarTop.isVisible = isUiVisible + viewBinding.cardBottom.isVisible = isUiVisible + } + + override fun onNovelSettingsChanged(settings: NovelReaderSettings) { + val modeChanged = settings.readingMode != readerSettings.readingMode + readerSettings = settings + viewBinding.webView.setBackgroundColor(settings.effectiveBackgroundColor) + + if (modeChanged) { + val currentHtml = viewModel.chapterContent.value + if (!currentHtml.isNullOrEmpty()) { + renderChapterContent(currentHtml) + } + } else { + val cssText = org.json.JSONObject.quote(settings.toCssVariables()) + viewBinding.webView.evaluateJavascript("updateStyles($cssText);", null) + } + } + + override fun onStartTrackingTouch(slider: Slider) { + isSliderTracking = true + } + + override fun onStopTrackingTouch(slider: Slider) { + isSliderTracking = false + val percent = slider.value / 100f + viewModel.updateScrollProgress(percent) + viewBinding.webView.evaluateJavascript("scrollToPercent($percent);", null) + } + + override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { + if (fromUser) { + viewBinding.textProgress.text = "${value.toInt()}%" + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/NovelReaderBridge.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/NovelReaderBridge.kt new file mode 100644 index 0000000000..3f6ac415b0 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/NovelReaderBridge.kt @@ -0,0 +1,48 @@ +package io.github.landwarderer.futon.novel.ui.reader + +import android.webkit.JavascriptInterface +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class NovelReaderBridge( + private val scope: CoroutineScope, + private val onScroll: (percent: Float) -> Unit, + private val onNearBottomReached: () -> Unit, + private val onCenterTapped: () -> Unit, + private val onPageChange: (currentPage: Int, totalPages: Int) -> Unit, +) { + + @JavascriptInterface + fun onScrollProgress(percent: Float) { + scope.launch(Dispatchers.Main) { + onScroll(percent) + } + } + + @JavascriptInterface + fun onNearBottom() { + scope.launch(Dispatchers.Main) { + onNearBottomReached() + } + } + + @JavascriptInterface + fun onCenterTap() { + scope.launch(Dispatchers.Main) { + onCenterTapped() + } + } + + @JavascriptInterface + fun onPageChanged(currentPage: Int, totalPages: Int) { + scope.launch(Dispatchers.Main) { + onPageChange(currentPage, totalPages) + } + } + + @JavascriptInterface + fun log(msg: String) { + android.util.Log.d("NovelReaderBridge", msg) + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/NovelReaderConfigSheet.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/NovelReaderConfigSheet.kt new file mode 100644 index 0000000000..627a1045de --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/NovelReaderConfigSheet.kt @@ -0,0 +1,157 @@ +package io.github.landwarderer.futon.novel.ui.reader + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.databinding.SheetNovelReaderConfigBinding +import io.github.landwarderer.futon.novel.data.model.NovelReadingMode +import io.github.landwarderer.futon.novel.data.model.NovelTextAlign +import io.github.landwarderer.futon.novel.data.model.NovelTheme + +class NovelReaderConfigSheet : BottomSheetDialogFragment() { + + interface Callback { + fun onNovelSettingsChanged(settings: NovelReaderSettings) + } + + private var _binding: SheetNovelReaderConfigBinding? = null + private val binding get() = _binding!! + + private lateinit var currentSettings: NovelReaderSettings + private var callback: Callback? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = SheetNovelReaderConfigBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + currentSettings = NovelReaderSettings.load(requireContext()) + callback = activity as? Callback ?: parentFragment as? Callback + + bindCurrentSettings() + setupListeners() + } + + override fun onDestroyView() { + super.onDestroyView() + NovelReaderSettings.save(requireContext(), currentSettings) + _binding = null + } + + @SuppressLint("SetTextI18n") + private fun bindCurrentSettings() { + // Theme + when (currentSettings.theme) { + NovelTheme.DARK -> binding.chipThemeDark.isChecked = true + NovelTheme.LIGHT -> binding.chipThemeLight.isChecked = true + NovelTheme.OLED -> binding.chipThemeOled.isChecked = true + NovelTheme.SEPIA -> binding.chipThemeSepia.isChecked = true + NovelTheme.CUSTOM -> {} + } + + // Reading Mode + when (currentSettings.readingMode) { + NovelReadingMode.CONTINUOUS_SCROLL -> binding.buttonModeContinuous.isChecked = true + NovelReadingMode.PAGED -> binding.buttonModePaged.isChecked = true + } + + // Font Size + binding.sliderFontSize.value = currentSettings.fontSizeSp.coerceIn(12f, 36f) + binding.textFontSizeValue.text = "${currentSettings.fontSizeSp.toInt()}sp" + + // Font Family + when (currentSettings.fontFamily) { + "serif" -> binding.chipFontSerif.isChecked = true + "sans-serif" -> binding.chipFontSans.isChecked = true + "monospace" -> binding.chipFontMono.isChecked = true + else -> binding.chipFontSerif.isChecked = true + } + + // Line Spacing + binding.sliderLineSpacing.value = currentSettings.lineSpacingMultiplier.coerceIn(1.0f, 2.4f) + binding.textLineSpacingValue.text = String.format("%.1f", currentSettings.lineSpacingMultiplier) + + // Text Alignment + when (currentSettings.textAlignment) { + NovelTextAlign.LEFT -> binding.buttonAlignLeft.isChecked = true + NovelTextAlign.JUSTIFY -> binding.buttonAlignJustify.isChecked = true + } + } + + @SuppressLint("SetTextI18n") + private fun setupListeners() { + binding.chipGroupThemes.setOnCheckedStateChangeListener { _, checkedIds -> + val newTheme = when { + checkedIds.contains(R.id.chip_theme_light) -> NovelTheme.LIGHT + checkedIds.contains(R.id.chip_theme_oled) -> NovelTheme.OLED + checkedIds.contains(R.id.chip_theme_sepia) -> NovelTheme.SEPIA + else -> NovelTheme.DARK + } + updateSettings { copy(theme = newTheme) } + } + + binding.toggleGroupReadingMode.addOnButtonCheckedListener { _, checkedId, isChecked -> + if (isChecked) { + val newMode = when (checkedId) { + R.id.button_mode_paged -> NovelReadingMode.PAGED + else -> NovelReadingMode.CONTINUOUS_SCROLL + } + updateSettings { copy(readingMode = newMode) } + } + } + + binding.sliderFontSize.addOnChangeListener { _, value, fromUser -> + if (fromUser) { + binding.textFontSizeValue.text = "${value.toInt()}sp" + updateSettings { copy(fontSizeSp = value) } + } + } + + binding.chipGroupFontFamily.setOnCheckedStateChangeListener { _, checkedIds -> + val newFont = when { + checkedIds.contains(R.id.chip_font_sans) -> "sans-serif" + checkedIds.contains(R.id.chip_font_mono) -> "monospace" + else -> "serif" + } + updateSettings { copy(fontFamily = newFont) } + } + + binding.sliderLineSpacing.addOnChangeListener { _, value, fromUser -> + if (fromUser) { + binding.textLineSpacingValue.text = String.format("%.1f", value) + updateSettings { copy(lineSpacingMultiplier = value) } + } + } + + binding.toggleGroupAlignment.addOnButtonCheckedListener { _, checkedId, isChecked -> + if (isChecked) { + val newAlign = when (checkedId) { + R.id.button_align_justify -> NovelTextAlign.JUSTIFY + else -> NovelTextAlign.LEFT + } + updateSettings { copy(textAlignment = newAlign) } + } + } + } + + private fun updateSettings(transform: NovelReaderSettings.() -> NovelReaderSettings) { + currentSettings = currentSettings.transform() + callback?.onNovelSettingsChanged(currentSettings) + } + + companion object { + const val TAG = "NovelReaderConfigSheet" + + fun newInstance(): NovelReaderConfigSheet = NovelReaderConfigSheet() + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/NovelReaderSettings.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/NovelReaderSettings.kt new file mode 100644 index 0000000000..b8c6bcc23f --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/NovelReaderSettings.kt @@ -0,0 +1,93 @@ +package io.github.landwarderer.futon.novel.ui.reader + +import android.content.Context +import android.content.SharedPreferences +import android.graphics.Color +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import io.github.landwarderer.futon.core.util.ext.getEnumValue +import io.github.landwarderer.futon.core.util.ext.putEnumValue +import io.github.landwarderer.futon.novel.data.model.NovelReadingMode +import io.github.landwarderer.futon.novel.data.model.NovelTextAlign +import io.github.landwarderer.futon.novel.data.model.NovelTheme + +data class NovelReaderSettings( + val fontSizeSp: Float = 18f, + val theme: NovelTheme = NovelTheme.DARK, + val customBackgroundColor: Int = Color.parseColor("#141414"), + val customTextColor: Int = Color.parseColor("#E0E0E0"), + val fontFamily: String = "serif", + val lineSpacingMultiplier: Float = 1.6f, + val paragraphMarginEm: Float = 1.0f, + val pageMarginDp: Int = 18, + val textAlignment: NovelTextAlign = NovelTextAlign.LEFT, + val readingMode: NovelReadingMode = NovelReadingMode.CONTINUOUS_SCROLL, +) { + val effectiveBackgroundColor: Int + get() = if (theme == NovelTheme.CUSTOM) customBackgroundColor else theme.backgroundColor + + val effectiveTextColor: Int + get() = if (theme == NovelTheme.CUSTOM) customTextColor else theme.textColor + + fun toCssVariables(): String { + val bgHex = String.format("#%06X", 0xFFFFFF and effectiveBackgroundColor) + val textHex = String.format("#%06X", 0xFFFFFF and effectiveTextColor) + return """ + :root { + --reader-bg: $bgHex; + --reader-text: $textHex; + --reader-font-size: ${fontSizeSp.toInt()}px; + --reader-font-family: $fontFamily, serif; + --reader-line-height: $lineSpacingMultiplier; + --reader-paragraph-margin: ${paragraphMarginEm}em; + --reader-page-margin: ${pageMarginDp}px; + --reader-text-align: ${textAlignment.cssValue}; + } + """.trimIndent() + } + + companion object { + private const val KEY_FONT_SIZE = "novel_font_size" + private const val KEY_THEME = "novel_theme" + private const val KEY_BG_COLOR = "novel_bg_color" + private const val KEY_TEXT_COLOR = "novel_text_color" + private const val KEY_FONT_FAMILY = "novel_font_family" + private const val KEY_LINE_SPACING = "novel_line_spacing" + private const val KEY_PARA_MARGIN = "novel_para_margin" + private const val KEY_PAGE_MARGIN = "novel_page_margin" + private const val KEY_TEXT_ALIGN = "novel_text_align" + private const val KEY_READING_MODE = "novel_reading_mode" + + fun load(context: Context): NovelReaderSettings { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + return NovelReaderSettings( + fontSizeSp = prefs.getFloat(KEY_FONT_SIZE, 18f), + theme = prefs.getEnumValue(KEY_THEME, NovelTheme.DARK), + customBackgroundColor = prefs.getInt(KEY_BG_COLOR, Color.parseColor("#141414")), + customTextColor = prefs.getInt(KEY_TEXT_COLOR, Color.parseColor("#E0E0E0")), + fontFamily = prefs.getString(KEY_FONT_FAMILY, "serif") ?: "serif", + lineSpacingMultiplier = prefs.getFloat(KEY_LINE_SPACING, 1.6f), + paragraphMarginEm = prefs.getFloat(KEY_PARA_MARGIN, 1.0f), + pageMarginDp = prefs.getInt(KEY_PAGE_MARGIN, 18), + textAlignment = prefs.getEnumValue(KEY_TEXT_ALIGN, NovelTextAlign.LEFT), + readingMode = prefs.getEnumValue(KEY_READING_MODE, NovelReadingMode.CONTINUOUS_SCROLL), + ) + } + + fun save(context: Context, settings: NovelReaderSettings) { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + prefs.edit { + putFloat(KEY_FONT_SIZE, settings.fontSizeSp) + putEnumValue(KEY_THEME, settings.theme) + putInt(KEY_BG_COLOR, settings.customBackgroundColor) + putInt(KEY_TEXT_COLOR, settings.customTextColor) + putString(KEY_FONT_FAMILY, settings.fontFamily) + putFloat(KEY_LINE_SPACING, settings.lineSpacingMultiplier) + putFloat(KEY_PARA_MARGIN, settings.paragraphMarginEm) + putInt(KEY_PAGE_MARGIN, settings.pageMarginDp) + putEnumValue(KEY_TEXT_ALIGN, settings.textAlignment) + putEnumValue(KEY_READING_MODE, settings.readingMode) + } + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/NovelReaderViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/NovelReaderViewModel.kt new file mode 100644 index 0000000000..90c26782f4 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/NovelReaderViewModel.kt @@ -0,0 +1,278 @@ +package io.github.landwarderer.futon.novel.ui.reader + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.landwarderer.futon.core.model.parcelable.ParcelableManga +import io.github.landwarderer.futon.core.nav.AppRouter +import io.github.landwarderer.futon.core.parser.MangaDataRepository +import io.github.landwarderer.futon.core.prefs.AppSettings +import io.github.landwarderer.futon.core.ui.BaseViewModel +import io.github.landwarderer.futon.core.util.ext.MutableEventFlow +import io.github.landwarderer.futon.core.util.ext.call +import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug +import io.github.landwarderer.futon.history.data.HistoryRepository +import io.github.landwarderer.futon.novel.data.epub.EpubParser +import io.github.landwarderer.futon.novel.data.pdf.PdfRendererHelper +import io.github.landwarderer.futon.core.nav.ReaderIntent +import io.github.landwarderer.futon.reader.ui.ReaderState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import java.io.File +import javax.inject.Inject + +@HiltViewModel +class NovelReaderViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val mangaDataRepository: MangaDataRepository, + private val mangaRepositoryFactory: io.github.landwarderer.futon.core.parser.MangaRepository.Factory, + private val historyRepository: HistoryRepository, + val settings: AppSettings, +) : BaseViewModel() { + + private val initialManga: Manga? = savedStateHandle.get(AppRouter.KEY_MANGA)?.manga + private val mangaId: Long = savedStateHandle.get(AppRouter.KEY_ID) ?: initialManga?.id ?: 0L + private val initialState: ReaderState? = savedStateHandle.get(ReaderIntent.EXTRA_STATE) + + private val _manga = MutableStateFlow(initialManga) + val manga: StateFlow = _manga.asStateFlow() + + private val _chapters = MutableStateFlow>(emptyList()) + val chapters: StateFlow> = _chapters.asStateFlow() + + private val _currentChapterIndex = MutableStateFlow(0) + val currentChapterIndex: StateFlow = _currentChapterIndex.asStateFlow() + + private val _currentChapter = MutableStateFlow(null) + val currentChapter: StateFlow = _currentChapter.asStateFlow() + + private val _chapterContent = MutableStateFlow(null) + val chapterContent: StateFlow = _chapterContent.asStateFlow() + + private val _isReadingLoading = MutableStateFlow(false) + val isReadingLoading: StateFlow = _isReadingLoading.asStateFlow() + + private val _scrollProgress = MutableStateFlow(0f) + val scrollProgress: StateFlow = _scrollProgress.asStateFlow() + + private val _isPdf = MutableStateFlow(false) + val isPdf: StateFlow = _isPdf.asStateFlow() + + val onChapterAppended = MutableEventFlow>() // title, html + + private var epubParser: EpubParser? = null + private var pdfHelper: PdfRendererHelper? = null + + init { + loadNovel() + } + + override fun onCleared() { + super.onCleared() + pdfHelper?.close() + pdfHelper = null + } + + fun loadNovel() { + launchJob(Dispatchers.IO) { + _isReadingLoading.value = true + try { + var current = _manga.value + if (current == null && mangaId != 0L) { + current = mangaDataRepository.findMangaById(mangaId, withChapters = true) + _manga.value = current + } + + if (current == null) { + _isReadingLoading.value = false + return@launchJob + } + + val mangaFile = current.url.let { url -> + if (url.startsWith("file://")) File(url.removePrefix("file://")) + else File(url) + } + + val isPdfFile = mangaFile.exists() && mangaFile.extension.equals("pdf", ignoreCase = true) + val isEpubFile = mangaFile.exists() && mangaFile.extension.equals("epub", ignoreCase = true) + _isPdf.value = isPdfFile + + val chapterList = if (isEpubFile) { + epubParser = EpubParser(mangaFile) + val book = epubParser?.parseBook() + book?.chapters?.map { ec -> + MangaChapter( + id = ec.id, + title = ec.title, + number = ec.index.toFloat(), + volume = 0, + url = ec.href, + uploadDate = 0L, + source = current.source, + scanlator = null, + branch = null, + ) + } ?: emptyList() + } else if (isPdfFile) { + pdfHelper = PdfRendererHelper(mangaFile) + val count = pdfHelper?.pageCount ?: 0 + (0 until count).map { pageIdx -> + MangaChapter( + id = pageIdx.toLong(), + title = "Page ${pageIdx + 1}", + number = (pageIdx + 1).toFloat(), + volume = 0, + url = "pdf://page/$pageIdx", + uploadDate = 0L, + source = current.source, + scanlator = null, + branch = null, + ) + } + } else { + // Online novel or database chapters + val repo = mangaRepositoryFactory.create(current.source) + val fullDetails = repo.getDetails(current) + fullDetails.chapters.orEmpty() + } + + _chapters.value = chapterList + + // Determine start chapter + val targetChapterId = initialState?.chapterId + val startIdx = if (targetChapterId != null) { + chapterList.indexOfFirst { it.id == targetChapterId }.coerceAtLeast(0) + } else { + val lastHistory = historyRepository.getOne(current) + if (lastHistory != null) { + chapterList.indexOfFirst { it.id == lastHistory.chapterId }.coerceAtLeast(0) + } else { + 0 + } + } + + if (chapterList.isNotEmpty()) { + loadChapterAtIndex(startIdx) + } else { + _chapterContent.value = "

No chapters found for this novel.

" + } + } catch (e: Exception) { + e.printStackTraceDebug() + _chapterContent.value = "

Error loading novel details.

" + } finally { + _isReadingLoading.value = false + } + } + } + + fun loadChapterAtIndex(index: Int) { + val list = _chapters.value + if (index !in list.indices) return + val target = list[index] + _currentChapterIndex.value = index + _currentChapter.value = target + + launchJob(Dispatchers.IO) { + _isReadingLoading.value = true + try { + val content = fetchChapterHtml(target) + if (content.isNullOrBlank()) { + _chapterContent.value = "

Unable to load chapter content.

Please check your network connection and try again.

" + } else { + _chapterContent.value = content + } + saveReadingProgress(0f) + } catch (e: Exception) { + e.printStackTraceDebug() + _chapterContent.value = "

Error loading chapter.

" + } finally { + _isReadingLoading.value = false + } + } + } + + fun switchChapter(delta: Int) { + val nextIdx = _currentChapterIndex.value + delta + loadChapterAtIndex(nextIdx) + } + + fun loadNextChapterForInfiniteScroll() { + val list = _chapters.value + val nextIdx = _currentChapterIndex.value + 1 + if (nextIdx !in list.indices) return + val nextChapter = list[nextIdx] + + launchJob(Dispatchers.IO) { + try { + val html = fetchChapterHtml(nextChapter) + if (!html.isNullOrBlank()) { + _currentChapterIndex.value = nextIdx + _currentChapter.value = nextChapter + onChapterAppended.call(Pair(nextChapter.title.orEmpty(), html)) + saveReadingProgress(0f) + } + } catch (e: Exception) { + e.printStackTraceDebug() + } + } + } + + private suspend fun fetchChapterHtml(chapter: MangaChapter): String? = withContext(Dispatchers.IO) { + val parser = epubParser + if (parser != null) { + return@withContext parser.getChapterHtml(chapter.url) + } + + val curManga = _manga.value ?: return@withContext null + try { + // Online novel source: fetch pages / chapter content + val repo = mangaRepositoryFactory.create(curManga.source) + val pages = repo.getPages(chapter) + // For text sources, pages may contain formatted text in url or html content + val textBuilder = StringBuilder() + for (page in pages) { + val trimmed = page.url.trim() + if (trimmed.startsWith("${trimmed}

") + } + } + textBuilder.toString() + } catch (e: Exception) { + e.printStackTraceDebug() + null + } + } + + fun updateScrollProgress(percent: Float) { + _scrollProgress.value = percent + saveReadingProgress(percent) + } + + private fun saveReadingProgress(percent: Float) { + val cur = _manga.value ?: return + val chap = _currentChapter.value ?: return + viewModelScope.launch(Dispatchers.IO) { + val chaps = _chapters.value + historyRepository.addOrUpdate( + manga = cur.copy(chapters = chaps), + chapterId = chap.id, + page = _currentChapterIndex.value, + scroll = (percent * 100).toInt(), + percent = percent, + force = false, + ) + } + } + + fun getPdfHelper(): PdfRendererHelper? = pdfHelper + + fun getEpubParser(): EpubParser? = epubParser +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/pdf/PdfPageAdapter.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/pdf/PdfPageAdapter.kt new file mode 100644 index 0000000000..9420c14be0 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/pdf/PdfPageAdapter.kt @@ -0,0 +1,99 @@ +package io.github.landwarderer.futon.novel.ui.reader.pdf + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug +import io.github.landwarderer.futon.databinding.ItemPdfPageBinding +import io.github.landwarderer.futon.novel.data.pdf.PdfRendererHelper + +class PdfPageAdapter( + private val scope: CoroutineScope, + private val getPdfHelper: () -> PdfRendererHelper?, + private val onPageClick: (() -> Unit)? = null, +) : RecyclerView.Adapter() { + + private var pageCount: Int = 0 + + fun setPageCount(count: Int) { + if (pageCount != count) { + pageCount = count + notifyDataSetChanged() + } + } + + override fun getItemCount(): Int = pageCount + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PageViewHolder { + val binding = ItemPdfPageBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return PageViewHolder(binding) + } + + override fun onBindViewHolder(holder: PageViewHolder, position: Int) { + val targetWidth = holder.itemView.resources.displayMetrics.widthPixels.takeIf { it > 0 } ?: 1080 + holder.bind(position, getPdfHelper(), targetWidth) + } + + override fun onViewRecycled(holder: PageViewHolder) { + super.onViewRecycled(holder) + holder.recycle() + } + + inner class PageViewHolder(val binding: ItemPdfPageBinding) : RecyclerView.ViewHolder(binding.root) { + private var loadJob: Job? = null + + init { + binding.root.setOnClickListener { + onPageClick?.invoke() + } + } + + fun bind(position: Int, helper: PdfRendererHelper?, targetWidth: Int) { + loadJob?.cancel() + binding.imageViewPage.setImageBitmap(null) + val estimatedHeight = (targetWidth * 1.4f).toInt() + binding.root.minimumHeight = estimatedHeight + + if (helper == null) { + binding.progressBarPage.isVisible = false + return + } + binding.progressBarPage.isVisible = true + + loadJob = scope.launch { + val bitmap = withContext(Dispatchers.Default) { + try { + helper.renderPage(position, targetWidth) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + e.printStackTraceDebug() + null + } + } + if (isActive) { + binding.progressBarPage.isVisible = false + if (bitmap != null) { + binding.root.minimumHeight = 0 + binding.imageViewPage.setImageBitmap(bitmap) + } + } + } + } + + fun recycle() { + loadJob?.cancel() + loadJob = null + binding.imageViewPage.setImageBitmap(null) + binding.progressBarPage.isVisible = false + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/pdf/PdfReaderActivity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/pdf/PdfReaderActivity.kt new file mode 100644 index 0000000000..9f48c37c71 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/pdf/PdfReaderActivity.kt @@ -0,0 +1,176 @@ +package io.github.landwarderer.futon.novel.ui.reader.pdf + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.View +import androidx.activity.viewModels +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.slider.Slider +import dagger.hilt.android.AndroidEntryPoint +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.core.ui.BaseFullscreenActivity +import io.github.landwarderer.futon.core.util.ext.observe +import io.github.landwarderer.futon.core.util.ext.observeEvent +import io.github.landwarderer.futon.databinding.ActivityPdfReaderBinding + +@AndroidEntryPoint +class PdfReaderActivity : + BaseFullscreenActivity(), + Slider.OnSliderTouchListener, + Slider.OnChangeListener { + + private val viewModel by viewModels() + private lateinit var adapter: PdfPageAdapter + private var isUiVisible = true + private var isSliderTracking = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityPdfReaderBinding.inflate(layoutInflater)) + + setupToolbar() + setupRecyclerView() + setupControls() + observeViewModel() + } + + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + viewBinding.appbarTop.setPadding(0, systemBars.top, 0, 0) + viewBinding.cardBottom.setPadding(0, 0, 0, systemBars.bottom) + return insets + } + + private fun setupToolbar() { + setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) + val initialTitle = viewModel.manga.value?.title ?: getString(R.string.loading_) + title = initialTitle + supportActionBar?.title = initialTitle + supportActionBar?.subtitle = null + viewBinding.toolbar.setNavigationOnClickListener { + onBackPressedDispatcher.onBackPressed() + } + } + + private fun setupRecyclerView() { + val layoutManager = LinearLayoutManager(this, RecyclerView.VERTICAL, false) + viewBinding.recyclerView.layoutManager = layoutManager + + adapter = PdfPageAdapter( + scope = lifecycleScope, + getPdfHelper = { viewModel.getPdfHelper() }, + onPageClick = { toggleUiVisibility() }, + ) + viewBinding.recyclerView.adapter = adapter + + viewBinding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + if (!isSliderTracking) { + val pos = layoutManager.findFirstVisibleItemPosition() + if (pos != RecyclerView.NO_POSITION) { + viewModel.onPageChanged(pos) + } + } + } + }) + + val gestureDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() { + override fun onSingleTapConfirmed(e: MotionEvent): Boolean { + toggleUiVisibility() + return true + } + }) + + viewBinding.recyclerView.addOnItemTouchListener(object : RecyclerView.SimpleOnItemTouchListener() { + override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { + gestureDetector.onTouchEvent(e) + return false + } + }) + } + + private fun setupControls() { + viewBinding.buttonPrev.setOnClickListener { viewModel.switchPage(-1) } + viewBinding.buttonNext.setOnClickListener { viewModel.switchPage(1) } + viewBinding.sliderProgress.addOnSliderTouchListener(this) + viewBinding.sliderProgress.addOnChangeListener(this) + } + + @SuppressLint("SetTextI18n") + private fun observeViewModel() { + viewModel.manga.observe(this) { manga -> + if (manga != null) { + title = manga.title + supportActionBar?.title = manga.title + } + } + + viewModel.isLoading.observe(this) { loading -> + viewBinding.progressBar.isVisible = loading + } + + viewModel.pageCount.observe(this) { count -> + adapter.setPageCount(count) + if (count > 0) { + viewBinding.sliderProgress.valueFrom = 1f + viewBinding.sliderProgress.valueTo = count.toFloat() + viewBinding.sliderProgress.stepSize = 1f + updatePageIndicator(viewModel.currentPage.value, count) + } else { + viewBinding.sliderProgress.valueFrom = 1f + viewBinding.sliderProgress.valueTo = 1f + updatePageIndicator(0, 0) + } + } + + viewModel.currentPage.observe(this) { page -> + val count = viewModel.pageCount.value + updatePageIndicator(page, count) + if (!isSliderTracking && count > 0) { + val sliderVal = (page + 1).toFloat().coerceIn(1f, count.toFloat()) + viewBinding.sliderProgress.value = sliderVal + } + } + + viewModel.onJumpToPage.observeEvent(this) { page -> + viewBinding.recyclerView.scrollToPosition(page) + } + } + + private fun updatePageIndicator(page: Int, total: Int) { + val text = if (total > 0) "${page + 1} / $total" else "" + supportActionBar?.subtitle = text + viewBinding.textProgress.text = text + } + + private fun toggleUiVisibility() { + isUiVisible = !isUiVisible + viewBinding.appbarTop.isVisible = isUiVisible + viewBinding.cardBottom.isVisible = isUiVisible + systemUiController.setSystemUiVisible(isUiVisible) + } + + override fun onStartTrackingTouch(slider: Slider) { + isSliderTracking = true + } + + override fun onStopTrackingTouch(slider: Slider) { + isSliderTracking = false + val targetPage = (slider.value.toInt() - 1).coerceAtLeast(0) + viewModel.jumpToPage(targetPage) + } + + override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { + if (fromUser) { + val total = viewModel.pageCount.value + updatePageIndicator(value.toInt() - 1, total) + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/pdf/PdfReaderViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/pdf/PdfReaderViewModel.kt new file mode 100644 index 0000000000..ff0c8db7ad --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/novel/ui/reader/pdf/PdfReaderViewModel.kt @@ -0,0 +1,244 @@ +package io.github.landwarderer.futon.novel.ui.reader.pdf + +import android.content.Context +import android.net.Uri +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.util.longHashCode +import io.github.landwarderer.futon.core.model.LocalNovelSource +import io.github.landwarderer.futon.core.model.parcelable.ParcelableManga +import io.github.landwarderer.futon.core.nav.AppRouter +import io.github.landwarderer.futon.core.nav.ReaderIntent +import io.github.landwarderer.futon.core.parser.MangaDataRepository +import io.github.landwarderer.futon.core.ui.BaseViewModel +import io.github.landwarderer.futon.core.util.ext.MutableEventFlow +import io.github.landwarderer.futon.core.util.ext.call +import io.github.landwarderer.futon.core.util.ext.isFileUri +import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug +import io.github.landwarderer.futon.core.util.ext.toFileOrNull +import io.github.landwarderer.futon.history.data.HistoryRepository +import io.github.landwarderer.futon.novel.data.pdf.PdfRendererHelper +import io.github.landwarderer.futon.reader.ui.ReaderState +import java.io.File +import javax.inject.Inject + +@HiltViewModel +class PdfReaderViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val mangaDataRepository: MangaDataRepository, + private val historyRepository: HistoryRepository, + @ApplicationContext private val context: Context, +) : BaseViewModel() { + + private val initialManga: Manga? = savedStateHandle.get(AppRouter.KEY_MANGA)?.manga + private val mangaId: Long = savedStateHandle.get(AppRouter.KEY_ID) ?: initialManga?.id ?: 0L + private val dataUri: Uri? = savedStateHandle.get(AppRouter.KEY_DATA) + private val initialState: ReaderState? = savedStateHandle.get(ReaderIntent.EXTRA_STATE) + + private val _manga = MutableStateFlow(initialManga) + val manga: StateFlow = _manga.asStateFlow() + + private val _pageCount = MutableStateFlow(0) + val pageCount: StateFlow = _pageCount.asStateFlow() + + private val _currentPage = MutableStateFlow(0) + val currentPage: StateFlow = _currentPage.asStateFlow() + + val onJumpToPage = MutableEventFlow() + + private var pdfHelper: PdfRendererHelper? = null + + init { + loadPdf() + } + + override fun onCleared() { + super.onCleared() + pdfHelper?.close() + pdfHelper = null + } + + fun getPdfHelper(): PdfRendererHelper? = pdfHelper + + fun loadPdf() { + launchLoadingJob(Dispatchers.IO) { + try { + var current = _manga.value + if (current == null && mangaId != 0L) { + current = mangaDataRepository.findMangaById(mangaId, withChapters = true) + _manga.value = current + } + + val helper = when { + current != null -> { + val url = current.url + when { + url.startsWith("file://") -> PdfRendererHelper(File(url.removePrefix("file://"))) + url.startsWith("content://") -> PdfRendererHelper(context, Uri.parse(url)) + File(url).exists() -> PdfRendererHelper(File(url)) + else -> runCatching { PdfRendererHelper(context, Uri.parse(url)) }.getOrNull() + } + } + dataUri != null -> { + if (dataUri.isFileUri()) { + val file = dataUri.toFileOrNull() + if (file != null && file.exists()) { + PdfRendererHelper(file) + } else { + PdfRendererHelper(context, dataUri) + } + } else { + PdfRendererHelper(context, dataUri) + } + } + else -> null + } + + pdfHelper = helper + val count = helper?.pageCount ?: 0 + _pageCount.value = count + + if (current == null && helper != null) { + val docTitle = dataUri?.lastPathSegment?.substringBeforeLast('.') ?: "PDF Document" + val chapters = (0 until count).map { pageIdx -> + MangaChapter( + id = pageIdx.toLong(), + title = "Page ${pageIdx + 1}", + number = (pageIdx + 1).toFloat(), + volume = 0, + url = "pdf://page/$pageIdx", + uploadDate = 0L, + source = LocalNovelSource, + scanlator = null, + branch = null, + ) + } + current = Manga( + id = dataUri?.toString()?.longHashCode() ?: System.currentTimeMillis(), + title = docTitle, + altTitle = null, + url = dataUri?.toString().orEmpty(), + publicUrl = dataUri?.toString().orEmpty(), + rating = -1f, + isNsfw = false, + coverUrl = "", + tags = emptySet(), + state = null, + author = null, + largeCoverUrl = null, + description = null, + chapters = chapters, + source = LocalNovelSource, + ) + _manga.value = current + } else if (current != null && (current.chapters.isNullOrEmpty() || current.chapters?.size != count)) { + val chapters = (0 until count).map { pageIdx -> + MangaChapter( + id = pageIdx.toLong(), + title = "Page ${pageIdx + 1}", + number = (pageIdx + 1).toFloat(), + volume = 0, + url = "pdf://page/$pageIdx", + uploadDate = 0L, + source = current.source, + scanlator = null, + branch = null, + ) + } + current = current.copy(chapters = chapters) + _manga.value = current + } + + val resumePage = when { + initialState?.page != null && initialState.page in 0 until count -> initialState.page + initialState?.chapterId != null && initialState.chapterId.toInt() in 0 until count -> initialState.chapterId.toInt() + current != null -> { + val history = historyRepository.getOne(current) + when { + history?.page != null && history.page in 0 until count -> history.page + history?.chapterId != null && history.chapterId.toInt() in 0 until count -> history.chapterId.toInt() + else -> 0 + } + } + else -> 0 + } + + _currentPage.value = resumePage + if (count > 0) { + onJumpToPage.call(resumePage) + } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + e.printStackTraceDebug() + } + } + } + + fun onPageChanged(page: Int) { + val count = _pageCount.value + if (count <= 0 || page !in 0 until count) return + if (_currentPage.value == page) return + _currentPage.value = page + saveReadingProgress(page) + } + + fun jumpToPage(page: Int) { + val count = _pageCount.value + if (count <= 0) return + val clamped = page.coerceIn(0, count - 1) + _currentPage.value = clamped + saveReadingProgress(clamped) + onJumpToPage.call(clamped) + } + + fun switchPage(delta: Int) { + jumpToPage(_currentPage.value + delta) + } + + private fun saveReadingProgress(page: Int) { + val cur = _manga.value ?: return + val count = _pageCount.value + val percent = if (count > 0) page.toFloat() / count.toFloat() else 0f + viewModelScope.launch(Dispatchers.IO) { + val chaps = cur.chapters ?: (0 until count).map { pageIdx -> + MangaChapter( + id = pageIdx.toLong(), + title = "Page ${pageIdx + 1}", + number = (pageIdx + 1).toFloat(), + volume = 0, + url = "pdf://page/$pageIdx", + uploadDate = 0L, + source = cur.source, + scanlator = null, + branch = null, + ) + } + try { + historyRepository.addOrUpdate( + manga = cur.copy(chapters = chaps), + chapterId = page.toLong(), + page = page, + scroll = 0, + percent = percent, + force = false, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + e.printStackTraceDebug() + } + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/onboarding/model/OnboardingPage.kt b/app/src/main/kotlin/io/github/landwarderer/futon/onboarding/model/OnboardingPage.kt new file mode 100644 index 0000000000..4a62788419 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/onboarding/model/OnboardingPage.kt @@ -0,0 +1,59 @@ +package io.github.landwarderer.futon.onboarding.model + +import androidx.annotation.AttrRes +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import io.github.landwarderer.futon.R +import androidx.appcompat.R as appcompatR +import com.google.android.material.R as materialR + +enum class OnboardingPage( + @DrawableRes val heroIconRes: Int? = null, + @StringRes val badgeRes: Int, + @StringRes val titleRes: Int, + @StringRes val subtitleRes: Int, + @StringRes val feature1Res: Int? = null, + @StringRes val feature2Res: Int? = null, + @StringRes val feature3Res: Int? = null, + val isWarningPage: Boolean = false, + @AttrRes val accentColorAttr: Int = appcompatR.attr.colorPrimary, +) { + WELCOME( + heroIconRes = R.drawable.ic_futon, + badgeRes = R.string.onboarding_page1_badge, + titleRes = R.string.onboarding_page1_title, + subtitleRes = R.string.onboarding_page1_subtitle, + feature1Res = R.string.onboarding_page1_feature1, + feature2Res = R.string.onboarding_page1_feature2, + feature3Res = R.string.onboarding_page1_feature3, + accentColorAttr = appcompatR.attr.colorPrimary, + ), + DUAL_MODES( + heroIconRes = null, + badgeRes = R.string.onboarding_page2_badge, + titleRes = R.string.onboarding_page2_title, + subtitleRes = R.string.onboarding_page2_subtitle, + feature1Res = R.string.onboarding_page2_feature1, + feature2Res = R.string.onboarding_page2_feature2, + feature3Res = R.string.onboarding_page2_feature3, + accentColorAttr = materialR.attr.colorTertiary, + ), + SOURCES_AND_NSFW( + heroIconRes = null, + badgeRes = R.string.onboarding_page3_badge, + titleRes = R.string.onboarding_page3_title, + subtitleRes = R.string.onboarding_page3_subtitle, + isWarningPage = true, + accentColorAttr = appcompatR.attr.colorError, + ), + GET_STARTED( + heroIconRes = null, + badgeRes = R.string.onboarding_page4_badge, + titleRes = R.string.onboarding_page4_title, + subtitleRes = R.string.onboarding_page4_subtitle, + feature1Res = R.string.onboarding_page4_feature1, + feature2Res = R.string.onboarding_page4_feature2, + feature3Res = R.string.onboarding_page4_feature3, + accentColorAttr = appcompatR.attr.colorPrimary, + ), +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/onboarding/ui/OnboardingActivity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/onboarding/ui/OnboardingActivity.kt new file mode 100644 index 0000000000..bf7a695fa9 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/onboarding/ui/OnboardingActivity.kt @@ -0,0 +1,231 @@ +package io.github.landwarderer.futon.onboarding.ui + +import android.animation.ValueAnimator +import android.os.Build +import android.os.Bundle +import android.view.HapticFeedbackConstants +import android.view.View +import androidx.activity.OnBackPressedCallback +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isInvisible +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 +import dagger.hilt.android.AndroidEntryPoint +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.core.nav.router +import io.github.landwarderer.futon.core.prefs.AppSettings +import io.github.landwarderer.futon.core.ui.BaseActivity +import io.github.landwarderer.futon.core.util.ext.resolveDp +import io.github.landwarderer.futon.databinding.ActivityOnboardingBinding +import io.github.landwarderer.futon.onboarding.model.OnboardingPage +import javax.inject.Inject + +@AndroidEntryPoint +class OnboardingActivity : BaseActivity() { + + @Inject + lateinit var settings: AppSettings + + private lateinit var adapter: OnboardingAdapter + private var lastSelectedPosition = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityOnboardingBinding.inflate(layoutInflater)) + + adapter = OnboardingAdapter { + performHapticTap() + router.openExtensionDownloader() + } + + viewBinding.viewPager.apply { + adapter = this@OnboardingActivity.adapter + offscreenPageLimit = 3 + setPageTransformer(OnboardingParallaxPageTransformer()) + registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + super.onPageSelected(position) + if (position != lastSelectedPosition) { + performHapticTick() + lastSelectedPosition = position + } + updateDots(position) + updateButtons(position) + triggerPageEntrance(position) + } + + override fun onPageScrollStateChanged(state: Int) { + super.onPageScrollStateChanged(state) + if (state == ViewPager2.SCROLL_STATE_IDLE) { + triggerSpringSettle(currentItem) + } + } + }) + } + + setupDotClickListeners() + + viewBinding.buttonSkip.setOnClickListener { + performHapticTap(it) + completeOnboarding() + } + + viewBinding.buttonBack.setOnClickListener { + performHapticTap(it) + if (viewBinding.viewPager.currentItem > 0) { + viewBinding.viewPager.currentItem -= 1 + } + } + + viewBinding.buttonNext.setOnClickListener { + val current = viewBinding.viewPager.currentItem + if (current < adapter.itemCount - 1) { + performHapticTap(it) + viewBinding.viewPager.currentItem = current + 1 + } else { + performHapticConfirm(it) + completeOnboarding() + } + } + + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + performHapticTap() + if (viewBinding.viewPager.currentItem > 0) { + viewBinding.viewPager.currentItem -= 1 + } else { + completeOnboarding() + } + } + }) + + updateDots(0) + updateButtons(0) + triggerPageEntrance(0) + } + + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val typeMask = WindowInsetsCompat.Type.systemBars() + val barsInsets = insets.getInsets(typeMask) + + viewBinding.layoutTopBar.updatePadding( + top = barsInsets.top, + left = barsInsets.left, + right = barsInsets.right, + ) + viewBinding.layoutBottomBar.updatePadding( + bottom = barsInsets.bottom, + left = barsInsets.left, + right = barsInsets.right, + ) + + return insets + } + + private fun setupDotClickListeners() { + val dots = listOf( + viewBinding.dot0, + viewBinding.dot1, + viewBinding.dot2, + viewBinding.dot3, + ) + dots.forEachIndexed { index, dot -> + dot.setOnClickListener { + if (viewBinding.viewPager.currentItem != index) { + performHapticTick(it) + viewBinding.viewPager.currentItem = index + } + } + } + } + + private fun updateDots(selectedIndex: Int) { + val dots = listOf( + viewBinding.dot0, + viewBinding.dot1, + viewBinding.dot2, + viewBinding.dot3, + ) + val activeWidth = resources.resolveDp(24) + val inactiveWidth = resources.resolveDp(8) + + dots.forEachIndexed { index, dot -> + val isSelected = index == selectedIndex + val targetWidth = if (isSelected) activeWidth else inactiveWidth + val currentWidth = dot.layoutParams.width + + if (currentWidth != targetWidth) { + val anim = ValueAnimator.ofInt(currentWidth, targetWidth).apply { + duration = 200 + addUpdateListener { animator -> + val value = animator.animatedValue as Int + dot.updateLayoutParams { width = value } + } + } + anim.start() + } + + dot.setBackgroundResource( + if (isSelected) R.drawable.bg_onboarding_dot_active + else R.drawable.bg_onboarding_dot_inactive, + ) + dot.animate() + .alpha(if (isSelected) 1.0f else 0.45f) + .setDuration(200) + .start() + } + } + + private fun updateButtons(position: Int) { + val isFirstPage = position == 0 + val isLastPage = position == OnboardingPage.entries.size - 1 + + viewBinding.buttonBack.isInvisible = isFirstPage + + if (isLastPage) { + viewBinding.buttonNext.setText(R.string.onboarding_get_started) + viewBinding.buttonNext.setIconResource(R.drawable.ic_check) + } else { + viewBinding.buttonNext.setText(R.string.onboarding_next) + viewBinding.buttonNext.setIconResource(R.drawable.ic_arrow_forward) + } + } + + private fun triggerPageEntrance(position: Int) { + viewBinding.viewPager.post { + val recyclerView = viewBinding.viewPager.getChildAt(0) as? RecyclerView ?: return@post + val holder = recyclerView.findViewHolderForAdapterPosition(position) as? OnboardingAdapter.PageViewHolder + holder?.animateEntrance() + } + } + + private fun triggerSpringSettle(position: Int) { + val recyclerView = viewBinding.viewPager.getChildAt(0) as? RecyclerView ?: return + val holder = recyclerView.findViewHolderForAdapterPosition(position) as? OnboardingAdapter.PageViewHolder + holder?.animateSpringSettle() + } + + private fun performHapticTick(view: View = viewBinding.root) { + view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) + } + + private fun performHapticTap(view: View = viewBinding.root) { + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + } + + private fun performHapticConfirm(view: View = viewBinding.root) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + view.performHapticFeedback(HapticFeedbackConstants.CONFIRM) + } else { + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + } + } + + private fun completeOnboarding() { + settings.onboardingVersion = AppSettings.CURRENT_ONBOARDING_VERSION + setResult(RESULT_OK) + finish() + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/onboarding/ui/OnboardingAdapter.kt b/app/src/main/kotlin/io/github/landwarderer/futon/onboarding/ui/OnboardingAdapter.kt new file mode 100644 index 0000000000..96d53720ae --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/onboarding/ui/OnboardingAdapter.kt @@ -0,0 +1,204 @@ +package io.github.landwarderer.futon.onboarding.ui + +import android.animation.ObjectAnimator +import android.animation.ValueAnimator +import android.content.res.ColorStateList +import android.view.LayoutInflater +import android.view.ViewGroup +import android.view.animation.AccelerateDecelerateInterpolator +import android.view.animation.DecelerateInterpolator +import android.view.animation.OvershootInterpolator +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.recyclerview.widget.RecyclerView +import io.github.landwarderer.futon.core.util.ext.getThemeColor +import io.github.landwarderer.futon.core.util.ext.resolveDp +import io.github.landwarderer.futon.databinding.ItemOnboardingPageBinding +import io.github.landwarderer.futon.onboarding.model.OnboardingPage + +class OnboardingAdapter( + private val pages: List = OnboardingPage.entries, + private val onQuickActionClick: () -> Unit, +) : RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PageViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = ItemOnboardingPageBinding.inflate(inflater, parent, false) + return PageViewHolder(binding) + } + + override fun onBindViewHolder(holder: PageViewHolder, position: Int) { + holder.bind(pages[position]) + } + + override fun onViewAttachedToWindow(holder: PageViewHolder) { + super.onViewAttachedToWindow(holder) + if (holder.bindingAdapterPosition == 0) { + holder.animateEntrance() + } else { + holder.startGlowAnimation() + } + } + + override fun onViewDetachedFromWindow(holder: PageViewHolder) { + super.onViewDetachedFromWindow(holder) + holder.stopGlowAnimation() + } + + override fun getItemCount(): Int = pages.size + + inner class PageViewHolder( + private val binding: ItemOnboardingPageBinding, + ) : RecyclerView.ViewHolder(binding.root) { + + private var glowAnimator: ObjectAnimator? = null + + fun bind(page: OnboardingPage) { + val accentColor = itemView.context.getThemeColor(page.accentColorAttr) + val colorState = ColorStateList.valueOf(accentColor) + + val iconRes = page.heroIconRes + if (iconRes != null) { + binding.cardHero.isVisible = true + binding.imageViewHero.setImageResource(iconRes) + binding.imageViewHero.imageTintList = null + binding.textViewBadge.updateLayoutParams { + topMargin = 0 + } + } else { + binding.cardHero.isVisible = false + binding.textViewBadge.updateLayoutParams { + topMargin = itemView.resources.resolveDp(28) + } + } + + binding.textViewBadge.setText(page.badgeRes) + binding.textViewBadge.setTextColor(accentColor) + binding.iconFeature1.imageTintList = colorState + binding.iconFeature2.imageTintList = colorState + binding.iconFeature3.imageTintList = colorState + binding.textTitle.setText(page.titleRes) + binding.textSubtitle.setText(page.subtitleRes) + + if (page.isWarningPage) { + binding.layoutFeature1.isVisible = false + binding.layoutFeature2.isVisible = false + binding.layoutFeature3.isVisible = false + binding.layoutWarningNotice.isVisible = true + binding.buttonQuickAction.setOnClickListener { + onQuickActionClick() + } + } else { + binding.layoutWarningNotice.isVisible = false + binding.layoutFeature1.isVisible = page.feature1Res != null + page.feature1Res?.let { binding.textFeature1.setText(it) } + + binding.layoutFeature2.isVisible = page.feature2Res != null + page.feature2Res?.let { binding.textFeature2.setText(it) } + + binding.layoutFeature3.isVisible = page.feature3Res != null + page.feature3Res?.let { binding.textFeature3.setText(it) } + } + } + + fun animateEntrance() { + val context = itemView.context + val density = context.resources.displayMetrics.density + val dp20 = 20f * density + val dp28 = 28f * density + + // 1. Hero Card pop and spring bounce (if visible) + if (binding.cardHero.isVisible) { + binding.cardHero.scaleX = 0.65f + binding.cardHero.scaleY = 0.65f + binding.cardHero.alpha = 0f + binding.cardHero.animate() + .scaleX(1.0f) + .scaleY(1.0f) + .alpha(1.0f) + .setDuration(450) + .setInterpolator(OvershootInterpolator(1.3f)) + .start() + startGlowAnimation() + } else { + stopGlowAnimation() + } + + // 2. Badge & Title slide-up cascade + binding.textViewBadge.translationY = dp20 + binding.textViewBadge.alpha = 0f + binding.textViewBadge.animate() + .translationY(0f) + .alpha(1.0f) + .setStartDelay(if (binding.cardHero.isVisible) 100 else 0) + .setDuration(350) + .setInterpolator(DecelerateInterpolator()) + .start() + + binding.textTitle.translationY = dp20 + binding.textTitle.alpha = 0f + binding.textTitle.animate() + .translationY(0f) + .alpha(1.0f) + .setStartDelay(if (binding.cardHero.isVisible) 140 else 40) + .setDuration(350) + .setInterpolator(DecelerateInterpolator()) + .start() + + // 3. Subtitle slide-up + binding.textSubtitle.translationY = dp20 + binding.textSubtitle.alpha = 0f + binding.textSubtitle.animate() + .translationY(0f) + .alpha(1.0f) + .setStartDelay(if (binding.cardHero.isVisible) 180 else 80) + .setDuration(350) + .setInterpolator(DecelerateInterpolator()) + .start() + + // 4. Content Card / Warning Box slide-up + binding.cardContent.translationY = dp28 + binding.cardContent.alpha = 0f + binding.cardContent.animate() + .translationY(0f) + .alpha(1.0f) + .setStartDelay(if (binding.cardHero.isVisible) 240 else 120) + .setDuration(400) + .setInterpolator(DecelerateInterpolator()) + .start() + } + + fun animateSpringSettle() { + if (binding.cardHero.isVisible) { + binding.cardHero.animate() + .scaleX(1.0f) + .scaleY(1.0f) + .rotation(0f) + .rotationY(0f) + .translationX(0f) + .setDuration(350) + .setInterpolator(OvershootInterpolator(1.3f)) + .start() + } + } + + fun startGlowAnimation() { + if (!binding.cardHero.isVisible) return + if (glowAnimator == null || glowAnimator?.isRunning != true) { + glowAnimator?.cancel() + glowAnimator = ObjectAnimator.ofFloat(binding.layoutHeroGlow, "alpha", 0.7f, 1.0f).apply { + duration = 1800 + repeatMode = ValueAnimator.REVERSE + repeatCount = ValueAnimator.INFINITE + interpolator = AccelerateDecelerateInterpolator() + start() + } + } + } + + fun stopGlowAnimation() { + glowAnimator?.cancel() + glowAnimator = null + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/onboarding/ui/OnboardingParallaxPageTransformer.kt b/app/src/main/kotlin/io/github/landwarderer/futon/onboarding/ui/OnboardingParallaxPageTransformer.kt new file mode 100644 index 0000000000..8b31554f1b --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/onboarding/ui/OnboardingParallaxPageTransformer.kt @@ -0,0 +1,63 @@ +package io.github.landwarderer.futon.onboarding.ui + +import android.view.View +import androidx.viewpager2.widget.ViewPager2 +import io.github.landwarderer.futon.R +import kotlin.math.abs +import kotlin.math.min + +class OnboardingParallaxPageTransformer : ViewPager2.PageTransformer { + + override fun transformPage(page: View, position: Float) { + val width = page.width.toFloat() + if (width <= 0f) return + + val effectiveWidth = min(width, 1000f) + + when { + position < -1f -> { + page.alpha = 0f + } + position <= 1f -> { + val absPos = abs(position) + page.cameraDistance = 24000f + + // Gentle page-level scale and alpha transition + val pageScale = 0.94f + (1f - absPos) * 0.06f + page.scaleX = pageScale + page.scaleY = pageScale + page.alpha = 0.4f + (1f - absPos) * 0.6f + + val cardHero = page.findViewById(R.id.cardHero) + val textTitle = page.findViewById(R.id.textTitle) + val textSubtitle = page.findViewById(R.id.textSubtitle) + val cardContent = page.findViewById(R.id.cardContent) + + // Counter-scroll translation: creates deep multi-plane parallax depth + if (cardHero?.visibility == View.VISIBLE) { + cardHero.translationX = -position * (effectiveWidth * 0.45f) + cardHero.cameraDistance = 16000f + cardHero.rotationY = -position * 20f + cardHero.rotation = -position * 8f + val heroScale = 0.88f + (1f - absPos) * 0.12f + cardHero.scaleX = heroScale + cardHero.scaleY = heroScale + } + + textTitle?.translationX = -position * (effectiveWidth * 0.25f) + textSubtitle?.translationX = -position * (effectiveWidth * 0.16f) + cardContent?.translationX = -position * (effectiveWidth * 0.08f) + + // Content card subtle scaling + cardContent?.let { content -> + val contentScale = 0.94f + (1f - absPos) * 0.06f + content.scaleX = contentScale + content.scaleY = contentScale + } + } + else -> { + page.alpha = 0f + } + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/search/domain/MangaSearchRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/search/domain/MangaSearchRepository.kt index 2928d568ef..4f92da9afd 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/search/domain/MangaSearchRepository.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/search/domain/MangaSearchRepository.kt @@ -15,6 +15,9 @@ import io.github.landwarderer.futon.core.db.entity.toManga import io.github.landwarderer.futon.core.db.entity.toMangaTag import io.github.landwarderer.futon.core.db.entity.toMangaTagsList import io.github.landwarderer.futon.core.prefs.AppSettings +import io.github.landwarderer.futon.core.model.getTitle +import io.github.landwarderer.futon.core.model.isNsfw +import io.github.landwarderer.futon.novel.data.source.NovelParserSource import io.github.landwarderer.futon.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.Manga @@ -133,9 +136,10 @@ class MangaSearchRepository @Inject constructor( return emptyList() } val skipNsfw = settings.isNsfwContentDisabled - val sources = sourcesRepository.allMangaSources + val allSources: List = sourcesRepository.allMangaSources.toList() + NovelParserSource.entries + val sources = allSources .filter { x -> - (x.contentType != ContentType.HENTAI || !skipNsfw) && x.title.contains(query, ignoreCase = true) + (!x.isNsfw() || !skipNsfw) && x.getTitle(context).contains(query, ignoreCase = true) } return if (limit == 0) { sources diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/MangaListActivity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/MangaListActivity.kt index 36e29113ca..8459be05db 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/MangaListActivity.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/MangaListActivity.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.model.LocalMangaSource +import io.github.landwarderer.futon.core.model.LocalNovelSource import io.github.landwarderer.futon.core.model.MangaSource import io.github.landwarderer.futon.core.model.getSummary import io.github.landwarderer.futon.core.model.getTitle @@ -38,6 +39,7 @@ import io.github.landwarderer.futon.core.util.ext.getSerializableExtraCompat import io.github.landwarderer.futon.core.util.ext.getThemeColor import io.github.landwarderer.futon.core.util.ext.observe import io.github.landwarderer.futon.core.util.ext.setTextAndVisible +import io.github.landwarderer.futon.novel.ui.local.NovelLocalListFragment import io.github.landwarderer.futon.core.util.ext.start import io.github.landwarderer.futon.databinding.ActivityMangaListBinding import io.github.landwarderer.futon.filter.ui.FilterCoordinator @@ -146,6 +148,8 @@ class MangaListActivity : setReorderingAllowed(true) val fragment = if (source == LocalMangaSource) { LocalListFragment() + } else if (source == LocalNovelSource) { + NovelLocalListFragment.newInstance() } else { RemoteListFragment.newInstance(source) } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/multi/SearchViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/multi/SearchViewModel.kt index 25b89bd6e2..e8496f1814 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/multi/SearchViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/multi/SearchViewModel.kt @@ -7,7 +7,10 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.model.LocalMangaSource +import io.github.landwarderer.futon.core.model.LocalNovelSource import io.github.landwarderer.futon.core.model.UnknownMangaSource +import io.github.landwarderer.futon.core.model.isNovel +import io.github.landwarderer.futon.core.model.isNovelSource import io.github.landwarderer.futon.core.nav.AppRouter import io.github.landwarderer.futon.core.parser.MangaDataRepository import io.github.landwarderer.futon.core.prefs.AppSettings @@ -145,10 +148,12 @@ class SearchViewModel @Inject constructor( searchJob = launchLoadingJob(Dispatchers.IO) { includeDisabledSources.value = true prevJob?.join() + val isNovel = settings.appMode.isNovel val sources = if (pinnedOnly.value) { emptyList() } else { sourcesRepository.getDisabledSources() + .filter { it.isNovelSource == isNovel } .sortedByDescending { it.priority() } } val semaphore = Semaphore(MAX_PARALLELISM) @@ -166,13 +171,14 @@ class SearchViewModel @Inject constructor( val prevJob = searchJob searchJob = launchLoadingJob(Dispatchers.IO) { prevJob?.cancelAndJoin() + val isNovel = settings.appMode.isNovel appendResult(searchHistory()) appendResult(searchFavorites()) appendResult(searchLocal()) val sources = if (pinnedOnly.value) { - sourcesRepository.getPinnedSources().toList() + sourcesRepository.getPinnedSources().filter { it.isNovelSource == isNovel } } else { - sourcesRepository.getEnabledSources() + sourcesRepository.getEnabledSources().filter { it.isNovelSource == isNovel } } val semaphore = Semaphore(MAX_PARALLELISM) sources.map { source -> @@ -192,7 +198,8 @@ class SearchViewModel @Inject constructor( searchHelper(query, kind) }.fold( onSuccess = { result -> - val filteredManga = result?.manga?.filterBlacklistedTags() + val isNovel = settings.appMode.isNovel + val filteredManga = result?.manga?.filter { it.isNovel == isNovel }?.filterBlacklistedTags() if (filteredManga.isNullOrEmpty()) { null } else { @@ -224,7 +231,8 @@ class SearchViewModel @Inject constructor( historyRepository.search(query, kind, Int.MAX_VALUE) }.fold( onSuccess = { result -> - val filteredManga = result.filterBlacklistedTags() + val isNovel = settings.appMode.isNovel + val filteredManga = result.filter { it.isNovel == isNovel }.filterBlacklistedTags() if (filteredManga.isNotEmpty()) { SearchResultsListModel( titleResId = R.string.history, @@ -254,7 +262,8 @@ class SearchViewModel @Inject constructor( favouritesRepository.search(query, kind, Int.MAX_VALUE) }.fold( onSuccess = { result -> - val filteredManga = result.filterBlacklistedTags() + val isNovel = settings.appMode.isNovel + val filteredManga = result.filter { it.isNovel == isNovel }.filterBlacklistedTags() if (filteredManga.isNotEmpty()) { SearchResultsListModel( titleResId = R.string.favourites, @@ -285,14 +294,18 @@ class SearchViewModel @Inject constructor( ) private suspend fun searchLocal(): SearchResultsListModel? = runCatchingCancellable { - searchHelperFactory.create(LocalMangaSource).invoke(query, kind) + val isNovel = settings.appMode.isNovel + val localSource = if (isNovel) LocalNovelSource else LocalMangaSource + searchHelperFactory.create(localSource).invoke(query, kind) }.fold( onSuccess = { result -> - val filteredManga = result?.manga?.filterBlacklistedTags() + val isNovel = settings.appMode.isNovel + val localSource = if (isNovel) LocalNovelSource else LocalMangaSource + val filteredManga = result?.manga?.filter { it.isNovel == isNovel }?.filterBlacklistedTags() if (!filteredManga.isNullOrEmpty()) { SearchResultsListModel( titleResId = 0, - source = LocalMangaSource, + source = localSource, list = mangaListMapper.toListModelList( manga = filteredManga, mode = ListMode.GRID, @@ -307,9 +320,11 @@ class SearchViewModel @Inject constructor( } }, onFailure = { error -> + val isNovel = settings.appMode.isNovel + val localSource = if (isNovel) LocalNovelSource else LocalMangaSource SearchResultsListModel( titleResId = 0, - source = LocalMangaSource, + source = localSource, list = emptyList(), error = error, listFilter = null, diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/SearchSuggestionViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/SearchSuggestionViewModel.kt index 272bb2965f..9f13a8081a 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/SearchSuggestionViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/SearchSuggestionViewModel.kt @@ -2,6 +2,8 @@ package io.github.landwarderer.futon.search.ui.suggestion import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.landwarderer.futon.core.model.isNovel +import io.github.landwarderer.futon.core.model.isNovelSource import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.core.prefs.SearchSuggestionType import io.github.landwarderer.futon.core.prefs.observeAsFlow @@ -59,9 +61,10 @@ class SearchSuggestionViewModel @Inject constructor( query.debounce(DEBOUNCE_TIMEOUT), sourcesRepository.observeEnabledSources().map { it.mapToSet { x -> x.name } }, settings.observeAsFlow(AppSettings.KEY_SEARCH_SUGGESTION_TYPES) { searchSuggestionTypes }, + settings.observeAppMode(), invalidationTrigger, ) - { a, b, c, _ -> + { a, b, c, _, _ -> Triple(a, b, c) }.mapLatest { (searchQuery, enabledSources, types) -> buildSearchSuggestion(searchQuery, enabledSources, types) @@ -183,7 +186,9 @@ class SearchSuggestionViewModel @Inject constructor( } private suspend fun getManga(searchQuery: String): List = runCatchingCancellable { + val isNovel = settings.appMode.isNovel val manga = repository.getMangaSuggestion(searchQuery, MAX_MANGA_ITEMS, null) + .filter { it.isNovel == isNovel } if (manga.isEmpty()) { emptyList() } else { @@ -196,7 +201,9 @@ class SearchSuggestionViewModel @Inject constructor( private fun getSources(searchQuery: String, enabledSources: Set): List = runCatchingCancellable { + val isNovel = settings.appMode.isNovel repository.getSourcesSuggestion(searchQuery, MAX_SOURCES_ITEMS) + .filter { it.isNovelSource == isNovel } .map { SearchSuggestionItem.Source(it, it.name in enabledSources) } }.getOrElse { e -> e.printStackTraceDebug("SearchSuggestionViewModel::getSources") @@ -205,7 +212,9 @@ class SearchSuggestionViewModel @Inject constructor( private suspend fun getRecentSources(searchQuery: String): List = if (searchQuery.isEmpty()) { runCatchingCancellable { + val isNovel = settings.appMode.isNovel repository.getSourcesSuggestion(MAX_SOURCES_TIPS_ITEMS) + .filter { it.isNovelSource == isNovel } .map { SearchSuggestionItem.SourceTip(it) } }.getOrElse { e -> e.printStackTraceDebug("SearchSuggestionViewModel::getRecentSources") diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/AppearanceSettingsFragment.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/AppearanceSettingsFragment.kt index c2e9e0f49c..04aa5fbbf7 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/AppearanceSettingsFragment.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/AppearanceSettingsFragment.kt @@ -16,6 +16,7 @@ import dagger.hilt.android.AndroidEntryPoint import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.os.AppShortcutManager import io.github.landwarderer.futon.core.prefs.AppSettings +import io.github.landwarderer.futon.core.prefs.HomeBackgroundFont import io.github.landwarderer.futon.core.prefs.ListMode import io.github.landwarderer.futon.core.prefs.ProgressIndicatorMode import io.github.landwarderer.futon.core.prefs.ScreenshotsPolicy @@ -61,6 +62,10 @@ class AppearanceSettingsFragment : entryValues = ProgressIndicatorMode.entries.names() setDefaultValueCompat(ProgressIndicatorMode.PERCENT_READ.name) } + findPreference(AppSettings.KEY_HOME_BACKGROUND_FONT)?.run { + entryValues = HomeBackgroundFont.entries.names() + setDefaultValueCompat(HomeBackgroundFont.DEFAULT.name) + } findPreference(AppSettings.KEY_APP_LOCALE)?.run { initLocalePicker(this) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/about/AboutSettingsFragment.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/about/AboutSettingsFragment.kt index 487c596e00..855c705a17 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/about/AboutSettingsFragment.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/about/AboutSettingsFragment.kt @@ -81,6 +81,11 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) { true } + AppSettings.KEY_ABOUT_ONBOARDING -> { + router.openOnboarding() + true + } + AppSettings.KEY_LINK_WEBLATE -> { openLink(R.string.url_weblate, preference.title) true diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourcesCatalogViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourcesCatalogViewModel.kt index 565b63a6cd..1a95d29788 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourcesCatalogViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourcesCatalogViewModel.kt @@ -41,6 +41,7 @@ class SourcesCatalogViewModel @Inject constructor( val onActionDone = MutableEventFlow() val locales: Set = buildSet { repository.allMangaSources.forEach { add(it.locale) } + io.github.landwarderer.futon.novel.data.source.NovelParserSource.entries.forEach { add(it.locale) } mihonExtensionManager.getMihonMangaSources().forEach { add(it.locale) } add(null) } @@ -143,6 +144,7 @@ class SourcesCatalogViewModel @Inject constructor( private fun getContentTypes(isNsfwDisabled: Boolean): List { val result = buildSet { repository.allMangaSources.forEach { add(it.contentType) } + add(ContentType.NOVEL) mihonExtensionManager.getMihonMangaSources().forEach { when(it.contentType) { io.github.landwarderer.futon.mihon.parsers.model.ContentType.MANGA -> add(ContentType.MANGA) diff --git a/app/src/main/kotlin/okhttp3/brotli/Brotli.kt b/app/src/main/kotlin/okhttp3/brotli/Brotli.kt deleted file mode 100644 index 04c76953cb..0000000000 --- a/app/src/main/kotlin/okhttp3/brotli/Brotli.kt +++ /dev/null @@ -1,6 +0,0 @@ -package okhttp3.brotli - -/** - * Stub object for Brotli compression algorithm. - */ -object Brotli diff --git a/app/src/main/res/drawable/bg_onboarding_dot_active.xml b/app/src/main/res/drawable/bg_onboarding_dot_active.xml new file mode 100644 index 0000000000..01e6e589ef --- /dev/null +++ b/app/src/main/res/drawable/bg_onboarding_dot_active.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_onboarding_dot_inactive.xml b/app/src/main/res/drawable/bg_onboarding_dot_inactive.xml new file mode 100644 index 0000000000..87dc90c7b6 --- /dev/null +++ b/app/src/main/res/drawable/bg_onboarding_dot_inactive.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/bg_onboarding_hero_glow.xml b/app/src/main/res/drawable/bg_onboarding_hero_glow.xml new file mode 100644 index 0000000000..593524a76b --- /dev/null +++ b/app/src/main/res/drawable/bg_onboarding_hero_glow.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_spotlight_halo.xml b/app/src/main/res/drawable/bg_spotlight_halo.xml new file mode 100644 index 0000000000..9092940d80 --- /dev/null +++ b/app/src/main/res/drawable/bg_spotlight_halo.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/drawable/bg_spotlight_ring.xml b/app/src/main/res/drawable/bg_spotlight_ring.xml new file mode 100644 index 0000000000..23b3dc8701 --- /dev/null +++ b/app/src/main/res/drawable/bg_spotlight_ring.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_extension_puzzle.xml b/app/src/main/res/drawable/ic_extension_puzzle.xml new file mode 100644 index 0000000000..1798031a4d --- /dev/null +++ b/app/src/main/res/drawable/ic_extension_puzzle.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_font.xml b/app/src/main/res/drawable/ic_font.xml new file mode 100644 index 0000000000..82ae69f61b --- /dev/null +++ b/app/src/main/res/drawable/ic_font.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_futon.png b/app/src/main/res/drawable/ic_futon.png new file mode 100644 index 0000000000..c708013476 Binary files /dev/null and b/app/src/main/res/drawable/ic_futon.png differ diff --git a/app/src/main/res/drawable/ic_novel.xml b/app/src/main/res/drawable/ic_novel.xml new file mode 100644 index 0000000000..16acadabae --- /dev/null +++ b/app/src/main/res/drawable/ic_novel.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout-w600dp-land/activity_main.xml b/app/src/main/res/layout-w600dp-land/activity_main.xml index 6fd6364973..3977751660 100644 --- a/app/src/main/res/layout-w600dp-land/activity_main.xml +++ b/app/src/main/res/layout-w600dp-land/activity_main.xml @@ -27,6 +27,22 @@ android:layout_width="match_parent" android:layout_height="match_parent"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_onboarding.xml b/app/src/main/res/layout/activity_onboarding.xml new file mode 100644 index 0000000000..fcc7794ab7 --- /dev/null +++ b/app/src/main/res/layout/activity_onboarding.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_pdf_reader.xml b/app/src/main/res/layout/activity_pdf_reader.xml new file mode 100644 index 0000000000..31c0833556 --- /dev/null +++ b/app/src/main/res/layout/activity_pdf_reader.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_onboarding_page.xml b/app/src/main/res/layout/item_onboarding_page.xml new file mode 100644 index 0000000000..f7e90c02aa --- /dev/null +++ b/app/src/main/res/layout/item_onboarding_page.xml @@ -0,0 +1,296 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_pdf_page.xml b/app/src/main/res/layout/item_pdf_page.xml new file mode 100644 index 0000000000..93e5496859 --- /dev/null +++ b/app/src/main/res/layout/item_pdf_page.xml @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/app/src/main/res/layout/layout_mode_switcher_spotlight.xml b/app/src/main/res/layout/layout_mode_switcher_spotlight.xml new file mode 100644 index 0000000000..0507ed23ed --- /dev/null +++ b/app/src/main/res/layout/layout_mode_switcher_spotlight.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/sheet_novel_reader_config.xml b/app/src/main/res/layout/sheet_novel_reader_config.xml new file mode 100644 index 0000000000..e2b60cc573 --- /dev/null +++ b/app/src/main/res/layout/sheet_novel_reader_config.xml @@ -0,0 +1,236 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/opt_main.xml b/app/src/main/res/menu/opt_main.xml index 543e808e8a..eb90b8bd18 100644 --- a/app/src/main/res/menu/opt_main.xml +++ b/app/src/main/res/menu/opt_main.xml @@ -12,6 +12,13 @@ android:visible="false" app:showAsAction="ifRoom" /> + + KEIYOUSHI + + @string/home_background_font_light_thin + @string/home_background_font_bold + @string/home_background_font_extra_bold + @string/home_background_font_none + + + LIGHT_THIN + BOLD + EXTRA_BOLD + NONE + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9f41dbfd31..6ecca0ef00 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -965,4 +965,75 @@ Connect to a power source to start downloads Storage quota cannot be lower than current usage Storage quota reached. Downloads paused. + Light Novels + Dark + Light + OLED + Sepia + Custom + Font Size + Line Spacing + Paragraph Spacing + Page Margins + Text Color + Background Color + Text Alignment + Left + Justify + Reading Mode + Continuous Scroll + Page Flip + Import Novel (.epub, .pdf) + Add Folder + Select Files + No light novels found + Light Novel Sources + Font Family + System Default + Serif + Sans-Serif + Monospace + Custom Font (.ttf/.otf) + Previous chapter + Change Home Screen Background Font + Light Thin + Bold + Extra Bold + None + + + Skip + Next + Back + Get Started + FREE & OPEN SOURCE + Welcome to Futon + Your modern, unified manga and light novel reader for Android. + Ad-free, fast, and battery-friendly reading + Offline downloads & background chapter updates + Cloud backup and reading progress sync + MANGA & LIGHT NOVELS + Dual Reading Modes + Switch effortlessly between graphic manga and rich text light novels. + Manga reader with Webtoon, paged, and continuous scrolling + Novel reader with customizable typography, spacing, and fonts + Seamless one-tap mode switching right on your home screen + SOURCES & EXTENSIONS + Sources & Repositories + Connect to diverse online catalogs via Tachiyomi & Mihon extensions. + NSFW is Disabled by Default + Adult (18+) content sources are disabled by default for privacy and safety. You can adjust content filters anytime in Settings. + Tachiyomi / Mihon Repositories + Add third-party extension repository URLs (such as Keiyoushi) in Settings → Sources to browse online catalogs. + Configure Extension Repos Now + READY TO READ + You\'re All Set! + Next, you can choose your preferred languages and content categories. + Filter sources by your spoken languages + Organize favorites into custom categories + Tap Get Started below to begin your journey + Replay Intro Walkthrough + Learn how to use Futon, dual modes, and source extensions + Use this to switch between manga and novels + Got it diff --git a/app/src/main/res/xml/pref_about.xml b/app/src/main/res/xml/pref_about.xml index 44ccb4d063..3eeec3ca44 100644 --- a/app/src/main/res/xml/pref_about.xml +++ b/app/src/main/res/xml/pref_about.xml @@ -8,6 +8,12 @@ android:key="app_version" android:persistent="false" /> + + + + diff --git a/app/src/test/kotlin/io/github/landwarderer/futon/core/prefs/HomeBackgroundFontTest.kt b/app/src/test/kotlin/io/github/landwarderer/futon/core/prefs/HomeBackgroundFontTest.kt new file mode 100644 index 0000000000..52748692c9 --- /dev/null +++ b/app/src/test/kotlin/io/github/landwarderer/futon/core/prefs/HomeBackgroundFontTest.kt @@ -0,0 +1,57 @@ +package io.github.landwarderer.futon.core.prefs + +import android.content.Context +import android.content.SharedPreferences +import io.github.landwarderer.futon.core.ui.util.FontCache +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` + +class HomeBackgroundFontTest { + + private lateinit var sharedPreferences: SharedPreferences + private lateinit var context: Context + + @Before + fun setUp() { + sharedPreferences = mock(SharedPreferences::class.java) + context = mock(Context::class.java) + } + + @Test + fun testEnumValuesAndDefaults() { + assertEquals(HomeBackgroundFont.BOLD, HomeBackgroundFont.DEFAULT) + assertEquals("fonts/MojiwTegami-Standard.ttf", HomeBackgroundFont.LIGHT_THIN.fontAssetPath) + assertEquals("fonts/ShimbashiGothic-Regular.otf", HomeBackgroundFont.BOLD.fontAssetPath) + assertEquals("fonts/GenEiKiwamiGo.ttf", HomeBackgroundFont.EXTRA_BOLD.fontAssetPath) + assertNull(HomeBackgroundFont.NONE.fontAssetPath) + } + + @Test + fun testAppSettingsReadDefault() { + `when`(sharedPreferences.getString(AppSettings.KEY_HOME_BACKGROUND_FONT, null)) + .thenReturn(null) + val settings = AppSettings(context, sharedPreferences) + assertEquals(HomeBackgroundFont.BOLD, settings.homeBackgroundFont) + } + + @Test + fun testAppSettingsReadCustom() { + `when`(sharedPreferences.getString(AppSettings.KEY_HOME_BACKGROUND_FONT, null)) + .thenReturn(HomeBackgroundFont.BOLD.name) + val settings = AppSettings(context, sharedPreferences) + assertEquals(HomeBackgroundFont.BOLD, settings.homeBackgroundFont) + } + + @Test + fun testFontCacheSafelyHandlesMissingAsset() { + val assetManager = mock(android.content.res.AssetManager::class.java) + `when`(context.assets).thenReturn(assetManager) + val result = FontCache.getTypeface(context, "non_existent_font.ttf") + assertNull("Missing asset should safely return null without throwing", result) + } +} diff --git a/app/src/test/kotlin/io/github/landwarderer/futon/core/prefs/NovelAppSettingsTest.kt b/app/src/test/kotlin/io/github/landwarderer/futon/core/prefs/NovelAppSettingsTest.kt new file mode 100644 index 0000000000..f4197ff4ad --- /dev/null +++ b/app/src/test/kotlin/io/github/landwarderer/futon/core/prefs/NovelAppSettingsTest.kt @@ -0,0 +1,73 @@ +package io.github.landwarderer.futon.core.prefs + +import android.content.Context +import android.content.SharedPreferences +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.Mockito.any +import org.mockito.Mockito.anySet +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import java.io.File + +class NovelAppSettingsTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var sharedPreferences: SharedPreferences + private lateinit var context: Context + + @Before + fun setUp() { + sharedPreferences = mock(SharedPreferences::class.java) + context = mock(Context::class.java) + } + + @Test + fun testUserSpecifiedNovelDirectoriesDefaultEmpty() { + `when`(sharedPreferences.getStringSet(AppSettings.KEY_LOCAL_NOVEL_DIRS, emptySet())) + .thenReturn(emptySet()) + val settings = AppSettings(context, sharedPreferences) + assertTrue(settings.userSpecifiedNovelDirectories.isEmpty()) + } + + @Test + fun testUserSpecifiedNovelDirectoriesFiltersUnreadable() { + val readableDir = tempFolder.newFolder("novel_folder") + val nonExistentDir = File(tempFolder.root, "non_existent") + + `when`(sharedPreferences.getStringSet(AppSettings.KEY_LOCAL_NOVEL_DIRS, emptySet())) + .thenReturn(setOf(readableDir.absolutePath, nonExistentDir.absolutePath)) + + val settings = AppSettings(context, sharedPreferences) + val dirs = settings.userSpecifiedNovelDirectories + + assertEquals(1, dirs.size) + assertTrue(dirs.contains(readableDir)) + } + + @Test + fun testSetUserSpecifiedNovelDirectories() { + val editor = mock(SharedPreferences.Editor::class.java) + `when`(sharedPreferences.edit()).thenReturn(editor) + `when`(editor.putStringSet(any(), anySet())).thenReturn(editor) + + val settings = AppSettings(context, sharedPreferences) + val dir1 = File("/storage/novels1") + val dir2 = File("/storage/novels2") + + settings.userSpecifiedNovelDirectories = setOf(dir1, dir2) + + verify(editor).putStringSet( + AppSettings.KEY_LOCAL_NOVEL_DIRS, + setOf(dir1.absolutePath, dir2.absolutePath), + ) + verify(editor).apply() + } +} diff --git a/app/src/test/kotlin/io/github/landwarderer/futon/novel/data/NovelStorageManagerTest.kt b/app/src/test/kotlin/io/github/landwarderer/futon/novel/data/NovelStorageManagerTest.kt new file mode 100644 index 0000000000..d83f7838ce --- /dev/null +++ b/app/src/test/kotlin/io/github/landwarderer/futon/novel/data/NovelStorageManagerTest.kt @@ -0,0 +1,88 @@ +package io.github.landwarderer.futon.novel.data + +import android.content.Context +import io.github.landwarderer.futon.core.parser.MangaDataRepository +import io.github.landwarderer.futon.core.prefs.AppSettings +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import java.io.File + +class NovelStorageManagerTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var context: Context + private lateinit var mangaRepository: MangaDataRepository + private lateinit var settings: AppSettings + private lateinit var manager: NovelStorageManager + + @Before + fun setUp() { + context = mock(Context::class.java) + mangaRepository = mock(MangaDataRepository::class.java) + settings = mock(AppSettings::class.java) + + val appFilesDir = tempFolder.newFolder("app_files") + `when`(context.getExternalFilesDir(null)).thenReturn(appFilesDir) + + manager = NovelStorageManager(context, mangaRepository, settings) + } + + @Test + fun testIsSupportedFile() { + assertTrue(NovelStorageManager.isSupportedFile(File("book.epub"))) + assertTrue(NovelStorageManager.isSupportedFile(File("doc.PDF"))) + assertTrue(NovelStorageManager.isSupportedFile(File("sample.Epub"))) + assertTrue(NovelStorageManager.isSupportedFile(File("document.pdf"))) + assertFalse(NovelStorageManager.isSupportedFile(File("image.png"))) + assertFalse(NovelStorageManager.isSupportedFile(File("text.txt"))) + assertFalse(NovelStorageManager.isSupportedFile(File("archive.zip"))) + assertFalse(NovelStorageManager.isSupportedFile(File("no_extension"))) + } + + @Test + fun testGetNovelDirectoriesDiscovery() { + val userDir1 = tempFolder.newFolder("user_novels_1") + val userDir2 = tempFolder.newFolder("user_novels_2") + `when`(settings.userSpecifiedNovelDirectories).thenReturn(setOf(userDir1, userDir2)) + + val directories = manager.getNovelDirectories() + + val expectedNovelsDir = manager.novelsDir + assertEquals(3, directories.size) + assertEquals(expectedNovelsDir, directories[0]) + assertTrue(directories.contains(userDir1)) + assertTrue(directories.contains(userDir2)) + } + + @Test + fun testAddNovelDirectory() { + val existingDir = tempFolder.newFolder("existing_dir") + val newDir = tempFolder.newFolder("new_dir") + `when`(settings.userSpecifiedNovelDirectories).thenReturn(setOf(existingDir)) + + manager.addNovelDirectory(newDir) + + verify(settings).userSpecifiedNovelDirectories = setOf(existingDir, newDir) + } + + @Test + fun testRemoveNovelDirectory() { + val dir1 = tempFolder.newFolder("dir_1") + val dir2 = tempFolder.newFolder("dir_2") + `when`(settings.userSpecifiedNovelDirectories).thenReturn(setOf(dir1, dir2)) + + manager.removeNovelDirectory(dir1) + + verify(settings).userSpecifiedNovelDirectories = setOf(dir2) + } +} diff --git a/app/src/test/kotlin/io/github/landwarderer/futon/novel/data/pdf/PdfRendererHelperTest.kt b/app/src/test/kotlin/io/github/landwarderer/futon/novel/data/pdf/PdfRendererHelperTest.kt new file mode 100644 index 0000000000..5e065966c5 --- /dev/null +++ b/app/src/test/kotlin/io/github/landwarderer/futon/novel/data/pdf/PdfRendererHelperTest.kt @@ -0,0 +1,40 @@ +package io.github.landwarderer.futon.novel.data.pdf + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import java.io.File + +class PdfRendererHelperTest { + + @Test + fun testInvalidFileGracefulHandling() { + val helper = PdfRendererHelper(File("/invalid/non_existent.pdf")) + assertEquals(0, helper.pageCount) + assertFalse(helper.isValid) + assertNull(helper.renderPage(0)) + assertNull(helper.renderThumbnail(0)) + helper.close() + } + + @Test + fun testUriConstructorGracefulHandling() { + val context = mock(Context::class.java) + val resolver = mock(ContentResolver::class.java) + val uri = mock(Uri::class.java) + `when`(context.contentResolver).thenReturn(resolver) + `when`(resolver.openFileDescriptor(uri, "r")).thenReturn(null) + + val helper = PdfRendererHelper(context, uri) + assertEquals(0, helper.pageCount) + assertFalse(helper.isValid) + assertNull(helper.renderPage(0)) + helper.close() + } +} diff --git a/app/src/test/kotlin/io/github/landwarderer/futon/novel/data/source/FreeWebNovelRepositoryTest.kt b/app/src/test/kotlin/io/github/landwarderer/futon/novel/data/source/FreeWebNovelRepositoryTest.kt new file mode 100644 index 0000000000..d1ada70d84 --- /dev/null +++ b/app/src/test/kotlin/io/github/landwarderer/futon/novel/data/source/FreeWebNovelRepositoryTest.kt @@ -0,0 +1,374 @@ +package io.github.landwarderer.futon.novel.data.source + +import io.github.landwarderer.futon.core.cache.MemoryContentCache +import kotlinx.coroutines.runBlocking +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaState +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.mockito.Mockito.mock + +class FreeWebNovelRepositoryTest { + + private val httpClient = OkHttpClient.Builder() + .addInterceptor { chain -> + val url = chain.request().url.toString() + val (contentType, responseBody) = when { + url.contains("ajax=chapters") && url.contains("page=2") -> { + "application/json; charset=utf-8" to buildChaptersPage2Json() + } + url.contains("ajax=chapters") -> { + "application/json; charset=utf-8" to buildChaptersPage1Json() + } + url.contains("/search") -> { + "text/html; charset=utf-8" to buildSearchHtml() + } + url.contains("/sort/most-popular/2") -> { + "text/html; charset=utf-8" to buildCatalogPage2Html() + } + url.contains("/sort/most-popular") -> { + "text/html; charset=utf-8" to buildCatalogPage1Html() + } + url.contains("/novel/shadow-slave/chapter-1") -> { + "text/html; charset=utf-8" to buildReaderChapterHtml() + } + url.contains("/novel/shadow-slave") -> { + "text/html; charset=utf-8" to buildNovelDetailsHtml() + } + url.contains("/novel/short-novel") -> { + "text/html; charset=utf-8" to buildShortNovelDetailsHtml() + } + else -> "text/html; charset=utf-8" to "" + } + Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(responseBody.toResponseBody(contentType.toMediaType())) + .build() + } + .build() + + private val cache = mock(MemoryContentCache::class.java) + private val repository = FreeWebNovelRepository(cache, httpClient) + + @Test + fun testGetList_popularityCatalog() = runBlocking { + val novels = repository.getList(0, SortOrder.POPULARITY, null) + assertTrue("Popularity catalog should return items", novels.isNotEmpty()) + assertEquals(2, novels.size) + + val first = novels.first() + assertEquals("Shadow Slave", first.title) + assertEquals("https://freewebnovel.com/novel/shadow-slave", first.url) + assertEquals("https://freewebnovel.com/files/article/image/1/1991/1991s.jpg", first.coverUrl) + assertEquals(4.6f, first.rating) + assertTrue("Tags should contain Action", first.tags.any { it.title == "Action" }) + + // Second item tests backwards compatibility with div.tit + val second = novels[1] + assertEquals("Invincible", second.title) + assertEquals("https://freewebnovel.com/novel/invincible-novel", second.url) + } + + @Test + fun testGetList_paginationPage2() = runBlocking { + val novels = repository.getList(20, SortOrder.POPULARITY, null) + assertTrue("Page 2 should return items", novels.isNotEmpty()) + val first = novels.first() + assertEquals("The Sovereign's Ascension", first.title) + assertEquals("https://freewebnovel.com/novel/the-sovereigns-ascension", first.url) + } + + @Test + fun testGetList_search() = runBlocking { + val filter = MangaListFilter(query = "shadow") + val novels = repository.getList(0, null, filter) + assertTrue("Search should return items", novels.isNotEmpty()) + val first = novels.first() + assertEquals("Shadow Slave", first.title) + } + + @Test + fun testGetDetails_withAjaxChapters() = runBlocking { + val dummyManga = Manga( + id = 1991L, + title = "Shadow Slave", + altTitles = emptySet(), + url = "https://freewebnovel.com/novel/shadow-slave", + publicUrl = "https://freewebnovel.com/novel/shadow-slave", + rating = 0f, + contentRating = null, + coverUrl = "", + tags = emptySet(), + state = null, + authors = emptySet(), + largeCoverUrl = null, + description = null, + chapters = null, + source = NovelParserSource.FREEWEBNOVEL, + ) + + val detailed = repository.getDetailsImpl(dummyManga) + assertEquals("Shadow Slave", detailed.title) + assertTrue("Author should be Guiltythree", detailed.authors.contains("Guiltythree")) + assertTrue("Tags should contain Action", detailed.tags.any { it.title == "Action" }) + assertEquals(MangaState.ONGOING, detailed.state) + assertTrue("Description should be extracted", detailed.description?.contains("Nightmare Spell") == true) + + val chapters = detailed.chapters + assertNotNull("Chapters must not be null", chapters) + assertEquals("Should have all 4 chapters across 2 pages", 4, chapters!!.size) + + assertEquals("Chapter 1: Nightmare Begins", chapters[0].title) + assertEquals(1f, chapters[0].number) + assertEquals("Chapter 2: Slave Caravan", chapters[1].title) + assertEquals(2f, chapters[1].number) + assertEquals("Chapter 3: The Strings of Fate", chapters[2].title) + assertEquals(3f, chapters[2].number) + assertEquals("Chapter 4: Mountain King", chapters[3].title) + assertEquals(4f, chapters[3].number) + } + + @Test + fun testGetDetails_withDomFallbackChapters() = runBlocking { + val dummyManga = Manga( + id = 2000L, + title = "Short Novel", + altTitles = emptySet(), + url = "https://freewebnovel.com/novel/short-novel", + publicUrl = "https://freewebnovel.com/novel/short-novel", + rating = 0f, + contentRating = null, + coverUrl = "", + tags = emptySet(), + state = null, + authors = emptySet(), + largeCoverUrl = null, + description = null, + chapters = null, + source = NovelParserSource.FREEWEBNOVEL, + ) + + val detailed = repository.getDetailsImpl(dummyManga) + assertEquals(MangaState.FINISHED, detailed.state) + val chapters = detailed.chapters + assertNotNull("Chapters must not be null", chapters) + assertEquals(2, chapters!!.size) + assertEquals("Chapter 1: Short Intro", chapters[0].title) + assertEquals("Chapter 2: Short End", chapters[1].title) + } + + @Test + fun testGetPages_cleansAdsAndScripts() = runBlocking { + val dummyChapter = MangaChapter( + id = 1260067L, + title = "Chapter 1", + number = 1f, + volume = 0, + url = "https://freewebnovel.com/novel/shadow-slave/chapter-1", + uploadDate = 0L, + source = NovelParserSource.FREEWEBNOVEL, + scanlator = null, + branch = null, + ) + + val pages = repository.getPagesImpl(dummyChapter) + assertEquals(1, pages.size) + val content = pages.first().url + + assertTrue("Must contain story content", content.contains("A frail-looking young man with pale skin")) + assertFalse("Ads must be removed", content.contains("ad-box")) + assertFalse("Reader ad skips must be removed", content.contains("reader-ad-skip")) + assertFalse("Comments widget must be removed", content.contains("comments-widget")) + assertFalse("Scripts must be removed", content.contains("alert(")) + } + + companion object { + private fun buildCatalogPage1Html(): String = """ + + + +
+
+
+
+ + + +
+
+

Shadow Slave

+
+ 4.6 +
+
+
+ +
+
+
+
+
+
+
+
+
+
+ + + +
+
+ +
+ 4.2 +
+
+
+
+
+ + + """.trimIndent() + + private fun buildCatalogPage2Html(): String = """ + + + +
+ +
+ + + """.trimIndent() + + private fun buildSearchHtml(): String = """ + + + +
+
+
+
+ + + +
+ +
+
+
+ + + """.trimIndent() + + private fun buildNovelDetailsHtml(): String = """ + + + + + + + + +
+
+
+
+

Growing up in poverty, Sunny was chosen by the Nightmare Spell...

+
+ +
+
+ + + """.trimIndent() + + private fun buildShortNovelDetailsHtml(): String = """ + + + + + + + +
+
+
A very short novel.
+ +
+
+ + + """.trimIndent() + + private fun buildChaptersPage1Json(): String = """ + { + "code": 200, + "page": 1, + "pageSize": 2, + "totalPage": 2, + "totalChapters": 4, + "html": "
  • Chapter 1: Nightmare Begins
  • Chapter 2: Slave Caravan
  • " + } + """.trimIndent() + + private fun buildChaptersPage2Json(): String = """ + { + "code": 200, + "page": 2, + "pageSize": 2, + "totalPage": 2, + "totalChapters": 4, + "html": "
  • Chapter 3: The Strings of Fate
  • Chapter 4: Mountain King
  • " + } + """.trimIndent() + + private fun buildReaderChapterHtml(): String = """ + + + +
    +
    Ad Banner
    +
    Skip ad
    +

    Chapter 1: Nightmare Begins

    +

    A frail-looking young man with pale skin was sitting on a rusty bench.

    +
    Comments section
    + +
    + + + """.trimIndent() + } +} diff --git a/app/src/test/kotlin/io/github/landwarderer/futon/novel/data/source/LNCrawlerRepositoryTest.kt b/app/src/test/kotlin/io/github/landwarderer/futon/novel/data/source/LNCrawlerRepositoryTest.kt new file mode 100644 index 0000000000..fb79044783 --- /dev/null +++ b/app/src/test/kotlin/io/github/landwarderer/futon/novel/data/source/LNCrawlerRepositoryTest.kt @@ -0,0 +1,314 @@ +package io.github.landwarderer.futon.novel.data.source + +import io.github.landwarderer.futon.core.cache.MemoryContentCache +import kotlinx.coroutines.runBlocking +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaState +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.mockito.Mockito.mock + +class LNCrawlerRepositoryTest { + + private val httpClient = OkHttpClient.Builder() + .addInterceptor { chain -> + val url = chain.request().url.toString() + val (contentType, responseBody) = when { + url.contains("/novels/search/") && url.contains("query=primal") -> { + "application/json; charset=utf-8" to buildSearchJson() + } + url.contains("/novels/search/") -> { + "application/json; charset=utf-8" to buildCatalogJson() + } + url.contains("/novels/the-primal-hunter/novelhulkcom/chapters/") -> { + "application/json; charset=utf-8" to buildChaptersJson() + } + url.contains("/novels/the-primal-hunter/novelhulkcom/chapter/1/") -> { + "application/json; charset=utf-8" to buildChapter1Json() + } + url.contains("/novels/the-primal-hunter/novelhulkcom/") -> { + "application/json; charset=utf-8" to buildSourceDetailJson() + } + url.contains("/novels/the-primal-hunter/") -> { + "application/json; charset=utf-8" to buildNovelDetailJson() + } + url.contains("/sitemap-novels.xml") -> { + "application/xml; charset=utf-8" to buildSitemapNovelsXml() + } + url.contains("/sitemap.xml") -> { + "application/xml; charset=utf-8" to buildSitemapIndexXml() + } + else -> "application/json; charset=utf-8" to "{}" + } + Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(responseBody.toResponseBody(contentType.toMediaType())) + .build() + } + .build() + + private val cache = mock(MemoryContentCache::class.java) + private val repository = LNCrawlerRepository(cache, httpClient) + + @Test + fun testGetList_popularityCatalog() = runBlocking { + val novels = repository.getList(0, SortOrder.POPULARITY, null) + assertTrue("Catalog should return items", novels.isNotEmpty()) + assertEquals(2, novels.size) + + val first = novels.first() + assertEquals("The Primal Hunter", first.title) + assertEquals("https://lncrawler.monster/novels/the-primal-hunter/", first.url) + assertEquals("https://api.lncrawler.monster/media/covers/the-primal-hunter.min.jpg", first.coverUrl) + assertEquals(4.8f, first.rating) + assertTrue("Authors should contain Zogarth", first.authors.contains("Zogarth")) + assertTrue("Tags should contain LitRPG", first.tags.any { it.title == "LitRPG" }) + + val second = novels[1] + assertEquals("Shadow Slave", second.title) + assertEquals("https://lncrawler.monster/novels/shadow-slave/", second.url) + } + + @Test + fun testGetList_search() = runBlocking { + val filter = MangaListFilter(query = "primal") + val novels = repository.getList(0, null, filter) + assertTrue("Search should return items", novels.isNotEmpty()) + assertEquals(1, novels.size) + assertEquals("The Primal Hunter", novels.first().title) + } + + @Test + fun testGetDetails_withChapters() = runBlocking { + val dummyManga = Manga( + id = 12345L, + title = "The Primal Hunter", + altTitles = emptySet(), + url = "https://lncrawler.monster/novels/the-primal-hunter/", + publicUrl = "https://lncrawler.monster/novels/the-primal-hunter/", + rating = 0f, + contentRating = null, + coverUrl = "", + tags = emptySet(), + state = null, + authors = emptySet(), + largeCoverUrl = null, + description = null, + chapters = null, + source = NovelParserSource.LNCRAWLER, + ) + + val detailed = repository.getDetailsImpl(dummyManga) + assertEquals("The Primal Hunter", detailed.title) + assertEquals(4.8f, detailed.rating) + assertTrue("Author should be Zogarth", detailed.authors.contains("Zogarth")) + assertTrue("Tags should contain LitRPG", detailed.tags.any { it.title == "LitRPG" }) + assertEquals(MangaState.ONGOING, detailed.state) + assertTrue("Description should be extracted", detailed.description?.contains("Jake, an apparently normal office worker") == true) + + val chapters = detailed.chapters + assertNotNull("Chapters must not be null", chapters) + assertEquals(2, chapters!!.size) + + assertEquals("Chapter 1: The Initiation", chapters[0].title) + assertEquals(1f, chapters[0].number) + assertEquals(1, chapters[0].volume) + assertEquals("Novel Hulk", chapters[0].scanlator) + + assertEquals("Chapter 2: The Tutorial Begins", chapters[1].title) + assertEquals(2f, chapters[1].number) + } + + @Test + fun testGetPages_cleansHtml() = runBlocking { + val dummyChapter = MangaChapter( + id = 9999L, + title = "Chapter 1", + number = 1f, + volume = 1, + url = "https://api.lncrawler.monster/novels/the-primal-hunter/novelhulkcom/chapter/1/", + uploadDate = 0L, + source = NovelParserSource.LNCRAWLER, + scanlator = "Novel Hulk", + branch = "Novel Hulk", + ) + + val pages = repository.getPagesImpl(dummyChapter) + assertEquals(1, pages.size) + val content = pages.first().url + + assertTrue("Must contain story content", content.contains("Jake woke up with a pounding headache.")) + assertFalse("Scripts must be stripped", content.contains("alert(")) + assertFalse("Styles must be stripped", content.contains("body { color: red; }")) + } + + @Test + fun testParseNovelAndSourceSlugs() { + val pair1 = repository.parseNovelAndSourceSlugs("https://lncrawler.monster/novels/the-primal-hunter/") + assertEquals("the-primal-hunter", pair1.first) + assertEquals(null, pair1.second) + + val pair2 = repository.parseNovelAndSourceSlugs("https://lncrawler.monster/novels/the-primal-hunter/novelhulkcom/") + assertEquals("the-primal-hunter", pair2.first) + assertEquals("novelhulkcom", pair2.second) + + val pair3 = repository.parseNovelAndSourceSlugs("https://lncrawler.monster/novels/the-primal-hunter/novelhulkcom/chapterlist/") + assertEquals("the-primal-hunter", pair3.first) + assertEquals("novelhulkcom", pair3.second) + + val pair4 = repository.parseNovelAndSourceSlugs("https://api.lncrawler.monster/novels/the-primal-hunter/novelhulkcom/") + assertEquals("the-primal-hunter", pair4.first) + assertEquals("novelhulkcom", pair4.second) + } + + @Test + fun testSitemapIndexAndNovels() = runBlocking { + val sitemaps = repository.fetchSitemapIndex() + assertEquals(2, sitemaps.size) + assertTrue("Should contain sitemap-novels.xml", sitemaps.contains("https://lncrawler.monster/sitemap-novels.xml")) + + val novels = repository.fetchSitemapNovels() + assertEquals(2, novels.size) + assertTrue("Should contain novel url", novels.contains("https://lncrawler.monster/novels/the-primal-hunter/")) + } + + companion object { + private fun buildCatalogJson(): String = """ + { + "count": 2, + "results": [ + { + "title": "The Primal Hunter", + "slug": "the-primal-hunter", + "avg_rating": 4.8, + "prefered_source": { + "source_slug": "novelhulkcom", + "cover_min_url": "https://api.lncrawler.monster/media/covers/the-primal-hunter.min.jpg", + "cover_url": "https://api.lncrawler.monster/media/covers/the-primal-hunter.jpg", + "authors": ["Zogarth"], + "tags": ["Action", "LitRPG"] + } + }, + { + "title": "Shadow Slave", + "slug": "shadow-slave", + "avg_rating": 4.9, + "prefered_source": { + "source_slug": "novelfirenet", + "cover_min_url": "https://api.lncrawler.monster/media/covers/shadow-slave.min.jpg", + "authors": ["Guiltythree"], + "tags": ["Action", "Fantasy"] + } + } + ] + } + """.trimIndent() + + private fun buildSearchJson(): String = """ + { + "count": 1, + "results": [ + { + "title": "The Primal Hunter", + "slug": "the-primal-hunter", + "avg_rating": 4.8, + "prefered_source": { + "source_slug": "novelhulkcom", + "cover_min_url": "https://api.lncrawler.monster/media/covers/the-primal-hunter.min.jpg", + "authors": ["Zogarth"], + "tags": ["Action", "LitRPG"] + } + } + ] + } + """.trimIndent() + + private fun buildNovelDetailJson(): String = """ + { + "title": "The Primal Hunter", + "slug": "the-primal-hunter", + "avg_rating": 4.8, + "prefered_source": { + "source_slug": "novelhulkcom", + "cover_url": "https://api.lncrawler.monster/media/covers/the-primal-hunter.jpg" + }, + "sources": [ + { + "source_slug": "novelhulkcom", + "source_name": "Novel Hulk" + } + ] + } + """.trimIndent() + + private fun buildSourceDetailJson(): String = """ + { + "source_name": "Novel Hulk", + "source_slug": "novelhulkcom", + "status": "Ongoing", + "cover_url": "https://api.lncrawler.monster/media/covers/the-primal-hunter.jpg", + "authors": ["Zogarth"], + "tags": ["Action", "LitRPG"], + "synopsis": "

    Jake, an apparently normal office worker, finds himself thrust into a vast multiverse filled with gods, beasts, and magic.

    " + } + """.trimIndent() + + private fun buildChaptersJson(): String = """ + { + "page": 1, + "total_pages": 1, + "total_chapters": 2, + "chapters": [ + { + "chapter_id": 1, + "title": "Chapter 1: The Initiation", + "volume": 1 + }, + { + "chapter_id": 2, + "title": "Chapter 2: The Tutorial Begins", + "volume": 1 + } + ] + } + """.trimIndent() + + private fun buildChapter1Json(): String = """ + { + "chapter_id": 1, + "title": "Chapter 1: The Initiation", + "body": "

    Jake woke up with a pounding headache.

    " + } + """.trimIndent() + + private fun buildSitemapIndexXml(): String = """ + + + https://lncrawler.monster/sitemap-novels.xml + https://lncrawler.monster/sitemap-sources.xml + + """.trimIndent() + + private fun buildSitemapNovelsXml(): String = """ + + + https://lncrawler.monster/novels/the-primal-hunter/ + https://lncrawler.monster/novels/shadow-slave/ + + """.trimIndent() + } +} diff --git a/app/src/test/kotlin/io/github/landwarderer/futon/novel/data/source/LnoriRepositoryTest.kt b/app/src/test/kotlin/io/github/landwarderer/futon/novel/data/source/LnoriRepositoryTest.kt new file mode 100644 index 0000000000..a5a7a927bd --- /dev/null +++ b/app/src/test/kotlin/io/github/landwarderer/futon/novel/data/source/LnoriRepositoryTest.kt @@ -0,0 +1,276 @@ +package io.github.landwarderer.futon.novel.data.source + +import io.github.landwarderer.futon.core.cache.MemoryContentCache +import kotlinx.coroutines.runBlocking +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.mockito.Mockito.mock +import java.util.concurrent.TimeUnit + +class LnoriRepositoryTest { + + private val httpClient = OkHttpClient.Builder() + .addInterceptor { chain -> + val url = chain.request().url.toString() + val html = when { + url.contains("/library") -> buildLibraryHtml() + url.contains("/series/") -> buildSeriesHtml() + url.contains("/book/") -> buildBookHtml() + else -> "" + } + Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(html.toResponseBody("text/html; charset=utf-8".toMediaType())) + .build() + } + .build() + + private val cache = mock(MemoryContentCache::class.java) + private val repository = LnoriRepository(cache, httpClient) + + @Before + fun setup() { + // Reset catalog cache if needed + } + + @Test + fun testGetList_returnsCatalogNovelsWithMetadata() = runBlocking { + val novels = repository.getList(0, null, null) + assertTrue("Catalog should return at least 20 novels", novels.size >= 20) + + for (novel in novels) { + assertTrue("Novel title must be non-blank", novel.title.isNotBlank()) + assertTrue("Novel url must start with https://lnori.com/series/", novel.url.startsWith("https://lnori.com/series/")) + assertTrue("Novel cover url must be non-blank", novel.coverUrl?.isNotBlank() == true) + assertTrue("Novel description must be non-blank", novel.description?.isNotBlank() == true) + assertFalse("Novel author must not be blank if present", novel.author?.isBlank() == true) + assertTrue("Novel tags should not be empty", novel.tags.isNotEmpty()) + } + } + + @Test + fun testGetList_queryFilterFindsMatchingNovels() = runBlocking { + val filter = MangaListFilter(query = "Re:ZERO") + val results = repository.getList(0, null, filter) + + assertTrue("Should find results for query 'Re:ZERO'", results.isNotEmpty()) + for (novel in results) { + val matchesTitle = novel.title.contains("Re:ZERO", ignoreCase = true) + val matchesAuthor = novel.author?.contains("Re:ZERO", ignoreCase = true) == true + assertTrue("Result must match query in title or author", matchesTitle || matchesAuthor) + } + } + + @Test + fun testGetList_tagFilterIncludeAndExclude() = runBlocking { + val isekaiTag = MangaTag(title = "Isekai", key = "isekai", source = NovelParserSource.LNORI) + val filterInclude = MangaListFilter(tags = setOf(isekaiTag)) + val isekaiNovels = repository.getList(0, null, filterInclude) + + assertTrue("Should find isekai novels", isekaiNovels.isNotEmpty()) + for (novel in isekaiNovels) { + assertTrue("Novel must have isekai tag", novel.tags.any { it.key.equals("isekai", ignoreCase = true) }) + } + + val filterExclude = MangaListFilter(tags = setOf(isekaiTag), tagsExclude = setOf(isekaiTag)) + val excludedNovels = repository.getList(0, null, filterExclude) + assertTrue("Novels with excluded tag should be empty", excludedNovels.isEmpty()) + } + + @Test + fun testGetList_alphabeticalSorting() = runBlocking { + val novels = repository.getList(0, SortOrder.ALPHABETICAL, null) + assertTrue("Novels list should not be empty", novels.size >= 2) + + for (i in 0 until novels.size - 1) { + val current = novels[i].title.lowercase() + val next = novels[i + 1].title.lowercase() + assertTrue("Catalog must be sorted alphabetically: '$current' <= '$next'", current <= next) + } + } + + @Test + fun testGetFilterOptions_providesAllUniqueTags() = runBlocking { + val options = repository.getFilterOptions() + val tags = options.availableTags + + assertTrue("Available tags count should be greater than 100", tags.size >= 100) + assertTrue("Tags should contain 'isekai'", tags.any { it.key == "isekai" }) + assertTrue("Tags should contain 'action'", tags.any { it.key == "action" }) + assertTrue("Tags should contain 'comedy'", tags.any { it.key == "comedy" }) + } + + @Test + fun testGetDetails_populatesChaptersWithVolumeLinks() = runBlocking { + val novels = repository.getList(0, null, null) + assertTrue("Novels list should not be empty", novels.isNotEmpty()) + + val firstNovel = novels.first() + val detailed = repository.getDetailsImpl(firstNovel) + + assertTrue("Detailed novel title should be non-blank", detailed.title.isNotBlank()) + assertTrue("Detailed novel description should be non-blank", detailed.description?.isNotBlank() == true) + + val chapters = detailed.chapters + assertNotNull("Detailed novel chapters must not be null", chapters) + assertTrue("Detailed novel must have at least one chapter", chapters?.isNotEmpty() == true) + + for (chap in chapters!!) { + assertTrue("Chapter title must be non-blank", chap.title?.isNotBlank() == true) + assertTrue("Chapter url must contain /book/", chap.url.contains("/book/")) + assertTrue("Chapter number must be >= 1", chap.number >= 1f) + assertTrue("Chapter volume must be >= 1", chap.volume >= 1) + } + } + + @Test + fun testGetPages_extractsContentAndRewritesImagesToAvif() = runBlocking { + val novels = repository.getList(0, null, null) + val firstNovel = novels.first() + val detailed = repository.getDetailsImpl(firstNovel) + val firstChapter = detailed.chapters!!.first() + + val pages = repository.getPagesImpl(firstChapter) + assertEquals("Novel volume chapter should return exactly 1 page containing reader HTML", 1, pages.size) + + val htmlContent = pages.first().url + assertTrue("Volume content HTML must be non-blank", htmlContent.isNotBlank()) + assertTrue("Volume content HTML must contain text content", htmlContent.length > 500) + + // Assert that img.lnori.com JPG URLs were rewritten to AVIF without regex null artifacts + assertFalse("Volume content must not contain img.lnori.com .jpg image references", htmlContent.contains("img.lnori.com") && htmlContent.contains(".jpg")) + assertFalse("Volume content must not contain .avifnull references", htmlContent.contains(".avifnull")) + } + + companion object { + private val TAG_POOL = (1..110).map { "tag$it" } + listOf("isekai", "action", "comedy", "fantasy", "drama", "adventure", "romance") + + private fun buildLibraryHtml(): String { + val sb = StringBuilder("") + val novels = listOf( + "A Certain Magical Index" to "Kamachi Kazuma", + "Baccano!" to "Narita Ryohgo", + "Classroom of the Elite" to "Kinugasa Syougo", + "Date A Live" to "Tachibana Koushi", + "Eighty-Six" to "Asato Asato", + "Fate/Zero" to "Urobuchi Gen", + "Goblin Slayer" to "Kagyu Kumo", + "Hyouka" to "Yonezawa Honobu", + "Infinite Dendrogram" to "Kaidou Sakon", + "Jaku-Chara Tomozaki-kun" to "Yaku Yuuki", + "KonoSuba" to "Akatsuki Natsume", + "Log Horizon" to "Touno Mamare", + "Mushoku Tensei" to "Rifujin na Magonote", + "No Game No Life" to "Kamiya Yuu", + "Overlord" to "Maruyama Kugane", + "Pending Novel" to "Author P", + "Qualidea Code" to "Sagara Sou", + "Re:ZERO -Starting Life in Another World-" to "Nagatsuki Tappei", + "Sword Art Online" to "Kawahara Reki", + "The Eminence in Shadow" to "Aizawa Daisuke", + "Utsuro no Hako to Zero no Maria" to "Mikage Eiji", + "Violet Evergarden" to "Akatsuki Kana", + "WorldEnd" to "Kareno Akira", + "Youjo Senki" to "Carlo Zen", + "Zaregoto Series" to "Nisio Isin", + ) + + novels.forEachIndexed { idx, (title, author) -> + val startTagIdx = (idx * 4) % TAG_POOL.size + val tags = mutableSetOf() + if (title.contains("Re:ZERO")) { + tags.addAll(listOf("isekai", "action", "comedy", "drama", "fantasy", "adventure")) + } else if (idx % 2 == 0) { + tags.add("isekai") + } + for (i in 0 until 5) { + tags.add(TAG_POOL[(startTagIdx + i) % TAG_POOL.size]) + } + val tagsAttr = tags.joinToString(",") + val slug = title.lowercase().replace("[^a-z0-9]+".toRegex(), "-").trim('-') + sb.append(""" +
    +
    + + $title + +
    + +

    $title

    +
    + """.trimIndent()) + } + sb.append("") + return sb.toString() + } + + private fun buildSeriesHtml(): String { + return """ + + + +

    Re:ZERO -Starting Life in Another World-

    + +

    Subaru Natsuki is summoned to another fantasy world.

    +
    + + """.trimIndent() + } + + private fun buildBookHtml(): String { + return """ + Volume 1 +
    +

    Prologue: The Waste Heat of the Beginning

    +

    Subaru Natsuki was an ordinary high school student who found himself suddenly summoned to an alternate fantasy world with no explanation whatsoever. There was no summoner in sight, nor any grand quest thrust upon him. Just a bustling marketplace full of demi-humans, lizard folk, and magic carriages moving about. Thinking he had obtained typical protagonist powers, he quickly learned that his only ability was to return by death.

    +

    He walked around the capital city trying to make sense of his surroundings, checking his belongings which consisted only of a convenience store plastic bag containing instant ramen, chips, and his uncharged flip phone.

    + Color Illustration 1 + +

    As dusk fell over the capital city of Lugnica, dark shadows began to loom across the cobblestone alleys...

    +
    + + """.trimIndent() + } + } +} diff --git a/app/src/test/kotlin/io/github/landwarderer/futon/novel/data/source/RanobesRepositoryTest.kt b/app/src/test/kotlin/io/github/landwarderer/futon/novel/data/source/RanobesRepositoryTest.kt new file mode 100644 index 0000000000..36b2016be7 --- /dev/null +++ b/app/src/test/kotlin/io/github/landwarderer/futon/novel/data/source/RanobesRepositoryTest.kt @@ -0,0 +1,322 @@ +package io.github.landwarderer.futon.novel.data.source + +import io.github.landwarderer.futon.core.cache.MemoryContentCache +import kotlinx.coroutines.runBlocking +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.mockito.Mockito.mock + +class RanobesRepositoryTest { + + private val httpClient = OkHttpClient.Builder() + .addInterceptor { chain -> + val url = chain.request().url.toString() + val responseBody = when { + url.contains("/ranking") -> buildRankingHtml() + url.contains("/novels/1207122") -> buildNovelDetailsHtml() + url.contains("/novels") -> buildNovelsHtml() + url.contains("/updates") -> buildUpdatesHtml() + url.contains("/search/sword") || url.contains("story=sword") -> buildSearchHtml() + url.contains("/chapters/1207122/page/2") -> buildChaptersPage2Html() + url.contains("/chapters/1207122") -> buildChaptersPage1Html() + url.contains("/3316269.html") -> buildReaderChapterHtml() + else -> "" + } + Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(responseBody.toResponseBody("text/html; charset=utf-8".toMediaType())) + .build() + } + .build() + + private val cache = mock(MemoryContentCache::class.java) + private val repository = RanobesRepository(cache, httpClient) + + @Test + fun testGetList_popularityRanking() = runBlocking { + val novels = repository.getList(0, SortOrder.POPULARITY, null) + assertTrue("Popularity ranking should return items", novels.isNotEmpty()) + + val first = novels.first() + assertEquals("Forty-Nine Doomsday Rules", first.title) + assertTrue("Novel URL must start with https://ranobes.net", first.url.startsWith("https://ranobes.net/novels/")) + assertFalse("Cover URL must not contain /thumbs/", first.coverUrl?.contains("/thumbs/") == true) + assertTrue("Cover URL must be full-res webp", first.coverUrl?.endsWith(".webp") == true) + assertTrue("Description must be extracted", first.description?.isNotBlank() == true) + + // Page 2 of ranking should return empty list (single ranking page) + val page2 = repository.getList(20, SortOrder.POPULARITY, null) + assertTrue("Page 2 of ranking should be empty", page2.isEmpty()) + } + + @Test + fun testGetList_newestNovels() = runBlocking { + val novels = repository.getList(0, SortOrder.NEWEST, null) + assertTrue("Catalog should return novels", novels.isNotEmpty()) + + val first = novels.first() + assertEquals("My Strange Life: Role Playing Game", first.title) + assertTrue("Novel URL must start with https://ranobes.net", first.url.startsWith("https://ranobes.net/novels/")) + assertFalse("Cover URL must not contain /thumbs/", first.coverUrl?.contains("/thumbs/") == true) + assertEquals("https://ranobes.net/uploads/posts/2026-09/1789452574_my-strange-life-role-playing-game.webp", first.coverUrl) + } + + @Test + fun testGetList_updatesTransformsChapterUrlToNovelUrl() = runBlocking { + val novels = repository.getList(0, SortOrder.UPDATED, null) + assertTrue("Updates should return items", novels.isNotEmpty()) + + val first = novels.first() + assertEquals("Forty-Nine Doomsday Rules", first.title) + // Should transform chapter url /forty-nine-doomsday-rules-1207122/3316269.html to novel url + assertEquals("https://ranobes.net/novels/1207122-forty-nine-doomsday-rules.html", first.url) + assertFalse("Cover URL must not contain /thumbs/", first.coverUrl?.contains("/thumbs/") == true) + assertTrue("Description should contain chapter and release time", first.description?.contains("Chapter 699") == true) + } + + @Test + fun testGetList_searchQuery() = runBlocking { + val filter = MangaListFilter(query = "sword") + val novels = repository.getList(0, null, filter) + assertTrue("Search should return novels", novels.isNotEmpty()) + + val first = novels.first() + assertTrue("Search result must match sword query", first.title.contains("Sword", ignoreCase = true)) + } + + @Test + fun testGetDetails_extractsMetadataAndWindowDataJsonChapters() = runBlocking { + val dummyManga = Manga( + id = 1207122L, + title = "Forty-Nine Doomsday Rules", + altTitles = emptySet(), + url = "https://ranobes.net/novels/1207122-forty-nine-doomsday-rules.html", + publicUrl = "https://ranobes.net/novels/1207122-forty-nine-doomsday-rules.html", + rating = 0f, + contentRating = null, + coverUrl = "", + tags = emptySet(), + state = null, + authors = emptySet(), + largeCoverUrl = null, + description = null, + chapters = null, + source = NovelParserSource.RANOBES, + ) + + val detailed = repository.getDetailsImpl(dummyManga) + assertEquals("Forty-Nine Doomsday Rules", detailed.title) + assertNotNull("Author should be extracted", detailed.authors) + assertTrue("Author set should contain AuthorName", detailed.authors?.contains("AuthorName") == true) + assertTrue("Description must be populated", detailed.description?.isNotBlank() == true) + + val chapters = detailed.chapters + assertNotNull("Chapters must not be null", chapters) + assertEquals("Should have all 4 chapters from 2 pages of JSON data", 4, chapters?.size) + + // Chapters should be ordered chronologically: Chapter 1 to Chapter 4 + assertEquals("Chapter 1: The Beginning", chapters!![0].title) + assertEquals(1f, chapters[0].number) + assertEquals("Chapter 2: The Awakening", chapters[1].title) + assertEquals(2f, chapters[1].number) + assertEquals("Chapter 3: The Threat", chapters[2].title) + assertEquals(3f, chapters[2].number) + assertEquals("Chapter 4: The Final Proposal", chapters[3].title) + assertEquals(4f, chapters[3].number) + } + + @Test + fun testGetPages_cleansAdsAndPreservesReaderText() = runBlocking { + val dummyChapter = MangaChapter( + id = 3316269L, + title = "Chapter 4", + number = 4f, + volume = 0, + url = "https://ranobes.net/forty-nine-doomsday-rules-1207122/3316269.html", + uploadDate = 0L, + source = NovelParserSource.RANOBES, + scanlator = null, + branch = null, + ) + + val pages = repository.getPagesImpl(dummyChapter) + assertEquals(1, pages.size) + val html = pages.first().url + + assertTrue("Should contain article text", html.contains("This is the sacred text of the chapter.")) + assertFalse("Ads must be removed", html.contains("Ad content to be removed")) + assertFalse("Free support banner must be removed", html.contains("free-support-top")) + assertFalse("Scripts must be removed", html.contains("alert(")) + } + + companion object { + private fun buildRankingHtml(): String { + return """ + + + +
    + +
    + + + """.trimIndent() + } + + private fun buildNovelsHtml(): String { + return """ + + + +
    + +
    + + + """.trimIndent() + } + + private fun buildUpdatesHtml(): String { + return """ + + + + + + + """.trimIndent() + } + + private fun buildSearchHtml(): String { + return """ + + + +
    + +
    + + + """.trimIndent() + } + + private fun buildNovelDetailsHtml(): String { + return """ + + + +
    +
    +
    +
    +

    Forty-Nine Doomsday Rules

    +
    Full detailed synopsis of Forty-Nine Doomsday Rules.
    + + +
    + + + """.trimIndent() + } + + private fun buildChaptersPage1Html(): String { + return """ + + + + + + + + """.trimIndent() + } + + private fun buildChaptersPage2Html(): String { + return """ + + + + + + + + """.trimIndent() + } + + private fun buildReaderChapterHtml(): String { + return """ + + + +
    +
    Support Ranobes banner
    +
    Ad content to be removed
    +

    This is the sacred text of the chapter.

    + +
    + + + """.trimIndent() + } + } +} diff --git a/app/src/test/kotlin/io/github/landwarderer/futon/novel/data/source/ScribbleHubRepositoryTest.kt b/app/src/test/kotlin/io/github/landwarderer/futon/novel/data/source/ScribbleHubRepositoryTest.kt new file mode 100644 index 0000000000..97aa2f30e8 --- /dev/null +++ b/app/src/test/kotlin/io/github/landwarderer/futon/novel/data/source/ScribbleHubRepositoryTest.kt @@ -0,0 +1,253 @@ +package io.github.landwarderer.futon.novel.data.source + +import io.github.landwarderer.futon.core.cache.MemoryContentCache +import kotlinx.coroutines.runBlocking +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaState +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.mockito.Mockito.mock + +class ScribbleHubRepositoryTest { + + private val httpClient = OkHttpClient.Builder() + .addInterceptor { chain -> + val url = chain.request().url.toString() + val (contentType, responseBody) = when { + url.contains("sh=shadow") -> { + "text/html; charset=utf-8" to buildSearchHtml() + } + url.contains("pg=2") -> { + "text/html; charset=utf-8" to buildCatalogPage2Html() + } + url.contains("/series-finder/") -> { + "text/html; charset=utf-8" to buildCatalogPage1Html() + } + url.contains("/read/") -> { + "text/html; charset=utf-8" to buildReaderChapterHtml() + } + url.contains("/series/") -> { + "text/html; charset=utf-8" to buildNovelDetailsHtml() + } + else -> "text/html; charset=utf-8" to "" + } + Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(responseBody.toResponseBody(contentType.toMediaType())) + .build() + } + .build() + + private val cache = mock(MemoryContentCache::class.java) + private val repository = ScribbleHubRepository(cache, httpClient) + + @Test + fun testGetList_popularityCatalog() = runBlocking { + val novels = repository.getList(0, SortOrder.POPULARITY, null) + assertTrue("Catalog should return novels", novels.isNotEmpty()) + assertEquals(2, novels.size) + + val first = novels.first() + assertEquals("The Young Master in the Shadows", first.title) + assertEquals("https://www.scribblehub.com/series/413997/the-young-master-in-the-shadows/", first.url) + assertEquals("https://cdn.scribblehub.com/seriesimg/mid/20/mid_413997.jpg", first.coverUrl) + assertEquals(4.4f, first.rating) + assertTrue("Author should be Akaichi", first.authors.contains("Akaichi")) + assertTrue("Tags should contain Action", first.tags.any { it.title == "Action" }) + assertTrue("Description should be present", first.description?.contains("He has returned to the past") == true) + } + + @Test + fun testGetList_paginationPage2() = runBlocking { + val novels = repository.getList(20, SortOrder.POPULARITY, null) + assertTrue("Page 2 should return novels", novels.isNotEmpty()) + val first = novels.first() + assertEquals("The Reincarnation of Alysara", first.title) + } + + @Test + fun testGetList_search() = runBlocking { + val filter = MangaListFilter(query = "shadow") + val novels = repository.getList(0, null, filter) + assertTrue("Search should return novels", novels.isNotEmpty()) + val first = novels.first() + assertEquals("The Young Master in the Shadows", first.title) + } + + @Test + fun testGetDetails_withChapters() = runBlocking { + val dummyManga = Manga( + id = 413997L, + title = "The Young Master in the Shadows", + altTitles = emptySet(), + url = "https://www.scribblehub.com/series/413997/the-young-master-in-the-shadows/", + publicUrl = "https://www.scribblehub.com/series/413997/the-young-master-in-the-shadows/", + rating = 0f, + contentRating = null, + coverUrl = "", + tags = emptySet(), + state = null, + authors = emptySet(), + largeCoverUrl = null, + description = null, + chapters = null, + source = NovelParserSource.SCRIBBLEHUB, + ) + + val detailed = repository.getDetailsImpl(dummyManga) + assertEquals("The Young Master in the Shadows", detailed.title) + assertTrue("Author should be Akaichi", detailed.authors.contains("Akaichi")) + assertTrue("Tags should contain Action", detailed.tags.any { it.title == "Action" }) + assertEquals(MangaState.ONGOING, detailed.state) + assertTrue("Description should be extracted", detailed.description?.contains("intrigue story") == true) + + val chapters = detailed.chapters + assertNotNull("Chapters must not be null", chapters) + assertEquals(3, chapters!!.size) + + assertEquals("Chapter 01 : Reborn", chapters[0].title) + assertEquals(1f, chapters[0].number) + assertEquals("Chapter 02 : Awakening", chapters[1].title) + assertEquals(2f, chapters[1].number) + assertEquals("Chapter 03 : The Journey", chapters[2].title) + assertEquals(3f, chapters[2].number) + } + + @Test + fun testGetPages_cleansReaderHtml() = runBlocking { + val dummyChapter = MangaChapter( + id = 414024L, + title = "Chapter 01 : Reborn", + number = 1f, + volume = 0, + url = "https://www.scribblehub.com/read/413997-the-young-master-in-the-shadows/chapter/414024/", + uploadDate = 0L, + source = NovelParserSource.SCRIBBLEHUB, + scanlator = null, + branch = null, + ) + + val pages = repository.getPagesImpl(dummyChapter) + assertEquals(1, pages.size) + val content = pages.first().url + + assertTrue("Must contain story content", content.contains("He has locked himself in the royal library")) + assertFalse("Scripts must be removed", content.contains("alert(")) + assertFalse("Footnotes must be removed", content.contains("modern-footnotes-footnote")) + } + + companion object { + private fun buildCatalogPage1Html(): String = """ + + + +
    +
    + +
    (4.4)
    +
    +
    + +
    + Akaichi +
    +
    + Action +
    + He has returned to the past. And this time he will live up to his role as a young master. +
    +
    +
    +
    + +
    (4.8)
    +
    + +
    + + + """.trimIndent() + + private fun buildCatalogPage2Html(): String = """ + + + + + + + """.trimIndent() + + private fun buildSearchHtml(): String = """ + + + + + + + """.trimIndent() + + private fun buildNovelDetailsHtml(): String = """ + + + +
    The Young Master in the Shadows
    +
    Akaichi
    +
    +
    This is a harem and intrigue story.
    +
    Action
    +
    Status: Ongoing
    + + + + """.trimIndent() + + private fun buildReaderChapterHtml(): String = """ + + + +
    +

    He has locked himself in the royal library.

    +
    Note 1
    + +
    + + + """.trimIndent() + } +} diff --git a/app/src/test/kotlin/io/github/landwarderer/futon/novel/data/source/WuxiaworldRepositoryTest.kt b/app/src/test/kotlin/io/github/landwarderer/futon/novel/data/source/WuxiaworldRepositoryTest.kt new file mode 100644 index 0000000000..2f24a72aad --- /dev/null +++ b/app/src/test/kotlin/io/github/landwarderer/futon/novel/data/source/WuxiaworldRepositoryTest.kt @@ -0,0 +1,59 @@ +package io.github.landwarderer.futon.novel.data.source + +import io.github.landwarderer.futon.core.cache.MemoryContentCache +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.mock +import java.util.concurrent.TimeUnit + +class WuxiaworldRepositoryTest { + + private val httpClient = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .build() + + private val cache = mock(MemoryContentCache::class.java) + private val repository = WuxiaworldRepository(cache, httpClient) + + @Test + fun testGetList_novelsDoNotHaveOngoingAsTitle() = runBlocking { + val novels = repository.getList(0, null, null) + assertTrue("Catalog should return novels", novels.isNotEmpty()) + + for (novel in novels) { + assertFalse("Novel title must not be 'Ongoing'", novel.title.equals("Ongoing", ignoreCase = true)) + assertFalse("Novel title must not be 'Completed'", novel.title.equals("Completed", ignoreCase = true)) + assertTrue("Novel title should be non-blank", novel.title.isNotBlank()) + assertTrue("Novel url should contain /novel/", novel.url.contains("/novel/")) + } + } + + @Test + fun testGetDetails_populatesNovelDetailsAndChapters() = runBlocking { + val novels = repository.getList(0, null, null) + assertTrue(novels.isNotEmpty()) + + val firstNovel = novels.first() + val detailed = repository.getDetailsImpl(firstNovel) + + println("Detailed novel title: ${detailed.title}") + println("Detailed novel author: ${detailed.authors}") + println("Detailed novel state: ${detailed.state}") + println("Detailed novel rating: ${detailed.rating}") + println("Detailed novel tags size: ${detailed.tags.size}") + println("Detailed novel chapters count: ${detailed.chapters?.size}") + + assertFalse("Detailed title must not be 'Ongoing'", detailed.title.equals("Ongoing", ignoreCase = true)) + assertNotNull("Detailed novel must have non-null description", detailed.description) + assertTrue("Detailed novel must have non-blank description", detailed.description?.isNotBlank() == true) + + val chapters = detailed.chapters + assertNotNull("Detailed novel must have chapters", chapters) + assertTrue("Detailed novel chapters list should not be empty", chapters?.isNotEmpty() == true) + } +} diff --git a/app/src/test/kotlin/io/github/landwarderer/futon/onboarding/OnboardingPageTest.kt b/app/src/test/kotlin/io/github/landwarderer/futon/onboarding/OnboardingPageTest.kt new file mode 100644 index 0000000000..0efcad78ca --- /dev/null +++ b/app/src/test/kotlin/io/github/landwarderer/futon/onboarding/OnboardingPageTest.kt @@ -0,0 +1,71 @@ +package io.github.landwarderer.futon.onboarding + +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.core.prefs.AppSettings +import io.github.landwarderer.futon.onboarding.model.OnboardingPage +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import androidx.appcompat.R as appcompatR +import com.google.android.material.R as materialR + +class OnboardingPageTest { + + @Test + fun testOnboardingPageCountAndOrdering() { + val pages = OnboardingPage.entries + assertEquals(4, pages.size) + assertEquals(OnboardingPage.WELCOME, pages[0]) + assertEquals(OnboardingPage.DUAL_MODES, pages[1]) + assertEquals(OnboardingPage.SOURCES_AND_NSFW, pages[2]) + assertEquals(OnboardingPage.GET_STARTED, pages[3]) + } + + @Test + fun testWarningPageFlag() { + assertFalse(OnboardingPage.WELCOME.isWarningPage) + assertFalse(OnboardingPage.DUAL_MODES.isWarningPage) + assertTrue(OnboardingPage.SOURCES_AND_NSFW.isWarningPage) + assertFalse(OnboardingPage.GET_STARTED.isWarningPage) + } + + @Test + fun testPageResourcesArePopulated() { + for (page in OnboardingPage.entries) { + if (page == OnboardingPage.WELCOME) { + assertEquals(R.drawable.ic_futon, page.heroIconRes) + } else { + assertNull(page.heroIconRes) + } + assertTrue(page.badgeRes != 0) + assertTrue(page.titleRes != 0) + assertTrue(page.subtitleRes != 0) + assertTrue(page.accentColorAttr != 0) + + if (!page.isWarningPage) { + assertNotNull(page.feature1Res) + assertNotNull(page.feature2Res) + assertNotNull(page.feature3Res) + } + } + } + + @Test + fun testAccentColorsConfigured() { + assertEquals(appcompatR.attr.colorPrimary, OnboardingPage.WELCOME.accentColorAttr) + assertEquals(materialR.attr.colorTertiary, OnboardingPage.DUAL_MODES.accentColorAttr) + assertEquals(appcompatR.attr.colorError, OnboardingPage.SOURCES_AND_NSFW.accentColorAttr) + assertEquals(appcompatR.attr.colorPrimary, OnboardingPage.GET_STARTED.accentColorAttr) + } + + @Test + fun testAppSettingsOnboardingConstants() { + assertEquals(1, AppSettings.CURRENT_ONBOARDING_VERSION) + assertEquals("onboarding_version", AppSettings.KEY_ONBOARDING_VERSION) + assertEquals("mode_switcher_spotlight_shown", AppSettings.KEY_MODE_SWITCHER_SPOTLIGHT_SHOWN) + assertEquals("about_onboarding", AppSettings.KEY_ABOUT_ONBOARDING) + } +} diff --git a/gradle.properties b/gradle.properties index def805e7e9..87df3ad81f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,7 +19,7 @@ org.gradle.jvmargs=-Xmx8192M -Dkotlin.daemon.jvm.options\="-Xmx8192M" android.enableR8.fullMode=true android.nonFinalResIds=false org.gradle.parallel=true -org.gradle.workers.max=2 +org.gradle.workers.max=6 org.gradle.caching=true org.gradle.configuration-cache=true kotlin.incremental=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 565f1c0000..8a4f6cede7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -123,6 +123,7 @@ rxjava = { module = "io.reactivex:rxjava", version.ref = "rxjava" } rxandroid = { module = "io.reactivex:rxandroid", version.ref = "rxandroid" } okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okhttp" } okhttp = { module = "com.squareup.okhttp3:okhttp" } +okhttp-brotli = { module = "com.squareup.okhttp3:okhttp-brotli" } okhttp-dnsoverhttps = { module = "com.squareup.okhttp3:okhttp-dnsoverhttps" } okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" }