diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2b6c953..901ae4e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -103,6 +103,19 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
+
+
+
+
+
+
+
-
-
-
-
diff --git a/app/src/main/java/ai/openclaw/app/WebShellActivity.kt b/app/src/main/java/ai/openclaw/app/WebShellActivity.kt
new file mode 100644
index 0000000..52ba2b8
--- /dev/null
+++ b/app/src/main/java/ai/openclaw/app/WebShellActivity.kt
@@ -0,0 +1,218 @@
+package ai.openclaw.app
+
+import android.annotation.SuppressLint
+import android.app.DownloadManager
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.os.Environment
+import android.webkit.CookieManager
+import android.webkit.DownloadListener
+import android.webkit.URLUtil
+import android.webkit.ValueCallback
+import android.webkit.WebChromeClient
+import android.webkit.WebResourceRequest
+import android.webkit.WebSettings
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import android.widget.Toast
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+
+/**
+ * v1 Web shell: a full-screen WebView pointed at the OpenClaw Gateway web chat.
+ *
+ * The official app's native UI (MainActivity) and WebSocket node connection
+ * infrastructure (NodeRuntime, NodeForegroundService, ...) remain intact in
+ * this fork — v2 will reuse them for the heartbeat watchdog.
+ */
+class WebShellActivity : AppCompatActivity() {
+
+ companion object {
+ /** Same Gateway the node WebSocket connects to; the web chat lives at its root. */
+ const val DEFAULT_GATEWAY_URL = "https://tech.greenlee.website/"
+
+ private const val EXTRA_GATEWAY_URL = "ai.openclaw.app.extra.GATEWAY_URL"
+
+ /** Launch the web shell, optionally overriding the default gateway URL. */
+ fun start(context: Context, gatewayUrl: String = DEFAULT_GATEWAY_URL) {
+ context.startActivity(
+ Intent(context, WebShellActivity::class.java)
+ .putExtra(EXTRA_GATEWAY_URL, gatewayUrl),
+ )
+ }
+ }
+
+ private var webView: WebView? = null
+ private var filePathCallback: ValueCallback>? = null
+
+ private val fileChooserLauncher =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ val results =
+ if (result.resultCode == RESULT_OK && result.data != null) {
+ val clipData = result.data!!.clipData
+ if (clipData != null) {
+ Array(clipData.itemCount) { index -> clipData.getItemAt(index).uri }
+ } else {
+ result.data!!.data?.let { arrayOf(it) }
+ }
+ } else {
+ null
+ }
+ filePathCallback?.onReceiveValue(results)
+ filePathCallback = null
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Full screen, edge-to-edge, no browser chrome.
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ WindowCompat.getInsetsController(window, window.decorView).apply {
+ systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ hide(WindowInsetsCompat.Type.systemBars())
+ }
+
+ val gatewayUrl = intent?.getStringExtra(EXTRA_GATEWAY_URL) ?: DEFAULT_GATEWAY_URL
+
+ webView =
+ WebView(this).apply {
+ configureWebView(this, gatewayUrl)
+ }
+ setContentView(webView)
+
+ onBackPressedDispatcher.addCallback(
+ this,
+ object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ if (webView?.canGoBack() == true) {
+ webView?.goBack()
+ } else {
+ finish()
+ }
+ }
+ },
+ )
+ }
+
+ @SuppressLint("SetJavaScriptEnabled")
+ private fun configureWebView(view: WebView, gatewayUrl: String) {
+ view.settings.apply {
+ javaScriptEnabled = true
+ domStorageEnabled = true
+ databaseEnabled = true
+ setSupportMultipleWindows(false)
+ mediaPlaybackRequiresUserGesture = false
+ // Proper mobile viewport: let the page's viewport meta drive layout.
+ useWideViewPort = true
+ loadWithOverviewMode = true
+ builtInZoomControls = false
+ displayZoomControls = false
+ cacheMode = WebSettings.LOAD_DEFAULT
+ }
+
+ // Persistent session: cookies + DOM storage survive app restarts.
+ CookieManager.getInstance().apply {
+ setAcceptCookie(true)
+ setAcceptThirdPartyCookies(view, true)
+ }
+
+ view.webViewClient =
+ object : WebViewClient() {
+ override fun shouldOverrideUrlLoading(
+ view: WebView?,
+ request: WebResourceRequest?,
+ ): Boolean {
+ val url = request?.url ?: return false
+ val gatewayHost = Uri.parse(DEFAULT_GATEWAY_URL).host
+ // Keep the gateway chat (and its assets) inside the WebView; open
+ // external links in the system browser.
+ if (url.host == gatewayHost) return false
+ return runCatching {
+ startActivity(Intent(Intent.ACTION_VIEW, url))
+ true
+ }.getOrDefault(false)
+ }
+ }
+
+ view.webChromeClient =
+ object : WebChromeClient() {
+ override fun onShowFileChooser(
+ webView: WebView?,
+ filePathCallback: ValueCallback>?,
+ fileChooserParams: FileChooserParams?,
+ ): Boolean {
+ this@WebShellActivity.filePathCallback?.onReceiveValue(null)
+ this@WebShellActivity.filePathCallback = filePathCallback
+ val intent =
+ fileChooserParams?.createIntent()
+ ?: Intent(Intent.ACTION_GET_CONTENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = "*/*"
+ }
+ return try {
+ fileChooserLauncher.launch(intent)
+ true
+ } catch (e: ActivityNotFoundException) {
+ this@WebShellActivity.filePathCallback = null
+ false
+ }
+ }
+ }
+
+ view.setDownloadListener(
+ DownloadListener { url, userAgent, contentDisposition, mimetype, _ ->
+ runCatching {
+ val request =
+ DownloadManager.Request(Uri.parse(url))
+ .setMimeType(mimetype)
+ .addRequestHeader("User-Agent", userAgent)
+ .setNotificationVisibility(
+ DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED,
+ )
+ .setDestinationInExternalPublicDir(
+ Environment.DIRECTORY_DOWNLOADS,
+ URLUtil.guessFileName(url, contentDisposition, mimetype),
+ )
+ (getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager).enqueue(request)
+ Toast.makeText(this@WebShellActivity, "Download started", Toast.LENGTH_SHORT).show()
+ }.onFailure {
+ Toast.makeText(this@WebShellActivity, "Download failed", Toast.LENGTH_SHORT).show()
+ }
+ },
+ )
+
+ view.loadUrl(gatewayUrl)
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ webView?.saveState(outState)
+ super.onSaveInstanceState(outState)
+ }
+
+ override fun onRestoreInstanceState(savedInstanceState: Bundle) {
+ super.onRestoreInstanceState(savedInstanceState)
+ webView?.restoreState(savedInstanceState)
+ }
+
+ override fun onPause() {
+ webView?.onPause()
+ super.onPause()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ webView?.onResume()
+ }
+
+ override fun onDestroy() {
+ webView?.destroy()
+ super.onDestroy()
+ }
+}