diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 5fb83a42..c5938992 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -51,6 +51,7 @@ android {
dependencies {
+ implementation(project(":web"))
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.activity:activity-compose:1.8.2")
diff --git a/build.gradle.kts b/build.gradle.kts
index 8e8f4ab9..0c90f2ad 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -2,4 +2,5 @@
plugins {
id("com.android.application") version "8.2.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.0" apply false
+ id("com.android.library") version "8.2.0" apply false
}
\ No newline at end of file
diff --git a/settings.gradle.kts b/settings.gradle.kts
index f025d273..201385ba 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -15,4 +15,4 @@ dependencyResolutionManagement {
rootProject.name = "compose-webview"
include(":app")
-
\ No newline at end of file
+include(":web")
diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/web/build.gradle.kts b/web/build.gradle.kts
new file mode 100644
index 00000000..eb732758
--- /dev/null
+++ b/web/build.gradle.kts
@@ -0,0 +1,54 @@
+plugins {
+ id("com.android.library")
+ id("org.jetbrains.kotlin.android")
+}
+
+android {
+ namespace = "com.kevinnzou.web"
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 24
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.1"
+ }
+}
+
+dependencies {
+ val composeBom = platform("androidx.compose:compose-bom:2024.01.00")
+ implementation(composeBom)
+ androidTestImplementation(composeBom)
+ implementation("androidx.compose.foundation:foundation")
+ implementation("androidx.activity:activity-compose:1.8.2")
+
+ implementation("androidx.core:core-ktx:1.12.0")
+ implementation("androidx.appcompat:appcompat:1.6.1")
+ implementation("com.google.android.material:material:1.11.0")
+ testImplementation("junit:junit:4.13.2")
+ androidTestImplementation("androidx.test.ext:junit:1.1.5")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
+}
\ No newline at end of file
diff --git a/web/consumer-rules.pro b/web/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/web/proguard-rules.pro b/web/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/web/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/web/src/androidTest/java/com/kevinnzou/web/ExampleInstrumentedTest.kt b/web/src/androidTest/java/com/kevinnzou/web/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..93d13414
--- /dev/null
+++ b/web/src/androidTest/java/com/kevinnzou/web/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.kevinnzou.web
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.kevinnzou.web.test", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/web/src/main/AndroidManifest.xml b/web/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..a5918e68
--- /dev/null
+++ b/web/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/web/src/main/java/com/kevinnzou/web/WebView.kt b/web/src/main/java/com/kevinnzou/web/WebView.kt
new file mode 100644
index 00000000..0691f60c
--- /dev/null
+++ b/web/src/main/java/com/kevinnzou/web/WebView.kt
@@ -0,0 +1,757 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:Suppress("DEPRECATION")
+
+package com.kevinnzou.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 kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+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
+ * @sample com.google.accompanist.sample.webview.BasicWebViewSample
+ */
+@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 = 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 = 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 overriden 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 LoadingState.Finished) return
+ state.loadingState = 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
+ }
+ }
+
+ @Deprecated("Use state.lastLoadedUrl instead")
+ public fun getCurrentUrl(): String? {
+ return when (this) {
+ is Url -> url
+ is Data -> baseUrl
+ is Post -> url
+ is NavigatorOnly -> throw IllegalStateException("Unsupported")
+ }
+ }
+
+ public 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 LoadingState.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 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(Dispatchers.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()
+): WebViewNavigator = remember(coroutineScope) { WebViewNavigator(coroutineScope) }
+
+/**
+ * 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: Map = emptyMap()
+): 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.
+ *
+ * @param data The uri to load in the WebView
+ * @sample com.google.accompanist.sample.webview.WebViewSaveStateSample
+ */
+@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?
+ }
+ }
+ )
+}
diff --git a/web/src/test/java/com/kevinnzou/web/ExampleUnitTest.kt b/web/src/test/java/com/kevinnzou/web/ExampleUnitTest.kt
new file mode 100644
index 00000000..f0f95835
--- /dev/null
+++ b/web/src/test/java/com/kevinnzou/web/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package com.kevinnzou.web
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file