diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/browser/GuestBrowserScreen.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/browser/GuestBrowserScreen.kt new file mode 100644 index 00000000..91c91868 --- /dev/null +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/browser/GuestBrowserScreen.kt @@ -0,0 +1,213 @@ +package com.yugahashimoto.andcode.feature.browser + +import android.annotation.SuppressLint +import android.view.ViewGroup +import android.webkit.WebChromeClient +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.yugahashimoto.andcode.R + +/** In-app browser for pages served inside the guest runtime (e.g. http://127.0.0.1:PORT/). */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GuestBrowserScreen( + initialUrl: String, + onBack: () -> Unit, +) { + var urlInput by remember { mutableStateOf(initialUrl) } + var progress by remember { mutableIntStateOf(0) } + var canGoBack by remember { mutableStateOf(false) } + var canGoForward by remember { mutableStateOf(false) } + var webView by remember { mutableStateOf(null) } + + BackHandler { + val view = webView + if (view != null && view.canGoBack()) { + view.goBack() + } else { + onBack() + } + } + + Scaffold( + topBar = { + GuestBrowserTopBar( + canGoBack = canGoBack, + canGoForward = canGoForward, + onBack = onBack, + onHistoryBack = { webView?.goBack() }, + onForward = { webView?.goForward() }, + onReload = { webView?.reload() }, + ) + }, + ) { padding -> + Column(modifier = Modifier.padding(padding).fillMaxSize()) { + GuestBrowserUrlBar( + urlInput = urlInput, + onUrlChange = { urlInput = it }, + onGo = { webView?.loadUrl(normalizeUrl(urlInput)) }, + ) + if (progress in 1..99) { + LinearProgressIndicator(progress = { progress / 100f }) + } + AndroidView( + modifier = Modifier.weight(1f), + factory = { context -> + WebView(context).apply { + layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + configure( + onProgress = { progress = it }, + onNavigationStateChange = { url, back, forward -> + urlInput = url + canGoBack = back + canGoForward = forward + }, + ) + webView = this + if (initialUrl.isNotBlank()) { + loadUrl(normalizeUrl(initialUrl)) + } + } + }, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun GuestBrowserTopBar( + canGoBack: Boolean, + canGoForward: Boolean, + onBack: () -> Unit, + onHistoryBack: () -> Unit, + onForward: () -> Unit, + onReload: () -> Unit, +) { + TopAppBar( + title = { Text(stringResource(R.string.guest_browser_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.nav_back), + ) + } + }, + actions = { + IconButton(enabled = canGoBack, onClick = onHistoryBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.guest_browser_back), + ) + } + IconButton(enabled = canGoForward, onClick = onForward) { + Icon( + Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = stringResource(R.string.guest_browser_forward), + ) + } + IconButton(onClick = onReload) { + Icon( + Icons.Default.Refresh, + contentDescription = stringResource(R.string.guest_browser_reload), + ) + } + }, + ) +} + +@Composable +private fun GuestBrowserUrlBar( + urlInput: String, + onUrlChange: (String) -> Unit, + onGo: () -> Unit, +) { + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + OutlinedTextField( + value = urlInput, + onValueChange = onUrlChange, + modifier = Modifier.weight(1f), + singleLine = true, + placeholder = { Text(stringResource(R.string.guest_browser_url_hint)) }, + ) + Button(onClick = onGo, modifier = Modifier.padding(start = 8.dp)) { + Text(stringResource(R.string.guest_browser_go)) + } + } +} + +private fun normalizeUrl(raw: String): String { + val trimmed = raw.trim() + return if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + trimmed + } else { + "http://$trimmed" + } +} + +@SuppressLint("SetJavaScriptEnabled") +private fun WebView.configure( + onProgress: (Int) -> Unit, + onNavigationStateChange: (url: String, canGoBack: Boolean, canGoForward: Boolean) -> Unit, +) { + // Guest pages (dev servers, dashboards, tool UIs) are interactive web apps that need JS. + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + webChromeClient = + object : WebChromeClient() { + override fun onProgressChanged( + view: WebView?, + newProgress: Int, + ) { + onProgress(newProgress) + } + } + webViewClient = + object : WebViewClient() { + override fun onPageFinished( + view: WebView?, + url: String?, + ) { + onProgress(100) + onNavigationStateChange( + url.orEmpty(), + view?.canGoBack() == true, + view?.canGoForward() == true, + ) + } + } +} diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsScreenV2.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsScreenV2.kt index 7aeadb9a..42507e64 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsScreenV2.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsScreenV2.kt @@ -24,6 +24,7 @@ import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Policy import androidx.compose.material.icons.filled.Router @@ -74,6 +75,7 @@ fun SettingsScreenV2( onOpenAgentSettings: () -> Unit = {}, onOpenGitHubSettings: () -> Unit = {}, onOpenLocalRuntime: () -> Unit, + onOpenGuestBrowser: () -> Unit = {}, onOpenRemoteConnection: () -> Unit, onOpenWorkspaces: () -> Unit, onOpenDiagnostics: () -> Unit, @@ -267,6 +269,12 @@ fun SettingsScreenV2( onClick = onOpenLocalRuntime, ) SettingsDivider() + SettingsRow( + icon = Icons.Default.OpenInBrowser, + title = stringResource(R.string.guest_browser_row), + onClick = onOpenGuestBrowser, + ) + SettingsDivider() SettingsRow( icon = Icons.Default.Router, title = stringResource(R.string.remote_connection_row), diff --git a/app/src/main/java/com/yugahashimoto/andcode/ui/navigation/Routes.kt b/app/src/main/java/com/yugahashimoto/andcode/ui/navigation/Routes.kt index 984fdd06..3884faf3 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/ui/navigation/Routes.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/ui/navigation/Routes.kt @@ -43,6 +43,7 @@ const val SCHEDULE_EDIT_ARG_ID = "scheduleId" const val SCHEDULE_EDIT_ROUTE_PATTERN = "$ROUTE_SCHEDULE_EDIT?$SCHEDULE_EDIT_ARG_ID={$SCHEDULE_EDIT_ARG_ID}" const val ROUTE_CODE_VIEWER = "code-viewer" const val ROUTE_TERMINAL = "terminal" +const val ROUTE_GUEST_BROWSER = "guest-browser" const val CODE_VIEWER_ROUTE_PATTERN = "$ROUTE_CODE_VIEWER/{runtimeId}/{workspacePath}/{filePath}" diff --git a/app/src/main/java/com/yugahashimoto/andcode/ui/navigation/SettingsNavGraph.kt b/app/src/main/java/com/yugahashimoto/andcode/ui/navigation/SettingsNavGraph.kt index 2802e93e..863eb296 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/ui/navigation/SettingsNavGraph.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/ui/navigation/SettingsNavGraph.kt @@ -74,6 +74,7 @@ fun NavGraphBuilder.settingsNavGraph( onOpenAgentSettings = { navController.navigate(ROUTE_SETTINGS_AGENTS) }, onOpenGitHubSettings = { navController.navigate(ROUTE_SETTINGS_GITHUB) }, onOpenLocalRuntime = { navController.navigate(LOCAL_RUNTIME_MANAGEMENT_ROUTE) }, + onOpenGuestBrowser = { navController.navigate(ROUTE_GUEST_BROWSER) }, onOpenRemoteConnection = { navController.navigate(ROUTE_REMOTE_CONNECTION) }, onOpenWorkspaces = { navController.navigate(ROUTE_WORKSPACES) }, onOpenDiagnostics = onShowDiagnostics, diff --git a/app/src/main/java/com/yugahashimoto/andcode/ui/navigation/WorkspaceNavGraph.kt b/app/src/main/java/com/yugahashimoto/andcode/ui/navigation/WorkspaceNavGraph.kt index 19e53be3..c97dd42d 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/ui/navigation/WorkspaceNavGraph.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/ui/navigation/WorkspaceNavGraph.kt @@ -8,6 +8,7 @@ import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import com.yugahashimoto.andcode.AndCodeApplication +import com.yugahashimoto.andcode.feature.browser.GuestBrowserScreen import com.yugahashimoto.andcode.feature.workspace.CodeViewerScreen import com.yugahashimoto.andcode.feature.workspace.CodeViewerViewModel import com.yugahashimoto.andcode.feature.workspace.LocalRuntimeManagementScreen @@ -184,6 +185,13 @@ fun NavGraphBuilder.workspaceNavGraph( ) } + composable(ROUTE_GUEST_BROWSER) { + GuestBrowserScreen( + initialUrl = app.localRuntimeManager.installedPort()?.let { "http://127.0.0.1:$it/" }.orEmpty(), + onBack = { navController.popBackStack() }, + ) + } + composable(CODE_VIEWER_ROUTE_PATTERN) { backStack -> val arguments = runCatching { diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 748b5b79..929a08ed 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1032,4 +1032,11 @@ سؤال حدث غير مدعوم معلومات الاتصال غير مكتملة + المتصفح الضيف + المتصفح الضيف + http://127.0.0.1:port + فتح + رجوع + تقدم + إعادة تحميل diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 78f7bea7..6aa8d2f2 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1032,4 +1032,11 @@ Pregunta Evento no compatible La información de conexión está incompleta + Navegador invitado + Navegador invitado + http://127.0.0.1:puerto + Abrir + Atrás + Avanzar + Recargar diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index f833a3ef..e8c71fb4 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1032,4 +1032,11 @@ Question Événement non pris en charge Les informations de connexion sont incomplètes + Navigateur invité + Navigateur invité + http://127.0.0.1:port + Ouvrir + Retour + Avancer + Recharger diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index da796e84..fe8bd49d 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1052,4 +1052,11 @@ 閉じる 他 %1$d 件のプルリクエストを表示 プルリクエストの表示を減らす + ゲストブラウザ + ゲストブラウザ + http://127.0.0.1:ポート + 開く + 戻る + 進む + 再読み込み diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index a4f42743..f286f47a 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1032,4 +1032,11 @@ Pergunta Evento não compatível As informações de conexão estão incompletas + Navegador convidado + Navegador convidado + http://127.0.0.1:porta + Abrir + Voltar + Avançar + Recarregar diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 62788ca2..774ba22c 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1032,4 +1032,11 @@ Вопрос Неподдерживаемое событие Недостаточно данных подключения + Гостевой браузер + Гостевой браузер + http://127.0.0.1:порт + Открыть + Назад + Вперёд + Обновить diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index d2752aeb..844f5aba 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1032,4 +1032,11 @@ 问题 不支持的事件 连接信息不完整 + 访客浏览器 + 访客浏览器 + http://127.0.0.1:端口 + 打开 + 后退 + 前进 + 重新加载 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 77213fec..455cbbf4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1052,4 +1052,11 @@ Fewer Show %1$d more pull requests Show fewer pull requests + Guest Browser + Guest Browser + http://127.0.0.1:port + Open + Back + Forward + Reload