v1: replace native UI with full-screen WebView web shell

WebShellActivity is now the launcher: full-screen WebView pointed at the
Gateway web chat (https://tech.greenlee.website/), no browser chrome,
mobile viewport, persistent session (cookies + DOM storage), file uploads
and downloads wired through. The official app's MainActivity and WebSocket
node infrastructure stay intact for the v2 heartbeat watchdog.
This commit is contained in:
Brendan Greenlee 2026-08-01 15:24:31 +00:00
parent 95069ff263
commit b9e4ab1cba
2 changed files with 231 additions and 4 deletions

View file

@ -103,6 +103,19 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- v1 launcher: full-screen WebView web shell pointed at the Gateway web chat.
MainActivity + WebSocket node infrastructure stay intact for v2 heartbeat. -->
<activity
android:name=".WebShellActivity"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|density|keyboard|keyboardHidden|navigation">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- External sender tasks must converge on one visible composer and share queue. -->
<activity
android:name=".MainActivity"
@ -113,10 +126,6 @@
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.ASSIST" />
<category android:name="android.intent.category.DEFAULT" />

View file

@ -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<Array<Uri>>? = 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<Array<Uri>>?,
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()
}
}