diff --git a/README.md b/README.md index f48dec0..d1f42a6 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ gradle.ext.roboswag = [ 'base-map', 'yandex-map', 'google-map', + 'webview', 'encrypted-shared-prefs' ] diff --git a/webview/.gitignore b/webview/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/webview/.gitignore @@ -0,0 +1 @@ +/build diff --git a/webview/build.gradle b/webview/build.gradle new file mode 100644 index 0000000..5b4bd46 --- /dev/null +++ b/webview/build.gradle @@ -0,0 +1,44 @@ +apply from: "../android-configs/lib-config.gradle" +apply plugin: 'kotlin-android' + +android { + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation project(":views") + implementation project(":kotlin-extensions") + + implementation "com.google.android.material:material" + implementation "androidx.constraintlayout:constraintlayout" + implementation "androidx.core:core-ktx" + + constraints { + implementation("com.google.android.material:material") { + version { + require '1.0.0' + } + } + implementation("androidx.constraintlayout:constraintlayout") { + version { + require '2.0.0-beta4' + } + } + implementation("androidx.core:core-ktx") { + version { + require '1.3.1' + } + } + implementation("org.jetbrains.kotlin:kotlin-stdlib") { + version { + require '1.3.0' + } + } + } +} + +repositories { + mavenCentral() +} diff --git a/webview/src/main/AndroidManifest.xml b/webview/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9157adb --- /dev/null +++ b/webview/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + diff --git a/webview/src/main/java/ru/touchin/roboswag/webview/web_view/BaseChromeWebViewClient.kt b/webview/src/main/java/ru/touchin/roboswag/webview/web_view/BaseChromeWebViewClient.kt new file mode 100644 index 0000000..13754f9 --- /dev/null +++ b/webview/src/main/java/ru/touchin/roboswag/webview/web_view/BaseChromeWebViewClient.kt @@ -0,0 +1,41 @@ +package ru.touchin.roboswag.webview.web_view + +import android.webkit.ConsoleMessage +import android.webkit.JsPromptResult +import android.webkit.JsResult +import android.webkit.WebChromeClient +import android.webkit.WebView + +open class BaseChromeWebViewClient( + private val onJsConfirm: (() -> Unit)? = null, + private val onJsAlert: (() -> Unit)? = null, + private val onJsPrompt: ((String?) -> Unit)? = null, + private val onJsError: ((error: ConsoleMessage) -> Unit)? = null +) : WebChromeClient() { + + override fun onJsConfirm(view: WebView?, url: String?, message: String?, result: JsResult?): Boolean { + onJsConfirm?.invoke() + result?.confirm() + return true + } + + override fun onJsAlert(view: WebView?, url: String?, message: String?, result: JsResult?): Boolean { + onJsAlert?.invoke() + result?.confirm() + return true + } + + override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { + if (consoleMessage?.messageLevel() == ConsoleMessage.MessageLevel.ERROR) { + onJsError?.invoke(consoleMessage) + } + return super.onConsoleMessage(consoleMessage) + } + + override fun onJsPrompt(view: WebView?, url: String?, message: String?, defaultValue: String?, result: JsPromptResult?): Boolean { + onJsPrompt?.invoke(defaultValue) + result?.confirm() + return true + } + +} diff --git a/webview/src/main/java/ru/touchin/roboswag/webview/web_view/BaseWebView.kt b/webview/src/main/java/ru/touchin/roboswag/webview/web_view/BaseWebView.kt new file mode 100644 index 0000000..8716804 --- /dev/null +++ b/webview/src/main/java/ru/touchin/roboswag/webview/web_view/BaseWebView.kt @@ -0,0 +1,141 @@ +package ru.touchin.roboswag.webview.web_view + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Color +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.webkit.ConsoleMessage +import android.webkit.WebView +import androidx.core.content.withStyledAttributes +import androidx.core.widget.TextViewCompat +import ru.touchin.extensions.setOnRippleClickListener +import ru.touchin.roboswag.views.widget.Switcher +import ru.touchin.roboswag.webview.R +import ru.touchin.roboswag.webview.databinding.BaseWebViewBinding + +open class BaseWebView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet?, + defStyleAttr: Int = 0 +) : Switcher(context, attrs), WebViewCallback { + + private val binding = BaseWebViewBinding.inflate(LayoutInflater.from(context), this) + + var onWebViewLoaded: (() -> Unit)? = null + var onWebViewRepeatButtonClicked: (() -> Unit)? = null + var onWebViewScrolled: ((WebView, Int, Int) -> Unit)? = null + var onCookieLoaded: ((cookies: Map) -> Unit)? = null + + var onJsConfirm: (() -> Unit)? = null + var onJsAlert: (() -> Unit)? = null + var onJsPrompt: ((defaultValue: String?) -> Unit)? = null + var onJsError: ((error: ConsoleMessage) -> Unit)? = null + + var isPullToRefreshEnable = false + set(value) { + binding.pullToRefresh.isEnabled = value + binding.pullToRefresh.isRefreshing = false + field = value + } + + var isRedirectEnable = false + + init { + binding.pullToRefresh.isEnabled = isPullToRefreshEnable + binding.apply { + context.withStyledAttributes(attrs, R.styleable.BaseWebView, defStyleAttr, 0) { + if (hasValue(R.styleable.BaseWebView_errorTextAppearance)) { + TextViewCompat.setTextAppearance(errorText, getResourceId(R.styleable.BaseWebView_errorTextAppearance, 0)) + } + if (hasValue(R.styleable.BaseWebView_repeatButtonTextAppearance)) { + TextViewCompat.setTextAppearance(errorRepeatButton, getResourceId(R.styleable.BaseWebView_repeatButtonTextAppearance, 0)) + } + if (hasValue(R.styleable.BaseWebView_progressBarTintColor)) { + progressBar.indeterminateTintList = ColorStateList.valueOf(getColor(R.styleable.BaseWebView_progressBarTintColor, Color.BLACK)) + } + if (hasValue(R.styleable.BaseWebView_repeatButtonBackground)) { + errorRepeatButton.setBackgroundResource(getResourceId(R.styleable.BaseWebView_repeatButtonBackground, 0)) + } + if (hasValue(R.styleable.BaseWebView_screenBackground)) { + setBackgroundResource(getResourceId(R.styleable.BaseWebView_screenBackground, 0)) + } + if (hasValue(R.styleable.BaseWebView_errorText)) { + errorText.text = getString(R.styleable.BaseWebView_errorText) + } + if (hasValue(R.styleable.BaseWebView_repeatButtonText)) { + errorRepeatButton.text = getString(R.styleable.BaseWebView_repeatButtonText) + } + } + pullToRefresh.setOnRefreshListener { + webView.reload() + } + errorRepeatButton.setOnRippleClickListener { + onWebViewRepeatButtonClicked?.invoke() + } + webView.onScrollChanged = { scrollX, scrollY, _, _ -> + onWebViewScrolled?.invoke(binding.webView, scrollX, scrollY) + } + setWebViewPreferences() + } + } + + override fun onStateChanged(newState: WebViewLoadingState) { + when { + newState == WebViewLoadingState.LOADED -> { + onWebViewLoaded?.invoke() + binding.pullToRefresh.isRefreshing = false + showChild(R.id.pull_to_refresh) + } + newState == WebViewLoadingState.LOADING + && !binding.pullToRefresh.isRefreshing -> { + showChild(R.id.progress_bar) + } + newState == WebViewLoadingState.ERROR -> { + showChild(R.id.error_layout) + } + } + } + + override fun onOverrideUrlLoading(url: String?): Boolean = isRedirectEnable + + override fun onPageCookiesLoaded(cookies: Map) { + onCookieLoaded?.invoke(cookies) + } + + fun setBaseWebViewClient(isSSlPinningEnable: Boolean = false) { + binding.webView.webViewClient = BaseWebViewClient(this, isSSlPinningEnable) + binding.webView.webChromeClient = BaseChromeWebViewClient(onJsConfirm, onJsAlert, onJsPrompt, onJsError) + } + + fun getWebView() = binding.webView + + fun loadUrl(url: String?) { + binding.webView.loadUrl(url) + } + + fun setState(newState: WebViewLoadingState) { + onStateChanged(newState) + } + + fun setOnWebViewDisplayedContentAction(action: () -> Unit) { + binding.webView.onWebViewDisplayedContent = action + } + + @SuppressLint("SetJavaScriptEnabled") + open fun setWebViewPreferences() { + binding.webView.apply { + scrollBarStyle = View.SCROLLBARS_INSIDE_OVERLAY + setLayerType(View.LAYER_TYPE_SOFTWARE, null) + with(settings) { + loadsImagesAutomatically = true + javaScriptEnabled = true + domStorageEnabled = true + loadWithOverviewMode = true + } + } + } + +} diff --git a/webview/src/main/java/ru/touchin/roboswag/webview/web_view/BaseWebViewClient.kt b/webview/src/main/java/ru/touchin/roboswag/webview/web_view/BaseWebViewClient.kt new file mode 100644 index 0000000..117806b --- /dev/null +++ b/webview/src/main/java/ru/touchin/roboswag/webview/web_view/BaseWebViewClient.kt @@ -0,0 +1,89 @@ +package ru.touchin.roboswag.webview.web_view + +import android.graphics.Bitmap +import android.net.http.SslError +import android.os.Handler +import android.os.Looper +import android.webkit.CookieManager +import android.webkit.SslErrorHandler +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.core.os.postDelayed + +open class BaseWebViewClient(private val callback: WebViewCallback, private val isSslPinningEnable: Boolean) : WebViewClient() { + + companion object { + private const val WEB_VIEW_TIMEOUT_MS = 30 * 1000L // 30 sec + } + + private var isError = false + private var isTimeout = true + + override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + isError = false + callback.onStateChanged(WebViewLoadingState.LOADING) + + Looper.myLooper()?.let { looper -> + val handler = Handler(looper) + handler.postDelayed(WEB_VIEW_TIMEOUT_MS) { + if (isTimeout) { + isError = true + pageFinished() + } + isTimeout = true + } + } + } + + override fun onPageFinished(view: WebView, url: String) { + super.onPageFinished(view, url) + isTimeout = false + callback.onPageCookiesLoaded(CookieManager.getInstance().getCookie(url).processCookies()) + pageFinished() + } + + override fun shouldOverrideUrlLoading(view: WebView, url: String?): Boolean { + return !callback.onOverrideUrlLoading(url) && view.originalUrl != null + } + + override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) { + if (isSslPinningEnable) { + super.onReceivedSslError(view, handler, error) + isError = true + callback.onStateChanged(WebViewLoadingState.ERROR) + } else { + handler.proceed() + } + } + + override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) { + if (!(error.errorCode == -10 && "about:blank" == request.url.toString())) { + isError = true + } + pageFinished() + } + + private fun pageFinished() { + callback.onStateChanged(if (isError) WebViewLoadingState.ERROR else WebViewLoadingState.LOADED) + } + + private fun String.processCookies(): Map { + val cookiesMap = mutableMapOf() + this.split(";") + .forEach { cookie -> + val splittedCookie = cookie.trim().split("=") + cookiesMap[splittedCookie.first()] = splittedCookie.last() + } + return cookiesMap + } + +} + +enum class WebViewLoadingState { + LOADING, + ERROR, + LOADED +} diff --git a/webview/src/main/java/ru/touchin/roboswag/webview/web_view/CustomWebView.kt b/webview/src/main/java/ru/touchin/roboswag/webview/web_view/CustomWebView.kt new file mode 100644 index 0000000..72a4a4e --- /dev/null +++ b/webview/src/main/java/ru/touchin/roboswag/webview/web_view/CustomWebView.kt @@ -0,0 +1,31 @@ +package ru.touchin.roboswag.webview.web_view + +import android.content.Context +import android.util.AttributeSet +import android.webkit.WebView + +class CustomWebView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet?, + defStyleAttr: Int = 0 +) : WebView(context, attrs, defStyleAttr) { + + var onWebViewDisplayedContent: (() -> Unit)? = null + var onScrollChanged: ((Int, Int, Int, Int) -> Unit)? = null + + // https://stackoverflow.com/a/14678910 + override fun invalidate() { + super.invalidate() + + if (contentHeight > 0) { + onWebViewDisplayedContent?.invoke() + } + + } + + override fun onScrollChanged(scrollX: Int, scrollY: Int, oldScrollX: Int, oldScrollY: Int) { + super.onScrollChanged(scrollX, scrollY, oldScrollX, oldScrollY) + onScrollChanged?.invoke(scrollX, scrollY, oldScrollX, oldScrollY) + } + +} diff --git a/webview/src/main/java/ru/touchin/roboswag/webview/web_view/WebViewCallback.kt b/webview/src/main/java/ru/touchin/roboswag/webview/web_view/WebViewCallback.kt new file mode 100644 index 0000000..59ddc11 --- /dev/null +++ b/webview/src/main/java/ru/touchin/roboswag/webview/web_view/WebViewCallback.kt @@ -0,0 +1,11 @@ +package ru.touchin.roboswag.webview.web_view + +interface WebViewCallback { + + fun onStateChanged(newState: WebViewLoadingState) + + fun onOverrideUrlLoading(url: String?): Boolean + + fun onPageCookiesLoaded(cookies: Map) + +} diff --git a/webview/src/main/res/layout/base_web_view.xml b/webview/src/main/res/layout/base_web_view.xml new file mode 100644 index 0000000..a167e46 --- /dev/null +++ b/webview/src/main/res/layout/base_web_view.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + diff --git a/webview/src/main/res/values/attrs.xml b/webview/src/main/res/values/attrs.xml new file mode 100644 index 0000000..ff75e22 --- /dev/null +++ b/webview/src/main/res/values/attrs.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + +