diff --git a/app/src/main/java/io/aatricks/novelscraper/data/local/PreferencesManager.kt b/app/src/main/java/io/aatricks/novelscraper/data/local/PreferencesManager.kt index a4d6265..ea81eaf 100644 --- a/app/src/main/java/io/aatricks/novelscraper/data/local/PreferencesManager.kt +++ b/app/src/main/java/io/aatricks/novelscraper/data/local/PreferencesManager.kt @@ -123,6 +123,10 @@ class PreferencesManager @Inject constructor( ?: io.aatricks.novelscraper.data.model.ReaderTheme.DARK.name set(value) = prefs.edit().putString(KEY_READER_THEME, value).apply() + var ignoreSslErrors: Boolean + get() = prefs.getBoolean(KEY_IGNORE_SSL_ERRORS, false) + set(value) = prefs.edit().putBoolean(KEY_IGNORE_SSL_ERRORS, value).apply() + // Clear all preferences fun clearAll() { prefs.edit().clear().apply() @@ -154,5 +158,6 @@ class PreferencesManager @Inject constructor( private const val KEY_MARGINS = "reader_margins" private const val KEY_PARAGRAPH_SPACING = "reader_paragraph_spacing" private const val KEY_READER_THEME = "reader_theme" + private const val KEY_IGNORE_SSL_ERRORS = "ignore_ssl_errors" } } diff --git a/app/src/main/java/io/aatricks/novelscraper/data/model/SortMode.kt b/app/src/main/java/io/aatricks/novelscraper/data/model/SortMode.kt new file mode 100644 index 0000000..4b8ac33 --- /dev/null +++ b/app/src/main/java/io/aatricks/novelscraper/data/model/SortMode.kt @@ -0,0 +1,8 @@ +package io.aatricks.novelscraper.data.model + +enum class SortMode { + LAST_READ, + DATE_ADDED, + TITLE, + PROGRESS +} diff --git a/app/src/main/java/io/aatricks/novelscraper/data/repository/ContentRepository.kt b/app/src/main/java/io/aatricks/novelscraper/data/repository/ContentRepository.kt index 48ddd70..0ae1b96 100644 --- a/app/src/main/java/io/aatricks/novelscraper/data/repository/ContentRepository.kt +++ b/app/src/main/java/io/aatricks/novelscraper/data/repository/ContentRepository.kt @@ -35,7 +35,8 @@ import javax.inject.Singleton @Singleton class ContentRepository @Inject constructor( @ApplicationContext private val context: android.content.Context, - private val htmlParser: HtmlParser + private val htmlParser: HtmlParser, + private val okHttpClient: OkHttpClient ) { companion object { @@ -45,11 +46,6 @@ class ContentRepository @Inject constructor( private val repositoryScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val okHttpClient = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build() - private val cacheDir: File get() = File(context.cacheDir, "html_cache").apply { if (!exists()) mkdirs() } private val mediaCacheDir: File get() = File(context.cacheDir, "media_cache").apply { if (!exists()) mkdirs() } private val epubCacheDir: File get() = File(context.cacheDir, "epub_cache").apply { if (!exists()) mkdirs() } @@ -100,8 +96,12 @@ class ContentRepository @Inject constructor( } private fun getReferer(url: String): String = try { - val uri = java.net.URI(url) - "${uri.scheme}://${uri.host}/" + if (url.contains("mangabat") || url.contains("manganato")) { + "https://manganato.com/" + } else { + val uri = java.net.URI(url) + "${uri.scheme}://${uri.host}/" + } } catch (e: Exception) { url } diff --git a/app/src/main/java/io/aatricks/novelscraper/data/repository/source/BaseJsoupSource.kt b/app/src/main/java/io/aatricks/novelscraper/data/repository/source/BaseJsoupSource.kt index 9c524ed..3da24ae 100644 --- a/app/src/main/java/io/aatricks/novelscraper/data/repository/source/BaseJsoupSource.kt +++ b/app/src/main/java/io/aatricks/novelscraper/data/repository/source/BaseJsoupSource.kt @@ -1,5 +1,6 @@ package io.aatricks.novelscraper.data.repository.source +import io.aatricks.novelscraper.data.local.PreferencesManager import io.aatricks.novelscraper.data.model.ExploreItem import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -7,22 +8,89 @@ import org.jsoup.Connection import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.jsoup.nodes.Element +import java.security.SecureRandom +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager -abstract class BaseJsoupSource : NovelSource { +abstract class BaseJsoupSource( + protected open val preferencesManager: PreferencesManager? = null, + protected open val okHttpClient: okhttp3.OkHttpClient? = null +) : NovelSource { protected open val userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" - protected open val timeout = 15000 + protected open val timeout = 15000L protected suspend fun io(block: suspend () -> T): T = withContext(Dispatchers.IO) { block() } - protected fun connect(url: String): Connection = Jsoup.connect(url) - .userAgent(userAgent) - .referrer(baseUrl) - .timeout(timeout) - .followRedirects(true) + protected fun getDocument(url: String): Document { + val client = okHttpClient + if (client != null) { + val request = okhttp3.Request.Builder() + .url(url) + .header("User-Agent", userAgent) + .header("Referer", baseUrl) + .build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) throw java.io.IOException("Unexpected code $response") + val html = response.body?.string() ?: throw java.io.IOException("Empty response") + return Jsoup.parse(html, url) + } + } + + // Fallback to Jsoup's connection if okHttpClient is not available + val connection = Jsoup.connect(url) + .userAgent(userAgent) + .referrer(baseUrl) + .timeout(timeout.toInt()) + .followRedirects(true) + + if (preferencesManager?.ignoreSslErrors == true) { + val trustAllCerts = arrayOf( + object : X509TrustManager { + override fun checkClientTrusted(chain: Array, authType: String) {} + override fun checkServerTrusted(chain: Array, authType: String) {} + override fun getAcceptedIssuers(): Array = arrayOf() + } + ) + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, trustAllCerts, java.security.SecureRandom()) + connection.sslSocketFactory(sslContext.socketFactory) + // Note: setDefaultHostnameVerifier is global and might affect other parts of the app + javax.net.ssl.HttpsURLConnection.setDefaultHostnameVerifier { _, _ -> true } + } + + return connection.get() + } - protected fun getDocument(url: String): Document = connect(url).get() + protected fun connect(url: String): Connection { + // This method is now legacy as we prefer getDocument with okHttpClient + val connection = Jsoup.connect(url) + .userAgent(userAgent) + .referrer(baseUrl) + .timeout(timeout.toInt()) + .followRedirects(true) + + if (preferencesManager?.ignoreSslErrors == true) { + val trustAllCerts = arrayOf( + object : X509TrustManager { + override fun checkClientTrusted(chain: Array, authType: String) {} + override fun checkServerTrusted(chain: Array, authType: String) {} + override fun getAcceptedIssuers(): Array = arrayOf() + } + ) + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, trustAllCerts, java.security.SecureRandom()) + connection.sslSocketFactory(sslContext.socketFactory) + javax.net.ssl.HttpsURLConnection.setDefaultHostnameVerifier { _, _ -> true } + } + + return connection + } protected fun Element.absoluteUrl(attributeKey: String): String { return resolveUrl(attr(attributeKey)) diff --git a/app/src/main/java/io/aatricks/novelscraper/data/repository/source/MangaBatSource.kt b/app/src/main/java/io/aatricks/novelscraper/data/repository/source/MangaBatSource.kt index ecb735a..231064a 100644 --- a/app/src/main/java/io/aatricks/novelscraper/data/repository/source/MangaBatSource.kt +++ b/app/src/main/java/io/aatricks/novelscraper/data/repository/source/MangaBatSource.kt @@ -1,9 +1,14 @@ package io.aatricks.novelscraper.data.repository.source +import io.aatricks.novelscraper.data.local.PreferencesManager import io.aatricks.novelscraper.data.model.ExploreItem import java.net.URLEncoder +import javax.inject.Inject -class MangaBatSource : BaseJsoupSource() { +class MangaBatSource @Inject constructor( + override val preferencesManager: PreferencesManager, + override val okHttpClient: okhttp3.OkHttpClient +) : BaseJsoupSource(preferencesManager, okHttpClient) { override val name = "MangaBat" override val baseUrl = "https://www.mangabats.com" diff --git a/app/src/main/java/io/aatricks/novelscraper/data/repository/source/NovelFireSource.kt b/app/src/main/java/io/aatricks/novelscraper/data/repository/source/NovelFireSource.kt index 59a2b81..6c17dc0 100644 --- a/app/src/main/java/io/aatricks/novelscraper/data/repository/source/NovelFireSource.kt +++ b/app/src/main/java/io/aatricks/novelscraper/data/repository/source/NovelFireSource.kt @@ -1,5 +1,6 @@ package io.aatricks.novelscraper.data.repository.source +import io.aatricks.novelscraper.data.local.PreferencesManager import io.aatricks.novelscraper.data.model.ExploreItem import io.aatricks.novelscraper.data.model.ChapterInfo import kotlinx.coroutines.Dispatchers @@ -9,8 +10,12 @@ import kotlinx.coroutines.withContext import org.json.JSONObject import org.jsoup.Jsoup import java.net.URLEncoder +import javax.inject.Inject -class NovelFireSource : BaseJsoupSource() { +class NovelFireSource @Inject constructor( + override val preferencesManager: PreferencesManager, + override val okHttpClient: okhttp3.OkHttpClient +) : BaseJsoupSource(preferencesManager, okHttpClient) { override val name = "NovelFire" override val baseUrl = "https://novelfire.net" diff --git a/app/src/main/java/io/aatricks/novelscraper/di/NetworkModule.kt b/app/src/main/java/io/aatricks/novelscraper/di/NetworkModule.kt index 9b4af3e..f7513b0 100644 --- a/app/src/main/java/io/aatricks/novelscraper/di/NetworkModule.kt +++ b/app/src/main/java/io/aatricks/novelscraper/di/NetworkModule.kt @@ -9,9 +9,15 @@ import io.ktor.client.engine.okhttp.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.serialization.kotlinx.json.* import kotlinx.serialization.json.Json +import io.aatricks.novelscraper.data.local.PreferencesManager import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor +import java.security.SecureRandom +import java.security.cert.X509Certificate import javax.inject.Singleton +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager @Module @InstallIn(SingletonComponent::class) @@ -25,12 +31,71 @@ object NetworkModule { @Provides @Singleton - fun provideOkHttpClient(): OkHttpClient { - return OkHttpClient.Builder() + fun provideOkHttpClient(preferencesManager: PreferencesManager): OkHttpClient { + val builder = OkHttpClient.Builder() .addInterceptor(HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BODY + level = HttpLoggingInterceptor.Level.HEADERS // Reduced verbosity for performance }) - .build() + .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .followSslRedirects(true) + .followRedirects(true) + + try { + val trustManagerFactory = javax.net.ssl.TrustManagerFactory.getInstance( + javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm() + ) + trustManagerFactory.init(null as java.security.KeyStore?) + val defaultTrustManager = trustManagerFactory.trustManagers.first { tm -> tm is X509TrustManager } as X509TrustManager + + val dynamicTrustManager = object : X509TrustManager { + override fun checkClientTrusted(chain: Array, authType: String) { + if (!preferencesManager.ignoreSslErrors) { + defaultTrustManager.checkClientTrusted(chain, authType) + } + } + + override fun checkServerTrusted(chain: Array, authType: String) { + if (preferencesManager.ignoreSslErrors) return + + try { + defaultTrustManager.checkServerTrusted(chain, authType) + } catch (e: Exception) { + // Double check in case of race condition or update + if (!preferencesManager.ignoreSslErrors) throw e + } + } + + override fun getAcceptedIssuers(): Array = defaultTrustManager.getAcceptedIssuers() + } + + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, arrayOf(dynamicTrustManager), SecureRandom()) + + builder.sslSocketFactory(sslContext.socketFactory, dynamicTrustManager) + + builder.hostnameVerifier { hostname, session -> + if (preferencesManager.ignoreSslErrors) true + else okhttp3.internal.tls.OkHostnameVerifier.verify(hostname, session) + } + } catch (e: Exception) { + // Fallback to a basic trust-all if something fails during setup and user wants to ignore errors + if (preferencesManager.ignoreSslErrors) { + try { + val trustAllCerts = object : X509TrustManager { + override fun checkClientTrusted(chain: Array, authType: String) {} + override fun checkServerTrusted(chain: Array, authType: String) {} + override fun getAcceptedIssuers(): Array = arrayOf() + } + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, arrayOf(trustAllCerts), SecureRandom()) + builder.sslSocketFactory(sslContext.socketFactory, trustAllCerts) + builder.hostnameVerifier { _, _ -> true } + } catch (inner: Exception) {} + } + } + + return builder.build() } @Provides diff --git a/app/src/main/java/io/aatricks/novelscraper/di/SourceModule.kt b/app/src/main/java/io/aatricks/novelscraper/di/SourceModule.kt index cec83e6..1cd596b 100644 --- a/app/src/main/java/io/aatricks/novelscraper/di/SourceModule.kt +++ b/app/src/main/java/io/aatricks/novelscraper/di/SourceModule.kt @@ -5,6 +5,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import dagger.multibindings.IntoSet +import io.aatricks.novelscraper.data.local.PreferencesManager import io.aatricks.novelscraper.data.repository.source.MangaBatSource import io.aatricks.novelscraper.data.repository.source.NovelFireSource import io.aatricks.novelscraper.data.repository.source.NovelSource @@ -16,10 +17,16 @@ object SourceModule { @Provides @Singleton @IntoSet - fun provideNovelFireSource(): NovelSource = NovelFireSource() + fun provideNovelFireSource( + preferencesManager: PreferencesManager, + okHttpClient: okhttp3.OkHttpClient + ): NovelSource = NovelFireSource(preferencesManager, okHttpClient) @Provides @Singleton @IntoSet - fun provideMangaBatSource(): NovelSource = MangaBatSource() + fun provideMangaBatSource( + preferencesManager: PreferencesManager, + okHttpClient: okhttp3.OkHttpClient + ): NovelSource = MangaBatSource(preferencesManager, okHttpClient) } diff --git a/app/src/main/java/io/aatricks/novelscraper/ui/components/ReaderImageView.kt b/app/src/main/java/io/aatricks/novelscraper/ui/components/ReaderImageView.kt index 24de133..2947e48 100644 --- a/app/src/main/java/io/aatricks/novelscraper/ui/components/ReaderImageView.kt +++ b/app/src/main/java/io/aatricks/novelscraper/ui/components/ReaderImageView.kt @@ -51,7 +51,13 @@ fun ReaderImageView( val imageRequest = remember(imageUrl, pageUrl) { val uri = try { java.net.URI(pageUrl) } catch (e: Exception) { null } - val referer = if (uri != null) "${uri.scheme}://${uri.host}/" else pageUrl + var referer = if (uri != null) "${uri.scheme}://${uri.host}/" else pageUrl + + // Special handling for MangaBat/Manganato images which often require specific referers + if (referer.contains("mangabat") || referer.contains("manganato")) { + referer = "https://manganato.com/" + } + val isCached = cachedFile.exists() ImageRequest.Builder(context) diff --git a/app/src/main/java/io/aatricks/novelscraper/ui/screens/LibraryDrawerContent.kt b/app/src/main/java/io/aatricks/novelscraper/ui/screens/LibraryDrawerContent.kt index 52ee297..b40d176 100644 --- a/app/src/main/java/io/aatricks/novelscraper/ui/screens/LibraryDrawerContent.kt +++ b/app/src/main/java/io/aatricks/novelscraper/ui/screens/LibraryDrawerContent.kt @@ -49,6 +49,7 @@ fun LibraryDrawerContent( var urlInput by remember { mutableStateOf("") } var isAddSectionVisible by remember { mutableStateOf(false) } + var isSettingsVisible by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() LaunchedEffect(Unit) { @@ -67,9 +68,18 @@ fun LibraryDrawerContent( onExploreClick = { onCloseDrawer() navController.navigate(ExploreRoute) - } + }, + onSettingsClick = { isSettingsVisible = true } ) + if (isSettingsVisible) { + SettingsDialog( + ignoreSslErrors = libraryUiState.ignoreSslErrors, + onIgnoreSslErrorsChange = { libraryViewModel.ignoreSslErrors = it }, + onDismiss = { isSettingsVisible = false } + ) + } + androidx.compose.animation.AnimatedVisibility(visible = isAddSectionVisible) { AddNovelSection( urlInput = urlInput, @@ -120,7 +130,8 @@ fun LibraryDrawerContent( private fun LibraryHeader( isAddVisible: Boolean, onToggleAdd: () -> Unit, - onExploreClick: () -> Unit + onExploreClick: () -> Unit, + onSettingsClick: () -> Unit ): Unit { Row( modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), @@ -134,6 +145,13 @@ private fun LibraryHeader( color = MaterialTheme.colorScheme.onSurface ) Row { + IconButton(onClick = onSettingsClick) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Settings", + tint = MaterialTheme.colorScheme.onSurface + ) + } IconButton(onClick = onExploreClick) { Icon( imageVector = Icons.Default.Image, @@ -620,6 +638,45 @@ private fun NovelChapterList( } } +@Composable +private fun SettingsDialog( + ignoreSslErrors: Boolean, + onIgnoreSslErrorsChange: (Boolean) -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("App Settings") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Ignore SSL Errors", style = MaterialTheme.typography.bodyLarge) + Text( + "Enable if you encounter certificate errors on public Wi-Fi. USE WITH CAUTION.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = ignoreSslErrors, + onCheckedChange = onIgnoreSslErrorsChange + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Close") + } + } + ) +} + @Composable private fun EmptyLibraryState() { Box( diff --git a/app/src/main/java/io/aatricks/novelscraper/ui/screens/ReaderScreen.kt b/app/src/main/java/io/aatricks/novelscraper/ui/screens/ReaderScreen.kt index bdbbd42..75f880d 100644 --- a/app/src/main/java/io/aatricks/novelscraper/ui/screens/ReaderScreen.kt +++ b/app/src/main/java/io/aatricks/novelscraper/ui/screens/ReaderScreen.kt @@ -3,6 +3,9 @@ package io.aatricks.novelscraper.ui.screens import android.app.Activity import android.webkit.WebView import android.webkit.WebViewClient +import android.webkit.WebSettings +import android.content.Intent +import android.net.Uri import androidx.activity.compose.BackHandler import androidx.compose.animation.* import androidx.compose.animation.core.animateFloatAsState @@ -16,6 +19,10 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.ui.draw.clip import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.* import androidx.compose.material.icons.filled.* @@ -38,6 +45,8 @@ import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat @@ -88,7 +97,7 @@ fun ReaderScreen( LaunchedEffect(uiState.error) { if (uiState.error?.contains("403") == true || uiState.error?.contains("503") == true) { - cloudflareUrl = uiState.content?.url ?: "" + cloudflareUrl = uiState.lastAttemptedUrl ?: uiState.content?.url ?: "" if (cloudflareUrl.startsWith("http")) { showCloudflareWebView = true } @@ -140,7 +149,8 @@ fun ReaderScreen( onRetry = { showCloudflareWebView = false readerViewModel.retryLoad() - } + }, + ignoreSslErrors = libraryViewModel.ignoreSslErrors ) } @@ -221,46 +231,165 @@ fun ReaderScreen( private fun CloudflareDialog( url: String, onDismiss: () -> Unit, - onRetry: () -> Unit + onRetry: () -> Unit, + ignoreSslErrors: Boolean = false ): Unit { - AlertDialog( + val context = LocalContext.current + var webViewError by remember { mutableStateOf(null) } + + Dialog( onDismissRequest = onDismiss, - title = { Text("Solve Challenge") }, - text = { - Column { - Text( - "Please solve the challenge to continue reading.", - style = MaterialTheme.typography.bodySmall - ) - Spacer(modifier = Modifier.height(8.dp)) + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + modifier = Modifier + .fillMaxWidth(0.95f) + .fillMaxHeight(0.85f) + .clip(RoundedCornerShape(16.dp)), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp + ) { + Column(modifier = Modifier.fillMaxSize()) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "Network Access Required", + style = MaterialTheme.typography.titleLarge + ) + Text( + "Solve the challenge or login below", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (webViewError != null) { + Icon( + Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + } + } + + if (webViewError != null) { + Text( + text = "Error: $webViewError", + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelSmall + ) + } + + // WebView Container Box( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(8.dp)) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(8.dp)) + .background(Color.White) + ) { + var internalWebView by remember { mutableStateOf(null) } + + AndroidView( + factory = { ctx -> + WebView(ctx).apply { + internalWebView = this + settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + loadWithOverviewMode = true + useWideViewPort = true + builtInZoomControls = true + displayZoomControls = false + mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + javaScriptCanOpenWindowsAutomatically = true + userAgentString = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + } + webViewClient = object : WebViewClient() { + override fun onReceivedSslError( + view: WebView?, + handler: android.webkit.SslErrorHandler?, + error: android.net.http.SslError? + ) { + if (ignoreSslErrors) handler?.proceed() + else super.onReceivedSslError(view, handler, error) + } + + override fun onReceivedError( + view: WebView?, + request: android.webkit.WebResourceRequest?, + error: android.webkit.WebResourceError? + ) { + if (request?.isForMainFrame == true) { + webViewError = error?.description?.toString() + } + } + } + loadUrl(url) + } + }, + modifier = Modifier.fillMaxSize() + ) + + // Floating Reload Button + if (webViewError != null) { + FilledIconButton( + onClick = { + webViewError = null + internalWebView?.reload() + }, + modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp) + ) { + Icon(Icons.Default.Refresh, contentDescription = "Reload") + } + } + } + + // Footer Actions + Row( modifier = Modifier .fillMaxWidth() - .height(400.dp) + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically ) { - AndroidView(factory = { ctx -> - WebView(ctx).apply { - settings.javaScriptEnabled = true - settings.userAgentString = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" - webViewClient = object : WebViewClient() {} - loadUrl(url) + TextButton( + onClick = { + runCatching { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + context.startActivity(intent) + } } - }) + ) { + Icon(Icons.Default.OpenInBrowser, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(4.dp)) + Text("Open in Browser") + } + + Spacer(modifier = Modifier.weight(1f)) + + TextButton(onClick = onDismiss) { + Text("Cancel") + } + + Button( + onClick = onRetry, + shape = RoundedCornerShape(8.dp) + ) { + Text("Done") + } } } - }, - confirmButton = { - Button(onClick = onRetry) { - Text("Done") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } } - ) + } } @Composable diff --git a/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/LibraryViewModel.kt b/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/LibraryViewModel.kt index 07857ac..7402a4e 100644 --- a/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/LibraryViewModel.kt +++ b/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/LibraryViewModel.kt @@ -1,10 +1,11 @@ package io.aatricks.novelscraper.ui.viewmodel -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import io.aatricks.novelscraper.data.local.PreferencesManager import io.aatricks.novelscraper.data.model.ContentType import io.aatricks.novelscraper.data.model.LibraryItem +import io.aatricks.novelscraper.data.model.SortMode import io.aatricks.novelscraper.data.repository.ContentRepository import io.aatricks.novelscraper.data.repository.ExploreRepository import io.aatricks.novelscraper.data.repository.LibraryRepository @@ -18,11 +19,19 @@ import android.util.Log class LibraryViewModel @Inject constructor( val repository: LibraryRepository, private val contentRepository: ContentRepository, - private val exploreRepository: ExploreRepository + private val exploreRepository: ExploreRepository, + private val preferencesManager: PreferencesManager ) : BaseViewModel(LibraryUiState()) { private val TAG = "LibraryViewModel" + var ignoreSslErrors: Boolean + get() = preferencesManager.ignoreSslErrors + set(value) { + preferencesManager.ignoreSslErrors = value + updateState { it.copy(ignoreSslErrors = value) } + } + private val _searchQuery = MutableStateFlow("") val searchQuery: StateFlow = _searchQuery.asStateFlow() @@ -48,16 +57,10 @@ class LibraryViewModel @Inject constructor( val selectedIds: Set = emptySet(), val selectedCount: Int = 0, val isEmpty: Boolean = true, - val currentlyReading: LibraryItem? = null + val currentlyReading: LibraryItem? = null, + val ignoreSslErrors: Boolean = false ) - enum class SortMode { - LAST_READ, - DATE_ADDED, - TITLE, - PROGRESS - } - private fun observeLibraryChanges(): Unit { viewModelScope.launch { val repoFlow = combine( @@ -88,7 +91,8 @@ class LibraryViewModel @Inject constructor( selectedIds = selectedIds, selectedCount = selectedIds.size, isEmpty = items.isEmpty(), - currentlyReading = items.find { it.isCurrentlyReading } + currentlyReading = items.find { it.isCurrentlyReading }, + ignoreSslErrors = preferencesManager.ignoreSslErrors ) }.collect { newState -> updateState { newState } diff --git a/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/ReaderViewModel.kt b/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/ReaderViewModel.kt index ca346f6..055e93b 100644 --- a/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/ReaderViewModel.kt +++ b/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/ReaderViewModel.kt @@ -72,6 +72,7 @@ class ReaderViewModel @Inject constructor( val isLoading: Boolean = true, val isNavigating: Boolean = false, val error: String? = null, + val lastAttemptedUrl: String? = null, val toastMessage: String? = null, val scrollPosition: Float = 0f, val scrollProgress: Int = 0, @@ -150,10 +151,20 @@ class ReaderViewModel @Inject constructor( if (handleEpubUrl(url, libraryItemId, fromBottom, isSilent)) return@launch saveCurrentProgress() - updateState { it.copy(isLoading = !isSilent, error = null) } + updateState { + it.copy( + isLoading = !isSilent, + error = null, + lastAttemptedUrl = url, + content = if (isSilent) it.content else null + ) + } when (val result = contentRepository.loadContent(url)) { - is ContentRepository.ContentResult.Success -> handleLoadSuccess(result, libraryItemId, fromBottom) + is ContentRepository.ContentResult.Success -> { + updateState { it.copy(lastAttemptedUrl = null) } + handleLoadSuccess(result, libraryItemId, fromBottom) + } is ContentRepository.ContentResult.Error -> handleLoadError(result) } } @@ -570,8 +581,9 @@ class ReaderViewModel @Inject constructor( } fun retryLoad(): Unit { - _uiState.value.content?.url?.let { url -> - loadContent(url, currentLibraryItemId) + val url = _uiState.value.lastAttemptedUrl ?: _uiState.value.content?.url + url?.let { + loadContent(it, currentLibraryItemId) } }