From 2df5a56160d3d7c274655464ef51ef482a4c8362 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 16:25:04 -0700 Subject: [PATCH 01/39] ADFA-5067 | Add deep-link request models, path-traversal guard, and bookkeeping helper New, self-contained plumbing for deep-link support (no behavioral wiring yet): - DeepLinkRequest/PendingFileRequest/DeepLinkOpenRequest models and the URL parser for https://www.appdevforall.org/device/open/project/{name}[/file/{f}[/line/{n}[/column/{n}]]]. - PendingDeepLinkOpen, an in-memory handoff for the close-then-reopen continuation. - resolveWithinDirectory, a path-traversal guard for the attacker-controllable {filename} segment, mirroring the existing zip-slip pattern in AssetsInstallationHelper.extractZipToDir. Also guards against InvalidPathException from an embedded NUL byte (a %00 in the URL decodes to a literal NUL character, which java.nio.file.Path.resolve() throws on if uncaught). - recordProjectOpenedBookkeeping, extracted from MainActivity.openProject so a deep-link-triggered project switch gets the same Recents/analytics bookkeeping. - New error strings for the above. Co-Authored-By: Claude Sonnet 5 --- .../deeplink/PendingDeepLinkOpen.kt | 33 +++++ .../androidide/models/DeepLinkRequest.kt | 115 +++++++++++++++++ .../itsaky/androidide/utils/PathTraversal.kt | 57 +++++++++ .../utils/ProjectOpenBookkeeping.kt | 66 ++++++++++ .../androidide/models/DeepLinkRequestTest.kt | 117 ++++++++++++++++++ .../androidide/utils/PathTraversalTest.kt | 79 ++++++++++++ resources/src/main/res/values/strings.xml | 4 + 7 files changed, 471 insertions(+) create mode 100644 app/src/main/java/com/itsaky/androidide/deeplink/PendingDeepLinkOpen.kt create mode 100644 app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt create mode 100644 app/src/main/java/com/itsaky/androidide/utils/PathTraversal.kt create mode 100644 app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt create mode 100644 app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/utils/PathTraversalTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/deeplink/PendingDeepLinkOpen.kt b/app/src/main/java/com/itsaky/androidide/deeplink/PendingDeepLinkOpen.kt new file mode 100644 index 0000000000..94fba3db21 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/deeplink/PendingDeepLinkOpen.kt @@ -0,0 +1,33 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.deeplink + +import com.itsaky.androidide.models.DeepLinkOpenRequest + +/** + * In-memory, process-lifetime handoff for "the user confirmed closing the current project via a + * deep link; once this activity instance is actually destroyed, open the requested project." + * + * Deliberately not acted on synchronously inside the close-confirmation dialog's button callback -- + * see [com.itsaky.androidide.activities.editor.EditorHandlerActivity.onDestroy] for why the hand-off + * must wait until the old, `singleTask` activity instance is guaranteed torn down. + */ +internal object PendingDeepLinkOpen { + @Volatile + var value: DeepLinkOpenRequest? = null +} diff --git a/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt b/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt new file mode 100644 index 0000000000..b2aba058af --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt @@ -0,0 +1,115 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.models + +import android.net.Uri +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * A request to open a file at an optional line/column, carried as part of a [DeepLinkRequest] or a + * [DeepLinkOpenRequest]. + * + * [lineRaw]/[columnRaw] are kept as raw strings rather than parsed [Int]s so that callers can + * distinguish "segment absent from the URL" (`null`) from "segment present but not a valid positive + * integer" (non-null, fails [String.toIntOrNull] or non-positive) -- the latter must be reported to the + * user, the former must not. + */ +@Parcelize +data class PendingFileRequest( + val filePath: String, + val lineRaw: String?, + val columnRaw: String?, +) : Parcelable { + companion object { + const val EXTRA_KEY = "com.itsaky.androidide.PENDING_FILE_REQUEST" + } +} + +/** + * A parsed (but not yet resolved-to-a-path) request for + * `https://www.appdevforall.org/device/open/project/{projectName}[/file/{filename}[/line/{n}[/column/{n}]]]`. + */ +@Parcelize +data class DeepLinkRequest( + val projectName: String, + val fileRequest: PendingFileRequest? = null, +) : Parcelable { + companion object { + const val EXTRA_KEY = "com.itsaky.androidide.DEEP_LINK_REQUEST" + + private const val SEGMENT_PROJECT = "project" + private const val SEGMENT_FILE = "file" + private const val SEGMENT_LINE = "line" + private const val SEGMENT_COLUMN = "column" + + /** + * Parses a deep-link [Uri] of the form described in [DeepLinkRequest]'s docs. Returns `null` if + * the URI does not contain a `project` segment followed by a name -- i.e. it isn't a deep link + * this app understands, not merely a deep link with missing optional parts. + */ + fun parse(uri: Uri?): DeepLinkRequest? { + val segments = uri?.pathSegments ?: return null + + val projectNameIdx = segments.indexOf(SEGMENT_PROJECT) + 1 + if (projectNameIdx <= 0 || projectNameIdx >= segments.size) { + return null + } + val projectName = segments[projectNameIdx] + + val fileIdx = segments.indexOf(SEGMENT_FILE).takeIf { it >= 0 }?.plus(1) + val fileRequest = + fileIdx?.let { startIdx -> + if (startIdx >= segments.size) { + return@let null + } + + // filenames may themselves contain '/', so the filename is every segment from + // `file` up to (but not including) the next recognized keyword, joined back together + val endIdx = + listOf(SEGMENT_LINE, SEGMENT_COLUMN) + .mapNotNull { keyword -> segments.indexOf(keyword).takeIf { it > startIdx } } + .minOrNull() ?: segments.size + + val filePath = segments.subList(startIdx, endIdx).joinToString("/") + + val lineIdx = segments.indexOf(SEGMENT_LINE).takeIf { it >= 0 }?.plus(1) + val columnIdx = segments.indexOf(SEGMENT_COLUMN).takeIf { it >= 0 }?.plus(1) + + PendingFileRequest( + filePath = filePath, + lineRaw = lineIdx?.let { segments.getOrNull(it) }, + columnRaw = columnIdx?.let { segments.getOrNull(it) }, + ) + } + + return DeepLinkRequest(projectName = projectName, fileRequest = fileRequest) + } + } +} + +/** + * The resolved-path counterpart to [DeepLinkRequest], used once the project name has been resolved to + * an absolute directory -- e.g. when handing a pending "close current project, then open this one" off + * across activities via [com.itsaky.androidide.deeplink.PendingDeepLinkOpen]. + */ +@Parcelize +data class DeepLinkOpenRequest( + val projectRoot: String, + val fileRequest: PendingFileRequest?, +) : Parcelable diff --git a/app/src/main/java/com/itsaky/androidide/utils/PathTraversal.kt b/app/src/main/java/com/itsaky/androidide/utils/PathTraversal.kt new file mode 100644 index 0000000000..99b1712f45 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/PathTraversal.kt @@ -0,0 +1,57 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import java.io.File +import java.nio.file.InvalidPathException + +/** + * Resolves [relativePath] against [baseDir], rejecting any attempt to escape outside it. Intended + * for attacker-controllable input (e.g. the `{filename}` segment of a deep-link URL) that must never + * be allowed to read/write outside a known root directory. + * + * Two layers, mirroring the zip-slip guard in + * [com.itsaky.androidide.assets.AssetsInstallationHelper.extractZipToDir]: + * 1. A lexical reject of `..`/a leading `/` or `\` -- cheap, catches the common case outright. + * 2. Resolve + normalize against [baseDir] and verify with [java.nio.file.Path.startsWith] (not + * string prefix matching, which would wrongly accept `/project` as inside `/project-evil`) -- + * this is the authoritative check: it operates on Java's own resolved path, so it isn't fooled by + * however `..` made it into the string (a literal `..` segment is the only way a path can name a + * parent directory at all, however it got decoded). + * + * Returns `null` if [relativePath] is invalid or escapes [baseDir] -- including when it's not a + * representable path at all (e.g. containing a decoded NUL byte, `Uri.pathSegments` percent-decodes + * before this function ever sees the string, so `%00` arrives as a literal NUL character, which + * [java.nio.file.Path] rejects with [InvalidPathException] rather than silently ignoring). + */ +fun resolveWithinDirectory( + baseDir: File, + relativePath: String, +): File? { + if (relativePath.contains("..") || relativePath.startsWith("/") || relativePath.startsWith("\\")) { + return null + } + + return try { + val base = baseDir.toPath().toAbsolutePath().normalize() + val resolved = base.resolve(relativePath).normalize() + if (!resolved.startsWith(base)) null else resolved.toFile() + } catch (e: InvalidPathException) { + null + } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt b/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt new file mode 100644 index 0000000000..fb178344fe --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt @@ -0,0 +1,66 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import android.content.Context +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.lifecycle.lifecycleScope +import com.itsaky.androidide.analytics.IAnalyticsManager +import com.itsaky.androidide.preferences.internal.GeneralPreferences +import com.itsaky.androidide.projects.ProjectManagerImpl +import com.itsaky.androidide.roomData.recentproject.RecentProject +import com.itsaky.androidide.roomData.recentproject.RecentProjectRoomDatabase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.File + +/** + * Marks [root] as the currently open project (singleton state + last-opened pref), records it in + * Recents, and tracks the open in analytics -- the same bookkeeping + * [com.itsaky.androidide.activities.MainActivity.openProject] does for a normal manual open, + * extracted so a deep-link-triggered project switch gets it too even though that path bypasses + * `openProject` entirely (see + * [com.itsaky.androidide.activities.editor.EditorHandlerActivity.onDestroy]). + * + * Uses [ProcessLifecycleOwner]'s scope rather than a per-activity one, since this can run from an + * activity's `onDestroy()` after its own `lifecycleScope` has already been cancelled. + */ +fun recordProjectOpenedBookkeeping( + context: Context, + root: File, + project: RecentProject?, + analyticsManager: IAnalyticsManager, +) { + ProjectManagerImpl.getInstance().projectPath = root.absolutePath + GeneralPreferences.lastOpenedProject = root.absolutePath + + val scope = ProcessLifecycleOwner.get().lifecycleScope + scope.launch(Dispatchers.IO) { + val location = root.absolutePath + val recentProject = + project ?: RecentProject( + name = root.name, + location = location, + createdAt = getCreatedTime(location).toString(), + lastModified = getLastModifiedTime(location).toString(), + ) + RecentProjectRoomDatabase.getDatabase(context, scope).recentProjectDao().insert(recentProject) + } + + analyticsManager.trackProjectOpened(root.absolutePath) +} diff --git a/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt b/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt new file mode 100644 index 0000000000..33d0d7b8d6 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt @@ -0,0 +1,117 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.models + +import android.net.Uri +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DeepLinkRequestTest { + private fun parse(url: String) = DeepLinkRequest.parse(Uri.parse(url)) + + @Test + fun `project only`() { + val request = parse("https://www.appdevforall.org/device/open/project/MyApp") + assertEquals(DeepLinkRequest(projectName = "MyApp"), request) + } + + @Test + fun `project and file`() { + val request = parse("https://www.appdevforall.org/device/open/project/MyApp/file/Main.kt") + assertEquals( + DeepLinkRequest( + projectName = "MyApp", + fileRequest = PendingFileRequest(filePath = "Main.kt", lineRaw = null, columnRaw = null), + ), + request, + ) + } + + @Test + fun `project, file, and line`() { + val request = parse("https://www.appdevforall.org/device/open/project/MyApp/file/Main.kt/line/42") + assertEquals( + DeepLinkRequest( + projectName = "MyApp", + fileRequest = PendingFileRequest(filePath = "Main.kt", lineRaw = "42", columnRaw = null), + ), + request, + ) + } + + @Test + fun `project, file, line, and column`() { + val request = + parse( + "https://www.appdevforall.org/device/open/project/MyApp/file/Main.kt/line/42/column/7", + ) + assertEquals( + DeepLinkRequest( + projectName = "MyApp", + fileRequest = PendingFileRequest(filePath = "Main.kt", lineRaw = "42", columnRaw = "7"), + ), + request, + ) + } + + @Test + fun `multi-segment file path is rejoined with slashes`() { + val request = + parse( + "https://www.appdevforall.org/device/open/project/MyApp/file/app/src/main/Main.kt/line/1", + ) + assertEquals("app/src/main/Main.kt", request?.fileRequest?.filePath) + assertEquals("1", request?.fileRequest?.lineRaw) + } + + @Test + fun `malformed line and column are carried through unparsed, not rejected`() { + val request = + parse( + "https://www.appdevforall.org/device/open/project/MyApp/file/Main.kt/line/abc/column/xyz", + ) + assertEquals("abc", request?.fileRequest?.lineRaw) + assertEquals("xyz", request?.fileRequest?.columnRaw) + } + + @Test + fun `missing project segment yields null`() { + assertNull(parse("https://www.appdevforall.org/device/open/MyApp")) + } + + @Test + fun `project segment with no name yields null`() { + assertNull(parse("https://www.appdevforall.org/device/open/project")) + assertNull(parse("https://www.appdevforall.org/device/open/project/")) + } + + @Test + fun `file keyword with no name yields no file request`() { + val request = parse("https://www.appdevforall.org/device/open/project/MyApp/file") + assertEquals(DeepLinkRequest(projectName = "MyApp", fileRequest = null), request) + } + + @Test + fun `null uri yields null`() { + assertNull(DeepLinkRequest.parse(null)) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/PathTraversalTest.kt b/app/src/test/java/com/itsaky/androidide/utils/PathTraversalTest.kt new file mode 100644 index 0000000000..270321460a --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/PathTraversalTest.kt @@ -0,0 +1,79 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.io.File + +class PathTraversalTest { + private val baseDir = File("/project/root") + private val nulCharacter = 0.toChar() + + @Test + fun `plain relative path resolves inside base`() { + val resolved = resolveWithinDirectory(baseDir, "src/Main.kt") + assertEquals(File("/project/root/src/Main.kt"), resolved) + } + + @Test + fun `literal dot-dot is rejected`() { + assertNull(resolveWithinDirectory(baseDir, "../../etc/passwd")) + } + + @Test + fun `dot-dot buried in the middle of a path is rejected`() { + // The shape produced once android.net.Uri decodes a single raw segment containing an + // encoded slash, e.g. the URL segment "foo%2f..%2f..%2fetc%2fpasswd" -- decoded to one + // string, but still containing ".." once decoded. + assertNull(resolveWithinDirectory(baseDir, "foo/../../etc/passwd")) + } + + @Test + fun `leading slash is rejected`() { + assertNull(resolveWithinDirectory(baseDir, "/etc/passwd")) + } + + @Test + fun `leading backslash is rejected`() { + assertNull(resolveWithinDirectory(baseDir, "\\Windows\\System32")) + } + + @Test + fun `embedded NUL character is rejected instead of throwing`() { + // android.net.Uri.pathSegments percent-decodes before this function ever sees the string, so + // a URL's "%00" arrives here as a literal NUL character. java.nio.file.Path throws + // InvalidPathException for that -- must be caught, not left to crash the caller. + assertNull(resolveWithinDirectory(baseDir, "foo" + nulCharacter + ".txt")) + } + + @Test + fun `a filename merely containing dot-dot as a substring is rejected too`() { + // Intentionally the stricter, simpler substring reject rather than a proper per-segment + // check -- project files never legitimately need consecutive dots in a name, so treating + // "a..b.txt" the same as an actual ".." traversal segment is an acceptable, safe trade-off. + assertNull(resolveWithinDirectory(baseDir, "a..b.txt")) + } + + @Test + fun `multi-segment path resolves and normalizes redundant separators`() { + val resolved = resolveWithinDirectory(baseDir, "app/src/main/Main.kt") + assertEquals(File("/project/root/app/src/main/Main.kt"), resolved) + } +} diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 7ff3bc7f18..513e71d582 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -135,6 +135,10 @@ Do you want to open the last opened project? The project was:\n%s Close this project Last opened project doesn\'t exist. + No project named \"%s\" was found. + File \"%s\" was not found in the project. + \"%s\" is not a valid line number. + \"%s\" is not a valid column number. Create new project Open a saved project Delete a saved project From 6b96c845f8dd628eb08da6211ef90ec4cb081879 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 16:25:23 -0700 Subject: [PATCH 02/39] ADFA-5067 | Add DeepLinkActivity as the sole App Link entry point DeepLinkActivity is a UI-less trampoline holding the only for https://www.appdevforall.org/device/open/project/... links. It parses the incoming URI, checks whether a project is already loaded (IProjectManager.getInstance().workspace), and routes to MainActivity (nothing open) or the live, singleTask EditorActivityKt (one is, reused via onNewIntent), then finishes itself immediately. Kept as a plain Activity (matching the existing SplashActivity precedent), not BaseIDEActivity, since it never calls setContentView and has no theming needs of its own -- this avoids a visible flash of MainActivity's real UI in the common case where the actual destination is the already-running editor. Co-Authored-By: Claude Sonnet 5 --- app/src/main/AndroidManifest.xml | 16 +++++ .../androidide/activities/DeepLinkActivity.kt | 66 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2cd24756d1..93e57142a9 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -96,6 +96,22 @@ android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize" android:exported="true" android:theme="@style/Theme.AndroidIDE" /> + + + + + + + + . + */ + +package com.itsaky.androidide.activities + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import com.itsaky.androidide.activities.editor.EditorActivityKt +import com.itsaky.androidide.models.DeepLinkRequest +import com.itsaky.androidide.projects.IProjectManager + +/** + * The sole `` holder for `https://www.appdevforall.org/device/open/project/...` App + * Links. Never shows any UI -- it only parses the incoming [android.net.Uri], decides whether a + * project is already loaded, and hands off to whichever real activity owns that scenario: + * [MainActivity] if nothing is open yet, or the already-running [EditorActivityKt] (via its + * `singleTask` `onNewIntent`) if one is. + * + * Kept as a plain [Activity] (like [SplashActivity]), not [com.itsaky.androidide.app.BaseIDEActivity], + * since it never calls `setContentView` and has no theming needs of its own. + */ +class DeepLinkActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val request = DeepLinkRequest.parse(intent?.data) + if (request == null) { + finish() + return + } + + val target = + if (IProjectManager.getInstance().workspace != null) { + EditorActivityKt::class.java + } else { + MainActivity::class.java + } + + startActivity( + Intent(this, target).apply { + putExtra(DeepLinkRequest.EXTRA_KEY, request) + // SINGLE_TOP: if `target` is MainActivity and one is already on top of the stack + // (e.g. the user was browsing recent projects when the link was tapped), reuse it via + // onNewIntent instead of stacking a second instance. EditorActivityKt is singleTask, + // so it always reuses its live instance regardless of this flag. + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + }, + ) + finish() + } +} From 8c42c354a336daf28f04b8a5f04593deb38cc471 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 16:25:46 -0700 Subject: [PATCH 03/39] ADFA-5067 | Handle deep links with no project open in MainActivity Wires DeepLinkRequest handling into MainActivity's onCreate/onNewIntent: resolves the project name via findValidProjects, flashes an error if it doesn't exist, and otherwise opens it directly via openProject (bypassing GeneralPreferences.confirmProjectOpen -- an explicit link tap is itself a specific request to open project X, so re-confirming it is redundant friction). openProject gains an optional pendingFileRequest param that rides along in the EditorActivityKt intent extras for file/line/column navigation once the project finishes loading; all existing call sites are unaffected since it defaults to null. Also reindents a pre-existing over-length line in startWebServer() that the Spotless ratchet now covers as a side effect of touching this file (no behavior change). Co-Authored-By: Claude Sonnet 5 --- .../androidide/activities/MainActivity.kt | 163 +++++++++++------- 1 file changed, 101 insertions(+), 62 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt index de2731000f..ec847d3cae 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt @@ -24,7 +24,7 @@ import android.util.Log import android.view.KeyEvent import android.view.View import androidx.activity.OnBackPressedCallback -import org.koin.androidx.viewmodel.ext.android.viewModel +import androidx.core.content.IntentCompat import androidx.core.graphics.Insets import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible @@ -34,35 +34,39 @@ import androidx.transition.doOnEnd import com.google.android.material.transition.MaterialSharedAxis import com.itsaky.androidide.FeedbackButtonManager import com.itsaky.androidide.R -import com.itsaky.androidide.activities.editor.EditorActivityKt import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.activities.editor.EditorActivityKt import com.itsaky.androidide.analytics.IAnalyticsManager import com.itsaky.androidide.app.EdgeToEdgeIDEActivity import com.itsaky.androidide.databinding.ActivityMainBinding +import com.itsaky.androidide.fragments.MainFragment +import com.itsaky.androidide.fragments.RecentProjectsFragment import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag.PROJECT_RECENT_TOP import com.itsaky.androidide.idetooltips.TooltipTag.SETUP_OVERVIEW +import com.itsaky.androidide.localWebServer.ServerConfig +import com.itsaky.androidide.localWebServer.WebServer +import com.itsaky.androidide.models.DeepLinkRequest +import com.itsaky.androidide.models.PendingFileRequest import com.itsaky.androidide.preferences.internal.GeneralPreferences -import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.resources.R.string +import com.itsaky.androidide.roomData.recentproject.RecentProject +import com.itsaky.androidide.shortcuts.IdeShortcutActions +import com.itsaky.androidide.shortcuts.ShortcutContext +import com.itsaky.androidide.shortcuts.ShortcutExecutionContext +import com.itsaky.androidide.shortcuts.ShortcutManager import com.itsaky.androidide.templates.ITemplateProvider import com.itsaky.androidide.utils.DialogUtils import com.itsaky.androidide.utils.Environment import com.itsaky.androidide.utils.FeatureFlags +import com.itsaky.androidide.utils.MainScreenActions import com.itsaky.androidide.utils.UrlManager +import com.itsaky.androidide.utils.applyBottomWindowInsetsPadding import com.itsaky.androidide.utils.findValidProjects +import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashInfo -import com.itsaky.androidide.utils.applyBottomWindowInsetsPadding -import com.itsaky.androidide.utils.MainScreenActions -import com.itsaky.androidide.fragments.MainFragment -import com.itsaky.androidide.fragments.RecentProjectsFragment -import com.itsaky.androidide.roomData.recentproject.RecentProject -import com.itsaky.androidide.shortcuts.IdeShortcutActions -import com.itsaky.androidide.shortcuts.ShortcutContext -import com.itsaky.androidide.shortcuts.ShortcutExecutionContext -import com.itsaky.androidide.shortcuts.ShortcutManager -import com.itsaky.androidide.utils.getCreatedTime -import com.itsaky.androidide.utils.getLastModifiedTime +import com.itsaky.androidide.utils.hasVisibleDialog +import com.itsaky.androidide.utils.recordProjectOpenedBookkeeping import com.itsaky.androidide.viewmodel.MainViewModel import com.itsaky.androidide.viewmodel.MainViewModel.Companion.SCREEN_CLONE_REPO import com.itsaky.androidide.viewmodel.MainViewModel.Companion.SCREEN_DELETE_PROJECTS @@ -74,12 +78,10 @@ import com.itsaky.androidide.viewmodel.MainViewModel.Companion.TOOLTIPS_WEB_VIEW import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import com.itsaky.androidide.localWebServer.ServerConfig -import com.itsaky.androidide.localWebServer.WebServer import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel import org.slf4j.LoggerFactory import java.io.File -import com.itsaky.androidide.utils.hasVisibleDialog class MainActivity : EdgeToEdgeIDEActivity() { private val log = LoggerFactory.getLogger(MainActivity::class.java) @@ -119,7 +121,7 @@ class MainActivity : EdgeToEdgeIDEActivity() { private val binding: ActivityMainBinding get() = checkNotNull(_binding) - override fun onCreate(savedInstanceState: Bundle?) { + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) MainScreenActions.register(this) @@ -127,7 +129,13 @@ class MainActivity : EdgeToEdgeIDEActivity() { // Start WebServer after installation is complete startWebServer() - if (savedInstanceState == null) { openLastProject() } + val deepLinkRequest = + IntentCompat.getParcelableExtra(intent, DeepLinkRequest.EXTRA_KEY, DeepLinkRequest::class.java) + if (deepLinkRequest != null) { + handleDeepLinkRequest(deepLinkRequest) + } else if (savedInstanceState == null) { + openLastProject() + } if (FeatureFlags.isExperimentsEnabled) { binding.codeOnTheGoLabel.title = getString(R.string.app_name) + "." @@ -172,21 +180,21 @@ class MainActivity : EdgeToEdgeIDEActivity() { } } - override fun dispatchKeyEvent(event: KeyEvent): Boolean { - return shortcutManager.dispatch( + override fun dispatchKeyEvent(event: KeyEvent): Boolean = + shortcutManager.dispatch( event = event, context = ShortcutContext.MAIN, focusView = currentFocus, hasModal = supportFragmentManager.hasVisibleDialog(), executionContext = mainShortcutExecutionContext, ) || super.dispatchKeyEvent(event) - } private val mainShortcutExecutionContext by lazy { ShortcutExecutionContext( - ideShortcutActions = IdeShortcutActions { - ActionData.create(this) - }, + ideShortcutActions = + IdeShortcutActions { + ActionData.create(this) + }, ) } @@ -245,16 +253,22 @@ class MainActivity : EdgeToEdgeIDEActivity() { */ private fun recreateVisibleFragmentView() { when (viewModel.currentScreen.value) { - SCREEN_MAIN -> - supportFragmentManager.beginTransaction() + SCREEN_MAIN -> { + supportFragmentManager + .beginTransaction() .setReorderingAllowed(true) .replace(R.id.main, MainFragment()) .commitNow() - SCREEN_SAVED_PROJECTS -> - supportFragmentManager.beginTransaction() + } + + SCREEN_SAVED_PROJECTS -> { + supportFragmentManager + .beginTransaction() .setReorderingAllowed(true) .replace(R.id.saved_projects_view, RecentProjectsFragment()) .commitNow() + } + else -> { } } } @@ -318,7 +332,7 @@ class MainActivity : EdgeToEdgeIDEActivity() { TOOLTIPS_WEB_VIEW -> binding.tooltipWebView SCREEN_SAVED_PROJECTS -> binding.savedProjectsView SCREEN_DELETE_PROJECTS -> binding.deleteProjectsView - SCREEN_CLONE_REPO -> binding.cloneRepositoryView + SCREEN_CLONE_REPO -> binding.cloneRepositoryView else -> throw IllegalArgumentException("Invalid screen id: '$screen'") } @@ -329,7 +343,7 @@ class MainActivity : EdgeToEdgeIDEActivity() { binding.tooltipWebView, binding.savedProjectsView, binding.deleteProjectsView, - binding.cloneRepositoryView, + binding.cloneRepositoryView, )) { fragment.isVisible = fragment == currentFragment } @@ -365,20 +379,25 @@ class MainActivity : EdgeToEdgeIDEActivity() { val validProjects = findValidProjects(Environment.PROJECTS_DIR) val lastOpenedPath = GeneralPreferences.lastOpenedProject - val projectToOpen = validProjects.find { it.absolutePath == lastOpenedPath } - ?: validProjects.maxByOrNull { it.lastModified() } + val projectToOpen = + validProjects.find { it.absolutePath == lastOpenedPath } + ?: validProjects.maxByOrNull { it.lastModified() } withContext(Dispatchers.Main) { when { - projectToOpen != null -> handleOpenProject(projectToOpen) + projectToOpen != null -> { + handleOpenProject(projectToOpen) + } - lastOpenedPath.isNotBlank() && lastOpenedPath != GeneralPreferences.NO_OPENED_PROJECT -> { - if (!File(lastOpenedPath).exists()) { - flashInfo(string.msg_opened_project_does_not_exist) - } - } + lastOpenedPath.isNotBlank() && lastOpenedPath != GeneralPreferences.NO_OPENED_PROJECT -> { + if (!File(lastOpenedPath).exists()) { + flashInfo(string.msg_opened_project_does_not_exist) + } + } - else -> Unit + else -> { + Unit + } } } } @@ -402,23 +421,13 @@ class MainActivity : EdgeToEdgeIDEActivity() { builder.show() } - internal fun openProject(root: File, project: RecentProject? = null, hasTemplateIssues: Boolean = false) { - ProjectManagerImpl.getInstance().projectPath = root.absolutePath - GeneralPreferences.lastOpenedProject = root.absolutePath - - lifecycleScope.launch(Dispatchers.IO) { - val location = root.absolutePath - val recentProject = project ?: RecentProject( - name = root.name, - location = location, - createdAt = getCreatedTime(location).toString(), - lastModified = getLastModifiedTime(location).toString() - ) - viewModel.saveProjectToRecents(recentProject) - } - - // Track project open in Firebase Analytics - analyticsManager.trackProjectOpened(root.absolutePath) + internal fun openProject( + root: File, + project: RecentProject? = null, + hasTemplateIssues: Boolean = false, + pendingFileRequest: PendingFileRequest? = null, + ) { + recordProjectOpenedBookkeeping(applicationContext, root, project, analyticsManager) if (isFinishing) { return @@ -427,21 +436,28 @@ class MainActivity : EdgeToEdgeIDEActivity() { val intent = Intent(this, EditorActivityKt::class.java).apply { putExtra("PROJECT_PATH", root.absolutePath) - if (hasTemplateIssues) { - putExtra("HAS_TEMPLATE_ISSUES", true) - } + if (hasTemplateIssues) { + putExtra("HAS_TEMPLATE_ISSUES", true) + } + pendingFileRequest?.let { putExtra(PendingFileRequest.EXTRA_KEY, it) } addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) } startActivity(intent) } - private fun startWebServer() { + private fun startWebServer() { lifecycleScope.launch(Dispatchers.IO) { try { val dbFile = Environment.DOC_DB log.info("Starting WebServer - using database file from: {}", dbFile.absolutePath) - val server = WebServer(ServerConfig(databasePath = dbFile.absolutePath, fileDirPath = applicationContext.filesDir.absolutePath)) + val server = + WebServer( + ServerConfig( + databasePath = dbFile.absolutePath, + fileDirPath = applicationContext.filesDir.absolutePath, + ), + ) webServer = server server.start() } catch (e: Exception) { @@ -454,6 +470,29 @@ class MainActivity : EdgeToEdgeIDEActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + setIntent(intent) + IntentCompat + .getParcelableExtra(intent, DeepLinkRequest.EXTRA_KEY, DeepLinkRequest::class.java) + ?.let { handleDeepLinkRequest(it) } + } + + /** + * Resolves [request]'s project name to an on-disk project directory and opens it -- called when + * [DeepLinkActivity] has already determined no project is currently loaded. A deep-link-triggered + * open bypasses [GeneralPreferences.confirmProjectOpen]: tapping the link is itself an explicit + * request for this specific project, so re-confirming it would be redundant friction. + */ + private fun handleDeepLinkRequest(request: DeepLinkRequest) { + lifecycleScope.launch(Dispatchers.IO) { + val projectDir = findValidProjects(Environment.PROJECTS_DIR).find { it.name == request.projectName } + withContext(Dispatchers.Main) { + if (projectDir == null) { + flashError(getString(string.msg_deeplink_project_not_found, request.projectName)) + return@withContext + } + openProject(projectDir, pendingFileRequest = request.fileRequest) + } + } } override fun onDestroy() { From 0df3845d6b734e2bed46c9e6b2ac1b59818358d5 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 16:26:12 -0700 Subject: [PATCH 04/39] ADFA-5067 | Handle deep links to an already-open project in EditorHandlerActivity This is the activity that owns both the confirm-close dialog and the open editor tabs, so it makes the same-project/different-project decision itself rather than MainActivity: - onNewIntent resolves the project name and compares it against IProjectManager's current workspace/projectDirPath. Same project already open -> no-op project-wise, just navigate to the requested file. Different project open -> reuse the existing, unmodified confirmProjectClose() dialog. - confirmProjectClose/performCloseAllFiles gain an optional trailing onClosed callback (default null, so both existing call sites -- back-press and the sidebar "Close Project" action -- are byte-for-byte unchanged in behavior). onClosed only records the pending request (PendingDeepLinkOpen); it does not call startActivity synchronously, because doing so immediately after finish() risks the framework redelivering the new PROJECT_PATH to the dying singleTask instance via onNewIntent instead of spawning a fresh one. Instead onDestroy() drains it once the instance is guaranteed torn down. - applyDeepLinkFileRequest resolves the file/line/column request through resolveWithinDirectory (path-traversal guard) and reuses the existing openFileAndSelect/validateRange clamping -- no new clamping logic needed. - postProjectInit consumes a pending file request once a freshly opened project (cold open, or the tail of a close-then-reopen) finishes loading. Also fixes a pre-existing race in openFileAndSelect, found while testing the above on-device: EditorFeatures.validateRange mutates its Position arguments in place, and a freshly-created CodeEditorView's own async content-load pipeline calls validateRange/setSelection on that *same* Range instance separately from this function's own call. If this function's postInLifecycle callback ran first -- while the document was still the just-constructed empty one line -- it permanently clamped the shared Position down to (0,0) before the real content ever loaded, so opening a file that wasn't already in a tab at a specific line silently landed the cursor at line 1 instead. Fixed with a defensive copy so this function can no longer corrupt the shared instance regardless of which side runs first. This is existing, general-purpose API, not deep-link-specific -- no other caller happened to combine "brand-new tab" with a non-origin selection before. Co-Authored-By: Claude Sonnet 5 --- .../editor/EditorHandlerActivity.kt | 166 +++++++++++++++++- 1 file changed, 160 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index ecd7ff984f..da83b3fdb1 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -30,6 +30,7 @@ import android.view.View import android.view.ViewGroup.LayoutParams import android.widget.TextView import androidx.collection.MutableIntObjectMap +import androidx.core.content.IntentCompat import androidx.core.content.res.ResourcesCompat import androidx.core.view.GravityCompat import androidx.core.view.doOnNextLayout @@ -48,6 +49,7 @@ import com.itsaky.androidide.actions.ActionsRegistry.Companion.getInstance import com.itsaky.androidide.actions.build.QuickRunAction import com.itsaky.androidide.actions.internal.DefaultActionsRegistry import com.itsaky.androidide.activities.PluginManagerActivity +import com.itsaky.androidide.analytics.IAnalyticsManager import com.itsaky.androidide.api.ActionContextProvider import com.itsaky.androidide.app.BaseApplication import com.itsaky.androidide.app.EditorEvents @@ -55,6 +57,7 @@ import com.itsaky.androidide.app.EditorProviderImpl import com.itsaky.androidide.app.IDEApplication import com.itsaky.androidide.databinding.FileActionPopupWindowBinding import com.itsaky.androidide.databinding.FileActionPopupWindowItemBinding +import com.itsaky.androidide.deeplink.PendingDeepLinkOpen import com.itsaky.androidide.editor.language.treesitter.JavaLanguage import com.itsaky.androidide.editor.language.treesitter.JsonLanguage import com.itsaky.androidide.editor.language.treesitter.KotlinLanguage @@ -71,9 +74,13 @@ import com.itsaky.androidide.fragments.sidebar.EditorSidebarFragment import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.interfaces.IEditorHandler +import com.itsaky.androidide.models.DeepLinkOpenRequest +import com.itsaky.androidide.models.DeepLinkRequest import com.itsaky.androidide.models.FileExtension import com.itsaky.androidide.models.OpenedFile import com.itsaky.androidide.models.OpenedFilesCache +import com.itsaky.androidide.models.PendingFileRequest +import com.itsaky.androidide.models.Position import com.itsaky.androidide.models.Range import com.itsaky.androidide.models.SaveResult import com.itsaky.androidide.plugins.manager.build.PluginBuildActionManager @@ -83,6 +90,7 @@ import com.itsaky.androidide.plugins.manager.ui.PluginEditorTabManager import com.itsaky.androidide.plugins.manager.ui.PluginToolbarHost import com.itsaky.androidide.plugins.manager.ui.PluginUiActionManager import com.itsaky.androidide.preferences.internal.EditorPreferences +import com.itsaky.androidide.projects.IProjectManager import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.projects.builder.BuildResult import com.itsaky.androidide.shortcuts.IdeShortcutActions @@ -90,17 +98,23 @@ import com.itsaky.androidide.shortcuts.ShortcutContext import com.itsaky.androidide.shortcuts.ShortcutExecutionContext import com.itsaky.androidide.shortcuts.ShortcutManager import com.itsaky.androidide.tasks.executeAsync +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult import com.itsaky.androidide.ui.CodeEditorView import com.itsaky.androidide.utils.DialogUtils.newMaterialDialogBuilder import com.itsaky.androidide.utils.DialogUtils.showConfirmationDialog import com.itsaky.androidide.utils.EditorActivityActions import com.itsaky.androidide.utils.EditorSidebarActions +import com.itsaky.androidide.utils.Environment import com.itsaky.androidide.utils.ImageUtils import com.itsaky.androidide.utils.IntentUtils.openImage import com.itsaky.androidide.utils.UniqueNameBuilder +import com.itsaky.androidide.utils.findValidProjects +import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.utils.forEachViewRecursively import com.itsaky.androidide.utils.hasVisibleDialog +import com.itsaky.androidide.utils.recordProjectOpenedBookkeeping +import com.itsaky.androidide.utils.resolveWithinDirectory import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable @@ -109,6 +123,7 @@ import kotlinx.coroutines.withContext import org.adfa.constants.CONTENT_KEY import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode +import org.koin.android.ext.android.inject import java.io.File import java.util.WeakHashMap import java.util.concurrent.ConcurrentHashMap @@ -157,6 +172,8 @@ open class EditorHandlerActivity : } private val shortcutManager by lazy { ShortcutManager(applicationContext) } + private val analyticsManager: IAnalyticsManager by inject() + private var pluginEditorProvider: EditorProviderImpl? = null private fun getTabPositionForFileIndex(fileIndex: Int): Int { @@ -328,6 +345,26 @@ open class EditorHandlerActivity : override fun onDestroy() { super.onDestroy() ActionContextProvider.clearActivity(this) + + // Drain any deep-link-triggered "close then reopen a different project" request recorded by + // confirmProjectCloseThenOpen's onClosed callback. This deliberately waits until onDestroy -- + // which only runs once the framework has committed to tearing this singleTask instance down -- + // rather than firing startActivity() synchronously right after finish(), because the two calls + // racing could otherwise have the new PROJECT_PATH redelivered to this dying instance via + // onNewIntent (which never reads it) instead of a genuinely new instance's onCreate. + PendingDeepLinkOpen.value?.let { pending -> + PendingDeepLinkOpen.value = null + val root = File(pending.projectRoot) + val ctx = applicationContext + recordProjectOpenedBookkeeping(ctx, root, project = null, analyticsManager = analyticsManager) + ctx.startActivity( + Intent(ctx, EditorActivityKt::class.java).apply { + putExtra("PROJECT_PATH", pending.projectRoot) + pending.fileRequest?.let { putExtra(PendingFileRequest.EXTRA_KEY, it) } + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }, + ) + } } override fun onResume() { @@ -711,8 +748,22 @@ open class EditorHandlerActivity : editor.setSelection(0, 0) return@postInLifecycle } - editor.validateRange(selection) - editor.setSelection(selection) + // EditorFeatures.validateRange mutates Position in place. For a file that was + // just opened (new CodeEditorView), that same `selection` instance was also handed + // to the view's constructor, whose own async content-load pipeline calls + // validateRange/setSelection on it again once the file finishes reading. If this + // call runs first -- while the document is still the freshly-constructed empty + // one line -- it clamps the shared Position down to (0,0) *before* the real + // content loads, permanently corrupting the value the constructor's own pipeline + // later relies on. Validate/apply a defensive copy here instead, so this call can + // never corrupt the shared instance regardless of which side runs first. + val safeSelection = + Range( + Position(selection.start.line, selection.start.column), + Position(selection.end.line, selection.end.column), + ) + editor.validateRange(safeSelection) + editor.setSelection(safeSelection) } } } @@ -1731,7 +1782,10 @@ open class EditorHandlerActivity : confirmProjectClose() } - private fun performCloseAllFiles(manualFinish: Boolean) { + private fun performCloseAllFiles( + manualFinish: Boolean, + onClosed: (() -> Unit)? = null, + ) { val pluginManager = IDEApplication.getPluginManager() val fileCount = editorViewModel.getOpenedFileCount() for (i in 0 until fileCount) { @@ -1756,10 +1810,11 @@ open class EditorHandlerActivity : if (manualFinish) { finish() + onClosed?.invoke() } } - private fun confirmProjectClose() { + private fun confirmProjectClose(onClosed: (() -> Unit)? = null) { val content = contentOrNull ?: return val builder = newMaterialDialogBuilder(this) builder.setTitle(string.title_confirm_project_close) @@ -1775,7 +1830,7 @@ open class EditorHandlerActivity : (content.editorContainer.getChildAt(i) as? CodeEditorView)?.editor?.markUnmodified() } - performCloseAllFiles(manualFinish = true) + performCloseAllFiles(manualFinish = true, onClosed = onClosed) } // OPTION 2: Save and close @@ -1785,7 +1840,7 @@ open class EditorHandlerActivity : saveAllAsync(notify = false) { runOnUiThread { if (contentOrNull == null) return@runOnUiThread - performCloseAllFiles(manualFinish = true) + performCloseAllFiles(manualFinish = true, onClosed = onClosed) } recentProjectsViewModel.updateProjectModifiedDate( editorViewModel.getProjectName(), @@ -1795,4 +1850,103 @@ open class EditorHandlerActivity : builder.show() } + + /** + * Entry point used only by the deep-link [onNewIntent] routing below: shows the same, + * unmodified confirm-close dialog as [doConfirmProjectClose], but [onClosed] runs once the user + * actually confirms a close (save-or-discard) -- never on Cancel, which leaves the current + * project open exactly as it was. + */ + private fun confirmProjectCloseThenOpen(onClosed: () -> Unit) { + confirmProjectClose(onClosed) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + + val request = + IntentCompat.getParcelableExtra(intent, DeepLinkRequest.EXTRA_KEY, DeepLinkRequest::class.java) + ?: return + + lifecycleScope.launch(Dispatchers.IO) { + val projectDir = findValidProjects(Environment.PROJECTS_DIR).find { it.name == request.projectName } + withContext(Dispatchers.Main) { + if (projectDir == null) { + flashError(getString(string.msg_deeplink_project_not_found, request.projectName)) + return@withContext + } + + if (IProjectManager.getInstance().workspace != null && + projectDir.absolutePath == IProjectManager.getInstance().projectDirPath + ) { + // Requirement #2: same project already open -- no-op project-wise, just navigate. + request.fileRequest?.let { applyDeepLinkFileRequest(it) } + return@withContext + } + + // Requirement #3: a different project is open. Reuse the existing, unmodified + // confirm-close dialog; only record the pending open if the user actually confirms -- + // see onDestroy() for why the reopen itself waits until this instance is torn down. + confirmProjectCloseThenOpen { + PendingDeepLinkOpen.value = DeepLinkOpenRequest(projectDir.absolutePath, request.fileRequest) + } + } + } + } + + override fun postProjectInit( + isSuccessful: Boolean, + failure: TaskExecutionResult.Failure?, + ) { + super.postProjectInit(isSuccessful, failure) + if (!isSuccessful) return + + // Covers requirement #1 (cold open + file) and the tail of requirement #3 (a fresh + // EditorActivityKt instance always runs the normal init pipeline, whether started by + // MainActivity.openProject or by this activity's own onDestroy() hand-off). + val request = + IntentCompat.getParcelableExtra(intent, PendingFileRequest.EXTRA_KEY, PendingFileRequest::class.java) + ?: return + intent.removeExtra(PendingFileRequest.EXTRA_KEY) // don't reapply on a later config-change recreate + applyDeepLinkFileRequest(request) + } + + /** + * Applies a deep-link file/line/column request to the *currently open* project. [request]'s + * file path is attacker-controllable URL input, so it's resolved through + * [resolveWithinDirectory] rather than a bare [File] constructor -- see that function's docs for + * why a lexical `..` check alone isn't enough. + */ + private fun applyDeepLinkFileRequest(request: PendingFileRequest) { + val projectDir = File(IProjectManager.getInstance().projectDirPath) + val file = resolveWithinDirectory(projectDir, request.filePath) + if (file == null || !file.exists()) { + flashError(getString(string.msg_deeplink_file_not_found, request.filePath)) + return + } + + // URL line/column are 1-based; internal Position is 0-based. + var line = 0 + var column = 0 + request.lineRaw?.let { raw -> + val parsed = raw.toIntOrNull() + if (parsed == null || parsed <= 0) { + flashError(getString(string.msg_deeplink_invalid_line, raw)) + } else { + line = parsed - 1 + } + } + request.columnRaw?.let { raw -> + val parsed = raw.toIntOrNull() + if (parsed == null || parsed <= 0) { + flashError(getString(string.msg_deeplink_invalid_column, raw)) + } else { + column = parsed - 1 + } + } + + val pos = Position(line, column) + openFileAndSelect(file, Range(pos, pos)) + } } From 1109bf1b52fe02220e824bbbf69e3a9c59e54402 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 16:26:30 -0700 Subject: [PATCH 05/39] ADFA-5067 | Add RFC 5785 .well-known/assetlinks.json for App Links verification Placed at the top level so it mirrors the real eventual absolute path (https://www.appdevforall.org/.well-known/assetlinks.json) exactly, meaning relocating it to the actual website later is a literal file copy, not a rename. sha256_cert_fingerprints is left as a TODO placeholder -- the real value belongs to whoever controls the release signing key / Play Console and can't be filled in from source. Until that's live, autoVerify will fail Digital Asset Links verification and Android may show a disambiguation chooser instead of auto-opening the app; expected per the ticket's own framing ("we will move it to the website later"). Co-Authored-By: Claude Sonnet 5 --- .well-known/README.md | 20 ++++++++++++++++++++ .well-known/assetlinks.json | 12 ++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 .well-known/README.md create mode 100644 .well-known/assetlinks.json diff --git a/.well-known/README.md b/.well-known/README.md new file mode 100644 index 0000000000..a4e94b30d1 --- /dev/null +++ b/.well-known/README.md @@ -0,0 +1,20 @@ +# `.well-known` (ADFA-5067) + +`assetlinks.json` in this directory is the [RFC 5785](https://www.rfc-editor.org/rfc/rfc5785) / +[Digital Asset Links](https://developers.google.com/digital-asset-links) file required for Android +App Links to `https://www.appdevforall.org/device/open/project/...` to auto-verify. + +This directory lives in the repo only until the actual website exists. To activate it: + +1. Copy this directory verbatim to the web server root, so it serves at + `https://www.appdevforall.org/.well-known/assetlinks.json` with `Content-Type: application/json`. +2. Replace the `TODO_REPLACE_WITH_RELEASE_SIGNING_SHA256_FINGERPRINT` placeholder with the SHA-256 + fingerprint of the certificate that actually signs the released APK/AAB — get it via + `keytool -list -v -keystore ` (whoever holds the release keystore), or from the Play + Console under **App integrity > App signing key certificate** if Play App Signing is used. This + cannot be filled in from source; it's a secret held by release engineering, not derivable from this + repository. + +Until both steps are done, `android:autoVerify="true"` on `DeepLinkActivity`'s intent-filter will fail +Digital Asset Links verification, and Android may show a disambiguation chooser instead of opening the +app directly when a link is tapped. This is expected for now. diff --git a/.well-known/assetlinks.json b/.well-known/assetlinks.json new file mode 100644 index 0000000000..51c327cb12 --- /dev/null +++ b/.well-known/assetlinks.json @@ -0,0 +1,12 @@ +[ + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "com.itsaky.androidide", + "sha256_cert_fingerprints": [ + "TODO_REPLACE_WITH_RELEASE_SIGNING_SHA256_FINGERPRINT" + ] + } + } +] From a0790b21509e402d68a3653eb45cee8de7a60b22 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 16:26:48 -0700 Subject: [PATCH 06/39] ADFA-5067 | Document the deep-link entry point in ARCHITECTURE.md Co-Authored-By: Claude Sonnet 5 --- ARCHITECTURE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4be5ac177e..18eae75b44 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -51,6 +51,8 @@ Feature code layers as **UI → ViewModel → Repository → data source**, with **EventBus is a deliberate side-channel.** Long-running, cross-module signals (build/install lifecycle, editor events) are broadcast via GreenRobot EventBus (`@Subscribe(threadMode = ThreadMode.MAIN)`) and the `eventbus-events` module's shared event types. Treat it as the integration bus *between* subsystems; don't use it to replace a ViewModel's own state inside a single screen. +**App Links enter through a UI-less trampoline, not `MainActivity` directly.** `DeepLinkActivity` (`app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt`) is the sole `` holder for `https://www.appdevforall.org/device/open/project/...`. It never renders anything — it parses the URI into a `DeepLinkRequest`, checks whether a project is already loaded (`IProjectManager.getInstance().workspace`), and routes to `MainActivity` (nothing open) or the live, `singleTask` `EditorActivityKt`/`EditorHandlerActivity` (a project is open — reused via `onNewIntent`), then finishes itself. This avoids a visible flash of `MainActivity`'s real UI when the actual destination is the already-running editor. `EditorHandlerActivity` owns the "different project is open" case entirely (it has the close-confirmation dialog and the open-tab state `MainActivity` doesn't): closing runs through the existing, unmodified `confirmProjectClose()` dialog, and only once the user actually confirms does an `onDestroy()`-triggered hand-off (`PendingDeepLinkOpen`) start the new project — deliberately deferred to `onDestroy()`, not fired synchronously after `finish()`, so the new `PROJECT_PATH` can't race a `singleTask` re-delivery to the dying instance. + ## Module Structure Strategy: **layer-and-subsystem based**, not feature-by-feature. The Gradle build has ~80 modules (`settings.gradle.kts`) plus three included composite builds. `app` is the integration point; the rest are libraries it composes. From 10786045e7409e216cc7465242e2d27c8a4778d2 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 17:54:06 -0700 Subject: [PATCH 07/39] ADFA-5067 | Fix deep-link routing race in DeepLinkActivity Route on ActionContextProvider.getActivity() (tracks the live EditorHandlerActivity instance) instead of IProjectManager's workspace, which stays null for the whole duration of a Gradle sync even while EditorActivityKt is already open -- a link tapped mid-sync was mis-routed to MainActivity instead of the running editor. Found in code review of PR 1651. --- .../com/itsaky/androidide/activities/DeepLinkActivity.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt index 398e4f1779..411027f385 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt @@ -21,8 +21,8 @@ import android.app.Activity import android.content.Intent import android.os.Bundle import com.itsaky.androidide.activities.editor.EditorActivityKt +import com.itsaky.androidide.api.ActionContextProvider import com.itsaky.androidide.models.DeepLinkRequest -import com.itsaky.androidide.projects.IProjectManager /** * The sole `` holder for `https://www.appdevforall.org/device/open/project/...` App @@ -44,8 +44,12 @@ class DeepLinkActivity : Activity() { return } + // ActionContextProvider tracks the live EditorHandlerActivity instance (set in its + // onResume, cleared in onDestroy) -- this reflects "is an editor actually on screen", + // unlike IProjectManager's workspace, which stays null for the whole duration of a + // Gradle sync even while EditorActivityKt is already open and visible. val target = - if (IProjectManager.getInstance().workspace != null) { + if (ActionContextProvider.getActivity() != null) { EditorActivityKt::class.java } else { MainActivity::class.java From aea677b83c4e7653fde167c4d9995c6662249ea3 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 17:54:19 -0700 Subject: [PATCH 08/39] ADFA-5067 | Guard MainActivity's deep-link handling against recreation Only handle a deep-link request when savedInstanceState == null, and clear the DeepLinkRequest extra afterward, matching postProjectInit's existing "don't reapply on a later config-change recreate" guard. Without this, a font-scale/dark-mode/locale change or a process-death restore re-triggered handleDeepLinkRequest and redundantly relaunched EditorActivityKt. Found in code review of PR 1651. --- .../itsaky/androidide/activities/MainActivity.kt | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt index ec847d3cae..c9180ba07b 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt @@ -129,12 +129,15 @@ class MainActivity : EdgeToEdgeIDEActivity() { // Start WebServer after installation is complete startWebServer() - val deepLinkRequest = - IntentCompat.getParcelableExtra(intent, DeepLinkRequest.EXTRA_KEY, DeepLinkRequest::class.java) - if (deepLinkRequest != null) { - handleDeepLinkRequest(deepLinkRequest) - } else if (savedInstanceState == null) { - openLastProject() + if (savedInstanceState == null) { + val deepLinkRequest = + IntentCompat.getParcelableExtra(intent, DeepLinkRequest.EXTRA_KEY, DeepLinkRequest::class.java) + if (deepLinkRequest != null) { + intent.removeExtra(DeepLinkRequest.EXTRA_KEY) // don't reapply on a later config-change recreate + handleDeepLinkRequest(deepLinkRequest) + } else { + openLastProject() + } } if (FeatureFlags.isExperimentsEnabled) { From 3e8fd652c5c59809f6caa0612607f126440c83ee Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 17:54:33 -0700 Subject: [PATCH 09/39] ADFA-5067 | Prevent stacked confirm-close dialogs from dropping a deep link confirmProjectClose() now dismisses any dialog it previously showed before showing a new one. Without this, two deep links for different projects arriving in quick succession (onNewIntent can fire repeatedly on the singleTask editor activity) could stack two confirm-close dialogs; confirming either one overwrote the single PendingDeepLinkOpen.value, silently dropping whichever project the user actually confirmed opening. Found in code review of PR 1651. --- .../activities/editor/EditorHandlerActivity.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index da83b3fdb1..7340181d0d 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -29,6 +29,7 @@ import android.view.KeyEvent import android.view.View import android.view.ViewGroup.LayoutParams import android.widget.TextView +import androidx.appcompat.app.AlertDialog import androidx.collection.MutableIntObjectMap import androidx.core.content.IntentCompat import androidx.core.content.res.ResourcesCompat @@ -1814,8 +1815,16 @@ open class EditorHandlerActivity : } } + // Tracks the currently-showing confirm-close dialog so a second deep link arriving while one + // is already up (onNewIntent can fire repeatedly for a singleTask activity) replaces it + // instead of stacking a second dialog -- two stacked dialogs would let either button confirm + // PendingDeepLinkOpen.value out from under the other, silently dropping whichever project the + // user actually confirmed opening. + private var activeProjectCloseDialog: AlertDialog? = null + private fun confirmProjectClose(onClosed: (() -> Unit)? = null) { val content = contentOrNull ?: return + activeProjectCloseDialog?.dismiss() val builder = newMaterialDialogBuilder(this) builder.setTitle(string.title_confirm_project_close) builder.setMessage(string.msg_confirm_project_close) @@ -1848,7 +1857,7 @@ open class EditorHandlerActivity : } } - builder.show() + activeProjectCloseDialog = builder.show() } /** From 045aa000fdbbd86066564926b81c2885cf71dfe1 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 17:54:50 -0700 Subject: [PATCH 10/39] ADFA-5067 | Fix reserved-keyword collision in DeepLinkRequest.parse() Replace repeated whole-list segments.indexOf(keyword) lookups with a cursor-based forward scan (indexOfFrom). indexOf always returns the first occurrence in the entire path, so a project name that happened to equal "line"/"file"/"column" was mistaken for that keyword later in the path, corrupting the file/line/column split. The cursor-based scan only matches occurrences at or after the previously consumed segment, so an already-consumed segment can never be re-matched. Adds a regression test for a project literally named "line". Found in code review of PR 1651. --- .../androidide/models/DeepLinkRequest.kt | 40 ++++++++++++------- .../androidide/models/DeepLinkRequestTest.kt | 15 +++++++ 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt b/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt index b2aba058af..1e5ba45ffd 100644 --- a/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt +++ b/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt @@ -58,6 +58,19 @@ data class DeepLinkRequest( private const val SEGMENT_LINE = "line" private const val SEGMENT_COLUMN = "column" + /** First index at or after [from] holding [segment], or -1. Unlike [List.indexOf], never + * matches an already-consumed segment earlier in the path -- e.g. a project name that + * happens to equal `"line"` can't be mistaken for the `line` keyword that follows it. */ + private fun List.indexOfFrom( + from: Int, + segment: String, + ): Int { + for (i in from until size) { + if (this[i] == segment) return i + } + return -1 + } + /** * Parses a deep-link [Uri] of the form described in [DeepLinkRequest]'s docs. Returns `null` if * the URI does not contain a `project` segment followed by a name -- i.e. it isn't a deep link @@ -66,35 +79,32 @@ data class DeepLinkRequest( fun parse(uri: Uri?): DeepLinkRequest? { val segments = uri?.pathSegments ?: return null - val projectNameIdx = segments.indexOf(SEGMENT_PROJECT) + 1 - if (projectNameIdx <= 0 || projectNameIdx >= segments.size) { + val projectIdx = segments.indexOfFrom(0, SEGMENT_PROJECT) + if (projectIdx < 0 || projectIdx + 1 >= segments.size) { return null } - val projectName = segments[projectNameIdx] + val projectName = segments[projectIdx + 1] - val fileIdx = segments.indexOf(SEGMENT_FILE).takeIf { it >= 0 }?.plus(1) + val fileIdx = segments.indexOfFrom(projectIdx + 2, SEGMENT_FILE) val fileRequest = - fileIdx?.let { startIdx -> + fileIdx.takeIf { it >= 0 }?.let { fIdx -> + val startIdx = fIdx + 1 if (startIdx >= segments.size) { return@let null } + val lineIdx = segments.indexOfFrom(startIdx, SEGMENT_LINE).takeIf { it >= 0 } + val columnIdx = segments.indexOfFrom(startIdx, SEGMENT_COLUMN).takeIf { it >= 0 } + // filenames may themselves contain '/', so the filename is every segment from // `file` up to (but not including) the next recognized keyword, joined back together - val endIdx = - listOf(SEGMENT_LINE, SEGMENT_COLUMN) - .mapNotNull { keyword -> segments.indexOf(keyword).takeIf { it > startIdx } } - .minOrNull() ?: segments.size - + val endIdx = listOfNotNull(lineIdx, columnIdx).minOrNull() ?: segments.size val filePath = segments.subList(startIdx, endIdx).joinToString("/") - val lineIdx = segments.indexOf(SEGMENT_LINE).takeIf { it >= 0 }?.plus(1) - val columnIdx = segments.indexOf(SEGMENT_COLUMN).takeIf { it >= 0 }?.plus(1) - PendingFileRequest( filePath = filePath, - lineRaw = lineIdx?.let { segments.getOrNull(it) }, - columnRaw = columnIdx?.let { segments.getOrNull(it) }, + lineRaw = lineIdx?.let { segments.getOrNull(it + 1) }, + columnRaw = columnIdx?.let { segments.getOrNull(it + 1) }, ) } diff --git a/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt b/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt index 33d0d7b8d6..65367f4206 100644 --- a/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt +++ b/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt @@ -83,6 +83,21 @@ class DeepLinkRequestTest { assertEquals("1", request?.fileRequest?.lineRaw) } + @Test + fun `project name equal to a reserved keyword does not corrupt line parsing`() { + // Regression test: a project literally named "line" used to make the parser latch onto the + // project-name segment itself as the `line` keyword (the first occurrence in the whole path), + // discarding the real line/42 suffix that follows `file`. + val request = parse("https://www.appdevforall.org/device/open/project/line/file/Main.kt/line/42") + assertEquals( + DeepLinkRequest( + projectName = "line", + fileRequest = PendingFileRequest(filePath = "Main.kt", lineRaw = "42", columnRaw = null), + ), + request, + ) + } + @Test fun `malformed line and column are carried through unparsed, not rejected`() { val request = From ab4be5eb579f09f8f4f85cac5881e79d4d62cde3 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 17:55:07 -0700 Subject: [PATCH 11/39] ADFA-5067 | Close symlink escape in resolveWithinDirectory The existing guard only normalized the path lexically, so a symlink physically present inside the project directory (e.g. from a git clone, which supports symlinks) pointing outside it was never detected -- the OS would follow it at actual file-open time. Add a third layer mirroring AssetsInstallationHelper.extractZipToDir's zip-slip guard: resolve the nearest existing ancestor of the requested path to its real, on-disk path via toRealPath() and re-verify containment. Skipped when the base directory itself doesn't exist, since there's nothing on disk to symlink-escape through. Adds a regression test with a real symlink pointing outside the base directory, and a companion test that a plain file inside a real base directory still resolves. Found in code review of PR 1651. --- .../itsaky/androidide/utils/PathTraversal.kt | 34 ++++++++++++++++--- .../androidide/utils/PathTraversalTest.kt | 30 ++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/utils/PathTraversal.kt b/app/src/main/java/com/itsaky/androidide/utils/PathTraversal.kt index 99b1712f45..91852e6b18 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/PathTraversal.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/PathTraversal.kt @@ -18,6 +18,8 @@ package com.itsaky.androidide.utils import java.io.File +import java.io.IOException +import java.nio.file.Files import java.nio.file.InvalidPathException /** @@ -25,14 +27,21 @@ import java.nio.file.InvalidPathException * for attacker-controllable input (e.g. the `{filename}` segment of a deep-link URL) that must never * be allowed to read/write outside a known root directory. * - * Two layers, mirroring the zip-slip guard in + * Three layers, mirroring the zip-slip guard in * [com.itsaky.androidide.assets.AssetsInstallationHelper.extractZipToDir]: * 1. A lexical reject of `..`/a leading `/` or `\` -- cheap, catches the common case outright. * 2. Resolve + normalize against [baseDir] and verify with [java.nio.file.Path.startsWith] (not * string prefix matching, which would wrongly accept `/project` as inside `/project-evil`) -- - * this is the authoritative check: it operates on Java's own resolved path, so it isn't fooled by - * however `..` made it into the string (a literal `..` segment is the only way a path can name a - * parent directory at all, however it got decoded). + * this operates on Java's own resolved path, so it isn't fooled by however `..` made it into the + * string (a literal `..` segment is the only way a path can name a parent directory at all, + * however it got decoded). + * 3. If [baseDir] exists on disk, resolve the nearest existing ancestor of the normalized path to + * its real, on-disk path via [java.nio.file.Path.toRealPath] and re-verify containment -- layer 2 + * is purely lexical and won't catch a symlink already present inside [baseDir] (e.g. a project + * cloned with git, which supports symlinks) that points outside it. Walking up to the nearest + * *existing* ancestor (rather than the resolved path itself) handles callers resolving a path + * that doesn't exist yet. Skipped when [baseDir] itself doesn't exist -- there is nothing on disk + * to symlink-escape through, so the lexical check above is already authoritative. * * Returns `null` if [relativePath] is invalid or escapes [baseDir] -- including when it's not a * representable path at all (e.g. containing a decoded NUL byte, `Uri.pathSegments` percent-decodes @@ -50,8 +59,23 @@ fun resolveWithinDirectory( return try { val base = baseDir.toPath().toAbsolutePath().normalize() val resolved = base.resolve(relativePath).normalize() - if (!resolved.startsWith(base)) null else resolved.toFile() + if (!resolved.startsWith(base)) { + return null + } + + if (!Files.exists(base)) { + return resolved.toFile() + } + + val realBase = base.toRealPath() + var existingAncestor = resolved + while (!Files.exists(existingAncestor)) { + existingAncestor = existingAncestor.parent ?: return null + } + if (!existingAncestor.toRealPath().startsWith(realBase)) null else resolved.toFile() } catch (e: InvalidPathException) { null + } catch (e: IOException) { + null } } diff --git a/app/src/test/java/com/itsaky/androidide/utils/PathTraversalTest.kt b/app/src/test/java/com/itsaky/androidide/utils/PathTraversalTest.kt index 270321460a..d220cfcc61 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/PathTraversalTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/PathTraversalTest.kt @@ -19,13 +19,20 @@ package com.itsaky.androidide.utils import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder import java.io.File +import java.nio.file.Files class PathTraversalTest { private val baseDir = File("/project/root") private val nulCharacter = 0.toChar() + @JvmField + @Rule + val tempFolder = TemporaryFolder() + @Test fun `plain relative path resolves inside base`() { val resolved = resolveWithinDirectory(baseDir, "src/Main.kt") @@ -76,4 +83,27 @@ class PathTraversalTest { val resolved = resolveWithinDirectory(baseDir, "app/src/main/Main.kt") assertEquals(File("/project/root/app/src/main/Main.kt"), resolved) } + + @Test + fun `plain file inside a real base directory still resolves`() { + val root = tempFolder.newFolder("real-project") + File(root, "src").mkdirs() + val target = File(root, "src/Main.kt").apply { writeText("fun main() {}") } + + val resolved = resolveWithinDirectory(root, "src/Main.kt") + assertEquals(target.canonicalFile, resolved?.canonicalFile) + } + + @Test + fun `symlink inside base pointing outside it is rejected`() { + // Regression test: the lexical/normalize check alone doesn't catch a symlink physically + // present inside the project directory (e.g. from a git clone, which supports symlinks) that + // points outside it -- resolveWithinDirectory must also verify the real, on-disk path. + val root = tempFolder.newFolder("real-project") + val outside = tempFolder.newFolder("outside") + File(outside, "secret.txt").writeText("secret") + Files.createSymbolicLink(File(root, "evil").toPath(), outside.toPath()) + + assertNull(resolveWithinDirectory(root, "evil/secret.txt")) + } } From 3ad035b73f0f625422209ec1df50287037524000 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 18:11:49 -0700 Subject: [PATCH 12/39] ADFA-5067 | Sync ARCHITECTURE.md with the DeepLinkActivity routing fix The doc still described the routing check as IProjectManager.getInstance().workspace, which the prior commit in this branch replaced with ActionContextProvider.getActivity() (see "Fix deep-link routing race in DeepLinkActivity"). --- ARCHITECTURE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 18eae75b44..c0b9a20e8e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -51,7 +51,7 @@ Feature code layers as **UI → ViewModel → Repository → data source**, with **EventBus is a deliberate side-channel.** Long-running, cross-module signals (build/install lifecycle, editor events) are broadcast via GreenRobot EventBus (`@Subscribe(threadMode = ThreadMode.MAIN)`) and the `eventbus-events` module's shared event types. Treat it as the integration bus *between* subsystems; don't use it to replace a ViewModel's own state inside a single screen. -**App Links enter through a UI-less trampoline, not `MainActivity` directly.** `DeepLinkActivity` (`app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt`) is the sole `` holder for `https://www.appdevforall.org/device/open/project/...`. It never renders anything — it parses the URI into a `DeepLinkRequest`, checks whether a project is already loaded (`IProjectManager.getInstance().workspace`), and routes to `MainActivity` (nothing open) or the live, `singleTask` `EditorActivityKt`/`EditorHandlerActivity` (a project is open — reused via `onNewIntent`), then finishes itself. This avoids a visible flash of `MainActivity`'s real UI when the actual destination is the already-running editor. `EditorHandlerActivity` owns the "different project is open" case entirely (it has the close-confirmation dialog and the open-tab state `MainActivity` doesn't): closing runs through the existing, unmodified `confirmProjectClose()` dialog, and only once the user actually confirms does an `onDestroy()`-triggered hand-off (`PendingDeepLinkOpen`) start the new project — deliberately deferred to `onDestroy()`, not fired synchronously after `finish()`, so the new `PROJECT_PATH` can't race a `singleTask` re-delivery to the dying instance. +**App Links enter through a UI-less trampoline, not `MainActivity` directly.** `DeepLinkActivity` (`app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt`) is the sole `` holder for `https://www.appdevforall.org/device/open/project/...`. It never renders anything — it parses the URI into a `DeepLinkRequest`, checks whether an editor is already on screen (`ActionContextProvider.getActivity()`, the live `EditorHandlerActivity` tracker -- not `IProjectManager`'s `workspace`, which stays null for the whole duration of a Gradle sync even while the editor is already open), and routes to `MainActivity` (nothing open) or the live, `singleTask` `EditorActivityKt`/`EditorHandlerActivity` (a project is open — reused via `onNewIntent`), then finishes itself. This avoids a visible flash of `MainActivity`'s real UI when the actual destination is the already-running editor. `EditorHandlerActivity` owns the "different project is open" case entirely (it has the close-confirmation dialog and the open-tab state `MainActivity` doesn't): closing runs through the existing, unmodified `confirmProjectClose()` dialog, and only once the user actually confirms does an `onDestroy()`-triggered hand-off (`PendingDeepLinkOpen`) start the new project — deliberately deferred to `onDestroy()`, not fired synchronously after `finish()`, so the new `PROJECT_PATH` can't race a `singleTask` re-delivery to the dying instance. ## Module Structure From 0f5b6829bb0db141c03aafa96713f29c50bf8d49 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 18:20:03 -0700 Subject: [PATCH 13/39] ADFA-5067 | Acquire RecentProjectDao through Koin, not a raw DB call recordProjectOpenedBookkeeping() called RecentProjectRoomDatabase.getDatabase(context, scope) directly instead of the RecentProjectDao already wired into Koin's coreModule (the same instance MainViewModel/RecentProjectsViewModel inject) -- a second, DI-bypassing acquisition path for the same singleton database, against ADR 0001/0006's "persistence is provided through Koin". recordProjectOpenedBookkeeping() now takes a RecentProjectDao parameter; both call sites (MainActivity, EditorHandlerActivity) inject it the same way they already inject analyticsManager. Found in architecture review of PR 1651. --- .../itsaky/androidide/activities/MainActivity.kt | 4 +++- .../activities/editor/EditorHandlerActivity.kt | 4 +++- .../androidide/utils/ProjectOpenBookkeeping.kt | 14 ++++++++------ 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt index c9180ba07b..51a865d588 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt @@ -51,6 +51,7 @@ import com.itsaky.androidide.models.PendingFileRequest import com.itsaky.androidide.preferences.internal.GeneralPreferences import com.itsaky.androidide.resources.R.string import com.itsaky.androidide.roomData.recentproject.RecentProject +import com.itsaky.androidide.roomData.recentproject.RecentProjectDao import com.itsaky.androidide.shortcuts.IdeShortcutActions import com.itsaky.androidide.shortcuts.ShortcutContext import com.itsaky.androidide.shortcuts.ShortcutExecutionContext @@ -91,6 +92,7 @@ class MainActivity : EdgeToEdgeIDEActivity() { @Suppress("ktlint:standard:backing-property-naming") private var _binding: ActivityMainBinding? = null private val analyticsManager: IAnalyticsManager by inject() + private val recentProjectDao: RecentProjectDao by inject() private var feedbackButtonManager: FeedbackButtonManager? = null private var webServer: WebServer? = null private val shortcutManager by lazy { ShortcutManager(applicationContext) } @@ -430,7 +432,7 @@ class MainActivity : EdgeToEdgeIDEActivity() { hasTemplateIssues: Boolean = false, pendingFileRequest: PendingFileRequest? = null, ) { - recordProjectOpenedBookkeeping(applicationContext, root, project, analyticsManager) + recordProjectOpenedBookkeeping(recentProjectDao, root, project, analyticsManager) if (isFinishing) { return diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index 7340181d0d..86478a1191 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -94,6 +94,7 @@ import com.itsaky.androidide.preferences.internal.EditorPreferences import com.itsaky.androidide.projects.IProjectManager import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.projects.builder.BuildResult +import com.itsaky.androidide.roomData.recentproject.RecentProjectDao import com.itsaky.androidide.shortcuts.IdeShortcutActions import com.itsaky.androidide.shortcuts.ShortcutContext import com.itsaky.androidide.shortcuts.ShortcutExecutionContext @@ -174,6 +175,7 @@ open class EditorHandlerActivity : private val shortcutManager by lazy { ShortcutManager(applicationContext) } private val analyticsManager: IAnalyticsManager by inject() + private val recentProjectDao: RecentProjectDao by inject() private var pluginEditorProvider: EditorProviderImpl? = null @@ -357,7 +359,7 @@ open class EditorHandlerActivity : PendingDeepLinkOpen.value = null val root = File(pending.projectRoot) val ctx = applicationContext - recordProjectOpenedBookkeeping(ctx, root, project = null, analyticsManager = analyticsManager) + recordProjectOpenedBookkeeping(recentProjectDao, root, project = null, analyticsManager = analyticsManager) ctx.startActivity( Intent(ctx, EditorActivityKt::class.java).apply { putExtra("PROJECT_PATH", pending.projectRoot) diff --git a/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt b/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt index fb178344fe..232f6dbc96 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt @@ -17,14 +17,13 @@ package com.itsaky.androidide.utils -import android.content.Context import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.lifecycleScope import com.itsaky.androidide.analytics.IAnalyticsManager import com.itsaky.androidide.preferences.internal.GeneralPreferences import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.roomData.recentproject.RecentProject -import com.itsaky.androidide.roomData.recentproject.RecentProjectRoomDatabase +import com.itsaky.androidide.roomData.recentproject.RecentProjectDao import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.io.File @@ -37,11 +36,15 @@ import java.io.File * `openProject` entirely (see * [com.itsaky.androidide.activities.editor.EditorHandlerActivity.onDestroy]). * + * [recentProjectDao] is the caller's Koin-provided instance (`by inject()`), the same one + * `di/AppModule.kt` wires into `MainViewModel`/`RecentProjectsViewModel` -- per ADR 0001/0006, + * persistence is always acquired through Koin, never by re-deriving the database directly. + * * Uses [ProcessLifecycleOwner]'s scope rather than a per-activity one, since this can run from an * activity's `onDestroy()` after its own `lifecycleScope` has already been cancelled. */ fun recordProjectOpenedBookkeeping( - context: Context, + recentProjectDao: RecentProjectDao, root: File, project: RecentProject?, analyticsManager: IAnalyticsManager, @@ -49,8 +52,7 @@ fun recordProjectOpenedBookkeeping( ProjectManagerImpl.getInstance().projectPath = root.absolutePath GeneralPreferences.lastOpenedProject = root.absolutePath - val scope = ProcessLifecycleOwner.get().lifecycleScope - scope.launch(Dispatchers.IO) { + ProcessLifecycleOwner.get().lifecycleScope.launch(Dispatchers.IO) { val location = root.absolutePath val recentProject = project ?: RecentProject( @@ -59,7 +61,7 @@ fun recordProjectOpenedBookkeeping( createdAt = getCreatedTime(location).toString(), lastModified = getLastModifiedTime(location).toString(), ) - RecentProjectRoomDatabase.getDatabase(context, scope).recentProjectDao().insert(recentProject) + recentProjectDao.insert(recentProject) } analyticsManager.trackProjectOpened(root.absolutePath) From ee35586dc4df7949a91e27cfa1d171d7922c9bbf Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 18:28:29 -0700 Subject: [PATCH 14/39] ADFA-5067 | Show a Toast when a deep link fails to parse DeepLinkActivity silently finished on an unparseable URI with no feedback to the user. Uses a Toast rather than the existing flashError helper -- this activity finishes immediately after, tearing down its window before a view-based Flashbar could ever render. Also adds msg_deeplink_scan_failed, used by the next commit. Addressed from inline PR review comments. --- .../com/itsaky/androidide/activities/DeepLinkActivity.kt | 5 +++++ resources/src/main/res/values/strings.xml | 2 ++ 2 files changed, 7 insertions(+) diff --git a/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt index 411027f385..f0062bc695 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt @@ -20,9 +20,11 @@ package com.itsaky.androidide.activities import android.app.Activity import android.content.Intent import android.os.Bundle +import android.widget.Toast import com.itsaky.androidide.activities.editor.EditorActivityKt import com.itsaky.androidide.api.ActionContextProvider import com.itsaky.androidide.models.DeepLinkRequest +import com.itsaky.androidide.resources.R.string /** * The sole `` holder for `https://www.appdevforall.org/device/open/project/...` App @@ -40,6 +42,9 @@ class DeepLinkActivity : Activity() { val request = DeepLinkRequest.parse(intent?.data) if (request == null) { + // A Toast, not flashError -- this activity finishes immediately below, tearing down its + // window before a view-based Flashbar could ever render. + Toast.makeText(this, getString(string.msg_deeplink_invalid_link), Toast.LENGTH_LONG).show() finish() return } diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 513e71d582..e4934bc86b 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -135,10 +135,12 @@ Do you want to open the last opened project? The project was:\n%s Close this project Last opened project doesn\'t exist. + This link could not be opened. No project named \"%s\" was found. File \"%s\" was not found in the project. \"%s\" is not a valid line number. \"%s\" is not a valid column number. + Could not scan projects for this link. Create new project Open a saved project Delete a saved project From 4196a342e7c3428e3e5f370e529bbb5eadc9e9d1 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 18:28:44 -0700 Subject: [PATCH 15/39] ADFA-5067 | Handle SecurityException scanning projects for a deep link findValidProjects() can throw SecurityException (e.g. a storage permission revoked mid-session) inside the IO coroutine launched by MainActivity.handleDeepLinkRequest and EditorHandlerActivity.onNewIntent. Uncaught, that would crash the coroutine's scope instead of just failing this one deep link. CancellationException is rethrown; other failures are logged and reported to the user on the main thread. Addressed from inline PR review comments. --- .../com/itsaky/androidide/activities/MainActivity.kt | 12 +++++++++++- .../activities/editor/EditorHandlerActivity.kt | 11 ++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt index 51a865d588..c643b2850c 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt @@ -76,6 +76,7 @@ import com.itsaky.androidide.viewmodel.MainViewModel.Companion.SCREEN_SAVED_PROJ import com.itsaky.androidide.viewmodel.MainViewModel.Companion.SCREEN_TEMPLATE_DETAILS import com.itsaky.androidide.viewmodel.MainViewModel.Companion.SCREEN_TEMPLATE_LIST import com.itsaky.androidide.viewmodel.MainViewModel.Companion.TOOLTIPS_WEB_VIEW +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -489,7 +490,16 @@ class MainActivity : EdgeToEdgeIDEActivity() { */ private fun handleDeepLinkRequest(request: DeepLinkRequest) { lifecycleScope.launch(Dispatchers.IO) { - val projectDir = findValidProjects(Environment.PROJECTS_DIR).find { it.name == request.projectName } + val projectDir = + try { + findValidProjects(Environment.PROJECTS_DIR).find { it.name == request.projectName } + } catch (e: CancellationException) { + throw e + } catch (e: SecurityException) { + log.error("Failed to scan {} for deep link", Environment.PROJECTS_DIR, e) + withContext(Dispatchers.Main) { flashError(getString(string.msg_deeplink_scan_failed)) } + return@launch + } withContext(Dispatchers.Main) { if (projectDir == null) { flashError(getString(string.msg_deeplink_project_not_found, request.projectName)) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index 86478a1191..940beb93f0 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -1881,7 +1881,16 @@ open class EditorHandlerActivity : ?: return lifecycleScope.launch(Dispatchers.IO) { - val projectDir = findValidProjects(Environment.PROJECTS_DIR).find { it.name == request.projectName } + val projectDir = + try { + findValidProjects(Environment.PROJECTS_DIR).find { it.name == request.projectName } + } catch (e: CancellationException) { + throw e + } catch (e: SecurityException) { + Log.e("EditorHandlerActivity", "Failed to scan ${Environment.PROJECTS_DIR} for deep link", e) + withContext(Dispatchers.Main) { flashError(getString(string.msg_deeplink_scan_failed)) } + return@launch + } withContext(Dispatchers.Main) { if (projectDir == null) { flashError(getString(string.msg_deeplink_project_not_found, request.projectName)) From cc74e65c4ff013faad0e6ac8d4f54f60f23a2451 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 18:29:02 -0700 Subject: [PATCH 16/39] ADFA-5067 | Don't let a Recents-write failure crash the app recordProjectOpenedBookkeeping()'s recentProjectDao.insert() ran with no error handling on ProcessLifecycleOwner's app-wide scope -- a transient Room/SQLite failure would crash the whole process instead of just failing to record one Recents entry. CancellationException is rethrown; other failures are logged. The in-memory project-open state (ProjectManagerImpl.projectPath, GeneralPreferences.lastOpenedProject) is set synchronously before the coroutine launches, so it's unaffected either way. Addressed from inline PR review comments. --- .../androidide/utils/ProjectOpenBookkeeping.kt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt b/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt index 232f6dbc96..ee37072e6f 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt @@ -24,10 +24,14 @@ import com.itsaky.androidide.preferences.internal.GeneralPreferences import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.roomData.recentproject.RecentProject import com.itsaky.androidide.roomData.recentproject.RecentProjectDao +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory import java.io.File +private val log = LoggerFactory.getLogger("ProjectOpenBookkeeping") + /** * Marks [root] as the currently open project (singleton state + last-opened pref), records it in * Recents, and tracks the open in analytics -- the same bookkeeping @@ -61,7 +65,16 @@ fun recordProjectOpenedBookkeeping( createdAt = getCreatedTime(location).toString(), lastModified = getLastModifiedTime(location).toString(), ) - recentProjectDao.insert(recentProject) + try { + recentProjectDao.insert(recentProject) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // This runs on ProcessLifecycleOwner's app-wide scope -- an uncaught exception here would + // crash the whole process, not just fail to record one Recents entry. The project-open + // state above is already set synchronously, so a Recents-write failure doesn't affect it. + log.warn("Failed to record opened project '{}' in Recents", recentProject.name, e) + } } analyticsManager.trackProjectOpened(root.absolutePath) From b68b50a31351a06a3395fb9850ae8f527c7a437f Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 18:29:17 -0700 Subject: [PATCH 17/39] ADFA-5067 | Name deliberately-unused catch bindings "_" resolveWithinDirectory()'s InvalidPathException/IOException catches intentionally discard the exception (the caller only needs null-or-not for attacker-controllable input) -- name the bindings "_" rather than "e" to make that explicit instead of reading as an accidentally swallowed exception. Addressed from inline PR review comments. --- .../main/java/com/itsaky/androidide/utils/PathTraversal.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/utils/PathTraversal.kt b/app/src/main/java/com/itsaky/androidide/utils/PathTraversal.kt index 91852e6b18..2f77d47964 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/PathTraversal.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/PathTraversal.kt @@ -73,9 +73,9 @@ fun resolveWithinDirectory( existingAncestor = existingAncestor.parent ?: return null } if (!existingAncestor.toRealPath().startsWith(realBase)) null else resolved.toFile() - } catch (e: InvalidPathException) { + } catch (_: InvalidPathException) { null - } catch (e: IOException) { + } catch (_: IOException) { null } } From 45d94cd6e9976dbd252426eabd94ada536c287b3 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 18:29:33 -0700 Subject: [PATCH 18/39] ADFA-5067 | Add more reserved-keyword-collision regression cases Two more cases for the indexOfFrom cursor-scan fix (045aa000f): a project named "line" with no line suffix, and a project named "file". Both already passed before this commit -- this only adds coverage. A third proposed case, a project's file *path* itself starting with a segment literally named "line" (e.g. .../file/line/Main.kt), is not addressable by any segment-based fix: with no delimiter between the optional line/column suffix and the preceding filename, "the file path happens to start with 'line'" and "there's a real line/{n} suffix" are the same shape at the segment level. Not tested here -- a real fix would need a schema change (e.g. line/column as query parameters). Addressed from inline PR review comments. --- .../androidide/models/DeepLinkRequestTest.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt b/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt index 65367f4206..91466ae5cc 100644 --- a/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt +++ b/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt @@ -98,6 +98,30 @@ class DeepLinkRequestTest { ) } + @Test + fun `project name equal to a reserved keyword with no line suffix yields no line`() { + val request = parse("https://www.appdevforall.org/device/open/project/line/file/Main.kt") + assertEquals( + DeepLinkRequest( + projectName = "line", + fileRequest = PendingFileRequest(filePath = "Main.kt", lineRaw = null, columnRaw = null), + ), + request, + ) + } + + @Test + fun `project name equal to the file keyword does not corrupt the file lookup`() { + val request = parse("https://www.appdevforall.org/device/open/project/file/file/Main.kt") + assertEquals( + DeepLinkRequest( + projectName = "file", + fileRequest = PendingFileRequest(filePath = "Main.kt", lineRaw = null, columnRaw = null), + ), + request, + ) + } + @Test fun `malformed line and column are carried through unparsed, not rejected`() { val request = From a45147070e6533af5eda43881bd119efdc3896a8 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 18:42:31 -0700 Subject: [PATCH 19/39] ADFA-5067 | Fix three deep-link close/open correctness gaps Three related fixes in EditorHandlerActivity, all in the deep-link close-then-reopen path: - confirmProjectClose(): a generation token now guards the "Save and close" async callback. saveAllAsync completes asynchronously, so an older deep-link request's callback could still fire (contentOrNull stays non-null until onStop()/onDestroy(), well after finish()) after a newer request's dialog was already answered, overwriting PendingDeepLinkOpen.value with the superseded project. Only the request owning the current token is allowed to act. - Same callback no longer closes files unconditionally after "Save and close": saveAll()'s return value is gradleSaved (whether a build file changed), not "everything saved successfully". Now checks hasUnsavedFiles() and reports a failure instead of silently discarding unsaved changes on a failed write. - applyDeepLinkFileRequest(): require file.isFile, not just file.exists() -- a deep link resolving to an existing directory was passed straight to openFileAndSelect(). Addressed from inline PR review comments. --- .../editor/EditorHandlerActivity.kt | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index 940beb93f0..83e11d9272 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -1824,9 +1824,22 @@ open class EditorHandlerActivity : // user actually confirmed opening. private var activeProjectCloseDialog: AlertDialog? = null + // Identifies the most recent deep-link-triggered close request (confirmProjectClose calls with + // a non-null onClosed). "Save and close" runs saveAllAsync asynchronously, so an older request's + // completion callback can still fire after a newer request's dialog has already been answered -- + // contentOrNull only turns null once onStop()/onDestroy() runs, well after finish() is called. + // Without this token, that late callback would overwrite PendingDeepLinkOpen.value with the + // superseded project. Only the request that owns the current token is allowed to act. + private var currentDeepLinkCloseToken: Any? = null + private fun confirmProjectClose(onClosed: (() -> Unit)? = null) { val content = contentOrNull ?: return activeProjectCloseDialog?.dismiss() + + val ownToken = onClosed?.let { Any().also { token -> currentDeepLinkCloseToken = token } } + + fun isStillCurrent() = onClosed == null || currentDeepLinkCloseToken === ownToken + val builder = newMaterialDialogBuilder(this) builder.setTitle(string.title_confirm_project_close) builder.setMessage(string.msg_confirm_project_close) @@ -1836,6 +1849,7 @@ open class EditorHandlerActivity : // OPTION 1: Close without saving builder.setNeutralButton(string.close_without_saving) { dialog, _ -> dialog.dismiss() + if (!isStillCurrent()) return@setNeutralButton for (i in 0 until editorViewModel.getOpenedFileCount()) { (content.editorContainer.getChildAt(i) as? CodeEditorView)?.editor?.markUnmodified() @@ -1850,7 +1864,14 @@ open class EditorHandlerActivity : saveAllAsync(notify = false) { runOnUiThread { - if (contentOrNull == null) return@runOnUiThread + if (contentOrNull == null || !isStillCurrent()) return@runOnUiThread + // saveAll()'s return value is gradleSaved (whether a build file changed), not + // "everything saved successfully" -- check actual editor state instead, so a + // failed write (disk full, permission) doesn't silently discard unsaved changes. + if (hasUnsavedFiles()) { + flashError(string.save_failed) + return@runOnUiThread + } performCloseAllFiles(manualFinish = true, onClosed = onClosed) } recentProjectsViewModel.updateProjectModifiedDate( @@ -1941,7 +1962,7 @@ open class EditorHandlerActivity : private fun applyDeepLinkFileRequest(request: PendingFileRequest) { val projectDir = File(IProjectManager.getInstance().projectDirPath) val file = resolveWithinDirectory(projectDir, request.filePath) - if (file == null || !file.exists()) { + if (file == null || !file.isFile) { flashError(getString(string.msg_deeplink_file_not_found, request.filePath)) return } From df705c9289d29057c51153ebc02e045a6162dba0 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 18:42:49 -0700 Subject: [PATCH 20/39] ADFA-5067 | Match line/column from the end of the path, not the start The previous fix (045aa000f) searched for the line/column keywords forward from just after `file`, which still mismatched a file path that legitimately contains "line" or "column" as an early segment (e.g. a directory named "line") when a real trailing line/{n} suffix also follows it -- the forward search would still latch onto the first, coincidental occurrence. line/column are trailing modifiers, so match them from the end of the path backward instead: check for "column" immediately before the last segment, then "line" in whatever remains. This correctly keeps an early, coincidental "line"/"column" segment as part of the filename as long as a real trailing pair follows it. The one shape still unresolvable: a file path whose entire content is just the keyword plus one segment, with nothing else following (e.g. `file/line/Main.kt` alone) -- indistinguishable from a real line suffix with no delimiter in this URL scheme; documented as a known limitation with a locked-in test rather than silently misbehaving. Addressed from inline PR review comments. --- .../androidide/models/DeepLinkRequest.kt | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt b/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt index 1e5ba45ffd..18e001608a 100644 --- a/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt +++ b/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt @@ -93,12 +93,26 @@ data class DeepLinkRequest( return@let null } - val lineIdx = segments.indexOfFrom(startIdx, SEGMENT_LINE).takeIf { it >= 0 } - val columnIdx = segments.indexOfFrom(startIdx, SEGMENT_COLUMN).takeIf { it >= 0 } + // line/column are trailing modifiers, so -- unlike the project/file lookup above -- + // they're matched from the END of the path backward (column first, then line in + // whatever remains), never by searching for the keyword's first occurrence. That + // makes a literal "line"/"column" segment earlier in the file path (e.g. a directory + // named "line") part of the filename rather than misread as metadata, as long as a + // real trailing pair follows it. The one shape this can't resolve: a file path whose + // *entire* content is just "line"/"column" plus one more segment, with nothing else + // following -- e.g. `file/line/Main.kt` alone -- is indistinguishable from an actual + // line suffix; this URL scheme has no delimiter to tell the two apart, so it's read + // as the keyword (existing behavior, unchanged). + var endIdx = segments.size + val columnIdx = + (endIdx - 2) + .takeIf { it >= startIdx && segments[it] == SEGMENT_COLUMN } + ?.also { endIdx = it } + val lineIdx = + (endIdx - 2) + .takeIf { it >= startIdx && segments[it] == SEGMENT_LINE } + ?.also { endIdx = it } - // filenames may themselves contain '/', so the filename is every segment from - // `file` up to (but not including) the next recognized keyword, joined back together - val endIdx = listOfNotNull(lineIdx, columnIdx).minOrNull() ?: segments.size val filePath = segments.subList(startIdx, endIdx).joinToString("/") PendingFileRequest( From de0e9e86d5a3eb6d9569540860932fa985007a07 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 18:43:05 -0700 Subject: [PATCH 21/39] ADFA-5067 | Add embedded-keyword regression tests; use Truth in this file Adds regression tests for the end-anchored line/column matching (df705c9): a file path segment literally named "line" or "column" is now preserved when a real trailing line/column suffix follows it, plus a test locking in the one remaining unresolvable shape (documented in the previous commit) so a future change doesn't alter it silently. Also converts this file's assertions from raw JUnit to Google Truth, per ARCHITECTURE.md's testing guidelines -- Truth is already available to :app's test source set transitively via testing:unit, so this is a same-file, no-build-config-change cleanup. Addressed from inline PR review comments. --- .../androidide/models/DeepLinkRequestTest.kt | 155 ++++++++++++------ 1 file changed, 101 insertions(+), 54 deletions(-) diff --git a/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt b/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt index 91466ae5cc..66fe0796de 100644 --- a/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt +++ b/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt @@ -18,8 +18,7 @@ package com.itsaky.androidide.models import android.net.Uri -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull +import com.google.common.truth.Truth.assertThat import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -31,31 +30,31 @@ class DeepLinkRequestTest { @Test fun `project only`() { val request = parse("https://www.appdevforall.org/device/open/project/MyApp") - assertEquals(DeepLinkRequest(projectName = "MyApp"), request) + assertThat(request).isEqualTo(DeepLinkRequest(projectName = "MyApp")) } @Test fun `project and file`() { val request = parse("https://www.appdevforall.org/device/open/project/MyApp/file/Main.kt") - assertEquals( - DeepLinkRequest( - projectName = "MyApp", - fileRequest = PendingFileRequest(filePath = "Main.kt", lineRaw = null, columnRaw = null), - ), - request, - ) + assertThat(request) + .isEqualTo( + DeepLinkRequest( + projectName = "MyApp", + fileRequest = PendingFileRequest(filePath = "Main.kt", lineRaw = null, columnRaw = null), + ), + ) } @Test fun `project, file, and line`() { val request = parse("https://www.appdevforall.org/device/open/project/MyApp/file/Main.kt/line/42") - assertEquals( - DeepLinkRequest( - projectName = "MyApp", - fileRequest = PendingFileRequest(filePath = "Main.kt", lineRaw = "42", columnRaw = null), - ), - request, - ) + assertThat(request) + .isEqualTo( + DeepLinkRequest( + projectName = "MyApp", + fileRequest = PendingFileRequest(filePath = "Main.kt", lineRaw = "42", columnRaw = null), + ), + ) } @Test @@ -64,13 +63,13 @@ class DeepLinkRequestTest { parse( "https://www.appdevforall.org/device/open/project/MyApp/file/Main.kt/line/42/column/7", ) - assertEquals( - DeepLinkRequest( - projectName = "MyApp", - fileRequest = PendingFileRequest(filePath = "Main.kt", lineRaw = "42", columnRaw = "7"), - ), - request, - ) + assertThat(request) + .isEqualTo( + DeepLinkRequest( + projectName = "MyApp", + fileRequest = PendingFileRequest(filePath = "Main.kt", lineRaw = "42", columnRaw = "7"), + ), + ) } @Test @@ -79,8 +78,8 @@ class DeepLinkRequestTest { parse( "https://www.appdevforall.org/device/open/project/MyApp/file/app/src/main/Main.kt/line/1", ) - assertEquals("app/src/main/Main.kt", request?.fileRequest?.filePath) - assertEquals("1", request?.fileRequest?.lineRaw) + assertThat(request?.fileRequest?.filePath).isEqualTo("app/src/main/Main.kt") + assertThat(request?.fileRequest?.lineRaw).isEqualTo("1") } @Test @@ -89,37 +88,85 @@ class DeepLinkRequestTest { // project-name segment itself as the `line` keyword (the first occurrence in the whole path), // discarding the real line/42 suffix that follows `file`. val request = parse("https://www.appdevforall.org/device/open/project/line/file/Main.kt/line/42") - assertEquals( - DeepLinkRequest( - projectName = "line", - fileRequest = PendingFileRequest(filePath = "Main.kt", lineRaw = "42", columnRaw = null), - ), - request, - ) + assertThat(request) + .isEqualTo( + DeepLinkRequest( + projectName = "line", + fileRequest = PendingFileRequest(filePath = "Main.kt", lineRaw = "42", columnRaw = null), + ), + ) } @Test fun `project name equal to a reserved keyword with no line suffix yields no line`() { val request = parse("https://www.appdevforall.org/device/open/project/line/file/Main.kt") - assertEquals( - DeepLinkRequest( - projectName = "line", - fileRequest = PendingFileRequest(filePath = "Main.kt", lineRaw = null, columnRaw = null), - ), - request, - ) + assertThat(request) + .isEqualTo( + DeepLinkRequest( + projectName = "line", + fileRequest = PendingFileRequest(filePath = "Main.kt", lineRaw = null, columnRaw = null), + ), + ) } @Test fun `project name equal to the file keyword does not corrupt the file lookup`() { val request = parse("https://www.appdevforall.org/device/open/project/file/file/Main.kt") - assertEquals( - DeepLinkRequest( - projectName = "file", - fileRequest = PendingFileRequest(filePath = "Main.kt", lineRaw = null, columnRaw = null), - ), - request, - ) + assertThat(request) + .isEqualTo( + DeepLinkRequest( + projectName = "file", + fileRequest = PendingFileRequest(filePath = "Main.kt", lineRaw = null, columnRaw = null), + ), + ) + } + + @Test + fun `a file path segment literally named 'line' is preserved when a real line suffix follows`() { + // Regression test: line/column are now matched from the end of the path backward, not by the + // keyword's first occurrence -- so a directory genuinely named "line" earlier in the file path + // is kept as part of the filename as long as a real trailing line/{n} pair follows it. + val request = + parse("https://www.appdevforall.org/device/open/project/MyApp/file/line/Main.kt/line/42") + assertThat(request) + .isEqualTo( + DeepLinkRequest( + projectName = "MyApp", + fileRequest = PendingFileRequest(filePath = "line/Main.kt", lineRaw = "42", columnRaw = null), + ), + ) + } + + @Test + fun `a file path segment literally named 'column' is preserved when a real trailing pair follows`() { + val request = + parse( + "https://www.appdevforall.org/device/open/project/MyApp/file/column/Main.kt/line/1/column/7", + ) + assertThat(request) + .isEqualTo( + DeepLinkRequest( + projectName = "MyApp", + fileRequest = PendingFileRequest(filePath = "column/Main.kt", lineRaw = "1", columnRaw = "7"), + ), + ) + } + + @Test + fun `a file path that is only 'line' plus one segment is read as the keyword -- known limitation`() { + // Documents, rather than fixes, a case the previous test's approach can't resolve: with + // nothing else in the path, `file/line/Main.kt` is structurally identical to a real line + // suffix -- there's no delimiter in this URL scheme to tell "a directory named line" apart + // from "the line keyword" when it's the only content after `file`. Locking in current + // behavior so a future change doesn't alter it silently. + val request = parse("https://www.appdevforall.org/device/open/project/MyApp/file/line/Main.kt") + assertThat(request) + .isEqualTo( + DeepLinkRequest( + projectName = "MyApp", + fileRequest = PendingFileRequest(filePath = "", lineRaw = "Main.kt", columnRaw = null), + ), + ) } @Test @@ -128,29 +175,29 @@ class DeepLinkRequestTest { parse( "https://www.appdevforall.org/device/open/project/MyApp/file/Main.kt/line/abc/column/xyz", ) - assertEquals("abc", request?.fileRequest?.lineRaw) - assertEquals("xyz", request?.fileRequest?.columnRaw) + assertThat(request?.fileRequest?.lineRaw).isEqualTo("abc") + assertThat(request?.fileRequest?.columnRaw).isEqualTo("xyz") } @Test fun `missing project segment yields null`() { - assertNull(parse("https://www.appdevforall.org/device/open/MyApp")) + assertThat(parse("https://www.appdevforall.org/device/open/MyApp")).isNull() } @Test fun `project segment with no name yields null`() { - assertNull(parse("https://www.appdevforall.org/device/open/project")) - assertNull(parse("https://www.appdevforall.org/device/open/project/")) + assertThat(parse("https://www.appdevforall.org/device/open/project")).isNull() + assertThat(parse("https://www.appdevforall.org/device/open/project/")).isNull() } @Test fun `file keyword with no name yields no file request`() { val request = parse("https://www.appdevforall.org/device/open/project/MyApp/file") - assertEquals(DeepLinkRequest(projectName = "MyApp", fileRequest = null), request) + assertThat(request).isEqualTo(DeepLinkRequest(projectName = "MyApp", fileRequest = null)) } @Test fun `null uri yields null`() { - assertNull(DeepLinkRequest.parse(null)) + assertThat(DeepLinkRequest.parse(null)).isNull() } } From 86c1f7025d192a44a5160f0dc4bd0235dc0b778f Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 18:47:55 -0700 Subject: [PATCH 22/39] ADFA-5067 | Block a new confirm-close while a save-and-close is in flight The generation-token fix (a451470) stops a stale "Save and close" completion from overwriting PendingDeepLinkOpen, but doesn't stop a second request from doing real damage while the first is still running: saveAllAsync iterates and mutates editorViewModel's file/editor state on a background coroutine, and "Close without saving" calls performCloseAllFiles synchronously on the main thread against that same state -- a second deep link answered with "Close without saving" while an earlier one's save is still in flight would race that save. confirmProjectClose() now drops a new request outright while closeInProgress is true (set for the duration of the async save), rather than showing a dialog whose buttons could trigger a concurrent mutation. This also protects the ordinary manual "close project" path against racing a deep-link-triggered save. Addressed from inline PR review comments. --- .../activities/editor/EditorHandlerActivity.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index 83e11d9272..b2d588bbf2 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -1832,8 +1832,20 @@ open class EditorHandlerActivity : // superseded project. Only the request that owns the current token is allowed to act. private var currentDeepLinkCloseToken: Any? = null + // True from the moment "Save and close" starts saveAllAsync until its callback runs. saveAllAsync + // iterates and mutates editorViewModel's file/editor state on a background coroutine -- a second + // confirmProjectClose answered with "Close without saving" while that's in flight would call + // performCloseAllFiles synchronously on the main thread against the same state, racing the save. + // The token above only stops a stale *result* from winning; it can't stop this concurrent access. + private var closeInProgress = false + private fun confirmProjectClose(onClosed: (() -> Unit)? = null) { val content = contentOrNull ?: return + if (closeInProgress) { + // A save-and-close is still writing files; dropping this request instead of showing a new + // dialog avoids racing that write. The user can retry once it finishes. + return + } activeProjectCloseDialog?.dismiss() val ownToken = onClosed?.let { Any().also { token -> currentDeepLinkCloseToken = token } } @@ -1862,8 +1874,10 @@ open class EditorHandlerActivity : builder.setPositiveButton(string.save_and_close) { dialog, _ -> dialog.dismiss() + closeInProgress = true saveAllAsync(notify = false) { runOnUiThread { + closeInProgress = false if (contentOrNull == null || !isStillCurrent()) return@runOnUiThread // saveAll()'s return value is gradleSaved (whether a build file changed), not // "everything saved successfully" -- check actual editor state instead, so a From 9741df79c3da0d9ec53a3bbd773f9a571f7f9408 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 19:05:51 -0700 Subject: [PATCH 23/39] ADFA-5067 | Remove dead saveProjectToRecents(); Koin-provide PendingDeepLinkOpen Two small cleanups deferred from the original code review: - MainViewModel.saveProjectToRecents() has had zero callers since the deep-link work replaced it with recordProjectOpenedBookkeeping() -- delete it along with the now-unused RecentProjectDao constructor parameter it existed only to serve. - PendingDeepLinkOpen was a hand-rolled Kotlin `object` singleton, against ADR 0006 ("no hand-rolled singletons -- prefer Koin"). Now a Koin-provided `single`, injected into EditorHandlerActivity the same way as analyticsManager/recentProjectDao. Same one-process-wide instance either way; this just keeps it substitutable in tests and out of the pattern the ADR asks new code to avoid. AppModule.kt's diff also reformats the whole file to tabs -- it wasn't previously tab-indented, and editing it at all pulls the whole file under the Spotless ratchet (file-level, not line-level). Addressed from deferred code-review findings. --- .../editor/EditorHandlerActivity.kt | 5 +- .../deeplink/PendingDeepLinkOpen.kt | 6 +- .../com/itsaky/androidide/di/AppModule.kt | 32 ++-- .../androidide/viewmodel/MainViewModel.kt | 155 ++++++++---------- 4 files changed, 94 insertions(+), 104 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index b2d588bbf2..ffa0855551 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -176,6 +176,7 @@ open class EditorHandlerActivity : private val analyticsManager: IAnalyticsManager by inject() private val recentProjectDao: RecentProjectDao by inject() + private val pendingDeepLinkOpen: PendingDeepLinkOpen by inject() private var pluginEditorProvider: EditorProviderImpl? = null @@ -355,8 +356,8 @@ open class EditorHandlerActivity : // rather than firing startActivity() synchronously right after finish(), because the two calls // racing could otherwise have the new PROJECT_PATH redelivered to this dying instance via // onNewIntent (which never reads it) instead of a genuinely new instance's onCreate. - PendingDeepLinkOpen.value?.let { pending -> - PendingDeepLinkOpen.value = null + pendingDeepLinkOpen.value?.let { pending -> + pendingDeepLinkOpen.value = null val root = File(pending.projectRoot) val ctx = applicationContext recordProjectOpenedBookkeeping(recentProjectDao, root, project = null, analyticsManager = analyticsManager) diff --git a/app/src/main/java/com/itsaky/androidide/deeplink/PendingDeepLinkOpen.kt b/app/src/main/java/com/itsaky/androidide/deeplink/PendingDeepLinkOpen.kt index 94fba3db21..ea30236301 100644 --- a/app/src/main/java/com/itsaky/androidide/deeplink/PendingDeepLinkOpen.kt +++ b/app/src/main/java/com/itsaky/androidide/deeplink/PendingDeepLinkOpen.kt @@ -26,8 +26,12 @@ import com.itsaky.androidide.models.DeepLinkOpenRequest * Deliberately not acted on synchronously inside the close-confirmation dialog's button callback -- * see [com.itsaky.androidide.activities.editor.EditorHandlerActivity.onDestroy] for why the hand-off * must wait until the old, `singleTask` activity instance is guaranteed torn down. + * + * Koin-provided (`single` in `di/AppModule.kt`) rather than a Kotlin `object`, per ADR 0006 -- + * still one process-wide instance either way, but this keeps it substitutable in tests and out of + * the "hand-rolled singleton" pattern the ADR asks new code to avoid. */ -internal object PendingDeepLinkOpen { +internal class PendingDeepLinkOpen { @Volatile var value: DeepLinkOpenRequest? = null } diff --git a/app/src/main/java/com/itsaky/androidide/di/AppModule.kt b/app/src/main/java/com/itsaky/androidide/di/AppModule.kt index 0e3b3f65f4..c63f37f09b 100644 --- a/app/src/main/java/com/itsaky/androidide/di/AppModule.kt +++ b/app/src/main/java/com/itsaky/androidide/di/AppModule.kt @@ -1,9 +1,9 @@ package com.itsaky.androidide.di - import com.itsaky.androidide.actions.FileActionManager import com.itsaky.androidide.analytics.AnalyticsManager import com.itsaky.androidide.analytics.IAnalyticsManager +import com.itsaky.androidide.deeplink.PendingDeepLinkOpen import com.itsaky.androidide.git.core.GitCredentialsManager import com.itsaky.androidide.roomData.recentproject.RecentProjectRoomDatabase import com.itsaky.androidide.viewmodel.CloneRepositoryViewModel @@ -14,8 +14,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import org.koin.android.ext.koin.androidApplication import org.koin.android.ext.koin.androidContext -import org.koin.dsl.module import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module val coreModule = module { @@ -25,24 +25,24 @@ val coreModule = single { AnalyticsManager() } viewModel { - GitBottomSheetViewModel(get()) + GitBottomSheetViewModel(get()) } - viewModel { MainViewModel(get()) } - viewModel { CloneRepositoryViewModel(get(), get()) } - + viewModel { MainViewModel() } + viewModel { CloneRepositoryViewModel(get(), get()) } - single { - CoroutineScope(SupervisorJob() + Dispatchers.IO) - } + single { + CoroutineScope(SupervisorJob() + Dispatchers.IO) + } - single { - RecentProjectRoomDatabase.getDatabase(androidApplication(), get()) - } + single { + RecentProjectRoomDatabase.getDatabase(androidApplication(), get()) + } - single { - get().recentProjectDao() - } + single { + get().recentProjectDao() + } - single { GitCredentialsManager(get()) } + single { GitCredentialsManager(get()) } + single { PendingDeepLinkOpen() } } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/MainViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/MainViewModel.kt index 46f42ba1ab..325502688c 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/MainViewModel.kt @@ -23,15 +23,10 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.itsaky.androidide.roomData.recentproject.RecentProject -import com.itsaky.androidide.roomData.recentproject.RecentProjectDao import com.itsaky.androidide.templates.Template -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch -import org.slf4j.Logger -import org.slf4j.LoggerFactory import java.util.concurrent.atomic.AtomicInteger /** @@ -39,84 +34,74 @@ import java.util.concurrent.atomic.AtomicInteger * * @author Akash Yadav */ -class MainViewModel( - private val recentProjectDao: RecentProjectDao -) : ViewModel() { - - companion object { - - // The values assigned to these variables reflect the order in which the screens are presented - // to the user. A screen with a lower value is displayed before a screen with a higher value. - // For example, SCREEN_MAIN is the first screen visible to the user, followed by SCREEN_TEMPLATE_LIST, - // and then SCREEN_TEMPLATE_DETAILS. - // - // These values are used as unique identifiers for the screens as well as for determining whether - // the screen change transition should be forward or backward. - const val SCREEN_MAIN = 0 - const val SCREEN_TEMPLATE_LIST = 1 - const val SCREEN_TEMPLATE_DETAILS = 2 - const val TOOLTIPS_WEB_VIEW = 3 - const val SCREEN_SAVED_PROJECTS = 4 - const val SCREEN_DELETE_PROJECTS = 5 - const val SCREEN_CLONE_REPO = 6 - - val logger : Logger = LoggerFactory.getLogger(MainViewModel::class.java) - } - - private val _currentScreen = MutableLiveData(-1) - private val _previousScreen = AtomicInteger(-1) - private val _isTransitionInProgress = MutableLiveData(false) - - private val cloneRepositoryEventChannel = Channel(Channel.BUFFERED) - - internal val template = MutableLiveData>(null) - internal val creatingProject = MutableLiveData(false) - - val currentScreen: LiveData = _currentScreen - - val cloneRepositoryEvent = cloneRepositoryEventChannel.receiveAsFlow() - - val previousScreen: Int - get() = _previousScreen.get() - - var isTransitionInProgress: Boolean - get() = _isTransitionInProgress.value ?: false - set(value) { - _isTransitionInProgress.value = value - } - - fun setScreen(screen: Int) { - _previousScreen.set(_currentScreen.value ?: SCREEN_MAIN) - _currentScreen.value = screen - } - - fun requestCloneRepository(url: String) { - viewModelScope.launch { - cloneRepositoryEventChannel.send(url) - } - setScreen(SCREEN_CLONE_REPO) - } - - fun postTransition(owner: LifecycleOwner, action: Runnable) { - if (isTransitionInProgress) { - _isTransitionInProgress.observe(owner, object : Observer { - override fun onChanged(t: Boolean) { - _isTransitionInProgress.removeObserver(this) - action.run() - } - }) - } else { - action.run() - } - } - - fun saveProjectToRecents(project: RecentProject) { - viewModelScope.launch(Dispatchers.IO) { - try { - recentProjectDao.insert(project) - } catch (e: Exception) { - logger.warn("Failed to save project to recents", e) - } - } - } +class MainViewModel : ViewModel() { + companion object { + // The values assigned to these variables reflect the order in which the screens are presented + // to the user. A screen with a lower value is displayed before a screen with a higher value. + // For example, SCREEN_MAIN is the first screen visible to the user, followed by SCREEN_TEMPLATE_LIST, + // and then SCREEN_TEMPLATE_DETAILS. + // + // These values are used as unique identifiers for the screens as well as for determining whether + // the screen change transition should be forward or backward. + const val SCREEN_MAIN = 0 + const val SCREEN_TEMPLATE_LIST = 1 + const val SCREEN_TEMPLATE_DETAILS = 2 + const val TOOLTIPS_WEB_VIEW = 3 + const val SCREEN_SAVED_PROJECTS = 4 + const val SCREEN_DELETE_PROJECTS = 5 + const val SCREEN_CLONE_REPO = 6 + } + + private val _currentScreen = MutableLiveData(-1) + private val _previousScreen = AtomicInteger(-1) + private val _isTransitionInProgress = MutableLiveData(false) + + private val cloneRepositoryEventChannel = Channel(Channel.BUFFERED) + + internal val template = MutableLiveData>(null) + internal val creatingProject = MutableLiveData(false) + + val currentScreen: LiveData = _currentScreen + + val cloneRepositoryEvent = cloneRepositoryEventChannel.receiveAsFlow() + + val previousScreen: Int + get() = _previousScreen.get() + + var isTransitionInProgress: Boolean + get() = _isTransitionInProgress.value ?: false + set(value) { + _isTransitionInProgress.value = value + } + + fun setScreen(screen: Int) { + _previousScreen.set(_currentScreen.value ?: SCREEN_MAIN) + _currentScreen.value = screen + } + + fun requestCloneRepository(url: String) { + viewModelScope.launch { + cloneRepositoryEventChannel.send(url) + } + setScreen(SCREEN_CLONE_REPO) + } + + fun postTransition( + owner: LifecycleOwner, + action: Runnable, + ) { + if (isTransitionInProgress) { + _isTransitionInProgress.observe( + owner, + object : Observer { + override fun onChanged(t: Boolean) { + _isTransitionInProgress.removeObserver(this) + action.run() + } + }, + ) + } else { + action.run() + } + } } From e9a1afbc9d482902c2b8ddc92e6e0866c1f4d491 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 19:06:09 -0700 Subject: [PATCH 24/39] ADFA-5067 | Look up a deep-linked project by name directly, not by scanning all MainActivity.handleDeepLinkRequest and EditorHandlerActivity.onNewIntent both did findValidProjects(PROJECTS_DIR).find { it.name == name } -- duplicated across both call sites, and findValidProjects itself validates every project under PROJECTS_DIR just to find one by a known name. Adds findValidProjectByName(), the O(1) counterpart to findValidProjects() for a caller that already knows the exact name, and uses it at both call sites -- deduplicating the expression and skipping the full-directory scan. Addressed from deferred code-review findings. --- .../androidide/activities/MainActivity.kt | 3 ++- .../editor/EditorHandlerActivity.kt | 4 ++-- .../androidide/utils/ProjectValidations.kt | 24 +++++++++++++++---- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt index c643b2850c..7a2a7dc233 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt @@ -63,6 +63,7 @@ import com.itsaky.androidide.utils.FeatureFlags import com.itsaky.androidide.utils.MainScreenActions import com.itsaky.androidide.utils.UrlManager import com.itsaky.androidide.utils.applyBottomWindowInsetsPadding +import com.itsaky.androidide.utils.findValidProjectByName import com.itsaky.androidide.utils.findValidProjects import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashInfo @@ -492,7 +493,7 @@ class MainActivity : EdgeToEdgeIDEActivity() { lifecycleScope.launch(Dispatchers.IO) { val projectDir = try { - findValidProjects(Environment.PROJECTS_DIR).find { it.name == request.projectName } + findValidProjectByName(Environment.PROJECTS_DIR, request.projectName) } catch (e: CancellationException) { throw e } catch (e: SecurityException) { diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index ffa0855551..1a5fb33143 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -110,7 +110,7 @@ import com.itsaky.androidide.utils.Environment import com.itsaky.androidide.utils.ImageUtils import com.itsaky.androidide.utils.IntentUtils.openImage import com.itsaky.androidide.utils.UniqueNameBuilder -import com.itsaky.androidide.utils.findValidProjects +import com.itsaky.androidide.utils.findValidProjectByName import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.utils.forEachViewRecursively @@ -1919,7 +1919,7 @@ open class EditorHandlerActivity : lifecycleScope.launch(Dispatchers.IO) { val projectDir = try { - findValidProjects(Environment.PROJECTS_DIR).find { it.name == request.projectName } + findValidProjectByName(Environment.PROJECTS_DIR, request.projectName) } catch (e: CancellationException) { throw e } catch (e: SecurityException) { diff --git a/app/src/main/java/com/itsaky/androidide/utils/ProjectValidations.kt b/app/src/main/java/com/itsaky/androidide/utils/ProjectValidations.kt index 4859e048c8..efdeb8cb21 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/ProjectValidations.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/ProjectValidations.kt @@ -11,14 +11,30 @@ internal fun File.isProjectCandidateDir(): Boolean = isDirectory && canRead() && internal fun findValidProjects(projectsRoot: File): List { if (!projectsRoot.isProjectCandidateDir()) return emptyList() - val subdirs = projectsRoot.listFiles() - ?.filter { it.isProjectCandidateDir() } - .orEmpty() + val subdirs = + projectsRoot + .listFiles() + ?.filter { it.isProjectCandidateDir() } + .orEmpty() if (subdirs.isEmpty()) return emptyList() return subdirs.filter { dir -> isValidProjectDirectory(dir) } } +/** + * Resolves [name] directly to `[projectsRoot]/[name]` and validates just that one directory -- + * the O(1) counterpart to [findValidProjects] for callers (e.g. deep links) that already know the + * exact project name and don't need every project under [projectsRoot] scanned to find it. + */ +internal fun findValidProjectByName( + projectsRoot: File, + name: String, +): File? { + if (!projectsRoot.isProjectCandidateDir()) return null + val candidate = File(projectsRoot, name) + return candidate.takeIf { it.isProjectCandidateDir() && isValidProjectDirectory(it) } +} + /** Determines if the directory contains a valid Android project structure. */ fun isValidProjectDirectory(selectedDir: File): Boolean { if (isPluginProject(selectedDir)) { @@ -56,4 +72,4 @@ internal fun isPluginProject(dir: File): Boolean { val pluginApiJar = File(dir, "libs/plugin-api.jar") val buildGradle = File(dir, "build.gradle.kts") return pluginApiJar.exists() && buildGradle.exists() -} \ No newline at end of file +} From f8cb2c988f776e9c8c0e81eb873544f4e641d4aa Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 19:06:41 -0700 Subject: [PATCH 25/39] ADFA-5067 | Deduplicate deep-link line/column parsing applyDeepLinkFileRequest() had two copy-pasted 8-line blocks for line/column parsing, differing only in the target var, the error string resource, and which PendingFileRequest field was read. Collapsed into one zeroBasedOrFlashError() helper. Also folds in a stray PendingDeepLinkOpen.value -> pendingDeepLinkOpen rename left over from 9741df7's Koin conversion. Addressed from deferred code-review findings. --- .../editor/EditorHandlerActivity.kt | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index 1a5fb33143..b042a1cc28 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -29,6 +29,7 @@ import android.view.KeyEvent import android.view.View import android.view.ViewGroup.LayoutParams import android.widget.TextView +import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.collection.MutableIntObjectMap import androidx.core.content.IntentCompat @@ -1945,7 +1946,7 @@ open class EditorHandlerActivity : // confirm-close dialog; only record the pending open if the user actually confirms -- // see onDestroy() for why the reopen itself waits until this instance is torn down. confirmProjectCloseThenOpen { - PendingDeepLinkOpen.value = DeepLinkOpenRequest(projectDir.absolutePath, request.fileRequest) + pendingDeepLinkOpen.value = DeepLinkOpenRequest(projectDir.absolutePath, request.fileRequest) } } } @@ -1983,26 +1984,29 @@ open class EditorHandlerActivity : } // URL line/column are 1-based; internal Position is 0-based. - var line = 0 - var column = 0 - request.lineRaw?.let { raw -> - val parsed = raw.toIntOrNull() - if (parsed == null || parsed <= 0) { - flashError(getString(string.msg_deeplink_invalid_line, raw)) - } else { - line = parsed - 1 - } - } - request.columnRaw?.let { raw -> - val parsed = raw.toIntOrNull() - if (parsed == null || parsed <= 0) { - flashError(getString(string.msg_deeplink_invalid_column, raw)) - } else { - column = parsed - 1 - } - } + val line = zeroBasedOrFlashError(request.lineRaw, string.msg_deeplink_invalid_line) + val column = zeroBasedOrFlashError(request.columnRaw, string.msg_deeplink_invalid_column) val pos = Position(line, column) openFileAndSelect(file, Range(pos, pos)) } + + /** + * Converts a 1-based deep-link line/column value to 0-based. A `null` [raw] (segment absent from + * the URL) silently defaults to 0; a present-but-invalid [raw] (fails [String.toIntOrNull] or + * non-positive) also defaults to 0 but reports [invalidMsgRes] to the user -- see + * [PendingFileRequest]'s docs for why those two cases are distinguished upstream. + */ + private fun zeroBasedOrFlashError( + raw: String?, + @StringRes invalidMsgRes: Int, + ): Int { + raw ?: return 0 + val parsed = raw.toIntOrNull() + if (parsed == null || parsed <= 0) { + flashError(getString(invalidMsgRes, raw)) + return 0 + } + return parsed - 1 + } } From 11d1988553a769d2b9641a2e2e08eb8c8528b02e Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 19:19:20 -0700 Subject: [PATCH 26/39] ADFA-5067 | Fix path traversal introduced by findValidProjectByName findValidProjectByName() (e9a1afb) joined projectsRoot with the attacker-controllable project name via a bare File(projectsRoot, name), regressing a safety property the O(n) findValidProjects() had for free: it only ever matches names of directories it already enumerated under projectsRoot, so it can't be pointed outside it. A deep link project name of "../../etc" (a decoded URL segment can contain slashes) would let the direct File join escape projectsRoot entirely. Resolves name through the existing resolveWithinDirectory() guard instead, matching the same protection already used for the file-path segment of a deep link. Adds regression tests: resolves a real project by name, rejects an unknown name, and rejects a dot-dot escape to a sibling directory. Found by CodeRabbit's review of the previous commit. --- .../androidide/utils/ProjectValidations.kt | 7 +- .../utils/ProjectValidationsTest.kt | 67 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/com/itsaky/androidide/utils/ProjectValidationsTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/utils/ProjectValidations.kt b/app/src/main/java/com/itsaky/androidide/utils/ProjectValidations.kt index efdeb8cb21..473c9f41d8 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/ProjectValidations.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/ProjectValidations.kt @@ -25,13 +25,18 @@ internal fun findValidProjects(projectsRoot: File): List { * Resolves [name] directly to `[projectsRoot]/[name]` and validates just that one directory -- * the O(1) counterpart to [findValidProjects] for callers (e.g. deep links) that already know the * exact project name and don't need every project under [projectsRoot] scanned to find it. + * + * [name] is attacker-controllable (a deep-link URL segment), so it's resolved through + * [resolveWithinDirectory] rather than a bare `File(projectsRoot, name)` -- [findValidProjects] + * only ever matches against names of directories it already enumerated under [projectsRoot], so it + * can't be pointed outside it, but a direct `File(root, name)` join can (e.g. `name = "../../etc"`). */ internal fun findValidProjectByName( projectsRoot: File, name: String, ): File? { if (!projectsRoot.isProjectCandidateDir()) return null - val candidate = File(projectsRoot, name) + val candidate = resolveWithinDirectory(projectsRoot, name) ?: return null return candidate.takeIf { it.isProjectCandidateDir() && isValidProjectDirectory(it) } } diff --git a/app/src/test/java/com/itsaky/androidide/utils/ProjectValidationsTest.kt b/app/src/test/java/com/itsaky/androidide/utils/ProjectValidationsTest.kt new file mode 100644 index 0000000000..5c21faf2d8 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/ProjectValidationsTest.kt @@ -0,0 +1,67 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class ProjectValidationsTest { + @JvmField + @Rule + val tempFolder = TemporaryFolder() + + private fun makeValidProject( + parent: File, + name: String, + ): File { + val project = File(parent, name).apply { mkdirs() } + val appDir = File(project, "app").apply { mkdirs() } + File(appDir, "build.gradle.kts").writeText("// stub") + return project + } + + @Test + fun `resolves an existing project by name`() { + val root = tempFolder.newFolder("projects") + val project = makeValidProject(root, "MyApp") + + assertThat(findValidProjectByName(root, "MyApp")?.canonicalFile).isEqualTo(project.canonicalFile) + } + + @Test + fun `unknown project name yields null`() { + val root = tempFolder.newFolder("projects") + assertThat(findValidProjectByName(root, "DoesNotExist")).isNull() + } + + @Test + fun `dot-dot traversal outside projectsRoot is rejected`() { + // Regression test: a bare File(projectsRoot, name) join let `name` escape projectsRoot + // entirely (e.g. name = "../outside"). A real deep link supplies this as a decoded URL + // segment, so a project sitting just outside the configured projects root must never be + // resolvable via a crafted project name. + val base = tempFolder.newFolder("base") + val root = File(base, "projects").apply { mkdirs() } + makeValidProject(base, "outside") + + assertThat(findValidProjectByName(root, "../outside")).isNull() + } +} From a44feeb06ddc73198b8b2942ad78b9bf5e686b48 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 19:19:39 -0700 Subject: [PATCH 27/39] ADFA-5067 | Narrow the Recents-insert catch to SQLException catch (e: Exception) around the single recentProjectDao.insert() call was broader than needed and would silently swallow an unrelated bug along with a genuine persistence failure. Room propagates android.database.SQLException (or subtypes like SQLiteConstraintException) from a failed @Insert, so catching that specifically still protects the app-wide scope from a persistence hiccup while letting anything else surface. Drops the now-redundant explicit CancellationException rethrow -- it doesn't overlap with SQLException, so it already propagates on its own. Addressed from inline PR review comments. --- .../itsaky/androidide/utils/ProjectOpenBookkeeping.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt b/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt index ee37072e6f..fd87e3ed4b 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt @@ -17,6 +17,7 @@ package com.itsaky.androidide.utils +import android.database.SQLException import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.lifecycleScope import com.itsaky.androidide.analytics.IAnalyticsManager @@ -24,7 +25,6 @@ import com.itsaky.androidide.preferences.internal.GeneralPreferences import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.roomData.recentproject.RecentProject import com.itsaky.androidide.roomData.recentproject.RecentProjectDao -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.slf4j.LoggerFactory @@ -67,12 +67,13 @@ fun recordProjectOpenedBookkeeping( ) try { recentProjectDao.insert(recentProject) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { + } catch (e: SQLException) { // This runs on ProcessLifecycleOwner's app-wide scope -- an uncaught exception here would // crash the whole process, not just fail to record one Recents entry. The project-open // state above is already set synchronously, so a Recents-write failure doesn't affect it. + // Catches SQLException specifically (Room propagates it, or subtypes like + // SQLiteConstraintException, from a failed @Insert) rather than a blanket Exception, so an + // unrelated bug here still surfaces instead of being silently swallowed. log.warn("Failed to record opened project '{}' in Recents", recentProject.name, e) } } From 6a92920df60063944abe3b420849bbaaf2f2e5ed Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 19:22:10 -0700 Subject: [PATCH 28/39] ADFA-5067 | Document MainViewModel's screen-state and event contracts The class doc was a one-liner ("ViewModel for main activity") that didn't cover the LiveData main-thread requirement, the -1 sentinel for "no screen yet", postTransition's defer-until-complete behavior, or that the clone-request event is a buffered, single-consumer Channel rather than persisted state. Doc-only change, no behavior change. Addressed from inline PR review comments. --- .../androidide/viewmodel/MainViewModel.kt | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/MainViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/MainViewModel.kt index 325502688c..6736fd9511 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/MainViewModel.kt @@ -30,7 +30,25 @@ import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicInteger /** - * [ViewModel] for main activity. + * [ViewModel] for [com.itsaky.androidide.activities.MainActivity] -- holds the single-Activity, + * multi-"screen" navigation state (see the `SCREEN_*` constants) plus one-shot events unrelated to + * persisted UI state. + * + * **Threading:** all mutable state here ([currentScreen], [isTransitionInProgress]) is backed by + * [MutableLiveData] set via direct `.value =` assignment, never `postValue` -- every mutator + * ([setScreen], the [isTransitionInProgress] setter) must run on the main thread. + * + * **Screen state:** [currentScreen]/[previousScreen] are mutually exclusive, identified by one of + * the `SCREEN_*` constants; `-1` is the sentinel for "no screen yet" rather than `null`, since both + * are non-nullable `Int`. [setScreen] records the outgoing screen as [previousScreen] before + * advancing [currentScreen] -- there's no history beyond that one step back. [postTransition] runs + * its `action` immediately unless [isTransitionInProgress] is true, in which case it defers `action` + * until the next transition-complete signal, then detaches its observer (fires at most once). + * + * **Clone-request event:** [requestCloneRepository] is a one-shot, single-consumer event, not + * persisted state -- delivered through a buffered [Channel] exposed as [cloneRepositoryEvent] via + * [kotlinx.coroutines.flow.receiveAsFlow]. A URL sent before any collector attaches is buffered, not + * dropped, but if more than one collector attaches, only one of them receives a given element. * * @author Akash Yadav */ From 7e927159b36e63997b9a3e77c9859bed7c2e0bb8 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 11 Aug 2026 22:06:32 -0700 Subject: [PATCH 29/39] ADFA-5067 | Dismiss the confirm-close dialog in onDestroy() activeProjectCloseDialog was tracked but never dismissed on destroy -- rotating the device (or any destroy) while the confirm-close dialog is showing leaked its window (WindowLeaked). Found by John Trujillo's review of PR 1651. --- .../androidide/activities/editor/EditorHandlerActivity.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index b042a1cc28..909ef66cc8 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -350,6 +350,9 @@ open class EditorHandlerActivity : override fun onDestroy() { super.onDestroy() ActionContextProvider.clearActivity(this) + // Not dismissing this would leak the dialog's window (WindowLeaked) past this activity's + // death -- e.g. a rotation while the confirm-close dialog is showing. + activeProjectCloseDialog?.dismiss() // Drain any deep-link-triggered "close then reopen a different project" request recorded by // confirmProjectCloseThenOpen's onClosed callback. This deliberately waits until onDestroy -- From fa73614e8c45e3ffa14376849bd44bbd68709bb4 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 11 Aug 2026 22:07:04 -0700 Subject: [PATCH 30/39] ADFA-5067 | Always invoke saveAllAsync's runAfter, even if saveAll throws CodeEditorView.save() propagates an IOException from a failed disk write uncaught. saveAllAsync's coroutine ran saveAll() with no try/catch, so that exception skipped straight past the withContext(Dispatchers.Main) { runAfter?.invoke() } that followed -- runAfter is the only place confirmProjectClose's confirmCloseInProgress guard gets reset, so a disk-full or permission failure during "Save and close" left it stuck true, permanently blocking closing that activity instance (on top of the uncaught exception itself being a crash risk). CancellationException is rethrown; other failures are logged and runAfter still runs. The other saveAllAsync caller (notifyFilesUnsaved) has the identical gap today (invokeAfter never runs on a save failure); this fixes it too, and now behaves the same as the success path there (proceeds regardless of whether every file actually saved), which is no worse than before. Found by John Trujillo's review of PR 1651. --- .../activities/editor/EditorHandlerActivity.kt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index 909ef66cc8..e7ea543a1e 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -938,8 +938,18 @@ open class EditorHandlerActivity : runAfter: (() -> Unit)?, ) { lifecycleScope.launch(Dispatchers.IO) { - withContext(NonCancellable) { - saveAll(notify, requestSync, processResources, progressConsumer) + try { + withContext(NonCancellable) { + saveAll(notify, requestSync, processResources, progressConsumer) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // A write failure here (e.g. CodeEditorView.save()'s IOException) must not skip + // runAfter below -- callers rely on it always running to know the save attempt is + // over, successful or not (e.g. confirmProjectClose's closeInProgress guard, which + // would otherwise stay stuck true and permanently block closing this activity). + Log.e("EditorHandlerActivity", "saveAll failed", e) } withContext(Dispatchers.Main) { runAfter?.invoke() From 2a9c28a40c9cfd0ebaf3302a0103312689b261dd Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 11 Aug 2026 22:07:36 -0700 Subject: [PATCH 31/39] ADFA-5067 | Reject overlapping confirm-close requests instead of hijacking confirmProjectClose() shared its dialog/token state between the plain manual close (back button, sidebar action) and the deep-link close-then-reopen flow. A deep link arriving while a manual close dialog was showing dismissed it and replaced it with one whose buttons run the deep-link's onClosed -- a user tapping "Close without saving" on what looked like an ordinary close ended up with an unrelated deep-linked project opened instead, or vice versa. Replaces the dismiss-and-replace strategy with reject-while-active: a single confirmCloseInProgress flag covers both the dialog being shown and its "Save and close" still writing files, and any confirmProjectClose call while it's set is dropped (with a flashError, previously silent) rather than allowed to interrupt whatever's already in flight. This also removes the need for the previous generation-token mechanism -- with only ever one flow active, there's no longer a "newer" request to distinguish from a "stale" one. Also fixes a related false-positive: the failed-save check added alongside the original guard used hasUnsavedFiles(), which stays true for files CodeEditorView.save() intentionally never writes (an ARCHIVE_EXTENSIONS extension, opened read-only) -- any such tab left "Save and close" permanently refusing to close. The new hasFilesThatFailedToSave() excludes those. Found by John Trujillo's review of PR 1651 and a fresh full re-review. --- .../editor/EditorHandlerActivity.kt | 66 +++++++++---------- .../itsaky/androidide/ui/CodeEditorView.kt | 4 +- resources/src/main/res/values/strings.xml | 1 + 3 files changed, 36 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index e7ea543a1e..7e6c69d9a0 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -102,6 +102,7 @@ import com.itsaky.androidide.shortcuts.ShortcutExecutionContext import com.itsaky.androidide.shortcuts.ShortcutManager import com.itsaky.androidide.tasks.executeAsync import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult +import com.itsaky.androidide.ui.ARCHIVE_EXTENSIONS import com.itsaky.androidide.ui.CodeEditorView import com.itsaky.androidide.utils.DialogUtils.newMaterialDialogBuilder import com.itsaky.androidide.utils.DialogUtils.showConfirmationDialog @@ -1080,6 +1081,16 @@ open class EditorHandlerActivity : getEditorForFile(file)?.isModified == true } + /** + * Like [hasUnsavedFiles], but excludes files [CodeEditorView.save] never actually writes (an + * [ARCHIVE_EXTENSIONS] extension, opened read-only) -- those can never leave the "modified" + * state through a save, so counting them as a save failure would block "Save and close" forever. + */ + private fun hasFilesThatFailedToSave() = + editorViewModel.getOpenedFiles().any { file -> + getEditorForFile(file)?.isModified == true && file.extension.lowercase() !in ARCHIVE_EXTENSIONS + } + private suspend inline fun performFileSave(crossinline action: suspend () -> T): T { setFilesSaving(true) try { @@ -1832,56 +1843,44 @@ open class EditorHandlerActivity : } } - // Tracks the currently-showing confirm-close dialog so a second deep link arriving while one - // is already up (onNewIntent can fire repeatedly for a singleTask activity) replaces it - // instead of stacking a second dialog -- two stacked dialogs would let either button confirm - // PendingDeepLinkOpen.value out from under the other, silently dropping whichever project the - // user actually confirmed opening. + // Tracked so onDestroy() can dismiss it (avoiding a leaked window) and so a confirm-close flow + // already in progress -- dialog showing, or its "Save and close" still writing files -- can + // reject a second, overlapping confirmProjectClose call rather than either stacking a second + // dialog or silently swapping out the one the user is already looking at. The two flows this + // guards between are the plain manual close (back button, sidebar action, onClosed == null) and + // the deep-link close-then-reopen (onClosed sets pendingDeepLinkOpen) -- letting one hijack the + // other's dialog would mean a user tapping "Close without saving" on what looks like an ordinary + // close ends up with an unrelated deep-linked project opened instead, or vice versa. private var activeProjectCloseDialog: AlertDialog? = null - - // Identifies the most recent deep-link-triggered close request (confirmProjectClose calls with - // a non-null onClosed). "Save and close" runs saveAllAsync asynchronously, so an older request's - // completion callback can still fire after a newer request's dialog has already been answered -- - // contentOrNull only turns null once onStop()/onDestroy() runs, well after finish() is called. - // Without this token, that late callback would overwrite PendingDeepLinkOpen.value with the - // superseded project. Only the request that owns the current token is allowed to act. - private var currentDeepLinkCloseToken: Any? = null - - // True from the moment "Save and close" starts saveAllAsync until its callback runs. saveAllAsync - // iterates and mutates editorViewModel's file/editor state on a background coroutine -- a second - // confirmProjectClose answered with "Close without saving" while that's in flight would call - // performCloseAllFiles synchronously on the main thread against the same state, racing the save. - // The token above only stops a stale *result* from winning; it can't stop this concurrent access. - private var closeInProgress = false + private var confirmCloseInProgress = false private fun confirmProjectClose(onClosed: (() -> Unit)? = null) { val content = contentOrNull ?: return - if (closeInProgress) { - // A save-and-close is still writing files; dropping this request instead of showing a new - // dialog avoids racing that write. The user can retry once it finishes. + if (confirmCloseInProgress) { + flashError(string.msg_project_close_in_progress) return } - activeProjectCloseDialog?.dismiss() - - val ownToken = onClosed?.let { Any().also { token -> currentDeepLinkCloseToken = token } } - - fun isStillCurrent() = onClosed == null || currentDeepLinkCloseToken === ownToken + confirmCloseInProgress = true val builder = newMaterialDialogBuilder(this) builder.setTitle(string.title_confirm_project_close) builder.setMessage(string.msg_confirm_project_close) + builder.setOnCancelListener { confirmCloseInProgress = false } - builder.setNegativeButton(string.cancel_project_text, null) + builder.setNegativeButton(string.cancel_project_text) { dialog, _ -> + dialog.dismiss() + confirmCloseInProgress = false + } // OPTION 1: Close without saving builder.setNeutralButton(string.close_without_saving) { dialog, _ -> dialog.dismiss() - if (!isStillCurrent()) return@setNeutralButton for (i in 0 until editorViewModel.getOpenedFileCount()) { (content.editorContainer.getChildAt(i) as? CodeEditorView)?.editor?.markUnmodified() } + // Activity is finishing either way; no need to reset confirmCloseInProgress. performCloseAllFiles(manualFinish = true, onClosed = onClosed) } @@ -1889,15 +1888,14 @@ open class EditorHandlerActivity : builder.setPositiveButton(string.save_and_close) { dialog, _ -> dialog.dismiss() - closeInProgress = true saveAllAsync(notify = false) { runOnUiThread { - closeInProgress = false - if (contentOrNull == null || !isStillCurrent()) return@runOnUiThread + confirmCloseInProgress = false + if (contentOrNull == null) return@runOnUiThread // saveAll()'s return value is gradleSaved (whether a build file changed), not // "everything saved successfully" -- check actual editor state instead, so a // failed write (disk full, permission) doesn't silently discard unsaved changes. - if (hasUnsavedFiles()) { + if (hasFilesThatFailedToSave()) { flashError(string.save_failed) return@runOnUiThread } diff --git a/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt b/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt index 5a219cfedd..1e46c0898e 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt @@ -88,7 +88,9 @@ import kotlin.math.abs private const val MIN_FONT_SIZE = EditorPreferences.FONT_SIZE_MIN private const val DEFAULT_FONT_SIZE = EditorPreferences.FONT_SIZE_DEFAULT private const val MAX_FONT_SIZE = EditorPreferences.FONT_SIZE_MAX -private val ARCHIVE_EXTENSIONS = setOf("apk", "cgp", "zip") + +/** File extensions [CodeEditorView.save] never writes -- these are opened read-only. */ +internal val ARCHIVE_EXTENSIONS = setOf("apk", "cgp", "zip") /** * A view that handles opened code editor. diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index e4934bc86b..320565964b 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -141,6 +141,7 @@ \"%s\" is not a valid line number. \"%s\" is not a valid column number. Could not scan projects for this link. + A project close is already in progress. Try again in a moment. Create new project Open a saved project Delete a saved project From 3b7afd78fa9f327c49a2b99021b6e170ab5cd820 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 11 Aug 2026 22:08:12 -0700 Subject: [PATCH 32/39] ADFA-5067 | Fix same-project fast path; dedupe deep-link project lookup The "already in this project" fast path in EditorHandlerActivity.onNewIntent required IProjectManager.getInstance().workspace != null, but workspace stays null for the whole duration of a Gradle sync -- so a deep link to the project that's already open, tapped while its own sync is still running, fell through to the disruptive "different project" branch and prompted to close and reopen the project the user was already in. Compares projectDirPath alone, which is set as soon as a project starts opening. Also extracts the identical ~15-line try/catch(CancellationException/ SecurityException) + null-check + flashError block around findValidProjectByName, duplicated between MainActivity and EditorHandlerActivity with two different logging APIs for the same log line, into one resolveDeepLinkProject() helper. Found by John Trujillo's review of PR 1651 (the workspace bug, independently) and a fresh full re-review (the duplication). --- .../androidide/activities/MainActivity.kt | 18 +----- .../editor/EditorHandlerActivity.kt | 26 +++----- .../utils/DeepLinkProjectResolution.kt | 60 +++++++++++++++++++ 3 files changed, 69 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/utils/DeepLinkProjectResolution.kt diff --git a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt index 7a2a7dc233..86aff54c18 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt @@ -63,12 +63,12 @@ import com.itsaky.androidide.utils.FeatureFlags import com.itsaky.androidide.utils.MainScreenActions import com.itsaky.androidide.utils.UrlManager import com.itsaky.androidide.utils.applyBottomWindowInsetsPadding -import com.itsaky.androidide.utils.findValidProjectByName import com.itsaky.androidide.utils.findValidProjects import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashInfo import com.itsaky.androidide.utils.hasVisibleDialog import com.itsaky.androidide.utils.recordProjectOpenedBookkeeping +import com.itsaky.androidide.utils.resolveDeepLinkProject import com.itsaky.androidide.viewmodel.MainViewModel import com.itsaky.androidide.viewmodel.MainViewModel.Companion.SCREEN_CLONE_REPO import com.itsaky.androidide.viewmodel.MainViewModel.Companion.SCREEN_DELETE_PROJECTS @@ -77,7 +77,6 @@ import com.itsaky.androidide.viewmodel.MainViewModel.Companion.SCREEN_SAVED_PROJ import com.itsaky.androidide.viewmodel.MainViewModel.Companion.SCREEN_TEMPLATE_DETAILS import com.itsaky.androidide.viewmodel.MainViewModel.Companion.SCREEN_TEMPLATE_LIST import com.itsaky.androidide.viewmodel.MainViewModel.Companion.TOOLTIPS_WEB_VIEW -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -491,21 +490,8 @@ class MainActivity : EdgeToEdgeIDEActivity() { */ private fun handleDeepLinkRequest(request: DeepLinkRequest) { lifecycleScope.launch(Dispatchers.IO) { - val projectDir = - try { - findValidProjectByName(Environment.PROJECTS_DIR, request.projectName) - } catch (e: CancellationException) { - throw e - } catch (e: SecurityException) { - log.error("Failed to scan {} for deep link", Environment.PROJECTS_DIR, e) - withContext(Dispatchers.Main) { flashError(getString(string.msg_deeplink_scan_failed)) } - return@launch - } + val projectDir = resolveDeepLinkProject(Environment.PROJECTS_DIR, request.projectName) ?: return@launch withContext(Dispatchers.Main) { - if (projectDir == null) { - flashError(getString(string.msg_deeplink_project_not_found, request.projectName)) - return@withContext - } openProject(projectDir, pendingFileRequest = request.fileRequest) } } diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index 7e6c69d9a0..656e40783f 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -112,12 +112,12 @@ import com.itsaky.androidide.utils.Environment import com.itsaky.androidide.utils.ImageUtils import com.itsaky.androidide.utils.IntentUtils.openImage import com.itsaky.androidide.utils.UniqueNameBuilder -import com.itsaky.androidide.utils.findValidProjectByName import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.utils.forEachViewRecursively import com.itsaky.androidide.utils.hasVisibleDialog import com.itsaky.androidide.utils.recordProjectOpenedBookkeeping +import com.itsaky.androidide.utils.resolveDeepLinkProject import com.itsaky.androidide.utils.resolveWithinDirectory import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -1929,25 +1929,13 @@ open class EditorHandlerActivity : ?: return lifecycleScope.launch(Dispatchers.IO) { - val projectDir = - try { - findValidProjectByName(Environment.PROJECTS_DIR, request.projectName) - } catch (e: CancellationException) { - throw e - } catch (e: SecurityException) { - Log.e("EditorHandlerActivity", "Failed to scan ${Environment.PROJECTS_DIR} for deep link", e) - withContext(Dispatchers.Main) { flashError(getString(string.msg_deeplink_scan_failed)) } - return@launch - } + val projectDir = resolveDeepLinkProject(Environment.PROJECTS_DIR, request.projectName) ?: return@launch withContext(Dispatchers.Main) { - if (projectDir == null) { - flashError(getString(string.msg_deeplink_project_not_found, request.projectName)) - return@withContext - } - - if (IProjectManager.getInstance().workspace != null && - projectDir.absolutePath == IProjectManager.getInstance().projectDirPath - ) { + // projectDirPath is set as soon as a project starts opening -- unlike workspace, which + // stays null for the whole duration of a Gradle sync -- so this correctly matches the + // "already in this project" case even mid-sync, instead of falling through to the + // disruptive close-and-reopen confirmation below for a no-op. + if (projectDir.absolutePath == IProjectManager.getInstance().projectDirPath) { // Requirement #2: same project already open -- no-op project-wise, just navigate. request.fileRequest?.let { applyDeepLinkFileRequest(it) } return@withContext diff --git a/app/src/main/java/com/itsaky/androidide/utils/DeepLinkProjectResolution.kt b/app/src/main/java/com/itsaky/androidide/utils/DeepLinkProjectResolution.kt new file mode 100644 index 0000000000..05f368ee1e --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/DeepLinkProjectResolution.kt @@ -0,0 +1,60 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import android.app.Activity +import com.itsaky.androidide.resources.R.string +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.slf4j.LoggerFactory +import java.io.File + +private val log = LoggerFactory.getLogger("DeepLinkProjectResolution") + +/** + * Resolves [projectName] to a validated project directory under [projectsRoot] for a deep link, + * handling the [SecurityException] [findValidProjectByName] can throw and reporting both "not + * found" and "scan failed" to the user via `flashError` on the main thread. A `null` result means + * the caller can just return -- either failure case already flashed its own message. + * + * Call from a background dispatcher (e.g. `Dispatchers.IO`); this only switches to + * [Dispatchers.Main] itself for the user-facing error messages. + */ +suspend fun Activity.resolveDeepLinkProject( + projectsRoot: File, + projectName: String, +): File? { + val projectDir = + try { + findValidProjectByName(projectsRoot, projectName) + } catch (e: CancellationException) { + throw e + } catch (e: SecurityException) { + log.error("Failed to scan {} for deep link", projectsRoot, e) + withContext(Dispatchers.Main) { flashError(getString(string.msg_deeplink_scan_failed)) } + return null + } + + if (projectDir == null) { + withContext(Dispatchers.Main) { + flashError(getString(string.msg_deeplink_project_not_found, projectName)) + } + } + return projectDir +} From dbf4f550fc05ad9b6bc193217b6fa33a62d1ad66 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 11 Aug 2026 22:08:41 -0700 Subject: [PATCH 33/39] ADFA-5067 | Drain the pending file request even when sync fails postProjectInit() only read and cleared the PendingFileRequest intent extra when isSuccessful was true, returning before either on failure. A cold open via a file+line deep link whose initial sync fails left the extra armed indefinitely; the next unrelated *successful* sync or build-variant switch on that same activity instance would still find it and silently jump the editor back to the original deep-linked file/line, discarding whatever the user was actually working on by then. Drains the extra unconditionally on the first postProjectInit call, regardless of outcome, and only applies it if that first sync succeeded. Found by a fresh full re-review of PR 1651. --- .../androidide/activities/editor/EditorHandlerActivity.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index 656e40783f..4aeb4b82a8 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -1956,7 +1956,6 @@ open class EditorHandlerActivity : failure: TaskExecutionResult.Failure?, ) { super.postProjectInit(isSuccessful, failure) - if (!isSuccessful) return // Covers requirement #1 (cold open + file) and the tail of requirement #3 (a fresh // EditorActivityKt instance always runs the normal init pipeline, whether started by @@ -1964,7 +1963,11 @@ open class EditorHandlerActivity : val request = IntentCompat.getParcelableExtra(intent, PendingFileRequest.EXTRA_KEY, PendingFileRequest::class.java) ?: return - intent.removeExtra(PendingFileRequest.EXTRA_KEY) // don't reapply on a later config-change recreate + // Drain the extra regardless of outcome, not just on success -- otherwise a failed sync + // leaves it armed, and it fires later on the next unrelated *successful* sync/variant switch, + // silently yanking the editor back to this stale request instead of never reapplying. + intent.removeExtra(PendingFileRequest.EXTRA_KEY) + if (!isSuccessful) return applyDeepLinkFileRequest(request) } From 8eb75ca8a0c8e3a65701686aac5a61c2e0c74c55 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 11 Aug 2026 22:09:05 -0700 Subject: [PATCH 34/39] ADFA-5067 | ActionContextProvider never hands back a finishing activity getActivity()'s WeakReference is only cleared in onDestroy(), so it stayed non-null for an EditorHandlerActivity that had already called finish() (e.g. the user picked "Close project") but hasn't been destroyed yet. DeepLinkActivity would then route a deep link tapped in that window to EditorActivityKt; since the existing instance is finishing, the framework creates a fresh instance instead of delivering via onNewIntent, whose onCreate never reads DEEP_LINK_REQUEST (only onNewIntent does) and falls back to reopening GeneralPreferences.lastOpenedProject -- the deep link was silently dropped and the wrong project opened. Filters isFinishing/isDestroyed out at the source rather than in each caller, since none of getActivity()'s three call sites (DeepLinkActivity, IDEApiFacade, EditorPanelDockableContent) can safely "trigger UI actions" on an activity that's already finishing or destroyed either. Found by John Trujillo's review of PR 1651. --- .../androidide/api/ActionContextProvider.kt | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/api/ActionContextProvider.kt b/app/src/main/java/com/itsaky/androidide/api/ActionContextProvider.kt index e5972bbeb1..1e7fdd9d38 100644 --- a/app/src/main/java/com/itsaky/androidide/api/ActionContextProvider.kt +++ b/app/src/main/java/com/itsaky/androidide/api/ActionContextProvider.kt @@ -8,24 +8,29 @@ import java.lang.ref.WeakReference * to allow decoupled services to trigger UI actions. */ object ActionContextProvider { - private var activityRef: WeakReference? = null + private var activityRef: WeakReference? = null - fun setActivity(activity: EditorHandlerActivity) { - this.activityRef = WeakReference(activity) - } + fun setActivity(activity: EditorHandlerActivity) { + this.activityRef = WeakReference(activity) + } - fun clearActivity() { - this.activityRef?.clear() - this.activityRef = null - } + fun clearActivity() { + this.activityRef?.clear() + this.activityRef = null + } - fun clearActivity(activity: EditorHandlerActivity) { - if (this.activityRef?.get() === activity) { - clearActivity() - } - } + fun clearActivity(activity: EditorHandlerActivity) { + if (this.activityRef?.get() === activity) { + clearActivity() + } + } - fun getActivity(): EditorHandlerActivity? { - return activityRef?.get() - } -} \ No newline at end of file + /** + * The current, live [EditorHandlerActivity], or `null` if there is none -- including one that + * called `finish()` but hasn't run `onDestroy()` (and cleared itself via [clearActivity]) yet. + * Android delivers `singleTask` intents to a finishing instance's [android.app.Activity.onNewIntent] + * inconsistently (a genuinely new instance can be created instead), so callers that route based + * on "is there a live editor to hand this off to" need this distinction, not just non-null. + */ + fun getActivity(): EditorHandlerActivity? = activityRef?.get()?.takeIf { !it.isFinishing && !it.isDestroyed } +} From 06751add8272ec83f9d891493771e043b6111270 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 11 Aug 2026 22:09:30 -0700 Subject: [PATCH 35/39] ADFA-5067 | Add CLEAR_TOP so repeated deep links don't stack MainActivity SINGLE_TOP alone can't dedupe MainActivity here: DeepLinkActivity is itself the top of the stack at the moment startActivity() runs (finish() comes after), so SINGLE_TOP's "is the target already at the top" check never matches -- MainActivity's own manifest declaration can't fix this either, since singleTop launch mode has the identical "must be literally on top" restriction as the Intent flag. Tapping two deep links while MainActivity is showing created two stacked MainActivity instances (each re-running startWebServer()), with Back walking through the stale one. CLEAR_TOP finds an existing MainActivity anywhere in the task and (combined with SINGLE_TOP, rather than the destroy-and-recreate CLEAR_TOP alone would do) redelivers to it via onNewIntent. EditorActivityKt is unaffected (already singleTask, always reuses its live instance). Found by John Trujillo's review of PR 1651. --- .../androidide/activities/DeepLinkActivity.kt | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt index f0062bc695..5f16086d5c 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt @@ -63,11 +63,19 @@ class DeepLinkActivity : Activity() { startActivity( Intent(this, target).apply { putExtra(DeepLinkRequest.EXTRA_KEY, request) - // SINGLE_TOP: if `target` is MainActivity and one is already on top of the stack - // (e.g. the user was browsing recent projects when the link was tapped), reuse it via - // onNewIntent instead of stacking a second instance. EditorActivityKt is singleTask, - // so it always reuses its live instance regardless of this flag. - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + // If `target` is MainActivity and one already exists in the task, reuse it via + // onNewIntent instead of stacking a second instance -- SINGLE_TOP alone isn't enough + // here, since DeepLinkActivity (not MainActivity) is what's actually on top of the + // stack at this exact call, so SINGLE_TOP's "already at the top" check never matches; + // CLEAR_TOP finds MainActivity anywhere in the task and reuses it via onNewIntent + // (combined with SINGLE_TOP, rather than the destroy-and-recreate CLEAR_TOP alone + // would do). EditorActivityKt is singleTask, so it always reuses its live instance + // regardless of these flags. + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP or + Intent.FLAG_ACTIVITY_CLEAR_TOP, + ) }, ) finish() From df7d7b40e03fce98c65bba72d2c92f5102648288 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 11 Aug 2026 22:09:52 -0700 Subject: [PATCH 36/39] ADFA-5067 | Reject "." and embedded separators in a deep-link project name resolveWithinDirectory's lexical check only rejects ".."/a leading separator, so a deep-link project name of "." resolved to projectsRoot itself -- if the projects directory happens to satisfy isValidProjectDirectory, the link would "open" the whole projects directory as if it were a single project. An embedded separator like "foo/bar" would similarly resolve two levels deep instead of naming a direct child. A project name is always a single path segment, so reject both up front. Found by John Trujillo's review of PR 1651. --- .../com/itsaky/androidide/utils/ProjectValidations.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/java/com/itsaky/androidide/utils/ProjectValidations.kt b/app/src/main/java/com/itsaky/androidide/utils/ProjectValidations.kt index 473c9f41d8..a18480e6e0 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/ProjectValidations.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/ProjectValidations.kt @@ -35,6 +35,14 @@ internal fun findValidProjectByName( projectsRoot: File, name: String, ): File? { + // A project name is always a single path segment. resolveWithinDirectory's lexical check only + // rejects ".."/a leading separator, so without this, name = "." would resolve to projectsRoot + // itself (opening the whole projects directory as "a project" if it happens to satisfy + // isValidProjectDirectory), and an embedded separator like "foo/bar" would resolve two levels + // deep instead of naming a direct child. + if (name.isEmpty() || name == "." || name.contains("/") || name.contains("\\")) { + return null + } if (!projectsRoot.isProjectCandidateDir()) return null val candidate = resolveWithinDirectory(projectsRoot, name) ?: return null return candidate.takeIf { it.isProjectCandidateDir() && isValidProjectDirectory(it) } From 8343ea9346326f1ffe7ebca26829045c7a24f11d Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 11 Aug 2026 22:10:18 -0700 Subject: [PATCH 37/39] ADFA-5067 | Widen the Recents-insert catch back to Throwable Narrowing this to SQLException (a44feeb06) assumed the usual "don't catch too broadly" guidance applies here, but this coroutine runs on ProcessLifecycleOwner's permanent, app-wide scope, which has no CoroutineExceptionHandler -- unlike the ViewModel-scoped version this replaced. Room's generated insert can throw non-SQLException types too (e.g. IllegalStateException from an already-closed database), and any of them escaping here crashes the whole process, not just fails to record one Recents entry. Given the severity of that scope, catching broadly is the correct tradeoff for this one line; CancellationException is still rethrown so cancellation isn't swallowed. Found by John Trujillo's review of PR 1651 and a fresh full re-review, independently. --- .../androidide/utils/ProjectOpenBookkeeping.kt | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt b/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt index fd87e3ed4b..5a67901c9f 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt @@ -17,7 +17,6 @@ package com.itsaky.androidide.utils -import android.database.SQLException import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.lifecycleScope import com.itsaky.androidide.analytics.IAnalyticsManager @@ -25,6 +24,7 @@ import com.itsaky.androidide.preferences.internal.GeneralPreferences import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.roomData.recentproject.RecentProject import com.itsaky.androidide.roomData.recentproject.RecentProjectDao +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.slf4j.LoggerFactory @@ -67,13 +67,15 @@ fun recordProjectOpenedBookkeeping( ) try { recentProjectDao.insert(recentProject) - } catch (e: SQLException) { - // This runs on ProcessLifecycleOwner's app-wide scope -- an uncaught exception here would - // crash the whole process, not just fail to record one Recents entry. The project-open - // state above is already set synchronously, so a Recents-write failure doesn't affect it. - // Catches SQLException specifically (Room propagates it, or subtypes like - // SQLiteConstraintException, from a failed @Insert) rather than a blanket Exception, so an - // unrelated bug here still surfaces instead of being silently swallowed. + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + // This runs on ProcessLifecycleOwner's permanent, app-wide scope, which has no + // CoroutineExceptionHandler -- unlike the ViewModel-scoped version this replaced, ANY + // escaping exception here (not just SQLException; Room's generated insert can also throw + // e.g. IllegalStateException from an already-closed database) crashes the whole process, + // not just fails to record one Recents entry. The project-open state above is already set + // synchronously, so a Recents-write failure doesn't affect it. log.warn("Failed to record opened project '{}' in Recents", recentProject.name, e) } } From de62fac1dc8a418c290c100d2f932ae24ccd2f17 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 11 Aug 2026 22:10:42 -0700 Subject: [PATCH 38/39] ADFA-5067 | Document the full deep-link routing/file-open flow The App-Links paragraph only covered the "nothing open" and "different project open" cases, omitting the "same project already open -- just navigate" branch and the whole file/line/column-opening feature (PendingFileRequest, applyDeepLinkFileRequest, resolveWithinDirectory's path-traversal guard). Found by a fresh full re-review of PR 1651. --- ARCHITECTURE.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c0b9a20e8e..365e4b1c2a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -51,7 +51,11 @@ Feature code layers as **UI → ViewModel → Repository → data source**, with **EventBus is a deliberate side-channel.** Long-running, cross-module signals (build/install lifecycle, editor events) are broadcast via GreenRobot EventBus (`@Subscribe(threadMode = ThreadMode.MAIN)`) and the `eventbus-events` module's shared event types. Treat it as the integration bus *between* subsystems; don't use it to replace a ViewModel's own state inside a single screen. -**App Links enter through a UI-less trampoline, not `MainActivity` directly.** `DeepLinkActivity` (`app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt`) is the sole `` holder for `https://www.appdevforall.org/device/open/project/...`. It never renders anything — it parses the URI into a `DeepLinkRequest`, checks whether an editor is already on screen (`ActionContextProvider.getActivity()`, the live `EditorHandlerActivity` tracker -- not `IProjectManager`'s `workspace`, which stays null for the whole duration of a Gradle sync even while the editor is already open), and routes to `MainActivity` (nothing open) or the live, `singleTask` `EditorActivityKt`/`EditorHandlerActivity` (a project is open — reused via `onNewIntent`), then finishes itself. This avoids a visible flash of `MainActivity`'s real UI when the actual destination is the already-running editor. `EditorHandlerActivity` owns the "different project is open" case entirely (it has the close-confirmation dialog and the open-tab state `MainActivity` doesn't): closing runs through the existing, unmodified `confirmProjectClose()` dialog, and only once the user actually confirms does an `onDestroy()`-triggered hand-off (`PendingDeepLinkOpen`) start the new project — deliberately deferred to `onDestroy()`, not fired synchronously after `finish()`, so the new `PROJECT_PATH` can't race a `singleTask` re-delivery to the dying instance. +**App Links enter through a UI-less trampoline, not `MainActivity` directly.** `DeepLinkActivity` (`app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt`) is the sole `` holder for `https://www.appdevforall.org/device/open/project/...`. It never renders anything — it parses the URI into a `DeepLinkRequest` (project name plus an optional file/line/column), checks whether an editor is already on screen (`ActionContextProvider.getActivity()`, the live `EditorHandlerActivity` tracker -- not `IProjectManager`'s `workspace`, which stays null for the whole duration of a Gradle sync even while the editor is already open), and routes to `MainActivity` (nothing open) or the live, `singleTask` `EditorActivityKt`/`EditorHandlerActivity` (a project is open — reused via `onNewIntent`), then finishes itself. This avoids a visible flash of `MainActivity`'s real UI when the actual destination is the already-running editor. + +`EditorHandlerActivity.onNewIntent` then branches three ways, comparing `projectDirPath` (set as soon as a project starts opening) rather than `workspace` so the mid-sync case still matches correctly: **same project already open** — no project-wise work, just navigate to the requested file (`applyDeepLinkFileRequest`); **a different project is open** — the existing, unmodified `confirmProjectClose()` dialog runs (it also guards against a second confirm-close request overlapping a manual close or an in-flight save), and only once the user actually confirms does an `onDestroy()`-triggered hand-off (`PendingDeepLinkOpen`, Koin-provided) start the new project — deliberately deferred to `onDestroy()`, not fired synchronously after `finish()`, so the new `PROJECT_PATH` can't race a `singleTask` re-delivery to the dying instance; **nothing was open** — `MainActivity.openProject`/`EditorHandlerActivity.postProjectInit` apply the pending file request once the cold-opened project's sync succeeds. + +The optional file path is attacker-controllable (a URL segment), so it's resolved through `PathTraversal.resolveWithinDirectory`'s traversal/symlink guard rather than a bare `File` join, both when opening a file in the already-open project and when matching the requested project name to a directory under `Environment.PROJECTS_DIR` (`findValidProjectByName`). ## Module Structure From 7a89bd6ae45e51954b530141d56599b864b7fa27 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 11 Aug 2026 22:35:55 -0700 Subject: [PATCH 39/39] ADFA-5067 | Use the inherited SLF4J logger, not android.util.Log saveAllAsync's new failure log used Log.e() in a class that already has BaseEditorActivity's protected SLF4J log field, against this repo's "use SLF4J LoggerFactory rather than android.util.Log" coding guideline. Also fixes a stale comment still referring to the guard by its old name (closeInProgress -> confirmCloseInProgress). Found by CodeRabbit's review of PR 1651. --- .../androidide/activities/editor/EditorHandlerActivity.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index 4aeb4b82a8..1b15aa72b7 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -948,9 +948,9 @@ open class EditorHandlerActivity : } catch (e: Exception) { // A write failure here (e.g. CodeEditorView.save()'s IOException) must not skip // runAfter below -- callers rely on it always running to know the save attempt is - // over, successful or not (e.g. confirmProjectClose's closeInProgress guard, which - // would otherwise stay stuck true and permanently block closing this activity). - Log.e("EditorHandlerActivity", "saveAll failed", e) + // over, successful or not (e.g. confirmProjectClose's confirmCloseInProgress guard, + // which would otherwise stay stuck true and permanently block closing this activity). + log.error("saveAll failed", e) } withContext(Dispatchers.Main) { runAfter?.invoke()