From 5bcfe0a85c3179850becc8083840726f99cceb67 Mon Sep 17 00:00:00 2001 From: Harsh Shandilya Date: Sun, 3 Dec 2023 22:31:43 +0530 Subject: [PATCH] feat(web): init --- android/lint-baseline.xml | 2 +- api/lint-baseline.xml | 2 +- common/lint-baseline.xml | 2 +- core/lint-baseline.xml | 2 +- database/core/lint-baseline.xml | 2 +- database/impl/lint-baseline.xml | 2 +- gradle/libs.versions.toml | 5 + model/lint-baseline.xml | 2 +- settings.gradle.kts | 1 + web/build.gradle.kts | 29 + web/lint-baseline.xml | 4 + web/src/main/AndroidManifest.xml | 7 + .../com/google/accompanist/web/WebView.kt | 632 ++++++++++++++++++ 13 files changed, 685 insertions(+), 7 deletions(-) create mode 100644 web/build.gradle.kts create mode 100644 web/lint-baseline.xml create mode 100644 web/src/main/AndroidManifest.xml create mode 100644 web/src/main/java/com/google/accompanist/web/WebView.kt diff --git a/android/lint-baseline.xml b/android/lint-baseline.xml index 005dc8e7..1a93d03c 100644 --- a/android/lint-baseline.xml +++ b/android/lint-baseline.xml @@ -1,5 +1,5 @@ - + - + diff --git a/common/lint-baseline.xml b/common/lint-baseline.xml index 731828ce..9295b327 100644 --- a/common/lint-baseline.xml +++ b/common/lint-baseline.xml @@ -1,4 +1,4 @@ - + diff --git a/core/lint-baseline.xml b/core/lint-baseline.xml index 731828ce..9295b327 100644 --- a/core/lint-baseline.xml +++ b/core/lint-baseline.xml @@ -1,4 +1,4 @@ - + diff --git a/database/core/lint-baseline.xml b/database/core/lint-baseline.xml index 731828ce..9295b327 100644 --- a/database/core/lint-baseline.xml +++ b/database/core/lint-baseline.xml @@ -1,4 +1,4 @@ - + diff --git a/database/impl/lint-baseline.xml b/database/impl/lint-baseline.xml index 731828ce..9295b327 100644 --- a/database/impl/lint-baseline.xml +++ b/database/impl/lint-baseline.xml @@ -1,4 +1,4 @@ - + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 98f2c4f7..a7757235 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ junit = "5.10.1" konvert = "2.4.0" kotlin = "1.9.21" kotlinResult = "1.1.18" +lifecycle = "2.7.0-rc01" retrofit = "2.9.0" richtext = "0.17.0" sentry-sdk = "7.0.0" @@ -36,7 +37,10 @@ androidx-compose-ui = { module = "androidx.compose.ui:ui" } androidx-compose-ui-text = { module = "androidx.compose.ui:ui-text" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-compose-ui-util = { module = "androidx.compose.ui:ui-util" } androidx-core-splashscreen = "androidx.core:core-splashscreen:1.0.1" +androidx-lifecycle-common = { module = "androidx.lifecycle:lifecycle-common", version.ref = "lifecycle" } +androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime", version.ref = "lifecycle" } androidx-lifecycle-compose = "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2" androidx-navigation-compose = "androidx.navigation:navigation-compose:2.7.5" androidx-paging-compose = "androidx.paging:paging-compose:3.2.1" @@ -73,6 +77,7 @@ junit-legacy = "junit:junit:4.13.2" konvert-api = { module = "io.mcarle:konvert-api", version.ref = "konvert" } konvert-processor = { module = "io.mcarle:konvert", version.ref = "konvert" } kotlinx-collections-immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.6" +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "serialization" } diff --git a/model/lint-baseline.xml b/model/lint-baseline.xml index 731828ce..9295b327 100644 --- a/model/lint-baseline.xml +++ b/model/lint-baseline.xml @@ -1,4 +1,4 @@ - + diff --git a/settings.gradle.kts b/settings.gradle.kts index 3fe7ed2d..1546a0de 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -163,4 +163,5 @@ include( "database:core", "database:impl", "model", + "web", ) diff --git a/web/build.gradle.kts b/web/build.gradle.kts new file mode 100644 index 00000000..06f9f503 --- /dev/null +++ b/web/build.gradle.kts @@ -0,0 +1,29 @@ +/* + * Copyright © 2023 Harsh Shandilya. + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +@file:Suppress("UnstableApiUsage") + +plugins { + id("dev.msfjarvis.claw.android-library") + id("dev.msfjarvis.claw.kotlin-android") +} + +android { + namespace = "com.google.accompanist.web" + buildFeatures.compose = true + composeOptions.kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() + kotlin.explicitApi() +} + +dependencies { + implementation(projects.core) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.util) + implementation(libs.androidx.lifecycle.runtime) + implementation(libs.androidx.activity.compose) + implementation(libs.kotlinx.collections.immutable) +} diff --git a/web/lint-baseline.xml b/web/lint-baseline.xml new file mode 100644 index 00000000..9295b327 --- /dev/null +++ b/web/lint-baseline.xml @@ -0,0 +1,4 @@ + + + + diff --git a/web/src/main/AndroidManifest.xml b/web/src/main/AndroidManifest.xml new file mode 100644 index 00000000..0291c6ad --- /dev/null +++ b/web/src/main/AndroidManifest.xml @@ -0,0 +1,7 @@ + + diff --git a/web/src/main/java/com/google/accompanist/web/WebView.kt b/web/src/main/java/com/google/accompanist/web/WebView.kt new file mode 100644 index 00000000..fff044f3 --- /dev/null +++ b/web/src/main/java/com/google/accompanist/web/WebView.kt @@ -0,0 +1,632 @@ +/* + * Copyright © 2023 Harsh Shandilya. + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package com.google.accompanist.web + +import android.content.Context +import android.graphics.Bitmap +import android.os.Bundle +import android.view.ViewGroup.LayoutParams +import android.webkit.WebChromeClient +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.FrameLayout +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.mapSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import com.google.accompanist.web.LoadingState.Finished +import com.google.accompanist.web.LoadingState.Loading +import dev.msfjarvis.claw.core.coroutines.DefaultDispatcherProvider +import dev.msfjarvis.claw.core.coroutines.DispatcherProvider +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * A wrapper around the Android View WebView to provide a basic WebView composable. + * + * If you require more customisation you are most likely better rolling your own and using this + * wrapper as an example. + * + * The WebView attempts to set the layoutParams based on the Compose modifier passed in. If it is + * incorrectly sizing, use the layoutParams composable function instead. + * + * @param state The webview state holder where the Uri to load is defined. + * @param modifier A compose modifier + * @param captureBackPresses Set to true to have this Composable capture back presses and navigate + * the WebView back. + * @param navigator An optional navigator object that can be used to control the WebView's + * navigation from outside the composable. + * @param onCreated Called when the WebView is first created, this can be used to set additional + * settings on the WebView. WebChromeClient and WebViewClient should not be set here as they will + * be subsequently overwritten after this lambda is called. + * @param onDispose Called when the WebView is destroyed. Provides a bundle which can be saved if + * you need to save and restore state in this WebView. + * @param client Provides access to WebViewClient via subclassing + * @param chromeClient Provides access to WebChromeClient via subclassing + * @param factory An optional WebView factory for using a custom subclass of WebView + */ +@Composable +public fun WebView( + state: WebViewState, + modifier: Modifier = Modifier, + captureBackPresses: Boolean = true, + navigator: WebViewNavigator = rememberWebViewNavigator(), + onCreated: (WebView) -> Unit = {}, + onDispose: (WebView) -> Unit = {}, + client: AccompanistWebViewClient = remember { AccompanistWebViewClient() }, + chromeClient: AccompanistWebChromeClient = remember { AccompanistWebChromeClient() }, + factory: ((Context) -> WebView)? = null, +) { + BoxWithConstraints(modifier) { + // WebView changes it's layout strategy based on + // it's layoutParams. We convert from Compose Modifier to + // layout params here. + val width = + if (constraints.hasFixedWidth) LayoutParams.MATCH_PARENT else LayoutParams.WRAP_CONTENT + val height = + if (constraints.hasFixedHeight) LayoutParams.MATCH_PARENT else LayoutParams.WRAP_CONTENT + + val layoutParams = FrameLayout.LayoutParams(width, height) + + WebView( + state, + layoutParams, + Modifier, + captureBackPresses, + navigator, + onCreated, + onDispose, + client, + chromeClient, + factory + ) + } +} + +/** + * A wrapper around the Android View WebView to provide a basic WebView composable. + * + * If you require more customisation you are most likely better rolling your own and using this + * wrapper as an example. + * + * The WebView attempts to set the layoutParams based on the Compose modifier passed in. If it is + * incorrectly sizing, use the layoutParams composable function instead. + * + * @param state The webview state holder where the Uri to load is defined. + * @param layoutParams A FrameLayout.LayoutParams object to custom size the underlying WebView. + * @param modifier A compose modifier + * @param captureBackPresses Set to true to have this Composable capture back presses and navigate + * the WebView back. + * @param navigator An optional navigator object that can be used to control the WebView's + * navigation from outside the composable. + * @param onCreated Called when the WebView is first created, this can be used to set additional + * settings on the WebView. WebChromeClient and WebViewClient should not be set here as they will + * be subsequently overwritten after this lambda is called. + * @param onDispose Called when the WebView is destroyed. Provides a bundle which can be saved if + * you need to save and restore state in this WebView. + * @param client Provides access to WebViewClient via subclassing + * @param chromeClient Provides access to WebChromeClient via subclassing + * @param factory An optional WebView factory for using a custom subclass of WebView + */ +@Composable +public fun WebView( + state: WebViewState, + layoutParams: FrameLayout.LayoutParams, + modifier: Modifier = Modifier, + captureBackPresses: Boolean = true, + navigator: WebViewNavigator = rememberWebViewNavigator(), + onCreated: (WebView) -> Unit = {}, + onDispose: (WebView) -> Unit = {}, + client: AccompanistWebViewClient = remember { AccompanistWebViewClient() }, + chromeClient: AccompanistWebChromeClient = remember { AccompanistWebChromeClient() }, + factory: ((Context) -> WebView)? = null, +) { + val webView = state.webView + + BackHandler(captureBackPresses && navigator.canGoBack) { webView?.goBack() } + + webView?.let { wv -> + LaunchedEffect(wv, navigator) { with(navigator) { wv.handleNavigationEvents() } } + + LaunchedEffect(wv, state) { + snapshotFlow { state.content } + .collect { content -> + when (content) { + is WebContent.Url -> { + wv.loadUrl(content.url, content.additionalHttpHeaders) + } + is WebContent.Data -> { + wv.loadDataWithBaseURL( + content.baseUrl, + content.data, + content.mimeType, + content.encoding, + content.historyUrl + ) + } + is WebContent.Post -> { + wv.postUrl(content.url, content.postData) + } + is WebContent.NavigatorOnly -> { + // NO-OP + } + } + } + } + } + + // Set the state of the client and chrome client + // This is done internally to ensure they always are the same instance as the + // parent Web composable + client.state = state + client.navigator = navigator + chromeClient.state = state + + AndroidView( + factory = { context -> + (factory?.invoke(context) ?: WebView(context)) + .apply { + onCreated(this) + + this.layoutParams = layoutParams + + state.viewState?.let { this.restoreState(it) } + + webChromeClient = chromeClient + webViewClient = client + } + .also { state.webView = it } + }, + modifier = modifier, + onRelease = { onDispose(it) } + ) +} + +/** + * AccompanistWebViewClient + * + * A parent class implementation of WebViewClient that can be subclassed to add custom behaviour. + * + * As Accompanist Web needs to set its own web client to function, it provides this intermediary + * class that can be overriden if further custom behaviour is required. + */ +public open class AccompanistWebViewClient : WebViewClient() { + public open lateinit var state: WebViewState + internal set + + public open lateinit var navigator: WebViewNavigator + internal set + + override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + state.loadingState = Loading(0.0f) + state.errorsForCurrentRequest.clear() + state.pageTitle = null + state.pageIcon = null + + state.lastLoadedUrl = url + } + + override fun onPageFinished(view: WebView, url: String?) { + super.onPageFinished(view, url) + state.loadingState = Finished + } + + override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) { + super.doUpdateVisitedHistory(view, url, isReload) + + navigator.canGoBack = view.canGoBack() + navigator.canGoForward = view.canGoForward() + } + + override fun onReceivedError( + view: WebView, + request: WebResourceRequest?, + error: WebResourceError? + ) { + super.onReceivedError(view, request, error) + + if (error != null) { + state.errorsForCurrentRequest.add(WebViewError(request, error)) + } + } +} + +/** + * AccompanistWebChromeClient + * + * A parent class implementation of WebChromeClient that can be subclassed to add custom behaviour. + * + * As Accompanist Web needs to set its own web client to function, it provides this intermediary + * class that can be overridden if further custom behaviour is required. + */ +public open class AccompanistWebChromeClient : WebChromeClient() { + public open lateinit var state: WebViewState + internal set + + override fun onReceivedTitle(view: WebView, title: String?) { + super.onReceivedTitle(view, title) + state.pageTitle = title + } + + override fun onReceivedIcon(view: WebView, icon: Bitmap?) { + super.onReceivedIcon(view, icon) + state.pageIcon = icon + } + + override fun onProgressChanged(view: WebView, newProgress: Int) { + super.onProgressChanged(view, newProgress) + if (state.loadingState is Finished) return + state.loadingState = Loading(newProgress / 100.0f) + } +} + +public sealed class WebContent { + public data class Url( + val url: String, + val additionalHttpHeaders: Map = emptyMap(), + ) : WebContent() + + public data class Data( + val data: String, + val baseUrl: String? = null, + val encoding: String = "utf-8", + val mimeType: String? = null, + val historyUrl: String? = null + ) : WebContent() + + public data class Post(val url: String, val postData: ByteArray) : WebContent() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Post + + if (url != other.url) return false + if (!postData.contentEquals(other.postData)) return false + + return true + } + + override fun hashCode(): Int { + var result = url.hashCode() + result = 31 * result + postData.contentHashCode() + return result + } + } + + public data object NavigatorOnly : WebContent() +} + +internal fun WebContent.withUrl(url: String) = + when (this) { + is WebContent.Url -> copy(url = url) + else -> WebContent.Url(url) + } + +/** Sealed class for constraining possible loading states. See [Loading] and [Finished]. */ +public sealed class LoadingState { + /** Describes a WebView that has not yet loaded for the first time. */ + public data object Initializing : LoadingState() + + /** + * Describes a webview between `onPageStarted` and `onPageFinished` events, contains a [progress] + * property which is updated by the webview. + */ + public data class Loading(val progress: Float) : LoadingState() + + /** Describes a webview that has finished loading content. */ + public data object Finished : LoadingState() +} + +/** + * A state holder to hold the state for the WebView. In most cases this will be remembered using the + * rememberWebViewState(uri) function. + */ +@Stable +public class WebViewState(webContent: WebContent) { + public var lastLoadedUrl: String? by mutableStateOf(null) + internal set + + /** The content being loaded by the WebView */ + public var content: WebContent by mutableStateOf(webContent) + + /** + * Whether the WebView is currently [LoadingState.Loading] data in its main frame (along with + * progress) or the data loading has [LoadingState.Finished]. See [LoadingState] + */ + public var loadingState: LoadingState by mutableStateOf(LoadingState.Initializing) + internal set + + /** Whether the webview is currently loading data in its main frame */ + public val isLoading: Boolean + get() = loadingState !is Finished + + /** The title received from the loaded content of the current page */ + public var pageTitle: String? by mutableStateOf(null) + internal set + + /** the favicon received from the loaded content of the current page */ + public var pageIcon: Bitmap? by mutableStateOf(null) + internal set + + /** + * A list for errors captured in the last load. Reset when a new page is loaded. Errors could be + * from any resource (iframe, image, etc.), not just for the main page. For more fine grained + * control use the OnError callback of the WebView. + */ + public val errorsForCurrentRequest: SnapshotStateList = mutableStateListOf() + + /** + * The saved view state from when the view was destroyed last. To restore state, use the navigator + * and only call loadUrl if the bundle is null. See WebViewSaveStateSample. + */ + public var viewState: Bundle? = null + internal set + + // We need access to this in the state saver. An internal DisposableEffect or AndroidView + // onDestroy is called after the state saver and so can't be used. + internal var webView by mutableStateOf(null) +} + +/** + * Allows control over the navigation of a WebView from outside the composable. E.g. for performing + * a back navigation in response to the user clicking the "up" button in a TopAppBar. + * + * @see [rememberWebViewNavigator] + */ +@Stable +public class WebViewNavigator( + private val coroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, +) { + private sealed interface NavigationEvent { + data object Back : NavigationEvent + + data object Forward : NavigationEvent + + data object Reload : NavigationEvent + + data object StopLoading : NavigationEvent + + data class LoadUrl( + val url: String, + val additionalHttpHeaders: Map = emptyMap() + ) : NavigationEvent + + data class LoadHtml( + val html: String, + val baseUrl: String? = null, + val mimeType: String? = null, + val encoding: String? = "utf-8", + val historyUrl: String? = null + ) : NavigationEvent + + data class PostUrl(val url: String, val postData: ByteArray) : NavigationEvent { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PostUrl + + if (url != other.url) return false + if (!postData.contentEquals(other.postData)) return false + + return true + } + + override fun hashCode(): Int { + var result = url.hashCode() + result = 31 * result + postData.contentHashCode() + return result + } + } + } + + private val navigationEvents: MutableSharedFlow = MutableSharedFlow(replay = 1) + + // Use Dispatchers.Main to ensure that the webview methods are called on UI thread + internal suspend fun WebView.handleNavigationEvents(): Nothing = + withContext(dispatcherProvider.main()) { + navigationEvents.collect { event -> + when (event) { + is NavigationEvent.Back -> goBack() + is NavigationEvent.Forward -> goForward() + is NavigationEvent.Reload -> reload() + is NavigationEvent.StopLoading -> stopLoading() + is NavigationEvent.LoadHtml -> + loadDataWithBaseURL( + event.baseUrl, + event.html, + event.mimeType, + event.encoding, + event.historyUrl + ) + is NavigationEvent.LoadUrl -> { + loadUrl(event.url, event.additionalHttpHeaders) + } + is NavigationEvent.PostUrl -> { + postUrl(event.url, event.postData) + } + } + } + } + + /** True when the web view is able to navigate backwards, false otherwise. */ + public var canGoBack: Boolean by mutableStateOf(false) + internal set + + /** True when the web view is able to navigate forwards, false otherwise. */ + public var canGoForward: Boolean by mutableStateOf(false) + internal set + + public fun loadUrl(url: String, additionalHttpHeaders: Map = emptyMap()) { + coroutineScope.launch { + navigationEvents.emit(NavigationEvent.LoadUrl(url, additionalHttpHeaders)) + } + } + + public fun loadHtml( + html: String, + baseUrl: String? = null, + mimeType: String? = null, + encoding: String? = "utf-8", + historyUrl: String? = null + ) { + coroutineScope.launch { + navigationEvents.emit(NavigationEvent.LoadHtml(html, baseUrl, mimeType, encoding, historyUrl)) + } + } + + public fun postUrl(url: String, postData: ByteArray) { + coroutineScope.launch { navigationEvents.emit(NavigationEvent.PostUrl(url, postData)) } + } + + /** Navigates the webview back to the previous page. */ + public fun navigateBack() { + coroutineScope.launch { navigationEvents.emit(NavigationEvent.Back) } + } + + /** Navigates the webview forward after going back from a page. */ + public fun navigateForward() { + coroutineScope.launch { navigationEvents.emit(NavigationEvent.Forward) } + } + + /** Reloads the current page in the webview. */ + public fun reload() { + coroutineScope.launch { navigationEvents.emit(NavigationEvent.Reload) } + } + + /** Stops the current page load (if one is loading). */ + public fun stopLoading() { + coroutineScope.launch { navigationEvents.emit(NavigationEvent.StopLoading) } + } +} + +/** + * Creates and remembers a [WebViewNavigator] using the default [CoroutineScope] or a provided + * override. + */ +@Composable +public fun rememberWebViewNavigator( + coroutineScope: CoroutineScope = rememberCoroutineScope(), + dispatcherProvider: DispatcherProvider = DefaultDispatcherProvider(), +): WebViewNavigator = + remember(coroutineScope) { WebViewNavigator(coroutineScope, dispatcherProvider) } + +/** A wrapper class to hold errors from the WebView. */ +@Immutable +public data class WebViewError( + /** The request the error came from. */ + val request: WebResourceRequest?, + /** The error that was reported. */ + val error: WebResourceError +) + +/** + * Creates a WebView state that is remembered across Compositions. + * + * @param url The url to load in the WebView + * @param additionalHttpHeaders Optional, additional HTTP headers that are passed to + * [WebView.loadUrl]. Note that these headers are used for all subsequent requests of the WebView. + */ +@Composable +public fun rememberWebViewState( + url: String, + additionalHttpHeaders: ImmutableMap = persistentMapOf() +): WebViewState = + // Rather than using .apply {} here we will recreate the state, this prevents + // a recomposition loop when the webview updates the url itself. + remember { + WebViewState(WebContent.Url(url = url, additionalHttpHeaders = additionalHttpHeaders)) + } + .apply { + this.content = WebContent.Url(url = url, additionalHttpHeaders = additionalHttpHeaders) + } + +/** + * Creates a WebView state that is remembered across Compositions. + * + * @param data The uri to load in the WebView + */ +@Composable +public fun rememberWebViewStateWithHTMLData( + data: String, + baseUrl: String? = null, + encoding: String = "utf-8", + mimeType: String? = null, + historyUrl: String? = null +): WebViewState = + remember { WebViewState(WebContent.Data(data, baseUrl, encoding, mimeType, historyUrl)) } + .apply { this.content = WebContent.Data(data, baseUrl, encoding, mimeType, historyUrl) } + +/** + * Creates a WebView state that is remembered across Compositions. + * + * @param url The url to load in the WebView + * @param postData The data to be posted to the WebView with the url + */ +@Composable +public fun rememberWebViewState(url: String, postData: ByteArray): WebViewState = + // Rather than using .apply {} here we will recreate the state, this prevents + // a recomposition loop when the webview updates the url itself. + remember { WebViewState(WebContent.Post(url = url, postData = postData)) } + .apply { this.content = WebContent.Post(url = url, postData = postData) } + +/** + * Creates a WebView state that is remembered across Compositions and saved across activity + * recreation. When using saved state, you cannot change the URL via recomposition. The only way to + * load a URL is via a WebViewNavigator. + */ +@Composable +public fun rememberSaveableWebViewState(): WebViewState = + rememberSaveable(saver = WebStateSaver) { WebViewState(WebContent.NavigatorOnly) } + +public val WebStateSaver: Saver = run { + val pageTitleKey = "pagetitle" + val lastLoadedUrlKey = "lastloaded" + val stateBundle = "bundle" + + mapSaver( + save = { + val viewState = Bundle().apply { it.webView?.saveState(this) } + mapOf( + pageTitleKey to it.pageTitle, + lastLoadedUrlKey to it.lastLoadedUrl, + stateBundle to viewState + ) + }, + restore = { + WebViewState(WebContent.NavigatorOnly).apply { + this.pageTitle = it[pageTitleKey] as String? + this.lastLoadedUrl = it[lastLoadedUrlKey] as String? + this.viewState = it[stateBundle] as Bundle? + } + } + ) +}