From b1849e5880be81634f27742b5ca6b31ea15f7b6b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 22:52:20 +0000 Subject: [PATCH] feat(browser): bring embedded nSites and nApplets to parity with full screen - An embedded nSite is shown as an nSite with the Tor row. Switching the route saves it and rebuilds the session, as the full-screen host relaunches. - "What it can access" is a new AccessInfoSheet card in both the embedded tab and the full-screen window, replacing the platform dialogs. - The embedded tab gets find in page, text size and the developer console through new NappletEmbedContract messages served by NappletHostService. Also removes nappletHost strings left unused by the Compose chrome. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TEkj7Eo2xbidVHAoF8GZKQ --- .../plans/2026-09-26-browser-ui-review.md | 15 +- .../browser/EmbeddedWebAppController.kt | 11 +- .../ui/screen/loggedIn/embed/ConsoleBridge.kt | 10 ++ .../favorites/EmbeddedNostrAppController.kt | 47 ++++- .../loggedIn/favorites/NostrAppScreen.kt | 89 +++++----- .../composeResources/values/strings.xml | 6 + .../browser/ui/pill/AccessInfoSheet.kt | 164 ++++++++++++++++++ .../browser/ui/pill/BrowserPillPreviews.kt | 17 ++ .../browser/ui/pill/BrowserPillRenderTest.kt | 2 + .../amethyst/napplethost/BrowserChromeHost.kt | 37 ++++ .../napplethost/NappletEmbedContract.kt | 28 +++ .../napplethost/NappletHostActivity.kt | 28 ++- .../napplethost/NappletHostService.kt | 68 ++++++++ nappletHost/src/main/res/values/strings.xml | 21 +-- 14 files changed, 447 insertions(+), 96 deletions(-) create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AccessInfoSheet.kt diff --git a/amethyst/plans/2026-09-26-browser-ui-review.md b/amethyst/plans/2026-09-26-browser-ui-review.md index f87c24a31b..2797131d3d 100644 --- a/amethyst/plans/2026-09-26-browser-ui-review.md +++ b/amethyst/plans/2026-09-26-browser-ui-review.md @@ -249,8 +249,13 @@ is gone. `EmbeddedPageDialogs`, `ConsoleLogEntry` and `BrowserWebTools.pageInfo`, plus the Android strings only they used. -Still open: -- The sandboxed apps' "What it can access" is still a platform `AlertDialog`. It lists launch - capabilities, not browser state. -- The embedded nsite/napplet tab has no find or text-size plumbing yet (`hasFind` / `hasTextSize` are - off there). +**Sandboxed apps, embedded and full screen alike:** +- An embedded nSite is labelled as an nSite, not "Sandboxed app", and gets the Tor row. Switching the + route saves it in `NappletNetworkRegistry` and rebuilds the session, as the full-screen host relaunches. +- "What it can access" is `AccessInfoSheet`: the launch capabilities, the keys-stay-in-Amethyst row, the + route (nSites), and Manage permissions. It replaces both the platform `AlertDialog` and the embedded + tab's `AccessDialog`. +- The embedded tab has find, text size and the console. `NappletEmbedContract` gained `MSG_FIND`, + `MSG_FIND_NEXT`, `MSG_FIND_RESULT`, `MSG_SET_TEXT_ZOOM` and `MSG_CONSOLE_LOG`, which + `NappletHostService` serves the same way `NappletBrowserService` does. Load and HTTP errors show up as + console errors, as they do full screen. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt index 4cd48b1181..8df5a2000f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt @@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FindBridge import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FindResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.MagnifierFrame +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.consoleLevelOf import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseImeEvent import java.util.concurrent.atomic.AtomicLong @@ -525,13 +526,3 @@ class EmbeddedWebAppController( private const val MAX_CONSOLE_LOGS = 200 } } - -/** Maps the provider's console level (WebView's `ConsoleMessage.MessageLevel` name) onto the chrome's. */ -private fun consoleLevelOf(level: String): ConsoleLine.Level = - when (level) { - "ERROR" -> ConsoleLine.Level.ERROR - "WARNING" -> ConsoleLine.Level.WARNING - "DEBUG" -> ConsoleLine.Level.DEBUG - "TIP" -> ConsoleLine.Level.INFO - else -> ConsoleLine.Level.LOG - } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/ConsoleBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/ConsoleBridge.kt index 84e3bb70be..faa8cf7204 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/ConsoleBridge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/ConsoleBridge.kt @@ -33,3 +33,13 @@ interface ConsoleBridge { fun clearConsoleLogs() } + +/** Maps a provider's console level (WebView's `ConsoleMessage.MessageLevel` name) onto the chrome's. */ +fun consoleLevelOf(level: String): ConsoleLine.Level = + when (level) { + "ERROR" -> ConsoleLine.Level.ERROR + "WARNING" -> ConsoleLine.Level.WARNING + "DEBUG" -> ConsoleLine.Level.DEBUG + "TIP" -> ConsoleLine.Level.INFO + else -> ConsoleLine.Level.LOG + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt index 39d3fb0f6b..79a7ed1dd5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt @@ -35,19 +35,27 @@ import android.os.Message import android.os.Messenger import android.os.SystemClock import androidx.annotation.RequiresApi +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory import androidx.privacysandbox.ui.client.view.SandboxedSdkView import androidx.privacysandbox.ui.core.SandboxedUiAdapter +import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles import com.vitorpamplona.amethyst.napplet.WebFileChooserCoordinator import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract import com.vitorpamplona.amethyst.napplethost.NappletHostContract +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBridge import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedLoadStatus import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedMagnifierProbe import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FindBridge +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FindResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.MagnifierFrame +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.consoleLevelOf import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseImeEvent import java.util.concurrent.atomic.AtomicLong @@ -67,7 +75,9 @@ class EmbeddedNostrAppController( private val params: Bundle, ) : EmbeddedSurfaceController, EmbeddedImeBridge, - EmbeddedMagnifierProbe { + EmbeddedMagnifierProbe, + ConsoleBridge, + FindBridge { private val incoming = Messenger(Handler(Looper.getMainLooper(), ::onServiceMessage)) private var serviceMessenger: Messenger? = null private var bound = false @@ -112,6 +122,14 @@ class EmbeddedNostrAppController( override var onMagnifierFrame: ((MagnifierFrame) -> Unit)? = null + /** The app's console output, capped at [MAX_CONSOLE_LOGS] entries. */ + override val consoleLogs = mutableStateListOf() + + override fun clearConsoleLogs() = consoleLogs.clear() + + private val _findResult = mutableStateOf(null) + override val findResult: State = _findResult + private val connection = object : ServiceConnection { override fun onServiceConnected( @@ -266,6 +284,22 @@ class EmbeddedNostrAppController( } } } + NappletEmbedContract.MSG_FIND_RESULT -> { + val data = msg.data ?: return true + _findResult.value = FindResult(data.getInt(NappletEmbedContract.KEY_FIND_ACTIVE), data.getInt(NappletEmbedContract.KEY_FIND_TOTAL)) + } + NappletEmbedContract.MSG_CONSOLE_LOG -> { + val data = msg.data ?: return true + if (consoleLogs.size >= MAX_CONSOLE_LOGS) consoleLogs.removeAt(0) + consoleLogs.add( + ConsoleLine( + consoleLevelOf(data.getString(NappletEmbedContract.KEY_CONSOLE_LEVEL).orEmpty()), + data.getString(NappletEmbedContract.KEY_CONSOLE_MESSAGE).orEmpty(), + data.getString(NappletEmbedContract.KEY_CONSOLE_SOURCE).orEmpty(), + data.getInt(NappletEmbedContract.KEY_CONSOLE_LINE, 0), + ), + ) + } NappletEmbedContract.MSG_MAGNIFIER_FRAME -> { val data = msg.data ?: return true val bytes = data.getByteArray(NappletEmbedContract.KEY_MAG_BYTES) ?: return true @@ -305,6 +339,15 @@ class EmbeddedNostrAppController( fun reload() = send(NappletEmbedContract.MSG_RELOAD) + override fun find(query: String) { + if (query.isEmpty()) _findResult.value = null + send(NappletEmbedContract.MSG_FIND) { putString(NappletEmbedContract.KEY_FIND_QUERY, query) } + } + + override fun findNext(forward: Boolean) = send(NappletEmbedContract.MSG_FIND_NEXT) { putBoolean(NappletEmbedContract.KEY_FIND_FORWARD, forward) } + + fun setTextZoom(percent: Int) = send(NappletEmbedContract.MSG_SET_TEXT_ZOOM) { putInt(NappletEmbedContract.KEY_TEXT_ZOOM, percent) } + /** User-triggered recovery for a stuck or failed session: reload the verified content from scratch. */ override fun retry() { hasLoadedReal = false @@ -353,5 +396,7 @@ class EmbeddedNostrAppController( private companion object { private val SESSION_SEQ = AtomicLong() + + private const val MAX_CONSOLE_LOGS = 200 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt index a3b12d3d61..3a7012cdcc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt @@ -26,18 +26,19 @@ import androidx.activity.compose.BackHandler import androidx.annotation.RequiresApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.AlertDialog +import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -50,11 +51,14 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.browser.BrowserChrome +import com.vitorpamplona.amethyst.commons.browser.ui.pill.AccessInfoSheet import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp @@ -62,10 +66,6 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.navigation.favoriteIds import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.browser_unsupported -import com.vitorpamplona.amethyst.commons.resources.favorite_app_access_static -import com.vitorpamplona.amethyst.commons.resources.favorite_app_access_title -import com.vitorpamplona.amethyst.commons.resources.favorite_app_network_open -import com.vitorpamplona.amethyst.commons.resources.favorite_app_network_tor import com.vitorpamplona.amethyst.commons.resources.favorite_app_still_loading import com.vitorpamplona.amethyst.commons.resources.favorite_app_unavailable import com.vitorpamplona.amethyst.commons.resources.favorite_apps @@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry +import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry import com.vitorpamplona.amethyst.napplethost.HostProfile import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract import com.vitorpamplona.amethyst.napplethost.NappletHostContract @@ -133,7 +134,10 @@ private fun EmbeddedNostrAppTab( // Mint the verified launch params (a fresh token per resolve); null until the event loads. Re-minted // on a theme flip (the params carry the resolved theme into the sandbox host's WebView). - val params = remember(coordinate, EmbeddedTabHost.rebuildEpoch) { FavoriteAppLauncher.embedParams(context, coordinate) } + // Bumped when the user re-routes an nSite (Tor ↔ open web): the session is rebuilt with the new route, + // as the full-screen host relaunches itself. + var networkEpoch by remember(coordinate) { mutableIntStateOf(0) } + val params = remember(coordinate, EmbeddedTabHost.rebuildEpoch, networkEpoch) { FavoriteAppLauncher.embedParams(context, coordinate) } if (params == null) { UnavailableTab(coordinate, accountViewModel, nav) return @@ -145,16 +149,19 @@ private fun EmbeddedNostrAppTab( val capLabels = params.getStringArrayList(NappletHostContract.EXTRA_CAP_LABELS).orEmpty() val profile = HostProfile.fromName(params.getString(NappletHostContract.EXTRA_HOST_PROFILE)) val useTor = params.getBoolean(NappletHostContract.EXTRA_USE_TOR, true) + // Only nSites have a route of their own to choose, and only when Tor is running. + val torOn = if (profile.exposesNetwork && params.getInt(NappletHostContract.EXTRA_PROXY_PORT, -1) > 0) useTor else null val scope = rememberCoroutineScope() var canGoBack by remember { mutableStateOf(false) } var showAccess by remember { mutableStateOf(false) } + var textZoom by remember(coordinate) { mutableIntStateOf(BrowserChrome.DEFAULT_TEXT_ZOOM) } val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() val isFavorite = remember(apps, coordinate) { apps.any { it.id == "nostr:$coordinate" } } val controller = - remember(id, EmbeddedTabHost.rebuildEpoch) { + remember(id, EmbeddedTabHost.rebuildEpoch, networkEpoch) { EmbeddedTabFactory.acquireNostrApp(context, coordinate, params, backgroundColor) } @@ -174,30 +181,39 @@ private fun EmbeddedNostrAppTab( // Stable per app (title/coordinate/isFavorite don't change often), so the tab layer isn't recomposed every frame. val chrome = - remember(title, coordinate, isFavorite, controller) { + remember(title, coordinate, isFavorite, torOn, textZoom, controller) { EmbeddedTabChrome( ui = BrowserPillUi( title = title.ifBlank { coordinate }, chrome = BrowserChrome.State( - surface = BrowserChrome.Surface.NAPPLET, + surface = if (profile == HostProfile.WEBSITE) BrowserChrome.Surface.NSITE else BrowserChrome.Surface.NAPPLET, presentation = BrowserChrome.Presentation.EMBEDDED, url = "", startUrl = "", + torOn = torOn, hasAccessInfo = true, - // The embedded nsite/napplet host has no find or text-size plumbing (yet). - hasFind = false, - hasTextSize = false, ), isFavorite = isFavorite, + textZoom = textZoom, ), onEvent = { event -> + if (event is BrowserPillEvent.TextZoom) { + textZoom = event.percent + controller.setTextZoom(event.percent) + } when ((event as? BrowserPillEvent.Action)?.action) { BrowserChrome.Action.RELOAD -> controller.reload() BrowserChrome.Action.OPEN_FULL_SCREEN -> FavoriteAppLauncher.launch(context, FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis()), appStillLoadingStr) BrowserChrome.Action.ACCESS_INFO -> showAccess = true + BrowserChrome.Action.TOR -> { + // Persist the new route, then rebuild the session so it loads that way. + NappletNetworkRegistry.set(permissionCoordinate, !useTor) + EmbeddedTabHost.evict(id) + networkEpoch++ + } BrowserChrome.Action.SITE_SETTINGS -> nav.nav(Route.ConnectedAppDetail(permissionCoordinate)) BrowserChrome.Action.FAVORITE -> { val favId = "nostr:$coordinate" @@ -247,7 +263,22 @@ private fun EmbeddedNostrAppTab( BackHandler(enabled = canGoBack) { controller.back() } if (showAccess) { - AccessDialog(title, capLabels, profile.exposesNetwork, useTor) { showAccess = false } + Dialog(onDismissRequest = { showAccess = false }, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + AccessInfoSheet( + title = title.ifBlank { coordinate }, + isWebsite = profile == HostProfile.WEBSITE, + capabilities = capLabels, + torOn = torOn, + onManagePermissions = { + showAccess = false + nav.nav(Route.ConnectedAppDetail(permissionCoordinate)) + }, + onDone = { showAccess = false }, + modifier = Modifier.widthIn(max = 560.dp), + ) + } + } } Scaffold( @@ -295,36 +326,6 @@ private fun UnavailableTab( } } -@Composable -private fun AccessDialog( - title: String, - capLabels: List, - showsNetwork: Boolean, - useTor: Boolean, - onDismiss: () -> Unit, -) { - val capsBody = - if (capLabels.isEmpty()) { - stringRes(Res.string.favorite_app_access_static) - } else { - capLabels.joinToString("\n") { "• $it" } - } - val networkBody = - if (showsNetwork) { - "\n\n" + stringRes(if (useTor) Res.string.favorite_app_network_tor else Res.string.favorite_app_network_open) - } else { - "" - } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(if (title.isBlank()) stringRes(Res.string.favorite_app_access_title) else title) }, - text = { Text(capsBody + networkBody) }, - confirmButton = { - TextButton(onClick = onDismiss) { Text(stringRes(android.R.string.ok)) } - }, - ) -} - private fun noticeResId(notice: String): StringResource? = when (notice) { NappletEmbedContract.NOTICE_PUBLISHED -> Res.string.favorite_notice_published diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index d1e8fac7c4..49b19ed171 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -5389,6 +5389,12 @@ Nothing allowed yet What it can access Your keys never leave Amethyst + Every sign, publish, upload or payment goes through your approval + No special access + It runs sandboxed and can't act on your account + Manage permissions + nSite · sandboxed + nApplet · sandboxed Console Secure connection Not secure diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AccessInfoSheet.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AccessInfoSheet.kt new file mode 100644 index 0000000000..e0bf7fdd64 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AccessInfoSheet.kt @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser.ui.pill + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_desc +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_keys_desc +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_manage +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_napplet +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_none +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_none_desc +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_nsite +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_connection +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_open +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_tor +import com.vitorpamplona.amethyst.commons.resources.browser_pill_ok +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_off +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_on +import com.vitorpamplona.amethyst.commons.ui.stringRes + +/** + * "What it can access" for a sandboxed nSite or nApplet: the capabilities it was launched with, how it + * reaches the network (nSites only), and the promise that the keys stay in Amethyst. The sandboxed + * counterpart of [PageInfoSheet], drawn with the same header and grouped cards. + * + * [capabilities] are already-localized labels; empty means a static site with no special access. + * [torOn] is null when the app has no network route of its own to show. + */ +@Composable +fun AccessInfoSheet( + title: String, + isWebsite: Boolean, + capabilities: List, + torOn: Boolean?, + onManagePermissions: (() -> Unit)?, + onDone: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surface, + shadowElevation = 6.dp, + border = PillDefaults.hairline(), + ) { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .padding(PillDefaults.SheetPadding), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + SiteMonogram(title, size = 48.dp) + Spacer(Modifier.width(14.dp)) + Column { + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Row(verticalAlignment = Alignment.CenterVertically) { + SecurityIcon(BrowserChrome.Security.SANDBOX, size = 14.dp) + Spacer(Modifier.width(4.dp)) + Text( + stringRes(if (isWebsite) Res.string.browser_pill_access_nsite else Res.string.browser_pill_access_napplet), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + GroupCard(heading = stringRes(Res.string.browser_pill_access)) { + if (capabilities.isEmpty()) { + GroupRow( + icon = { Icon(MaterialSymbols.Shield, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) }, + iconContainer = MaterialTheme.colorScheme.surfaceContainerHighest, + title = stringRes(Res.string.browser_pill_access_none), + supporting = stringRes(Res.string.browser_pill_access_none_desc), + onClick = null, + ) + } else { + capabilities.forEachIndexed { index, label -> + if (index > 0) GroupDivider() + GroupRow( + icon = { Icon(MaterialSymbols.CheckCircle, contentDescription = null, tint = MaterialTheme.colorScheme.onSecondaryContainer) }, + iconContainer = MaterialTheme.colorScheme.secondaryContainer, + title = label, + supporting = null, + onClick = null, + ) + } + } + GroupDivider() + GroupRow( + icon = { Icon(MaterialSymbols.Key, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) }, + iconContainer = MaterialTheme.colorScheme.surfaceContainerHighest, + title = stringRes(Res.string.browser_pill_access_desc), + supporting = stringRes(Res.string.browser_pill_access_keys_desc), + onClick = null, + ) + } + + torOn?.let { tor -> + GroupCard(heading = stringRes(Res.string.browser_pill_info_connection)) { + GroupRow( + icon = { PillActionIcon(BrowserChrome.Action.TOR, tint = if (tor) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, size = 22.dp) }, + iconContainer = if (tor) MaterialTheme.colorScheme.tertiaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest, + title = stringRes(if (tor) Res.string.browser_pill_info_tor else Res.string.browser_pill_info_open), + supporting = stringRes(if (tor) Res.string.browser_pill_tor_on else Res.string.browser_pill_tor_off), + onClick = null, + ) + } + } + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically) { + if (onManagePermissions != null) { + TextButton(onClick = onManagePermissions) { Text(stringRes(Res.string.browser_pill_access_manage)) } + Spacer(Modifier.width(8.dp)) + } + Button(onClick = onDone) { Text(stringRes(Res.string.browser_pill_ok)) } + } + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillPreviews.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillPreviews.kt index ace3a1cf43..1a2531d2c4 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillPreviews.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillPreviews.kt @@ -238,3 +238,20 @@ fun PageInfoPreview() { } } } + +@Preview +@Composable +fun AccessInfoPreview() { + PreviewFrame { + Box(Modifier.padding(16.dp).width(380.dp)) { + AccessInfoSheet( + title = "Habla", + isWebsite = true, + capabilities = listOf("Sign events as you", "Publish to your relays", "Upload files"), + torOn = true, + onManagePermissions = {}, + onDone = {}, + ) + } + } +} diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt index 3ac0d504e9..08711fed43 100644 --- a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt @@ -89,6 +89,8 @@ class BrowserPillRenderTest { @Test fun pageInfo() = render("11-page-info", 820, 1250) { PageInfoPreview() } + @Test fun accessInfo() = render("12-access-info", 820, 610) { AccessInfoPreview() } + private companion object { const val SETTLE_FRAMES = 12 const val FRAME_MILLIS = 60L diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt index 5041339b99..a781eb4722 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.commons.browser.BrowserChrome import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission +import com.vitorpamplona.amethyst.commons.browser.ui.pill.AccessInfoSheet import com.vitorpamplona.amethyst.commons.browser.ui.pill.AddressSuggestion import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserChromeTheme import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPill @@ -101,6 +102,15 @@ class BrowserChromeHost( fun onPanelsChanged() {} } + /** What a sandboxed app was launched with, for [showAccessInfo]. */ + class AccessInfo( + val title: String, + val isWebsite: Boolean, + val capabilities: List, + val torOn: Boolean?, + val onManagePermissions: (() -> Unit)?, + ) + /** A page's JS dialog waiting for an answer. */ class PendingDialog( val type: PageDialogType, @@ -137,6 +147,7 @@ class BrowserChromeHost( var dialog by mutableStateOf(null) var permissionPrompt by mutableStateOf(null) private var pageInfoOpen by mutableStateOf(false) + private var accessInfo by mutableStateOf(null) private var certificate by mutableStateOf(null) private var topView: ComposeView? = null @@ -203,6 +214,11 @@ class BrowserChromeHost( console.add(line) } + /** "What it can access" for a sandboxed nSite or nApplet. */ + fun showAccessInfo(info: AccessInfo) { + accessInfo = info + } + /** Page info for the page on screen, with its certificate when it has one. */ fun showPageInfo(certificate: CertificateInfo?) { this.certificate = certificate @@ -329,6 +345,27 @@ class BrowserChromeHost( ) } } + accessInfo?.let { info -> + Dialog(onDismissRequest = { accessInfo = null }, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + AccessInfoSheet( + title = info.title, + isWebsite = info.isWebsite, + capabilities = info.capabilities, + torOn = info.torOn, + onManagePermissions = + info.onManagePermissions?.let { manage -> + { + accessInfo = null + manage() + } + }, + onDone = { accessInfo = null }, + modifier = Modifier.widthIn(max = 560.dp), + ) + } + } + } if (pageInfoOpen) { Dialog(onDismissRequest = { pageInfoOpen = false }, properties = DialogProperties(usePlatformDefaultWidth = false)) { Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt index 1f977a02d3..38df1171f7 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt @@ -115,6 +115,34 @@ object NappletEmbedContract { */ const val MSG_FILE_CHOOSER_RESULT = 19 + /** Client → provider: find [KEY_FIND_QUERY] in the page; an empty query clears the highlights. */ + const val MSG_FIND = 20 + + /** Client → provider: move to the next ([KEY_FIND_FORWARD] true) or previous match. */ + const val MSG_FIND_NEXT = 21 + + /** Provider → client: [KEY_FIND_ACTIVE] (0-based) of [KEY_FIND_TOTAL] matches. */ + const val MSG_FIND_RESULT = 22 + + /** Client → provider: set the page's text size to [KEY_TEXT_ZOOM] percent. */ + const val MSG_SET_TEXT_ZOOM = 23 + + /** + * Provider → client: one line for the developer console — [KEY_CONSOLE_LEVEL] (WebView's + * `ConsoleMessage.MessageLevel` name), [KEY_CONSOLE_MESSAGE], [KEY_CONSOLE_SOURCE], [KEY_CONSOLE_LINE]. + */ + const val MSG_CONSOLE_LOG = 24 + + const val KEY_FIND_QUERY = "findQuery" + const val KEY_FIND_FORWARD = "findForward" + const val KEY_FIND_ACTIVE = "findActive" + const val KEY_FIND_TOTAL = "findTotal" + const val KEY_TEXT_ZOOM = "textZoom" + const val KEY_CONSOLE_LEVEL = "consoleLevel" + const val KEY_CONSOLE_MESSAGE = "consoleMessage" + const val KEY_CONSOLE_SOURCE = "consoleSource" + const val KEY_CONSOLE_LINE = "consoleLine" + const val KEY_FILE_CHOOSER_ID = "fileChooserId" const val KEY_FILE_CHOOSER_ACCEPT = "fileChooserAccept" const val KEY_FILE_CHOOSER_MULTIPLE = "fileChooserMultiple" diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt index b16ab84463..e14cc25611 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.napplethost import android.annotation.SuppressLint -import android.app.AlertDialog import android.content.ComponentName import android.content.Intent import android.content.ServiceConnection @@ -213,9 +212,9 @@ class NappletHostActivity : ComponentActivity() { private val backCallback = object : OnBackPressedCallback(false) { override fun handleOnBackPressed() { - if (chrome?.handleBack() == true) { - Unit - } else if (this@NappletHostActivity::webView.isInitialized && !webViewGone && webView.canGoBack()) { + // The chrome first (open pill, find), then the applet's own history. + if (chrome?.handleBack() == true) return + if (this@NappletHostActivity::webView.isInitialized && !webViewGone && webView.canGoBack()) { webView.goBack() } else { isEnabled = false @@ -1047,18 +1046,15 @@ class NappletHostActivity : ComponentActivity() { /** Lists, in plain language, exactly which capabilities this napplet was launched with. */ private fun showAccessDialog() { - val body = - if (capabilityLabels.isEmpty()) { - getString(R.string.napplet_chrome_static_site) - } else { - capabilityLabels.joinToString("\n") { "• $it" } + "\n\n" + getString(R.string.napplet_chrome_keys_safe) - } - AlertDialog - .Builder(this) - .setTitle(getString(R.string.napplet_chrome_access_title, barTitle())) - .setMessage(body) - .setPositiveButton(android.R.string.ok, null) - .show() + chrome?.showAccessInfo( + BrowserChromeHost.AccessInfo( + title = barTitle(), + isWebsite = profile == HostProfile.WEBSITE, + capabilities = capabilityLabels, + torOn = if (profile.exposesNetwork && proxyPort > 0) useTor else null, + onManagePermissions = if (brokerMessenger != null) ::openPermissions else null, + ), + ) } /** diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt index b3a69b8d13..998117aad8 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -38,6 +38,7 @@ import android.os.Messenger import android.os.SystemClock import android.view.View import android.view.ViewGroup +import android.webkit.ConsoleMessage import android.webkit.JsPromptResult import android.webkit.JsResult import android.webkit.RenderProcessGoneDetail @@ -60,6 +61,7 @@ import androidx.webkit.ProxyController import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract import com.vitorpamplona.amethyst.commons.util.booleanOrNull import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull @@ -129,6 +131,9 @@ class NappletHostService : Service() { // Last main-frame error state, pushed to the client so it can show an error/retry overlay over the // surface (the embedded surface has no error page of its own). var loadFailed = false + + // The user's text size, re-applied when a renderer crash forces a fresh WebView. + var textZoom = BrowserChrome.DEFAULT_TEXT_ZOOM val replyMessenger = Messenger(Handler(Looper.getMainLooper()) { onBrokerReply(this, it) }) } @@ -213,6 +218,17 @@ class NappletHostService : Service() { tab.bridgeReplyProxy?.postMessage(payload) } NappletEmbedContract.MSG_MAGNIFIER_REQUEST -> onMagnifierRequest(msg) + NappletEmbedContract.MSG_FIND -> { + val wv = tabFor(msg)?.webView ?: return true + val query = msg.data?.getString(NappletEmbedContract.KEY_FIND_QUERY).orEmpty() + if (query.isEmpty()) wv.clearMatches() else wv.findAllAsync(query) + } + NappletEmbedContract.MSG_FIND_NEXT -> tabFor(msg)?.webView?.findNext(msg.data?.getBoolean(NappletEmbedContract.KEY_FIND_FORWARD, true) ?: true) + NappletEmbedContract.MSG_SET_TEXT_ZOOM -> { + val tab = tabFor(msg) ?: return true + tab.textZoom = msg.data?.getInt(NappletEmbedContract.KEY_TEXT_ZOOM, tab.textZoom) ?: tab.textZoom + tab.webView?.let { BrowserWebTools.setTextZoom(it, tab.textZoom) } + } NappletEmbedContract.MSG_FILE_CHOOSER_RESULT -> { val tab = tabFor(msg) ?: return true val data = msg.data ?: return true @@ -362,6 +378,8 @@ class NappletHostService : Service() { wv.dropSystemBarInsets() if (tab.profile.exposesNetwork) applyWebViewProxy(effectiveProxy) WebViewCompat.addWebMessageListener(wv, NappletWebContract.BRIDGE_NAME, setOf(NappletWebContract.ORIGIN), ::onShellMessage) + wv.setFindListener { active, total, _ -> pushFindResult(tab, active, total) } + if (tab.textZoom != BrowserChrome.DEFAULT_TEXT_ZOOM) BrowserWebTools.setTextZoom(wv, tab.textZoom) tab.webView = wv wv.loadUrl(NappletWebContract.SHELL_URL) return wv @@ -426,6 +444,11 @@ class NappletHostService : Service() { fileChooserParams: FileChooserParams, ): Boolean = requestFileChooser(tab, filePathCallback, fileChooserParams) + override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { + pushConsoleLog(tab, consoleMessage.messageLevel().name, consoleMessage.message(), consoleMessage.sourceId(), consoleMessage.lineNumber()) + return true + } + // Setting a chrome client at all is what opts this WebView into the default JS-dialog handling, // and this one is built from a Service context — there is no window token to attach a dialog to, // and an applet's alert() must not be able to draw over the main app's trusted chrome anyway. @@ -547,6 +570,7 @@ class NappletHostService : Service() { request: WebResourceRequest, error: WebResourceError, ) { + pushConsoleLog(tab, ConsoleMessage.MessageLevel.ERROR.name, getString(R.string.napplet_console_load_error, error.errorCode, error.description?.toString().orEmpty()), request.url?.toString().orEmpty(), 0) // Only a main-frame failure blanks the applet; a missing sub-resource is irrelevant to whether // it opened. if (!request.isForMainFrame) return @@ -554,6 +578,14 @@ class NappletHostService : Service() { pushLoadState(tab, isLoading = false) } + override fun onReceivedHttpError( + view: WebView, + request: WebResourceRequest, + errorResponse: WebResourceResponse, + ) { + pushConsoleLog(tab, ConsoleMessage.MessageLevel.ERROR.name, getString(R.string.napplet_console_http_error, errorResponse.statusCode, errorResponse.reasonPhrase.orEmpty()), request.url?.toString().orEmpty(), 0) + } + /** * The renderer died. It is shared by every WebView in `:napplet`, and an unhandled crash kills the * whole process — every other tab included. Drop just this tab's WebView and report the load as @@ -599,6 +631,42 @@ class NappletHostService : Service() { runCatching { tab.clientMessenger?.send(message) } } + private fun pushFindResult( + tab: NappletTab, + active: Int, + total: Int, + ) { + val message = + Message.obtain(null, NappletEmbedContract.MSG_FIND_RESULT).apply { + data = + Bundle().apply { + putInt(NappletEmbedContract.KEY_FIND_ACTIVE, active) + putInt(NappletEmbedContract.KEY_FIND_TOTAL, total) + } + } + runCatching { tab.clientMessenger?.send(message) } + } + + private fun pushConsoleLog( + tab: NappletTab, + level: String, + text: String, + source: String, + line: Int, + ) { + val message = + Message.obtain(null, NappletEmbedContract.MSG_CONSOLE_LOG).apply { + data = + Bundle().apply { + putString(NappletEmbedContract.KEY_CONSOLE_LEVEL, level) + putString(NappletEmbedContract.KEY_CONSOLE_MESSAGE, text) + putString(NappletEmbedContract.KEY_CONSOLE_SOURCE, source) + putInt(NappletEmbedContract.KEY_CONSOLE_LINE, line) + } + } + runCatching { tab.clientMessenger?.send(message) } + } + /** Tells the client whether a main-frame load is in flight and whether it failed, so it can overlay a spinner/retry. */ private fun pushLoadState( tab: NappletTab, diff --git a/nappletHost/src/main/res/values/strings.xml b/nappletHost/src/main/res/values/strings.xml index 2ac71b58ff..d55e67ec81 100644 --- a/nappletHost/src/main/res/values/strings.xml +++ b/nappletHost/src/main/res/values/strings.xml @@ -4,30 +4,11 @@ Invalid nApplet. This device\'s WebView is too old to run nApplets safely. - - - What “%1$s” can access - It can never read your keys, and every sign, publish, upload, or payment was approved by you. Manage access in Settings ▸ nApplets. - Static site — it has no special access to your account. - What this app can access - Manage permissions - Reload - + “%1$s” published a note as you “%1$s” uploaded a file “%1$s” made a payment - - This site loads over Tor. Tap to change. - This site loads over the open web. Tap to change. - - Loads over Tor - Loads over the open web - - - Add to favorites - Remove from favorites - Couldn\'t load “%1$s” The publisher\'s servers may be offline, or you\'re not connected. You can try again.