From 2df5a56160d3d7c274655464ef51ef482a4c8362 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 16:25:04 -0700 Subject: [PATCH 01/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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() From 49c0cd308204235c77296b81c400909956383f1d Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 14 Aug 2026 13:02:03 -0700 Subject: [PATCH 40/55] ADFA-5067: Validate deep-link scheme/host; fix silent line/column parsing gap DeepLinkActivity is exported="true" (required for App Links), so its intent-filter's data scoping only constrains implicit intent matching -- any co-installed app can still target it with an explicit intent carrying an arbitrary Uri, bypassing the manifest's host/path restriction entirely. DeepLinkRequest.parse now re-validates scheme/host/path prefix itself, closing that gap regardless of how the intent arrived. Also fixes a parsing gap the code-review's second pass found in the line/column backward scan: a bare "line" segment sitting directly in front of a matched "column" pair (e.g. .../line/column/7) was silently folded into the file path with no line requested and no error, instead of being reported as an invalid/missing line value per this class's own documented contract. Adds regression tests for both. --- .../androidide/models/DeepLinkRequest.kt | 40 +++++++++++++++++-- .../androidide/models/DeepLinkRequestTest.kt | 35 ++++++++++++++++ 2 files changed, 71 insertions(+), 4 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 18e001608a..a12738b325 100644 --- a/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt +++ b/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt @@ -53,6 +53,10 @@ data class DeepLinkRequest( companion object { const val EXTRA_KEY = "com.itsaky.androidide.DEEP_LINK_REQUEST" + private const val SCHEME = "https" + private const val HOST = "www.appdevforall.org" + private const val PATH_PREFIX = "/device/open/project/" + private const val SEGMENT_PROJECT = "project" private const val SEGMENT_FILE = "file" private const val SEGMENT_LINE = "line" @@ -73,11 +77,23 @@ data class DeepLinkRequest( /** * 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. + * the URI does not match this scheme/host/path at all, or 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. + * + * [DeepLinkActivity][com.itsaky.androidide.activities.DeepLinkActivity] is `exported="true"` (a + * requirement for App Links), which means its `` data scoping only constrains + * *implicit* intent matching -- any co-installed app can still target it directly with an + * explicit intent carrying an arbitrary [Uri]. Re-checking scheme/host/path prefix here, rather + * than trusting the manifest declaration alone, closes that gap regardless of how the intent + * arrived. */ fun parse(uri: Uri?): DeepLinkRequest? { - val segments = uri?.pathSegments ?: return null + if (uri == null || uri.scheme != SCHEME || uri.host != HOST || uri.path?.startsWith(PATH_PREFIX) != true) { + return null + } + + val segments = uri.pathSegments val projectIdx = segments.indexOfFrom(0, SEGMENT_PROJECT) if (projectIdx < 0 || projectIdx + 1 >= segments.size) { @@ -113,11 +129,27 @@ data class DeepLinkRequest( .takeIf { it >= startIdx && segments[it] == SEGMENT_LINE } ?.also { endIdx = it } + // A "line" segment sitting directly in front of a matched "column" pair (e.g. + // `.../line/column/7`) isn't part of a keyword-value pair itself -- the slot right + // before "column" holds a non-numeric "line" instead of a value -- but it's also not + // the ambiguous-filename case the comment above carves out, since it's adjacent to a + // keyword that WAS recognized. Report it as an invalid (missing) line value rather + // than silently folding "line" into the file path with no line number and no error. + val danglingLineRaw = + if (lineIdx == null && columnIdx != null && + (columnIdx - 1).let { it >= startIdx && segments[it] == SEGMENT_LINE } + ) { + endIdx = columnIdx - 1 + "" // present but not a valid integer -> reported to the user, per this class's docs + } else { + null + } + val filePath = segments.subList(startIdx, endIdx).joinToString("/") PendingFileRequest( filePath = filePath, - lineRaw = lineIdx?.let { segments.getOrNull(it + 1) }, + lineRaw = lineIdx?.let { segments.getOrNull(it + 1) } ?: danglingLineRaw, 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 66fe0796de..98b755b95d 100644 --- a/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt +++ b/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt @@ -200,4 +200,39 @@ class DeepLinkRequestTest { fun `null uri yields null`() { assertThat(DeepLinkRequest.parse(null)).isNull() } + + @Test + fun `wrong scheme yields null`() { + // DeepLinkActivity is exported (required for App Links), so its intent-filter's data scoping + // only constrains implicit intent matching -- an explicit intent from another app can carry + // any Uri. This must be rejected here regardless of how the intent arrived. + assertThat(parse("http://www.appdevforall.org/device/open/project/MyApp")).isNull() + } + + @Test + fun `wrong host yields null`() { + assertThat(parse("https://evil.example/device/open/project/MyApp")).isNull() + } + + @Test + fun `wrong path prefix yields null`() { + assertThat(parse("https://www.appdevforall.org/some/other/path/project/MyApp")).isNull() + } + + @Test + fun `a bare 'line' keyword immediately before a 'column' pair is reported as invalid, not swallowed into the path`() { + // Regression test: `.../line/column/7` has no numeric value for "line" -- unlike the + // swallowed-into-filename ambiguity documented above, "line" here sits directly in front of a + // recognized "column" pair, so it must surface as an invalid line rather than silently + // becoming part of the file path with no line requested and no error. + val request = + parse("https://www.appdevforall.org/device/open/project/MyApp/file/Main.kt/line/column/7") + assertThat(request) + .isEqualTo( + DeepLinkRequest( + projectName = "MyApp", + fileRequest = PendingFileRequest(filePath = "Main.kt", lineRaw = "", columnRaw = "7"), + ), + ) + } } From 8b8150ff23d3cbafddf6c0d0248ef60b74cc6af6 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 14 Aug 2026 13:02:27 -0700 Subject: [PATCH 41/55] ADFA-5067: Fix deep-link project-open/close race conditions from code review Several fixes surfaced by an /code-review xhigh pass on this feature: - MainActivity.openProject: check isFinishing before mutating global project state (ProjectManagerImpl.projectPath, GeneralPreferences.lastOpenedProject, Recents bookkeeping), not after. Previously the state was already pointed at the never-opened project if the activity started finishing mid-flight, with the actual open silently dropped and no error shown. - EditorHandlerActivity.onNewIntent: - Handle IProjectManager.projectDirPath's "" sentinel (no project has actually finished initializing in this instance) as its own case rather than falling into the "different project" branch -- confirmProjectClose silently no-ops there since contentOrNull is null, dropping the deep link with no error shown. Routes through the same onDestroy()-deferred handoff used for a confirmed project switch instead. - Guard the IO-to-Main continuation against isFinishing/isDestroyed -- lifecycleScope only cancels at ON_DESTROY, so a close started while resolveDeepLinkProject was still scanning disk could otherwise still show the confirm-close dialog on a dying window. - Preserve a not-yet-applied PendingFileRequest extra across setIntent(intent) when the new intent doesn't carry its own -- otherwise an unrelated onNewIntent call arriving before postProjectInit reads it (e.g. mid-Gradle-sync) silently drops the pending file/line navigation. - Inlined the now-trivial confirmProjectCloseThenOpen wrapper into its single call site. - EditorHandlerActivity's "Save and close" confirm-close callback: - Always invoke onClosed (e.g. arming a pending deep-link project switch) even when contentOrNull is null (binding torn down while the save was in flight) -- only the view manipulation in performCloseAllFiles actually needs content. - Moved updateProjectModifiedDate so it no longer fires when hasFilesThatFailedToSave() aborts the close -- it was a sibling statement outside the runOnUiThread block, so it ran unconditionally even on a failed save. - EditorHandlerActivity.applyDeepLinkFileRequest: moved the blocking resolveWithinDirectory/File.isFile filesystem check off the main thread, matching openFile's own Dispatchers.IO precedent for its file check. - BaseEditorActivity.onCreate: when a deep-link request is present but this onCreate is a genuinely new EditorActivityKt instance (rather than the live singleTask instance's onNewIntent -- DeepLinkActivity's live-instance check is a documented best-effort, not a guarantee), don't silently substitute GeneralPreferences.lastOpenedProject for the requested project. Forward the deep-link extra to MainActivity instead so it can still resolve and open the correct project. --- .../androidide/activities/MainActivity.kt | 4 +- .../activities/editor/BaseEditorActivity.kt | 36 +++++- .../editor/EditorHandlerActivity.kt | 118 ++++++++++++------ 3 files changed, 109 insertions(+), 49 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 e60fc667c2..53ce7c1103 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt @@ -432,12 +432,12 @@ class MainActivity : EdgeToEdgeIDEActivity() { hasTemplateIssues: Boolean = false, pendingFileRequest: PendingFileRequest? = null, ) { - recordProjectOpenedBookkeeping(recentProjectDao, root, project, analyticsManager) - if (isFinishing) { return } + recordProjectOpenedBookkeeping(recentProjectDao, root, project, analyticsManager) + val intent = Intent(this, EditorActivityKt::class.java).apply { putExtra("PROJECT_PATH", root.absolutePath) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index 2d8f88dc70..fb6b405a28 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -54,6 +54,7 @@ import androidx.annotation.UiThread import androidx.appcompat.app.ActionBarDrawerToggle import androidx.collection.MutableIntIntMap import androidx.core.content.ContextCompat +import androidx.core.content.IntentCompat import androidx.core.graphics.Insets import androidx.core.view.GravityCompat import androidx.core.view.ViewCompat @@ -102,6 +103,7 @@ import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.interfaces.DiagnosticClickListener import com.itsaky.androidide.lookup.Lookup import com.itsaky.androidide.lsp.models.DiagnosticItem +import com.itsaky.androidide.models.DeepLinkRequest import com.itsaky.androidide.models.DiagnosticGroup import com.itsaky.androidide.models.OpenedFile import com.itsaky.androidide.models.Range @@ -653,14 +655,30 @@ abstract class BaseEditorActivity : * building the editor UI. */ override fun onCreate(savedInstanceState: Bundle?) { + // DeepLinkActivity routes a deep link to this activity's class only when it believes a live + // singleTask instance already exists to handle it via onNewIntent (see + // ActionContextProvider.getActivity()'s docs on how that check can still be stale) -- if + // Android instead spins up a genuinely new instance, this onCreate runs and onNewIntent + // never does, so this is EXTRA_KEY's only other reader on the editor side. + val deepLinkRequest = + IntentCompat.getParcelableExtra(intent, DeepLinkRequest.EXTRA_KEY, DeepLinkRequest::class.java) + // The OS can recreate EditorActivity after process death without routing through // MainActivity, leaving the ProjectManagerImpl singleton's lateinit projectPath unset. - // Restore it from the saved state, the launch intent, or the last opened project. - val restoredProjectPath = + // Restore it from the saved state or the launch intent; only fall back to the last opened + // project when there's no pending deep link -- otherwise this would silently open the wrong + // project instead of the one the link actually requested. + val explicitProjectPath = savedInstanceState?.getString(KEY_PROJECT_PATH)?.takeIf { it.isNotBlank() } ?: intent?.getStringExtra("PROJECT_PATH")?.takeIf { it.isNotBlank() } - ?: GeneralPreferences.lastOpenedProject - .takeIf { it.isNotBlank() && it != GeneralPreferences.NO_OPENED_PROJECT } + val restoredProjectPath = + explicitProjectPath + ?: if (deepLinkRequest == null) { + GeneralPreferences.lastOpenedProject + .takeIf { it.isNotBlank() && it != GeneralPreferences.NO_OPENED_PROJECT } + } else { + null + } if (restoredProjectPath != null) { ProjectManagerImpl.getInstance().projectPath = restoredProjectPath } @@ -668,10 +686,16 @@ abstract class BaseEditorActivity : // If we still have no project path after every fallback, we cannot safely build the // editor UI (setupToolbar -> getProjectName dereferences the project path). Route the - // user back to MainActivity instead of crashing. + // user back to MainActivity instead of crashing -- forwarding a pending deep link along so + // MainActivity can still resolve and open the requested project, instead of silently + // dropping it here. if (ProjectManagerImpl.getInstance().projectDirPath.isBlank()) { log.warn("No project path available in EditorActivity.onCreate(); returning to MainActivity") - startActivity(Intent(this, MainActivity::class.java)) + startActivity( + Intent(this, MainActivity::class.java).apply { + deepLinkRequest?.let { putExtra(DeepLinkRequest.EXTRA_KEY, it) } + }, + ) finish() 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 1b15aa72b7..4408a34300 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 @@ -356,7 +356,7 @@ open class EditorHandlerActivity : 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 -- + // onNewIntent's confirmProjectClose(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 @@ -1891,7 +1891,6 @@ open class EditorHandlerActivity : saveAllAsync(notify = false) { 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. @@ -1899,29 +1898,36 @@ open class EditorHandlerActivity : flashError(string.save_failed) return@runOnUiThread } - performCloseAllFiles(manualFinish = true, onClosed = onClosed) + recentProjectsViewModel.updateProjectModifiedDate( + editorViewModel.getProjectName(), + ) + // contentOrNull can already be null here if the binding was torn down while the + // save was in flight -- performCloseAllFiles would NPE on the view manipulation it + // does, but onClosed (e.g. arming a pending deep-link project switch) has no such + // dependency and must still run, or a confirmed close silently drops it. + if (contentOrNull != null) { + performCloseAllFiles(manualFinish = true, onClosed = onClosed) + } else { + onClosed?.invoke() + } } - recentProjectsViewModel.updateProjectModifiedDate( - editorViewModel.getProjectName(), - ) } } activeProjectCloseDialog = 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) + + // Preserve a not-yet-applied file-navigation request from the previous intent -- postProjectInit + // reads it lazily once a sync completes, and setIntent() below would otherwise silently drop it + // if this onNewIntent call is for something unrelated to that pending request. + if (!intent.hasExtra(PendingFileRequest.EXTRA_KEY)) { + IntentCompat + .getParcelableExtra(getIntent(), PendingFileRequest.EXTRA_KEY, PendingFileRequest::class.java) + ?.let { intent.putExtra(PendingFileRequest.EXTRA_KEY, it) } + } setIntent(intent) val request = @@ -1931,21 +1937,42 @@ open class EditorHandlerActivity : lifecycleScope.launch(Dispatchers.IO) { val projectDir = resolveDeepLinkProject(Environment.PROJECTS_DIR, request.projectName) ?: return@launch withContext(Dispatchers.Main) { - // 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 - } + // The activity may have started finishing while resolveDeepLinkProject was still + // scanning disk -- lifecycleScope only cancels at ON_DESTROY, not the moment isFinishing + // first flips true, so this continuation can otherwise still run and try to show the + // confirm-close dialog on a dying window. + if (isFinishing || isDestroyed) return@withContext + + val currentProjectPath = IProjectManager.getInstance().projectDirPath + when { + currentProjectPath.isBlank() -> { + // No project has actually finished initializing in this instance (e.g. it was + // recreated after process death without a PROJECT_PATH extra) -- confirmProjectClose + // would silently no-op here since contentOrNull is null, dropping the deep link + // with no error shown. Route through the same onDestroy()-deferred handoff used for + // a confirmed project switch instead of showing a close dialog for a project that, + // as far as the user can see, was never really open. + pendingDeepLinkOpen.value = DeepLinkOpenRequest(projectDir.absolutePath, request.fileRequest) + finish() + } - // 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) + // 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. + projectDir.absolutePath == currentProjectPath -> { + // Requirement #2: same project already open -- no-op project-wise, just navigate. + request.fileRequest?.let { applyDeepLinkFileRequest(it) } + } + + else -> { + // 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. + confirmProjectClose { + pendingDeepLinkOpen.value = DeepLinkOpenRequest(projectDir.absolutePath, request.fileRequest) + } + } } } } @@ -1976,21 +2003,30 @@ open class EditorHandlerActivity : * 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. + * + * [resolveWithinDirectory]'s ancestor-symlink walk and the [File.isFile] check both hit disk, so + * -- like [openFile]'s own image check -- this runs off [Dispatchers.IO] rather than blocking the + * main thread the two call sites (`onNewIntent`, [postProjectInit]) invoke this from. */ private fun applyDeepLinkFileRequest(request: PendingFileRequest) { - val projectDir = File(IProjectManager.getInstance().projectDirPath) - val file = resolveWithinDirectory(projectDir, request.filePath) - if (file == null || !file.isFile) { - flashError(getString(string.msg_deeplink_file_not_found, request.filePath)) - return - } + lifecycleScope.launch(Dispatchers.IO) { + val projectDir = File(IProjectManager.getInstance().projectDirPath) + val file = resolveWithinDirectory(projectDir, request.filePath)?.takeIf { it.isFile } - // URL line/column are 1-based; internal Position is 0-based. - val line = zeroBasedOrFlashError(request.lineRaw, string.msg_deeplink_invalid_line) - val column = zeroBasedOrFlashError(request.columnRaw, string.msg_deeplink_invalid_column) + withContext(Dispatchers.Main) { + if (file == null) { + flashError(getString(string.msg_deeplink_file_not_found, request.filePath)) + return@withContext + } - val pos = Position(line, column) - openFileAndSelect(file, Range(pos, pos)) + // URL line/column are 1-based; internal Position is 0-based. + 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)) + } + } } /** From 232d249849c6598f1fde21e274d3e51a5f181065 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 14 Aug 2026 15:41:05 -0700 Subject: [PATCH 42/55] ADFA-5067: Document the blank-projectDirPath branch in onNewIntent The architecture-review pass found ARCHITECTURE.md described EditorHandlerActivity.onNewIntent's deep-link routing as two-way (same project / different project), but an earlier fix in this branch added a third branch: when projectDirPath is still blank (this instance never finished initializing a project), it defers through the same onDestroy()-deferred handoff instead of showing a confirm-close dialog that would silently no-op. --- ARCHITECTURE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d9312d6b1e..533c819b8f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -53,7 +53,7 @@ Feature code layers as **UI → ViewModel → Repository → data source**, with **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. +`EditorHandlerActivity.onNewIntent` then branches on `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; `projectDirPath` **is still blank** — this instance never actually finished initializing a project (e.g. recreated after process death with no `PROJECT_PATH` extra), so `confirmProjectClose()` would silently no-op (`contentOrNull` is null); this case reuses the same `onDestroy()`-deferred hand-off instead of showing a close dialog for a project that was never really open; **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`). From e2e4f0326794b065a1c2115cb4487e6985fbf5b7 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 14 Aug 2026 20:04:22 -0700 Subject: [PATCH 43/55] ADFA-5067: Reindent GitBottomSheetFragment.kt and IEditorHandler.kt to tabs Mechanical only, no logic change -- both files predate this branch and were never reformatted; the Spotless ratchet pulls in the whole file the first time either is touched, so isolate that reformat from the behavioral fixes that actually motivate touching them. --- .../fragments/git/GitBottomSheetFragment.kt | 853 +++++++++--------- .../androidide/interfaces/IEditorHandler.kt | 148 +-- 2 files changed, 524 insertions(+), 477 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index 51a0acda05..76078f1878 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -38,420 +38,441 @@ import org.koin.androidx.viewmodel.ext.android.activityViewModel import java.io.File class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { - - private val viewModel: GitBottomSheetViewModel by activityViewModel() - private val bottomSheetViewModel: BottomSheetViewModel by activityViewModel() - private lateinit var fileChangeAdapter: GitFileChangeAdapter - private lateinit var credentialsManager: GitCredentialsManager - - private var _binding: FragmentGitBottomSheetBinding? = null - private val binding get() = _binding!! - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - _binding = FragmentGitBottomSheetBinding.bind(view) - credentialsManager = GitCredentialsManager(requireContext()) - - fileChangeAdapter = GitFileChangeAdapter( - onFileClicked = { change -> - when (change.type) { - ChangeType.CONFLICTED -> { - val activity = requireActivity() - if (activity is EditorHandlerActivity) { - viewLifecycleOwner.lifecycleScope.launch { - val repo = viewModel.currentRepository - repo?.let { - activity.checkForExternalFileChanges(force = true) - activity.openFile(File(repo.rootDir, change.path)) - bottomSheetViewModel.setSheetState(BottomSheetBehavior.STATE_COLLAPSED) - } - } - } - } - - else -> { - val dialog = GitDiffViewerDialog.newInstance(change.path) - dialog.show(childFragmentManager, "GitDiffViewerDialog") - } - } - }, - onSelectionChanged = { - validateCommitButton() - updateCheckAllButton() - }, - onResolveConflict = { change -> - viewModel.resolveConflict(change.path) - } - ) - - binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) - binding.recyclerView.adapter = fileChangeAdapter - binding.recyclerView.onLongPress { _ -> - TooltipManager.showIdeCategoryTooltip( - context = requireContext(), - anchorView = binding.recyclerView, - tag = TooltipTag.PROJECT_GIT_FILES, - ) - } - - viewLifecycleOwner.lifecycleScope.launch { - launch { - viewModel.currentBranch.collectLatest { branchName -> - if (branchName != null) { - binding.tvBranchName.visibility = View.VISIBLE - binding.tvBranchName.text = - getString(R.string.current_branch_name, branchName) - } else { - binding.tvBranchName.visibility = View.GONE - } - } - } - - combine( - viewModel.isGitRepository, - viewModel.gitStatus - ) { isRepo, status -> - val allChanges = - status.staged + status.unstaged + status.untracked + status.conflicted - - when { - !isRepo -> binding.apply { - emptyView.visibility = View.VISIBLE - emptyView.text = getString(R.string.not_a_git_repo) - recyclerView.visibility = View.GONE - btnCheckAll.visibility = View.GONE - commitSection.visibility = View.GONE - authorWarning.visibility = View.GONE - commitHistoryButton.visibility = View.GONE - btnAbortMerge.visibility = View.GONE - } - - allChanges.isEmpty() -> binding.apply { - emptyView.visibility = View.VISIBLE - emptyView.text = getString(R.string.no_uncommitted_changes) - recyclerView.visibility = View.GONE - btnCheckAll.visibility = View.GONE - commitSection.visibility = View.GONE - authorWarning.visibility = View.GONE - commitHistoryButton.visibility = View.VISIBLE - btnAbortMerge.visibility = View.GONE - } - - else -> { - // Only offer "Check All" when there is at least one - // non-conflicted file; conflicted files can't be staged. - val hasSelectable = allChanges.any { it.type != ChangeType.CONFLICTED } - binding.apply { - emptyView.visibility = View.GONE - recyclerView.visibility = View.VISIBLE - btnCheckAll.visibility = - if (hasSelectable) View.VISIBLE else View.GONE - commitSection.visibility = View.VISIBLE - authorWarning.visibility = - if (hasAuthorInfo()) View.GONE else View.VISIBLE - commitHistoryButton.visibility = View.VISIBLE - btnAbortMerge.visibility = - if (status.isMerging) View.VISIBLE else View.GONE - } - fileChangeAdapter.submitList(allChanges) { - updateCheckAllButton() - } - } - } - }.collectLatest { } - } - - setupCommitUI() - - binding.commitHistoryButton.apply { - setOnClickListener { - val dialog = GitCommitHistoryDialog() - dialog.show(childFragmentManager, "CommitHistoryDialog") - } - setTooltipOnView(TooltipTag.PROJECT_GIT_COMMIT_HISTORY) - } - - setupPullUI() - } - - override fun onResume() { - super.onResume() - updateAuthorUI() - } - - private fun updateAuthorUI() { - val hasAuthor = hasAuthorInfo() - val allChanges = - viewModel.gitStatus.value.staged + viewModel.gitStatus.value.unstaged + viewModel.gitStatus.value.untracked + viewModel.gitStatus.value.conflicted - binding.authorWarning.visibility = - if (!hasAuthor && allChanges.isNotEmpty()) View.VISIBLE else View.GONE - validateCommitButton() - } - - private fun hasAuthorInfo(): Boolean { - return !GitPreferences.userName.isNullOrBlank() && !GitPreferences.userEmail.isNullOrBlank() - } - - private fun setupCommitUI() { - binding.commitSummary.doAfterTextChanged { validateCommitButton() } - binding.commitDescription.doAfterTextChanged { validateCommitButton() } - - binding.btnCheckAll.setOnClickListener { - if (fileChangeAdapter.areAllSelected()) { - fileChangeAdapter.clearSelection() - } else { - fileChangeAdapter.selectAll() - } - } - - binding.btnAbortMerge.apply { - setOnClickListener { - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.abort_merge) - .setMessage(R.string.confirm_abort_merge) - .setPositiveButton(R.string.abort_merge) { _, _ -> - viewModel.abortMerge { - val activity = requireActivity() - if (activity is EditorHandlerActivity) { - activity.checkForExternalFileChanges(force = true) - } - } - } - .setNegativeButton(android.R.string.cancel, null) - .create() - dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_ABORT_MERGE) - dialog.show() - } - setTooltipOnView(TooltipTag.PROJECT_GIT_ABORT) - } - - binding.authorAvatar.apply { - setOnClickListener { showAuthorPopup() } - setTooltipOnView(TooltipTag.PROJECT_GIT_ID) - } - - binding.commitButton.apply { - setOnClickListener { - checkUnsavedChangesAndProceed { - val summary = binding.commitSummary.text?.toString()?.trim() ?: "" - val description = binding.commitDescription.text?.toString()?.trim() - - if (summary.isNotEmpty() && fileChangeAdapter.selectedFiles.isNotEmpty() && hasAuthorInfo()) { - viewModel.commitChanges( - summary = summary, - description = description, - selectedPaths = fileChangeAdapter.selectedFiles.toList() - ) { - // Clear the inputs on successful commit - binding.commitSummary.text?.clear() - binding.commitDescription.text?.clear() - fileChangeAdapter.selectedFiles.clear() - updateCheckAllButton() - } - } - } - } - setTooltipOnView(TooltipTag.PROJECT_GIT_COMMIT) - } - } - - private fun showAuthorPopup() { - val name = GitPreferences.userName.orEmpty().ifBlank { getString(R.string.author_not_set) } - val email = - GitPreferences.userEmail.orEmpty().ifBlank { getString(R.string.author_not_set) } - val message = getString(R.string.git_committing_as, name) + "\n" + - getString(R.string.git_committing_email, email) + "\n\n" + - getString(R.string.git_update_config_in_preferences) - - val spannable = SpannableString(message) - val preferencesText = getString(R.string.git_update_config_in_preferences) - val startIndex = message.indexOf(preferencesText) - - val builder = MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.idepref_git_author_title) - .setMessage(spannable) - .setPositiveButton(android.R.string.ok, null) - - val dialog = builder.create() - - if (startIndex != -1) { - spannable.setSpan( - object : ClickableSpan() { - override fun onClick(widget: View) { - val intent = Intent( - requireContext(), - PreferencesActivity::class.java - ) - dialog.dismiss() - startActivity(intent) - } - }, - startIndex, - startIndex + preferencesText.length, - SPAN_EXCLUSIVE_EXCLUSIVE - ) - } - - dialog.show() - dialog.findViewById(android.R.id.message)?.movementMethod = - LinkMovementMethod.getInstance() - } - - private fun validateCommitButton() { - // May be invoked from async adapter callbacks; bail if the view is gone. - val binding = _binding ?: return - val hasSummary = !binding.commitSummary.text.isNullOrBlank() - val hasSelection = fileChangeAdapter.selectedFiles.isNotEmpty() - val hasAuthor = hasAuthorInfo() - binding.commitButton.isEnabled = hasSummary && hasSelection && hasAuthor - } - - private fun updateCheckAllButton() { - // May be invoked from the async submitList commit callback; bail if the view is gone. - val binding = _binding ?: return - binding.btnCheckAll.setText( - if (fileChangeAdapter.areAllSelected()) R.string.uncheck_all else R.string.check_all - ) - } - - private fun setupPullUI() { - viewLifecycleOwner.lifecycleScope.launch { - viewModel.isGitRepository.collectLatest { isRepo -> - binding.btnPull.visibility = if (isRepo) View.VISIBLE else View.GONE - } - } - - viewLifecycleOwner.lifecycleScope.launch { - viewModel.pullState.collectLatest { state -> - when (state) { - is PullUiState.Idle -> { - binding.btnPull.isEnabled = true - binding.pullProgress.visibility = View.GONE - } - - is PullUiState.Pulling -> { - binding.btnPull.isEnabled = false - binding.pullProgress.visibility = View.VISIBLE - } - - is PullUiState.Success -> { - binding.btnPull.isEnabled = true - binding.pullProgress.visibility = View.GONE - flashSuccess(R.string.pull_successful) - viewModel.resetPullState() - refreshEditorContent() - } - - is PullUiState.Conflicts -> { - binding.btnPull.isEnabled = true - binding.pullProgress.visibility = View.GONE - val message = state.message ?: getString(R.string.info_merge_conflicts) - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(R.string.merge_conflicts)) - .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .create() - dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_MERGE_CONFLICTS) - dialog.show() - viewModel.resetPullState() - refreshEditorContent() - } - - is PullUiState.Error -> { - binding.btnPull.isEnabled = true - binding.pullProgress.visibility = View.GONE - val message = - state.message ?: state.errorResId?.let { resId -> - if (state.errorArgs != null) getString( - resId, - *state.errorArgs.toTypedArray() - ) else getString(resId) - } - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.pull_failed) - .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .create() - dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_PULL_FAIL) - dialog.show() - } - } - } - } - - binding.btnPull.apply { - setOnClickListener { - checkUnsavedChangesAndProceed { - val username = credentialsManager.getUsername() - val token = credentialsManager.getToken() - if (!username.isNullOrBlank() && !token.isNullOrBlank()) { - viewModel.pull(username, token) - } else { - showGitCredentialsDialog( - credentialsManager = credentialsManager, - positiveButtonTextResId = R.string.pull - ) { user, accessToken -> - viewModel.pull(user, accessToken) - } - } - } - } - setTooltipOnView(TooltipTag.GIT_PULL) - } - } - - private fun refreshEditorContent(force: Boolean = false) { - val activity = requireActivity() - if (activity is EditorHandlerActivity) { - activity.checkForExternalFileChanges(force) - } - } - - private fun checkUnsavedChangesAndProceed(action: () -> Unit) { - val handler = requireActivity() as? IEditorHandler - if (handler?.areFilesModified() == true) { - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.title_files_unsaved) - .setMessage(R.string.msg_save_before_git_action) - .setPositiveButton(R.string.save_before_git_action) { _, _ -> - handler.saveAllAsync { action() } - } - .setNegativeButton(R.string.no_save_before_git_action) { _, _ -> - action() - } - .setNeutralButton(android.R.string.cancel, null) - .create() - dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_SAVE) - dialog.show() - } else { - action() - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - private fun AlertDialog.setTooltipOnDialog(tag: String) { - onLongPress { view -> - TooltipManager.showIdeCategoryTooltip( - context = view.context, - anchorView = view, - tag = tag - ) - true - } - } - - private fun View.setTooltipOnView(tag: String) { - setOnLongClickListener { view -> - TooltipManager.showIdeCategoryTooltip( - context = view.context, - anchorView = view, - tag = tag - ) - true - } - } + private val viewModel: GitBottomSheetViewModel by activityViewModel() + private val bottomSheetViewModel: BottomSheetViewModel by activityViewModel() + private lateinit var fileChangeAdapter: GitFileChangeAdapter + private lateinit var credentialsManager: GitCredentialsManager + + @Suppress("ktlint:standard:backing-property-naming") + private var _binding: FragmentGitBottomSheetBinding? = null + private val binding get() = _binding!! + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + _binding = FragmentGitBottomSheetBinding.bind(view) + credentialsManager = GitCredentialsManager(requireContext()) + + fileChangeAdapter = + GitFileChangeAdapter( + onFileClicked = { change -> + when (change.type) { + ChangeType.CONFLICTED -> { + val activity = requireActivity() + if (activity is EditorHandlerActivity) { + viewLifecycleOwner.lifecycleScope.launch { + val repo = viewModel.currentRepository + repo?.let { + activity.checkForExternalFileChanges(force = true) + activity.openFile(File(repo.rootDir, change.path)) + bottomSheetViewModel.setSheetState(BottomSheetBehavior.STATE_COLLAPSED) + } + } + } + } + + else -> { + val dialog = GitDiffViewerDialog.newInstance(change.path) + dialog.show(childFragmentManager, "GitDiffViewerDialog") + } + } + }, + onSelectionChanged = { + validateCommitButton() + updateCheckAllButton() + }, + onResolveConflict = { change -> + viewModel.resolveConflict(change.path) + }, + ) + + binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) + binding.recyclerView.adapter = fileChangeAdapter + binding.recyclerView.onLongPress { _ -> + TooltipManager.showIdeCategoryTooltip( + context = requireContext(), + anchorView = binding.recyclerView, + tag = TooltipTag.PROJECT_GIT_FILES, + ) + } + + viewLifecycleOwner.lifecycleScope.launch { + launch { + viewModel.currentBranch.collectLatest { branchName -> + if (branchName != null) { + binding.tvBranchName.visibility = View.VISIBLE + binding.tvBranchName.text = + getString(R.string.current_branch_name, branchName) + } else { + binding.tvBranchName.visibility = View.GONE + } + } + } + + combine( + viewModel.isGitRepository, + viewModel.gitStatus, + ) { isRepo, status -> + val allChanges = + status.staged + status.unstaged + status.untracked + status.conflicted + + when { + !isRepo -> { + binding.apply { + emptyView.visibility = View.VISIBLE + emptyView.text = getString(R.string.not_a_git_repo) + recyclerView.visibility = View.GONE + btnCheckAll.visibility = View.GONE + commitSection.visibility = View.GONE + authorWarning.visibility = View.GONE + commitHistoryButton.visibility = View.GONE + btnAbortMerge.visibility = View.GONE + } + } + + allChanges.isEmpty() -> { + binding.apply { + emptyView.visibility = View.VISIBLE + emptyView.text = getString(R.string.no_uncommitted_changes) + recyclerView.visibility = View.GONE + btnCheckAll.visibility = View.GONE + commitSection.visibility = View.GONE + authorWarning.visibility = View.GONE + commitHistoryButton.visibility = View.VISIBLE + btnAbortMerge.visibility = View.GONE + } + } + + else -> { + // Only offer "Check All" when there is at least one + // non-conflicted file; conflicted files can't be staged. + val hasSelectable = allChanges.any { it.type != ChangeType.CONFLICTED } + binding.apply { + emptyView.visibility = View.GONE + recyclerView.visibility = View.VISIBLE + btnCheckAll.visibility = + if (hasSelectable) View.VISIBLE else View.GONE + commitSection.visibility = View.VISIBLE + authorWarning.visibility = + if (hasAuthorInfo()) View.GONE else View.VISIBLE + commitHistoryButton.visibility = View.VISIBLE + btnAbortMerge.visibility = + if (status.isMerging) View.VISIBLE else View.GONE + } + fileChangeAdapter.submitList(allChanges) { + updateCheckAllButton() + } + } + } + }.collectLatest { } + } + + setupCommitUI() + + binding.commitHistoryButton.apply { + setOnClickListener { + val dialog = GitCommitHistoryDialog() + dialog.show(childFragmentManager, "CommitHistoryDialog") + } + setTooltipOnView(TooltipTag.PROJECT_GIT_COMMIT_HISTORY) + } + + setupPullUI() + } + + override fun onResume() { + super.onResume() + updateAuthorUI() + } + + private fun updateAuthorUI() { + val hasAuthor = hasAuthorInfo() + val allChanges = + viewModel.gitStatus.value.staged + viewModel.gitStatus.value.unstaged + viewModel.gitStatus.value.untracked + + viewModel.gitStatus.value.conflicted + binding.authorWarning.visibility = + if (!hasAuthor && allChanges.isNotEmpty()) View.VISIBLE else View.GONE + validateCommitButton() + } + + private fun hasAuthorInfo(): Boolean = !GitPreferences.userName.isNullOrBlank() && !GitPreferences.userEmail.isNullOrBlank() + + private fun setupCommitUI() { + binding.commitSummary.doAfterTextChanged { validateCommitButton() } + binding.commitDescription.doAfterTextChanged { validateCommitButton() } + + binding.btnCheckAll.setOnClickListener { + if (fileChangeAdapter.areAllSelected()) { + fileChangeAdapter.clearSelection() + } else { + fileChangeAdapter.selectAll() + } + } + + binding.btnAbortMerge.apply { + setOnClickListener { + val dialog = + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.abort_merge) + .setMessage(R.string.confirm_abort_merge) + .setPositiveButton(R.string.abort_merge) { _, _ -> + viewModel.abortMerge { + val activity = requireActivity() + if (activity is EditorHandlerActivity) { + activity.checkForExternalFileChanges(force = true) + } + } + }.setNegativeButton(android.R.string.cancel, null) + .create() + dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_ABORT_MERGE) + dialog.show() + } + setTooltipOnView(TooltipTag.PROJECT_GIT_ABORT) + } + + binding.authorAvatar.apply { + setOnClickListener { showAuthorPopup() } + setTooltipOnView(TooltipTag.PROJECT_GIT_ID) + } + + binding.commitButton.apply { + setOnClickListener { + checkUnsavedChangesAndProceed { + val summary = + binding.commitSummary.text + ?.toString() + ?.trim() ?: "" + val description = + binding.commitDescription.text + ?.toString() + ?.trim() + + if (summary.isNotEmpty() && fileChangeAdapter.selectedFiles.isNotEmpty() && hasAuthorInfo()) { + viewModel.commitChanges( + summary = summary, + description = description, + selectedPaths = fileChangeAdapter.selectedFiles.toList(), + ) { + // Clear the inputs on successful commit + binding.commitSummary.text?.clear() + binding.commitDescription.text?.clear() + fileChangeAdapter.selectedFiles.clear() + updateCheckAllButton() + } + } + } + } + setTooltipOnView(TooltipTag.PROJECT_GIT_COMMIT) + } + } + + private fun showAuthorPopup() { + val name = GitPreferences.userName.orEmpty().ifBlank { getString(R.string.author_not_set) } + val email = + GitPreferences.userEmail.orEmpty().ifBlank { getString(R.string.author_not_set) } + val message = + getString(R.string.git_committing_as, name) + "\n" + + getString(R.string.git_committing_email, email) + "\n\n" + + getString(R.string.git_update_config_in_preferences) + + val spannable = SpannableString(message) + val preferencesText = getString(R.string.git_update_config_in_preferences) + val startIndex = message.indexOf(preferencesText) + + val builder = + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.idepref_git_author_title) + .setMessage(spannable) + .setPositiveButton(android.R.string.ok, null) + + val dialog = builder.create() + + if (startIndex != -1) { + spannable.setSpan( + object : ClickableSpan() { + override fun onClick(widget: View) { + val intent = + Intent( + requireContext(), + PreferencesActivity::class.java, + ) + dialog.dismiss() + startActivity(intent) + } + }, + startIndex, + startIndex + preferencesText.length, + SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + + dialog.show() + dialog.findViewById(android.R.id.message)?.movementMethod = + LinkMovementMethod.getInstance() + } + + private fun validateCommitButton() { + // May be invoked from async adapter callbacks; bail if the view is gone. + val binding = _binding ?: return + val hasSummary = !binding.commitSummary.text.isNullOrBlank() + val hasSelection = fileChangeAdapter.selectedFiles.isNotEmpty() + val hasAuthor = hasAuthorInfo() + binding.commitButton.isEnabled = hasSummary && hasSelection && hasAuthor + } + + private fun updateCheckAllButton() { + // May be invoked from the async submitList commit callback; bail if the view is gone. + val binding = _binding ?: return + binding.btnCheckAll.setText( + if (fileChangeAdapter.areAllSelected()) R.string.uncheck_all else R.string.check_all, + ) + } + + private fun setupPullUI() { + viewLifecycleOwner.lifecycleScope.launch { + viewModel.isGitRepository.collectLatest { isRepo -> + binding.btnPull.visibility = if (isRepo) View.VISIBLE else View.GONE + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.pullState.collectLatest { state -> + when (state) { + is PullUiState.Idle -> { + binding.btnPull.isEnabled = true + binding.pullProgress.visibility = View.GONE + } + + is PullUiState.Pulling -> { + binding.btnPull.isEnabled = false + binding.pullProgress.visibility = View.VISIBLE + } + + is PullUiState.Success -> { + binding.btnPull.isEnabled = true + binding.pullProgress.visibility = View.GONE + flashSuccess(R.string.pull_successful) + viewModel.resetPullState() + refreshEditorContent() + } + + is PullUiState.Conflicts -> { + binding.btnPull.isEnabled = true + binding.pullProgress.visibility = View.GONE + val message = state.message ?: getString(R.string.info_merge_conflicts) + val dialog = + MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.merge_conflicts)) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .create() + dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_MERGE_CONFLICTS) + dialog.show() + viewModel.resetPullState() + refreshEditorContent() + } + + is PullUiState.Error -> { + binding.btnPull.isEnabled = true + binding.pullProgress.visibility = View.GONE + val message = + state.message ?: state.errorResId?.let { resId -> + if (state.errorArgs != null) { + getString( + resId, + *state.errorArgs.toTypedArray(), + ) + } else { + getString(resId) + } + } + val dialog = + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.pull_failed) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .create() + dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_PULL_FAIL) + dialog.show() + } + } + } + } + + binding.btnPull.apply { + setOnClickListener { + checkUnsavedChangesAndProceed { + val username = credentialsManager.getUsername() + val token = credentialsManager.getToken() + if (!username.isNullOrBlank() && !token.isNullOrBlank()) { + viewModel.pull(username, token) + } else { + showGitCredentialsDialog( + credentialsManager = credentialsManager, + positiveButtonTextResId = R.string.pull, + ) { user, accessToken -> + viewModel.pull(user, accessToken) + } + } + } + } + setTooltipOnView(TooltipTag.GIT_PULL) + } + } + + private fun refreshEditorContent(force: Boolean = false) { + val activity = requireActivity() + if (activity is EditorHandlerActivity) { + activity.checkForExternalFileChanges(force) + } + } + + private fun checkUnsavedChangesAndProceed(action: () -> Unit) { + val handler = requireActivity() as? IEditorHandler + if (handler?.areFilesModified() == true) { + val dialog = + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.title_files_unsaved) + .setMessage(R.string.msg_save_before_git_action) + .setPositiveButton(R.string.save_before_git_action) { _, _ -> + handler.saveAllAsync { action() } + }.setNegativeButton(R.string.no_save_before_git_action) { _, _ -> + action() + }.setNeutralButton(android.R.string.cancel, null) + .create() + dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_SAVE) + dialog.show() + } else { + action() + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun AlertDialog.setTooltipOnDialog(tag: String) { + onLongPress { view -> + TooltipManager.showIdeCategoryTooltip( + context = view.context, + anchorView = view, + tag = tag, + ) + true + } + } + + private fun View.setTooltipOnView(tag: String) { + setOnLongClickListener { view -> + TooltipManager.showIdeCategoryTooltip( + context = view.context, + anchorView = view, + tag = tag, + ) + true + } + } } diff --git a/app/src/main/java/com/itsaky/androidide/interfaces/IEditorHandler.kt b/app/src/main/java/com/itsaky/androidide/interfaces/IEditorHandler.kt index 3a25c882b7..dbe215afae 100644 --- a/app/src/main/java/com/itsaky/androidide/interfaces/IEditorHandler.kt +++ b/app/src/main/java/com/itsaky/androidide/interfaces/IEditorHandler.kt @@ -26,64 +26,90 @@ import java.io.File * @author Akash Yadav */ interface IEditorHandler { - - fun findIndexOfEditorByFile(file: File?) : Int - - fun getCurrentEditor(): CodeEditorView? - fun getEditorAtIndex(index: Int) : CodeEditorView? - fun getEditorForFile(file: File) : CodeEditorView? - - suspend fun openFile(file: File) : CodeEditorView? = openFile(file, null) - suspend fun openFile(file: File, selection: Range?) : CodeEditorView? - fun openFileAndSelect(file: File, selection: Range?) - fun openFileAndGetIndex(file: File, selection: Range?) : Int - - fun areFilesModified(): Boolean - fun areFilesSaving(): Boolean - - /** - * Save all files. - * - * @param notify Whether to notify the user about the save event. - * @param processResources Whether the resources must be generated after the save operation. - * @param progressConsumer A function which consumes the progress of the save operation. - * See [saveAllResult] for more details. - */ - suspend fun saveAll( - notify: Boolean = true, - requestSync: Boolean = true, - processResources: Boolean = false, - progressConsumer: ((progress: Int, total: Int) -> Unit)? = null - ) : Boolean - - /** - * Save all files asynchronously. - * - * @param runAfter A callback function which will be run after the files are saved. - * @see saveAll - */ - fun saveAllAsync( - notify: Boolean = true, - requestSync: Boolean = true, - processResources: Boolean = false, - progressConsumer: ((progress: Int, total: Int) -> Unit)? = null, - runAfter: (() -> Unit)? = null - ) - - /** - * Save all files and get the [SaveResult]. - * - * @param progressConsumer A function which consumes the progress of the save operation. The first - * parameter of the function is the current save progress (saved file count) and the second parameter - * is the total file count. - */ - suspend fun saveAllResult(progressConsumer: ((progress: Int, total: Int) -> Unit)? = null) : SaveResult - suspend fun saveResult(index: Int, result: SaveResult) - - fun closeFile(index: Int) = closeFile(index) {} - fun closeFile(index: Int, runAfter: () -> Unit) - fun closeAll() = closeAll {} - fun closeAll(runAfter: () -> Unit) - fun closeOthers() - fun openFAQActivity(htmlData: String) -} \ No newline at end of file + fun findIndexOfEditorByFile(file: File?): Int + + fun getCurrentEditor(): CodeEditorView? + + fun getEditorAtIndex(index: Int): CodeEditorView? + + fun getEditorForFile(file: File): CodeEditorView? + + suspend fun openFile(file: File): CodeEditorView? = openFile(file, null) + + suspend fun openFile( + file: File, + selection: Range?, + ): CodeEditorView? + + fun openFileAndSelect( + file: File, + selection: Range?, + ) + + fun openFileAndGetIndex( + file: File, + selection: Range?, + ): Int + + fun areFilesModified(): Boolean + + fun areFilesSaving(): Boolean + + /** + * Save all files. + * + * @param notify Whether to notify the user about the save event. + * @param processResources Whether the resources must be generated after the save operation. + * @param progressConsumer A function which consumes the progress of the save operation. + * See [saveAllResult] for more details. + */ + suspend fun saveAll( + notify: Boolean = true, + requestSync: Boolean = true, + processResources: Boolean = false, + progressConsumer: ((progress: Int, total: Int) -> Unit)? = null, + ): Boolean + + /** + * Save all files asynchronously. + * + * @param runAfter A callback function which will be run after the files are saved. + * @see saveAll + */ + fun saveAllAsync( + notify: Boolean = true, + requestSync: Boolean = true, + processResources: Boolean = false, + progressConsumer: ((progress: Int, total: Int) -> Unit)? = null, + runAfter: (() -> Unit)? = null, + ) + + /** + * Save all files and get the [SaveResult]. + * + * @param progressConsumer A function which consumes the progress of the save operation. The first + * parameter of the function is the current save progress (saved file count) and the second parameter + * is the total file count. + */ + suspend fun saveAllResult(progressConsumer: ((progress: Int, total: Int) -> Unit)? = null): SaveResult + + suspend fun saveResult( + index: Int, + result: SaveResult, + ) + + fun closeFile(index: Int) = closeFile(index) {} + + fun closeFile( + index: Int, + runAfter: () -> Unit, + ) + + fun closeAll() = closeAll {} + + fun closeAll(runAfter: () -> Unit) + + fun closeOthers() + + fun openFAQActivity(htmlData: String) +} From 47a6eef5a192c32cbc1186a84ec431b1fbab4c03 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 14 Aug 2026 20:06:34 -0700 Subject: [PATCH 44/55] ADFA-5067: Fix third-round /code-review xhigh findings Addresses all 15 findings from the latest code-review pass on the deep links feature: - MainActivity's deep-link handler now honors confirmProjectOpen instead of bypassing it -- MainActivity is exported (required for the launcher), so any co-installed app could otherwise force a project open with no confirmation. - saveAllAsync's whole body (not just saveAll()) now runs NonCancellable, so a save-and-close deep-link switch can't be silently dropped if the activity tears down mid-save. - BaseEditorActivity.onCreate compares the deep link's target project against whatever project a stale/new instance actually holds, redirecting to MainActivity on mismatch instead of silently building editor UI for the wrong project. - saveAllAsync's runAfter now reports save success/failure; callers (GitBottomSheetFragment, confirmProjectClose) check it instead of assuming the callback firing means the save succeeded. - A third overlapping deep-link close request now supersedes the second's pending callback instead of being silently dropped. - MainActivity.openProject's Recents/analytics bookkeeping runs regardless of isFinishing again; only the startActivity() call is gated. - ActionContextProvider.setActivity moved from onResume to onCreate, closing the race window where a live instance was briefly undiscoverable to DeepLinkActivity. - DeepLinkRequest.parse now reports a bare trailing "column" keyword (no value) as invalid instead of silently folding it into the file path. - MainActivity.onNewIntent clears the deep-link extra like onCreate does. - findValidProjectByName now matches NFC/NFD Unicode-normalized project names. - resolveDeepLinkProject and applyDeepLinkFileRequest guard isFinishing/isDestroyed before touching UI, matching sibling code paths. - ProjectOpenBookkeeping's catch narrowed from Throwable back to Exception so a genuine JVM Error still crashes and gets reported. - ZipUtils.unzipFile brought up to the same zip-slip rigor as the other two independent implementations, with cross-references added between all three. - ARCHITECTURE.md documents the BaseEditorActivity fallback-routing path. Adds regression tests for the dangling-column parse case and NFC/NFD project-name matching. --- ARCHITECTURE.md | 4 +- .../androidide/activities/DeepLinkActivity.kt | 6 +- .../androidide/activities/MainActivity.kt | 39 ++++++--- .../activities/editor/BaseEditorActivity.kt | 16 +++- .../editor/EditorHandlerActivity.kt | 81 +++++++++++++------ .../androidide/api/ActionContextProvider.kt | 7 ++ .../assets/AssetsInstallationHelper.kt | 8 ++ .../fragments/git/GitBottomSheetFragment.kt | 9 ++- .../androidide/interfaces/IEditorHandler.kt | 7 +- .../androidide/models/DeepLinkRequest.kt | 16 +++- .../utils/DeepLinkProjectResolution.kt | 8 +- .../itsaky/androidide/utils/PathTraversal.kt | 5 +- .../utils/ProjectOpenBookkeeping.kt | 8 +- .../androidide/utils/ProjectValidations.kt | 17 +++- .../androidide/models/DeepLinkRequestTest.kt | 16 ++++ .../utils/ProjectValidationsTest.kt | 15 ++++ .../com/itsaky/androidide/utils/ZipUtils.kt | 20 +++++ 17 files changed, 228 insertions(+), 54 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 533c819b8f..60832777d0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -53,7 +53,9 @@ Feature code layers as **UI → ViewModel → Repository → data source**, with **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 on `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; `projectDirPath` **is still blank** — this instance never actually finished initializing a project (e.g. recreated after process death with no `PROJECT_PATH` extra), so `confirmProjectClose()` would silently no-op (`contentOrNull` is null); this case reuses the same `onDestroy()`-deferred hand-off instead of showing a close dialog for a project that was never really open; **nothing was open** — `MainActivity.openProject`/`EditorHandlerActivity.postProjectInit` apply the pending file request once the cold-opened project's sync succeeds. +`EditorHandlerActivity.onNewIntent` then branches on `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 a *third* overlapping request supersedes the second's pending callback rather than being dropped), 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; `projectDirPath` **is still blank** — this instance never actually finished initializing a project (e.g. recreated after process death with no `PROJECT_PATH` extra), so `confirmProjectClose()` would silently no-op (`contentOrNull` is null); this case reuses the same `onDestroy()`-deferred hand-off instead of showing a close dialog for a project that was never really open; **nothing was open** — `MainActivity.openProject`/`EditorHandlerActivity.postProjectInit` apply the pending file request once the cold-opened project's sync succeeds. + +`DeepLinkActivity`'s "is a live editor already on screen" check (`ActionContextProvider.getActivity()`) is itself a heuristic, not a guarantee: Android can still spin up a genuinely new `EditorActivityKt` instance instead of delivering to the live one via `onNewIntent`. `BaseEditorActivity.onCreate` is `EXTRA_KEY`'s only other reader on the editor side for exactly this case — it compares the deep link's requested project name against whatever project the new instance actually ends up holding (explicit `PROJECT_PATH` extra, restored `savedInstanceState`, or the process-wide `ProjectManagerImpl` singleton's last-loaded project) and, on a mismatch, bounces back to `MainActivity` with the deep link forwarded rather than silently continuing to build editor UI for the wrong project. 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`). 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 5f16086d5c..2174697436 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt @@ -50,9 +50,9 @@ class DeepLinkActivity : Activity() { } // 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. + // onCreate, cleared in onDestroy) -- this reflects "is an editor instance already alive + // to hand this off to via onNewIntent", unlike IProjectManager's workspace, which stays + // null for the whole duration of a Gradle sync even while EditorActivityKt is already open. val target = if (ActionContextProvider.getActivity() != null) { EditorActivityKt::class.java 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 53ce7c1103..40cf4c0b0c 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt @@ -408,20 +408,26 @@ class MainActivity : EdgeToEdgeIDEActivity() { } } - private fun handleOpenProject(root: File) { + private fun handleOpenProject( + root: File, + pendingFileRequest: PendingFileRequest? = null, + ) { if (GeneralPreferences.confirmProjectOpen) { - askProjectOpenPermission(root) + askProjectOpenPermission(root, pendingFileRequest) return } - openProject(root) + openProject(root, pendingFileRequest = pendingFileRequest) } - private fun askProjectOpenPermission(root: File) { + private fun askProjectOpenPermission( + root: File, + pendingFileRequest: PendingFileRequest? = null, + ) { val builder = DialogUtils.newMaterialDialogBuilder(this) builder.setTitle(string.title_confirm_open_project) builder.setMessage(getString(string.msg_confirm_open_project, root.absolutePath)) builder.setCancelable(false) - builder.setPositiveButton(string.yes) { _, _ -> openProject(root) } + builder.setPositiveButton(string.yes) { _, _ -> openProject(root, pendingFileRequest = pendingFileRequest) } builder.setNegativeButton(string.no, null) builder.show() } @@ -432,12 +438,14 @@ class MainActivity : EdgeToEdgeIDEActivity() { hasTemplateIssues: Boolean = false, pendingFileRequest: PendingFileRequest? = null, ) { + // Bookkeeping (Recents/analytics/lastOpenedProject) must run regardless of isFinishing -- + // only the startActivity() below is unsafe from a finishing activity. + recordProjectOpenedBookkeeping(recentProjectDao, root, project, analyticsManager) + if (isFinishing) { return } - recordProjectOpenedBookkeeping(recentProjectDao, root, project, analyticsManager) - val intent = Intent(this, EditorActivityKt::class.java).apply { putExtra("PROJECT_PATH", root.absolutePath) @@ -478,20 +486,27 @@ class MainActivity : EdgeToEdgeIDEActivity() { setIntent(intent) IntentCompat .getParcelableExtra(intent, DeepLinkRequest.EXTRA_KEY, DeepLinkRequest::class.java) - ?.let { handleDeepLinkRequest(it) } + ?.let { + intent.removeExtra(DeepLinkRequest.EXTRA_KEY) // don't reapply on a later config-change recreate + 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. + * [DeepLinkActivity] has already determined no project is currently loaded. + * + * This still goes through [handleOpenProject] (honoring [GeneralPreferences.confirmProjectOpen]) + * rather than calling [openProject] directly: [MainActivity] is `exported="true"` (required for + * the launcher), so any co-installed app can target it directly with this same extra, bypassing + * [DeepLinkActivity]'s own URI re-validation entirely. Skipping the confirmation gate here would + * let such an app silently force a project open with no user interaction at all. */ private fun handleDeepLinkRequest(request: DeepLinkRequest) { lifecycleScope.launch(Dispatchers.IO) { val projectDir = resolveDeepLinkProject(Environment.PROJECTS_DIR, request.projectName) ?: return@launch withContext(Dispatchers.Main) { - openProject(projectDir, pendingFileRequest = request.fileRequest) + handleOpenProject(projectDir, pendingFileRequest = request.fileRequest) } } } diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index fb6b405a28..e31c7292a3 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -689,8 +689,20 @@ abstract class BaseEditorActivity : // user back to MainActivity instead of crashing -- forwarding a pending deep link along so // MainActivity can still resolve and open the requested project, instead of silently // dropping it here. - if (ProjectManagerImpl.getInstance().projectDirPath.isBlank()) { - log.warn("No project path available in EditorActivity.onCreate(); returning to MainActivity") + // + // A deep link also forces this even when a project path IS already loaded: DeepLinkActivity + // routes here only when it believes a live instance already exists to handle the request via + // onNewIntent, but that check can be stale (see ActionContextProvider.getActivity()'s docs) + // -- Android may spin up this genuinely new instance instead, which inherits whatever project + // ProjectManagerImpl's process-wide singleton was last holding, not necessarily the one this + // deep link actually targets. Comparing against the project directory's name (matching how + // projects live directly under Environment.PROJECTS_DIR) catches that mismatch without an + // extra disk scan. + val projectDirPath = ProjectManagerImpl.getInstance().projectDirPath + val deepLinkTargetsAnotherProject = + deepLinkRequest != null && File(projectDirPath).name != deepLinkRequest.projectName + if (projectDirPath.isBlank() || deepLinkTargetsAnotherProject) { + log.warn("No matching project available in EditorActivity.onCreate(); returning to MainActivity") startActivity( Intent(this, MainActivity::class.java).apply { deepLinkRequest?.let { putExtra(DeepLinkRequest.EXTRA_KEY, it) } 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 4408a34300..ff8daede23 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 @@ -247,6 +247,10 @@ open class EditorHandlerActivity : } override fun onCreate(savedInstanceState: Bundle?) { + // Registered here, not onResume, so this instance is discoverable via + // ActionContextProvider.getActivity() for its whole lifetime -- see that function's docs for + // the redundant-open race a gap between onCreate and onResume otherwise leaves open. + ActionContextProvider.setActivity(this) setupPluginFragmentFactory() mBuildEventListener.setActivity(this) super.onCreate(savedInstanceState) @@ -378,7 +382,6 @@ open class EditorHandlerActivity : override fun onResume() { super.onResume() - ActionContextProvider.setActivity(this) isOpenedFilesSaved.set(false) checkForExternalFileChanges() // Invalidate the options menu to reflect any changes @@ -936,24 +939,34 @@ open class EditorHandlerActivity : requestSync: Boolean, processResources: Boolean, progressConsumer: ((Int, Int) -> Unit)?, - runAfter: (() -> Unit)?, + runAfter: ((Boolean) -> Unit)?, ) { lifecycleScope.launch(Dispatchers.IO) { - try { - withContext(NonCancellable) { - saveAll(notify, requestSync, processResources, progressConsumer) + // The whole body -- not just saveAll() -- runs NonCancellable. onDestroy() cancels + // lifecycleScope's Job as soon as it runs; leaving NonCancellable partway through (e.g. + // right before invoking runAfter) would let that cancellation surface at the next + // suspension point and drop runAfter entirely instead of running it. Callers rely on it + // always running (e.g. confirmProjectClose's onClosed, which arms a pending deep-link + // project switch and would otherwise vanish with no error if this activity is torn down + // while the save is still in flight). + withContext(NonCancellable) { + val saveSucceeded = + try { + saveAll(notify, requestSync, processResources, progressConsumer) + true + } 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 confirmCloseInProgress guard, + // which would otherwise stay stuck true and permanently block closing this activity). + log.error("saveAll failed", e) + false + } + withContext(Dispatchers.Main) { + runAfter?.invoke(saveSucceeded) } - } 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 confirmCloseInProgress guard, - // which would otherwise stay stuck true and permanently block closing this activity). - log.error("saveAll failed", e) - } - withContext(Dispatchers.Main) { - runAfter?.invoke() } } } @@ -1854,22 +1867,35 @@ open class EditorHandlerActivity : private var activeProjectCloseDialog: AlertDialog? = null private var confirmCloseInProgress = false + // The onClosed to actually run once the in-flight confirm-close flow resolves. Read at + // resolution time rather than captured per-call, so a THIRD overlapping request (e.g. a deep + // link C arriving while confirmCloseInProgress is already true for an earlier B) can supersede + // B by overwriting this field, instead of being silently dropped by the confirmCloseInProgress + // guard below with no way to ever apply it. + private var pendingCloseCallback: (() -> Unit)? = null + private fun confirmProjectClose(onClosed: (() -> Unit)? = null) { val content = contentOrNull ?: return if (confirmCloseInProgress) { + pendingCloseCallback = onClosed flashError(string.msg_project_close_in_progress) return } confirmCloseInProgress = true + pendingCloseCallback = onClosed val builder = newMaterialDialogBuilder(this) builder.setTitle(string.title_confirm_project_close) builder.setMessage(string.msg_confirm_project_close) - builder.setOnCancelListener { confirmCloseInProgress = false } + builder.setOnCancelListener { + confirmCloseInProgress = false + pendingCloseCallback = null + } builder.setNegativeButton(string.cancel_project_text) { dialog, _ -> dialog.dismiss() confirmCloseInProgress = false + pendingCloseCallback = null } // OPTION 1: Close without saving @@ -1881,20 +1907,22 @@ open class EditorHandlerActivity : } // Activity is finishing either way; no need to reset confirmCloseInProgress. - performCloseAllFiles(manualFinish = true, onClosed = onClosed) + performCloseAllFiles(manualFinish = true, onClosed = pendingCloseCallback) } // OPTION 2: Save and close builder.setPositiveButton(string.save_and_close) { dialog, _ -> dialog.dismiss() - saveAllAsync(notify = false) { + saveAllAsync(notify = false) { saveSucceeded -> runOnUiThread { confirmCloseInProgress = false // 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 (hasFilesThatFailedToSave()) { + // !saveSucceeded is checked too: an exception can abort the save before it even + // gets to a given file, which would leave that file's modified flag unchanged. + if (!saveSucceeded || hasFilesThatFailedToSave()) { flashError(string.save_failed) return@runOnUiThread } @@ -1903,12 +1931,12 @@ open class EditorHandlerActivity : ) // contentOrNull can already be null here if the binding was torn down while the // save was in flight -- performCloseAllFiles would NPE on the view manipulation it - // does, but onClosed (e.g. arming a pending deep-link project switch) has no such - // dependency and must still run, or a confirmed close silently drops it. + // does, but pendingCloseCallback (e.g. arming a pending deep-link project switch) + // has no such dependency and must still run, or a confirmed close silently drops it. if (contentOrNull != null) { - performCloseAllFiles(manualFinish = true, onClosed = onClosed) + performCloseAllFiles(manualFinish = true, onClosed = pendingCloseCallback) } else { - onClosed?.invoke() + pendingCloseCallback?.invoke() } } } @@ -2014,6 +2042,11 @@ open class EditorHandlerActivity : val file = resolveWithinDirectory(projectDir, request.filePath)?.takeIf { it.isFile } withContext(Dispatchers.Main) { + // The activity may have started finishing while resolveWithinDirectory was still + // hitting disk -- lifecycleScope only cancels at ON_DESTROY, not the moment isFinishing + // first flips true, so this continuation can otherwise still run and touch a dying + // window. Same race onNewIntent already guards against. + if (isFinishing || isDestroyed) return@withContext if (file == null) { flashError(getString(string.msg_deeplink_file_not_found, request.filePath)) return@withContext 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 1e7fdd9d38..e7a4880e3e 100644 --- a/app/src/main/java/com/itsaky/androidide/api/ActionContextProvider.kt +++ b/app/src/main/java/com/itsaky/androidide/api/ActionContextProvider.kt @@ -31,6 +31,13 @@ object ActionContextProvider { * 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. + * + * [setActivity] is called from `onCreate` (not `onResume`), so an instance is discoverable for + * its entire lifetime rather than leaving a blind window between `onCreate` and `onResume` where + * a caller like [com.itsaky.androidide.activities.DeepLinkActivity] would otherwise see `null` for + * a live instance and start a second, redundant open flow via `MainActivity`. The `isFinishing`/ + * `isDestroyed` filter above still excludes an instance that registered but is already tearing + * down. */ fun getActivity(): EditorHandlerActivity? = activityRef?.get()?.takeIf { !it.isFinishing && !it.isDestroyed } } diff --git a/app/src/main/java/com/itsaky/androidide/assets/AssetsInstallationHelper.kt b/app/src/main/java/com/itsaky/androidide/assets/AssetsInstallationHelper.kt index 9359ece5aa..a8664c184b 100644 --- a/app/src/main/java/com/itsaky/androidide/assets/AssetsInstallationHelper.kt +++ b/app/src/main/java/com/itsaky/androidide/assets/AssetsInstallationHelper.kt @@ -254,6 +254,14 @@ object AssetsInstallationHelper { destDir: Path, ) = extractZipToDir(Files.newInputStream(srcFile), destDir) + /** + * Mirrors the zip-slip guard in `com.itsaky.androidide.utils.ZipUtils.unzipFile` and + * [com.itsaky.androidide.utils.resolveWithinDirectory] -- three independent implementations of + * the same lexical-reject + normalize-and-verify + symlink-resolve pattern (this one can't be + * shared with `ZipUtils` since that lives in the `common` module, which `app` depends on, not + * the other way around). Any future fix to the containment algorithm below must be applied in + * all three places. + */ @WorkerThread internal fun extractZipToDir( srcStream: InputStream, diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index 76078f1878..1193bffa04 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -26,6 +26,7 @@ import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.interfaces.IEditorHandler import com.itsaky.androidide.preferences.internal.GitPreferences +import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.utils.onLongPress import com.itsaky.androidide.viewmodel.BottomSheetViewModel @@ -437,7 +438,13 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { .setTitle(R.string.title_files_unsaved) .setMessage(R.string.msg_save_before_git_action) .setPositiveButton(R.string.save_before_git_action) { _, _ -> - handler.saveAllAsync { action() } + handler.saveAllAsync { succeeded -> + if (succeeded) { + action() + } else { + flashError(R.string.save_failed) + } + } }.setNegativeButton(R.string.no_save_before_git_action) { _, _ -> action() }.setNeutralButton(android.R.string.cancel, null) diff --git a/app/src/main/java/com/itsaky/androidide/interfaces/IEditorHandler.kt b/app/src/main/java/com/itsaky/androidide/interfaces/IEditorHandler.kt index dbe215afae..d269630e51 100644 --- a/app/src/main/java/com/itsaky/androidide/interfaces/IEditorHandler.kt +++ b/app/src/main/java/com/itsaky/androidide/interfaces/IEditorHandler.kt @@ -73,7 +73,10 @@ interface IEditorHandler { /** * Save all files asynchronously. * - * @param runAfter A callback function which will be run after the files are saved. + * @param runAfter A callback function which will be run after the save attempt is over, whether + * it succeeded or not, receiving `true` iff every file saved without throwing. Callers that act + * on the saved state (e.g. proceeding with a git operation) must check this rather than assuming + * the callback firing means the save succeeded. * @see saveAll */ fun saveAllAsync( @@ -81,7 +84,7 @@ interface IEditorHandler { requestSync: Boolean = true, processResources: Boolean = false, progressConsumer: ((progress: Int, total: Int) -> Unit)? = null, - runAfter: (() -> Unit)? = null, + runAfter: ((succeeded: Boolean) -> Unit)? = 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 index a12738b325..55d1f3c4bf 100644 --- a/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt +++ b/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt @@ -145,12 +145,26 @@ data class DeepLinkRequest( null } + // A bare trailing "column" with nothing after it (e.g. ".../file/Main.kt/column") can + // never be matched by the keyword-at-(size-2) pair check above -- being the very last + // segment itself leaves no slot for a value. Detect it directly and report it the same + // way danglingLineRaw does, instead of silently folding "column" into the file path. + val danglingColumnRaw = + if (columnIdx == null && lineIdx == null && + (endIdx - 1).let { it >= startIdx && segments[it] == SEGMENT_COLUMN } + ) { + endIdx -= 1 + "" + } else { + null + } + val filePath = segments.subList(startIdx, endIdx).joinToString("/") PendingFileRequest( filePath = filePath, lineRaw = lineIdx?.let { segments.getOrNull(it + 1) } ?: danglingLineRaw, - columnRaw = columnIdx?.let { segments.getOrNull(it + 1) }, + columnRaw = columnIdx?.let { segments.getOrNull(it + 1) } ?: danglingColumnRaw, ) } diff --git a/app/src/main/java/com/itsaky/androidide/utils/DeepLinkProjectResolution.kt b/app/src/main/java/com/itsaky/androidide/utils/DeepLinkProjectResolution.kt index 05f368ee1e..18ae202378 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/DeepLinkProjectResolution.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/DeepLinkProjectResolution.kt @@ -47,11 +47,15 @@ suspend fun Activity.resolveDeepLinkProject( 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)) } + // The activity may have started finishing while the scan above was still hitting disk -- + // don't flash an error against a dying window. + if (!isFinishing && !isDestroyed) { + withContext(Dispatchers.Main) { flashError(getString(string.msg_deeplink_scan_failed)) } + } return null } - if (projectDir == null) { + if (projectDir == null && !isFinishing && !isDestroyed) { withContext(Dispatchers.Main) { flashError(getString(string.msg_deeplink_project_not_found, projectName)) } 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 2f77d47964..90728349d2 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/PathTraversal.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/PathTraversal.kt @@ -28,7 +28,10 @@ import java.nio.file.InvalidPathException * be allowed to read/write outside a known root directory. * * Three layers, mirroring the zip-slip guard in - * [com.itsaky.androidide.assets.AssetsInstallationHelper.extractZipToDir]: + * [com.itsaky.androidide.assets.AssetsInstallationHelper.extractZipToDir] and + * `com.itsaky.androidide.utils.ZipUtils.unzipFile` (the `common` module's own copy, needed since + * it can't depend on `app` to call this function directly). Any future fix to the containment + * algorithm below must be applied in all three places: * 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`) -- 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 1fc3162271..fa07a79474 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt @@ -76,13 +76,15 @@ fun recordProjectOpenedBookkeeping( } } catch (e: CancellationException) { throw e - } catch (e: Throwable) { + } catch (e: Exception) { // 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 + // 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. + // synchronously, so a Recents-write failure doesn't affect it. Deliberately narrower than + // Throwable: a genuine JVM Error (OutOfMemoryError, StackOverflowError) should still crash + // and get reported rather than being silently downgraded to this warning. log.warn("Failed to record opened project '{}' in Recents", recentProject.name, e) } } 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 a18480e6e0..9c3cc8e34c 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/ProjectValidations.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/ProjectValidations.kt @@ -1,6 +1,7 @@ package com.itsaky.androidide.utils import java.io.File +import java.text.Normalizer import kotlin.collections.filter import kotlin.collections.orEmpty @@ -44,8 +45,20 @@ internal fun findValidProjectByName( return null } if (!projectsRoot.isProjectCandidateDir()) return null - val candidate = resolveWithinDirectory(projectsRoot, name) ?: return null - return candidate.takeIf { it.isProjectCandidateDir() && isValidProjectDirectory(it) } + + // A deep-link name is typically authored/normalized as NFC by web tooling, but an on-disk + // project directory imported from elsewhere (e.g. a git clone authored on macOS, which + // decomposes accented filenames to NFD) may not codepoint-match it even though the two look + // identical. Try both normal forms -- still O(1) filesystem lookups, not a directory scan -- + // rather than reporting a visually-identical project as "not found". + val candidateNames = linkedSetOf(name, Normalizer.normalize(name, Normalizer.Form.NFC), Normalizer.normalize(name, Normalizer.Form.NFD)) + for (candidateName in candidateNames) { + val candidate = resolveWithinDirectory(projectsRoot, candidateName) ?: continue + if (candidate.isProjectCandidateDir() && isValidProjectDirectory(candidate)) { + return candidate + } + } + return null } /** Determines if the directory contains a valid Android project structure. */ 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 98b755b95d..3a6b5b1de0 100644 --- a/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt +++ b/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt @@ -219,6 +219,22 @@ class DeepLinkRequestTest { assertThat(parse("https://www.appdevforall.org/some/other/path/project/MyApp")).isNull() } + @Test + fun `a bare trailing 'column' keyword with no value is reported as invalid, not swallowed into the path`() { + // Regression test: `.../column` with nothing after it can never match the keyword-at- + // (size-2) pair check (there's no slot left for a value), so it used to silently fold into + // the file path with no error at all -- unlike the equivalent dangling-line-before-column + // case below, which was already reported. + val request = parse("https://www.appdevforall.org/device/open/project/MyApp/file/Main.kt/column") + assertThat(request) + .isEqualTo( + DeepLinkRequest( + projectName = "MyApp", + fileRequest = PendingFileRequest(filePath = "Main.kt", lineRaw = null, columnRaw = ""), + ), + ) + } + @Test fun `a bare 'line' keyword immediately before a 'column' pair is reported as invalid, not swallowed into the path`() { // Regression test: `.../line/column/7` has no numeric value for "line" -- unlike the diff --git a/app/src/test/java/com/itsaky/androidide/utils/ProjectValidationsTest.kt b/app/src/test/java/com/itsaky/androidide/utils/ProjectValidationsTest.kt index 5c21faf2d8..570f9f22f2 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/ProjectValidationsTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/ProjectValidationsTest.kt @@ -22,6 +22,7 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import java.io.File +import java.text.Normalizer class ProjectValidationsTest { @JvmField @@ -52,6 +53,20 @@ class ProjectValidationsTest { assertThat(findValidProjectByName(root, "DoesNotExist")).isNull() } + @Test + fun `NFC-normalized name matches an NFD on-disk project directory`() { + // Regression test: a deep link URL is typically NFC-normalized by web tooling, but an + // imported project directory (e.g. a git clone authored on macOS, which decomposes + // accented filenames to NFD) may not codepoint-match it even though the two look identical. + val root = tempFolder.newFolder("projects") + val nfc = Normalizer.normalize("Café", Normalizer.Form.NFC) + val nfd = Normalizer.normalize("Café", Normalizer.Form.NFD) + assertThat(nfd).isNotEqualTo(nfc) // sanity check: the two forms really are distinct strings + val project = makeValidProject(root, nfd) + + assertThat(findValidProjectByName(root, nfc)?.canonicalFile).isEqualTo(project.canonicalFile) + } + @Test fun `dot-dot traversal outside projectsRoot is rejected`() { // Regression test: a bare File(projectsRoot, name) join let `name` escape projectsRoot diff --git a/common/src/main/java/com/itsaky/androidide/utils/ZipUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/ZipUtils.kt index a21c2ab5f0..c1683cf3c1 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/ZipUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/ZipUtils.kt @@ -19,6 +19,7 @@ package com.itsaky.androidide.utils import java.io.File import java.io.IOException +import java.nio.file.Files import java.util.zip.ZipFile object ZipUtils { @@ -26,6 +27,13 @@ object ZipUtils { * Extracts every entry of [zipFile] into [destDir], preserving directory structure, and * returns the list of extracted files. Rejects entries that would extract outside [destDir] * (zip-slip). + * + * Mirrors the containment checks in + * [com.itsaky.androidide.assets.AssetsInstallationHelper.extractZipToDir] and + * [com.itsaky.androidide.utils.resolveWithinDirectory] (a third, independent implementation of + * the same lexical-reject + normalize-and-verify + symlink-resolve pattern, needed here because + * this `common` module can't depend on `app`, which those two live in). Any future fix to the + * containment algorithm must be applied in all three places. */ @JvmStatic @Throws(IOException::class) @@ -41,12 +49,24 @@ object ZipUtils { val entries = zip.entries() while (entries.hasMoreElements()) { val entry = entries.nextElement() + + if (entry.name.contains("..") || entry.name.startsWith("/") || entry.name.startsWith("\\")) { + throw IOException("Zip entry contains dangerous path components: ${entry.name}") + } + val outFile = File(destDir, entry.name) if (!outFile.canonicalPath.startsWith(destDirPath)) { throw IOException("Zip entry is outside of the target directory: ${entry.name}") } + // The checks above are lexical (entry name) or rely on canonicalPath's own symlink + // resolution for a path that may not exist yet -- neither catches writing through an + // existing symlink already inside destDir. Reject that up front. + if (Files.isSymbolicLink(outFile.toPath())) { + throw IOException("Refusing to extract over an existing symlink: ${entry.name}") + } + if (entry.isDirectory) { outFile.mkdirs() } else { From dd21d62b73e833541292f39e724a35050ad2a839 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 14 Aug 2026 22:07:37 -0700 Subject: [PATCH 45/55] ADFA-5067: Fix /code-review max findings - onNewIntent never cleared DeepLinkRequest.EXTRA_KEY after consuming it (applied, deferred, or dropped by a cancelled close dialog), so a cancelled deep-link request could resurface on a later process-death recreate: Android redelivers the last-set intent verbatim, and BaseEditorActivity.onCreate would then wrongly compare a live, unrelated project against the stale request's projectName and bounce the user out of it. Strip the extra as soon as onNewIntent takes ownership of it, regardless of how it's eventually resolved. - Cancelling confirmProjectClose's dialog unconditionally discarded pendingCloseCallback, including a *later* request that had superseded it while the dialog was already showing (e.g. a second deep link arriving mid-dialog) - contradicting the field's own "must not be silently dropped" comment. Give a superseded callback its own confirmation instead of silently dropping it: cancelling now compares what's currently in pendingCloseCallback against what this specific dialog was built for, and re-invokes confirmProjectClose for the superseding one if they differ. - The deep-link "is this a different project" fallback compared the on-disk directory name against the raw deep-link name with plain string inequality, unlike ProjectValidations.findValidProjectByName (added earlier in this PR), which already tries NFC and NFD forms for exactly this reason. Extracted the same tolerance into a small projectNamesMatch(a, b) helper and used it in both the existing filesystem-lookup path and this in-memory comparison, instead of duplicating the 3-form dance a second time. - DeepLinkRequest.parse()'s trailing line/column parsing computed both keywords against the same original, un-trimmed end position instead of peeling them off sequentially - so a real "line/5" pair followed by a bare, valueless trailing "column" (".../Main.kt/line/5/column") swallowed the entire "line/5" into the file path instead of parsing line=5 and separately flagging the dangling column. A bare trailing "line" alone (".../Main.kt/line") was also silently absorbed into the file path with no error at all, asymmetric with the equivalent bare "column" case, which was already caught. Restructured to peel column off the end first, then check line against whatever's left - verified this against all 12 pre-existing DeepLinkRequestTest cases by hand-tracing before touching the code, then added 2 regression tests for the two reported shapes. - One of the three saveAllAsync callers this PR touches (notifyFilesUnsaved) ignored the succeeded parameter the other two now check, so a failed save there silently re-ran invokeAfter as if it succeeded, re-showing the same "files unsaved" dialog with no explanation. Matched the pattern already used at the other two call sites (confirmProjectClose's save-and-close branch, and GitBottomSheetFragment's pre-git-action save). - onNewIntent only recognized DeepLinkRequest.EXTRA_KEY, so a plain project-switch intent from MainActivity.openProject (Recents, Clone, Template creation) was silently dropped whenever a different project was already live in this singleTask activity - a pre-existing gap, but one this PR's new onNewIntent override was directly positioned to close. Added handlePlainProjectSwitch, mirroring the deep-link "different project" handling (same no-op-if-already-open check, same confirm-close-then-reopen handoff via pendingDeepLinkOpen) just without a project name to resolve first, since the caller already supplies an absolute path. Skipped: the zip-slip path-containment logic being hand-duplicated three times (PathTraversal.kt, AssetsInstallationHelper.kt, common/.../ZipUtils.kt) - the review's own verification already confirmed all three currently agree on every constructed attack input; it's a real altitude/cleanup observation, not a live bug, and the review flagged it as such itself. Verified: :app compiles, the full :app unit test suite passes (including all 12 pre-existing DeepLinkRequestTest cases plus the 2 new ones), spotlessApply required one fix along the way (a KDoc landed between two declarations instead of directly above one, caught by ktlint's standard:kdoc rule) which is now clean. Co-Authored-By: Claude Sonnet 5 --- .../activities/editor/BaseEditorActivity.kt | 3 +- .../editor/EditorHandlerActivity.kt | 80 +++++++++++++++- .../androidide/models/DeepLinkRequest.kt | 94 ++++++++++--------- .../androidide/utils/ProjectValidations.kt | 14 +++ .../androidide/models/DeepLinkRequestTest.kt | 32 +++++++ 5 files changed, 171 insertions(+), 52 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index e31c7292a3..16857336d0 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -139,6 +139,7 @@ import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashMessage import com.itsaky.androidide.utils.getOrStoreInitialPadding import com.itsaky.androidide.utils.isAtLeastR +import com.itsaky.androidide.utils.projectNamesMatch import com.itsaky.androidide.utils.resolveAttr import com.itsaky.androidide.viewmodel.ApkInstallationViewModel import com.itsaky.androidide.viewmodel.AppLogsCoordinator @@ -700,7 +701,7 @@ abstract class BaseEditorActivity : // extra disk scan. val projectDirPath = ProjectManagerImpl.getInstance().projectDirPath val deepLinkTargetsAnotherProject = - deepLinkRequest != null && File(projectDirPath).name != deepLinkRequest.projectName + deepLinkRequest != null && !projectNamesMatch(File(projectDirPath).name, deepLinkRequest.projectName) if (projectDirPath.isBlank() || deepLinkTargetsAnotherProject) { log.warn("No matching project available in EditorActivity.onCreate(); returning to MainActivity") startActivity( 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 ff8daede23..72bc2730e0 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 @@ -1303,7 +1303,21 @@ open class EditorHandlerActivity : message = getString(string.msg_files_unsaved, TextUtils.join("\n", mapped)), positiveClickListener = { dialog, _ -> dialog.dismiss() - saveAllAsync(notify = true, runAfter = { runOnUiThread(invokeAfter) }) + saveAllAsync( + notify = true, + runAfter = { succeeded -> + runOnUiThread { + // Matches the other two saveAllAsync callers this PR touches: a failed save + // must not silently re-run invokeAfter as if the files were saved, which + // would just re-show this same dialog with no explanation of why. + if (!succeeded) { + flashError(string.save_failed) + return@runOnUiThread + } + invokeAfter.run() + } + }, + ) }, ) { dialog, _ -> dialog.dismiss() @@ -1887,15 +1901,26 @@ open class EditorHandlerActivity : val builder = newMaterialDialogBuilder(this) builder.setTitle(string.title_confirm_project_close) builder.setMessage(string.msg_confirm_project_close) - builder.setOnCancelListener { + + // If a later, superseding request (e.g. a second deep link arriving while this dialog was + // already showing) overwrote pendingCloseCallback, cancelling *this* dialog must not + // silently drop that superseding request too -- give it its own confirmation instead. + // confirmCloseInProgress is reset first so the recursive call starts a fresh dialog rather + // than hitting the "already in progress" guard above. + fun cancelOrDecline() { confirmCloseInProgress = false + val superseding = pendingCloseCallback pendingCloseCallback = null + if (superseding !== onClosed) { + confirmProjectClose(superseding) + } } + builder.setOnCancelListener { cancelOrDecline() } + builder.setNegativeButton(string.cancel_project_text) { dialog, _ -> dialog.dismiss() - confirmCloseInProgress = false - pendingCloseCallback = null + cancelOrDecline() } // OPTION 1: Close without saving @@ -1960,7 +1985,22 @@ open class EditorHandlerActivity : val request = IntentCompat.getParcelableExtra(intent, DeepLinkRequest.EXTRA_KEY, DeepLinkRequest::class.java) - ?: return + if (request == null) { + // Not a deep link -- a plain project-switch intent from MainActivity.openProject (Recents, + // Clone, Template creation) redelivered here via onNewIntent because this singleTask + // instance is already alive for a different project. Without this, the user taps a + // different project elsewhere in the app and nothing visibly happens. + handlePlainProjectSwitch(intent) + return + } + + // This is the request's only chance to be consumed: whether it's applied immediately, + // deferred via pendingDeepLinkOpen, or dropped because the user cancels the close-project + // dialog below, it must not linger on the intent setIntent() just stored. Android redelivers + // that same intent verbatim to onCreate() if this process dies and gets recreated later, and + // BaseEditorActivity.onCreate() would then wrongly compare a live, unrelated project against + // this stale request's projectName and bounce the user out of it. + intent.removeExtra(DeepLinkRequest.EXTRA_KEY) lifecycleScope.launch(Dispatchers.IO) { val projectDir = resolveDeepLinkProject(Environment.PROJECTS_DIR, request.projectName) ?: return@launch @@ -2026,6 +2066,36 @@ open class EditorHandlerActivity : applyDeepLinkFileRequest(request) } + // Handles a plain project-switch intent from MainActivity.openProject (Recents, Clone, or + // Template creation) redelivered here via onNewIntent because this singleTask instance is + // already alive -- mirrors the deep-link "different project" handling in onNewIntent above + // (same no-op-if-already-open check, same confirm-close-then-reopen handoff), just without a + // project name to resolve first since the caller already supplies an absolute path directly. + private fun handlePlainProjectSwitch(intent: Intent) { + val newProjectPath = intent.getStringExtra("PROJECT_PATH")?.takeIf { it.isNotBlank() } ?: return + val fileRequest = + IntentCompat.getParcelableExtra(intent, PendingFileRequest.EXTRA_KEY, PendingFileRequest::class.java) + + val currentProjectPath = IProjectManager.getInstance().projectDirPath + when { + currentProjectPath.isBlank() -> { + pendingDeepLinkOpen.value = DeepLinkOpenRequest(newProjectPath, fileRequest) + finish() + } + + newProjectPath == currentProjectPath -> { + // Same project already open -- no-op project-wise, just navigate. + fileRequest?.let { applyDeepLinkFileRequest(it) } + } + + else -> { + confirmProjectClose { + pendingDeepLinkOpen.value = DeepLinkOpenRequest(newProjectPath, fileRequest) + } + } + } + } + /** * 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 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 55d1f3c4bf..27d17ed1dc 100644 --- a/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt +++ b/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt @@ -110,61 +110,63 @@ data class DeepLinkRequest( } // 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). + // they're matched from the END of the path backward (column peeled off first, then + // line against 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. Peeling column off before + // checking for line (rather than computing both against the original, un-trimmed end) + // matters for a case like ".../Main.kt/line/5/column": a bare trailing "column" with + // no value consumed first re-exposes "line/5" as a real pair for the line check that + // follows, instead of two independent checks both missing it against the original end. + // 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 } - - // A "line" segment sitting directly in front of a matched "column" pair (e.g. - // `.../line/column/7`) isn't part of a keyword-value pair itself -- the slot right - // before "column" holds a non-numeric "line" instead of a value -- but it's also not - // the ambiguous-filename case the comment above carves out, since it's adjacent to a - // keyword that WAS recognized. Report it as an invalid (missing) line value rather - // than silently folding "line" into the file path with no line number and no error. - val danglingLineRaw = - if (lineIdx == null && columnIdx != null && - (columnIdx - 1).let { it >= startIdx && segments[it] == SEGMENT_LINE } - ) { - endIdx = columnIdx - 1 - "" // present but not a valid integer -> reported to the user, per this class's docs - } else { - null + + var columnRaw: String? = null + val columnPairIdx = (endIdx - 2).takeIf { it >= startIdx && segments[it] == SEGMENT_COLUMN } + if (columnPairIdx != null) { + columnRaw = segments[columnPairIdx + 1] + endIdx = columnPairIdx + } else { + // A bare trailing "column" with nothing after it (e.g. ".../file/Main.kt/column") + // can never be matched by the pair check above -- being the very last segment + // itself leaves no slot for a value. Report it as invalid rather than silently + // folding "column" into the file path. + val danglingColumnIdx = (endIdx - 1).takeIf { it >= startIdx && segments[it] == SEGMENT_COLUMN } + if (danglingColumnIdx != null) { + columnRaw = "" // present but not a valid integer -> reported to the user, per this class's docs + endIdx = danglingColumnIdx } + } - // A bare trailing "column" with nothing after it (e.g. ".../file/Main.kt/column") can - // never be matched by the keyword-at-(size-2) pair check above -- being the very last - // segment itself leaves no slot for a value. Detect it directly and report it the same - // way danglingLineRaw does, instead of silently folding "column" into the file path. - val danglingColumnRaw = - if (columnIdx == null && lineIdx == null && - (endIdx - 1).let { it >= startIdx && segments[it] == SEGMENT_COLUMN } - ) { - endIdx -= 1 - "" - } else { - null + var lineRaw: String? = null + val linePairIdx = (endIdx - 2).takeIf { it >= startIdx && segments[it] == SEGMENT_LINE } + if (linePairIdx != null) { + lineRaw = segments[linePairIdx + 1] + endIdx = linePairIdx + } else { + // Same shape as the dangling-column case above, checked against whatever endIdx + // the column layer left behind -- covers both a bare trailing "line" with nothing + // after it, and a "line" sitting directly in front of a column pair that was just + // peeled off (e.g. ".../line/column/7"), where the slot before "column" holds a + // non-numeric "line" instead of a value. + val danglingLineIdx = (endIdx - 1).takeIf { it >= startIdx && segments[it] == SEGMENT_LINE } + if (danglingLineIdx != null) { + lineRaw = "" + endIdx = danglingLineIdx } + } val filePath = segments.subList(startIdx, endIdx).joinToString("/") PendingFileRequest( filePath = filePath, - lineRaw = lineIdx?.let { segments.getOrNull(it + 1) } ?: danglingLineRaw, - columnRaw = columnIdx?.let { segments.getOrNull(it + 1) } ?: danglingColumnRaw, + lineRaw = lineRaw, + columnRaw = columnRaw, ) } 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 9c3cc8e34c..f6f0a07fc1 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/ProjectValidations.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/ProjectValidations.kt @@ -61,6 +61,20 @@ internal fun findValidProjectByName( return null } +/** + * True if [a] and [b] name the same project, tolerating an NFC/NFD codepoint difference (e.g. an + * accented project name authored as NFD on macOS vs. the NFC form a deep-link URL typically + * carries) - the same normalization [findValidProjectByName] applies for its filesystem lookup, + * but as a direct string comparison here rather than multiple candidate paths. + */ +internal fun projectNamesMatch( + a: String, + b: String, +): Boolean { + if (a == b) return true + return Normalizer.normalize(a, Normalizer.Form.NFC) == Normalizer.normalize(b, Normalizer.Form.NFC) +} + /** Determines if the directory contains a valid Android project structure. */ fun isValidProjectDirectory(selectedDir: File): Boolean { if (isPluginProject(selectedDir)) { 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 3a6b5b1de0..fe95d55eda 100644 --- a/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt +++ b/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt @@ -251,4 +251,36 @@ class DeepLinkRequestTest { ), ) } + + @Test + fun `a real line pair followed by a bare trailing 'column' is still parsed, not swallowed whole`() { + // Regression test: a bare trailing "column" used to be checked independently against the + // original, un-trimmed end -- missing it, then leaving the real "line/5" pair unexamined and + // swallowed whole into the file path ("Main.kt/line/5") instead of peeling "column" off first + // and re-checking what's left for the line pair it exposes. + val request = + parse("https://www.appdevforall.org/device/open/project/MyApp/file/Main.kt/line/5/column") + assertThat(request) + .isEqualTo( + DeepLinkRequest( + projectName = "MyApp", + fileRequest = PendingFileRequest(filePath = "Main.kt", lineRaw = "5", columnRaw = ""), + ), + ) + } + + @Test + fun `a bare trailing 'line' keyword with no value is reported as invalid, not swallowed into the path`() { + // Regression test: symmetric to the bare-trailing-"column" case above, which was already + // caught -- a bare trailing "line" used to silently fold into the file path with no line + // number and no error at all. + val request = parse("https://www.appdevforall.org/device/open/project/MyApp/file/Main.kt/line") + assertThat(request) + .isEqualTo( + DeepLinkRequest( + projectName = "MyApp", + fileRequest = PendingFileRequest(filePath = "Main.kt", lineRaw = "", columnRaw = null), + ), + ) + } } From 277435d7a024ee463d72253e1f5c2beae6a5a8b2 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 15 Aug 2026 08:08:37 -0700 Subject: [PATCH 46/55] ADFA-5067: Fix second /code-review max findings pass - setActivity() moved to run after super.onCreate() (not before), so ActionContextProvider.getActivity() no longer exposes a partially-constructed instance (toolbar/action registry not yet wired) to external callers like a floating EditorPanelDockableContent window. - onNewIntent's stale-PendingFileRequest carry-forward now only applies when the new intent isn't itself a project switch (no DeepLinkRequest or PROJECT_PATH extra) -- otherwise a still-loading project's un-drained file request could get attached to an unrelated switch to a different project. - Extracted switchToProject(), deduping the identical blank/same/ different-project dispatch previously copy-pasted between onNewIntent's deep-link branch and handlePlainProjectSwitch -- fixing handlePlainProjectSwitch's missing removeExtra(PendingFileRequest.EXTRA_KEY) in the same-project branch as a side effect of the merge. - Extracted performPendingDeepLinkOpen() and call it from confirmProjectClose's "Save and close" completion too, not just onDestroy(): if this instance is destroyed while that save is still in flight, the completion's contentOrNull == null branch can run after onDestroy() already drained pendingDeepLinkOpen once, stranding the pending switch until some unrelated later instance's onDestroy() happens to find it. - applyDeepLinkFileRequest now catches SecurityException around its disk resolution, matching the sibling resolveDeepLinkProject, which already treats it as a real risk for the same kind of I/O. - A dangling line/column keyword (parsed as raw = "") no longer shows a literal '"" is not a valid line number.' message -- added msg_deeplink_no_value as a readable placeholder. - BaseEditorActivity.onCreate now forwards the deep link's file/line/ column request via PendingFileRequest when a fresh instance is spun up for an already-matching project (previously silently dropped), and its MainActivity restart on a project mismatch now carries CLEAR_TOP/SINGLE_TOP flags -- this branch is reachable far more often since the prior round's deepLinkTargetsAnotherProject check, so a missing flag would leave a stale MainActivity instance on the back stack more visibly than the original rare trigger. - MainActivity.handleDeepLinkRequest now guards isFinishing/isDestroyed before opening a project, and defers removing the DeepLinkRequest extra until the point it's actually consumed (success or "not found") rather than eagerly in onCreate -- a config change this activity doesn't declare (font scale, day/night) recreates it with savedInstanceState != null while the resolve may still be in flight, which previously lost the deep link silently instead of retrying it on the new instance. - askProjectOpenPermission now dismisses a previous confirm-open dialog instead of stacking a second one underneath it when overlapping deep links arrive with GeneralPreferences.confirmProjectOpen enabled. - DeepLinkRequest.parse() now compares scheme/host case-insensitively per RFC 3986, with a regression test. Skipped: the zip-slip path-containment logic still being hand-duplicated three times -- same reasoning as the prior round (a real altitude observation, not a live bug; all three still agree on every constructed attack input). Verified: :app compiles, spotlessCheck is clean, and the full :app unit test suite passes (including the new case-insensitive scheme/host regression test). Co-Authored-By: Claude Sonnet 5 --- .../androidide/activities/MainActivity.kt | 45 ++++-- .../activities/editor/BaseEditorActivity.kt | 13 ++ .../editor/EditorHandlerActivity.kt | 146 ++++++++++++------ .../androidide/models/DeepLinkRequest.kt | 9 +- .../androidide/models/DeepLinkRequestTest.kt | 9 ++ resources/src/main/res/values/strings.xml | 1 + 6 files changed, 162 insertions(+), 61 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 40cf4c0b0c..fffe2a33b1 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt @@ -23,6 +23,7 @@ import android.os.Bundle import android.view.KeyEvent import android.view.View import androidx.activity.OnBackPressedCallback +import androidx.appcompat.app.AlertDialog import androidx.core.content.IntentCompat import androidx.core.graphics.Insets import androidx.core.view.WindowInsetsCompat @@ -131,11 +132,17 @@ class MainActivity : EdgeToEdgeIDEActivity() { // Start WebServer after installation is complete startWebServer() - if (savedInstanceState == null) { - val deepLinkRequest = - IntentCompat.getParcelableExtra(intent, DeepLinkRequest.EXTRA_KEY, DeepLinkRequest::class.java) + val deepLinkRequest = + IntentCompat.getParcelableExtra(intent, DeepLinkRequest.EXTRA_KEY, DeepLinkRequest::class.java) + // A config change this activity doesn't declare (e.g. font scale, day/night) recreates it with + // savedInstanceState != null while handleDeepLinkRequest's resolve may still be in flight -- + // the old instance's lifecycleScope (and its coroutine) is cancelled with it. Gating solely on + // savedInstanceState == null would silently lose a not-yet-consumed request instead of + // retrying it on the new instance; deepLinkRequest != null is a safe extra signal here since + // the extra is only ever removed once handleDeepLinkRequest has actually consumed it (see + // there), never eagerly. + if (savedInstanceState == null || deepLinkRequest != null) { if (deepLinkRequest != null) { - intent.removeExtra(DeepLinkRequest.EXTRA_KEY) // don't reapply on a later config-change recreate handleDeepLinkRequest(deepLinkRequest) } else { openLastProject() @@ -419,17 +426,26 @@ class MainActivity : EdgeToEdgeIDEActivity() { openProject(root, pendingFileRequest = pendingFileRequest) } + // Tracked so a later overlapping request (e.g. two deep links arriving in quick succession while + // GeneralPreferences.confirmProjectOpen is enabled) dismisses the dialog already showing instead + // of stacking a second one underneath it -- letting both stack would let the user confirm the + // visible (later) one, then unknowingly tap the earlier one now exposed behind it, triggering a + // confusing second close-and-reopen inside the editor that just opened. Also dismissed in + // onDestroy() to avoid leaking its window. + private var activeOpenPermissionDialog: AlertDialog? = null + private fun askProjectOpenPermission( root: File, pendingFileRequest: PendingFileRequest? = null, ) { + activeOpenPermissionDialog?.dismiss() val builder = DialogUtils.newMaterialDialogBuilder(this) builder.setTitle(string.title_confirm_open_project) builder.setMessage(getString(string.msg_confirm_open_project, root.absolutePath)) builder.setCancelable(false) builder.setPositiveButton(string.yes) { _, _ -> openProject(root, pendingFileRequest = pendingFileRequest) } builder.setNegativeButton(string.no, null) - builder.show() + activeOpenPermissionDialog = builder.show() } internal fun openProject( @@ -486,10 +502,7 @@ class MainActivity : EdgeToEdgeIDEActivity() { setIntent(intent) IntentCompat .getParcelableExtra(intent, DeepLinkRequest.EXTRA_KEY, DeepLinkRequest::class.java) - ?.let { - intent.removeExtra(DeepLinkRequest.EXTRA_KEY) // don't reapply on a later config-change recreate - handleDeepLinkRequest(it) - } + ?.let { handleDeepLinkRequest(it) } } /** @@ -504,8 +517,19 @@ class MainActivity : EdgeToEdgeIDEActivity() { */ private fun handleDeepLinkRequest(request: DeepLinkRequest) { lifecycleScope.launch(Dispatchers.IO) { - val projectDir = resolveDeepLinkProject(Environment.PROJECTS_DIR, request.projectName) ?: return@launch + val projectDir = resolveDeepLinkProject(Environment.PROJECTS_DIR, request.projectName) withContext(Dispatchers.Main) { + // Only remove the extra once this point is actually reached -- if this coroutine was + // cancelled before now (e.g. onDestroy() from a config-change recreate mid-resolve), the + // extra stays intact so onCreate's relaxed savedInstanceState check can retry it on the + // freshly recreated instance instead of silently losing it. + intent.removeExtra(DeepLinkRequest.EXTRA_KEY) + // The activity may have started finishing while resolveDeepLinkProject was still + // scanning disk -- lifecycleScope only cancels at ON_DESTROY, not the moment isFinishing + // first flips true, so this continuation can otherwise still run and show a dialog on a + // dying window. + if (isFinishing || isDestroyed) return@withContext + projectDir ?: return@withContext handleOpenProject(projectDir, pendingFileRequest = request.fileRequest) } } @@ -514,6 +538,7 @@ class MainActivity : EdgeToEdgeIDEActivity() { override fun onDestroy() { webServer?.stop() ITemplateProvider.getInstance().release() + activeOpenPermissionDialog?.dismiss() super.onDestroy() _binding = null } diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index 16857336d0..5371282929 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -106,6 +106,7 @@ import com.itsaky.androidide.lsp.models.DiagnosticItem import com.itsaky.androidide.models.DeepLinkRequest import com.itsaky.androidide.models.DiagnosticGroup import com.itsaky.androidide.models.OpenedFile +import com.itsaky.androidide.models.PendingFileRequest import com.itsaky.androidide.models.Range import com.itsaky.androidide.models.SearchResult import com.itsaky.androidide.plugins.extensions.FileTabMenuItem @@ -707,12 +708,24 @@ abstract class BaseEditorActivity : startActivity( Intent(this, MainActivity::class.java).apply { deepLinkRequest?.let { putExtra(DeepLinkRequest.EXTRA_KEY, it) } + // This branch is reachable far more often now (any deepLinkTargetsAnotherProject + // mismatch, not just a rare cold process-death recreate) -- without CLEAR_TOP, a + // MainActivity instance already lower in the back stack (Main -> Open Project -> + // Editor) would get a stacked duplicate instead of being reused, leaving back-press + // landing on the stale earlier instance instead of exiting. + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) }, ) finish() return } + // The deep link's project already matches what's loaded (a stale liveness check spun up this + // new instance instead of redelivering via onNewIntent) -- forward its file/line/column + // request through the normal PendingFileRequest pipeline so postProjectInit still applies it + // once the project finishes initializing, instead of silently dropping it here. + deepLinkRequest?.fileRequest?.let { intent.putExtra(PendingFileRequest.EXTRA_KEY, it) } + editorViewModel.isBuildInProgress = false editorViewModel.isInitializing = false 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 72bc2730e0..a1bd07b18b 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 @@ -247,14 +247,19 @@ open class EditorHandlerActivity : } override fun onCreate(savedInstanceState: Bundle?) { - // Registered here, not onResume, so this instance is discoverable via - // ActionContextProvider.getActivity() for its whole lifetime -- see that function's docs for - // the redundant-open race a gap between onCreate and onResume otherwise leaves open. - ActionContextProvider.setActivity(this) setupPluginFragmentFactory() mBuildEventListener.setActivity(this) super.onCreate(savedInstanceState) + // Registered here (right after super.onCreate() finishes wiring the toolbar/action registry), + // not onResume, so this instance is discoverable via ActionContextProvider.getActivity() for + // almost its whole lifetime -- see that function's docs for the redundant-open race a gap + // between onCreate and onResume otherwise leaves open. Registering before super.onCreate() + // returns would instead expose a partially-constructed activity (no toolbar/action registry + // yet) to external callers like a floating EditorPanelDockableContent window, which is + // explicitly documented to outlive this activity and can act on it at any time. + ActionContextProvider.setActivity(this) + supportFragmentManager.registerFragmentLifecycleCallbacks(pluginFontScalingListener, true) floatingTabController.start() @@ -352,6 +357,23 @@ open class EditorHandlerActivity : Log.d("EditorHandlerActivity", "Saved open plugin tabs: $openPluginTabIds") } + // Actually performs a pending "close then reopen a different project" hand-off recorded via + // pendingDeepLinkOpen. Shared by onDestroy() (the normal case -- see its docs for why the + // hand-off waits until here) and confirmProjectClose's "Save and close" completion (the race + // case -- see there for why that path can't always rely on onDestroy() running afterward). + private fun performPendingDeepLinkOpen(pending: DeepLinkOpenRequest) { + val root = File(pending.projectRoot) + val ctx = applicationContext + recordProjectOpenedBookkeeping(recentProjectDao, 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 onDestroy() { super.onDestroy() ActionContextProvider.clearActivity(this) @@ -367,16 +389,7 @@ open class EditorHandlerActivity : // 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(recentProjectDao, 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) - }, - ) + performPendingDeepLinkOpen(pending) } } @@ -1962,6 +1975,15 @@ open class EditorHandlerActivity : performCloseAllFiles(manualFinish = true, onClosed = pendingCloseCallback) } else { pendingCloseCallback?.invoke() + // contentOrNull == null means this instance is already destroyed (contentOrNull + // returns null once isDestroyed) -- onDestroy()'s one-shot drain of + // pendingDeepLinkOpen already ran and won't run again for this instance. Without + // this, a pending open armed by the callback above would sit stranded until some + // unrelated later EditorHandlerActivity instance's onDestroy() happens to find it. + pendingDeepLinkOpen.value?.let { pending -> + pendingDeepLinkOpen.value = null + performPendingDeepLinkOpen(pending) + } } } } @@ -1973,10 +1995,17 @@ open class EditorHandlerActivity : override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + // Only true for an intent that ISN'T itself requesting a project switch (neither a deep link + // nor a plain MainActivity.openProject hand-off) -- e.g. some other explicit re-launch of this + // activity. Gating the carry-forward below on this prevents a still-loading project's own + // stale file request from getting attached to an unrelated switch to a *different* project. + val isProjectSwitchIntent = + intent.hasExtra(DeepLinkRequest.EXTRA_KEY) || intent.hasExtra("PROJECT_PATH") + // Preserve a not-yet-applied file-navigation request from the previous intent -- postProjectInit // reads it lazily once a sync completes, and setIntent() below would otherwise silently drop it // if this onNewIntent call is for something unrelated to that pending request. - if (!intent.hasExtra(PendingFileRequest.EXTRA_KEY)) { + if (!isProjectSwitchIntent && !intent.hasExtra(PendingFileRequest.EXTRA_KEY)) { IntentCompat .getParcelableExtra(getIntent(), PendingFileRequest.EXTRA_KEY, PendingFileRequest::class.java) ?.let { intent.putExtra(PendingFileRequest.EXTRA_KEY, it) } @@ -2010,38 +2039,7 @@ open class EditorHandlerActivity : // first flips true, so this continuation can otherwise still run and try to show the // confirm-close dialog on a dying window. if (isFinishing || isDestroyed) return@withContext - - val currentProjectPath = IProjectManager.getInstance().projectDirPath - when { - currentProjectPath.isBlank() -> { - // No project has actually finished initializing in this instance (e.g. it was - // recreated after process death without a PROJECT_PATH extra) -- confirmProjectClose - // would silently no-op here since contentOrNull is null, dropping the deep link - // with no error shown. Route through the same onDestroy()-deferred handoff used for - // a confirmed project switch instead of showing a close dialog for a project that, - // as far as the user can see, was never really open. - pendingDeepLinkOpen.value = DeepLinkOpenRequest(projectDir.absolutePath, request.fileRequest) - finish() - } - - // 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. - projectDir.absolutePath == currentProjectPath -> { - // Requirement #2: same project already open -- no-op project-wise, just navigate. - request.fileRequest?.let { applyDeepLinkFileRequest(it) } - } - - else -> { - // 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. - confirmProjectClose { - pendingDeepLinkOpen.value = DeepLinkOpenRequest(projectDir.absolutePath, request.fileRequest) - } - } - } + switchToProject(projectDir.absolutePath, request.fileRequest) } } } @@ -2075,20 +2073,49 @@ open class EditorHandlerActivity : val newProjectPath = intent.getStringExtra("PROJECT_PATH")?.takeIf { it.isNotBlank() } ?: return val fileRequest = IntentCompat.getParcelableExtra(intent, PendingFileRequest.EXTRA_KEY, PendingFileRequest::class.java) + // Drain regardless of outcome, matching postProjectInit's own explicit drain -- otherwise a + // same-project no-op below leaves this armed, and it fires again on a later unrelated sync. + intent.removeExtra(PendingFileRequest.EXTRA_KEY) + switchToProject(newProjectPath, fileRequest) + } + + /** + * Shared three-way dispatch for switching this singleTask instance to [newProjectPath]: no + * project loaded yet, the same project already open, or a different project requiring the + * confirm-close-then-reopen handoff. Used by both the deep-link path (onNewIntent, once the + * project name is resolved to a path) and the plain project-switch path ([handlePlainProjectSwitch], + * which already has an absolute path from its caller) -- previously duplicated in both places. + */ + private fun switchToProject( + newProjectPath: String, + fileRequest: PendingFileRequest?, + ) { val currentProjectPath = IProjectManager.getInstance().projectDirPath when { currentProjectPath.isBlank() -> { + // No project has actually finished initializing in this instance (e.g. it was + // recreated after process death without a PROJECT_PATH extra) -- confirmProjectClose + // would silently no-op here since contentOrNull is null, dropping the request with no + // error shown. Route through the same onDestroy()-deferred handoff used for a + // confirmed project switch instead of showing a close dialog for a project that, as + // far as the user can see, was never really open. pendingDeepLinkOpen.value = DeepLinkOpenRequest(newProjectPath, fileRequest) finish() } + // 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. newProjectPath == currentProjectPath -> { - // Same project already open -- no-op project-wise, just navigate. fileRequest?.let { applyDeepLinkFileRequest(it) } } else -> { + // 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. confirmProjectClose { pendingDeepLinkOpen.value = DeepLinkOpenRequest(newProjectPath, fileRequest) } @@ -2109,7 +2136,23 @@ open class EditorHandlerActivity : private fun applyDeepLinkFileRequest(request: PendingFileRequest) { lifecycleScope.launch(Dispatchers.IO) { val projectDir = File(IProjectManager.getInstance().projectDirPath) - val file = resolveWithinDirectory(projectDir, request.filePath)?.takeIf { it.isFile } + val file = + try { + resolveWithinDirectory(projectDir, request.filePath)?.takeIf { it.isFile } + } catch (e: CancellationException) { + throw e + } catch (e: SecurityException) { + // resolveWithinDirectory's toRealPath()/Files.exists() walk and the chained + // File.isFile() check both hit disk -- resolveDeepLinkProject already treats this + // as a real risk for the same kind of I/O one call away. + log.error("Failed to resolve deep-link file request for {}", request.filePath, e) + withContext(Dispatchers.Main) { + if (!isFinishing && !isDestroyed) { + flashError(getString(string.msg_deeplink_scan_failed)) + } + } + return@launch + } withContext(Dispatchers.Main) { // The activity may have started finishing while resolveWithinDirectory was still @@ -2145,7 +2188,10 @@ open class EditorHandlerActivity : raw ?: return 0 val parsed = raw.toIntOrNull() if (parsed == null || parsed <= 0) { - flashError(getString(invalidMsgRes, raw)) + // A dangling keyword (a trailing line/column segment with no value after it) is reported + // as raw = "" -- show a readable placeholder instead of interpolating literal empty quotes. + val shown = raw.ifEmpty { getString(string.msg_deeplink_no_value) } + flashError(getString(invalidMsgRes, shown)) return 0 } return parsed - 1 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 27d17ed1dc..3a055d94f4 100644 --- a/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt +++ b/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt @@ -89,7 +89,14 @@ data class DeepLinkRequest( * arrived. */ fun parse(uri: Uri?): DeepLinkRequest? { - if (uri == null || uri.scheme != SCHEME || uri.host != HOST || uri.path?.startsWith(PATH_PREFIX) != true) { + // Scheme and host are case-insensitive per RFC 3986 -- an explicit intent from another app + // (see this function's own doc on why that's re-validated at all) could carry either in + // non-canonical case, and a semantically valid link must not be rejected over that alone. + if (uri == null || + !uri.scheme.equals(SCHEME, ignoreCase = true) || + !uri.host.equals(HOST, ignoreCase = true) || + uri.path?.startsWith(PATH_PREFIX) != true + ) { return null } 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 fe95d55eda..de223e6b70 100644 --- a/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt +++ b/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt @@ -219,6 +219,15 @@ class DeepLinkRequestTest { assertThat(parse("https://www.appdevforall.org/some/other/path/project/MyApp")).isNull() } + @Test + fun `non-canonical scheme and host case still matches`() { + // Scheme and host are case-insensitive per RFC 3986 -- an explicit intent from another app + // (see the "wrong scheme" test's rationale) could carry either in non-canonical case, and a + // semantically valid link must not be rejected over that alone. + val request = parse("HTTPS://WWW.APPDEVFORALL.ORG/device/open/project/MyApp") + assertThat(request).isEqualTo(DeepLinkRequest(projectName = "MyApp")) + } + @Test fun `a bare trailing 'column' keyword with no value is reported as invalid, not swallowed into the path`() { // Regression test: `.../column` with nothing after it can never match the keyword-at- diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index dcb603f71f..5c059e6108 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -142,6 +142,7 @@ File \"%s\" was not found in the project. \"%s\" is not a valid line number. \"%s\" is not a valid column number. + (no value given) Could not scan projects for this link. A project close is already in progress. Try again in a moment. Create new project From 84bc0de35c21c0ae9336b9b60569b81b7c1b2b04 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 15 Aug 2026 08:15:07 -0700 Subject: [PATCH 47/55] ADFA-5067: Fix CodeRabbit findings from the dd21d62b7 review round - confirmProjectClose's confirmCloseInProgress guard overwrote pendingCloseCallback unconditionally, including with a plain manual close's onClosed == null -- so pressing back/sidebar-close while a deep-link-triggered close dialog was already showing silently erased the armed deep-link switch with nothing to supersede it. Only overwrite when the new request actually carries its own callback. - GitBottomSheetFragment's saveAllAsync completion could run action() (which dereferences the fragment's view binding) after onDestroyView() cleared it, since saveAllAsync is owned by the activity's lifecycle, not the fragment's view. Bail out when _binding is null. - Added the missing ZipUtilsTest regression coverage for the isSymbolicLink rejection branch (traversal was already covered, the separate existing-symlink guard wasn't). Skipped: the "unresolved deep-link project" finding on EditorHandlerActivity's onNewIntent -- resolveDeepLinkProject already flashes msg_deeplink_project_not_found/msg_deeplink_scan_failed before returning null (see its own doc comment: "A null result means the caller can just return -- either failure case already flashed its own message"), so this finding doesn't hold against current code. Replied on the PR thread with this reasoning. Verified: :app and :common compile, spotlessCheck is clean, and both modules' full unit test suites pass (including the new symlink regression test). Co-Authored-By: Claude Sonnet 5 --- .../editor/EditorHandlerActivity.kt | 7 ++++- .../fragments/git/GitBottomSheetFragment.kt | 7 +++++ .../itsaky/androidide/utils/ZipUtilsTest.kt | 28 +++++++++++++++++++ 3 files changed, 41 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 a1bd07b18b..777aee7336 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 @@ -1904,7 +1904,12 @@ open class EditorHandlerActivity : private fun confirmProjectClose(onClosed: (() -> Unit)? = null) { val content = contentOrNull ?: return if (confirmCloseInProgress) { - pendingCloseCallback = onClosed + // A plain close (onClosed == null, e.g. back button/sidebar) must not erase an + // already-armed deep-link switch -- only a request that carries its own callback + // supersedes the pending one. + if (onClosed != null) { + pendingCloseCallback = onClosed + } flashError(string.msg_project_close_in_progress) return } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index 1193bffa04..357c41a7f1 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -439,6 +439,13 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { .setMessage(R.string.msg_save_before_git_action) .setPositiveButton(R.string.save_before_git_action) { _, _ -> handler.saveAllAsync { succeeded -> + // saveAllAsync is owned by the activity's lifecycle and can still invoke + // this callback after onDestroyView() clears _binding (e.g. the user + // navigated away while the save was in flight) -- action() at this call + // site dereferences binding, so bail out before touching it. + if (_binding == null) { + return@saveAllAsync + } if (succeeded) { action() } else { diff --git a/common/src/test/java/com/itsaky/androidide/utils/ZipUtilsTest.kt b/common/src/test/java/com/itsaky/androidide/utils/ZipUtilsTest.kt index a8c2acc349..ee18d29f4e 100644 --- a/common/src/test/java/com/itsaky/androidide/utils/ZipUtilsTest.kt +++ b/common/src/test/java/com/itsaky/androidide/utils/ZipUtilsTest.kt @@ -7,6 +7,7 @@ import org.junit.Test import org.junit.rules.TemporaryFolder import java.io.File import java.io.IOException +import java.nio.file.Files import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream @@ -58,4 +59,31 @@ class ZipUtilsTest { val escapedFile = File(destDir.parentFile, "evil.txt") assertThat(escapedFile.exists()).isFalse() } + + @Test + fun `unzipFile refuses to extract over an existing symlink`() { + val destDir = tempFolder.newFolder("dest") + val realFile = File(destDir, "real.txt").apply { writeText("original") } + val linkPath = File(destDir, "link.txt").toPath() + try { + Files.createSymbolicLink(linkPath, realFile.toPath()) + } catch (e: UnsupportedOperationException) { + // Symlinks aren't supported on this filesystem -- nothing to test here. + return + } + + // The symlink's target is inside destDir, so the canonical-path containment check alone + // would pass -- this isolates the separate, explicit isSymbolicLink guard. + val zipFile = tempFolder.newFile("archive.zip") + ZipOutputStream(zipFile.outputStream()).use { zip -> + zip.putNextEntry(ZipEntry("link.txt")) + zip.write("payload".toByteArray()) + zip.closeEntry() + } + + assertThrows(IOException::class.java) { ZipUtils.unzipFile(zipFile, destDir) } + + assertThat(Files.isSymbolicLink(linkPath)).isTrue() + assertThat(realFile.readText()).isEqualTo("original") + } } From 696fc4ef4916ebda46ab28d0a67d315fd6d2c94c Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 15 Aug 2026 15:19:53 -0700 Subject: [PATCH 48/55] ADFA-5067: Fix real findings from another /code-review max pass Several of the ~15 raw findings from this round turned out to be stale (analyzed against pre-fix code, apparently from a branch mix-up during the review's long run) -- verified every one against current code before touching anything. Confirmed-valid fixes: - switchToProject's "same project" branch called applyDeepLinkFileRequest unconditionally, with no check of confirmCloseInProgress, unlike the "different project" branch the flag exists to guard -- a second request for the still-open project could navigate underneath an already-showing close-confirmation dialog for an unrelated switch. - switchToProject's "different project" branch could silently drop the request if contentOrNull was already null when it ran (confirmProjectClose no-ops immediately in that case) -- the exact failure mode the isBlank() branch already avoids by not depending on confirmProjectClose at all; now routes through the same onDestroy()-deferred handoff. - confirmProjectClose's cancel/decline path left the intent's PROJECT_PATH pointing at the abandoned switch target (set by onNewIntent's setIntent() before the dialog even showed) -- a process-death recreate after a genuine cancel would silently reopen the abandoned project instead of resuming the one that's actually staying open. Now restores PROJECT_PATH (and clears the stale PendingFileRequest) on a true decline. - resolveWithinDirectory("", ...) returned baseDir itself instead of null (Path.resolve("") is a documented no-op), violating its own "returns null" contract -- masked at its one production call site by an incidental .isFile check, but findValidProjectByName already needed its own separate empty-string guard for the same reason. Added an explicit lexical check. - applyDeepLinkFileRequest's two independent zeroBasedOrFlashError calls could each flash their own error for a URL with both an invalid line and column, stacking two indefinite-duration Flashbars. Replaced with zeroBasedOrInvalid + a single at-most-one-message dispatch. - ARCHITECTURE.md's Recent-Projects consumer list still named MainViewModel (no longer a consumer after this PR's own refactor) and omitted EditorHandlerActivity (a new consumer this PR added). - Added regression tests for the empty-path fix and for the actually- reachable single-segment ".." case (the existing traversal test's "../outside" input contains a "/" and was already short-circuited by a separate guard before ever reaching resolveWithinDirectory). Skipped (verified against current code, not applicable or already handled): a fallback in BaseEditorActivity.onCreate that (per the finding) only rechecked projectDirPath.isBlank() -- already superseded by the deepLinkTargetsAnotherProject check from a prior round; a claim that onNewIntent's PendingFileRequest carry-forward could resurrect a stale request -- the isProjectSwitchIntent guard from a prior round already prevents the carry-forward in that exact scenario; a claim that MainActivity.handleDeepLinkRequest has no re-entrancy guard -- overlapping requests already correctly route through handlePlainProjectSwitch's own switchToProject dispatch; the bare-trailing-line/column parsing gap -- already fixed by a prior round's backward-peeling restructure (traced by hand against both cited failure shapes). Also skipped as intentional/low-value: the ActionContextProvider finish()-to-onDestroy() race (narrow, no clean fix without new cross-activity coordination); the findValidProjectByName-vs-findValidProjects symlink-check inconsistency (arguably correct as-is -- stricter validation for untrusted deep-link input than for locally-trusted browsing); the zip-slip logic now being independently implemented a 4th time (PluginPathAllowlist, pre-existing, unrelated module) -- same reasoning as prior rounds, still not a live bug in this PR's own copy; the "Save and close" failure path not invoking onClosed -- the pending callback isn't actually cleared, so a later retry still honors it, just without reassuring messaging. Verified: :app compiles, spotlessCheck is clean, and the full :app unit test suite passes (including the 2 new regression tests). Co-Authored-By: Claude Sonnet 5 --- ARCHITECTURE.md | 2 +- .../editor/EditorHandlerActivity.kt | 66 +++++++++++++------ .../itsaky/androidide/utils/PathTraversal.kt | 7 +- .../androidide/utils/PathTraversalTest.kt | 10 +++ .../utils/ProjectValidationsTest.kt | 17 +++++ 5 files changed, 79 insertions(+), 23 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 60832777d0..aa5701db3e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -108,7 +108,7 @@ These structural facts shape every module. Day-to-day build *commands* live in ` > **Persistence policy (authoritative):** new relational/queryable persistence uses **Room** (`@Entity` + DAO + `RoomDatabase` with explicit migrations, provided via Koin). Non-relational settings use the **filesystem/preferences (DataStore)**. **Raw SQLite is the exception, not the default** — see [ADR 0001](docs/adr/0001-prefer-room-for-persistence.md). > -> **Recent Projects** is the reference example of the default: `app/src/main/java/com/itsaky/androidide/roomData/recentproject/` (`RecentProjectRoomDatabase`, `@Database version = 4` with migrations 1→4; `RecentProjectDao`; the `RecentProject` `@Entity` → table `recent_project_table`). It's provided via Koin in `di/AppModule.kt` and consumed by `MainViewModel`, `RecentProjectsViewModel`, `MainActivity`, `ProjectInfoBottomSheet`, and `ProjectCreationManager`. +> **Recent Projects** is the reference example of the default: `app/src/main/java/com/itsaky/androidide/roomData/recentproject/` (`RecentProjectRoomDatabase`, `@Database version = 4` with migrations 1→4; `RecentProjectDao`; the `RecentProject` `@Entity` → table `recent_project_table`). It's provided via Koin in `di/AppModule.kt` and consumed by `RecentProjectsViewModel`, `MainActivity`, `EditorHandlerActivity`, `ProjectInfoBottomSheet`, and `ProjectCreationManager`. > > **Raw SQLite is allowed only when** the database is prebuilt and opened read-only, the data is performance/allocation-critical and needs granular schema control, or the schema is shared across a process/component boundary. Current exceptions: symbol indexing (`lsp/indexing/SQLiteIndex.kt`), tooltips (`idetooltips/ToolTipManager.kt`), in-app/plugin help (`plugin-manager/.../documentation/PluginDocumentationManager.kt`), and the local web server (`app/.../localWebServer/WebServer.kt`). The `androidx.room:*` strings in `editor`'s `GroovyAutoComplete` are autocomplete suggestions for the *user's* code, not CoGo persistence. > 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 777aee7336..441bca3a4c 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,7 +29,6 @@ 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 @@ -1931,6 +1930,18 @@ open class EditorHandlerActivity : pendingCloseCallback = null if (superseding !== onClosed) { confirmProjectClose(superseding) + } else { + // onNewIntent/handlePlainProjectSwitch already called setIntent() with the abandoned + // switch's target (PROJECT_PATH/PendingFileRequest) before this dialog could even show + // -- a genuine decline (nothing superseding it) must restore the intent to reflect the + // project that's actually staying open, or a later process-death recreate would read + // the abandoned target from getIntent() and silently reopen it instead of resuming this + // one (see BaseEditorActivity.onCreate's PROJECT_PATH fallback). + val stayingProjectPath = IProjectManager.getInstance().projectDirPath + if (stayingProjectPath.isNotBlank()) { + intent.putExtra("PROJECT_PATH", stayingProjectPath) + intent.removeExtra(PendingFileRequest.EXTRA_KEY) + } } } @@ -2114,7 +2125,22 @@ open class EditorHandlerActivity : // "already in this project" case even mid-sync, instead of falling through to the // disruptive close-and-reopen confirmation below for a no-op. newProjectPath == currentProjectPath -> { - fileRequest?.let { applyDeepLinkFileRequest(it) } + if (confirmCloseInProgress) { + // A close-confirmation dialog for a *different* project switch is already + // showing -- navigating underneath it now would just get silently discarded if + // the user goes on to confirm that close. + flashError(string.msg_project_close_in_progress) + } else { + fileRequest?.let { applyDeepLinkFileRequest(it) } + } + } + + // contentOrNull == null (binding already torn down) would make confirmProjectClose + // silently no-op below, dropping this request with no error shown -- the same failure + // mode the isBlank() branch above avoids by not depending on confirmProjectClose at all. + contentOrNull == null -> { + pendingDeepLinkOpen.value = DeepLinkOpenRequest(newProjectPath, fileRequest) + finish() } else -> { @@ -2171,8 +2197,18 @@ open class EditorHandlerActivity : } // URL line/column are 1-based; internal Position is 0-based. - val line = zeroBasedOrFlashError(request.lineRaw, string.msg_deeplink_invalid_line) - val column = zeroBasedOrFlashError(request.columnRaw, string.msg_deeplink_invalid_column) + val (line, lineInvalidRaw) = zeroBasedOrInvalid(request.lineRaw) + val (column, columnInvalidRaw) = zeroBasedOrInvalid(request.columnRaw) + + // A dangling keyword (a trailing line/column segment with no value after it) is + // reported as raw = "" -- show a readable placeholder instead of literal empty quotes. + fun shown(raw: String) = raw.ifEmpty { getString(string.msg_deeplink_no_value) } + // At most one Flashbar here -- a malformed URL can have both line and column invalid + // at once, and showing both would stack two indefinite-duration bars instead of one. + when { + lineInvalidRaw != null -> flashError(getString(string.msg_deeplink_invalid_line, shown(lineInvalidRaw))) + columnInvalidRaw != null -> flashError(getString(string.msg_deeplink_invalid_column, shown(columnInvalidRaw))) + } val pos = Position(line, column) openFileAndSelect(file, Range(pos, pos)) @@ -2181,24 +2217,14 @@ open class EditorHandlerActivity : } /** - * 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 + * Converts a 1-based deep-link line/column value to 0-based, paired with the raw value if it + * was present but invalid (fails [String.toIntOrNull] or non-positive) -- a `null` [raw] + * (segment absent from the URL) is never reported, only a present-but-invalid one. See * [PendingFileRequest]'s docs for why those two cases are distinguished upstream. */ - private fun zeroBasedOrFlashError( - raw: String?, - @StringRes invalidMsgRes: Int, - ): Int { - raw ?: return 0 + private fun zeroBasedOrInvalid(raw: String?): Pair { + raw ?: return 0 to null val parsed = raw.toIntOrNull() - if (parsed == null || parsed <= 0) { - // A dangling keyword (a trailing line/column segment with no value after it) is reported - // as raw = "" -- show a readable placeholder instead of interpolating literal empty quotes. - val shown = raw.ifEmpty { getString(string.msg_deeplink_no_value) } - flashError(getString(invalidMsgRes, shown)) - return 0 - } - return parsed - 1 + return if (parsed == null || parsed <= 0) 0 to raw else (parsed - 1) to null } } 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 90728349d2..677e174ab6 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/PathTraversal.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/PathTraversal.kt @@ -32,7 +32,10 @@ import java.nio.file.InvalidPathException * `com.itsaky.androidide.utils.ZipUtils.unzipFile` (the `common` module's own copy, needed since * it can't depend on `app` to call this function directly). Any future fix to the containment * algorithm below must be applied in all three places: - * 1. A lexical reject of `..`/a leading `/` or `\` -- cheap, catches the common case outright. + * 1. A lexical reject of an empty string, `..`, or a leading `/` or `\` -- cheap, catches the + * common case outright. An empty string is rejected explicitly: [java.nio.file.Path.resolve] + * treats it as a no-op and returns [baseDir] itself unchanged, which would otherwise trivially + * pass the containment check below and violate this function's own "returns null" contract. * 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 operates on Java's own resolved path, so it isn't fooled by however `..` made it into the @@ -55,7 +58,7 @@ fun resolveWithinDirectory( baseDir: File, relativePath: String, ): File? { - if (relativePath.contains("..") || relativePath.startsWith("/") || relativePath.startsWith("\\")) { + if (relativePath.isEmpty() || relativePath.contains("..") || relativePath.startsWith("/") || relativePath.startsWith("\\")) { return 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 d220cfcc61..cd590a5faf 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/PathTraversalTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/PathTraversalTest.kt @@ -44,6 +44,16 @@ class PathTraversalTest { assertNull(resolveWithinDirectory(baseDir, "../../etc/passwd")) } + @Test + fun `empty relative path is rejected instead of resolving to baseDir itself`() { + // Regression test: java.nio.file.Path.resolve("") is a documented no-op, returning the base + // path unchanged -- without an explicit empty-string check, the containment check below + // would trivially pass and this function would violate its own "returns null" contract, + // silently returning baseDir. DeepLinkRequest.parse's own documented "known limitation" (a + // file path whose entire content is just the "line" keyword) produces exactly this shape. + assertNull(resolveWithinDirectory(baseDir, "")) + } + @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 diff --git a/app/src/test/java/com/itsaky/androidide/utils/ProjectValidationsTest.kt b/app/src/test/java/com/itsaky/androidide/utils/ProjectValidationsTest.kt index 570f9f22f2..957b7720fe 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/ProjectValidationsTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/ProjectValidationsTest.kt @@ -73,10 +73,27 @@ class ProjectValidationsTest { // 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. + // + // This exact input ("../outside" contains a "/") is actually short-circuited by + // findValidProjectByName's own separate name.contains("/") guard, never reaching + // resolveWithinDirectory's traversal logic -- see the test below for the single-segment + // ".." case a real deep link's URL path segment can actually carry (Uri.pathSegments never + // contains a literal "/" within one segment). val base = tempFolder.newFolder("base") val root = File(base, "projects").apply { mkdirs() } makeValidProject(base, "outside") assertThat(findValidProjectByName(root, "../outside")).isNull() } + + @Test + fun `a single-segment 'dot-dot' name is rejected`() { + // The reachable counterpart to the test above: a deep link's project-name URL segment can + // never contain "/" (Uri.pathSegments splits on it), so name = ".." alone -- not "../x" -- + // is the actual traversal shape resolveWithinDirectory's lexical check must catch. + val base = tempFolder.newFolder("base") + val root = File(base, "projects").apply { mkdirs() } + + assertThat(findValidProjectByName(root, "..")).isNull() + } } From b8e1c437d2711792f4670cacdb5d177ef15584f8 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 15 Aug 2026 15:29:56 -0700 Subject: [PATCH 49/55] ADFA-5067: Fix CodeRabbit nitpicks from the 84bc0de35 review round - BaseEditorActivity.onCreate's deep-link-matches-loaded-project branch consumed fileRequest into PendingFileRequest.EXTRA_KEY but never cleared DeepLinkRequest.EXTRA_KEY, unlike EditorHandlerActivity.onNewIntent's own drain of the same extra for the same reason -- a process-death recreate would redeliver the launch intent verbatim and re-navigate to the same file/line a second time. - performCloseAllFiles only invoked onClosed inside the manualFinish branch; latent only (today's one manualFinish=false caller never passes a callback), but a one-line, no-behavior-change fix for any future caller that does. - ZipUtilsTest's new symlink-rejection test silently reported "passed" on a filesystem without symlink support instead of "skipped" -- swapped the swallowed catch for Assume.assumeTrue so the cause stays visible. Verified: :app and :common compile, spotlessCheck is clean, and both modules' full unit test suites pass. Co-Authored-By: Claude Sonnet 5 --- .../activities/editor/BaseEditorActivity.kt | 4 ++++ .../activities/editor/EditorHandlerActivity.kt | 2 +- .../com/itsaky/androidide/utils/ZipUtilsTest.kt | 16 ++++++++++------ 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index 5371282929..d3b26b9fdc 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -725,6 +725,10 @@ abstract class BaseEditorActivity : // request through the normal PendingFileRequest pipeline so postProjectInit still applies it // once the project finishes initializing, instead of silently dropping it here. deepLinkRequest?.fileRequest?.let { intent.putExtra(PendingFileRequest.EXTRA_KEY, it) } + // Consumed here -- mirror EditorHandlerActivity.onNewIntent's own drain of this same extra, + // so a launch intent redelivered verbatim after process death doesn't re-navigate to the + // same file/line a second time. + deepLinkRequest?.let { intent.removeExtra(DeepLinkRequest.EXTRA_KEY) } editorViewModel.isBuildInProgress = false editorViewModel.isInitializing = false 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 441bca3a4c..004cbb5fb9 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 @@ -1878,8 +1878,8 @@ open class EditorHandlerActivity : if (manualFinish) { finish() - onClosed?.invoke() } + onClosed?.invoke() } // Tracked so onDestroy() can dismiss it (avoiding a leaked window) and so a confirm-close flow diff --git a/common/src/test/java/com/itsaky/androidide/utils/ZipUtilsTest.kt b/common/src/test/java/com/itsaky/androidide/utils/ZipUtilsTest.kt index ee18d29f4e..ae7871f68b 100644 --- a/common/src/test/java/com/itsaky/androidide/utils/ZipUtilsTest.kt +++ b/common/src/test/java/com/itsaky/androidide/utils/ZipUtilsTest.kt @@ -2,6 +2,7 @@ package com.itsaky.androidide.utils import com.google.common.truth.Truth.assertThat import org.junit.Assert.assertThrows +import org.junit.Assume import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder @@ -65,12 +66,15 @@ class ZipUtilsTest { val destDir = tempFolder.newFolder("dest") val realFile = File(destDir, "real.txt").apply { writeText("original") } val linkPath = File(destDir, "link.txt").toPath() - try { - Files.createSymbolicLink(linkPath, realFile.toPath()) - } catch (e: UnsupportedOperationException) { - // Symlinks aren't supported on this filesystem -- nothing to test here. - return - } + val symlinkCreated = + try { + Files.createSymbolicLink(linkPath, realFile.toPath()) + true + } catch (e: UnsupportedOperationException) { + false + } + // Report as skipped, not silently passed, on a filesystem without symlink support. + Assume.assumeTrue("Symlinks are not supported on this filesystem", symlinkCreated) // The symlink's target is inside destDir, so the canonical-path containment check alone // would pass -- this isolates the separate, explicit isSymbolicLink guard. From 656a236c1063b883257ce763a1ea9b70788951b8 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 15 Aug 2026 15:32:46 -0700 Subject: [PATCH 50/55] ADFA-5067: Fix test-isolation gap in the single-segment dot-dot test The new "single-segment 'dot-dot' name is rejected" test used a bare directory for base, so it could pass for the wrong reason: even if resolveWithinDirectory had a traversal regression and resolved ".." to base, findValidProjectByName would still return null via isValidProjectDirectory rejecting base for lacking the app/build.gradle marker -- masking the exact regression the test claims to catch. Make base a valid project via makeValidProject so a traversal regression would actually surface as a non-null, valid result. Verified: full :app unit test suite passes, spotlessCheck is clean. Co-Authored-By: Claude Sonnet 5 --- .../com/itsaky/androidide/utils/ProjectValidationsTest.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/com/itsaky/androidide/utils/ProjectValidationsTest.kt b/app/src/test/java/com/itsaky/androidide/utils/ProjectValidationsTest.kt index 957b7720fe..5b588ec272 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/ProjectValidationsTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/ProjectValidationsTest.kt @@ -91,7 +91,13 @@ class ProjectValidationsTest { // The reachable counterpart to the test above: a deep link's project-name URL segment can // never contain "/" (Uri.pathSegments splits on it), so name = ".." alone -- not "../x" -- // is the actual traversal shape resolveWithinDirectory's lexical check must catch. - val base = tempFolder.newFolder("base") + // + // base is made a *valid* project (not just a bare directory) so this test actually exercises + // that lexical check: with a bare directory, findValidProjectByName would return null either + // way -- via the traversal check working correctly, or via isValidProjectDirectory rejecting + // an escaped-but-unmarked base -- so the assertion couldn't tell a traversal regression apart + // from a passing test. + val base = makeValidProject(tempFolder.root, "base") val root = File(base, "projects").apply { mkdirs() } assertThat(findValidProjectByName(root, "..")).isNull() From 35903809f7d2e9df1dc1c0635cca22e97a623637 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 15 Aug 2026 22:28:49 -0700 Subject: [PATCH 51/55] ADFA-5067: Fix real findings from a third /code-review max pass - EditorHandlerActivity.onCreate() re-registered process-wide singleton state (ActionContextProvider, the plugin editor provider) unconditionally even when super.onCreate() (BaseEditorActivity) had already called finish() for a project mismatch -- finish() doesn't stop execution from continuing, so a doomed duplicate instance could silently clobber a different, actually-live instance's registration, invisible to ActionContextProvider.getActivity() for the rest of its lifetime once the doomed instance's onDestroy() runs. Added an isFinishing guard right after super.onCreate(), and guarded preDestroy()'s unconditional setEditorProvider(null) on pluginEditorProvider != null so a doomed instance's teardown can't null out a live instance's provider either. - handlePlainProjectSwitch had no isFinishing/isDestroyed guard, unlike the deep-link path's own switchToProject call site -- a second onNewIntent redelivered before this instance's own onDestroy() (from an earlier armed pendingDeepLinkOpen) could overwrite the already-armed request and silently drop it. - onNewIntent's isProjectSwitchIntent treated any PROJECT_PATH intent as a "switch to a different project," even one re-targeting the project already loading (e.g. a bare Recents re-tap with no file context) -- skipping the carry-forward and losing a still-pending file/line request from the original cold-open intent for no reason. Narrowed the check to only apply when the path actually differs from what's currently loaded. - confirmProjectClose's "Save and close" failure branch didn't check whether pendingCloseCallback had been superseded by a third overlapping request while the save was in flight, unlike cancelOrDecline() which explicitly promotes a superseding callback to its own confirmation -- now mirrors that handling. - notifyFilesUnsaved's saveAllAsync callback (used before closeFile/ closeOthers/closeAll) only checked succeeded, not hasFilesThatFailedToSave() like confirmProjectClose's structurally identical path -- a per-file write that silently failed without saveAll() throwing could get its tab closed/discarded as if it were saved. - flashError(string.save_failed)/flashError(string.msg_project_close_in_progress) incidentally used the ~1s auto-dismissing Int overload while this PR's own deep-link errors use the indefinite, must-dismiss String overload for equally save-safety-relevant messages -- routed these through getString() to match, without touching the shared flashError(Int) utility's default (used by ~30 unrelated call sites project-wide). - ActionContextProvider.activityRef was a plain var read from a suspend fun (IDEApiFacade.runApp()) with no guarantee its caller is on the main thread that writes it -- marked @Volatile, matching this PR's sibling PendingDeepLinkOpen.value for the identical pattern. - DeepLinkRequest.parse's column/line trailing-keyword peeling was the same algorithm copy-pasted twice; extracted a shared peelTrailingKeyword helper (verified against all existing DeepLinkRequestTest cases by hand before and after). - PathTraversalTest.kt used raw JUnit asserts instead of Google Truth, the one holdout among this PR's new test files; converted, and added the missing FileSystemException fallback (Windows without symlink privilege) its own symlink test lacked -- and ZipUtilsTest's analogous test only caught UnsupportedOperationException, not this. - Broadened DeepLinkRequest's "known limitation" doc comment: the keyword/non-numeric-value ambiguity it already accepted for the degenerate two-segment case (`file/line/Main.kt` alone) equally applies to any longer path ending in [keyword-named directory, non-numeric segment] -- documented, not fixed, since a numeric-lookahead check would break the intentional "malformed but present" case tested elsewhere (`.../line/abc` must surface as invalid, not become part of the path). Skipped: a claim that askProjectOpenPermission's dismiss-and-replace dialog risks a "mid-tap" accidental confirmation across the swap -- Android's touch dispatch doesn't redirect an in-flight gesture to a newly-shown window; the dialog already displays the differing project path in its own text. A yet another (5th) independent zip-slip/path- containment implementation (plugin-manager's IdeArchiveServiceImpl, pre-existing, unrelated module) -- same reasoning as three prior rounds: a real cleanup observation, not a live bug in this PR's own copies. Zero unit test coverage for EditorHandlerActivity's confirm-close state machine -- a legitimate gap, but the existing test file is an unrelated pre-existing stub, and proper coverage needs either a full Robolectric Activity harness or extracting the state machine into a testable class, disproportionate to this review-fix pass. Verified: :app and :common compile, spotlessCheck is clean, and both modules' full unit test suites pass. Co-Authored-By: Claude Sonnet 5 --- .../editor/EditorHandlerActivity.kt | 68 +++++++++++++--- .../androidide/api/ActionContextProvider.kt | 5 ++ .../androidide/models/DeepLinkRequest.kt | 80 +++++++++---------- .../androidide/utils/PathTraversalTest.kt | 44 ++++++---- .../itsaky/androidide/utils/ZipUtilsTest.kt | 11 ++- 5 files changed, 142 insertions(+), 66 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 004cbb5fb9..7204f9b715 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 @@ -235,7 +235,13 @@ open class EditorHandlerActivity : TSLanguageRegistry.instance.destroy() editorViewModel.removeAllFiles() - IDEApplication.getPluginManager()?.setEditorProvider(null) + // Guarded on pluginEditorProvider (rather than unconditional) so an instance whose onCreate() + // returned early because it was already finishing (see onCreate()) -- and which therefore + // never registered a provider of its own -- can't null out a DIFFERENT, actually-live + // instance's provider out from under it during its own teardown. + if (pluginEditorProvider != null) { + IDEApplication.getPluginManager()?.setEditorProvider(null) + } pluginEditorProvider?.dispose() pluginEditorProvider = null } @@ -250,6 +256,17 @@ open class EditorHandlerActivity : mBuildEventListener.setActivity(this) super.onCreate(savedInstanceState) + // BaseEditorActivity.onCreate() (just run via super.onCreate() above) may have already called + // finish() -- e.g. this instance was spun up for a deep link whose project doesn't match what + // it holds, or with no project path at all -- and returned; finish() doesn't stop execution + // from continuing here. Without this check, the registrations below would unconditionally + // clobber process-wide singleton state (ActionContextProvider, the plugin editor provider) + // away from whatever OTHER, actually-live instance currently owns it, with nothing to ever + // restore it once this doomed instance is eventually torn down. + if (isFinishing) { + return + } + // Registered here (right after super.onCreate() finishes wiring the toolbar/action registry), // not onResume, so this instance is discoverable via ActionContextProvider.getActivity() for // almost its whole lifetime -- see that function's docs for the redundant-open race a gap @@ -1319,11 +1336,13 @@ open class EditorHandlerActivity : notify = true, runAfter = { succeeded -> runOnUiThread { - // Matches the other two saveAllAsync callers this PR touches: a failed save - // must not silently re-run invokeAfter as if the files were saved, which - // would just re-show this same dialog with no explanation of why. - if (!succeeded) { - flashError(string.save_failed) + // Matches confirmProjectClose's identical check: saveAllAsync's succeeded + // only means saveAll() didn't throw, not that every file's write actually + // landed (a silent per-file failure, e.g. disk full, leaves isModified + // true without succeeded going false) -- proceeding to invokeAfter (which + // closes/discards these files) on that alone risks silent data loss. + if (!succeeded || hasFilesThatFailedToSave()) { + flashError(getString(string.save_failed)) return@runOnUiThread } invokeAfter.run() @@ -1909,7 +1928,7 @@ open class EditorHandlerActivity : if (onClosed != null) { pendingCloseCallback = onClosed } - flashError(string.msg_project_close_in_progress) + flashError(getString(string.msg_project_close_in_progress)) return } confirmCloseInProgress = true @@ -1977,7 +1996,20 @@ open class EditorHandlerActivity : // !saveSucceeded is checked too: an exception can abort the save before it even // gets to a given file, which would leave that file's modified flag unchanged. if (!saveSucceeded || hasFilesThatFailedToSave()) { - flashError(string.save_failed) + // Routed through the String overload (indefinite duration, must-dismiss) rather + // than flashError(Int) (a ~1s auto-dismissing toast) -- a user who looks away + // right after tapping "Save and close" must not miss that the close was aborted + // and the activity is still open with unsaved changes. + flashError(getString(string.save_failed)) + // A later, superseding request (e.g. a third deep link arriving while this save + // was in flight) must not be silently dropped just because THIS attempt's save + // failed -- give it its own confirmation, mirroring cancelOrDecline()'s handling + // of the identical race on the cancel path. + val superseding = pendingCloseCallback + pendingCloseCallback = null + if (superseding !== onClosed) { + confirmProjectClose(superseding) + } return@runOnUiThread } recentProjectsViewModel.updateProjectModifiedDate( @@ -2015,8 +2047,18 @@ open class EditorHandlerActivity : // nor a plain MainActivity.openProject hand-off) -- e.g. some other explicit re-launch of this // activity. Gating the carry-forward below on this prevents a still-loading project's own // stale file request from getting attached to an unrelated switch to a *different* project. + // A plain PROJECT_PATH intent re-targeting the project that's already loading (e.g. a bare + // Recents re-tap with no file context of its own) is NOT the "unrelated switch to a different + // project" this guard exists for -- treating it as one would skip the carry-forward below and + // lose a still-pending file request from the original cold-open intent for no reason, since + // handlePlainProjectSwitch's own same-project branch only applies whatever fileRequest THIS + // intent carries (often none) rather than reading the carried-forward extra itself. A deep + // link is always treated as a switch here regardless: its own file target (if any) is applied + // directly from the parsed request, never through this carry-forward mechanism, so excluding + // it from the carry-forward can't lose anything the deep link path doesn't already handle. val isProjectSwitchIntent = - intent.hasExtra(DeepLinkRequest.EXTRA_KEY) || intent.hasExtra("PROJECT_PATH") + intent.hasExtra(DeepLinkRequest.EXTRA_KEY) || + intent.getStringExtra("PROJECT_PATH")?.let { it != IProjectManager.getInstance().projectDirPath } == true // Preserve a not-yet-applied file-navigation request from the previous intent -- postProjectInit // reads it lazily once a sync completes, and setIntent() below would otherwise silently drop it @@ -2086,6 +2128,12 @@ open class EditorHandlerActivity : // (same no-op-if-already-open check, same confirm-close-then-reopen handoff), just without a // project name to resolve first since the caller already supplies an absolute path directly. private fun handlePlainProjectSwitch(intent: Intent) { + // This instance may already be finishing (e.g. it just armed pendingDeepLinkOpen and called + // finish() from switchToProject's isBlank() branch, awaiting its own onDestroy()) -- without + // this guard, a second onNewIntent redelivered before that onDestroy() runs could reach the + // same isBlank() branch again and overwrite the already-armed request with this one, silently + // dropping the original. The deep-link path already guards the same race. + if (isFinishing || isDestroyed) return val newProjectPath = intent.getStringExtra("PROJECT_PATH")?.takeIf { it.isNotBlank() } ?: return val fileRequest = IntentCompat.getParcelableExtra(intent, PendingFileRequest.EXTRA_KEY, PendingFileRequest::class.java) @@ -2129,7 +2177,7 @@ open class EditorHandlerActivity : // A close-confirmation dialog for a *different* project switch is already // showing -- navigating underneath it now would just get silently discarded if // the user goes on to confirm that close. - flashError(string.msg_project_close_in_progress) + flashError(getString(string.msg_project_close_in_progress)) } else { fileRequest?.let { applyDeepLinkFileRequest(it) } } 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 e7a4880e3e..b439b471b3 100644 --- a/app/src/main/java/com/itsaky/androidide/api/ActionContextProvider.kt +++ b/app/src/main/java/com/itsaky/androidide/api/ActionContextProvider.kt @@ -8,6 +8,11 @@ import java.lang.ref.WeakReference * to allow decoupled services to trigger UI actions. */ object ActionContextProvider { + // IDEApiFacade.runApp() (a suspend fun with no explicit Dispatchers.Main) reads getActivity() with + // no guarantee its caller is already on the main thread that writes this -- @Volatile establishes + // the same happens-before guarantee this PR's sibling PendingDeepLinkOpen.value already relies on + // for the identical cross-thread read/write pattern. + @Volatile private var activityRef: WeakReference? = null fun setActivity(activity: EditorHandlerActivity) { 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 3a055d94f4..fafcc0c3c8 100644 --- a/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt +++ b/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt @@ -75,6 +75,30 @@ data class DeepLinkRequest( return -1 } + /** + * Peels a trailing `keyword`/value pair off the end of `this[startIdx until endIdx]`, or a + * bare, valueless `keyword` at the very last position (e.g. a URL ending in `.../column` with + * nothing after it). Returns the raw value paired with the new `endIdx` (that segment, and its + * value if any, excluded) -- `null` raw if `keyword` wasn't found at all (endIdx unchanged), + * `""` raw if found dangling with no value, e.g. -- see [parse]'s inline docs for why there's + * no numeric check on the paired value itself. + */ + private fun List.peelTrailingKeyword( + startIdx: Int, + endIdx: Int, + keyword: String, + ): Pair { + val pairIdx = (endIdx - 2).takeIf { it >= startIdx && this[it] == keyword } + if (pairIdx != null) { + return this[pairIdx + 1] to pairIdx + } + val danglingIdx = (endIdx - 1).takeIf { it >= startIdx && this[it] == keyword } + if (danglingIdx != null) { + return "" to danglingIdx + } + return null to endIdx + } + /** * Parses a deep-link [Uri] of the form described in [DeepLinkRequest]'s docs. Returns `null` if * the URI does not match this scheme/host/path at all, or does not contain a `project` segment @@ -126,47 +150,23 @@ data class DeepLinkRequest( // matters for a case like ".../Main.kt/line/5/column": a bare trailing "column" with // no value consumed first re-exposes "line/5" as a real pair for the line check that // follows, instead of two independent checks both missing it against the original end. - // 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). + // The shape this can't resolve: any path whose last two segments happen to be + // [directory-literally-named "line"/"column", some other segment] -- not just the + // degenerate two-segment case (`file/line/Main.kt` alone), but equally a longer one + // (`file/foo/line/Notes.txt`, where "foo" is a real preceding directory). Neither is + // distinguishable from an actual line/column suffix by position alone, and this URL + // scheme has no delimiter to tell them apart -- there's no numeric-lookahead check on + // the value segment because that would instead break the *intentional* "malformed but + // present" case this class's docs call out (e.g. `.../line/abc`, which must surface as + // an invalid line number, not silently become part of the file path). Both read as the + // keyword (existing behavior, unchanged); a user who genuinely has a directory named + // "line"/"column" must avoid placing the target file's segment where it would be + // misread as the value. var endIdx = segments.size - - var columnRaw: String? = null - val columnPairIdx = (endIdx - 2).takeIf { it >= startIdx && segments[it] == SEGMENT_COLUMN } - if (columnPairIdx != null) { - columnRaw = segments[columnPairIdx + 1] - endIdx = columnPairIdx - } else { - // A bare trailing "column" with nothing after it (e.g. ".../file/Main.kt/column") - // can never be matched by the pair check above -- being the very last segment - // itself leaves no slot for a value. Report it as invalid rather than silently - // folding "column" into the file path. - val danglingColumnIdx = (endIdx - 1).takeIf { it >= startIdx && segments[it] == SEGMENT_COLUMN } - if (danglingColumnIdx != null) { - columnRaw = "" // present but not a valid integer -> reported to the user, per this class's docs - endIdx = danglingColumnIdx - } - } - - var lineRaw: String? = null - val linePairIdx = (endIdx - 2).takeIf { it >= startIdx && segments[it] == SEGMENT_LINE } - if (linePairIdx != null) { - lineRaw = segments[linePairIdx + 1] - endIdx = linePairIdx - } else { - // Same shape as the dangling-column case above, checked against whatever endIdx - // the column layer left behind -- covers both a bare trailing "line" with nothing - // after it, and a "line" sitting directly in front of a column pair that was just - // peeled off (e.g. ".../line/column/7"), where the slot before "column" holds a - // non-numeric "line" instead of a value. - val danglingLineIdx = (endIdx - 1).takeIf { it >= startIdx && segments[it] == SEGMENT_LINE } - if (danglingLineIdx != null) { - lineRaw = "" - endIdx = danglingLineIdx - } - } + val (columnRaw, endIdxAfterColumn) = segments.peelTrailingKeyword(startIdx, endIdx, SEGMENT_COLUMN) + endIdx = endIdxAfterColumn + val (lineRaw, endIdxAfterLine) = segments.peelTrailingKeyword(startIdx, endIdx, SEGMENT_LINE) + endIdx = endIdxAfterLine val filePath = segments.subList(startIdx, endIdx).joinToString("/") 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 cd590a5faf..7c75bdf870 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/PathTraversalTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/PathTraversalTest.kt @@ -17,12 +17,13 @@ package com.itsaky.androidide.utils -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull +import com.google.common.truth.Truth.assertThat +import org.junit.Assume import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import java.io.File +import java.nio.file.FileSystemException import java.nio.file.Files class PathTraversalTest { @@ -36,12 +37,12 @@ class PathTraversalTest { @Test fun `plain relative path resolves inside base`() { val resolved = resolveWithinDirectory(baseDir, "src/Main.kt") - assertEquals(File("/project/root/src/Main.kt"), resolved) + assertThat(resolved).isEqualTo(File("/project/root/src/Main.kt")) } @Test fun `literal dot-dot is rejected`() { - assertNull(resolveWithinDirectory(baseDir, "../../etc/passwd")) + assertThat(resolveWithinDirectory(baseDir, "../../etc/passwd")).isNull() } @Test @@ -51,7 +52,7 @@ class PathTraversalTest { // would trivially pass and this function would violate its own "returns null" contract, // silently returning baseDir. DeepLinkRequest.parse's own documented "known limitation" (a // file path whose entire content is just the "line" keyword) produces exactly this shape. - assertNull(resolveWithinDirectory(baseDir, "")) + assertThat(resolveWithinDirectory(baseDir, "")).isNull() } @Test @@ -59,17 +60,17 @@ class PathTraversalTest { // 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")) + assertThat(resolveWithinDirectory(baseDir, "foo/../../etc/passwd")).isNull() } @Test fun `leading slash is rejected`() { - assertNull(resolveWithinDirectory(baseDir, "/etc/passwd")) + assertThat(resolveWithinDirectory(baseDir, "/etc/passwd")).isNull() } @Test fun `leading backslash is rejected`() { - assertNull(resolveWithinDirectory(baseDir, "\\Windows\\System32")) + assertThat(resolveWithinDirectory(baseDir, "\\Windows\\System32")).isNull() } @Test @@ -77,7 +78,7 @@ class PathTraversalTest { // 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")) + assertThat(resolveWithinDirectory(baseDir, "foo" + nulCharacter + ".txt")).isNull() } @Test @@ -85,13 +86,13 @@ class PathTraversalTest { // 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")) + assertThat(resolveWithinDirectory(baseDir, "a..b.txt")).isNull() } @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) + assertThat(resolved).isEqualTo(File("/project/root/app/src/main/Main.kt")) } @Test @@ -101,7 +102,7 @@ class PathTraversalTest { val target = File(root, "src/Main.kt").apply { writeText("fun main() {}") } val resolved = resolveWithinDirectory(root, "src/Main.kt") - assertEquals(target.canonicalFile, resolved?.canonicalFile) + assertThat(resolved?.canonicalFile).isEqualTo(target.canonicalFile) } @Test @@ -112,8 +113,23 @@ class PathTraversalTest { 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")) + val symlinkCreated = + try { + Files.createSymbolicLink(File(root, "evil").toPath(), outside.toPath()) + true + } catch (e: UnsupportedOperationException) { + // The filesystem itself doesn't support symlinks (e.g. FAT32). + false + } catch (e: FileSystemException) { + // Windows NTFS supports symlinks but requires an elevated/Developer Mode privilege to + // create them -- without it, creation fails with this (a permission error), not + // UnsupportedOperationException. + false + } + // Report as skipped, not silently passed, when this environment can't create symlinks. + Assume.assumeTrue("Symlinks are not supported/permitted on this filesystem", symlinkCreated) + + assertThat(resolveWithinDirectory(root, "evil/secret.txt")).isNull() } } diff --git a/common/src/test/java/com/itsaky/androidide/utils/ZipUtilsTest.kt b/common/src/test/java/com/itsaky/androidide/utils/ZipUtilsTest.kt index ae7871f68b..f0e6812390 100644 --- a/common/src/test/java/com/itsaky/androidide/utils/ZipUtilsTest.kt +++ b/common/src/test/java/com/itsaky/androidide/utils/ZipUtilsTest.kt @@ -8,6 +8,7 @@ import org.junit.Test import org.junit.rules.TemporaryFolder import java.io.File import java.io.IOException +import java.nio.file.FileSystemException import java.nio.file.Files import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream @@ -71,10 +72,16 @@ class ZipUtilsTest { Files.createSymbolicLink(linkPath, realFile.toPath()) true } catch (e: UnsupportedOperationException) { + // The filesystem itself doesn't support symlinks (e.g. FAT32). + false + } catch (e: FileSystemException) { + // Windows NTFS supports symlinks but requires an elevated/Developer Mode privilege to + // create them -- without it, creation fails with this (a permission error), not + // UnsupportedOperationException. false } - // Report as skipped, not silently passed, on a filesystem without symlink support. - Assume.assumeTrue("Symlinks are not supported on this filesystem", symlinkCreated) + // Report as skipped, not silently passed, when this environment can't create symlinks. + Assume.assumeTrue("Symlinks are not supported/permitted on this filesystem", symlinkCreated) // The symlink's target is inside destDir, so the canonical-path containment check alone // would pass -- this isolates the separate, explicit isSymbolicLink guard. From 85877e296cd71da44d24f8e90b7624a430f7640c Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 16 Aug 2026 05:59:53 -0700 Subject: [PATCH 52/55] ADFA-5067: Fix real findings from a fourth /code-review max pass - openFile()'s null-selection fallback aliased the shared, mutable Range.NONE/Position.NONE singleton directly into CodeEditorView's constructor, whose async content-load pipeline calls validateRange/ setSelection on it (the identical hazard openFileAndSelect's own selection != null path already guards against with a defensive copy). Position has mutable var line/column and overrides equals() structurally, so this could permanently corrupt every future `== Range.NONE`/`== Position.NONE` "nothing found" sentinel check elsewhere in the app (GoToDefinition, FindUsages, OrganizeImportsAction) the first time ANY file was opened with no explicit selection -- the most common "just open a file" path in the app. Now constructs a fresh, non-aliased Position/Range instead. - preDestroy() unconditionally called the process-wide TSLanguageRegistry.instance.destroy(), whose own KDoc says it "must be called only when the application is exiting" -- exactly the same doomed-duplicate-instance corruption class this PR already guarded the plugin editor provider against, just missed for this call. Added the same didCompleteLiveOnCreate guard (a dedicated flag, since pluginEditorProvider alone isn't the right signal to reuse here). - ActionContextProvider.setActivity was only called from onCreate (moved there from onResume in an earlier round), so once a different, stale-duplicate instance briefly registered over a live one and was then destroyed, the live instance had no way to reclaim the registration for the rest of its life -- re-added the onResume call alongside onCreate's. - handlePlainProjectSwitch's isFinishing/isDestroyed guard (added in the previous round to stop an overlapping request from overwriting an already-armed pendingDeepLinkOpen) traded that problem for a strictly worse one: silently dropping the newer request entirely, even though MainActivity.openProject had already synchronously recorded it as opened everywhere (Recents, lastOpenedProject, analytics) before redelivering the intent. Removed the guard -- letting the later request supersede matches the last-request-wins pattern already used for pendingCloseCallback and askProjectOpenPermission elsewhere in this file, and keeps behavior consistent with that bookkeeping. - onNewIntent's isProjectSwitchIntent treated any deep link as automatically a "switch to a different project," even one re-targeting the project already loading -- skipping the carry-forward and losing a still-pending file/line request from the original cold-open for no reason when the second deep link had no file target of its own (or none at all). Now compares the deep link's project name against the currently-loading project's directory name first (mirroring BaseEditorActivity.onCreate's own synchronous, disk-free deepLinkTargetsAnotherProject check). - cancelOrDecline()'s intent-restoration (added last round to fix a different bug: an abandoned switch's PROJECT_PATH surviving a decline) ran unconditionally, including for a plain manual close (onClosed == null) that never went through onNewIntent's setIntent() in the first place -- corrupting a legitimate, unrelated pending file request that intent already held. Now scoped to onClosed != null. - confirmProjectClose's "Save and close" success handler treated contentOrNull == null as proof onDestroy() had already run and drained pendingDeepLinkOpen, but contentOrNull also goes null via isDestroying, which onPause() sets from isFinishing well before onDestroy() actually runs. Draining and performing the hand-off in that window risked redelivering the new PROJECT_PATH to this still-alive singleTask instance via onNewIntent instead of a genuinely new instance -- the exact race onDestroy()'s deferred design exists to avoid. Now checks the real isDestroyed flag instead. - notifyFilesUnsaved's hasFilesThatFailedToSave() check (added last round) scanned every open file project-wide instead of the specific file(s) actually being closed, so an unrelated, still-open file's save failure could block closeFile/closeOthers from closing the file(s) the user actually asked to close. hasFilesThatFailedToSave now takes an optional files list (defaulting to all open files for confirmProjectClose's whole-project close); notifyFilesUnsaved scopes it to unsavedEditors. - GitBottomSheetFragment's checkUnsavedChangesAndProceed had the identical succeeded-alone gap IEditorHandler's own KDoc specifically calls out this exact caller for: proceeding with a git commit/pull whenever saveAllAsync's succeeded flag was true, without checking per-file modified state the way confirmProjectClose/notifyFilesUnsaved now do. Added the same areFilesModified() check (the public IEditorHandler-interface equivalent Fragment code can call). - MainActivity.handleDeepLinkRequest's intent.removeExtra/handleOpenProject read the live getIntent() property rather than a reference captured for the specific request being resolved, so a slower, older deep-link resolve could strip a newer, still-in-flight request's extra, or navigate the user back to its own (superseded) target after a faster second request already won. Added latestDeepLinkRequest tracking, mirroring this PR's other supersede-tracking fields. - Merged switchToProject's currentProjectPath.isBlank() and contentOrNull == null branches (byte-identical bodies reached via two separate when-conditions) into one. - Extracted a shared drainPendingDeepLinkOpen() helper for the "check pendingDeepLinkOpen, null it, perform the hand-off" sequence previously duplicated between onDestroy() and confirmProjectClose's save-success path. Skipped: askProjectOpenPermission's dismiss-and-replace still has no supersede-then-re-offer mechanism if the newer dialog is itself declined -- same class of issue as a previous round's finding, but recovering the earlier request could be just as confusing as dropping it (there's no clearly-correct answer here, unlike the close/save flows where data loss is the concern), so the existing last-request-wins trade-off stands. findValidProjectByName's blanket ".."-substring reject on project names containing consecutive dots -- already an explicit, tested, deliberate trade-off from an earlier round for resolveWithinDirectory generally ("project files never legitimately need consecutive dots in a name"). The zip-slip/path-containment triplication having already diverged in mechanism between its three copies -- same reasoning as every prior round: a real cleanup observation, not a live bug in this PR's own code. Verified: :app compiles, spotlessCheck is clean, and the full :app unit test suite passes. Co-Authored-By: Claude Sonnet 5 --- .../androidide/activities/DeepLinkActivity.kt | 9 +- .../androidide/activities/MainActivity.kt | 24 ++- .../editor/EditorHandlerActivity.kt | 186 ++++++++++++------ .../androidide/api/ActionContextProvider.kt | 14 +- .../fragments/git/GitBottomSheetFragment.kt | 8 +- 5 files changed, 160 insertions(+), 81 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 2174697436..2e46313a98 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt @@ -49,10 +49,11 @@ class DeepLinkActivity : Activity() { return } - // ActionContextProvider tracks the live EditorHandlerActivity instance (set in its - // onCreate, cleared in onDestroy) -- this reflects "is an editor instance already alive - // to hand this off to via onNewIntent", unlike IProjectManager's workspace, which stays - // null for the whole duration of a Gradle sync even while EditorActivityKt is already open. + // ActionContextProvider tracks the live EditorHandlerActivity instance (set in its onCreate + // and re-asserted in onResume, cleared in onDestroy) -- this reflects "is an editor instance + // already alive to hand this off to via onNewIntent", unlike IProjectManager's workspace, + // which stays null for the whole duration of a Gradle sync even while EditorActivityKt is + // already open. val target = if (ActionContextProvider.getActivity() != null) { EditorActivityKt::class.java 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 fffe2a33b1..c70cda83fa 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt @@ -98,6 +98,10 @@ class MainActivity : EdgeToEdgeIDEActivity() { private var webServer: WebServer? = null private val shortcutManager by lazy { ShortcutManager(applicationContext) } + // Tracked so a slower, older deep-link resolve (still in flight when a second, faster-resolving + // deep link arrives) can tell it's been superseded -- see handleDeepLinkRequest. + private var latestDeepLinkRequest: DeepLinkRequest? = null + private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { @@ -516,20 +520,30 @@ class MainActivity : EdgeToEdgeIDEActivity() { * let such an app silently force a project open with no user interaction at all. */ private fun handleDeepLinkRequest(request: DeepLinkRequest) { + latestDeepLinkRequest = request lifecycleScope.launch(Dispatchers.IO) { val projectDir = resolveDeepLinkProject(Environment.PROJECTS_DIR, request.projectName) withContext(Dispatchers.Main) { - // Only remove the extra once this point is actually reached -- if this coroutine was - // cancelled before now (e.g. onDestroy() from a config-change recreate mid-resolve), the - // extra stays intact so onCreate's relaxed savedInstanceState check can retry it on the - // freshly recreated instance instead of silently losing it. - intent.removeExtra(DeepLinkRequest.EXTRA_KEY) + // Only remove the extra (and only if it's still THIS request's -- see below) once this + // point is actually reached -- if this coroutine was cancelled before now (e.g. + // onDestroy() from a config-change recreate mid-resolve), the extra stays intact so + // onCreate's relaxed savedInstanceState check can retry it on the freshly recreated + // instance instead of silently losing it. + if (latestDeepLinkRequest === request) { + intent.removeExtra(DeepLinkRequest.EXTRA_KEY) + } // The activity may have started finishing while resolveDeepLinkProject was still // scanning disk -- lifecycleScope only cancels at ON_DESTROY, not the moment isFinishing // first flips true, so this continuation can otherwise still run and show a dialog on a // dying window. if (isFinishing || isDestroyed) return@withContext projectDir ?: return@withContext + // A second, faster-resolving deep link superseded this one while it was still resolving + // -- reading the ambient intent property above (rather than a reference captured for + // THIS call) means this cleanup could otherwise strip the newer request's still-unconsumed + // extra, and this stale, slower request must not now bounce the user back to its own + // (older) target after they've already been taken to the newer one. + if (latestDeepLinkRequest !== request) return@withContext handleOpenProject(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 7204f9b715..9cbf2709b0 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 @@ -115,6 +115,7 @@ 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.projectNamesMatch import com.itsaky.androidide.utils.recordProjectOpenedBookkeeping import com.itsaky.androidide.utils.resolveDeepLinkProject import com.itsaky.androidide.utils.resolveWithinDirectory @@ -181,6 +182,11 @@ open class EditorHandlerActivity : private var pluginEditorProvider: EditorProviderImpl? = null + // True once onCreate() has completed past its isFinishing check -- see there and preDestroy() + // for why a doomed, finishing-from-birth instance must not run teardown meant only for an + // instance that actually became the live one. + private var didCompleteLiveOnCreate = false + private fun getTabPositionForFileIndex(fileIndex: Int): Int { val safeContent = contentOrNull ?: return -1 val totalTabs = safeContent.tabs.tabCount @@ -232,7 +238,14 @@ open class EditorHandlerActivity : override fun preDestroy() { super.preDestroy() - TSLanguageRegistry.instance.destroy() + // TSLanguageRegistry.instance is a process-wide singleton whose own KDoc says destroy() "must + // be called only when the application is exiting" -- guarded on didCompleteLiveOnCreate (same + // reasoning as pluginEditorProvider below) so a doomed instance, spun up and finishing before + // its onCreate() ever got this far, can't tear down the registry a different, actually-live + // sibling instance still depends on for syntax highlighting. + if (didCompleteLiveOnCreate) { + TSLanguageRegistry.instance.destroy() + } editorViewModel.removeAllFiles() // Guarded on pluginEditorProvider (rather than unconditional) so an instance whose onCreate() @@ -266,14 +279,16 @@ open class EditorHandlerActivity : if (isFinishing) { return } + didCompleteLiveOnCreate = true // Registered here (right after super.onCreate() finishes wiring the toolbar/action registry), - // not onResume, so this instance is discoverable via ActionContextProvider.getActivity() for - // almost its whole lifetime -- see that function's docs for the redundant-open race a gap - // between onCreate and onResume otherwise leaves open. Registering before super.onCreate() - // returns would instead expose a partially-constructed activity (no toolbar/action registry - // yet) to external callers like a floating EditorPanelDockableContent window, which is - // explicitly documented to outlive this activity and can act on it at any time. + // not just onResume (see there too), so this instance is discoverable via + // ActionContextProvider.getActivity() for almost its whole lifetime -- see that function's + // docs for the redundant-open race a gap between onCreate and onResume otherwise leaves open. + // Registering before super.onCreate() returns would instead expose a partially-constructed + // activity (no toolbar/action registry yet) to external callers like a floating + // EditorPanelDockableContent window, which is explicitly documented to outlive this activity + // and can act on it at any time. ActionContextProvider.setActivity(this) supportFragmentManager.registerFragmentLifecycleCallbacks(pluginFontScalingListener, true) @@ -390,6 +405,17 @@ open class EditorHandlerActivity : ) } + // Drains pendingDeepLinkOpen (if armed) and performs its hand-off -- shared by onDestroy() (the + // normal case) and confirmProjectClose's "Save and close" completion once isDestroyed confirms + // onDestroy() already ran (the race case) -- so the one-shot "check, null, perform" sequence has + // a single copy instead of being kept in sync by hand across both call sites. + private fun drainPendingDeepLinkOpen() { + pendingDeepLinkOpen.value?.let { pending -> + pendingDeepLinkOpen.value = null + performPendingDeepLinkOpen(pending) + } + } + override fun onDestroy() { super.onDestroy() ActionContextProvider.clearActivity(this) @@ -403,14 +429,19 @@ 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 - performPendingDeepLinkOpen(pending) - } + drainPendingDeepLinkOpen() } override fun onResume() { super.onResume() + // Re-asserted here too (not just onCreate) so this instance reclaims ActionContextProvider's + // registration whenever it becomes the foreground-active one again -- e.g. if a different, + // stale-duplicate instance briefly registered over it (see ActionContextProvider.getActivity()'s + // docs) and was then destroyed, clearing the reference entirely with nothing left to restore it + // otherwise. A doomed instance whose onCreate() returned early (isFinishing) never reaches + // onResume() at all, so this can't re-expose a partially-constructed instance the way doing this + // unconditionally in onCreate() would. + ActionContextProvider.setActivity(this) isOpenedFilesSaved.set(false) checkForExternalFileChanges() // Invalidate the options menu to reflect any changes @@ -815,7 +846,15 @@ open class EditorHandlerActivity : selection: Range?, ): CodeEditorView? = withContext(Dispatchers.Main) { - val range = selection ?: Range.NONE + // Not the shared Range.NONE/Position.NONE singleton -- openFileAndGetIndex below hands this + // straight to CodeEditorView's constructor, whose async content-load pipeline calls + // validateRange/setSelection on it (the identical hazard openFileAndSelect's own selection + // != null path already guards against with a defensive copy). Position has mutable var + // line/column and overrides equals() structurally, so mutating the actual Range.NONE/ + // Position.NONE instance in place would permanently corrupt every future `== Range.NONE`/ + // `== Position.NONE` "nothing found" sentinel check elsewhere in the app (e.g. + // GoToDefinition, FindUsages, OrganizeImportsAction) for the rest of the process. + val range = selection ?: Range(Position(-1, -1), Position(-1, -1)) val isImage = withContext(Dispatchers.IO) { ImageUtils.isImage(file) } if (isImage) { openImage(this@EditorHandlerActivity, file) @@ -1127,9 +1166,14 @@ open class EditorHandlerActivity : * 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. + * + * @param files The files to check -- defaults to every currently open file, appropriate for a + * whole-project close like [confirmProjectClose]. A narrower close (e.g. [closeFile]'s single + * tab, via [notifyFilesUnsaved]) must scope this to just the file(s) actually being closed, or + * an unrelated, still-open file's save failure would block a close it has nothing to do with. */ - private fun hasFilesThatFailedToSave() = - editorViewModel.getOpenedFiles().any { file -> + private fun hasFilesThatFailedToSave(files: List = editorViewModel.getOpenedFiles()) = + files.any { file -> getEditorForFile(file)?.isModified == true && file.extension.lowercase() !in ARCHIVE_EXTENSIONS } @@ -1341,7 +1385,10 @@ open class EditorHandlerActivity : // landed (a silent per-file failure, e.g. disk full, leaves isModified // true without succeeded going false) -- proceeding to invokeAfter (which // closes/discards these files) on that alone risks silent data loss. - if (!succeeded || hasFilesThatFailedToSave()) { + // Scoped to unsavedEditors (not every open file, unlike confirmProjectClose's + // whole-project close) -- this call can be for a single tab (closeFile), and + // an unrelated, still-open file's save failure must not block it. + if (!succeeded || hasFilesThatFailedToSave(unsavedEditors.mapNotNull { it?.file })) { flashError(getString(string.save_failed)) return@runOnUiThread } @@ -1949,13 +1996,18 @@ open class EditorHandlerActivity : pendingCloseCallback = null if (superseding !== onClosed) { confirmProjectClose(superseding) - } else { + } else if (onClosed != null) { // onNewIntent/handlePlainProjectSwitch already called setIntent() with the abandoned // switch's target (PROJECT_PATH/PendingFileRequest) before this dialog could even show - // -- a genuine decline (nothing superseding it) must restore the intent to reflect the - // project that's actually staying open, or a later process-death recreate would read - // the abandoned target from getIntent() and silently reopen it instead of resuming this - // one (see BaseEditorActivity.onCreate's PROJECT_PATH fallback). + // -- a genuine decline of that switch (onClosed != null, nothing superseding it) must + // restore the intent to reflect the project that's actually staying open, or a later + // process-death recreate would read the abandoned target from getIntent() and silently + // reopen it instead of resuming this one (see BaseEditorActivity.onCreate's PROJECT_PATH + // fallback). A plain manual close (onClosed == null, e.g. the sidebar's "Close Project") + // never went through onNewIntent's setIntent() in the first place -- there's nothing to + // restore, and touching the intent here would instead corrupt whatever legitimate + // pending state it already holds (e.g. an original cold-open's still-unconsumed file + // request, mid-sync). val stayingProjectPath = IProjectManager.getInstance().projectDirPath if (stayingProjectPath.isNotBlank()) { intent.putExtra("PROJECT_PATH", stayingProjectPath) @@ -2023,14 +2075,17 @@ open class EditorHandlerActivity : performCloseAllFiles(manualFinish = true, onClosed = pendingCloseCallback) } else { pendingCloseCallback?.invoke() - // contentOrNull == null means this instance is already destroyed (contentOrNull - // returns null once isDestroyed) -- onDestroy()'s one-shot drain of - // pendingDeepLinkOpen already ran and won't run again for this instance. Without - // this, a pending open armed by the callback above would sit stranded until some - // unrelated later EditorHandlerActivity instance's onDestroy() happens to find it. - pendingDeepLinkOpen.value?.let { pending -> - pendingDeepLinkOpen.value = null - performPendingDeepLinkOpen(pending) + // contentOrNull also goes null via isDestroying, which onPause() sets from + // isFinishing -- well before onDestroy() actually runs -- so it is NOT reliable + // proof onDestroy()'s one-shot drain already happened. Only isDestroyed (the real + // Activity flag, true only once onDestroy() has actually been called) means that. + // If onDestroy() hasn't run yet, it still will (isFinishing guarantees it + // eventually does) and will drain whatever pendingCloseCallback just armed itself + // -- draining it here instead would risk redelivering the new PROJECT_PATH to + // this still-alive singleTask instance via onNewIntent rather than a genuinely new + // instance, the exact race onDestroy()'s deferred design exists to avoid. + if (isDestroyed) { + drainPendingDeepLinkOpen() } } } @@ -2043,21 +2098,27 @@ open class EditorHandlerActivity : override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - // Only true for an intent that ISN'T itself requesting a project switch (neither a deep link - // nor a plain MainActivity.openProject hand-off) -- e.g. some other explicit re-launch of this - // activity. Gating the carry-forward below on this prevents a still-loading project's own - // stale file request from getting attached to an unrelated switch to a *different* project. - // A plain PROJECT_PATH intent re-targeting the project that's already loading (e.g. a bare - // Recents re-tap with no file context of its own) is NOT the "unrelated switch to a different - // project" this guard exists for -- treating it as one would skip the carry-forward below and - // lose a still-pending file request from the original cold-open intent for no reason, since - // handlePlainProjectSwitch's own same-project branch only applies whatever fileRequest THIS - // intent carries (often none) rather than reading the carried-forward extra itself. A deep - // link is always treated as a switch here regardless: its own file target (if any) is applied - // directly from the parsed request, never through this carry-forward mechanism, so excluding - // it from the carry-forward can't lose anything the deep link path doesn't already handle. + val deepLinkRequest = + IntentCompat.getParcelableExtra(intent, DeepLinkRequest.EXTRA_KEY, DeepLinkRequest::class.java) + + // Only true for an intent that ISN'T itself requesting a switch to a genuinely *different* + // project -- e.g. some other explicit re-launch of this activity, or a deep link/PROJECT_PATH + // intent that re-targets the project already loading. Gating the carry-forward below on this + // prevents a still-loading project's own stale file request from getting attached to an + // unrelated switch to a different project, while still preserving it when the incoming intent + // turns out to be for the SAME project: a same-project deep link with no file target of its + // own (or a bare Recents re-tap) would otherwise silently lose the original cold-open's still- + // pending request, since neither switchToProject's nor handlePlainProjectSwitch's same-project + // branch reads the carried-forward extra itself -- they only apply whatever fileRequest THIS + // intent carries, which is often none. Comparing the deep link's project name against the + // currently-loading project's directory name (mirroring BaseEditorActivity.onCreate's own + // deepLinkTargetsAnotherProject check) is a synchronous, disk-free way to tell same from + // different without waiting on the deep-link path's own async resolve. val isProjectSwitchIntent = - intent.hasExtra(DeepLinkRequest.EXTRA_KEY) || + ( + deepLinkRequest != null && + !projectNamesMatch(File(IProjectManager.getInstance().projectDirPath).name, deepLinkRequest.projectName) + ) || intent.getStringExtra("PROJECT_PATH")?.let { it != IProjectManager.getInstance().projectDirPath } == true // Preserve a not-yet-applied file-navigation request from the previous intent -- postProjectInit @@ -2070,8 +2131,7 @@ open class EditorHandlerActivity : } setIntent(intent) - val request = - IntentCompat.getParcelableExtra(intent, DeepLinkRequest.EXTRA_KEY, DeepLinkRequest::class.java) + val request = deepLinkRequest if (request == null) { // Not a deep link -- a plain project-switch intent from MainActivity.openProject (Recents, // Clone, Template creation) redelivered here via onNewIntent because this singleTask @@ -2128,12 +2188,16 @@ open class EditorHandlerActivity : // (same no-op-if-already-open check, same confirm-close-then-reopen handoff), just without a // project name to resolve first since the caller already supplies an absolute path directly. private fun handlePlainProjectSwitch(intent: Intent) { - // This instance may already be finishing (e.g. it just armed pendingDeepLinkOpen and called - // finish() from switchToProject's isBlank() branch, awaiting its own onDestroy()) -- without - // this guard, a second onNewIntent redelivered before that onDestroy() runs could reach the - // same isBlank() branch again and overwrite the already-armed request with this one, silently - // dropping the original. The deep-link path already guards the same race. - if (isFinishing || isDestroyed) return + // Deliberately no isFinishing/isDestroyed early-return here (unlike the deep-link path): this + // instance may already be finishing because it just armed pendingDeepLinkOpen for an earlier + // request and called finish() (switchToProject's isBlank() branch), awaiting its own + // onDestroy(). Dropping this request outright would be strictly worse than letting it + // supersede the earlier one -- MainActivity.openProject already synchronously recorded THIS + // project as opened everywhere (ProjectManagerImpl, lastOpenedProject, Recents, analytics) + // before redelivering this intent, so silently ignoring it here would leave every persisted + // "last opened project" record pointing at a project the app never actually opens. Letting the + // later request win (matching pendingCloseCallback's/askProjectOpenPermission's same + // last-request-wins pattern elsewhere in this file) keeps behavior consistent with bookkeeping. val newProjectPath = intent.getStringExtra("PROJECT_PATH")?.takeIf { it.isNotBlank() } ?: return val fileRequest = IntentCompat.getParcelableExtra(intent, PendingFileRequest.EXTRA_KEY, PendingFileRequest::class.java) @@ -2157,13 +2221,13 @@ open class EditorHandlerActivity : ) { val currentProjectPath = IProjectManager.getInstance().projectDirPath when { - currentProjectPath.isBlank() -> { - // No project has actually finished initializing in this instance (e.g. it was - // recreated after process death without a PROJECT_PATH extra) -- confirmProjectClose - // would silently no-op here since contentOrNull is null, dropping the request with no - // error shown. Route through the same onDestroy()-deferred handoff used for a - // confirmed project switch instead of showing a close dialog for a project that, as - // far as the user can see, was never really open. + // Either no project has actually finished initializing in this instance yet (e.g. it was + // recreated after process death without a PROJECT_PATH extra), or contentOrNull is + // already null (binding torn down) -- either way, confirmProjectClose below would + // silently no-op, dropping the request with no error shown. Route through the same + // onDestroy()-deferred handoff used for a confirmed project switch instead of showing (or + // trying to show) a close dialog that can't work either way. + currentProjectPath.isBlank() || contentOrNull == null -> { pendingDeepLinkOpen.value = DeepLinkOpenRequest(newProjectPath, fileRequest) finish() } @@ -2183,14 +2247,6 @@ open class EditorHandlerActivity : } } - // contentOrNull == null (binding already torn down) would make confirmProjectClose - // silently no-op below, dropping this request with no error shown -- the same failure - // mode the isBlank() branch above avoids by not depending on confirmProjectClose at all. - contentOrNull == null -> { - pendingDeepLinkOpen.value = DeepLinkOpenRequest(newProjectPath, fileRequest) - finish() - } - else -> { // 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 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 b439b471b3..eb566d80a6 100644 --- a/app/src/main/java/com/itsaky/androidide/api/ActionContextProvider.kt +++ b/app/src/main/java/com/itsaky/androidide/api/ActionContextProvider.kt @@ -37,12 +37,14 @@ object ActionContextProvider { * 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. * - * [setActivity] is called from `onCreate` (not `onResume`), so an instance is discoverable for - * its entire lifetime rather than leaving a blind window between `onCreate` and `onResume` where - * a caller like [com.itsaky.androidide.activities.DeepLinkActivity] would otherwise see `null` for - * a live instance and start a second, redundant open flow via `MainActivity`. The `isFinishing`/ - * `isDestroyed` filter above still excludes an instance that registered but is already tearing - * down. + * [setActivity] is called from both `onCreate` and `onResume`: `onCreate` closes the blind window + * between `onCreate` and `onResume` where a caller like + * [com.itsaky.androidide.activities.DeepLinkActivity] would otherwise see `null` for a live + * instance and start a second, redundant open flow via `MainActivity`; `onResume` lets an instance + * reclaim this registration whenever it becomes foreground-active again, in case a different, + * stale-duplicate instance briefly registered over it and was destroyed without anything else + * restoring it. The `isFinishing`/`isDestroyed` filter above still excludes an instance that + * registered but is already tearing down. */ fun getActivity(): EditorHandlerActivity? = activityRef?.get()?.takeIf { !it.isFinishing && !it.isDestroyed } } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index 357c41a7f1..6bbd483c8a 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -446,7 +446,13 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { if (_binding == null) { return@saveAllAsync } - if (succeeded) { + // succeeded alone means saveAll() didn't throw, not that every file's write + // actually landed (a silent per-file failure, e.g. disk full, leaves a file + // modified without succeeded going false) -- proceeding to action() (a git + // commit/pull) on that alone risks operating on a working tree whose edits + // were never written to disk. areFilesModified() reflects the up-to-date + // per-file modified state maintained as each file is saved. + if (succeeded && handler.areFilesModified() == false) { action() } else { flashError(R.string.save_failed) From 40abc0a6e3d025197f682dcfd2352b7c4ea8d83c Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 16 Aug 2026 17:01:53 -0700 Subject: [PATCH 53/55] ADFA-5067: Fix real findings from a fifth /code-review max pass Most severe: ProjectHandlerActivity's onCreate()/preDestroy() ran their startServices()/teardown unconditionally, with no guard analogous to EditorHandlerActivity's own didCompleteLiveOnCreate. A doomed duplicate instance (spun up by a stale deep-link liveness check, then immediately finished by BaseEditorActivity.onCreate) could still run this superclass's body -- unregistering the global GradleBuildService Lookup entry, shutting down the IDELanguageClientImpl singleton, and racing to overwrite the live instance's build event listener, silently breaking build/run/LSP for an unrelated, already-open project. Added the same guard pattern at this layer. Also fixed several deep-link/project-switch state-machine gaps in EditorHandlerActivity, all confirmed reachable against the current code: - switchToProject's same-project branch left a stale carried-forward PendingFileRequest on the intent, which postProjectInit would later silently reapply over a newer navigation. - confirmProjectClose's cancelOrDecline() and the "Save and close" failure branch dropped the original PendingFileRequest for the project that ends up staying open, instead of restoring it. - confirmCloseInProgress deliberately stays stuck true after "Close without saving", but nothing ever read the pendingCloseCallback a later request parked there in the window before onDestroy() actually runs -- now drained in onDestroy(). - onNewIntent had no supersession guard for its deep-link resolve coroutine, unlike MainActivity's existing latestDeepLinkRequest pattern; added the same mechanism here. DeepLinkProjectResolution.resolveDeepLinkProject checked isFinishing/isDestroyed before hopping to Dispatchers.Main instead of after, unlike its sibling callers -- moved the check inside the Main-dispatcher block so it can't miss the activity finishing during the hop itself. Skipped as accepted trade-offs (already effectively decided/documented in prior rounds, or performance/design suggestions rather than bugs): drainPendingDeepLinkOpen()'s lack of instance-scoping (real but requires two simultaneously-alive instances, the same precondition findings 1-3 above already narrow); PathTraversal's dangling-symlink walk-past (both current callers already reject the result via isFile/isDirectory regardless); the close/reopen state machine's repeated redesigns (addressed concretely by the fixes above, a sealed- class rewrite is out of scope for a bug-fix pass); performPendingDeepLinkOpen's project=null tree-walk (perf-only); DeepLinkRequest's line/column keyword collision and PathTraversal/ZipUtils's containment-algorithm duplication (both already documented, conscious trade-offs from earlier rounds). --- .../editor/EditorHandlerActivity.kt | 73 +++++++++++++++++-- .../editor/ProjectHandlerActivity.kt | 24 +++++- .../utils/DeepLinkProjectResolution.kt | 14 ++-- 3 files changed, 95 insertions(+), 16 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 9cbf2709b0..9886727d0d 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 @@ -423,6 +423,13 @@ open class EditorHandlerActivity : // death -- e.g. a rotation while the confirm-close dialog is showing. activeProjectCloseDialog?.dismiss() + // A "Close without saving" confirm deliberately leaves confirmCloseInProgress stuck true + // (see there) since this instance is finishing either way -- a later request that arrived in + // the window before onDestroy() actually ran got parked here with nothing else left to read + // it. Run and clear it now instead of silently orphaning it. + pendingCloseCallback?.invoke() + pendingCloseCallback = null + // Drain any deep-link-triggered "close then reopen a different project" request recorded by // onNewIntent's confirmProjectClose(onClosed) callback. This deliberately waits until onDestroy -- // which only runs once the framework has committed to tearing this singleTask instance down -- @@ -1966,6 +1973,30 @@ open class EditorHandlerActivity : // guard below with no way to ever apply it. private var pendingCloseCallback: (() -> Unit)? = null + // Captured in onNewIntent, right before setIntent() replaces the intent, whenever the incoming + // intent targets a genuinely different project -- restored by cancelOrDecline()/the "Save and + // close" failure branch below if that switch attempt doesn't end up completing, so the staying + // project's own still-pending file request (if any) isn't silently lost. + private var pendingFileRequestBeforeSwitch: PendingFileRequest? = null + + // Tracked so a slower, older deep-link resolve (still in flight when a second, faster-resolving + // deep link arrives via onNewIntent) can tell it's been superseded -- mirrors + // MainActivity.latestDeepLinkRequest's identical race on the cold-open path. + private var latestDeepLinkRequest: DeepLinkRequest? = null + + private fun restoreIntentToStayingProject() { + val stayingProjectPath = IProjectManager.getInstance().projectDirPath + if (stayingProjectPath.isBlank()) return + intent.putExtra("PROJECT_PATH", stayingProjectPath) + val restore = pendingFileRequestBeforeSwitch + pendingFileRequestBeforeSwitch = null + if (restore != null) { + intent.putExtra(PendingFileRequest.EXTRA_KEY, restore) + } else { + intent.removeExtra(PendingFileRequest.EXTRA_KEY) + } + } + private fun confirmProjectClose(onClosed: (() -> Unit)? = null) { val content = contentOrNull ?: return if (confirmCloseInProgress) { @@ -2008,11 +2039,7 @@ open class EditorHandlerActivity : // restore, and touching the intent here would instead corrupt whatever legitimate // pending state it already holds (e.g. an original cold-open's still-unconsumed file // request, mid-sync). - val stayingProjectPath = IProjectManager.getInstance().projectDirPath - if (stayingProjectPath.isNotBlank()) { - intent.putExtra("PROJECT_PATH", stayingProjectPath) - intent.removeExtra(PendingFileRequest.EXTRA_KEY) - } + restoreIntentToStayingProject() } } @@ -2031,8 +2058,13 @@ open class EditorHandlerActivity : (content.editorContainer.getChildAt(i) as? CodeEditorView)?.editor?.markUnmodified() } - // Activity is finishing either way; no need to reset confirmCloseInProgress. - performCloseAllFiles(manualFinish = true, onClosed = pendingCloseCallback) + // Activity is finishing either way; no need to reset confirmCloseInProgress. Null out + // pendingCloseCallback now so a later request arriving before onDestroy() actually runs + // (confirmCloseInProgress stays stuck true) parks its own callback instead of this + // already-consumed one being read and invoked again by onDestroy()'s drain below. + val onClosedNow = pendingCloseCallback + pendingCloseCallback = null + performCloseAllFiles(manualFinish = true, onClosed = onClosedNow) } // OPTION 2: Save and close @@ -2061,6 +2093,10 @@ open class EditorHandlerActivity : pendingCloseCallback = null if (superseding !== onClosed) { confirmProjectClose(superseding) + } else if (onClosed != null) { + // Mirrors cancelOrDecline()'s identical restoration -- this failed "Save and + // close" is itself a decline of the switch, and nothing superseded it. + restoreIntentToStayingProject() } return@runOnUiThread } @@ -2129,6 +2165,15 @@ open class EditorHandlerActivity : .getParcelableExtra(getIntent(), PendingFileRequest.EXTRA_KEY, PendingFileRequest::class.java) ?.let { intent.putExtra(PendingFileRequest.EXTRA_KEY, it) } } + // The reverse case: this IS a switch to a genuinely different project, so the carry-forward + // above is skipped and the old intent's own still-pending file request (for the project + // that's actually staying open if this switch gets cancelled/declined) would otherwise be + // lost the moment setIntent() below replaces it. restoreIntentToStayingProject() puts it back + // if that turns out to be what happens. + if (isProjectSwitchIntent) { + pendingFileRequestBeforeSwitch = + IntentCompat.getParcelableExtra(getIntent(), PendingFileRequest.EXTRA_KEY, PendingFileRequest::class.java) + } setIntent(intent) val request = deepLinkRequest @@ -2149,6 +2194,10 @@ open class EditorHandlerActivity : // this stale request's projectName and bounce the user out of it. intent.removeExtra(DeepLinkRequest.EXTRA_KEY) + // Tracked so a second deep link delivered moments later doesn't have its resolve complete + // out of order with this one -- mirrors MainActivity.latestDeepLinkRequest's identical race. + latestDeepLinkRequest = request + lifecycleScope.launch(Dispatchers.IO) { val projectDir = resolveDeepLinkProject(Environment.PROJECTS_DIR, request.projectName) ?: return@launch withContext(Dispatchers.Main) { @@ -2157,6 +2206,9 @@ open class EditorHandlerActivity : // first flips true, so this continuation can otherwise still run and try to show the // confirm-close dialog on a dying window. if (isFinishing || isDestroyed) return@withContext + // A newer deep link's onNewIntent call already superseded this one -- switching to + // this stale target now would undo the newer request the user actually tapped. + if (latestDeepLinkRequest !== request) return@withContext switchToProject(projectDir.absolutePath, request.fileRequest) } } @@ -2245,6 +2297,13 @@ open class EditorHandlerActivity : } else { fileRequest?.let { applyDeepLinkFileRequest(it) } } + if (fileRequest != null) { + // This request supersedes whatever onNewIntent's carry-forward guard just + // re-armed onto the intent from the PREVIOUS, still-unconsumed request -- leaving + // it in place would have postProjectInit silently jump back to that stale target + // once the current sync completes, discarding this newer navigation. + intent.removeExtra(PendingFileRequest.EXTRA_KEY) + } } else -> { diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt index b63a3e6540..92ba233349 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt @@ -188,6 +188,14 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { private val buildServiceConnection = GradleBuildServiceConnnection() + // True once onCreate() has completed past its isFinishing check -- mirrors + // EditorHandlerActivity.didCompleteLiveOnCreate. super.onCreate() (BaseEditorActivity) may + // already have called finish() for a doomed instance spun up by a stale deep-link liveness + // check; finish() doesn't stop execution, so without this flag preDestroy() would unregister + // the process-wide build-service Lookup entry and shut down the LSP singleton that an + // actually-live sibling instance still depends on. + private var didCompleteLiveOnCreate = false + companion object { private val logger = LoggerFactory.getLogger(ProjectHandlerActivity::class.java) @@ -214,6 +222,16 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // super.onCreate() may have already called finish() for a doomed instance (see + // EditorHandlerActivity.onCreate's own isFinishing guard for the fuller explanation); + // finish() doesn't stop execution here, so without this check startServices() below would + // unconditionally bind a build service and register a listener that preDestroy() will + // later tear down, corrupting the actually-live sibling instance's state. + if (isFinishing) { + return + } + didCompleteLiveOnCreate = true + editorViewModel._isSyncNeeded.observe(this) { isSyncNeeded -> if (!isSyncNeeded) { // dismiss if already showing @@ -373,7 +391,7 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { syncNotificationFlashbar?.dismiss() syncNotificationFlashbar = null - if (isDestroying) { + if (didCompleteLiveOnCreate && isDestroying) { releaseServerListener() this.initializingFuture?.cancel(true) this.initializingFuture = null @@ -381,13 +399,13 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { doCloseAll() } - if (IDELanguageClientImpl.isInitialized()) { + if (didCompleteLiveOnCreate && IDELanguageClientImpl.isInitialized()) { IDELanguageClientImpl.shutdown() } super.preDestroy() - if (isDestroying) { + if (didCompleteLiveOnCreate && isDestroying) { try { stopLanguageServers() } catch (_: Exception) { diff --git a/app/src/main/java/com/itsaky/androidide/utils/DeepLinkProjectResolution.kt b/app/src/main/java/com/itsaky/androidide/utils/DeepLinkProjectResolution.kt index 18ae202378..29a0d4dcc7 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/DeepLinkProjectResolution.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/DeepLinkProjectResolution.kt @@ -47,17 +47,19 @@ suspend fun Activity.resolveDeepLinkProject( throw e } catch (e: SecurityException) { log.error("Failed to scan {} for deep link", projectsRoot, e) - // The activity may have started finishing while the scan above was still hitting disk -- - // don't flash an error against a dying window. - if (!isFinishing && !isDestroyed) { - withContext(Dispatchers.Main) { flashError(getString(string.msg_deeplink_scan_failed)) } + withContext(Dispatchers.Main) { + // Re-checked here, not before the hop -- the activity can start finishing during the + // hop itself, and a check taken only beforehand would miss that window. + if (!isFinishing && !isDestroyed) flashError(getString(string.msg_deeplink_scan_failed)) } return null } - if (projectDir == null && !isFinishing && !isDestroyed) { + if (projectDir == null) { withContext(Dispatchers.Main) { - flashError(getString(string.msg_deeplink_project_not_found, projectName)) + if (!isFinishing && !isDestroyed) { + flashError(getString(string.msg_deeplink_project_not_found, projectName)) + } } } return projectDir From 5528e14df93f3c554c7baab350018577e4844f39 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 16 Aug 2026 21:36:11 -0700 Subject: [PATCH 54/55] ADFA-5067: Fix real findings from a sixth /code-review max pass - switchToProject compared newProjectPath against the process-wide ProjectManagerImpl singleton's path, which a concurrent MainActivity.openProject() can overwrite while this instance is mid-teardown for an earlier switch -- making an unrelated project look like a same-project no-op and silently dropping the request. Added an isFinishing branch (checked first) that supersedes the pending open instead. - onNewIntent's pendingFileRequestBeforeSwitch capture (added last round) re-read getIntent() on every project-switch intent, so a second overlapping switch arriving before the first resolved would clobber the original staying project's captured request with whatever the first switch's own intent happened to carry. Guarded the capture with a one-shot flag. - confirmProjectClose's "Save and close" success path invoked pendingCloseCallback without nulling the field first, unlike the "Close without saving" branch -- onDestroy()'s own unconditional drain would then invoke the same callback a second time. Capture- then-null before use, matching the sibling branch. - askProjectOpenPermission's dismiss-and-replace policy had no awareness that its two callers (auto-open-last-project and deep-link resolution) can race each other: a deep link's confirmation dialog could get silently swapped out for an unrelated "open last project" prompt if the auto-open scan finished a moment later. Threaded an isDeepLink flag through so a deep link (explicit user action) can always replace, but the reverse can't. Skipped as accepted trade-offs (documented, or not currently reachable): a plain-switch intent with an empty-but-present PROJECT_PATH extra can arm pendingFileRequestBeforeSwitch with no drain path, but EditorActivityKt isn't exported and its only real caller never passes a blank path; ProjectManagerImpl.projectPath's lack of synchronization is a pre-existing, out-of-scope infra gap. Skipped as legitimate but optional design/duplication/efficiency suggestions, several of which are direct, known consequences of this PR's own prior minimal-diff fixes (didCompleteLiveOnCreate duplicated per-class, the close/reopen supersede logic duplicated at two sites, latestDeepLinkRequest duplicated in two classes): the 3x path- containment duplication's doc-comment nit, DeepLinkActivity's liveness-heuristic-vs-authoritative-signal redesign, the three independent "did save succeed" checks, the six-site isFinishing/ isDestroyed guard duplication, and findValidProjectByName's eager NFC/NFD normalization. --- .../androidide/activities/MainActivity.kt | 19 ++++++++- .../editor/EditorHandlerActivity.kt | 40 ++++++++++++++++--- 2 files changed, 52 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 c70cda83fa..f2d661ef05 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt @@ -422,9 +422,10 @@ class MainActivity : EdgeToEdgeIDEActivity() { private fun handleOpenProject( root: File, pendingFileRequest: PendingFileRequest? = null, + isDeepLink: Boolean = false, ) { if (GeneralPreferences.confirmProjectOpen) { - askProjectOpenPermission(root, pendingFileRequest) + askProjectOpenPermission(root, pendingFileRequest, isDeepLink) return } openProject(root, pendingFileRequest = pendingFileRequest) @@ -438,11 +439,25 @@ class MainActivity : EdgeToEdgeIDEActivity() { // onDestroy() to avoid leaking its window. private var activeOpenPermissionDialog: AlertDialog? = null + // Whether activeOpenPermissionDialog (if any) came from a deep link -- see askProjectOpenPermission. + private var activeOpenPermissionDialogIsDeepLink = false + private fun askProjectOpenPermission( root: File, pendingFileRequest: PendingFileRequest? = null, + isDeepLink: Boolean = false, ) { + // A deep link is an explicit, just-tapped user action and may always replace whatever's + // showing (including another deep link's own dialog, e.g. two links arriving in quick + // succession) -- but not the reverse: tryOpenLastProject's auto-open scan can complete + // moments after a deep link's dialog is already up, and silently yanking that away for an + // unrelated "open last project" prompt would be far more surprising than just dropping this + // slower, non-explicit request instead. + if (!isDeepLink && activeOpenPermissionDialogIsDeepLink && activeOpenPermissionDialog?.isShowing == true) { + return + } activeOpenPermissionDialog?.dismiss() + activeOpenPermissionDialogIsDeepLink = isDeepLink val builder = DialogUtils.newMaterialDialogBuilder(this) builder.setTitle(string.title_confirm_open_project) builder.setMessage(getString(string.msg_confirm_open_project, root.absolutePath)) @@ -544,7 +559,7 @@ class MainActivity : EdgeToEdgeIDEActivity() { // extra, and this stale, slower request must not now bounce the user back to its own // (older) target after they've already been taken to the newer one. if (latestDeepLinkRequest !== request) return@withContext - handleOpenProject(projectDir, pendingFileRequest = request.fileRequest) + handleOpenProject(projectDir, pendingFileRequest = request.fileRequest, isDeepLink = true) } } } 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 9886727d0d..e4318bf3ee 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 @@ -1979,6 +1979,13 @@ open class EditorHandlerActivity : // project's own still-pending file request (if any) isn't silently lost. private var pendingFileRequestBeforeSwitch: PendingFileRequest? = null + // True once pendingFileRequestBeforeSwitch has captured the ORIGINAL staying project's request. + // Without this, a second overlapping project-switch intent arriving before the first is + // resolved/declined would re-capture from getIntent() -- which by then holds the FIRST switch + // attempt's intent, not the original -- clobbering the real value with whatever (usually + // nothing) that intermediate intent happened to carry. + private var capturedPendingFileRequestBeforeSwitch = false + // Tracked so a slower, older deep-link resolve (still in flight when a second, faster-resolving // deep link arrives via onNewIntent) can tell it's been superseded -- mirrors // MainActivity.latestDeepLinkRequest's identical race on the cold-open path. @@ -1990,6 +1997,7 @@ open class EditorHandlerActivity : intent.putExtra("PROJECT_PATH", stayingProjectPath) val restore = pendingFileRequestBeforeSwitch pendingFileRequestBeforeSwitch = null + capturedPendingFileRequestBeforeSwitch = false if (restore != null) { intent.putExtra(PendingFileRequest.EXTRA_KEY, restore) } else { @@ -2103,14 +2111,19 @@ open class EditorHandlerActivity : recentProjectsViewModel.updateProjectModifiedDate( editorViewModel.getProjectName(), ) + // Captured then nulled before use, mirroring the neutral-button handler above -- + // otherwise onDestroy()'s own unconditional pendingCloseCallback?.invoke() would fire + // this same callback a second time. + val onClosedNow = pendingCloseCallback + pendingCloseCallback = null // contentOrNull can already be null here if the binding was torn down while the // save was in flight -- performCloseAllFiles would NPE on the view manipulation it - // does, but pendingCloseCallback (e.g. arming a pending deep-link project switch) - // has no such dependency and must still run, or a confirmed close silently drops it. + // does, but onClosedNow (e.g. arming a pending deep-link project switch) has no such + // dependency and must still run, or a confirmed close silently drops it. if (contentOrNull != null) { - performCloseAllFiles(manualFinish = true, onClosed = pendingCloseCallback) + performCloseAllFiles(manualFinish = true, onClosed = onClosedNow) } else { - pendingCloseCallback?.invoke() + onClosedNow?.invoke() // contentOrNull also goes null via isDestroying, which onPause() sets from // isFinishing -- well before onDestroy() actually runs -- so it is NOT reliable // proof onDestroy()'s one-shot drain already happened. Only isDestroyed (the real @@ -2170,9 +2183,14 @@ open class EditorHandlerActivity : // that's actually staying open if this switch gets cancelled/declined) would otherwise be // lost the moment setIntent() below replaces it. restoreIntentToStayingProject() puts it back // if that turns out to be what happens. - if (isProjectSwitchIntent) { + // Guarded on capturedPendingFileRequestBeforeSwitch so a SECOND overlapping switch intent, + // arriving before the first is resolved/declined, doesn't re-capture from getIntent() -- by + // then holding the first switch's own intent, not the original staying project's -- and + // clobber the real value with whatever (usually nothing) that intermediate intent carries. + if (isProjectSwitchIntent && !capturedPendingFileRequestBeforeSwitch) { pendingFileRequestBeforeSwitch = IntentCompat.getParcelableExtra(getIntent(), PendingFileRequest.EXTRA_KEY, PendingFileRequest::class.java) + capturedPendingFileRequestBeforeSwitch = true } setIntent(intent) @@ -2273,6 +2291,18 @@ open class EditorHandlerActivity : ) { val currentProjectPath = IProjectManager.getInstance().projectDirPath when { + // This instance is already finishing (e.g. it just armed pendingDeepLinkOpen for an + // earlier switch and called finish() below, awaiting its own onDestroy()) -- comparing + // newProjectPath against currentProjectPath below would be comparing against + // ProjectManagerImpl's process-wide path, which a *different*, unrelated instance's + // MainActivity.openProject() can overwrite in the meantime, making this look like a + // same-project no-op when it isn't. Superseding the earlier pending open (last request + // wins, matching handlePlainProjectSwitch's own reasoning) is unconditionally correct + // here since this instance can't do anything else with a new request anyway. + isFinishing -> { + pendingDeepLinkOpen.value = DeepLinkOpenRequest(newProjectPath, fileRequest) + } + // Either no project has actually finished initializing in this instance yet (e.g. it was // recreated after process death without a PROJECT_PATH extra), or contentOrNull is // already null (binding torn down) -- either way, confirmProjectClose below would From 8618ca603c2389eeb1810e0bd0649b8f5979fa97 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 17 Aug 2026 13:00:04 -0700 Subject: [PATCH 55/55] ADFA-5067: Address remaining open CodeRabbit test nitpicks - ZipUtilsTest's symlink test caught any FileSystemException as "symlinks unsupported," swallowing unexpected failures (flagged by detekt). Narrow it to the specific Windows "privilege not held" reason and rethrow anything else. - PathTraversalTest's plain-relative-path assertion compared against a hardcoded POSIX absolute path literal, which can mismatch on Windows where File's absolute-path resolution differs. Build the expected path from baseDir instead. --- .../java/com/itsaky/androidide/utils/PathTraversalTest.kt | 2 +- .../test/java/com/itsaky/androidide/utils/ZipUtilsTest.kt | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) 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 7c75bdf870..3dde85d43b 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/PathTraversalTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/PathTraversalTest.kt @@ -37,7 +37,7 @@ class PathTraversalTest { @Test fun `plain relative path resolves inside base`() { val resolved = resolveWithinDirectory(baseDir, "src/Main.kt") - assertThat(resolved).isEqualTo(File("/project/root/src/Main.kt")) + assertThat(resolved).isEqualTo(File(baseDir, "src/Main.kt").absoluteFile) } @Test diff --git a/common/src/test/java/com/itsaky/androidide/utils/ZipUtilsTest.kt b/common/src/test/java/com/itsaky/androidide/utils/ZipUtilsTest.kt index f0e6812390..70a5cbd812 100644 --- a/common/src/test/java/com/itsaky/androidide/utils/ZipUtilsTest.kt +++ b/common/src/test/java/com/itsaky/androidide/utils/ZipUtilsTest.kt @@ -76,8 +76,10 @@ class ZipUtilsTest { false } catch (e: FileSystemException) { // Windows NTFS supports symlinks but requires an elevated/Developer Mode privilege to - // create them -- without it, creation fails with this (a permission error), not - // UnsupportedOperationException. + // create them -- without it, creation fails with this specific reason (a permission + // error), not UnsupportedOperationException. Any other reason is a real, unexpected + // failure and must not be silently swallowed. + if (e.reason?.contains("privilege", ignoreCase = true) != true) throw e false } // Report as skipped, not silently passed, when this environment can't create symlinks.