feat: add Dead Links management module and internal browser screen
This commit is contained in:
parent
2198324c2d
commit
b0a6e8100b
@ -167,6 +167,9 @@ dependencies {
|
|||||||
// JSoup for HTML parsing (metadata extraction)
|
// JSoup for HTML parsing (metadata extraction)
|
||||||
implementation(libs.jsoup)
|
implementation(libs.jsoup)
|
||||||
|
|
||||||
|
// Readability for better article extraction
|
||||||
|
implementation("net.dankito.readability4j:readability4j:1.0.8")
|
||||||
|
|
||||||
// Biometric
|
// Biometric
|
||||||
implementation(libs.androidx.biometric)
|
implementation(libs.androidx.biometric)
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,14 @@ import org.jsoup.Jsoup
|
|||||||
import org.jsoup.nodes.Document
|
import org.jsoup.nodes.Document
|
||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
import org.jsoup.safety.Safelist
|
import org.jsoup.safety.Safelist
|
||||||
|
import net.dankito.readability4j.Readability4J
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.MainScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.coroutines.resume
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@ -24,53 +32,19 @@ data class ReadableArticle(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracteur d'articles style Readability basé sur JSoup.
|
* Extracteur d'articles basé sur Readability4J (portage de Mozilla Readability).
|
||||||
* Extrait le contenu principal d'une page web en supprimant navigation, pubs, sidebars, etc.
|
* Extrait le contenu principal d'une page web en supprimant navigation, pubs, sidebars, etc.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class ArticleExtractor @Inject constructor() {
|
class ArticleExtractor @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "ArticleExtractor"
|
private const val TAG = "ArticleExtractor"
|
||||||
private const val TIMEOUT_MS = 15000
|
private const val TIMEOUT_MS = 15000
|
||||||
private const val WORDS_PER_MINUTE = 200
|
private const val WORDS_PER_MINUTE = 200
|
||||||
|
|
||||||
// Éléments à supprimer systématiquement
|
|
||||||
private val REMOVE_SELECTORS = listOf(
|
|
||||||
"script", "style", "noscript", "iframe", "object", "embed",
|
|
||||||
"nav", "header:not(article header)", "footer:not(article footer)",
|
|
||||||
".sidebar", "#sidebar", ".widget", ".ad", ".ads", ".advert",
|
|
||||||
".advertisement", "[class*=advert]", "[id*=advert]",
|
|
||||||
".social-share", ".share-buttons", ".sharing",
|
|
||||||
".comments", "#comments", ".comment-section",
|
|
||||||
".related-posts", ".related-articles", ".recommended",
|
|
||||||
".newsletter", ".subscribe", ".popup", ".modal",
|
|
||||||
".cookie-banner", ".cookie-notice", ".gdpr",
|
|
||||||
".breadcrumb", ".breadcrumbs", ".pagination",
|
|
||||||
".menu", ".navigation", "#navigation",
|
|
||||||
"[role=navigation]", "[role=banner]", "[role=complementary]",
|
|
||||||
".toc", "#toc", ".table-of-contents"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Sélecteurs pour trouver le contenu principal (ordre de priorité)
|
|
||||||
private val CONTENT_SELECTORS = listOf(
|
|
||||||
"article",
|
|
||||||
"[role=main]",
|
|
||||||
"main",
|
|
||||||
".post-content",
|
|
||||||
".article-content",
|
|
||||||
".entry-content",
|
|
||||||
".content-body",
|
|
||||||
".article-body",
|
|
||||||
".post-body",
|
|
||||||
".story-body",
|
|
||||||
"#article-body",
|
|
||||||
"#content",
|
|
||||||
".content",
|
|
||||||
".post",
|
|
||||||
".article"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Safelist HTML permise dans le contenu nettoyé
|
// Safelist HTML permise dans le contenu nettoyé
|
||||||
private val READER_SAFELIST = Safelist.relaxed()
|
private val READER_SAFELIST = Safelist.relaxed()
|
||||||
.addTags("figure", "figcaption", "picture", "source", "video", "audio")
|
.addTags("figure", "figcaption", "picture", "source", "video", "audio")
|
||||||
@ -91,14 +65,98 @@ class ArticleExtractor @Inject constructor() {
|
|||||||
val doc = Jsoup.connect(url)
|
val doc = Jsoup.connect(url)
|
||||||
.timeout(TIMEOUT_MS)
|
.timeout(TIMEOUT_MS)
|
||||||
.userAgent("Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36")
|
.userAgent("Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36")
|
||||||
|
.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.5")
|
||||||
.followRedirects(true)
|
.followRedirects(true)
|
||||||
.maxBodySize(5 * 1024 * 1024) // 5 MB max
|
.maxBodySize(5 * 1024 * 1024) // 5 MB max
|
||||||
.get()
|
.get()
|
||||||
|
|
||||||
extractFromDocument(doc, url)
|
val article = extractFromDocument(doc, url)
|
||||||
|
if (article == null || article.wordCount < 50) {
|
||||||
|
// Si l'extraction JSoup échoue ou renvoie très peu de texte (ex: Cloudflare protection),
|
||||||
|
// on essaie avec WebView
|
||||||
|
Log.d(TAG, "JSoup n'a pas pu extraire assez de contenu, essai avec WebView...")
|
||||||
|
return@withContext extractWithWebView(url)
|
||||||
|
}
|
||||||
|
article
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Erreur extraction article pour $url", e)
|
Log.e(TAG, "Erreur extraction article pour $url", e)
|
||||||
null
|
extractWithWebView(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback: Utilise une WebView cachée pour exécuter le JavaScript (contourne Cloudflare/SPAs)
|
||||||
|
*/
|
||||||
|
private suspend fun extractWithWebView(url: String): ReadableArticle? = withContext(Dispatchers.Main) {
|
||||||
|
suspendCancellableCoroutine { continuation ->
|
||||||
|
var isResumed = false
|
||||||
|
try {
|
||||||
|
val webView = android.webkit.WebView(context).apply {
|
||||||
|
settings.javaScriptEnabled = true
|
||||||
|
settings.domStorageEnabled = true
|
||||||
|
settings.loadsImagesAutomatically = false // Pas besoin de charger les images pour le DOM
|
||||||
|
settings.userAgentString = "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
|
||||||
|
}
|
||||||
|
// Timeout global de 15 secondes
|
||||||
|
val timeoutJob = MainScope().launch {
|
||||||
|
delay(15000)
|
||||||
|
if (!isResumed && continuation.isActive) {
|
||||||
|
isResumed = true
|
||||||
|
webView.stopLoading()
|
||||||
|
webView.destroy()
|
||||||
|
continuation.resume(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
webView.webViewClient = object : android.webkit.WebViewClient() {
|
||||||
|
override fun onPageFinished(view: android.webkit.WebView?, pageUrl: String?) {
|
||||||
|
// Attendre un peu que le JS modifie le DOM (Cloudflare / React)
|
||||||
|
MainScope().launch {
|
||||||
|
delay(3000)
|
||||||
|
if (isResumed || !continuation.isActive) return@launch
|
||||||
|
|
||||||
|
view?.evaluateJavascript(
|
||||||
|
"(function() { return document.documentElement.outerHTML; })();"
|
||||||
|
) { html ->
|
||||||
|
if (isResumed || !continuation.isActive) return@evaluateJavascript
|
||||||
|
isResumed = true
|
||||||
|
timeoutJob.cancel()
|
||||||
|
|
||||||
|
val unescaped = try {
|
||||||
|
if (html != null && html != "null") {
|
||||||
|
org.json.JSONTokener(html).nextValue() as? String ?: html
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
html ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val doc = Jsoup.parse(unescaped, url)
|
||||||
|
val article = extractFromDocument(doc, url)
|
||||||
|
|
||||||
|
view?.destroy()
|
||||||
|
continuation.resume(article)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
webView.loadUrl(url)
|
||||||
|
|
||||||
|
continuation.invokeOnCancellation {
|
||||||
|
timeoutJob.cancel()
|
||||||
|
if (!isResumed) {
|
||||||
|
webView.stopLoading()
|
||||||
|
webView.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (continuation.isActive && !isResumed) {
|
||||||
|
continuation.resume(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,29 +165,27 @@ class ArticleExtractor @Inject constructor() {
|
|||||||
*/
|
*/
|
||||||
fun extractFromDocument(doc: Document, baseUrl: String): ReadableArticle? {
|
fun extractFromDocument(doc: Document, baseUrl: String): ReadableArticle? {
|
||||||
return try {
|
return try {
|
||||||
// Extraire les métadonnées
|
// Extraire les métadonnées avec JSoup (plus robuste que Readability pour le site_name/image)
|
||||||
val title = extractTitle(doc)
|
|
||||||
val author = extractAuthor(doc)
|
|
||||||
val siteName = extractSiteName(doc, baseUrl)
|
val siteName = extractSiteName(doc, baseUrl)
|
||||||
val leadImage = extractLeadImage(doc, baseUrl)
|
val leadImage = extractLeadImage(doc, baseUrl)
|
||||||
|
|
||||||
// Nettoyer le document
|
// Utiliser Readability4J pour extraire l'article principal
|
||||||
val cleanDoc = doc.clone()
|
val readability = Readability4J(baseUrl, doc.html())
|
||||||
removeUnwantedElements(cleanDoc)
|
val article = readability.parse()
|
||||||
|
|
||||||
// Trouver le contenu principal
|
if (article.content == null || article.textContent == null) {
|
||||||
val mainContent = findMainContent(cleanDoc)
|
return null
|
||||||
?: return null
|
}
|
||||||
|
|
||||||
// Nettoyer le HTML du contenu principal
|
// Nettoyer le HTML du contenu principal
|
||||||
val cleanHtml = Jsoup.clean(
|
val cleanHtml = Jsoup.clean(
|
||||||
mainContent.html(),
|
article.content!!,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
READER_SAFELIST
|
READER_SAFELIST
|
||||||
)
|
)
|
||||||
|
|
||||||
// Calculer les stats
|
// Calculer les stats
|
||||||
val textContent = Jsoup.parse(cleanHtml).text()
|
val textContent = article.textContent!!
|
||||||
val wordCount = textContent.split(Regex("\\s+")).filter { it.isNotBlank() }.size
|
val wordCount = textContent.split(Regex("\\s+")).filter { it.isNotBlank() }.size
|
||||||
val readingTime = maxOf(1, wordCount / WORDS_PER_MINUTE)
|
val readingTime = maxOf(1, wordCount / WORDS_PER_MINUTE)
|
||||||
|
|
||||||
@ -139,8 +195,8 @@ class ArticleExtractor @Inject constructor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ReadableArticle(
|
ReadableArticle(
|
||||||
title = title ?: "Sans titre",
|
title = article.title ?: extractTitle(doc) ?: "Sans titre",
|
||||||
author = author,
|
author = article.byline ?: extractAuthor(doc),
|
||||||
siteName = siteName,
|
siteName = siteName,
|
||||||
content = cleanHtml,
|
content = cleanHtml,
|
||||||
leadImage = leadImage,
|
leadImage = leadImage,
|
||||||
@ -214,110 +270,6 @@ class ArticleExtractor @Inject constructor() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun removeUnwantedElements(doc: Document) {
|
|
||||||
for (selector in REMOVE_SELECTORS) {
|
|
||||||
try {
|
|
||||||
doc.select(selector).remove()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// Ignorer les erreurs de sélecteur
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trouve le contenu principal en utilisant des heuristiques.
|
|
||||||
* Essaie d'abord les sélecteurs connus, puis scoring par densité de texte.
|
|
||||||
*/
|
|
||||||
private fun findMainContent(doc: Document): Element? {
|
|
||||||
// 1. Essayer les sélecteurs connus
|
|
||||||
for (selector in CONTENT_SELECTORS) {
|
|
||||||
val candidates = doc.select(selector)
|
|
||||||
if (candidates.isNotEmpty()) {
|
|
||||||
// Prendre le candidat avec le plus de texte
|
|
||||||
val best = candidates.maxByOrNull { it.text().length }
|
|
||||||
if (best != null && best.text().length > 200) {
|
|
||||||
return best
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Scoring par densité de texte sur les <div> et <section>
|
|
||||||
val candidates = doc.select("div, section")
|
|
||||||
if (candidates.isEmpty()) return doc.body()
|
|
||||||
|
|
||||||
var bestElement: Element? = null
|
|
||||||
var bestScore = 0.0
|
|
||||||
|
|
||||||
for (element in candidates) {
|
|
||||||
val score = scoreElement(element)
|
|
||||||
if (score > bestScore) {
|
|
||||||
bestScore = score
|
|
||||||
bestElement = element
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bestElement ?: doc.body()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Score un élément selon sa probabilité de contenir le contenu principal.
|
|
||||||
* Inspiré de l'algorithme Readability de Mozilla.
|
|
||||||
*/
|
|
||||||
private fun scoreElement(element: Element): Double {
|
|
||||||
var score = 0.0
|
|
||||||
|
|
||||||
// Texte direct (pas dans les enfants)
|
|
||||||
val text = element.ownText()
|
|
||||||
val textLength = text.length
|
|
||||||
|
|
||||||
// Plus de texte = plus probable
|
|
||||||
score += textLength * 0.1
|
|
||||||
|
|
||||||
// Nombre de paragraphes
|
|
||||||
val paragraphs = element.select("> p, > div > p")
|
|
||||||
score += paragraphs.size * 10.0
|
|
||||||
|
|
||||||
// Nombre de balises de contenu (images, code, etc.)
|
|
||||||
score += element.select("img").size * 3.0
|
|
||||||
score += element.select("pre, code").size * 5.0
|
|
||||||
score += element.select("blockquote").size * 3.0
|
|
||||||
score += element.select("h2, h3, h4").size * 5.0
|
|
||||||
|
|
||||||
// Pénalité pour les liens (haute densité = probablement navigation)
|
|
||||||
val links = element.select("a")
|
|
||||||
val linkTextLength = links.sumOf { it.text().length }
|
|
||||||
val totalTextLength = element.text().length
|
|
||||||
if (totalTextLength > 0) {
|
|
||||||
val linkDensity = linkTextLength.toDouble() / totalTextLength
|
|
||||||
if (linkDensity > 0.5) {
|
|
||||||
score *= 0.2 // Forte pénalité
|
|
||||||
} else if (linkDensity > 0.3) {
|
|
||||||
score *= 0.5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pénalité pour les éléments trop courts
|
|
||||||
if (totalTextLength < 100) {
|
|
||||||
score *= 0.1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bonus pour les classes/ids évocateurs
|
|
||||||
val classId = "${element.className()} ${element.id()}".lowercase()
|
|
||||||
if (classId.contains("article") || classId.contains("content") ||
|
|
||||||
classId.contains("post") || classId.contains("entry") ||
|
|
||||||
classId.contains("text") || classId.contains("body")) {
|
|
||||||
score *= 1.5
|
|
||||||
}
|
|
||||||
if (classId.contains("comment") || classId.contains("sidebar") ||
|
|
||||||
classId.contains("footer") || classId.contains("header") ||
|
|
||||||
classId.contains("nav") || classId.contains("menu") ||
|
|
||||||
classId.contains("ad") || classId.contains("widget")) {
|
|
||||||
score *= 0.2
|
|
||||||
}
|
|
||||||
|
|
||||||
return score
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolveUrl(url: String, baseUrl: String): String {
|
private fun resolveUrl(url: String, baseUrl: String): String {
|
||||||
return when {
|
return when {
|
||||||
url.startsWith("http") -> url
|
url.startsWith("http") -> url
|
||||||
|
|||||||
@ -0,0 +1,136 @@
|
|||||||
|
package com.shaarit.presentation.browser
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import java.net.URLDecoder
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
@Composable
|
||||||
|
fun InternalBrowserScreen(
|
||||||
|
encodedUrl: String,
|
||||||
|
onNavigateBack: () -> Unit
|
||||||
|
) {
|
||||||
|
val url = remember(encodedUrl) { URLDecoder.decode(encodedUrl, "UTF-8") }
|
||||||
|
var pageTitle by remember { mutableStateOf(url) }
|
||||||
|
var isLoading by remember { mutableStateOf(true) }
|
||||||
|
var progress by remember { mutableFloatStateOf(0f) }
|
||||||
|
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = pageTitle,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = url,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Fermer")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
if (webView?.canGoBack() == true) {
|
||||||
|
webView?.goBack()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = webView?.canGoBack() == true
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.ArrowBack, contentDescription = "Retour")
|
||||||
|
}
|
||||||
|
IconButton(onClick = { webView?.reload() }) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = "Actualiser")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(visible = isLoading) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = progress,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AndroidView(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
factory = { context ->
|
||||||
|
WebView(context).apply {
|
||||||
|
settings.javaScriptEnabled = true
|
||||||
|
settings.domStorageEnabled = true
|
||||||
|
settings.loadsImagesAutomatically = true
|
||||||
|
|
||||||
|
webViewClient = object : WebViewClient() {
|
||||||
|
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||||
|
super.onPageStarted(view, url, favicon)
|
||||||
|
isLoading = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
|
super.onPageFinished(view, url)
|
||||||
|
isLoading = false
|
||||||
|
view?.title?.let { if (it.isNotBlank()) pageTitle = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
webChromeClient = object : WebChromeClient() {
|
||||||
|
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||||
|
super.onProgressChanged(view, newProgress)
|
||||||
|
progress = newProgress / 100f
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceivedTitle(view: WebView?, title: String?) {
|
||||||
|
super.onReceivedTitle(view, title)
|
||||||
|
if (!title.isNullOrBlank()) {
|
||||||
|
pageTitle = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadUrl(url)
|
||||||
|
webView = this
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = {
|
||||||
|
webView = it
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,6 +14,9 @@ import androidx.compose.material.icons.filled.Delete
|
|||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.Folder
|
||||||
|
import androidx.compose.material.icons.filled.Link
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@ -43,6 +46,8 @@ fun DeadLinksScreen(
|
|||||||
val selectedLinkIds by viewModel.selectedLinkIds.collectAsState()
|
val selectedLinkIds by viewModel.selectedLinkIds.collectAsState()
|
||||||
val isSelectionMode by viewModel.isSelectionMode.collectAsState()
|
val isSelectionMode by viewModel.isSelectionMode.collectAsState()
|
||||||
val testResults by viewModel.linkTestResults.collectAsState()
|
val testResults by viewModel.linkTestResults.collectAsState()
|
||||||
|
val collections by viewModel.collections.collectAsState()
|
||||||
|
var showAddToCollectionDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@ -83,9 +88,25 @@ fun DeadLinksScreen(
|
|||||||
actions = {
|
actions = {
|
||||||
if (isSelectionMode) {
|
if (isSelectionMode) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = { showAddToCollectionDialog = true }
|
||||||
viewModel.excludeSelectedFromHealthCheck()
|
) {
|
||||||
}
|
Icon(
|
||||||
|
imageVector = Icons.Default.Folder,
|
||||||
|
contentDescription = "Ajouter à une collection",
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.verifySelectedLinks() }
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Link,
|
||||||
|
contentDescription = "Validation de fonctionnement des liens",
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.excludeSelectedFromHealthCheck() }
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.CheckCircle,
|
imageVector = Icons.Default.CheckCircle,
|
||||||
@ -103,6 +124,43 @@ fun DeadLinksScreen(
|
|||||||
},
|
},
|
||||||
containerColor = Color.Transparent
|
containerColor = Color.Transparent
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
|
if (showAddToCollectionDialog) {
|
||||||
|
val regularCollections = remember(collections) { collections.filter { !it.isSmart } }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showAddToCollectionDialog = false },
|
||||||
|
title = { Text("Ajouter à une collection") },
|
||||||
|
text = {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
if (regularCollections.isEmpty()) {
|
||||||
|
Text("Aucune collection disponible.")
|
||||||
|
} else {
|
||||||
|
regularCollections.forEach { c ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
viewModel.addLinksToCollection(c.id, selectedLinkIds)
|
||||||
|
showAddToCollectionDialog = false
|
||||||
|
}
|
||||||
|
.padding(vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(c.icon)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Text(c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { showAddToCollectionDialog = false }) {
|
||||||
|
Text("Fermer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
|
|||||||
@ -7,24 +7,35 @@ import androidx.paging.cachedIn
|
|||||||
import com.shaarit.data.local.dao.LinkDao
|
import com.shaarit.data.local.dao.LinkDao
|
||||||
import com.shaarit.domain.model.ShaarliLink
|
import com.shaarit.domain.model.ShaarliLink
|
||||||
import com.shaarit.domain.repository.LinkRepository
|
import com.shaarit.domain.repository.LinkRepository
|
||||||
|
import com.shaarit.data.local.dao.CollectionDao
|
||||||
|
import com.shaarit.data.local.entity.CollectionLinkCrossRef
|
||||||
|
import com.shaarit.data.sync.SyncManager
|
||||||
|
import com.shaarit.core.storage.TokenManager
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class DeadLinksViewModel @Inject constructor(
|
class DeadLinksViewModel @Inject constructor(
|
||||||
private val linkRepository: LinkRepository,
|
private val linkRepository: LinkRepository,
|
||||||
private val linkDao: LinkDao
|
private val linkDao: LinkDao,
|
||||||
|
private val collectionDao: CollectionDao,
|
||||||
|
private val syncManager: SyncManager,
|
||||||
|
private val tokenManager: TokenManager
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val pagedDeadLinks: Flow<PagingData<ShaarliLink>> =
|
val pagedDeadLinks: Flow<PagingData<ShaarliLink>> =
|
||||||
linkRepository.getDeadLinksStream()
|
linkRepository.getDeadLinksStream()
|
||||||
.cachedIn(viewModelScope)
|
.cachedIn(viewModelScope)
|
||||||
|
|
||||||
|
val collections = collectionDao.getAllCollections()
|
||||||
|
.stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|
||||||
private val _selectedLinkIds = MutableStateFlow<Set<Int>>(emptySet())
|
private val _selectedLinkIds = MutableStateFlow<Set<Int>>(emptySet())
|
||||||
val selectedLinkIds: StateFlow<Set<Int>> = _selectedLinkIds.asStateFlow()
|
val selectedLinkIds: StateFlow<Set<Int>> = _selectedLinkIds.asStateFlow()
|
||||||
|
|
||||||
@ -60,6 +71,36 @@ class DeadLinksViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun verifySelectedLinks() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val ids = _selectedLinkIds.value.toList()
|
||||||
|
if (ids.isNotEmpty()) {
|
||||||
|
val links = linkDao.getLinksByIds(ids)
|
||||||
|
for (link in links) {
|
||||||
|
verifyLink(link.id, link.url)
|
||||||
|
}
|
||||||
|
clearSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addLinksToCollection(collectionId: Long, linkIds: Set<Int>) {
|
||||||
|
if (linkIds.isEmpty()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
linkIds.forEach { linkId ->
|
||||||
|
try {
|
||||||
|
collectionDao.addLinkToCollection(
|
||||||
|
CollectionLinkCrossRef(collectionId = collectionId, linkId = linkId)
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokenManager.setCollectionsConfigDirty(true)
|
||||||
|
syncManager.syncNow()
|
||||||
|
clearSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun deleteLink(id: Int) {
|
fun deleteLink(id: Int) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
linkRepository.deleteLink(id)
|
linkRepository.deleteLink(id)
|
||||||
|
|||||||
@ -285,6 +285,7 @@ fun FeedScreen(
|
|||||||
onNavigateToReminders: () -> Unit = {},
|
onNavigateToReminders: () -> Unit = {},
|
||||||
onNavigateToTodo: () -> Unit = {},
|
onNavigateToTodo: () -> Unit = {},
|
||||||
onNavigateToTodoDetail: (Int) -> Unit = {},
|
onNavigateToTodoDetail: (Int) -> Unit = {},
|
||||||
|
onNavigateToBrowser: (String) -> Unit = {},
|
||||||
onPlayAudio: ((com.shaarit.domain.model.ShaarliLink) -> Unit)? = null,
|
onPlayAudio: ((com.shaarit.domain.model.ShaarliLink) -> Unit)? = null,
|
||||||
initialTagFilter: String? = null,
|
initialTagFilter: String? = null,
|
||||||
initialCollectionId: Long? = null,
|
initialCollectionId: Long? = null,
|
||||||
@ -1709,6 +1710,9 @@ fun FeedScreen(
|
|||||||
reminderTargetLinkId = linkId
|
reminderTargetLinkId = linkId
|
||||||
reminderTargetLinkTitle = link.title
|
reminderTargetLinkTitle = link.title
|
||||||
showReminderSheet = true
|
showReminderSheet = true
|
||||||
|
},
|
||||||
|
onOpenInternalClick = { url ->
|
||||||
|
onNavigateToBrowser(url)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.Alarm
|
|||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.filled.TaskAlt
|
import androidx.compose.material.icons.filled.TaskAlt
|
||||||
|
import androidx.compose.material.icons.filled.OpenInBrowser
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
@ -822,7 +823,8 @@ fun LinkDetailsView(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onLinkClick: (String) -> Unit,
|
onLinkClick: (String) -> Unit,
|
||||||
onReadClick: ((Int) -> Unit)? = null,
|
onReadClick: ((Int) -> Unit)? = null,
|
||||||
onReminderClick: ((Int) -> Unit)? = null
|
onReminderClick: ((Int) -> Unit)? = null,
|
||||||
|
onOpenInternalClick: ((String) -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@ -911,8 +913,9 @@ fun LinkDetailsView(
|
|||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
// Reader Mode & Reminder actions
|
// Reader Mode & Reminder actions
|
||||||
Row(
|
FlowRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
if (onReadClick != null && !link.url.startsWith("note://")) {
|
if (onReadClick != null && !link.url.startsWith("note://")) {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
@ -935,6 +938,18 @@ fun LinkDetailsView(
|
|||||||
Text("Rappel", style = MaterialTheme.typography.labelMedium)
|
Text("Rappel", style = MaterialTheme.typography.labelMedium)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (onOpenInternalClick != null && !link.url.startsWith("note://")) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
onOpenInternalClick(link.url)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.OpenInBrowser, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text("Ouvrir", style = MaterialTheme.typography.labelMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|||||||
@ -52,6 +52,9 @@ sealed class Screen(val route: String) {
|
|||||||
object TodoDetail : Screen("todoDetail/{linkId}") {
|
object TodoDetail : Screen("todoDetail/{linkId}") {
|
||||||
fun createRoute(linkId: Int): String = "todoDetail/$linkId"
|
fun createRoute(linkId: Int): String = "todoDetail/$linkId"
|
||||||
}
|
}
|
||||||
|
object Browser : Screen("browser/{encodedUrl}") {
|
||||||
|
fun createRoute(encodedUrl: String): String = "browser/$encodedUrl"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -167,6 +170,10 @@ fun AppNavGraph(
|
|||||||
onNavigateToReminders = { navController.navigate(Screen.Reminders.route) },
|
onNavigateToReminders = { navController.navigate(Screen.Reminders.route) },
|
||||||
onNavigateToTodo = { navController.navigate(Screen.Todo.route) },
|
onNavigateToTodo = { navController.navigate(Screen.Todo.route) },
|
||||||
onNavigateToTodoDetail = { linkId -> navController.navigate(Screen.TodoDetail.createRoute(linkId)) },
|
onNavigateToTodoDetail = { linkId -> navController.navigate(Screen.TodoDetail.createRoute(linkId)) },
|
||||||
|
onNavigateToBrowser = { url ->
|
||||||
|
val encoded = URLEncoder.encode(url, "UTF-8")
|
||||||
|
navController.navigate(Screen.Browser.createRoute(encoded))
|
||||||
|
},
|
||||||
onPlayAudio = onPlayAudio,
|
onPlayAudio = onPlayAudio,
|
||||||
initialTagFilter = tag,
|
initialTagFilter = tag,
|
||||||
initialCollectionId = collectionId
|
initialCollectionId = collectionId
|
||||||
@ -374,5 +381,20 @@ fun AppNavGraph(
|
|||||||
onNavigateBack = { navController.popBackStack() }
|
onNavigateBack = { navController.popBackStack() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = Screen.Browser.route,
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument("encodedUrl") {
|
||||||
|
type = NavType.StringType
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) { backStackEntry ->
|
||||||
|
val encodedUrl = backStackEntry.arguments?.getString("encodedUrl") ?: ""
|
||||||
|
com.shaarit.presentation.browser.InternalBrowserScreen(
|
||||||
|
encodedUrl = encodedUrl,
|
||||||
|
onNavigateBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
eatingwell.html
Normal file
BIN
eatingwell.html
Normal file
Binary file not shown.
@ -1,3 +1,3 @@
|
|||||||
#Wed Apr 22 22:23:35 2026
|
#Thu Apr 23 15:59:20 2026
|
||||||
VERSION_NAME=2.10.0
|
VERSION_NAME=2.12.0
|
||||||
VERSION_CODE=35
|
VERSION_CODE=38
|
||||||
Loading…
x
Reference in New Issue
Block a user