summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader/readability/ExtractionFailure.kt
blob: 63fbdb28ec6e0a3d20cca0b62931a85e78ea706c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
package org.terst.feedreader.readability

/**
 * Why an extraction did not produce article content.
 *
 * Extraction used to fail silently: ReaderScreen did `extracted?.content?.let { }`,
 * so a null result — whatever caused it — left the reader showing the raw feed
 * content with nothing logged and nothing shown. The only way a failure ever
 * surfaced was the user noticing and filing a bug report by hand, which could
 * not say why either.
 */
sealed interface ExtractionFailure {

    /** The page load reported a transport-level error. */
    data class LoadError(val code: Int, val description: String?) : ExtractionFailure

    /** The server answered, but with an error status. */
    data class HttpError(val statusCode: Int, val reason: String?) : ExtractionFailure

    /** The page never finished loading within the budget. */
    data class TimedOut(val millis: Long) : ExtractionFailure

    /** Readability itself threw, or returned something unusable. */
    data class ReadabilityError(val message: String) : ExtractionFailure

    /** Readability ran but found no article body worth showing. */
    data object NoContent : ExtractionFailure

    /** The JS result could not be decoded. */
    data class BadResult(val raw: String) : ExtractionFailure

    /**
     * Readability returned content, but it was the page's cookie/consent notice,
     * not the article -- see [ConsentNotice].
     */
    data object ConsentInterstitial : ExtractionFailure
}

private const val MILLIS_PER_SECOND = 1_000

/**
 * How much of the raw JS payload a diagnostic keeps. The result can be tens of
 * kilobytes; a bug report needs enough to recognise the shape of it, not all.
 */
private const val RAW_DIAGNOSTIC_CHARS = 200

/** A short, user-facing explanation. Shown in a snackbar and in bug reports. */
fun ExtractionFailure.userMessage(): String = when (this) {
    is ExtractionFailure.LoadError ->
        "Couldn't load the page" + (description?.takeIf { it.isNotBlank() }?.let { " ($it)" } ?: "")
    is ExtractionFailure.HttpError -> "Server returned HTTP $statusCode"
    is ExtractionFailure.TimedOut -> "Page took longer than ${millis / MILLIS_PER_SECOND}s to load"
    is ExtractionFailure.ReadabilityError -> "Couldn't parse the article ($message)"
    ExtractionFailure.NoContent -> "No article text found on the page"
    is ExtractionFailure.BadResult -> "Unexpected result from the page"
    ExtractionFailure.ConsentInterstitial -> "Only the site's cookie notice came through, not the article"
}

/**
 * A longer form for the bug report, carrying the detail the short message drops.
 * Bug reports are the only channel for a failure the user has already lost, so
 * this keeps the raw specifics rather than the tidy summary.
 */
fun ExtractionFailure.diagnostic(): String = when (this) {
    is ExtractionFailure.LoadError -> "LoadError code=$code description=${description ?: "none"}"
    is ExtractionFailure.HttpError -> "HttpError status=$statusCode reason=${reason ?: "none"}"
    is ExtractionFailure.TimedOut -> "TimedOut after ${millis}ms"
    is ExtractionFailure.ReadabilityError -> "ReadabilityError $message"
    ExtractionFailure.NoContent -> "NoContent (Readability returned no usable body)"
    is ExtractionFailure.BadResult -> "BadResult raw=${raw.take(RAW_DIAGNOSTIC_CHARS)}"
    ExtractionFailure.ConsentInterstitial ->
        "ConsentInterstitial (extracted content was the page's cookie/consent notice)"
}