summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'app/src')
-rw-r--r--app/src/main/java/org/terst/feedreader/data/Daos.kt3
-rw-r--r--app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt64
-rw-r--r--app/src/main/java/org/terst/feedreader/stash/StashClient.kt29
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt24
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt49
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt6
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt54
7 files changed, 189 insertions, 40 deletions
diff --git a/app/src/main/java/org/terst/feedreader/data/Daos.kt b/app/src/main/java/org/terst/feedreader/data/Daos.kt
index 0b763e1..1f442e9 100644
--- a/app/src/main/java/org/terst/feedreader/data/Daos.kt
+++ b/app/src/main/java/org/terst/feedreader/data/Daos.kt
@@ -136,6 +136,9 @@ interface ArticleDao {
@Query("UPDATE articles SET extractedContent = :content WHERE id = :id")
suspend fun setExtractedContent(id: String, content: String)
+ @Query("UPDATE articles SET extractedContent = NULL WHERE id = :id")
+ suspend fun clearExtractedContent(id: String)
+
@Query("UPDATE articles SET isSaved = :saved WHERE id = :id")
suspend fun setSaved(id: String, saved: Boolean)
diff --git a/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt b/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt
index 3cb4741..9a96e63 100644
--- a/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt
+++ b/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt
@@ -180,6 +180,17 @@ object ReaderExtractor {
}
}
+ // Some sites lazy-load the embed iframe itself, not just images --
+ // leaving `src` blank or a generic loader URL until their own script
+ // runs, which never happens here. Resolve iframe data-src first (same
+ // idea as the image lazy-load fix above) so the YouTube detection below
+ // has a real URL to work with instead of nothing.
+ var lazyFrames = document.querySelectorAll('iframe[data-src], iframe[data-lazy-src]');
+ for (var i = 0; i < lazyFrames.length; i++) {
+ var lazyFrameSrc = lazyFrames[i].getAttribute('data-src') || lazyFrames[i].getAttribute('data-lazy-src');
+ if (lazyFrameSrc) lazyFrames[i].setAttribute('src', lazyFrameSrc);
+ }
+
// A YouTube iframe embed is dead weight here -- JS is off in the WebView
// that actually displays this content, so the real embedded player could
// never initialize anyway (just a blank/broken box). Swap it for a
@@ -193,20 +204,47 @@ object ReaderExtractor {
);
for (var i = 0; i < iframes.length; i++) {
var frame = iframes[i];
- var match = frame.getAttribute('src').match(/embed\/([a-zA-Z0-9_-]+)/);
- if (!match) continue;
- var videoId = match[1];
+ var frameSrc = frame.getAttribute('src') || '';
+ // A real video ID is always exactly 11 chars -- a playlist/live
+ // embed's path is literally ".../embed/videoseries?list=...", which
+ // (coincidentally exactly 11 characters itself) previously slipped
+ // past a length-only check and built a thumbnail URL for a video
+ // that doesn't exist (confirmed live: rendered as a broken-image
+ // glyph, not a still frame -- exactly what was reported).
+ var match = frameSrc.match(/embed\/([a-zA-Z0-9_-]{11})(?:[/?]|$)/);
+ if (match && match[1] === 'videoseries') match = null;
+ // Neither a class NOR an inline style survives here -- Readability
+ // strips both as part of its own cleanup. A bare <a><img></a> with
+ // no surrounding text didn't survive either, for a different reason:
+ // Readability's link-density cleanup treats a node that's almost
+ // entirely inside an <a> as a probable ad/share-button unit and
+ // discards it (confirmed live: the iframe was there, the transform
+ // ran, and the result still vanished from the final extracted
+ // content). A <figure>/<figcaption> pair -- the exact shape a normal
+ // captioned photo takes, which reliably survives elsewhere in this
+ // pipeline -- with the caption text OUTSIDE the link keeps the
+ // node's link-density low enough to read as real content.
+ var figure = document.createElement('figure');
var link = document.createElement('a');
- link.className = 'yt-embed';
- link.href = 'https://www.youtube.com/watch?v=' + videoId;
- var thumb = document.createElement('img');
- thumb.src = 'https://i.ytimg.com/vi/' + videoId + '/hqdefault.jpg';
- thumb.alt = 'YouTube video';
- var play = document.createElement('span');
- play.className = 'yt-play';
- link.appendChild(thumb);
- link.appendChild(play);
- frame.parentNode.replaceChild(link, frame);
+ figure.appendChild(link);
+ if (match) {
+ var videoId = match[1];
+ link.href = 'https://www.youtube.com/watch?v=' + videoId;
+ var thumb = document.createElement('img');
+ thumb.src = 'https://i.ytimg.com/vi/' + videoId + '/hqdefault.jpg';
+ thumb.alt = 'YouTube video';
+ link.appendChild(thumb);
+ var caption = document.createElement('figcaption');
+ caption.textContent = '▶ Watch on YouTube';
+ figure.appendChild(caption);
+ } else {
+ // No confident single video ID (a playlist, a live stream, or
+ // lazy-load resolution above still came up empty) -- a plain
+ // link beats a broken thumbnail or a dead iframe.
+ link.href = frameSrc || 'https://www.youtube.com/';
+ link.textContent = 'Watch on YouTube';
+ }
+ frame.parentNode.replaceChild(figure, frame);
}
// A news/blog article page's <header> conventionally holds everything
diff --git a/app/src/main/java/org/terst/feedreader/stash/StashClient.kt b/app/src/main/java/org/terst/feedreader/stash/StashClient.kt
index 28cc2b3..b613d66 100644
--- a/app/src/main/java/org/terst/feedreader/stash/StashClient.kt
+++ b/app/src/main/java/org/terst/feedreader/stash/StashClient.kt
@@ -36,6 +36,11 @@ sealed class StashSaveResult {
data class Failed(val message: String) : StashSaveResult()
}
+sealed class StashVerifyResult {
+ object Valid : StashVerifyResult()
+ data class Invalid(val message: String) : StashVerifyResult()
+}
+
/**
* Client for the parts of Stash's API this app needs: POST /artifacts to
* archive an article. Uses sync=true so content_md comes back in the same
@@ -48,6 +53,30 @@ class StashClient(
private val baseUrl: String,
private val apiKey: String
) {
+ // A cheap authenticated GET against the same collection /artifacts POSTs to, rather than
+ // a dedicated /me or /health endpoint this API doesn't document -- reads are safe/free of
+ // side effects, unlike actually creating an artifact just to test the key works.
+ suspend fun verifyCredentials(): StashVerifyResult = withContext(Dispatchers.IO) {
+ if (baseUrl.isBlank() || apiKey.isBlank()) {
+ return@withContext StashVerifyResult.Invalid("Base URL and API key are both required")
+ }
+ val request = Request.Builder()
+ .url("$baseUrl/artifacts")
+ .header("X-API-Key", apiKey)
+ .get()
+ .build()
+
+ runCatching {
+ client.newCall(request).execute().use { response ->
+ when {
+ response.isSuccessful -> StashVerifyResult.Valid
+ response.code == 401 || response.code == 403 -> StashVerifyResult.Invalid("Rejected: API key not accepted")
+ else -> StashVerifyResult.Invalid("HTTP ${response.code}")
+ }
+ }
+ }.getOrElse { StashVerifyResult.Invalid(it.message ?: "Couldn't reach that URL") }
+ }
+
suspend fun saveArticle(url: String, title: String?): StashSaveResult = withContext(Dispatchers.IO) {
if (baseUrl.isBlank() || apiKey.isBlank()) {
return@withContext StashSaveResult.Failed("Stash isn't configured (set base URL and API key in Settings)")
diff --git a/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt b/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt
index c001b69..c29a585 100644
--- a/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt
@@ -80,6 +80,7 @@ fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Un
var extractionFailed by remember(url) { mutableStateOf(false) }
var isReaderMode by remember(url) { mutableStateOf(true) }
var isSaving by remember(url) { mutableStateOf(false) }
+ var isReextracting by remember(url) { mutableStateOf(false) }
var moreMenuExpanded by remember(url) { mutableStateOf(false) }
var longPressedLink by remember(url) { mutableStateOf<String?>(null) }
@@ -194,6 +195,29 @@ fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Un
}
DropdownMenu(expanded = moreMenuExpanded, onDismissRequest = { moreMenuExpanded = false }) {
DropdownMenuItem(
+ enabled = !isReextracting,
+ text = { Text("Re-download and re-extract") },
+ onClick = {
+ moreMenuExpanded = false
+ scope.launch {
+ isReextracting = true
+ val extracted = ReaderExtractor.extract(context, url)
+ isReextracting = false
+ val content = extracted?.content
+ if (content != null) {
+ extractedTitle = extracted.title
+ extractedContent = content
+ extractionFailed = false
+ isReaderMode = true
+ db.articleDao().setExtractedContent(articleId, content)
+ snackbarHostState.showSnackbar("Re-extracted")
+ } else {
+ snackbarHostState.showSnackbar("Re-extraction failed")
+ }
+ }
+ }
+ )
+ DropdownMenuItem(
text = { Text("Find another source") },
onClick = {
moreMenuExpanded = false
diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
index b7f6018..06227b7 100644
--- a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
@@ -24,10 +24,7 @@ import androidx.compose.material.icons.filled.BookmarkBorder
import androidx.compose.material.icons.filled.CloudDone
import androidx.compose.material.icons.filled.CloudUpload
import androidx.compose.material.icons.filled.MoreVert
-import androidx.compose.material.icons.filled.NavigateBefore
-import androidx.compose.material.icons.filled.NavigateNext
import androidx.compose.material.icons.filled.OpenInBrowser
-import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.outlined.Article
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.BottomAppBar
@@ -252,7 +249,7 @@ private fun ReaderContent(
} else {
isReaderMode = false
readerContent = null
- db.articleDao().setExtractedContent(articleId, null)
+ db.articleDao().clearExtractedContent(articleId)
snackbarHostState.showSnackbar("Reader mode disabled for $feedLabel")
}
}
@@ -393,6 +390,32 @@ private fun ReaderContent(
onOpenBrowser(a.link)
}
)
+ // Forces a fresh fetch + Readability pass, overwriting whatever's
+ // cached -- for when extraction came back thin, stale, or broken
+ // (e.g. a YouTube thumbnail that failed to resolve) and simply hasn't
+ // been retried since whatever caused that.
+ DropdownMenuItem(
+ enabled = !isExtracting,
+ text = { Text("Re-download and re-extract") },
+ onClick = {
+ moreMenuExpanded = false
+ val a = currentArticle ?: return@DropdownMenuItem
+ scope.launch {
+ isExtracting = true
+ val extracted = ReaderExtractor.extract(context, a.link)
+ isExtracting = false
+ val content = extracted?.content
+ if (content != null) {
+ readerContent = content
+ isReaderMode = true
+ db.articleDao().setExtractedContent(a.id, content)
+ snackbarHostState.showSnackbar("Re-extracted")
+ } else {
+ snackbarHostState.showSnackbar("Re-extraction failed")
+ }
+ }
+ }
+ )
DropdownMenuItem(
text = { Text("Report bug") },
onClick = {
@@ -406,26 +429,16 @@ private fun ReaderContent(
},
bottomBar = {
BottomAppBar {
- TextButton(
- onClick = onPrevious,
- enabled = hasPrevious,
- modifier = Modifier.weight(1f)
- ) {
- Icon(Icons.Default.NavigateBefore, contentDescription = null)
+ TextButton(onClick = onPrevious, enabled = hasPrevious, modifier = Modifier.weight(1f)) {
Text("Previous")
}
// Advances like Next, but skips the implicit "mark read" that moving on
// normally carries -- for "I looked, but I'm not done with this one yet."
- IconButton(onClick = onNextKeepUnread, enabled = hasNext) {
- Icon(Icons.Default.SkipNext, contentDescription = "Next, keep unread")
+ TextButton(onClick = onNextKeepUnread, enabled = hasNext, modifier = Modifier.weight(1f)) {
+ Text("Keep unread")
}
- TextButton(
- onClick = onNext,
- enabled = hasNext,
- modifier = Modifier.weight(1f)
- ) {
+ TextButton(onClick = onNext, enabled = hasNext, modifier = Modifier.weight(1f)) {
Text("Next")
- Icon(Icons.Default.NavigateNext, contentDescription = null)
}
}
},
diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt
index b984d1d..1fd2411 100644
--- a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt
@@ -135,12 +135,6 @@ fun wrapReaderHtml(bodyContent: String, title: String? = null): String = """
explicit width/height was deliberately sized by the page itself, so leave
those alone -- just cap the ones that would otherwise size themselves. */
svg:not([width]):not([height]) { max-width: 32px; max-height: 32px; }
- /* A tap-to-open thumbnail standing in for a YouTube embed (see ReaderExtractor) --
- JS is off here, so the real embedded player could never have worked anyway. */
- .yt-embed { position: relative; display: block; margin: 0.5em 0; }
- .yt-embed .yt-play { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
- width: 0; height: 0; border-style: solid; border-width: 18px 0 18px 30px;
- border-color: transparent transparent transparent rgba(255,255,255,0.85); }
</style></head>
<body>${if (!title.isNullOrBlank()) "<h1 class=\"article-title\">${escapeHtml(title)}</h1>" else ""}$bodyContent</body></html>
""".trimIndent()
diff --git a/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt b/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt
index b7897f4..3bd6e3e 100644
--- a/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt
@@ -13,7 +13,10 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Visibility
+import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -36,12 +39,17 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.terst.feedreader.data.AppDatabase
import org.terst.feedreader.data.FilterAction
import org.terst.feedreader.data.FilterRuleEntity
+import org.terst.feedreader.data.Net
import org.terst.feedreader.data.Settings
+import org.terst.feedreader.stash.StashClient
+import org.terst.feedreader.stash.StashVerifyResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -53,6 +61,9 @@ fun SettingsScreen(onBack: () -> Unit) {
var stashUrl by remember { mutableStateOf("") }
var stashKey by remember { mutableStateOf("") }
+ var stashKeyVisible by remember { mutableStateOf(false) }
+ var isVerifyingStash by remember { mutableStateOf(false) }
+ var stashStatus by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
stashUrl = settings.stashBaseUrlNow()
stashKey = settings.stashApiKeyNow()
@@ -105,15 +116,52 @@ fun SettingsScreen(onBack: () -> Unit) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = stashKey,
- onValueChange = { stashKey = it },
+ onValueChange = { stashKey = it; stashStatus = null },
label = { Text("API key") },
+ singleLine = true,
+ visualTransformation = if (stashKeyVisible) VisualTransformation.None else PasswordVisualTransformation(),
+ trailingIcon = {
+ IconButton(onClick = { stashKeyVisible = !stashKeyVisible }) {
+ Icon(
+ if (stashKeyVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
+ contentDescription = if (stashKeyVisible) "Hide API key" else "Show API key"
+ )
+ }
+ },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Button(
- onClick = { scope.launch { settings.setStash(stashUrl, stashKey) } },
+ enabled = !isVerifyingStash,
+ onClick = {
+ scope.launch {
+ isVerifyingStash = true
+ stashStatus = null
+ val client = StashClient(Net.client, stashUrl.trim(), stashKey.trim())
+ when (val result = client.verifyCredentials()) {
+ is StashVerifyResult.Valid -> {
+ settings.setStash(stashUrl.trim(), stashKey.trim())
+ stashStatus = "Verified and saved"
+ }
+ is StashVerifyResult.Invalid -> {
+ stashStatus = result.message
+ }
+ }
+ isVerifyingStash = false
+ }
+ },
modifier = Modifier.fillMaxWidth()
- ) { Text("Save") }
+ ) {
+ if (isVerifyingStash) {
+ CircularProgressIndicator(modifier = Modifier.height(20.dp).width(20.dp))
+ } else {
+ Text("Verify and save")
+ }
+ }
+ stashStatus?.let {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(it, style = MaterialTheme.typography.bodySmall)
+ }
}
HorizontalDivider()
}