Skip to content

Commit ff7d11f

Browse files
authored
fix: open a device folder's workspace, and keep it (#383)
The Android picker is ACTION_OPEN_DOCUMENT_TREE and returns a directory, never a file, so a workspace on device storage can only be reached through the folder holding it. Nothing joined the two. The folder opened as a folder, and the `.code-workspace` in it was reachable only by knowing to find the file in the explorer and press the button on it; desktop VS Code offers that step itself, from code the browser workbench does not carry. A granted folder holding exactly one workspace file now opens as that workspace. Two is left as a folder rather than guessed at, and a directory merely named like one is excluded, because the workbench answers a workspace it cannot read with an empty window and no message. Pressing that button did not work either. It navigates the workbench to `?workspace=<file>`, which the shell could not read: folderFromUrl parsed only `folder` and required a directory, so both consumers of the page-load callback were skipped. The workspace was not remembered and the next launch fell through to the default projects directory, and adoptWorkbenchFolder never ran, so a workspace opened out of a device-folder mirror got no write-back watcher and every edit stayed in the mirror instead of reaching the user's own files, with nothing on screen to say so. workbenchUrl now names a workspace file with `workspace=` and everything else with `folder=`, so a remembered workspace round-trips. folderFromUrl reads both parameters through workbenchTarget, which stats each for the kind of thing it is. The remembered path may now be a file, so its existence test is exists() rather than isDirectory. resourceRootsInForce publishes the directory holding a workspace rather than the file, which as a path prefix would match only itself and refuse every resource beside it. A closed folder was the same defect one state over. `?ew=true` is the third thing the workbench can be showing and the folder chain cannot name it, so a renderer crash over a closed folder reopened the workspace the user had just closed. Those paths now restore the URL rather than rebuild one, which is what handleResumeFromBackground already did for the same reason. The saf-bridge extension also contributes Open Recent Folder to the status bar remote indicator, the device picker having been reachable only by typing its name into the command palette. Its directory moves to 1.5.0 because the server caches the extension scan against the extensions root mtime, which editing a manifest in place does not change. The user guide said to open folders with File > Open Folder, which browses the app's own storage and cannot reach Documents, Downloads or an SD card, and FR-FS-05 claimed multi-root workspaces without qualification. Both now say what the code does. Verified on emulators at API 33 and API 37: granting Documents/wsdemo opens it as `proj (Workspace)` with both roots, the edit that stayed in the mirror now reaches the device folder, the workspace survives a cold start and a renderer crash, a closed folder stays closed across one, and removing a mirror while its workspace is open is still refused. A root the workspace lists outside the granted folder is drawn in the warning colour rather than failing silently, which is why none is guarded against. Closes #381
1 parent b451c56 commit ff7d11f

10 files changed

Lines changed: 484 additions & 14 deletions

File tree

MILESTONES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ M6 (Release) → Play Store release
290290
- Material Icon Theme, ESLint, Prettier, Python, Tailwind CSS
291291
- [x] 4 custom VSCodroid extensions:
292292
- `vscodroid.vscodroid-welcome-1.2.2`, welcome tab with quick actions
293-
- `vscodroid.vscodroid-saf-bridge-1.4.0`, SAF storage integration
293+
- `vscodroid.vscodroid-saf-bridge-1.5.0`, SAF storage integration
294294
- `vscodroid.vscodroid-process-monitor-1.2.0`, phantom process monitoring
295295
- `vscodroid.vscodroid-serve-network-1.1.0`, serve a dev server on the LAN
296296
- [x] `extensions.json` manifest auto-generated on first run

android/app/src/main/assets/extensions/vscodroid.vscodroid-saf-bridge-1.4.0/extension.js renamed to android/app/src/main/assets/extensions/vscodroid.vscodroid-saf-bridge-1.5.0/extension.js

File renamed without changes.

android/app/src/main/assets/extensions/vscodroid.vscodroid-saf-bridge-1.4.0/package.json renamed to android/app/src/main/assets/extensions/vscodroid.vscodroid-saf-bridge-1.5.0/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "vscodroid-saf-bridge",
33
"displayName": "VSCodroid Android Bridge",
44
"description": "Commands that reach Android: device folders, browser, SSH keys, storage, about",
5-
"version": "1.4.0",
5+
"version": "1.5.0",
66
"publisher": "vscodroid",
77
"engines": {
88
"vscode": "^1.96.0"
@@ -59,6 +59,10 @@
5959
],
6060
"menus": {
6161
"statusBar/remoteIndicator": [
62+
{
63+
"command": "vscodroid.openRecentFolder",
64+
"group": "remote_10_vscodroid_folder"
65+
},
6266
{
6367
"command": "vscodroid.about",
6468
"group": "remote_90_vscodroid_about"

android/app/src/main/kotlin/com/vscodroid/MainActivity.kt

Lines changed: 153 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1230,9 +1230,21 @@ class MainActivity : AppCompatActivity() {
12301230

12311231
dialog.dismiss()
12321232

1233-
// Reload VS Code with the mirror directory
1233+
// Reload VS Code with the mirror, or with the workspace it holds.
1234+
// The listing is off the main thread for the reason every other
1235+
// disk read here is: `MainThreadWatch` installs a policy that
1236+
// logs one, and `folderOpenTarget` stats each candidate.
12341237
if (navigate && serverPort > 0) {
1235-
navigateToFolder(serverPort, mirrorDir.absolutePath)
1238+
val target = withContext(Dispatchers.IO) {
1239+
folderOpenTarget(
1240+
mirrorDir.absolutePath,
1241+
mirrorDir.list()?.asList().orEmpty(),
1242+
)
1243+
}
1244+
if (target != mirrorDir.absolutePath) {
1245+
Logger.i(tag, "The granted folder holds a workspace; opening that")
1246+
}
1247+
navigateToFolder(serverPort, target)
12361248
}
12371249
} catch (e: CancellationException) {
12381250
// Not a folder that failed. This Activity is being destroyed and
@@ -2303,12 +2315,22 @@ class MainActivity : AppCompatActivity() {
23032315
}
23042316
}
23052317

2306-
private fun loadVSCode(port: Int, folderPath: String? = null) {
2318+
private fun loadVSCode(port: Int, folderPath: String? = null, fromUrl: String? = null) {
23072319
// Every route here is the user asking for the editor or the editor going
23082320
// up over a page that never refused it, so the refusal is over. The one
23092321
// caller that is neither checks the flag before calling; see onServerReady.
23102322
rendererCrashLoopShown = false
23112323
initBridge(port)
2324+
// Before the folder chain, because a closed folder is the one state that
2325+
// chain cannot name and would otherwise fall through to the remembered
2326+
// folder, reopening the workspace the user had just closed. [fromUrl] is
2327+
// for the caller whose WebView no longer holds the URL it is asking about,
2328+
// which is the renderer-crash path: [recreateWebView] builds a new one.
2329+
emptyWindowUrl(fromUrl ?: webView?.url, port)?.let {
2330+
Logger.i(tag, "Restoring the closed-folder window rather than a folder")
2331+
webView?.loadUrl(it)
2332+
return
2333+
}
23122334
// onServerReady routes a restart through here without a folder. Falling back
23132335
// to the folder already on screen keeps the user's workspace instead of
23142336
// dropping them back into the default projects directory.
@@ -2387,8 +2409,14 @@ class MainActivity : AppCompatActivity() {
23872409
private fun folderFromUrl(url: String?): String? =
23882410
url?.let { it.toUri() }
23892411
?.takeIf { it.isHierarchical }
2390-
?.getQueryParameter("folder")
2391-
?.takeIf { File(it).isDirectory }
2412+
?.let {
2413+
workbenchTarget(
2414+
folder = it.getQueryParameter("folder"),
2415+
workspace = it.getQueryParameter("workspace"),
2416+
isDirectory = { path -> File(path).isDirectory },
2417+
isFile = { path -> File(path).isFile },
2418+
)
2419+
}
23922420

23932421
/**
23942422
* Initializes the WebView bridge, security manager, and clients.
@@ -2675,7 +2703,11 @@ class MainActivity : AppCompatActivity() {
26752703
private fun rememberedWorkspaceFolder(): String? = rememberedFolderToReopen(
26762704
remembered = workspacePrefs.getString(KEY_LAST_FOLDER, null),
26772705
mirrorsRoot = Environment.getSafMirrorsDir(this),
2678-
exists = { File(it).isDirectory },
2706+
// A directory or a `.code-workspace` file, because both are things the
2707+
// workbench can be pointed back at and both are now remembered. The test
2708+
// is still that the path is there: a workspace deleted while the app was
2709+
// away would otherwise be reopened onto nothing.
2710+
exists = { File(it).exists() },
26792711
mirrorIsGranted = { safManager.folderForOpenedPath(it) != null },
26802712
)
26812713

@@ -3537,7 +3569,7 @@ class MainActivity : AppCompatActivity() {
35373569
// Always via loadVSCode so initBridge re-registers on the new WebView;
35383570
// loading the old URL directly would leave it without the bridge. The
35393571
// folder is carried over from the URL the destroyed WebView was showing.
3540-
loadVSCode(serverPort, folderFromUrl(lastUrl))
3572+
loadVSCode(serverPort, folderFromUrl(lastUrl), fromUrl = lastUrl)
35413573
}
35423574
}
35433575

@@ -4258,10 +4290,123 @@ internal fun authCallbackIsExpected(
42584290
* from sending a wrong one.
42594291
*/
42604292
internal fun workbenchUrl(port: Int, folderPath: String, token: String?): String {
4261-
val base = "http://127.0.0.1:$port/?folder=${Uri.encode(folderPath)}"
4293+
val param = if (folderPath.endsWith(WORKSPACE_FILE_SUFFIX)) "workspace" else "folder"
4294+
val base = "http://127.0.0.1:$port/?$param=${Uri.encode(folderPath)}"
42624295
return if (token.isNullOrEmpty()) base else "$base&tkn=${Uri.encode(token)}"
42634296
}
42644297

4298+
/** What the workbench calls a multi-root workspace, in the only place it is spelled. */
4299+
internal const val WORKSPACE_FILE_SUFFIX = ".code-workspace"
4300+
4301+
/**
4302+
* What a workbench URL has open, whether that is a folder or a workspace.
4303+
*
4304+
* The workbench navigates itself, and it names the two cases with different
4305+
* query parameters: `folder` for a directory and `workspace` for a
4306+
* `.code-workspace` file. Reading only `folder` is what made a workspace
4307+
* invisible to this side, and the cost was not that the URL looked wrong. It was
4308+
* that [MainActivity.adoptWorkbenchFolder] never ran, so a workspace opened out
4309+
* of a device-folder mirror got no write-back watcher and every edit stayed in
4310+
* the mirror, and that the workspace was not remembered, so the next launch
4311+
* reopened the default projects directory instead.
4312+
*
4313+
* Each branch stats what it reads, and for the kind of thing it is. A `folder`
4314+
* naming a file and a `workspace` naming a directory are both URLs nothing
4315+
* builds; refusing them keeps a dead or wrong path from pinning the WebView,
4316+
* which is what the directory test has always been for.
4317+
*
4318+
* The predicates are passed rather than called because `File` is unavailable in
4319+
* a plain JVM test, the same reason [rememberedFolderToReopen] takes its own.
4320+
*/
4321+
internal fun workbenchTarget(
4322+
folder: String?,
4323+
workspace: String?,
4324+
isDirectory: (String) -> Boolean,
4325+
isFile: (String) -> Boolean,
4326+
): String? = folder?.takeIf(isDirectory) ?: workspace?.takeIf(isFile)
4327+
4328+
/**
4329+
* The directory a target stands in: itself for a folder, its parent for a workspace.
4330+
*
4331+
* Only the resource interceptor wants this. It publishes the open workspace as a
4332+
* resource root, and [com.vscodroid.webview.resourceRootsInForce] matches a root
4333+
* by path prefix, so a root that is a single `.code-workspace` file matches only
4334+
* that file and every resource beside it is refused. The statically published
4335+
* roots cover the mirrors and projects trees, so this is the difference only for
4336+
* a workspace held outside both, which is exactly the case nothing else covers.
4337+
*
4338+
* The other two readers of the field want the prefix and get it either way:
4339+
* `folderForOpenedPath` and `mirrorNameFor` both reduce a path to the mirror
4340+
* holding it, and a file inside a mirror reduces the same as its directory does.
4341+
*/
4342+
internal fun workspaceDirectoryInForce(path: String?): String? =
4343+
if (path != null && path.endsWith(WORKSPACE_FILE_SUFFIX)) File(path).parent else path
4344+
4345+
/**
4346+
* What to open once a device folder has been granted and synced.
4347+
*
4348+
* The Android picker is `ACTION_OPEN_DOCUMENT_TREE` and returns a directory,
4349+
* never a file, so a workspace on device storage can only ever be reached
4350+
* through the folder holding it. Nothing joined the two: the folder opened as a
4351+
* folder, and the `.code-workspace` sitting in it was reachable only by knowing
4352+
* to find the file in the explorer and press the button on it. That is the gap
4353+
* behind "I cannot find a way to open an existing workspace", and it is the
4354+
* shell's to close, because desktop VS Code offers this from code the browser
4355+
* workbench does not carry (no `contains a workspace file` string exists in the
4356+
* bundle).
4357+
*
4358+
* Exactly one, at the top level, or nothing. Two is a guess, and guessing wrong
4359+
* is worse than opening the folder the user actually chose, from which either is
4360+
* one tap away. A directory that merely ends in `.code-workspace` is excluded
4361+
* because the workbench answers a workspace it cannot read with an empty window
4362+
* and no message, which is the failure this whole change exists to remove.
4363+
*/
4364+
/**
4365+
* The URL to reload when the workbench had the folder closed, or null.
4366+
*
4367+
* A closed folder is the third thing the workbench can be showing, and it is the
4368+
* one the folder chain cannot express: [workbenchTarget] answers a path or
4369+
* nothing, and "no folder, deliberately" is not a path. So every re-navigation
4370+
* over a closed folder fell through to the remembered folder and put the user
4371+
* back into the workspace they had just closed. `handleResumeFromBackground`
4372+
* already sidesteps this by calling `reload()` rather than rebuilding a URL, and
4373+
* this is the same answer for the paths that do rebuild.
4374+
*
4375+
* Returned verbatim and without a token. The workbench was already running, so
4376+
* the server has turned the token into a cookie that outlives this by a week,
4377+
* which is the reasoning the resume path states in full. A `folder` or
4378+
* `workspace` URL is deliberately NOT returned: those the folder chain can name,
4379+
* and it rebuilds them with a fresh token rather than reloading a stripped one.
4380+
*
4381+
* What makes a URL ours is asked of [workbenchUrl] rather than spelled again
4382+
* here. That keeps the host in exactly one expression, which is the affordance
4383+
* `the workbench URL is assembled in exactly one place` exists to protect: a
4384+
* second spelling is what lets the string loaded drift from the string logged,
4385+
* and it caught this function written the obvious way.
4386+
*/
4387+
internal fun emptyWindowUrl(url: String?, port: Int): String? {
4388+
if (url == null) return null
4389+
val ours = workbenchUrl(port, "", null).substringBefore('?')
4390+
if (!url.startsWith(ours)) return null
4391+
val query = url.substringAfter('?', "")
4392+
if (query.isEmpty()) return null
4393+
val closed = query.split('&').any {
4394+
it.substringBefore('=') == "ew" && it.substringAfter('=', "") == "true"
4395+
}
4396+
return if (closed) url else null
4397+
}
4398+
4399+
internal fun folderOpenTarget(
4400+
folderPath: String,
4401+
names: List<String>,
4402+
isFile: (String) -> Boolean = { File(it).isFile },
4403+
): String =
4404+
names.filter { it.endsWith(WORKSPACE_FILE_SUFFIX) }
4405+
.map { "$folderPath${File.separator}$it" }
4406+
.filter(isFile)
4407+
.singleOrNull()
4408+
?: folderPath
4409+
42654410
/**
42664411
* Whether a folder switch that failed should leave the previous folder watched.
42674412
*

android/app/src/main/kotlin/com/vscodroid/webview/VSCodroidWebViewClient.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import com.vscodroid.bridge.authRequestIdsIn
2020
import com.vscodroid.isExtensionCallback
2121
import com.vscodroid.util.Environment
2222
import com.vscodroid.util.Logger
23+
import com.vscodroid.workspaceDirectoryInForce
2324
import java.io.ByteArrayInputStream
2425
import java.io.FilterInputStream
2526
import java.io.File
@@ -196,8 +197,15 @@ private var lastRefusedWorkspace: String? = null
196197
* See [lastRefusedWorkspace].
197198
*/
198199
internal fun resourceRootsInForce(
199-
published: List<String>, sensitive: List<String>, candidate: String?
200+
published: List<String>, sensitive: List<String>, workspaceOrFolder: String?
200201
): List<String> {
202+
// Normalised here and not at the two suppliers, because both converge on this
203+
// and `ServiceWorkerRetentionTest` refuses a supplier written as anything but
204+
// `self.get()?....`: wrapping one would either capture the Activity or widen
205+
// a check whose own message says not to. A `.code-workspace` names a file,
206+
// and a root is matched by path prefix, so publishing the file publishes only
207+
// itself and refuses every resource beside it.
208+
val candidate = workspaceDirectoryInForce(workspaceOrFolder)
201209
val workspace = workspaceRootOrNull(candidate, sensitive)
202210
if (candidate != null && workspace == null) {
203211
if (candidate != lastRefusedWorkspace) {

0 commit comments

Comments
 (0)