new: add offline fallback support for WebView

This commit is contained in:
2026-07-08 12:47:38 +02:00
parent b063dfaff6
commit f6a12ad84a
14 changed files with 747 additions and 20 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,10 +1,7 @@
package bzh.ajaury.chombev.web.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
@@ -15,6 +12,7 @@ import androidx.webkit.WebViewFeature
@Composable
actual fun PlatformWebView(
url: String,
offlineHtml: String?,
darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit,
modifier: Modifier,
@@ -24,7 +22,15 @@ actual fun PlatformWebView(
factory = { context ->
WebView(context).apply {
// Keep navigation inside the embedded view instead of launching an external browser.
webViewClient = WebClientDelegatingExtLinks(onLoadingStateChange = onLoadingStateChange)
webViewClient = WebClientDelegatingExtLinks(
onLoadingStateChange = onLoadingStateChange,
onMainFrameError = {
// Swap in the bundled offline copy, keeping the real URL as base so links resolve.
offlineHtml?.let { html ->
loadDataWithBaseURL(url, html, "text/html", "utf-8", null)
}
},
)
settings.javaScriptEnabled = false
settings.domStorageEnabled = true
applyDarkTheme(darkTheme)
@@ -10,9 +10,13 @@ import android.webkit.WebViewClient
/**
* Keeps the initial page inside the embedded web view but opens any link the user taps in the system
* browser. Reports page load start/finish through [onLoadingStateChange] so the UI can show a loader.
*
* [onMainFrameError] fires when the top-level page itself fails to load (not a sub-resource), so the
* caller can swap in an offline fallback.
*/
class WebClientDelegatingExtLinks(
private val onLoadingStateChange: (Boolean) -> Unit = {},
private val onMainFrameError: () -> Unit = {},
) : WebViewClient() {
override fun onPageStarted(
view: WebView?,
@@ -34,6 +38,11 @@ class WebClientDelegatingExtLinks(
request: WebResourceRequest?,
error: WebResourceError?,
) {
// Only fall back for the top-level page; a failed sub-resource (image, etc.) shouldn't
// replace the whole page.
if (request?.isForMainFrame == true) {
onMainFrameError()
}
// Stop the loader even if the page failed, so the user isn't stuck on the spinner.
onLoadingStateChange(false)
}
@@ -7,6 +7,7 @@ import bzh.ajaury.chombev.preferences.di.preferencesModule
import bzh.ajaury.chombev.records.di.recordsModule
import bzh.ajaury.chombev.records.ui.viewmodel.RecordsViewModel
import bzh.ajaury.chombev.resources.di.resourcesModule
import bzh.ajaury.chombev.web.ui.viewmodel.WebViewModel
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
@@ -18,4 +19,5 @@ val sharedModule = module {
includes(resourcesModule)
viewModelOf(::RecordsViewModel)
viewModelOf(::PlayerViewModel)
viewModelOf(::WebViewModel)
}
@@ -4,12 +4,21 @@ import kotlinx.serialization.Serializable
/**
* Web destinations reachable from the main bottom navigation bar and shown inside the app.
* Placeholder URLs for now.
*
* [offlineAsset] is the resource path of a bundled HTML snapshot shown when the online [url] cannot
* be reached, so the page still has content offline.
*/
@Serializable
enum class WebPage(
val url: String,
val offlineAsset: String,
) {
DEEPEN("https://ksk.bzh/fr/chom-bev-approfondir/"),
ABOUT("https://ksk.bzh/fr/chom-bev-a-propos/"),
DEEPEN(
url = "https://ksk.bzh/fr/chom-bev-approfondir/",
offlineAsset = "files/offline_deepen.html",
),
ABOUT(
url = "https://ksk.bzh/fr/chom-bev-a-propos/",
offlineAsset = "files/offline_about.html",
),
}
@@ -8,12 +8,16 @@ import androidx.compose.ui.Modifier
* honouring [darkTheme] so the page matches the app's light/dark appearance; the others fall back to
* [WebViewUnavailable], which offers to open the page in the system browser.
*
* When [url] cannot be reached, the native web views swap in [offlineHtml] — a bundled offline
* snapshot — so the page still shows content. When it is `null` no fallback is loaded.
*
* [onLoadingStateChange] reports whether a page is currently loading, so the caller can show a
* loader. Fallback platforms report `false` immediately since there is nothing to load.
*/
@Composable
expect fun PlatformWebView(
url: String,
offlineHtml: String?,
darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit = {},
modifier: Modifier = Modifier,
@@ -11,33 +11,62 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import bzh.ajaury.chombev.navigation.WebPage
import bzh.ajaury.chombev.web.ui.viewmodel.WebUiState
import bzh.ajaury.chombev.web.ui.viewmodel.WebViewModel
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
/**
* Displays a [WebPage] inside the app: the embedded page, matching the app's [darkTheme]. A
* [WebViewLoader] covers the page while it loads. The surrounding top and bottom bars are provided by
* the app-level scaffold.
* Displays a [WebPage] inside the app. Injects a [WebViewModel] keyed by the page so each page keeps
* its own online URL and bundled offline snapshot.
*/
@Composable
fun WebViewScreen(
page: WebPage,
contentPadding: PaddingValues,
darkTheme: Boolean = isSystemInDarkTheme(),
viewModel: WebViewModel = koinViewModel(key = page.name) { parametersOf(page) },
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
WebViewScreen(
uiState = uiState,
contentPadding = contentPadding,
darkTheme = darkTheme,
)
}
/**
* Renders the embedded page for [uiState], matching the app's [darkTheme]. A [WebViewLoader] covers
* the page while it loads; if the online page fails, the web view swaps in the offline snapshot. The
* surrounding top and bottom bars are provided by the app-level scaffold.
*/
@Composable
fun WebViewScreen(
uiState: WebUiState?,
contentPadding: PaddingValues,
darkTheme: Boolean = isSystemInDarkTheme(),
) {
// Start in the loading state; each distinct page loads afresh.
var isLoading by remember(page) { mutableStateOf(true) }
var isLoading by remember(uiState) { mutableStateOf(true) }
Box(
modifier = Modifier
.padding(contentPadding)
.fillMaxSize(),
) {
PlatformWebView(
url = page.url,
darkTheme = darkTheme,
onLoadingStateChange = { isLoading = it },
modifier = Modifier.fillMaxSize(),
)
if (uiState != null) {
PlatformWebView(
url = uiState.url,
offlineHtml = uiState.offlineHtml,
darkTheme = darkTheme,
onLoadingStateChange = { isLoading = it },
modifier = Modifier.fillMaxSize(),
)
}
if (isLoading) {
WebViewLoader(modifier = Modifier.fillMaxSize())
}
@@ -0,0 +1,16 @@
package bzh.ajaury.chombev.web.ui.viewmodel
import androidx.compose.runtime.Immutable
/**
* UI state for [bzh.ajaury.chombev.web.ui.WebViewScreen].
*
* [url] is the online page to display. [offlineHtml] is the bundled fallback shown when [url] fails
* to load; it is `null` until the snapshot has been read (or if reading it failed), in which case no
* offline fallback is available.
*/
@Immutable
data class WebUiState(
val url: String = "",
val offlineHtml: String? = null,
)
@@ -0,0 +1,42 @@
package bzh.ajaury.chombev.web.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import bzh.ajaury.chombev.core.logging.domain.Logger
import bzh.ajaury.chombev.navigation.WebPage
import bzh.ajaury.chombev.resources.domain.ResourceReader
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* Owns the state for a single [page]: the online URL to display and its bundled offline snapshot,
* read from resources on init so it is ready as a fallback the moment the online page fails to load.
*/
class WebViewModel(
private val page: WebPage,
private val resourceReader: ResourceReader,
private val logger: Logger,
) : ViewModel() {
private val _uiState = MutableStateFlow<WebUiState?>(null)
val uiState: StateFlow<WebUiState?> = _uiState.asStateFlow()
init {
loadOfflineSnapshot()
}
private fun loadOfflineSnapshot() {
viewModelScope.launch {
val html = runCatching { resourceReader.read(page.offlineAsset) }
.onFailure {
logger.error(
"Failed to read offline snapshot ${page.offlineAsset}",
it,
)
}.getOrNull()
_uiState.update { WebUiState(url = page.url, offlineHtml = html) }
}
}
}
@@ -23,6 +23,7 @@ import platform.darwin.NSObject
@Composable
actual fun PlatformWebView(
url: String,
offlineHtml: String?,
darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit,
modifier: Modifier,
@@ -30,6 +31,8 @@ actual fun PlatformWebView(
// WKWebView holds its navigationDelegate weakly, so keep a strong reference across recompositions.
val navigationDelegate = remember { WebViewNavigationDelegate() }
navigationDelegate.onLoadingStateChange = onLoadingStateChange
navigationDelegate.offlineHtml = offlineHtml
navigationDelegate.baseUrl = url
UIKitView(
modifier = modifier,
@@ -54,12 +57,19 @@ actual fun PlatformWebView(
/**
* Forwards WKWebView load lifecycle events to [onLoadingStateChange] so the UI can show a loader
* while a page is loading and hide it once loading finishes (or fails).
* while a page is loading and hide it once loading finishes.
*
* When a navigation fails, it swaps in [offlineHtml] (loaded against [baseUrl] so links resolve) so
* the page still shows content offline. [showingOffline] guards against re-entering the fallback.
*/
private class WebViewNavigationDelegate :
NSObject(),
WKNavigationDelegateProtocol {
var onLoadingStateChange: (Boolean) -> Unit = {}
var offlineHtml: String? = null
var baseUrl: String = ""
private var showingOffline = false
override fun webView(
webView: WKWebView,
@@ -82,7 +92,7 @@ private class WebViewNavigationDelegate :
didFailNavigation: WKNavigation?,
withError: NSError,
) {
onLoadingStateChange(false)
handleFailure(webView)
}
// Shares the (WKWebView, WKNavigation?, NSError) Kotlin signature with didFailNavigation above.
@@ -92,7 +102,17 @@ private class WebViewNavigationDelegate :
didFailProvisionalNavigation: WKNavigation?,
withError: NSError,
) {
onLoadingStateChange(false)
handleFailure(webView)
}
private fun handleFailure(webView: WKWebView) {
val html = offlineHtml
if (html != null && !showingOffline) {
showingOffline = true
webView.loadHTMLString(string = html, baseURL = NSURL.URLWithString(baseUrl))
} else {
onLoadingStateChange(false)
}
}
}
@@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier
@Composable
actual fun PlatformWebView(
url: String,
offlineHtml: String?,
darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit,
modifier: Modifier,
@@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier
@Composable
actual fun PlatformWebView(
url: String,
offlineHtml: String?,
darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit,
modifier: Modifier,
@@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier
@Composable
actual fun PlatformWebView(
url: String,
offlineHtml: String?,
darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit,
modifier: Modifier,