1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
|
package org.meshbay.client
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets
import android.webkit.ConsoleMessage
import android.webkit.PermissionRequest
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.widget.FrameLayout
import androidx.webkit.ScriptHandler
import androidx.webkit.WebViewAssetLoader
import androidx.webkit.WebViewClientCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import org.json.JSONObject
import org.meshbay.client.bridge.Bridge
import org.meshbay.client.bridge.Channels
import org.meshbay.client.bridge.KeyChannels
import org.meshbay.client.cast.CastChannels
import org.meshbay.client.cast.CastService
import org.meshbay.client.hub.HubClient
import org.meshbay.client.keys.SecretStore
import org.meshbay.client.save.SaveSinks
import org.meshbay.client.shell.Pickers
import org.meshbay.client.shell.ShellWebView
import org.meshbay.client.shell.EngineCheck
import org.meshbay.client.shell.NativeText
import org.meshbay.client.shell.UiAssets
import java.util.concurrent.CountDownLatch
/**
* The shell: one WebView showing the packaged interface, and the bridge.
*
* What this file must never do: load anything into the WebView that is not the
* package (the hub never becomes the document origin — T3), or hand the page a
* way to the hub other than the bridge.
*/
class MainActivity : Activity() {
private lateinit var root: FrameLayout
private lateinit var web: ShellWebView
private lateinit var cast: CastChannels
private lateinit var hub: HubClient
private lateinit var channels: Channels
private val pickers = Pickers(this)
private val text = NativeText { code ->
try { assets.open("ui/locales/$code.js").bufferedReader().use { it.readText() } } catch (e: java.io.IOException) { null }
}
private var shim: ScriptHandler? = null
private var fullscreen: View? = null
private var fullscreenCallback: WebChromeClient.CustomViewCallback? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
root = FrameLayout(this)
setContentView(root)
applyInsets(root)
// A WebView too old for the page's crypto would fail at the first
// handshake; say so before loading anything.
EngineCheck.problem(this)?.let { setContentView(EngineCheck.screen(this, it)); return }
hub = HubClient(getSharedPreferences("shell", Context.MODE_PRIVATE))
web = ShellWebView(this)
root.addView(web, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
configure(web)
val keys = KeyChannels(SecretStore(this), confirm = ::confirmNatively,
declined = { text.get("native.declined", channels.locale) })
val saves = SaveSinks(this, getSharedPreferences("downloads", Context.MODE_PRIVATE), pickers,
startActivity = { intent -> runOnUiThread { startActivity(intent) } })
// A process killed mid-download left unfinished files; nothing else will.
Thread { saves.cleanUpAfterAKilledProcess() }.start()
cast = CastChannels(this, onCasting = { on -> runOnUiThread { casting(on) } },
tell = { m -> runOnUiThread { android.widget.Toast.makeText(this, m, android.widget.Toast.LENGTH_LONG).show() } })
channels = Channels(hub, onHubChanged = { runOnUiThread { reloadForHub() } },
hasCatalogue = { code -> hasAsset("ui/locales/$code.js") }, keys = keys, saves = saves,
cast = cast)
WebViewCompat.addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN), Bridge(channels))
cast.control.warmUp()
installShim()
web.loadUrl(UiAssets.START)
}
private fun configure(web: WebView) {
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
web.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true // IndexedDB and localStorage: session, resume positions
allowFileAccess = false
allowContentAccess = false
mediaPlaybackRequiresUserGesture = true
setSupportMultipleWindows(false)
mixedContentMode = android.webkit.WebSettings.MIXED_CONTENT_NEVER_ALLOW
}
android.webkit.CookieManager.getInstance().setAcceptThirdPartyCookies(web, false)
val loader = WebViewAssetLoader.Builder()
.setDomain(UiAssets.HOST)
.addPathHandler(UiAssets.PREFIX, UiAssets(this))
.build()
web.webViewClient = object : WebViewClientCompat() {
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
val url = request.url
if (url.host == UiAssets.HOST) return loader.shouldInterceptRequest(url) ?: refused()
// reCAPTCHA (sign-up) and nothing else goes to the network from
// the page; the policy says the same, this is the second wall.
if (UiAssets.isRecaptcha(url.host) && url.scheme == "https") return null
if (url.scheme == "blob" || url.scheme == "data") return null
return refused()
}
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val url = request.url
if (url.host == UiAssets.HOST) return false
// The hub must never become the document origin. A link out
// opens in the person's browser, not in a window holding keys.
if (request.isForMainFrame && (url.scheme == "https" || url.scheme == "http")) openExternally(url)
return !(UiAssets.isRecaptcha(url.host) && !request.isForMainFrame)
}
}
web.webChromeClient = object : WebChromeClient() {
// Grant by enumeration: nothing. Camera, microphone, MIDI and
// whatever Chromium adds next arrive refused.
override fun onPermissionRequest(request: PermissionRequest) = request.deny()
// Without this, a video's requestFullscreen() never settles — a
// refusal that never rejects (CLAUDE.md). Measured in the spike.
override fun onShowCustomView(view: View, callback: CustomViewCallback) {
fullscreen?.let { root.removeView(it) }
fullscreen = view
fullscreenCallback = callback
root.addView(view, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
web.visibility = View.INVISIBLE
setFullscreenBars(true)
}
override fun onHideCustomView() {
fullscreen?.let { root.removeView(it) }
fullscreen = null
fullscreenCallback = null
web.visibility = View.VISIBLE
setFullscreenBars(false)
}
// <input type=file>: the system picker; the page reads what it is
// given through the File objects the WebView makes of the URIs.
// The callback is always answered, or the next chooser never opens.
override fun onShowFileChooser(view: WebView, callback: android.webkit.ValueCallback<Array<Uri>>,
params: FileChooserParams): Boolean {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*")
.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, params.mode == FileChooserParams.MODE_OPEN_MULTIPLE)
Thread {
val result = pickers.run(intent)
// A single pick in multiple mode can come back with an
// empty clipData and the file in `data`: take whichever
// carries it, or the page receives a selection of nothing.
val uris = result?.let { r ->
val clip = r.clipData?.takeIf { it.itemCount > 0 }
clip?.let { c -> (0 until c.itemCount).map { c.getItemAt(it).uri } } ?: listOfNotNull(r.data)
}?.takeIf { it.isNotEmpty() }?.toTypedArray()
runOnUiThread { callback.onReceiveValue(uris) }
}.start()
return true
}
override fun onConsoleMessage(m: ConsoleMessage): Boolean {
if (BuildConfig.DEBUG) Log.i("MeshBayPage", "${m.messageLevel()} ${m.message()} @${m.sourceId()}:${m.lineNumber()}")
return true
}
}
}
/**
* The shim, with the hub address in it: the page reads that synchronously
* while its modules load. Changing the hub replaces the shim and reloads —
* a page left running would go on talking to the old hub with no sign of it.
*/
private fun installShim() {
shim?.remove()
val source = assets.open("bridge/meshbay-bridge.js").bufferedReader().use { it.readText() }
val binary = WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_ARRAY_BUFFER)
val prelude = "const HUB_BASE = ${JSONObject.quote(hub.base)};\nconst BINARY = $binary;\n" +
"const CAST = ${cast.control.available()};\n"
shim = WebViewCompat.addDocumentStartJavaScript(web, "(function(){$prelude$source\n})();", setOf(UiAssets.ORIGIN))
}
private fun reloadForHub() {
installShim()
web.loadUrl(UiAssets.START)
}
/**
* A confirmation this process draws (main.js confirmNatively). Called from
* a bridge worker, never the UI thread, which it waits on. The keyboard is
* handed back to the page afterwards: after a dialog the document can stay
* unfocused and every keystroke go nowhere (CLAUDE.md, ask.js).
*/
private fun confirmNatively(key: String): Boolean {
val done = CountDownLatch(1)
var accepted = false
runOnUiThread {
android.app.AlertDialog.Builder(this)
.setMessage(text.get(key, channels.locale))
.setPositiveButton(text.get("dialog.ok", channels.locale)) { _, _ -> accepted = true }
.setNegativeButton(text.get("dialog.cancel", channels.locale), null)
.setOnDismissListener { web.requestFocus(); done.countDown() }
.show()
}
done.await()
return accepted
}
@Deprecated("Activity results for the system pickers; the platform API, kept for minSdk 26.")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (!pickers.deliver(requestCode, resultCode, data)) super.onActivityResult(requestCode, resultCode, data)
}
/**
* A cast keeps the process, the Wi-Fi and the page alive with the screen
* off (spike S-2a, scenario F): the foreground service holds the first two,
* the WebView reported visible holds the third.
*/
private fun casting(on: Boolean) {
web.keepVisible = on
val service = Intent(this, CastService::class.java)
if (on) startForegroundService(service) else stopService(service)
}
private fun hasAsset(path: String) = try { assets.open(path).close(); true } catch (e: java.io.IOException) { false }
private fun refused() = WebResourceResponse("text/plain", "utf-8", 403, "Forbidden", emptyMap(), "".byteInputStream())
private fun openExternally(url: Uri) {
try { startActivity(Intent(Intent.ACTION_VIEW, url).addCategory(Intent.CATEGORY_BROWSABLE)) }
catch (e: android.content.ActivityNotFoundException) { Log.w(Bridge.TAG, "no browser for $url") }
}
/** Edge-to-edge is enforced from Android 15: keep the page clear of the bars and the keyboard. */
private fun applyInsets(view: View) {
view.setOnApplyWindowInsetsListener { v, insets ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val bars = if (fullscreen != null) android.graphics.Insets.NONE
else insets.getInsets(WindowInsets.Type.systemBars() or WindowInsets.Type.ime() or WindowInsets.Type.displayCutout())
v.setPadding(bars.left, bars.top, bars.right, bars.bottom)
} else {
@Suppress("DEPRECATION")
v.setPadding(insets.systemWindowInsetLeft, insets.systemWindowInsetTop,
insets.systemWindowInsetRight, insets.systemWindowInsetBottom)
}
insets
}
}
private fun setFullscreenBars(on: Boolean) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val c = window.insetsController ?: return
if (on) {
c.hide(WindowInsets.Type.systemBars())
c.systemBarsBehavior = android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
} else c.show(WindowInsets.Type.systemBars())
}
root.requestApplyInsets()
}
@Deprecated("Back is handed to the page in phase 4; until then it leaves fullscreen or backgrounds the app.")
override fun onBackPressed() {
if (fullscreen != null) { fullscreenCallback?.onCustomViewHidden(); return }
if (web.canGoBack()) { web.goBack(); return }
// Never finish(): that would tear down every connection and transfer.
moveTaskToBack(true)
}
override fun onDestroy() {
if (::cast.isInitialized && cast.relay.active) { cast.relay.stop(); casting(false) }
if (::web.isInitialized) { root.removeView(web); web.destroy() }
super.onDestroy()
}
}
|