diff --git a/android/app-newm/src/main/java/io/newm/screens/marketplace/MarketplacePresenter.kt b/android/app-newm/src/main/java/io/newm/screens/marketplace/MarketplacePresenter.kt index 5256a2f41..20eb51748 100644 --- a/android/app-newm/src/main/java/io/newm/screens/marketplace/MarketplacePresenter.kt +++ b/android/app-newm/src/main/java/io/newm/screens/marketplace/MarketplacePresenter.kt @@ -1,12 +1,7 @@ package io.newm.screens.marketplace -import android.content.Context -import android.net.ConnectivityManager import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext +import androidx.compose.runtime.LaunchedEffect import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter import io.newm.shared.commonPublic.analytics.NewmAppEventLogger @@ -18,23 +13,7 @@ class MarketplacePresenter( ) : Presenter { @Composable override fun present(): MarketplaceState { - val context = LocalContext.current - val connectivityManager = - remember { - context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - } - val isNetworkAvailable by remember { - mutableStateOf(connectivityManager.activeNetwork != null) - } - return when { - !isNetworkAvailable -> { - MarketplaceState.Error - } - - else -> { - eventLogger.logPageLoad(AppScreens.MarketplaceScreen.name) - MarketplaceState.Content(eventSink = {}) - } - } + LaunchedEffect(Unit) { eventLogger.logPageLoad(AppScreens.MarketplaceScreen.name) } + return MarketplaceState.Content(eventSink = {}) } } diff --git a/android/app-newm/src/main/java/io/newm/screens/marketplace/MarketplaceScreenUi.kt b/android/app-newm/src/main/java/io/newm/screens/marketplace/MarketplaceScreenUi.kt index 55ef7e975..409b8a255 100644 --- a/android/app-newm/src/main/java/io/newm/screens/marketplace/MarketplaceScreenUi.kt +++ b/android/app-newm/src/main/java/io/newm/screens/marketplace/MarketplaceScreenUi.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import io.newm.core.resources.R import io.newm.core.ui.LoadingScreen @@ -23,11 +22,10 @@ fun MarketplaceScreenUi( state: MarketplaceState, eventLogger: NewmAppEventLogger, ) { - val context = LocalContext.current Column(modifier = modifier.fillMaxSize().statusBarsPadding()) { when (state) { is MarketplaceState.Content -> { - FullScreenWebView(context, MARKETPLACE_URL) + FullScreenWebView(url = MARKETPLACE_URL) } MarketplaceState.Loading -> { diff --git a/android/app-newm/src/main/java/io/newm/screens/recordstore/RecordStorePresenter.kt b/android/app-newm/src/main/java/io/newm/screens/recordstore/RecordStorePresenter.kt index 837531898..09573e09c 100644 --- a/android/app-newm/src/main/java/io/newm/screens/recordstore/RecordStorePresenter.kt +++ b/android/app-newm/src/main/java/io/newm/screens/recordstore/RecordStorePresenter.kt @@ -1,12 +1,7 @@ package io.newm.screens.recordstore -import android.content.Context -import android.net.ConnectivityManager import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext +import androidx.compose.runtime.LaunchedEffect import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter import io.newm.shared.commonPublic.analytics.NewmAppEventLogger @@ -18,23 +13,7 @@ class RecordStorePresenter( ) : Presenter { @Composable override fun present(): RecordStoreState { - val context = LocalContext.current - val connectivityManager = - remember { - context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - } - val isNetworkAvailable by remember { - mutableStateOf(connectivityManager.activeNetwork != null) - } - return when { - !isNetworkAvailable -> { - RecordStoreState.Error - } - - else -> { - eventLogger.logPageLoad(AppScreens.RecordStoreScreen.name) - RecordStoreState.Content(eventSink = {}) - } - } + LaunchedEffect(Unit) { eventLogger.logPageLoad(AppScreens.RecordStoreScreen.name) } + return RecordStoreState.Content(eventSink = {}) } } diff --git a/android/app-newm/src/main/java/io/newm/screens/recordstore/RecordStoreScreenUi.kt b/android/app-newm/src/main/java/io/newm/screens/recordstore/RecordStoreScreenUi.kt index 31c63de70..c1d66394e 100644 --- a/android/app-newm/src/main/java/io/newm/screens/recordstore/RecordStoreScreenUi.kt +++ b/android/app-newm/src/main/java/io/newm/screens/recordstore/RecordStoreScreenUi.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import io.newm.core.resources.R @@ -25,11 +24,10 @@ fun RecordStoreScreenUi( state: RecordStoreState, eventLogger: NewmAppEventLogger, ) { - val context = LocalContext.current Column(modifier = modifier.fillMaxSize().statusBarsPadding().testTag(TAG_NFT_LIBRARY_SCREEN)) { when (state) { is RecordStoreState.Content -> { - FullScreenWebView(context, RECORD_STORE_URL) + FullScreenWebView(url = RECORD_STORE_URL) } RecordStoreState.Loading -> { diff --git a/android/app-newm/src/main/java/io/newm/screens/studio/StudioPresenter.kt b/android/app-newm/src/main/java/io/newm/screens/studio/StudioPresenter.kt index 73f016dc7..5b3403738 100644 --- a/android/app-newm/src/main/java/io/newm/screens/studio/StudioPresenter.kt +++ b/android/app-newm/src/main/java/io/newm/screens/studio/StudioPresenter.kt @@ -1,14 +1,11 @@ package io.newm.screens.studio -import android.content.Context -import android.net.ConnectivityManager import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter import io.newm.shared.commonInternal.TokenManager @@ -22,15 +19,6 @@ class StudioPresenter( ) : Presenter { @Composable override fun present(): StudioState { - val context = LocalContext.current - val connectivityManager = - remember { - context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - } - val isNetworkAvailable by remember { - mutableStateOf(connectivityManager.activeNetwork != null) - } - var accessToken by remember { mutableStateOf(null) } var refreshToken by remember { mutableStateOf(null) } var isLoading by remember { mutableStateOf(true) } @@ -47,12 +35,8 @@ class StudioPresenter( StudioState.Loading } - !isNetworkAvailable -> { - StudioState.Error - } - else -> { - eventLogger.logPageLoad(AppScreens.MarketplaceScreen.name) + LaunchedEffect(Unit) { eventLogger.logPageLoad(AppScreens.StudioScreen.name) } StudioState.Content( eventSink = {}, accessToken = accessToken, diff --git a/android/app-newm/src/main/java/io/newm/screens/studio/StudioScreenUi.kt b/android/app-newm/src/main/java/io/newm/screens/studio/StudioScreenUi.kt index 81283423f..d39d4dc28 100644 --- a/android/app-newm/src/main/java/io/newm/screens/studio/StudioScreenUi.kt +++ b/android/app-newm/src/main/java/io/newm/screens/studio/StudioScreenUi.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import io.newm.core.resources.R import io.newm.core.ui.LoadingScreen @@ -23,12 +22,10 @@ fun StudioScreenUi( state: StudioState, eventLogger: NewmAppEventLogger, ) { - val context = LocalContext.current Column(modifier = modifier.fillMaxSize().statusBarsPadding()) { when (state) { is StudioState.Content -> { FullScreenWebView( - context = context, url = STUDIO_URL, accessToken = state.accessToken, refreshToken = state.refreshToken, diff --git a/core-resources/src/androidMain/res/values/strings.xml b/core-resources/src/androidMain/res/values/strings.xml index 42d96b616..871d0a705 100644 --- a/core-resources/src/androidMain/res/values/strings.xml +++ b/core-resources/src/androidMain/res/values/strings.xml @@ -126,6 +126,9 @@ RecordStore It looks like you\'re not connected to the internet. Please check your connection and try again. Connection Lost + Unable to load page + Something went wrong while loading this page. Please try again. + Retry Confirm new password Enter your email to receive reset instructions. Continue diff --git a/core-resources/src/commonMain/composeResources/values/strings.xml b/core-resources/src/commonMain/composeResources/values/strings.xml index b0c3b6eaa..c808ffb40 100644 --- a/core-resources/src/commonMain/composeResources/values/strings.xml +++ b/core-resources/src/commonMain/composeResources/values/strings.xml @@ -126,6 +126,9 @@ RecordStore It looks like you\'re not connected to the internet. Please check your connection and try again. Connection Lost + Unable to load page + Something went wrong while loading this page. Please try again. + Retry Confirm new password Enter your email to receive reset instructions. Continue diff --git a/core-ui/src/androidMain/kotlin/io/newm/core/ui/utils/ErrorScreen.kt b/core-ui/src/androidMain/kotlin/io/newm/core/ui/utils/ErrorScreen.kt index f0db86f27..f4fa19583 100644 --- a/core-ui/src/androidMain/kotlin/io/newm/core/ui/utils/ErrorScreen.kt +++ b/core-ui/src/androidMain/kotlin/io/newm/core/ui/utils/ErrorScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -13,11 +14,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import io.newm.core.ui.buttons.PrimaryButton @Composable fun ErrorScreen( title: String, message: String, + actionLabel: String? = null, + onAction: (() -> Unit)? = null, ) { Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize().padding(16.dp)) { Column(horizontalAlignment = Alignment.CenterHorizontally) { @@ -34,6 +38,14 @@ fun ErrorScreen( textAlign = TextAlign.Center, modifier = Modifier.padding(horizontal = 16.dp), ) + if (actionLabel != null && onAction != null) { + Spacer(modifier = Modifier.height(24.dp)) + PrimaryButton( + text = actionLabel, + onClick = onAction, + modifier = Modifier.widthIn(max = 280.dp), + ) + } } } } diff --git a/core-ui/src/androidMain/kotlin/io/newm/core/ui/webview/FullScreenWebView.kt b/core-ui/src/androidMain/kotlin/io/newm/core/ui/webview/FullScreenWebView.kt index d58609ccd..6b122b55f 100644 --- a/core-ui/src/androidMain/kotlin/io/newm/core/ui/webview/FullScreenWebView.kt +++ b/core-ui/src/androidMain/kotlin/io/newm/core/ui/webview/FullScreenWebView.kt @@ -1,87 +1,236 @@ package io.newm.core.ui.webview import android.annotation.SuppressLint -import android.content.Context import android.content.Intent +import android.net.http.SslError import android.webkit.CookieManager +import android.webkit.SslErrorHandler +import android.webkit.WebResourceError import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.viewinterop.AndroidView import androidx.core.net.toUri +import io.newm.core.resources.R +import io.newm.core.ui.LoadingScreen @SuppressLint("SetJavaScriptEnabled") @Composable fun FullScreenWebView( - context: Context, url: String, + modifier: Modifier = Modifier, accessToken: String? = null, refreshToken: String? = null, ) { - AndroidView( - factory = { ctx -> - WebView(ctx).apply { - val cookieManager = CookieManager.getInstance() - cookieManager.setAcceptCookie(true) - cookieManager.setAcceptThirdPartyCookies(this, true) - - val baseUrl = url.toUri() // Prefer Uri.parse() for full URLs - val domain = baseUrl.host ?: "newm.studio" // fallback if parsing fails - - // Set cookies only if they exist - accessToken?.let { - val accessCookie = "accessToken=$it; path=/; domain=$domain" - cookieManager.setCookie("https://$domain", accessCookie) - } - refreshToken?.let { - val refreshCookie = "refreshToken=$it; path=/; domain=$domain" - cookieManager.setCookie("https://$domain", refreshCookie) - } + val context = LocalContext.current + val cookieManager = remember { CookieManager.getInstance() } + var webView by remember { mutableStateOf(null) } + var loadRequestId by remember(url) { mutableIntStateOf(0) } + var lastAppliedLoadRequestId by remember(url) { mutableIntStateOf(-1) } + var hasLoadedSuccessfully by remember(url) { mutableStateOf(false) } + var isPageLoading by remember(url) { mutableStateOf(true) } + var errorType by remember(url) { mutableStateOf(null) } + + val retryLabel = stringResource(R.string.web_view_retry) + val offlineTitle = stringResource(R.string.record_store_error_title) + val offlineMessage = stringResource(R.string.record_store_error_message) + val loadErrorTitle = stringResource(R.string.web_view_load_error_title) + val loadErrorMessage = stringResource(R.string.web_view_load_error_message) - cookieManager.flush() // Ensure cookies are written immediately - - webViewClient = - object : WebViewClient() { - override fun shouldOverrideUrlLoading( - view: WebView?, - request: WebResourceRequest?, - ): Boolean { - val currentUrl = request?.url.toString() - return if (isInternalUrl(currentUrl)) { - // Load the URL in the current WebView - false - } else { - // Open external links - launchExternalUrl(context, currentUrl) - true + DisposableEffect(Unit) { + onDispose { + webView?.apply { + stopLoading() + removeAllViews() + destroy() + } + webView = null + } + } + + Box(modifier = modifier.fillMaxSize()) { + AndroidView( + factory = { ctx -> + WebView(ctx).apply { + webView = this + cookieManager.setAcceptCookie(true) + cookieManager.setAcceptThirdPartyCookies(this, true) + + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + + webViewClient = + object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest?, + ): Boolean { + if (request?.isForMainFrame != true) return false + val currentUrl = request.url.toString() + return if (isInternalUrl(currentUrl)) { + false + } else { + launchExternalUrl(context, currentUrl) + true + } + } + + override fun onPageStarted( + view: WebView?, + url: String?, + favicon: android.graphics.Bitmap?, + ) { + errorType = null + isPageLoading = true + } + + override fun onPageFinished( + view: WebView?, + url: String?, + ) { + if (errorType == null) { + hasLoadedSuccessfully = true + isPageLoading = false + } + } + + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError?, + ) { + if (request?.isForMainFrame != true) return + isPageLoading = false + errorType = error?.toWebViewErrorType() ?: WebViewErrorType.PageLoad + } + + override fun onReceivedHttpError( + view: WebView?, + request: WebResourceRequest?, + errorResponse: WebResourceResponse?, + ) { + if (request?.isForMainFrame != true) return + if ((errorResponse?.statusCode ?: 0) >= 400) { + isPageLoading = false + errorType = WebViewErrorType.PageLoad + } + } + + override fun onReceivedSslError( + view: WebView?, + handler: SslErrorHandler?, + error: SslError?, + ) { + handler?.cancel() + isPageLoading = false + errorType = WebViewErrorType.PageLoad } } - } + } + }, + update = { view -> + syncAuthCookies( + cookieManager = cookieManager, + url = url, + accessToken = accessToken, + refreshToken = refreshToken, + ) + if (lastAppliedLoadRequestId != loadRequestId) { + lastAppliedLoadRequestId = loadRequestId + errorType = null + isPageLoading = true + view.stopLoading() + view.loadUrl(url) + } + }, + modifier = Modifier.fillMaxSize(), + ) - settings.javaScriptEnabled = true - settings.domStorageEnabled = true - settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + if (isPageLoading && !hasLoadedSuccessfully) { + LoadingScreen() + } - loadUrl(url) - } - }, - modifier = Modifier.fillMaxSize(), - ) + errorType?.let { webViewErrorType -> + WebViewErrorScreen( + title = + when (webViewErrorType) { + WebViewErrorType.Offline -> offlineTitle + WebViewErrorType.PageLoad -> loadErrorTitle + }, + message = + when (webViewErrorType) { + WebViewErrorType.Offline -> offlineMessage + WebViewErrorType.PageLoad -> loadErrorMessage + }, + actionLabel = retryLabel, + onAction = { loadRequestId += 1 }, + ) + } + } +} + +private fun syncAuthCookies( + cookieManager: CookieManager, + url: String, + accessToken: String?, + refreshToken: String?, +) { + val domain = url.toUri().host ?: return + + accessToken?.let { + cookieManager.setCookie( + "https://$domain", + "accessToken=$it; path=/; domain=$domain; Secure; SameSite=Lax", + ) + } + refreshToken?.let { + cookieManager.setCookie( + "https://$domain", + "refreshToken=$it; path=/; domain=$domain; Secure; SameSite=Lax", + ) + } + cookieManager.flush() } -private fun isInternalUrl(url: String): Boolean = - listOf("newm.studio", "newm.io", "recordstore.newm.io").any { domain -> - url.contains(domain, ignoreCase = true) +private fun WebResourceError.toWebViewErrorType(): WebViewErrorType = + when (errorCode) { + WebViewClient.ERROR_HOST_LOOKUP, + WebViewClient.ERROR_CONNECT, + WebViewClient.ERROR_TIMEOUT, + WebViewClient.ERROR_IO, + WebViewClient.ERROR_PROXY_AUTHENTICATION, + WebViewClient.ERROR_UNKNOWN, + -> WebViewErrorType.Offline + + else -> WebViewErrorType.PageLoad } +private enum class WebViewErrorType { + Offline, + PageLoad, +} + fun launchExternalUrl( - context: Context, + context: android.content.Context, url: String, ) { - val intent = Intent(Intent.ACTION_VIEW, url.toUri()) - context.startActivity(intent) + runCatching { + val intent = Intent(Intent.ACTION_VIEW, url.toUri()) + context.startActivity(intent) + } } diff --git a/core-ui/src/androidMain/kotlin/io/newm/core/ui/webview/WebViewErrorScreen.kt b/core-ui/src/androidMain/kotlin/io/newm/core/ui/webview/WebViewErrorScreen.kt new file mode 100644 index 000000000..f4e2f4704 --- /dev/null +++ b/core-ui/src/androidMain/kotlin/io/newm/core/ui/webview/WebViewErrorScreen.kt @@ -0,0 +1,125 @@ +package io.newm.core.ui.webview + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +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.graphics.Brush +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.newm.core.ui.buttons.PrimaryButton +import io.newm.core.ui.theme.DarkViolet +import io.newm.core.ui.theme.Gray16 +import io.newm.core.ui.theme.Gray600 +import io.newm.core.ui.theme.Pinkish +import io.newm.core.ui.theme.White50 + +@Composable +internal fun WebViewErrorScreen( + title: String, + message: String, + actionLabel: String, + onAction: () -> Unit, +) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .fillMaxSize() + .background( + brush = + Brush.verticalGradient( + colors = listOf(Gray600, Gray16, MaterialTheme.colors.background), + ), + ).padding(24.dp), + ) { + Column( + modifier = Modifier.widthIn(max = 420.dp).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Box( + modifier = + Modifier + .clip(CircleShape) + .background( + brush = + Brush.linearGradient( + colors = + listOf( + DarkViolet.copy(alpha = 0.28f), + Pinkish.copy(alpha = 0.28f), + ), + ), + ).border(1.dp, White50, CircleShape) + .padding(20.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colors.primary, + modifier = Modifier.size(32.dp), + ) + } + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(28.dp), + backgroundColor = MaterialTheme.colors.surface, + elevation = 10.dp, + border = + androidx.compose.foundation.BorderStroke( + width = 1.dp, + color = MaterialTheme.colors.primary.copy(alpha = 0.12f), + ), + ) { + Column( + modifier = + Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = title, + style = MaterialTheme.typography.h4, + color = MaterialTheme.colors.onSurface, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = message, + style = MaterialTheme.typography.body1, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.76f), + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(28.dp)) + PrimaryButton( + text = actionLabel, + onClick = onAction, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } +} diff --git a/core-ui/src/commonMain/kotlin/io/newm/core/ui/webview/WebViewUrlPolicy.kt b/core-ui/src/commonMain/kotlin/io/newm/core/ui/webview/WebViewUrlPolicy.kt new file mode 100644 index 000000000..ca5e19147 --- /dev/null +++ b/core-ui/src/commonMain/kotlin/io/newm/core/ui/webview/WebViewUrlPolicy.kt @@ -0,0 +1,37 @@ +package io.newm.core.ui.webview + +private val internalHosts = setOf("newm.io", "newm.studio") +private val internalSchemes = setOf("http", "https") + +internal fun isInternalUrl(url: String): Boolean { + val parsedUrl = parseUrl(url) ?: return false + if (parsedUrl.scheme !in internalSchemes) return false + return internalHosts.any { allowedHost -> + parsedUrl.host == allowedHost || parsedUrl.host.endsWith(".$allowedHost") + } +} + +private fun parseUrl(url: String): ParsedUrl? { + val scheme = + url + .substringBefore("://", missingDelimiterValue = "") + .takeIf { it.isNotBlank() } + ?.lowercase() ?: return null + + val host = + url + .substringAfter("://", missingDelimiterValue = "") + .substringBefore('/') + .substringBefore('?') + .substringBefore('#') + .substringBefore(':') + .takeIf { it.isNotBlank() } + ?.lowercase() ?: return null + + return ParsedUrl(scheme = scheme, host = host) +} + +private data class ParsedUrl( + val scheme: String, + val host: String, +) diff --git a/core-ui/src/commonTest/kotlin/io/newm/core/ui/webview/WebViewUrlPolicyTest.kt b/core-ui/src/commonTest/kotlin/io/newm/core/ui/webview/WebViewUrlPolicyTest.kt new file mode 100644 index 000000000..374887adb --- /dev/null +++ b/core-ui/src/commonTest/kotlin/io/newm/core/ui/webview/WebViewUrlPolicyTest.kt @@ -0,0 +1,33 @@ +package io.newm.core.ui.webview + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WebViewUrlPolicyTest { + @Test + fun `allows exact internal hosts`() { + assertTrue(isInternalUrl("https://newm.io")) + assertTrue(isInternalUrl("https://newm.studio/home/library")) + } + + @Test + fun `allows internal subdomains`() { + assertTrue(isInternalUrl("https://recordstore.newm.io")) + assertTrue(isInternalUrl("https://marketplace.newm.io")) + } + + @Test + fun `rejects lookalike external domains`() { + assertFalse(isInternalUrl("https://evilnewm.io")) + assertFalse(isInternalUrl("https://newm.io.evil.com")) + } + + @Test + fun `rejects malformed or non http urls`() { + assertFalse(isInternalUrl("mailto:support@newm.io")) + assertFalse(isInternalUrl("intent://marketplace.newm.io")) + assertFalse(isInternalUrl("ftp://newm.io")) + assertFalse(isInternalUrl("not-a-url")) + } +}