diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index c5fed1199..02fb6be4f 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -181,6 +181,7 @@ internal object Env { const val PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=to.bitkit" const val EXCHANGES_URL = "https://bitcoin.org/en/exchanges#international" const val BIT_REFILL_URL = "https://embed.bitrefill.com" + const val BTC_MAP_URL = "https://btcmap.org/map" private const val BITREFILL_REF = "AL6dyZYt" private const val BITREFILL_PAYMENT_METHOD = "bitcoin" // Payment method "bitcoin" gives a unified invoice private const val BITREFILL_APP_NAME = "Bitkit" diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 8e5e8ff31..c32e47c97 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -45,9 +45,9 @@ import to.bitkit.ui.screens.profile.CreateProfileScreen import to.bitkit.ui.screens.profile.ProfileIntroScreen import to.bitkit.ui.screens.scanner.QrScanningScreen import to.bitkit.ui.screens.scanner.SCAN_REQUEST_KEY -import to.bitkit.ui.screens.shop.ShopDiscoverScreen +import to.bitkit.ui.screens.shop.shopDiscover.ShopDiscoverScreen import to.bitkit.ui.screens.shop.ShopIntroScreen -import to.bitkit.ui.screens.shop.ShopWebViewScreen +import to.bitkit.ui.screens.shop.shopWebView.ShopWebViewScreen import to.bitkit.ui.screens.transfer.FundingAdvancedScreen import to.bitkit.ui.screens.transfer.FundingScreen import to.bitkit.ui.screens.transfer.LiquidityScreen diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/ShopDiscoverScreen.kt b/app/src/main/java/to/bitkit/ui/screens/shop/ShopDiscoverScreen.kt deleted file mode 100644 index 7830cedf6..000000000 --- a/app/src/main/java/to/bitkit/ui/screens/shop/ShopDiscoverScreen.kt +++ /dev/null @@ -1,179 +0,0 @@ -package to.bitkit.ui.screens.shop - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import to.bitkit.R -import to.bitkit.models.BitrefillCategory -import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.SuggestionCard -import to.bitkit.ui.components.Text13Up -import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.CloseNavIcon -import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.shared.util.gradientBackground -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors - -@Composable -fun ShopDiscoverScreen( - onClose: () -> Unit, - onBack: () -> Unit, - navigateWebView: (String, String) -> Unit, //Page, Title -) { - ScreenColumn( - modifier = Modifier.gradientBackground(), - ) { - AppTopBar( - titleText = stringResource(R.string.other__shop__discover__nav_title), - onBackClick = onBack, - actions = { CloseNavIcon(onClick = onClose) }, - ) - - LazyColumn( - modifier = Modifier.padding(horizontal = 16.dp), - ) { - item { - VerticalSpacer(16.dp) - - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - val title = stringResource(R.string.other__shop__discover__gift_cards__title) - SuggestionCard( - modifier = Modifier.weight(1f), - gradientColor = Colors.Green, - title = title, - description = stringResource(R.string.other__shop__discover__gift_cards__description), - icon = R.drawable.gift, - captionColor = Colors.Gray1, - size = 164, - onClick = { - navigateWebView("gift-cards", title) - }, - ) - val title2 = stringResource(R.string.other__shop__discover__esims__title) - SuggestionCard( - modifier = Modifier.weight(1f), - gradientColor = Colors.Yellow, - title = title2, - description = stringResource(R.string.other__shop__discover__esims__description), - icon = R.drawable.globe, - captionColor = Colors.Gray1, - size = 164, - onClick = { - navigateWebView("esims", title2) - }, - ) - } - - VerticalSpacer(16.dp) - - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - val title = stringResource(R.string.other__shop__discover__refill__title) - SuggestionCard( - modifier = Modifier.weight(1f), - gradientColor = Colors.Purple, - title = title, - description = stringResource(R.string.other__shop__discover__refill__description), - icon = R.drawable.phone, - captionColor = Colors.Gray1, - size = 164, - onClick = { - navigateWebView("refill", title) - }, - ) - val title2 = stringResource(R.string.other__shop__discover__travel__title) - SuggestionCard( - modifier = Modifier.weight(1f), - gradientColor = Colors.Red, - title = title2, - description = stringResource(R.string.other__shop__discover__travel__description), - icon = R.drawable.rocket_2, - size = 164, - captionColor = Colors.Gray1, - onClick = { - navigateWebView("buy/travel", title2) - }, - ) - } - - VerticalSpacer(32.dp) - - Text13Up(stringResource(R.string.other__shop__discover__label), color = Colors.White64) - - VerticalSpacer(16.dp) - } - - items(items = BitrefillCategory.entries.toList(), key = { it.name }) { item -> - Column { - Row( - modifier = Modifier - .padding(top = 8.5.dp, bottom = 10.5.dp) - .clickable { - navigateWebView(item.route, item.title) - }, - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .clip(CircleShape) - .size(32.dp) - .background(Colors.White10), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = item.icon, - contentDescription = null, - tint = Colors.White64, - modifier = Modifier.size(16.dp), - ) - } - BodyM( - text = item.title, - modifier = Modifier - .weight(1f) - .padding(horizontal = 8.dp), - ) - Icon( - painter = painterResource(R.drawable.ic_chevron_right), - contentDescription = null, - tint = Colors.White64, - modifier = Modifier.size(24.dp), - ) - } - HorizontalDivider() - } - } - } - } -} - -@Preview -@Composable -private fun Preview() { - AppThemeSurface { - ShopDiscoverScreen(onClose = {}, onBack = {}, navigateWebView = { _, _ -> }) - } -} diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/ShopWebViewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/shop/ShopWebViewScreen.kt deleted file mode 100644 index f6381b599..000000000 --- a/app/src/main/java/to/bitkit/ui/screens/shop/ShopWebViewScreen.kt +++ /dev/null @@ -1,198 +0,0 @@ -package to.bitkit.ui.screens.shop - -import android.annotation.SuppressLint -import android.graphics.Bitmap -import android.util.Log -import android.view.ViewGroup -import android.webkit.JavascriptInterface -import android.webkit.WebResourceError -import android.webkit.WebResourceRequest -import android.webkit.WebView -import android.webkit.WebViewClient -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.viewinterop.AndroidView -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import to.bitkit.R -import to.bitkit.env.Env -import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.CloseNavIcon -import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.utils.Logger - -@Serializable -data class WebViewMessage( - val event: String, - @SerialName("paymentUri") - val paymentUri: String? = null -) - -@SuppressLint("SetJavaScriptEnabled") -@Composable -fun ShopWebViewScreen( - onClose: () -> Unit, - onBack: () -> Unit, - onPaymentIntent: (String) -> Unit, - page: String, - title: String, -) { - var isLoading by remember { mutableStateOf(true) } - var webView: WebView? by remember { mutableStateOf(null) } - - // Create a JavaScript interface for message handling - class WebViewInterface { - @JavascriptInterface - fun postMessage(message: String) { - try { - val json = Json { ignoreUnknownKeys = true } - val data = json.decodeFromString(message) - - Log.d("WebView", "Received message: $message") - - when (data.event) { - "payment_intent" -> { - data.paymentUri?.let { uri -> - onPaymentIntent(uri) - } - } - // Add more event types as needed - else -> { - Log.d("WebView", "Unknown event type: ${data.event}") - } - } - } catch (e: Exception) { - Log.e("WebView", "Error parsing message: $message", e) - } - } - } - - ScreenColumn { - AppTopBar( - titleText = "${stringResource(R.string.other__shop__discover__nav_title)} $title", - onBackClick = onBack, - actions = { CloseNavIcon(onClick = onClose) }, - ) - - 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, - ) - - webViewClient = object : WebViewClient() { - override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { - super.onPageStarted(view, url, favicon) - isLoading = true - } - - override fun onPageFinished(view: WebView?, url: String?) { - super.onPageFinished(view, url) - isLoading = false - - // Inject JavaScript to bridge postMessage to Android - view?.evaluateJavascript(""" - window.ReactNativeWebView = { - postMessage: function(data) { - Android.postMessage(data); - } - }; - - // 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 onReceivedError( - view: WebView?, - request: WebResourceRequest?, - error: WebResourceError?, - ) { - super.onReceivedError(view, request, error) - Logger.warn("Error: ${error?.description}, Code: ${error?.errorCode}, URL: ${request?.url}", context = "ShopWebViewScreen") - isLoading = false - - error?.let { - if (it.errorCode == WebViewClient.ERROR_HOST_LOOKUP || - it.errorCode == WebViewClient.ERROR_CONNECT || - it.errorCode == WebViewClient.ERROR_TIMEOUT || - it.errorCode == WebViewClient.ERROR_FILE_NOT_FOUND) { - onClose() - } - } - } - } - - // Configure WebView settings - settings.apply { - javaScriptEnabled = true - domStorageEnabled = true - allowContentAccess = true - allowFileAccess = false - } - - addJavascriptInterface(WebViewInterface(), "Android") - - loadUrl(Env.buildBitrefillUri(page = page)) - webView = this - } - }, - ) - - if (isLoading) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } - } - - BackHandler { - webView?.let { - if (it.canGoBack()) { - it.goBack() - } else { - onBack() - } - } ?: onBack() - } - } -} - -@Preview -@Composable -private fun Preview() { - AppThemeSurface { - ShopWebViewScreen( - onClose = {}, - onBack = {}, - onPaymentIntent = { uri -> - }, - page = "esims", - title = "Gift Cards" - ) - } -} diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopDiscover/MapWebViewClient.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopDiscover/MapWebViewClient.kt new file mode 100644 index 000000000..e3e3aece7 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopDiscover/MapWebViewClient.kt @@ -0,0 +1,52 @@ +package to.bitkit.ui.screens.shop.shopDiscover + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import to.bitkit.utils.Logger + +/** + * Simple WebViewClient for the map tab that handles loading states and errors + */ +class MapWebViewClient( + private val onLoadingStateChanged: (Boolean) -> Unit, + private val onError: (() -> Unit)? = null +) : WebViewClient() { + + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + onLoadingStateChanged(true) + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + onLoadingStateChanged(false) + } + + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError?, + ) { + super.onReceivedError(view, request, error) + Logger.warn( + "Error: ${error?.description}, Code: ${error?.errorCode}, URL: ${request?.url}", + context = "MapTabContent", + ) + onLoadingStateChanged(false) + + error?.let { + if (it.errorCode == ERROR_HOST_LOOKUP || + it.errorCode == ERROR_CONNECT || + it.errorCode == ERROR_TIMEOUT || + it.errorCode == ERROR_FILE_NOT_FOUND + ) { + onError?.invoke() + } + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopDiscover/ShopDiscoverScreen.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopDiscover/ShopDiscoverScreen.kt new file mode 100644 index 000000000..f7a548f29 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopDiscover/ShopDiscoverScreen.kt @@ -0,0 +1,286 @@ +package to.bitkit.ui.screens.shop.shopDiscover + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.view.ViewGroup +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRowDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import to.bitkit.R +import to.bitkit.env.Env +import to.bitkit.models.BitrefillCategory +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.CaptionB +import to.bitkit.ui.components.SuggestionCard +import to.bitkit.ui.components.Text13Up +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.CloseNavIcon +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.shared.util.clickableAlpha +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.theme.Shapes +import to.bitkit.ui.utils.configureForBasicWebContent +import to.bitkit.utils.Logger + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShopDiscoverScreen( + onClose: () -> Unit, + onBack: () -> Unit, + navigateWebView: (String, String) -> Unit, //Page, Title +) { + var selectedTabIndex by remember { mutableIntStateOf(0) } + val tabTitles = listOf( + stringResource(R.string.other__shop__discover__tabs__shop), + stringResource(R.string.other__shop__discover__tabs__map), + ) + + ScreenColumn( + modifier = Modifier.gradientBackground(), + ) { + AppTopBar( + titleText = stringResource(R.string.other__shop__discover__nav_title), + onBackClick = onBack, + actions = { CloseNavIcon(onClick = onClose) }, + ) + + PrimaryTabRow( + selectedTabIndex = selectedTabIndex, + containerColor = Color.Transparent, + indicator = { + TabRowDefaults.PrimaryIndicator( + modifier = Modifier.tabIndicatorOffset(selectedTabIndex), + color = Colors.Yellow, + width = Dp.Unspecified, + ) + }, + modifier = Modifier.padding(horizontal = 16.dp), + ) { + tabTitles.forEachIndexed { index, title -> + Tab( + selected = selectedTabIndex == index, + onClick = { selectedTabIndex = index }, + unselectedContentColor = Colors.White64, + text = { CaptionB(title) }, + ) + } + } + + when (selectedTabIndex) { + 0 -> ShopTabContent(navigateWebView = navigateWebView) + 1 -> MapTabContent() + } + } +} + +@Composable +private fun ShopTabContent( + navigateWebView: (String, String) -> Unit, +) { + LazyColumn( + modifier = Modifier.padding(horizontal = 16.dp), + ) { + item { + VerticalSpacer(16.dp) + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + val title = stringResource(R.string.other__shop__discover__gift_cards__title) + SuggestionCard( + modifier = Modifier.weight(1f), + gradientColor = Colors.Green, + title = title, + description = stringResource(R.string.other__shop__discover__gift_cards__description), + icon = R.drawable.gift, + captionColor = Colors.Gray1, + size = 164, + onClick = { + navigateWebView("gift-cards", title) + }, + ) + val title2 = stringResource(R.string.other__shop__discover__esims__title) + SuggestionCard( + modifier = Modifier.weight(1f), + gradientColor = Colors.Yellow, + title = title2, + description = stringResource(R.string.other__shop__discover__esims__description), + icon = R.drawable.globe, + captionColor = Colors.Gray1, + size = 164, + onClick = { + navigateWebView("esims", title2) + }, + ) + } + + VerticalSpacer(16.dp) + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + val title = stringResource(R.string.other__shop__discover__refill__title) + SuggestionCard( + modifier = Modifier.weight(1f), + gradientColor = Colors.Purple, + title = title, + description = stringResource(R.string.other__shop__discover__refill__description), + icon = R.drawable.phone, + captionColor = Colors.Gray1, + size = 164, + onClick = { + navigateWebView("refill", title) + }, + ) + val title2 = stringResource(R.string.other__shop__discover__travel__title) + SuggestionCard( + modifier = Modifier.weight(1f), + gradientColor = Colors.Red, + title = title2, + description = stringResource(R.string.other__shop__discover__travel__description), + icon = R.drawable.rocket_2, + size = 164, + captionColor = Colors.Gray1, + onClick = { + navigateWebView("buy/travel", title2) + }, + ) + } + + VerticalSpacer(32.dp) + + Text13Up(stringResource(R.string.other__shop__discover__label), color = Colors.White64) + + VerticalSpacer(16.dp) + } + + items(items = BitrefillCategory.entries.toList(), key = { it.name }) { item -> + Column { + Row( + modifier = Modifier + .clickableAlpha { + navigateWebView(item.route, item.title) + } + .padding(top = 8.5.dp, bottom = 10.5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .clip(CircleShape) + .size(32.dp) + .background(Colors.White10), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = item.icon, + contentDescription = null, + tint = Colors.White64, + modifier = Modifier.size(16.dp), + ) + } + BodyM( + text = item.title, + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp), + ) + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = Colors.White64, + modifier = Modifier.size(24.dp), + ) + } + HorizontalDivider() + } + } + } +} + +@SuppressLint("SetJavaScriptEnabled") +@Composable +private fun MapTabContent() { + var isLoading by remember { mutableStateOf(true) } + + val webViewClient = remember { + MapWebViewClient( + onLoadingStateChanged = { loading -> isLoading = loading } + ) + } + + Box( + modifier = Modifier + .padding(top = 16.dp, start = 16.dp, end = 16.dp) + .clip(Shapes.medium), + contentAlignment = Alignment.Center, + ) { + AndroidView( + modifier = Modifier.fillMaxWidth(), + factory = { context -> + WebView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + + this.webViewClient = webViewClient + configureForBasicWebContent() + loadUrl(Env.BTC_MAP_URL) + } + }, + ) + + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + } +} + +@Preview +@Composable +private fun Preview() { + AppThemeSurface { + ShopDiscoverScreen(onClose = {}, onBack = {}, navigateWebView = { _, _ -> }) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt new file mode 100644 index 000000000..bb652a856 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt @@ -0,0 +1,73 @@ +package to.bitkit.ui.screens.shop.shopWebView + +import android.graphics.Bitmap +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import to.bitkit.utils.Logger + +/** + * Custom WebViewClient for handling page loading and error states + */ +class ShopWebViewClient( + private val onLoadingStateChanged: (Boolean) -> Unit, + private val onError: () -> Unit +) : WebViewClient() { + + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + onLoadingStateChanged(true) + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + onLoadingStateChanged(false) + + // Inject JavaScript to bridge postMessage to Android + view?.evaluateJavascript( + """ + window.ReactNativeWebView = { + postMessage: function(data) { + Android.postMessage(data); + } + }; + + // 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 onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError?, + ) { + super.onReceivedError(view, request, error) + Logger.warn( + "Error: ${error?.description}, Code: ${error?.errorCode}, URL: ${request?.url}", + context = "ShopWebViewScreen" + ) + onLoadingStateChanged(false) + + error?.let { + if (it.errorCode == ERROR_HOST_LOOKUP || + it.errorCode == ERROR_CONNECT || + it.errorCode == ERROR_TIMEOUT || + it.errorCode == ERROR_FILE_NOT_FOUND + ) { + onError() + } + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt new file mode 100644 index 000000000..8617c58e5 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt @@ -0,0 +1,70 @@ +package to.bitkit.ui.screens.shop.shopWebView + +import android.util.Log +import android.webkit.JavascriptInterface +import kotlinx.serialization.json.Json +import to.bitkit.ui.screens.shop.shopWebView.WebViewMessage +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. + */ +class ShopWebViewInterface( + private val onPaymentIntent: (String) -> Unit, +) { + private val json = Json { ignoreUnknownKeys = true } + + /** + * Handles messages posted from JavaScript. + * This method is called on a background thread - ensure thread safety. + * + * @param message JSON string containing the message data + */ + @JavascriptInterface + fun postMessage(message: String) { + if (message.isBlank()) { + Logger.warn("Received empty message", context = "WebView") + return + } + + try { + val data = json.decodeFromString(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") + } + + else -> { + Logger.debug("Unknown event type: ${data.event}", context = "WebView") + } + } + } catch (e: Exception) { + Logger.error("Error parsing message: $message", e) + } + } + + /** + * Returns whether the interface is ready to receive messages. + * + * @return true if the interface is initialized and ready + */ + @JavascriptInterface + fun isReady(): Boolean { + return true + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewScreen.kt new file mode 100644 index 000000000..dd97fb94a --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewScreen.kt @@ -0,0 +1,106 @@ +package to.bitkit.ui.screens.shop.shopWebView + +import android.annotation.SuppressLint +import android.view.ViewGroup +import android.webkit.WebSettings +import android.webkit.WebView +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.viewinterop.AndroidView +import to.bitkit.R +import to.bitkit.env.Env +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.CloseNavIcon +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.utils.configureForBasicWebContent + + +@SuppressLint("SetJavaScriptEnabled", "JavascriptInterface") +@Composable +fun ShopWebViewScreen( + onClose: () -> Unit, + onBack: () -> Unit, + onPaymentIntent: (String) -> Unit, + page: String, + title: String, +) { + var isLoading by remember { mutableStateOf(true) } + var webView: WebView? by remember { mutableStateOf(null) } + + val webViewInterface = remember { ShopWebViewInterface(onPaymentIntent) } + val webViewClient = remember { + ShopWebViewClient( + onLoadingStateChanged = { loading -> isLoading = loading }, + onError = onClose + ) + } + + ScreenColumn { + AppTopBar( + titleText = "${stringResource(R.string.other__shop__discover__nav_title)} $title", + onBackClick = onBack, + actions = { CloseNavIcon(onClick = onClose) }, + ) + + 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, + ) + + this.webViewClient = webViewClient + configureForBasicWebContent() + addJavascriptInterface(webViewInterface, "Android") + loadUrl(Env.buildBitrefillUri(page = page)) + webView = this + } + }, + ) + + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + } + + BackHandler { + webView?.let { + if (it.canGoBack()) { + it.goBack() + } else { + onBack() + } + } ?: onBack() + } + } +} + +@Preview +@Composable +private fun Preview() { + AppThemeSurface { + ShopWebViewScreen( + onClose = {}, + onBack = {}, + onPaymentIntent = { uri -> + }, + page = "esims", + title = "Gift Cards" + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/WebViewMessage.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/WebViewMessage.kt new file mode 100644 index 000000000..cb776d495 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/WebViewMessage.kt @@ -0,0 +1,9 @@ +package to.bitkit.ui.screens.shop.shopWebView + +import kotlinx.serialization.Serializable + +@Serializable +data class WebViewMessage( + val event: String, + val paymentUri: String? = null +) diff --git a/app/src/main/java/to/bitkit/ui/utils/WeViewExtensions.kt b/app/src/main/java/to/bitkit/ui/utils/WeViewExtensions.kt new file mode 100644 index 000000000..6cf9731c9 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/utils/WeViewExtensions.kt @@ -0,0 +1,26 @@ +package to.bitkit.ui.utils + +import android.annotation.SuppressLint +import android.webkit.WebSettings +import android.webkit.WebView + +/** + * Configures WebView settings for basic web content display + */ +@SuppressLint("SetJavaScriptEnabled") +fun WebView.configureForBasicWebContent() { + settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + allowContentAccess = true + allowFileAccess = false + allowUniversalAccessFromFileURLs = false + allowFileAccessFromFileURLs = false + // Disable mixed content for security + mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW + // Enable zoom controls for map + setSupportZoom(true) + builtInZoomControls = true + displayZoomControls = false + } +}