@@ -2,6 +2,7 @@ package org.turbowarp.android
22
33import android.annotation.SuppressLint
44import android.content.Context
5+ import android.content.Intent
56import android.graphics.Bitmap
67import android.net.Uri
78import android.view.ViewGroup
@@ -22,59 +23,147 @@ import java.net.URLConnection
2223import androidx.core.net.toUri
2324import org.json.JSONArray
2425import org.json.JSONObject
26+ import java.io.InputStream
27+
28+ private fun addIndexIfNeeded (path : String ): String {
29+ return if (path.endsWith(" /" )) {
30+ " $path /index.html"
31+ } else {
32+ path
33+ }
34+ }
35+
36+ private fun makeFetchableResponse (data : InputStream , path : String ): WebResourceResponse {
37+ // TODO: use our own mime types instead of the system's
38+ val mimeType = URLConnection .guessContentTypeFromName(path)
39+
40+ return WebResourceResponse (
41+ mimeType,
42+ null ,
43+ 200 ,
44+ " OK" ,
45+ mapOf<String , String >(
46+ " Access-Control-Allow-Origin" to " *"
47+ ),
48+ data,
49+ )
50+ }
51+
52+ private fun makeErrorResponse (): WebResourceResponse {
53+ // TODO
54+ return WebResourceResponse (null , null , null )
55+ }
2556
2657private class ServeAsset (
2758 private val context : Context ,
2859 private val subfolder : String
2960) : WebViewAssetLoader.PathHandler {
3061 override fun handle (path : String ): WebResourceResponse ? {
3162 return try {
32- val assetPath = " $subfolder /$path "
33- val inputStream = context.assets.open(assetPath)
34- val mimeType = URLConnection .guessContentTypeFromName(assetPath)
35- WebResourceResponse (mimeType, null , inputStream)
63+ // TODO: probably vulnerable to path traversal
64+ val pathWithIndex = addIndexIfNeeded(path)
65+ val assetPath = " $subfolder /$pathWithIndex "
66+
67+ val stream = context.assets.open(assetPath)
68+ makeFetchableResponse(stream, pathWithIndex)
69+ } catch (_: IOException ) {
70+ makeErrorResponse()
71+ }
72+ }
73+ }
74+
75+ private class ServeBrotliAsset (
76+ private val context : Context ,
77+ private val subfolder : String
78+ ) : WebViewAssetLoader.PathHandler {
79+ override fun handle (path : String ): WebResourceResponse ? {
80+ return try {
81+ // TODO: probably vulnerable to path traversal
82+ val pathWithIndex = addIndexIfNeeded(path)
83+ val compressedAssetPath = " $subfolder /$pathWithIndex .br"
84+
85+ val stream = readBrotliAssetAsStream(context, compressedAssetPath)
86+ makeFetchableResponse(stream, pathWithIndex)
3687 } catch (_: IOException ) {
37- // TODO: better error page?
38- WebResourceResponse (null , null , null )
88+ // TODO: does this fall-through to remote or fallthrough?
89+ null
90+ }
91+ }
92+ }
93+
94+ private class ServeLibraryAsset (
95+ private val context : Context ,
96+ private val subfolder : String
97+ ) : WebViewAssetLoader.PathHandler {
98+ private fun findMd5ext (path : String ): String? {
99+ val md5ext = Regex (" [0-9a-f]{32}\\ .\\ w{3}" , RegexOption .IGNORE_CASE ).find(path)
100+ return md5ext?.value
101+ }
102+
103+ override fun handle (path : String ): WebResourceResponse ? {
104+ return try {
105+ val md5ext = findMd5ext(path)
106+
107+ if (md5ext == null ) {
108+ makeErrorResponse()
109+ } else {
110+ val compressedAssetPath = " $subfolder /$md5ext .br"
111+ val stream = readBrotliAssetAsStream(context, compressedAssetPath)
112+ makeFetchableResponse(stream, md5ext)
113+ }
114+ } catch (_: IOException ) {
115+ makeErrorResponse()
39116 }
40117 }
41118}
42119
43120private class TurboWarpWebViewClient (
44121 private val context : Context ,
45122 private val preloads : List <String >,
123+ private val initialUrl : String
46124) : WebViewClient() {
47125 private val assetLoaders = mapOf (
48126 " editor.android-assets.turbowarp.org" to WebViewAssetLoader .Builder ()
49127 .setDomain(" editor.android-assets.turbowarp.org" )
50128 .addPathHandler(" /" , ServeAsset (context, " dist-renderer-webpack/editor" ))
51129 .build(),
52130
53- " packager.android-assets .turbowarp.org" to WebViewAssetLoader .Builder ()
54- .setDomain(" packager.android-assets .turbowarp.org" )
55- .addPathHandler(" /" , ServeAsset (context, " packager " ))
131+ " extensions .turbowarp.org" to WebViewAssetLoader .Builder ()
132+ .setDomain(" extensions .turbowarp.org" )
133+ .addPathHandler(" /" , ServeBrotliAsset (context, " dist-extensions " ))
56134 .build(),
57135
58- " about.android-assets.turbowarp.org" to WebViewAssetLoader .Builder ()
59- .setDomain(" about.android-assets.turbowarp.org" )
60- .addPathHandler(" /" , ServeAsset (context, " about" ))
61- .build()
136+ " assets.scratch.mit.edu" to WebViewAssetLoader .Builder ()
137+ .setDomain(" assets.scratch.mit.edu" )
138+ .addPathHandler(" /" , ServeLibraryAsset (context, " dist-library-files" ))
139+ .build(),
140+
141+ " cdn.assets.scratch.mit.edu" to WebViewAssetLoader .Builder ()
142+ .setDomain(" cdn.assets.scratch.mit.edu" )
143+ .addPathHandler(" /" , ServeLibraryAsset (context, " dist-library-files" ))
144+ .build(),
145+
146+ " packager.turbowarp.org" to WebViewAssetLoader .Builder ()
147+ .setDomain(" packager.turbowarp.org" )
148+ .addPathHandler(" /" , ServeAsset (context, " packager" ))
149+ .build(),
62150 )
63151
64152 override fun onPageStarted (view : WebView ? , url : String? , favicon : Bitmap ? ) {
65153 super .onPageStarted(view, url, favicon)
66154
155+ // Execute preloads in IIFE so that we only expose the variables we want to
67156 val sb = StringBuilder ()
68- sb.append(" (function() { 'use strict';\n " );
157+ sb.append(" (function() { 'use strict';\n " )
69158 for (preloadName in preloads) {
70159 // We assume that the preloads variable is trusted, don't need to worry about path
71160 // traversal or anything like that.
72161 val preloadScript = readAssetAsString(context, " preload/$preloadName " )
73162 sb.append(preloadScript)
74163 }
75- sb.append(" \n }());" );
164+ sb.append(" \n }());" )
76165
77- view?.evaluateJavascript(sb.toString(), null );
166+ view?.evaluateJavascript(sb.toString(), null )
78167 }
79168
80169 override fun shouldInterceptRequest (
@@ -84,6 +173,18 @@ private class TurboWarpWebViewClient(
84173 val loader = request.url.host?.let { assetLoaders[it] }
85174 return loader?.shouldInterceptRequest(request.url)
86175 }
176+
177+ override fun shouldOverrideUrlLoading (view : WebView ? , request : WebResourceRequest ? ): Boolean {
178+ // Open links in browser app
179+ // TODO: can we make this feel a bit less weird? like that custom tabs thing?
180+ if (request?.url.toString() != initialUrl) {
181+ val intent = Intent (Intent .ACTION_VIEW , request?.url)
182+ view?.context?.startActivity(intent)
183+ return true
184+ }
185+
186+ return super .shouldOverrideUrlLoading(view, request)
187+ }
87188}
88189
89190interface IpcHandler {
@@ -152,9 +253,10 @@ fun TurboWarpWebView(
152253 settings.allowFileAccess = false
153254 settings.allowContentAccess = false
154255
155- // Easier debugging
256+ // To help troubleshooting
156257 val version = BuildConfig .VERSION_NAME
157- settings.userAgentString + = " org.turbowarp.android/$version "
258+ val appId = BuildConfig .APPLICATION_ID
259+ settings.userAgentString + = " $appId /$version "
158260
159261 if (ipcHandler != null ) {
160262 val origin = getOrigin(url)
@@ -167,11 +269,15 @@ fun TurboWarpWebView(
167269 addJavascriptInterface(IpcSync (ipcHandler), " AndroidIpcSync" )
168270 }
169271
170- webViewClient = TurboWarpWebViewClient (context, if (ipcHandler == null ) {
171- preloads
172- } else {
173- listOf (" ipc-init.js" ).plus(preloads)
174- })
272+ webViewClient = TurboWarpWebViewClient (
273+ context = context,
274+ initialUrl = url,
275+ preloads = if (ipcHandler == null ) {
276+ preloads
277+ } else {
278+ listOf (" ipc-init.js" ).plus(preloads)
279+ }
280+ )
175281 }
176282 },
177283 update = { webView ->
0 commit comments