Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .cursor/notes/libs.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ This document provides a comprehensive reference for all libraries used in the b
### Layout
- **ConstraintLayout Compose**: https://developer.android.com/jetpack/compose/layouts/constraintlayout

### WebKit
- **Documentation**: https://developer.android.com/jetpack/androidx/releases/webkit
- **WebMessageListener**: https://developer.android.com/develop/ui/views/layout/webapps/native-api-access-jsbridge

## Architecture & Dependency Injection

### Hilt
Expand Down
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,7 @@ dependencies {
implementation(libs.core.ktx)
implementation(libs.core.splashscreen)
implementation(libs.appcompat)
implementation(libs.webkit)
implementation(libs.activity.compose)
implementation(libs.material)
implementation(libs.datastore.preferences)
Expand Down
11 changes: 10 additions & 1 deletion app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
Expand All @@ -51,6 +52,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import to.bitkit.R
import to.bitkit.appwidget.AppWidgetRefreshReason
import to.bitkit.appwidget.appWidgetRefreshScheduler
import to.bitkit.env.Env
Expand Down Expand Up @@ -1446,14 +1448,21 @@ private fun NavGraphBuilder.shop(
)
}
deepLinkableComposable<Routes.ShopWebView> {
val blockedNavigationMessage = stringResource(R.string.other__shop__external_link_blocked)
ShopWebViewScreen(
onClose = { navController.navigateToHome() },
onBack = { navController.popBackStack() },
page = it.toRoute<Routes.ShopWebView>().page,
title = it.toRoute<Routes.ShopWebView>().title,
onPaymentIntent = { data ->
appViewModel.onScanResult(data)
}
},
onBlockedNavigation = {
appViewModel.toast(
type = Toast.ToastType.WARNING,
title = blockedNavigationMessage,
)
},
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package to.bitkit.ui.screens.shop.shopWebView

import to.bitkit.env.Env
import java.net.URI

/** Root host for Bitrefill shop pages and payment_intent messages. */
internal const val BITREFILL_ROOT_HOST = "bitrefill.com"

/** Default HTTPS port accepted for the trusted shop payment origin. */
private const val HTTPS_DEFAULT_PORT = 443

internal fun isAllowedShopHost(host: String?): Boolean {
val normalized = host?.lowercase()?.trim('.') ?: return false
return normalized == BITREFILL_ROOT_HOST || normalized.endsWith(".$BITREFILL_ROOT_HOST")
}

internal fun isAllowedShopOrigin(url: String?): Boolean {
if (url.isNullOrBlank()) return false
val parsed = runCatching { URI(url.trim()) }.getOrNull() ?: return false
if (parsed.scheme?.equals("https", ignoreCase = true) != true) return false
return isAllowedShopHost(parsed.host)
}

private val bitrefillEmbedOrigin = URI(Env.BITREFILL_URL)

private fun hasTrustedPaymentOrigin(parsed: URI): Boolean {
val hasTrustedScheme = parsed.scheme.equals(bitrefillEmbedOrigin.scheme, ignoreCase = true)
val hasTrustedHost = parsed.host.equals(bitrefillEmbedOrigin.host, ignoreCase = true)
val hasTrustedPort = parsed.port == -1 || parsed.port == HTTPS_DEFAULT_PORT
return hasTrustedScheme && hasTrustedHost && hasTrustedPort && parsed.rawUserInfo == null
}

internal fun isAllowedShopPaymentPage(url: String?): Boolean {
if (url.isNullOrBlank()) return false
val parsed = runCatching { URI(url.trim()) }.getOrNull() ?: return false
return hasTrustedPaymentOrigin(parsed)
}

internal fun isAllowedShopPaymentOrigin(origin: String?): Boolean {
if (origin.isNullOrBlank()) return false
val parsed = runCatching { URI(origin.trim()) }.getOrNull() ?: return false
return hasTrustedPaymentOrigin(parsed) &&
parsed.rawPath.isNullOrEmpty() &&
parsed.rawQuery == null &&
parsed.rawFragment == null
}

internal fun shopPaymentOriginRules(): Set<String> = setOf(Env.BITREFILL_URL)

internal fun shopMessageBridgeScript(): String = """
if (!window.__bitkitShopBridgeInstalled) {
window.__bitkitShopBridgeInstalled = true;
window.ReactNativeWebView = {
postMessage: function(data) {
Android.postMessage(typeof data === 'string' ? data : JSON.stringify(data));
}
};
window.addEventListener('message', function(event) {
if (event.origin !== '${Env.BITREFILL_URL}') return;
var data = event.data;
if (data == null) return;
Android.postMessage(typeof data === 'string' ? data : JSON.stringify(data));
});
}
""".trimIndent()
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,13 @@ import to.bitkit.utils.Logger
*/
class ShopWebViewClient(
private val onLoadingStateChanged: (Boolean) -> Unit,
private val onError: () -> Unit
private val onError: () -> Unit,
private val onBlockedNavigation: () -> Unit,
private val isPaymentBridgeSupported: () -> Boolean,
) : WebViewClient() {
private companion object {
const val TAG = "ShopWebViewClient"
}

override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
Expand All @@ -24,29 +29,18 @@ class ShopWebViewClient(
super.onPageFinished(view, url)
onLoadingStateChanged(false)

// Inject JavaScript to bridge postMessage to Android
view?.evaluateJavascript(
"""
window.ReactNativeWebView = {
postMessage: function(data) {
Android.postMessage(data);
}
};
if (isPaymentBridgeSupported() && isAllowedShopPaymentPage(url)) {
view?.evaluateJavascript(shopMessageBridgeScript(), null)
}
}

// Override the default postMessage if it exists
if (window.postMessage) {
window.originalPostMessage = window.postMessage;
window.postMessage = function(data) {
if (typeof data === 'string') {
Android.postMessage(data);
} else {
Android.postMessage(JSON.stringify(data));
}
};
}
""".trimIndent(),
null
)
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
Comment thread
ben-kaufman marked this conversation as resolved.
if (request?.isForMainFrame != true) return false
val url = request.url?.toString()
if (isAllowedShopOrigin(url)) return false
Logger.warn("Blocked shop navigation to untrusted origin '$url'", context = TAG)
onBlockedNavigation()
return true
}

@Suppress("ComplexCondition")
Expand All @@ -58,7 +52,7 @@ class ShopWebViewClient(
super.onReceivedError(view, request, error)
Logger.warn(
"Error: ${error?.description}, Code: ${error?.errorCode}, URL: ${request?.url}",
context = "ShopWebViewScreen"
context = TAG,
)
onLoadingStateChanged(false)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,70 +1,92 @@
package to.bitkit.ui.screens.shop.shopWebView

import android.webkit.JavascriptInterface
import android.webkit.WebView
import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import kotlinx.serialization.json.Json
import to.bitkit.utils.Logger

/**
* JavaScript interface for handling WebView messages.
*
* SECURITY NOTE: This interface is exposed to JavaScript running in the WebView.
* Only methods annotated with @JavascriptInterface are accessible from JavaScript
* on API 17+ (Android 4.2+). All methods should validate input and handle errors
* gracefully since they run on a background thread.
*
* Thread Safety: JavaScript interacts with this object on a private background
* thread. All callbacks should be thread-safe or use appropriate dispatching.
* [attachTo] uses an origin-scoped WebMessageListener. Payment handling is
* disabled when that listener is unavailable because legacy JavaScript
* interfaces cannot identify the calling frame.
*/
class ShopWebViewInterface(
private val onPaymentIntent: (String) -> Unit,
private val isWebMessageListenerSupported: () -> Boolean = {
WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)
},
private val addWebMessageListener: (
WebView,
String,
Set<String>,
WebViewCompat.WebMessageListener,
) -> Unit = { webView, jsObjectName, allowedOriginRules, listener ->
WebViewCompat.addWebMessageListener(webView, jsObjectName, allowedOriginRules, listener)
},
) {
private companion object {
const val TAG = "ShopWebViewInterface"
const val JS_OBJECT_NAME = "Android"
const val PAYMENT_INTENT_EVENT = "payment_intent"
}

private val json = Json { ignoreUnknownKeys = true }
private val webMessageListenerSupported by lazy(isWebMessageListenerSupported)

/**
* Handles messages posted from JavaScript.
* This method is called on a background thread - ensure thread safety.
*
* @param message JSON string containing the message data
*/
@Suppress("NestedBlockDepth")
@JavascriptInterface
fun postMessage(message: String) {
if (message.isBlank()) {
Logger.warn("Received empty message", context = "WebView")
internal fun supportsPaymentBridge() = webMessageListenerSupported

fun attachTo(webView: WebView) {
if (!supportsPaymentBridge()) {
Logger.warn("Disabled shop payment bridge because WebMessageListener is unavailable", context = TAG)
return
}

runCatching {
val data = json.decodeFromString<WebViewMessage>(message)
when (data.event) {
"payment_intent" -> {
data.paymentUri?.let { uri ->
// Validate URI before passing it along
if (uri.isNotBlank()) {
onPaymentIntent(uri)
} else {
Logger.warn("Received payment_intent with empty URI", context = "WebView")
}
} ?: Logger.warn("Received payment_intent without URI", context = "WebView")
}
addWebMessageListener(
webView,
JS_OBJECT_NAME,
shopPaymentOriginRules(),
) { _, message, sourceOrigin, _, _ ->
onWebMessage(message, sourceOrigin.toString())
}
}

else -> {
Logger.debug("Unknown event type: ${data.event}", context = "WebView")
}
}
}.onFailure {
Logger.error("Error parsing message: $message", it, context = "WebView")
internal fun onWebMessage(message: WebMessageCompat, sourceOrigin: String?) {
if (message.type != WebMessageCompat.TYPE_STRING) {
Logger.warn("Rejected non-string shop WebView message", context = TAG)
return
}
val data = message.data.orEmpty()
if (data.isBlank()) {
Logger.warn("Received empty shop WebView message", context = TAG)
return
}
handlePaymentMessage(data, sourceOrigin)
}

/**
* Returns whether the interface is ready to receive messages.
*
* @return true if the interface is initialized and ready
*/
@Suppress("FunctionOnlyReturningConstant")
@JavascriptInterface
fun isReady(): Boolean {
return true
internal fun handlePaymentMessage(message: String, sourceOrigin: String?) {
if (!isAllowedShopPaymentOrigin(sourceOrigin)) {
Logger.warn("Rejected shop payment_intent from untrusted origin '$sourceOrigin'", context = TAG)
return
}

val data = runCatching { json.decodeFromString<WebViewMessage>(message) }.getOrElse {
Logger.debug("Ignored unrecognized shop WebView message", context = TAG)
return
}
when (data.event) {
PAYMENT_INTENT_EVENT -> {
val uri = data.paymentUri?.trim().orEmpty()
if (uri.isBlank()) {
Logger.warn("Received payment_intent with empty URI", context = TAG)
return
}
onPaymentIntent(uri)
}
else -> Logger.debug("Ignored shop WebView event '${data.event}'", context = TAG)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,30 @@ import to.bitkit.ui.scaffold.DrawerNavIcon
import to.bitkit.ui.scaffold.ScreenColumn
import to.bitkit.ui.theme.AppThemeSurface

@SuppressLint("SetJavaScriptEnabled", "JavascriptInterface")
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun ShopWebViewScreen(
onClose: () -> Unit,
onBack: () -> Unit,
onPaymentIntent: (String) -> Unit,
onBlockedNavigation: () -> Unit,
page: String,
title: String,
) {
var isLoading by remember { mutableStateOf(true) }
var webView: WebView? by remember { mutableStateOf(null) }

val webViewInterface = remember { ShopWebViewInterface(onPaymentIntent) }
val webViewInterface = remember {
ShopWebViewInterface(
onPaymentIntent = onPaymentIntent,
)
}
val webViewClient = remember {
ShopWebViewClient(
onLoadingStateChanged = { loading -> isLoading = loading },
onError = onClose
onError = onClose,
onBlockedNavigation = onBlockedNavigation,
isPaymentBridgeSupported = webViewInterface::supportsPaymentBridge,
)
}

Expand All @@ -54,21 +61,21 @@ fun ShopWebViewScreen(

Box(modifier = Modifier.weight(1f)) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
WebView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)

webView = this
this.webViewClient = webViewClient
configureForBasicWebContent()
addJavascriptInterface(webViewInterface, "Android")
webViewInterface.attachTo(this)
loadUrl(bitrefillUrlOf(page))
webView = this
}
},
modifier = Modifier.fillMaxSize()
)

if (isLoading) {
Expand Down Expand Up @@ -96,6 +103,7 @@ private fun Preview() {
onClose = {},
onBack = {},
onPaymentIntent = {},
onBlockedNavigation = {},
page = "esims",
title = "Gift Cards"
)
Expand Down
Loading
Loading