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