From 2df5a56160d3d7c274655464ef51ef482a4c8362 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 10 Aug 2026 16:25:04 -0700 Subject: [PATCH 01/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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. From 07616575b64f686680a01aad50e674c9e482c568 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 21 Aug 2026 12:08:46 -0700 Subject: [PATCH 56/76] ADFA-5067: Address hal-eisen-adfa's PR review findings Data loss / lost-work fixes in the project-switch and deep-link flow: - DeepLinkActivity: drop FLAG_ACTIVITY_CLEAR_TOP when routing to MainActivity. ActionContextProvider.getActivity() can miss a live, backgrounded EditorHandlerActivity (a documented gap), and CLEAR_TOP would then destroy that live editor to clear the path to MainActivity, discarding unsaved work with no prompt. - EditorHandlerActivity: MainActivity.openProject's bookkeeping call mutates the process-wide projectDirPath global to the NEW path before EditorHandlerActivity ever compares against it, so its same-project/different-project detection could never actually fire a genuine switch - tapping a different project from Recents while one was already open showed no confirm-close and silently kept displaying the old project. Threads the pre-mutation path through a new PREVIOUS_PROJECT_PATH intent extra instead. - EditorHandlerActivity.onDestroy: gate the pending-close-callback drain on isFinishing. A non-finishing recreate (a config change EditorActivityKt doesn't declare, or "Don't keep activities") could land while a confirm-close dialog was still showing and silently confirm/discard the project it was showing. - EditorHandlerActivity.saveAllAsync: bail before invoking runAfter if the activity is finishing/destroyed. Wrapping the whole save in NonCancellable (needed so the write itself survives teardown) also made the Main-dispatcher runAfter hop survive teardown, touching a dying window/cleared ViewModels. - EditorHandlerActivity: don't drain a pending file request until the project is actually ready (workspace != null) - draining unconditionally left postProjectInit's deferred retry with nothing once a mid-sync request's apply attempt silently failed. - EditorHandlerActivity.restoreIntentToStayingProject: reset the switch-capture fields before the blank-path bail, not after, so a blank projectDirPath doesn't leave them stuck for the rest of the instance's life. - MainActivity: track deep-link consumption via a field persisted in onSaveInstanceState, not by mutating the Intent's own extra. A process-death recreate redelivers the original, unmutated launch Intent, so the old signal didn't survive it and the same request force-reopened a project the user had already navigated away from. Other confirmed bugs: - BaseEditorActivity.preDestroy: guard BuildOutputProvider/plugin snippet-listener teardown on a new didCompleteLiveOnCreate flag, matching the sibling guards EditorHandlerActivity/ ProjectHandlerActivity already have. A doomed duplicate instance whose onCreate bailed early never registered as their owner, so its teardown was wiping out a live sibling's registration instead. - EditorHandlerActivity.checkForExternalFileChanges: recompute areFilesModified after markAsSaved(). It's a cached flag only refreshed as a side effect of a successful per-file write, so it could stay stale-true after an external-change reload, permanently blocking GitBottomSheetFragment's save-before-git-action gate. - ZipUtils.unzipFile: reject a `..` path *segment*, not a substring (a filename like "notes..txt" was wrongly rejected); skip extracting over an existing symlink instead of aborting the whole archive (a user's legitimately symlinked gradlew broke Gradle wrapper install). - PathTraversal.resolveWithinDirectory: use Files.exists(_, NOFOLLOW_LINKS) in the ancestor walk. Plain Files.exists() follows symlinks, so a dangling one read as absent and the walk stepped past it instead of rejecting it. Cleanups: - Extract RecentProjectRepository so MainActivity/EditorHandlerActivity no longer inject RecentProjectDao (a Room data source) directly, per ARCHITECTURE.md's UI -> ViewModel -> Repository -> data source layering. - EditorHandlerActivity: use Range's existing copy constructor instead of hand-rebuilding one from raw Positions (equivalent today). - Correct a KDoc claiming MainActivity's exported="true" is "required for the launcher" - SplashActivity holds the actual MAIN/LAUNCHER filter; MainActivity has none, which is exactly why it's the actual attack surface the surrounding paragraph describes. - Remove .well-known/assetlinks.json and .well-known/README.md: now served from an R2 bucket via a Cloudflare Worker (#1693), making these repo-committed copies dead weight. Co-Authored-By: Claude Sonnet 5 --- .well-known/README.md | 20 --- .well-known/assetlinks.json | 12 -- ARCHITECTURE.md | 4 +- .../androidide/activities/DeepLinkActivity.kt | 26 ++-- .../androidide/activities/MainActivity.kt | 102 ++++++++++----- .../activities/editor/BaseEditorActivity.kt | 17 ++- .../editor/EditorHandlerActivity.kt | 117 +++++++++++++----- .../com/itsaky/androidide/di/AppModule.kt | 6 + .../repositories/RecentProjectRepository.kt | 36 ++++++ .../RecentProjectRepositoryImpl.kt | 32 +++++ .../itsaky/androidide/utils/PathTraversal.kt | 8 +- .../utils/ProjectOpenBookkeeping.kt | 17 +-- .../com/itsaky/androidide/utils/ZipUtils.kt | 12 +- .../itsaky/androidide/utils/ZipUtilsTest.kt | 34 ++++- 14 files changed, 318 insertions(+), 125 deletions(-) delete mode 100644 .well-known/README.md delete mode 100644 .well-known/assetlinks.json create mode 100644 app/src/main/java/com/itsaky/androidide/repositories/RecentProjectRepository.kt create mode 100644 app/src/main/java/com/itsaky/androidide/repositories/RecentProjectRepositoryImpl.kt diff --git a/.well-known/README.md b/.well-known/README.md deleted file mode 100644 index a4e94b30d1..0000000000 --- a/.well-known/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# `.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 deleted file mode 100644 index 51c327cb12..0000000000 --- a/.well-known/assetlinks.json +++ /dev/null @@ -1,12 +0,0 @@ -[ - { - "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" - ] - } - } -] diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index cc75ac306a..e3ac3db16f 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` (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. +**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://appdevforall.org/device/open/project/...` (and the identical `www` subdomain). 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 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. @@ -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 `RecentProjectsViewModel`, `MainActivity`, `EditorHandlerActivity`, `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`, `ProjectInfoBottomSheet`, and `ProjectCreationManager` directly, and by `MainActivity`/`EditorHandlerActivity` indirectly through `RecentProjectRepository` (`repositories/RecentProjectRepository.kt`) -- kept behind that interface, rather than injecting the DAO into those two Activities directly, per this section's own UI -> ViewModel -> Repository -> data source layering. > > **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/DeepLinkActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt index 2e46313a98..0d7d84684b 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt @@ -27,9 +27,10 @@ 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 - * 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: + * The sole `` holder for `https://appdevforall.org/device/open/project/...` (and the + * identical `www` subdomain) 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. * @@ -64,18 +65,17 @@ class DeepLinkActivity : Activity() { startActivity( Intent(this, target).apply { putExtra(DeepLinkRequest.EXTRA_KEY, request) - // 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. + // FLAG_ACTIVITY_CLEAR_TOP deliberately omitted: MainActivity has no special launch + // mode, so if an existing MainActivity instance sits lower in this task's back stack + // under a live EditorActivityKt - which ActionContextProvider.getActivity() can miss + // even when that editor is alive (see its KDoc) - CLEAR_TOP would destroy that editor + // to clear the path down to MainActivity, discarding unsaved work with no prompt. + // Without it, this may at worst stack a redundant MainActivity instance, a harmless + // nuisance; EditorActivityKt is singleTask, so it always reuses its live instance via + // onNewIntent regardless of these flags. addFlags( Intent.FLAG_ACTIVITY_NEW_TASK or - Intent.FLAG_ACTIVITY_SINGLE_TOP or - Intent.FLAG_ACTIVITY_CLEAR_TOP, + Intent.FLAG_ACTIVITY_SINGLE_TOP, ) }, ) 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 f2d661ef05..0b26addbf8 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt @@ -26,6 +26,7 @@ import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AlertDialog import androidx.core.content.IntentCompat import androidx.core.graphics.Insets +import androidx.core.os.BundleCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope @@ -49,9 +50,10 @@ 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.IProjectManager +import com.itsaky.androidide.repositories.RecentProjectRepository 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 @@ -93,7 +95,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 val recentProjectRepository: RecentProjectRepository by inject() private var feedbackButtonManager: FeedbackButtonManager? = null private var webServer: WebServer? = null private val shortcutManager by lazy { ShortcutManager(applicationContext) } @@ -102,6 +104,18 @@ class MainActivity : EdgeToEdgeIDEActivity() { // deep link arrives) can tell it's been superseded -- see handleDeepLinkRequest. private var latestDeepLinkRequest: DeepLinkRequest? = null + // The last deep-link request actually opened (or, if GeneralPreferences.confirmProjectOpen is on, + // actually confirmed) -- see handleOpenProject/askProjectOpenPermission. Persisted via + // onSaveInstanceState rather than signalled by removing the Intent's own extra: a genuine process + // death redelivers the ORIGINAL, unmutated launch Intent (extras and all) once the user returns to + // the task, so an Intent-mutation-based "already handled" signal doesn't survive it and this same + // request force-reopens a project the user has since navigated away from. A config-change + // recreate, in contrast, preserves this field across the recreate but correctly leaves it unset + // if the recreate happens before the user actually responds to the confirm dialog, so that dialog + // (destroyed along with the old instance) gets a fresh retry on the new one instead of the link + // being silently dropped. + private var consumedDeepLinkRequest: DeepLinkRequest? = null + private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { @@ -138,19 +152,22 @@ class MainActivity : EdgeToEdgeIDEActivity() { val deepLinkRequest = IntentCompat.getParcelableExtra(intent, DeepLinkRequest.EXTRA_KEY, DeepLinkRequest::class.java) + consumedDeepLinkRequest = + savedInstanceState?.let { + BundleCompat.getParcelable(it, KEY_CONSUMED_DEEP_LINK_REQUEST, 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) { - handleDeepLinkRequest(deepLinkRequest) - } else { - openLastProject() - } + // retrying it on the new instance; comparing against consumedDeepLinkRequest (restored above) + // rather than just checking deepLinkRequest != null is what tells a genuinely new/not-yet-acted- + // on request apart from the system redelivering the same original launch Intent verbatim after + // this same request was already fully handled (see consumedDeepLinkRequest's own docs). + if (deepLinkRequest != null && deepLinkRequest != consumedDeepLinkRequest) { + handleDeepLinkRequest(deepLinkRequest) + } else if (savedInstanceState == null) { + openLastProject() } if (FeatureFlags.isExperimentsEnabled) { @@ -428,6 +445,11 @@ class MainActivity : EdgeToEdgeIDEActivity() { askProjectOpenPermission(root, pendingFileRequest, isDeepLink) return } + // No confirmation gate -- opening happens immediately below, so this is "confirm time" for + // consumedDeepLinkRequest's purposes. + if (isDeepLink) { + consumedDeepLinkRequest = latestDeepLinkRequest + } openProject(root, pendingFileRequest = pendingFileRequest) } @@ -462,7 +484,15 @@ class MainActivity : EdgeToEdgeIDEActivity() { 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.setPositiveButton(string.yes) { _, _ -> + // The user has now actually confirmed -- "confirm time" for consumedDeepLinkRequest's + // purposes, unlike merely having shown this dialog (see its own docs on why that + // distinction matters for a recreate that happens while this dialog is still up). + if (isDeepLink) { + consumedDeepLinkRequest = latestDeepLinkRequest + } + openProject(root, pendingFileRequest = pendingFileRequest) + } builder.setNegativeButton(string.no, null) activeOpenPermissionDialog = builder.show() } @@ -473,9 +503,16 @@ class MainActivity : EdgeToEdgeIDEActivity() { hasTemplateIssues: Boolean = false, pendingFileRequest: PendingFileRequest? = null, ) { + // Captured before the bookkeeping call below overwrites it: EditorHandlerActivity.onNewIntent + // (already-live singleTask instance) needs to know what project WAS open to tell a genuine + // switch from a same-project no-op, but recordProjectOpenedBookkeeping's synchronous + // ProjectManagerImpl.projectPath write below makes that global read the NEW path by the time + // onNewIntent runs -- comparing against it there would always see "already open". + val previousProjectPath = IProjectManager.getInstance().projectDirPath + // Bookkeeping (Recents/analytics/lastOpenedProject) must run regardless of isFinishing -- // only the startActivity() below is unsafe from a finishing activity. - recordProjectOpenedBookkeeping(recentProjectDao, root, project, analyticsManager) + recordProjectOpenedBookkeeping(recentProjectRepository, root, project, analyticsManager) if (isFinishing) { return @@ -484,6 +521,7 @@ class MainActivity : EdgeToEdgeIDEActivity() { val intent = Intent(this, EditorActivityKt::class.java).apply { putExtra("PROJECT_PATH", root.absolutePath) + putExtra("PREVIOUS_PROJECT_PATH", previousProjectPath) if (hasTemplateIssues) { putExtra("HAS_TEMPLATE_ISSUES", true) } @@ -529,24 +567,18 @@ class MainActivity : EdgeToEdgeIDEActivity() { * [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. + * rather than calling [openProject] directly: [MainActivity] is `exported="true"` -- not because + * anything requires it to be (`SplashActivity` holds the actual MAIN/LAUNCHER intent-filter; + * [MainActivity] has none of its own), which is exactly why 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) { latestDeepLinkRequest = request lifecycleScope.launch(Dispatchers.IO) { val projectDir = resolveDeepLinkProject(Environment.PROJECTS_DIR, request.projectName) withContext(Dispatchers.Main) { - // 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 @@ -554,11 +586,14 @@ class MainActivity : EdgeToEdgeIDEActivity() { 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. + // -- 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 + // consumedDeepLinkRequest is set once this request is actually opened (or confirmed, if + // GeneralPreferences.confirmProjectOpen is on) -- see handleOpenProject/ + // askProjectOpenPermission and consumedDeepLinkRequest's own docs for why marking it + // here, before the user has necessarily responded to that confirm dialog, would be too + // early. handleOpenProject(projectDir, pendingFileRequest = request.fileRequest, isDeepLink = true) } } @@ -571,4 +606,13 @@ class MainActivity : EdgeToEdgeIDEActivity() { super.onDestroy() _binding = null } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putParcelable(KEY_CONSUMED_DEEP_LINK_REQUEST, consumedDeepLinkRequest) + } + + companion object { + private const val KEY_CONSUMED_DEEP_LINK_REQUEST = "consumedDeepLinkRequest" + } } 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 d3b26b9fdc..eb780ae67f 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 @@ -215,6 +215,15 @@ abstract class BaseEditorActivity : val appLogsViewModel by viewModels() var appLogsCoordinator: AppLogsCoordinator? = null + // Mirrors EditorHandlerActivity's/ProjectHandlerActivity's own same-named, independently-tracked + // flags: set only once onCreate reaches its end without bailing out early (the "no matching + // project" doomed-duplicate-instance branch above returns before this runs). preDestroy() checks + // it before touching the process-wide singletons this onCreate registers this instance with + // (BuildOutputProvider, the plugin snippet-refresh listener) -- a doomed instance never actually + // registered as their owner, so clearing them on its teardown would wipe out whatever a + // genuinely live sibling instance set up instead. + private var didCompleteLiveOnCreate = false + @Suppress("ktlint:standard:backing-property-naming") internal var _binding: ActivityEditorBinding? = null val binding: ActivityEditorBinding @@ -463,9 +472,11 @@ abstract class BaseEditorActivity : internal abstract fun doOpenHelp() protected open fun preDestroy() { - BuildOutputProvider.clearBottomSheet() + if (didCompleteLiveOnCreate) { + BuildOutputProvider.clearBottomSheet() - IDEApplication.getPluginManager()?.setSnippetRefreshListener(null) + IDEApplication.getPluginManager()?.setSnippetRefreshListener(null) + } Shizuku.removeBinderReceivedListener(shizukuBinderReceivedListener) if (isAtLeastR()) wadbConnectionViewModel.stop(this) @@ -809,6 +820,8 @@ abstract class BaseEditorActivity : observeFileOperations() setupGestureDetector() + + didCompleteLiveOnCreate = true } override fun onConfigurationChanged(newConfig: Configuration) { 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 e4318bf3ee..6ee57957c4 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,7 +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.repositories.RecentProjectRepository import com.itsaky.androidide.shortcuts.IdeShortcutActions import com.itsaky.androidide.shortcuts.ShortcutContext import com.itsaky.androidide.shortcuts.ShortcutExecutionContext @@ -177,7 +177,7 @@ open class EditorHandlerActivity : private val shortcutManager by lazy { ShortcutManager(applicationContext) } private val analyticsManager: IAnalyticsManager by inject() - private val recentProjectDao: RecentProjectDao by inject() + private val recentProjectRepository: RecentProjectRepository by inject() private val pendingDeepLinkOpen: PendingDeepLinkOpen by inject() private var pluginEditorProvider: EditorProviderImpl? = null @@ -395,7 +395,7 @@ open class EditorHandlerActivity : private fun performPendingDeepLinkOpen(pending: DeepLinkOpenRequest) { val root = File(pending.projectRoot) val ctx = applicationContext - recordProjectOpenedBookkeeping(recentProjectDao, root, project = null, analyticsManager = analyticsManager) + recordProjectOpenedBookkeeping(recentProjectRepository, root, project = null, analyticsManager = analyticsManager) ctx.startActivity( Intent(ctx, EditorActivityKt::class.java).apply { putExtra("PROJECT_PATH", pending.projectRoot) @@ -423,20 +423,31 @@ 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 + // Both gated on isFinishing: onDestroy() also runs for a non-finishing recreate (a config + // change EditorActivityKt's own configChanges doesn't cover - dark mode, locale, display size + // - or "Don't keep activities"), which can land while the confirm-close dialog above is still + // showing and pendingCloseCallback is already armed for it (set the moment the dialog opens, + // not once the user actually chooses an option - see confirmProjectClose). Without this guard, + // a config change the user never asked for silently confirms that pending close/switch and + // discards the project it was showing. The two legitimate confirm paths (Close without saving, + // Save and close) both call finish() before their own onClosed callback runs, so isFinishing is + // already true there by the time onDestroy() drains it. + if (isFinishing) { + // 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 -- - // 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. - drainPendingDeepLinkOpen() + // 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 -- + // 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. + drainPendingDeepLinkOpen() + } } override fun onResume() { @@ -483,6 +494,11 @@ open class EditorHandlerActivity : editorView.markAsSaved() fileTimestamps[file.absolutePath] = currentTimestamp updateTabs() + // Without this, areFilesModified() (a cached flag, only ever recomputed as a side + // effect of a successful per-file write - see saveResultInternal) can stay + // stale-true after this reload+markAsSaved: nothing else here reflects that the + // buffer this loop just cleaned is no longer modified. + editorViewModel.areFilesModified = hasUnsavedFiles() } } } @@ -836,11 +852,7 @@ open class EditorHandlerActivity : // 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), - ) + val safeSelection = Range(selection) editor.validateRange(safeSelection) editor.setSelection(safeSelection) } @@ -861,7 +873,7 @@ open class EditorHandlerActivity : // 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 range = selection ?: Range(Range.NONE) val isImage = withContext(Dispatchers.IO) { ImageUtils.isImage(file) } if (isImage) { openImage(this@EditorHandlerActivity, file) @@ -1040,6 +1052,14 @@ open class EditorHandlerActivity : false } withContext(Dispatchers.Main) { + // NonCancellable above means this whole block, including this Main-dispatcher hop, + // keeps running even after onDestroy() -- unlike before this method wrapped the + // entire body in NonCancellable, when that hop was simply dropped on teardown. + // runAfter callers (confirmProjectClose's onClosed, notifyFilesUnsaved's) build + // window-bound UI (flashError -> WindowManager.BadTokenException on a destroyed + // window) and read ViewModels that a real teardown has already cleared - skip the UI + // tail there while still having let the write itself complete above. + if (isFinishing || isDestroyed) return@withContext runAfter?.invoke(saveSucceeded) } } @@ -1992,12 +2012,18 @@ open class EditorHandlerActivity : private var latestDeepLinkRequest: DeepLinkRequest? = null private fun restoreIntentToStayingProject() { - val stayingProjectPath = IProjectManager.getInstance().projectDirPath - if (stayingProjectPath.isBlank()) return - intent.putExtra("PROJECT_PATH", stayingProjectPath) + // Reset unconditionally, before the blank-path bail below: a blank projectDirPath (e.g. a + // post-process-death recreate with no PROJECT_PATH) must not leave these permanently set -- + // every later switch's capture guard would otherwise stay false forever, silently losing the + // staying project's pending file request on every subsequent decline for the rest of this + // instance's life. val restore = pendingFileRequestBeforeSwitch pendingFileRequestBeforeSwitch = null capturedPendingFileRequestBeforeSwitch = false + + val stayingProjectPath = IProjectManager.getInstance().projectDirPath + if (stayingProjectPath.isBlank()) return + intent.putExtra("PROJECT_PATH", stayingProjectPath) if (restore != null) { intent.putExtra(PendingFileRequest.EXTRA_KEY, restore) } else { @@ -2163,12 +2189,18 @@ open class EditorHandlerActivity : // 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. + // "PREVIOUS_PROJECT_PATH", when present, is what IProjectManager.projectDirPath held before + // MainActivity.openProject's bookkeeping call overwrote it to the NEW path -- by the time this + // intent arrives here, the global itself already reads as the new path regardless of whether + // this is actually a switch, so re-reading it for the comparison below would never detect one. + val previousProjectPath = + intent.getStringExtra("PREVIOUS_PROJECT_PATH") ?: IProjectManager.getInstance().projectDirPath val isProjectSwitchIntent = ( deepLinkRequest != null && !projectNamesMatch(File(IProjectManager.getInstance().projectDirPath).name, deepLinkRequest.projectName) ) || - intent.getStringExtra("PROJECT_PATH")?.let { it != IProjectManager.getInstance().projectDirPath } == true + intent.getStringExtra("PROJECT_PATH")?.let { it != previousProjectPath } == 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 @@ -2271,11 +2303,18 @@ 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) + // No unconditional drain here (unlike this method's earlier version): switchToProject's own + // same-project branch now drains this only once the request is actually applied or + // intentionally dropped, so a request arriving mid-sync stays armed for postProjectInit's + // deferred retry instead of being silently lost. The other branches (isFinishing, blank path, + // different project) don't read the intent's own copy at all -- they thread fileRequest + // through DeepLinkOpenRequest to a brand-new intent instead. - switchToProject(newProjectPath, fileRequest) + // See onNewIntent's identical read: MainActivity.openProject's bookkeeping call already + // overwrote the live global to newProjectPath before this intent arrived. + val previousProjectPath = + intent.getStringExtra("PREVIOUS_PROJECT_PATH") ?: IProjectManager.getInstance().projectDirPath + switchToProject(newProjectPath, fileRequest, previousProjectPath) } /** @@ -2284,12 +2323,18 @@ open class EditorHandlerActivity : * 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. + * + * [previousProjectPath] defaults to the live [IProjectManager] global, which is accurate for the + * deep-link caller (nothing pre-mutates it before onNewIntent runs there); the plain-switch caller + * passes its own pre-mutation snapshot instead, since by the time its intent arrives, + * MainActivity.openProject's bookkeeping has already overwritten that global to [newProjectPath]. */ private fun switchToProject( newProjectPath: String, fileRequest: PendingFileRequest?, + previousProjectPath: String = IProjectManager.getInstance().projectDirPath, ) { - val currentProjectPath = IProjectManager.getInstance().projectDirPath + val currentProjectPath = previousProjectPath 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 @@ -2319,15 +2364,21 @@ 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 -> { + // Gates both applying now and draining the intent's own copy below: a request + // arriving while the project is still syncing (workspace == null) must stay armed for + // postProjectInit's deferred retry once that sync completes, or it's lost for good -- + // applyDeepLinkFileRequest resolves against files a still-in-progress sync may not + // have settled yet. + val projectReady = IProjectManager.getInstance().workspace != null 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(getString(string.msg_project_close_in_progress)) - } else { + } else if (projectReady) { fileRequest?.let { applyDeepLinkFileRequest(it) } } - if (fileRequest != null) { + if (fileRequest != null && (confirmCloseInProgress || projectReady)) { // 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 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 c63f37f09b..993bf4d48e 100644 --- a/app/src/main/java/com/itsaky/androidide/di/AppModule.kt +++ b/app/src/main/java/com/itsaky/androidide/di/AppModule.kt @@ -5,6 +5,8 @@ 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.repositories.RecentProjectRepository +import com.itsaky.androidide.repositories.RecentProjectRepositoryImpl import com.itsaky.androidide.roomData.recentproject.RecentProjectRoomDatabase import com.itsaky.androidide.viewmodel.CloneRepositoryViewModel import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel @@ -42,6 +44,10 @@ val coreModule = get().recentProjectDao() } + single { + RecentProjectRepositoryImpl(get()) + } + single { GitCredentialsManager(get()) } single { PendingDeepLinkOpen() } diff --git a/app/src/main/java/com/itsaky/androidide/repositories/RecentProjectRepository.kt b/app/src/main/java/com/itsaky/androidide/repositories/RecentProjectRepository.kt new file mode 100644 index 0000000000..2c863b552b --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/repositories/RecentProjectRepository.kt @@ -0,0 +1,36 @@ +/* + * 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.repositories + +import com.itsaky.androidide.roomData.recentproject.RecentProject + +/** + * Repository for recording a project's presence in Recents -- keeps [RecentProjectDao] (a Room + * data source) out of the UI layer, per ARCHITECTURE.md's UI -> ViewModel -> Repository -> data + * source layering. + */ +interface RecentProjectRepository { + /** Inserts [recentProject] into Recents; a no-op if a row for its location already exists. */ + suspend fun insert(recentProject: RecentProject) + + /** Updates the detected language for the Recents row at [location]. */ + suspend fun updateLanguage( + location: String, + language: String, + ) +} diff --git a/app/src/main/java/com/itsaky/androidide/repositories/RecentProjectRepositoryImpl.kt b/app/src/main/java/com/itsaky/androidide/repositories/RecentProjectRepositoryImpl.kt new file mode 100644 index 0000000000..617cbec725 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/repositories/RecentProjectRepositoryImpl.kt @@ -0,0 +1,32 @@ +/* + * 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.repositories + +import com.itsaky.androidide.roomData.recentproject.RecentProject +import com.itsaky.androidide.roomData.recentproject.RecentProjectDao + +class RecentProjectRepositoryImpl( + private val recentProjectDao: RecentProjectDao, +) : RecentProjectRepository { + override suspend fun insert(recentProject: RecentProject) = recentProjectDao.insert(recentProject) + + override suspend fun updateLanguage( + location: String, + language: String, + ) = recentProjectDao.updateLanguage(location, language) +} 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 677e174ab6..053df98a30 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/PathTraversal.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/PathTraversal.kt @@ -21,6 +21,7 @@ import java.io.File import java.io.IOException import java.nio.file.Files import java.nio.file.InvalidPathException +import java.nio.file.LinkOption /** * Resolves [relativePath] against [baseDir], rejecting any attempt to escape outside it. Intended @@ -75,7 +76,12 @@ fun resolveWithinDirectory( val realBase = base.toRealPath() var existingAncestor = resolved - while (!Files.exists(existingAncestor)) { + // NOFOLLOW_LINKS: plain Files.exists() follows symlinks, so a *dangling* symlink (one whose + // target doesn't currently exist) would otherwise read as absent here, walking straight past + // it to its parent instead of stopping to verify it -- toRealPath() below throws IOException + // (caught at the bottom) for a genuinely dangling target, correctly rejecting the path instead + // of silently trusting whatever ends up at the far side of it later. + while (!Files.exists(existingAncestor, LinkOption.NOFOLLOW_LINKS)) { existingAncestor = existingAncestor.parent ?: return null } if (!existingAncestor.toRealPath().startsWith(realBase)) null else resolved.toFile() 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 fa07a79474..83c7f4aba3 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt @@ -22,8 +22,8 @@ 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.repositories.RecentProjectRepository import com.itsaky.androidide.roomData.recentproject.RecentProject -import com.itsaky.androidide.roomData.recentproject.RecentProjectDao import com.itsaky.androidide.templates.Language import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -41,15 +41,18 @@ private val log = LoggerFactory.getLogger("ProjectOpenBookkeeping") * `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. + * [recentProjectRepository] is the caller's Koin-provided instance (`by inject()`) -- per ADR + * 0001/0006, persistence is always acquired through Koin, never by re-deriving the database + * directly, and kept behind this repository interface (not the raw DAO) so callers in the UI layer + * (both [com.itsaky.androidide.activities.MainActivity] and + * [com.itsaky.androidide.activities.editor.EditorHandlerActivity]) don't depend on a Room data + * source directly, per ARCHITECTURE.md's UI -> ViewModel -> Repository -> data source layering. * * 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( - recentProjectDao: RecentProjectDao, + recentProjectRepository: RecentProjectRepository, root: File, project: RecentProject?, analyticsManager: IAnalyticsManager, @@ -70,9 +73,9 @@ fun recordProjectOpenedBookkeeping( try { // Insert is IGNOREd for a project already in Recents, so refresh the detected language // separately -- but never clobber a stored value with a failed ("Unknown") detection. - recentProjectDao.insert(recentProject) + recentProjectRepository.insert(recentProject) if (!recentProject.language.equals(Language.Unknown.lang, ignoreCase = true)) { - recentProjectDao.updateLanguage(recentProject.location, recentProject.language) + recentProjectRepository.updateLanguage(recentProject.location, recentProject.language) } } catch (e: CancellationException) { throw e 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 c1683cf3c1..13ba3c2678 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/ZipUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/ZipUtils.kt @@ -50,7 +50,11 @@ object ZipUtils { while (entries.hasMoreElements()) { val entry = entries.nextElement() - if (entry.name.contains("..") || entry.name.startsWith("/") || entry.name.startsWith("\\")) { + // Per-segment, not a bare substring match: "notes..txt" or "a..b/c.txt" are harmless + // names that a substring check would wrongly abort the whole archive over. + if (entry.name.startsWith("/") || entry.name.startsWith("\\") || + entry.name.split('/', '\\').any { it == ".." } + ) { throw IOException("Zip entry contains dangerous path components: ${entry.name}") } @@ -62,9 +66,11 @@ object ZipUtils { // 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. + // existing symlink already inside destDir. A user symlinking e.g. gradlew or + // gradle/wrapper to a shared location is legitimate, so skip this one entry (leaving + // their symlink as-is) rather than aborting the whole extraction over it. if (Files.isSymbolicLink(outFile.toPath())) { - throw IOException("Refusing to extract over an existing symlink: ${entry.name}") + continue } if (entry.isDirectory) { 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 70a5cbd812..b21ff2bf06 100644 --- a/common/src/test/java/com/itsaky/androidide/utils/ZipUtilsTest.kt +++ b/common/src/test/java/com/itsaky/androidide/utils/ZipUtilsTest.kt @@ -63,7 +63,7 @@ class ZipUtilsTest { } @Test - fun `unzipFile refuses to extract over an existing symlink`() { + fun `unzipFile skips an entry that would extract over an existing symlink, without aborting the rest`() { val destDir = tempFolder.newFolder("dest") val realFile = File(destDir, "real.txt").apply { writeText("original") } val linkPath = File(destDir, "link.txt").toPath() @@ -86,17 +86,45 @@ class ZipUtilsTest { 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. + // would pass -- this isolates the separate, explicit isSymbolicLink guard. A second, + // unrelated entry proves a skip doesn't abort the whole archive (e.g. a user's legitimately + // symlinked gradlew alongside a normal Gradle wrapper zip entry). val zipFile = tempFolder.newFile("archive.zip") ZipOutputStream(zipFile.outputStream()).use { zip -> zip.putNextEntry(ZipEntry("link.txt")) zip.write("payload".toByteArray()) zip.closeEntry() + + zip.putNextEntry(ZipEntry("unrelated.txt")) + zip.write("unrelated content".toByteArray()) + zip.closeEntry() } - assertThrows(IOException::class.java) { ZipUtils.unzipFile(zipFile, destDir) } + val extracted = ZipUtils.unzipFile(zipFile, destDir) assertThat(Files.isSymbolicLink(linkPath)).isTrue() assertThat(realFile.readText()).isEqualTo("original") + assertThat(File(destDir, "unrelated.txt").readText()).isEqualTo("unrelated content") + assertThat(extracted.map { it.name }).containsExactly("unrelated.txt") + } + + @Test + fun `unzipFile allows a harmless double-dot inside a path segment`() { + val zipFile = tempFolder.newFile("archive.zip") + ZipOutputStream(zipFile.outputStream()).use { zip -> + zip.putNextEntry(ZipEntry("notes..txt")) + zip.write("note content".toByteArray()) + zip.closeEntry() + + zip.putNextEntry(ZipEntry("a..b/c.txt")) + zip.write("nested content".toByteArray()) + zip.closeEntry() + } + + val destDir = tempFolder.newFolder("dest") + ZipUtils.unzipFile(zipFile, destDir) + + assertThat(File(destDir, "notes..txt").readText()).isEqualTo("note content") + assertThat(File(destDir, "a..b/c.txt").readText()).isEqualTo("nested content") } } From 72a10428e64ff90e6c33228483aa0177ae8cc554 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 21 Aug 2026 12:09:01 -0700 Subject: [PATCH 57/76] ADFA-5067: Accept the apex domain for App Links, not just www The intent-filter only matched www.appdevforall.org, so a hand-typed or shared apex link (no www) opened in a browser instead of the app. Per hal-eisen-adfa's review: both hosts already serve an identical, verified assetlinks.json via the Cloudflare Worker from #1693 with no redirect, so this is a second element plus accepting the same host in DeepLinkRequest.parse's own re-validation (DeepLinkActivity is exported, so that re-check - not the manifest declaration alone - is what actually gates a request). Co-Authored-By: Claude Sonnet 5 --- app/src/main/AndroidManifest.xml | 4 ++++ .../com/itsaky/androidide/models/DeepLinkRequest.kt | 10 +++++++--- .../itsaky/androidide/models/DeepLinkRequestTest.kt | 8 ++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3788d1281b..e52b2979f8 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -110,6 +110,10 @@ android:scheme="https" android:host="www.appdevforall.org" android:pathPrefix="/device/open/project/" /> + elements on DeepLinkActivity's intent-filter) -- kept in sync with that list. + private val HOSTS = setOf("www.appdevforall.org", "appdevforall.org") private const val PATH_PREFIX = "/device/open/project/" private const val SEGMENT_PROJECT = "project" @@ -118,7 +122,7 @@ data class DeepLinkRequest( // 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) || + HOSTS.none { it.equals(uri.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 de223e6b70..4000d86305 100644 --- a/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt +++ b/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt @@ -214,6 +214,14 @@ class DeepLinkRequestTest { assertThat(parse("https://evil.example/device/open/project/MyApp")).isNull() } + @Test + fun `apex host without the www subdomain also matches`() { + // Both hosts serve an identical, verified assetlinks.json - see AndroidManifest.xml's matching + // pair of elements on DeepLinkActivity's intent-filter. + val request = parse("https://appdevforall.org/device/open/project/MyApp") + assertThat(request).isEqualTo(DeepLinkRequest(projectName = "MyApp")) + } + @Test fun `wrong path prefix yields null`() { assertThat(parse("https://www.appdevforall.org/some/other/path/project/MyApp")).isNull() From 059400e68b71b932345fc936309f969d7bee4e0a Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 21 Aug 2026 23:21:44 -0700 Subject: [PATCH 58/76] ADFA-5067: Keep the deep-link handoff alive through teardown, and remember every consumed request Two findings from the review, both real. The liveness guard in saveAllAsync skipped runAfter wholesale, which threw away the non-UI half of a callback's work. Its own comment names the case: a confirmed "Save and close" arms a process-wide pending deep-link switch that has to outlive this instance, so with the guard in place the requested project never opened and nothing was logged. runAfter is invoked unconditionally again, and the two callbacks in this file guard what actually needs a live window -- the same shape GitBottomSheetFragment's _binding check already had. Save-and-close gets an explicit teardown branch that still performs the handoff, mirroring the contentOrNull == null branch beside it. A single consumedDeepLinkRequest slot let a first link re-fire after process death: consuming link B leaves the task's launch Intent still carrying A, and that is the Intent a recreate is handed, so A no longer matched and reopened its project. Every consumed request is remembered now, in a new ConsumedDeepLinkRequests kept outside the activity so this bookkeeping is testable -- three separate lifecycle paths depend on it. Capped at 32 with oldest-first eviction so a looping sender cannot grow the saved Bundle. 7 tests on the new class, all of which fail against single-slot semantics. Co-Authored-By: Claude Opus 5 --- .../androidide/activities/MainActivity.kt | 34 +++---- .../editor/EditorHandlerActivity.kt | 30 +++++-- .../deeplink/ConsumedDeepLinkRequests.kt | 51 +++++++++++ .../deeplink/ConsumedDeepLinkRequestsTest.kt | 89 +++++++++++++++++++ 4 files changed, 184 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequests.kt create mode 100644 app/src/test/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequestsTest.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 0b26addbf8..92317c25d7 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt @@ -40,6 +40,7 @@ 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.deeplink.ConsumedDeepLinkRequests import com.itsaky.androidide.fragments.MainFragment import com.itsaky.androidide.fragments.RecentProjectsFragment import com.itsaky.androidide.idetooltips.TooltipManager @@ -114,7 +115,9 @@ class MainActivity : EdgeToEdgeIDEActivity() { // if the recreate happens before the user actually responds to the confirm dialog, so that dialog // (destroyed along with the old instance) gets a fresh retry on the new one instead of the link // being silently dropped. - private var consumedDeepLinkRequest: DeepLinkRequest? = null + // Every request consumed in this task, not just the last one -- see the class for why one slot + // was not enough. + private val consumedDeepLinkRequests = ConsumedDeepLinkRequests() private val onBackPressedCallback = object : OnBackPressedCallback(true) { @@ -152,19 +155,20 @@ class MainActivity : EdgeToEdgeIDEActivity() { val deepLinkRequest = IntentCompat.getParcelableExtra(intent, DeepLinkRequest.EXTRA_KEY, DeepLinkRequest::class.java) - consumedDeepLinkRequest = + consumedDeepLinkRequests.restore( savedInstanceState?.let { - BundleCompat.getParcelable(it, KEY_CONSUMED_DEEP_LINK_REQUEST, DeepLinkRequest::class.java) - } + BundleCompat.getParcelableArrayList(it, KEY_CONSUMED_DEEP_LINK_REQUESTS, 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; comparing against consumedDeepLinkRequest (restored above) + // retrying it on the new instance; comparing against consumedDeepLinkRequests (restored above) // rather than just checking deepLinkRequest != null is what tells a genuinely new/not-yet-acted- // on request apart from the system redelivering the same original launch Intent verbatim after - // this same request was already fully handled (see consumedDeepLinkRequest's own docs). - if (deepLinkRequest != null && deepLinkRequest != consumedDeepLinkRequest) { + // this same request was already fully handled (see consumedDeepLinkRequests' own docs). + if (deepLinkRequest != null && deepLinkRequest !in consumedDeepLinkRequests) { handleDeepLinkRequest(deepLinkRequest) } else if (savedInstanceState == null) { openLastProject() @@ -446,9 +450,9 @@ class MainActivity : EdgeToEdgeIDEActivity() { return } // No confirmation gate -- opening happens immediately below, so this is "confirm time" for - // consumedDeepLinkRequest's purposes. + // consumedDeepLinkRequests' purposes. if (isDeepLink) { - consumedDeepLinkRequest = latestDeepLinkRequest + consumedDeepLinkRequests.add(latestDeepLinkRequest) } openProject(root, pendingFileRequest = pendingFileRequest) } @@ -485,11 +489,11 @@ class MainActivity : EdgeToEdgeIDEActivity() { builder.setMessage(getString(string.msg_confirm_open_project, root.absolutePath)) builder.setCancelable(false) builder.setPositiveButton(string.yes) { _, _ -> - // The user has now actually confirmed -- "confirm time" for consumedDeepLinkRequest's + // The user has now actually confirmed -- "confirm time" for consumedDeepLinkRequests' // purposes, unlike merely having shown this dialog (see its own docs on why that // distinction matters for a recreate that happens while this dialog is still up). if (isDeepLink) { - consumedDeepLinkRequest = latestDeepLinkRequest + consumedDeepLinkRequests.add(latestDeepLinkRequest) } openProject(root, pendingFileRequest = pendingFileRequest) } @@ -589,9 +593,9 @@ class MainActivity : EdgeToEdgeIDEActivity() { // -- 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 - // consumedDeepLinkRequest is set once this request is actually opened (or confirmed, if + // the request is recorded as consumed once this request is actually opened (or confirmed, if // GeneralPreferences.confirmProjectOpen is on) -- see handleOpenProject/ - // askProjectOpenPermission and consumedDeepLinkRequest's own docs for why marking it + // askProjectOpenPermission and consumedDeepLinkRequests' own docs for why marking it // here, before the user has necessarily responded to that confirm dialog, would be too // early. handleOpenProject(projectDir, pendingFileRequest = request.fileRequest, isDeepLink = true) @@ -609,10 +613,10 @@ class MainActivity : EdgeToEdgeIDEActivity() { override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - outState.putParcelable(KEY_CONSUMED_DEEP_LINK_REQUEST, consumedDeepLinkRequest) + outState.putParcelableArrayList(KEY_CONSUMED_DEEP_LINK_REQUESTS, consumedDeepLinkRequests.toSavedList()) } companion object { - private const val KEY_CONSUMED_DEEP_LINK_REQUEST = "consumedDeepLinkRequest" + private const val KEY_CONSUMED_DEEP_LINK_REQUESTS = "consumedDeepLinkRequests" } } 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 6ee57957c4..8544b3fb74 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 @@ -1055,11 +1055,15 @@ open class EditorHandlerActivity : // NonCancellable above means this whole block, including this Main-dispatcher hop, // keeps running even after onDestroy() -- unlike before this method wrapped the // entire body in NonCancellable, when that hop was simply dropped on teardown. - // runAfter callers (confirmProjectClose's onClosed, notifyFilesUnsaved's) build - // window-bound UI (flashError -> WindowManager.BadTokenException on a destroyed - // window) and read ViewModels that a real teardown has already cleared - skip the UI - // tail there while still having let the write itself complete above. - if (isFinishing || isDestroyed) return@withContext + // + // runAfter is invoked unconditionally, teardown included. A liveness check here used + // to skip it wholesale, which silently dropped the *non-UI* half of a callback's + // work: confirmProjectClose's onClosed arms a process-wide pending deep-link switch + // (ADFA-5067) that has to outlive this instance, so losing it means a confirmed + // "Save and close" never opens the project the link asked for, with nothing logged. + // Each callback decides for itself what needs a live window -- see the teardown + // branches at the two call sites in this file, and GitBottomSheetFragment's own + // _binding check. runAfter?.invoke(saveSucceeded) } } @@ -1407,6 +1411,10 @@ open class EditorHandlerActivity : notify = true, runAfter = { succeeded -> runOnUiThread { + // Nothing in this tail survives teardown usefully: flashError needs a live + // window, and invokeAfter closes tabs on a binding that is going away. The + // write itself already completed in saveAllAsync. + if (isFinishing || isDestroyed) return@runOnUiThread // 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 @@ -2108,6 +2116,18 @@ open class EditorHandlerActivity : saveAllAsync(notify = false) { saveSucceeded -> runOnUiThread { confirmCloseInProgress = false + + // Teardown: no window for a message, and no point re-confirming a superseded request + // on an instance that is going away -- but the handoff below is process-wide state + // that a new instance drains, so it must still happen. Mirrors the contentOrNull == + // null branch further down, which exists for the same reason. + if (isFinishing || isDestroyed) { + val onClosedDuringTeardown = pendingCloseCallback + pendingCloseCallback = null + onClosedDuringTeardown?.invoke() + if (isDestroyed) drainPendingDeepLinkOpen() + 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. diff --git a/app/src/main/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequests.kt b/app/src/main/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequests.kt new file mode 100644 index 0000000000..90e5009a13 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequests.kt @@ -0,0 +1,51 @@ +package com.itsaky.androidide.deeplink + +import com.itsaky.androidide.models.DeepLinkRequest + +/** + * The deep-link requests this task has already acted on, so a redelivered Intent carrying one of + * them does not force its project open a second time (ADFA-5067). + * + * Every consumed request is remembered, not just the latest. One slot was not enough: after link A + * is consumed and link B arrives through `onNewIntent`, `setIntent` makes B the live Intent while + * the task still holds A as its launch Intent -- and that is the Intent a recreate after process + * death is given. A then failed a "same as the last consumed request" test and reopened its project, + * which is the very loss the single field existed to prevent. + * + * Kept out of the activity so the bookkeeping can be tested without one: this is the third distinct + * lifecycle path (config change, process death, second link) whose correctness rests entirely on it. + */ +internal class ConsumedDeepLinkRequests { + private val requests = LinkedHashSet() + + /** For `onSaveInstanceState`; pairs with [restore]. */ + fun toSavedList(): ArrayList = ArrayList(requests) + + /** Replaces the contents with [saved], which is null when there is no instance state to restore. */ + fun restore(saved: List?) { + requests.clear() + saved?.let(requests::addAll) + } + + operator fun contains(request: DeepLinkRequest): Boolean = request in requests + + /** + * Records [request] as acted on. Null is accepted and ignored: the caller's "latest request" can + * legitimately be unset by the time a confirmation dialog is answered. + * + * Oldest-first eviction past [MAX_REMEMBERED] keeps the saved Bundle bounded against a sender + * that fires links in a loop. The evicted case degrades to the old behaviour -- one spurious + * reopen of a link superseded 32 links ago -- which no real sequence reaches. + */ + fun add(request: DeepLinkRequest?) { + request ?: return + requests += request + while (requests.size > MAX_REMEMBERED) { + requests.remove(requests.first()) + } + } + + private companion object { + const val MAX_REMEMBERED = 32 + } +} diff --git a/app/src/test/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequestsTest.kt b/app/src/test/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequestsTest.kt new file mode 100644 index 0000000000..0ddd6af983 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequestsTest.kt @@ -0,0 +1,89 @@ +package com.itsaky.androidide.deeplink + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.models.DeepLinkRequest +import org.junit.Test + +/** + * ADFA-5067: a redelivered Intent must not force its project open twice. + * + * The scenario that matters is the one a single stored request got wrong -- two links, then process + * death, where the Intent the system hands back is the task's *launch* Intent rather than the last + * one `setIntent` saw. + */ +class ConsumedDeepLinkRequestsTest { + private fun request(name: String) = DeepLinkRequest(projectName = name) + + @Test + fun `a consumed request is recognised`() { + val consumed = ConsumedDeepLinkRequests() + consumed.add(request("alpha")) + + assertThat(request("alpha") in consumed).isTrue() + assertThat(request("beta") in consumed).isFalse() + } + + // The single-slot bug: consuming B made A look unconsumed again, and A is what a post-process-death + // recreate is handed, so A's project reopened over whatever the user was doing. + @Test + fun `consuming a second request does not un-consume the first`() { + val consumed = ConsumedDeepLinkRequests() + consumed.add(request("alpha")) + consumed.add(request("beta")) + + assertThat(request("alpha") in consumed).isTrue() + assertThat(request("beta") in consumed).isTrue() + } + + @Test + fun `the set survives a save and restore`() { + val consumed = ConsumedDeepLinkRequests() + consumed.add(request("alpha")) + consumed.add(request("beta")) + + val restored = ConsumedDeepLinkRequests() + restored.restore(consumed.toSavedList()) + + assertThat(request("alpha") in restored).isTrue() + assertThat(request("beta") in restored).isTrue() + } + + @Test + fun `restoring nothing leaves an empty set, not a stale one`() { + val consumed = ConsumedDeepLinkRequests() + consumed.add(request("alpha")) + consumed.restore(null) + + assertThat(request("alpha") in consumed).isFalse() + } + + @Test + fun `a repeated request is remembered once`() { + val consumed = ConsumedDeepLinkRequests() + consumed.add(request("alpha")) + consumed.add(request("alpha")) + + assertThat(consumed.toSavedList()).containsExactly(request("alpha")) + } + + @Test + fun `a null request is ignored`() { + val consumed = ConsumedDeepLinkRequests() + consumed.add(null) + + assertThat(consumed.toSavedList()).isEmpty() + } + + // The cap bounds the saved Bundle; what it must not do is forget the most recent requests. + @Test + fun `past the cap the oldest is evicted and the newest kept`() { + val consumed = ConsumedDeepLinkRequests() + repeat(40) { consumed.add(request("project$it")) } + + assertThat(consumed.toSavedList()).hasSize(32) + assertThat(request("project0") in consumed).isFalse() + assertThat(request("project7") in consumed).isFalse() + assertThat(request("project8") in consumed).isTrue() + assertThat(request("project39") in consumed).isTrue() + } +} From 8a2044bb4e028bfc6c6f6e954ed563e792bd386d Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 21 Aug 2026 23:59:20 -0700 Subject: [PATCH 59/76] ADFA-5067: Log the save-during-teardown branch instead of leaving it silent This is the branch that used to lose a confirmed deep-link project switch, and it is invisible from the UI -- the only symptom was a project that never opened. An on-device attempt to exercise it could not trigger it: the phone declines to destroy the activity while the app holds a foreground service, so the line is also how we will know if it ever fires in the field. Co-Authored-By: Claude Opus 5 --- .../androidide/activities/editor/EditorHandlerActivity.kt | 8 ++++++++ 1 file changed, 8 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 8544b3fb74..8327dd7ea9 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 @@ -2124,6 +2124,14 @@ open class EditorHandlerActivity : if (isFinishing || isDestroyed) { val onClosedDuringTeardown = pendingCloseCallback pendingCloseCallback = null + // Logged, not silent: this branch is the one that used to lose the switch, and it + // is invisible from the UI -- the only symptom was a project that never opened. + log.info( + "Save completed during teardown (isFinishing={}, isDestroyed={}); running the close callback anyway: {}.", + isFinishing, + isDestroyed, + onClosedDuringTeardown != null, + ) onClosedDuringTeardown?.invoke() if (isDestroyed) drainPendingDeepLinkOpen() return@runOnUiThread From 90662d9a23691acc256b217c942db9fd2b67ebde Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 24 Aug 2026 11:26:08 -0700 Subject: [PATCH 60/76] ADFA-5067: Reject a dot-dot path segment, not any filename containing dots resolveWithinDirectory rejected any relative path containing ".." as a substring, so a deep link to a legitimately named file -- notes..txt, a..b/c.kt -- failed with no explanation. The old test called this an acceptable trade-off on the grounds that project files never need consecutive dots; the sibling guard in ZipUtils.unzipFile had already concluded the opposite for the same pattern, and it is right. Nothing is given up. Only a literal ".." segment can name a parent directory, so the per-segment check catches every traversal the substring check did, and the normalize + startsWith + toRealPath layers below remain what actually enforce containment. The traversal-rejection tests pass identically before and after. Percent-decoding happens in Uri.pathSegments before this function runs, so an encoded traversal arrives as a literal ".." segment and is caught; a double-encoded one arrives as the filename "%2e%2e", which cannot name a parent. Both now have tests. The three copies of this containment algorithm are still three copies. That is a separate change: this one has no Android dependency and `app` already depends on `common`, so it can be shared rather than mirrored by hand. Co-Authored-By: Claude Opus 5 --- .../itsaky/androidide/utils/PathTraversal.kt | 21 ++++++++--- .../androidide/utils/PathTraversalTest.kt | 35 ++++++++++++++++--- 2 files changed, 46 insertions(+), 10 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 053df98a30..407f5d1d06 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/PathTraversal.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/PathTraversal.kt @@ -33,10 +33,15 @@ import java.nio.file.LinkOption * `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 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. + * 1. A lexical reject of an empty string, a `..` *segment*, or a leading `/` or `\` -- cheap, + * catches the common case outright. Per segment, not as a substring: `notes..txt` and + * `a..b/c.kt` are legitimate filenames, and a deep link to one has no business failing. Only a + * literal `..` segment can name a parent directory, so nothing is lost -- and layers 2 and 3 + * below are what actually enforce containment in any case. (The sibling zip guards reached the + * same conclusion for the same reason; `ZipUtils.unzipFile` spells it out.) 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 @@ -59,7 +64,13 @@ fun resolveWithinDirectory( baseDir: File, relativePath: String, ): File? { - if (relativePath.isEmpty() || relativePath.contains("..") || relativePath.startsWith("/") || relativePath.startsWith("\\")) { + // Split on both separators: '\' is not a path separator on Android, but a caller handing over a + // Windows-style path should not have it silently treated as one long filename. + if (relativePath.isEmpty() || + relativePath.startsWith("/") || + relativePath.startsWith("\\") || + relativePath.split('/', '\\').any { it == ".." } + ) { 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 3dde85d43b..d24ab8efc8 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/PathTraversalTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/PathTraversalTest.kt @@ -81,12 +81,37 @@ class PathTraversalTest { assertThat(resolveWithinDirectory(baseDir, "foo" + nulCharacter + ".txt")).isNull() } + // This used to be rejected, on the reasoning that project files never legitimately need + // consecutive dots. They do -- and a deep link to one failing with no explanation is a bug, not + // a safe trade-off. Nothing is given up: only a literal ".." *segment* can name a parent, and + // the tests below cover every way of writing one. @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. - assertThat(resolveWithinDirectory(baseDir, "a..b.txt")).isNull() + fun `a filename containing dot-dot is resolved, not rejected`() { + assertThat(resolveWithinDirectory(baseDir, "notes..txt")) + .isEqualTo(File(baseDir, "notes..txt").absoluteFile) + assertThat(resolveWithinDirectory(baseDir, "a..b/c.kt")) + .isEqualTo(File(baseDir, "a..b/c.kt").absoluteFile) + assertThat(resolveWithinDirectory(baseDir, "....gitignore")) + .isEqualTo(File(baseDir, "....gitignore").absoluteFile) + } + + // The segment itself, in every position, is still refused. + @Test + fun `a dot-dot segment is rejected wherever it appears`() { + assertThat(resolveWithinDirectory(baseDir, "..")).isNull() + assertThat(resolveWithinDirectory(baseDir, "../x")).isNull() + assertThat(resolveWithinDirectory(baseDir, "a/../b")).isNull() + assertThat(resolveWithinDirectory(baseDir, "a/..")).isNull() + assertThat(resolveWithinDirectory(baseDir, "a\\..\\b")).isNull() + } + + // Percent-decoding happens in Uri.pathSegments before this function sees the string, so an + // encoded traversal arrives as a literal ".." segment and is caught above. A double-encoded one + // arrives as the harmless filename "%2e%2e", which cannot name a parent directory. + @Test + fun `a double-encoded dot-dot is an ordinary filename`() { + assertThat(resolveWithinDirectory(baseDir, "%2e%2e/x")) + .isEqualTo(File(baseDir, "%2e%2e/x").absoluteFile) } @Test From 3bcce80015b9549814364337a7bf88eebc6ee839 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 24 Aug 2026 15:09:12 -0700 Subject: [PATCH 61/76] ADFA-5067: Run the save on the application scope, not the activity's NonCancellable protects saveAllAsync's body only once it has started running. A launch on the IO dispatcher can still be queued when onDestroy() cancels the activity's scope, in which case the body never starts, runAfter never runs, and a confirmed deep-link project switch is lost -- the same loss as the liveness guard this PR already removed, through a narrower window. The application scope from AppModule has no such window. The activity is retained for the duration of the save, which is what NonCancellable already implied. Co-Authored-By: Claude Opus 5 --- .../activities/editor/EditorHandlerActivity.kt | 17 ++++++++++++++--- 1 file changed, 14 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 8327dd7ea9..50c7bd4ca5 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 @@ -120,6 +120,7 @@ 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.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch @@ -180,6 +181,10 @@ open class EditorHandlerActivity : private val recentProjectRepository: RecentProjectRepository by inject() private val pendingDeepLinkOpen: PendingDeepLinkOpen by inject() + // The process-wide scope from AppModule, used only by saveAllAsync -- see there for why the + // activity's own scope is not enough. + private val appScope: CoroutineScope by inject() + private var pluginEditorProvider: EditorProviderImpl? = null // True once onCreate() has completed past its isFinishing check -- see there and preDestroy() @@ -1028,9 +1033,15 @@ open class EditorHandlerActivity : progressConsumer: ((Int, Int) -> Unit)?, runAfter: ((Boolean) -> Unit)?, ) { - lifecycleScope.launch(Dispatchers.IO) { - // The whole body -- not just saveAll() -- runs NonCancellable. onDestroy() cancels - // lifecycleScope's Job as soon as it runs; leaving NonCancellable partway through (e.g. + // Not lifecycleScope: NonCancellable protects the body only once it has started running, and + // a launch on the IO dispatcher can still be queued when onDestroy() cancels the activity's + // scope -- in which case the body never starts and runAfter never runs, losing a confirmed + // deep-link project switch exactly as if the guard that used to skip it were still there + // (found in review). The application scope has no such window. The activity is retained for + // the duration of the save, which is what NonCancellable already implied. + appScope.launch(Dispatchers.IO) { + // The whole body -- not just saveAll() -- runs NonCancellable. onDestroy() cancels the + // activity'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 From e9393243ccc415d4a52627d2e9cd60c8ab12b63e Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 25 Aug 2026 12:42:09 -0700 Subject: [PATCH 62/76] ADFA-5067: Stop exporting MainActivity, the deep-link handoff target MainActivity was exported="true" while declaring no intent-filter of its own -- SplashActivity holds MAIN/LAUNCHER -- so nothing outside the app ever needed to launch it. With deep-link support it became the component that accepts a parsed DeepLinkRequest as an Intent extra, which any co-installed app could send directly: DeepLinkActivity's URI validation bypassed, an arbitrary project forced open and an arbitrary file inside it navigated to, with no user interaction and no permission. The confirmation gate handleDeepLinkRequest relied on for exactly this reason is not one: GeneralPreferences.confirmProjectOpen defaults to false, so on a default install askProjectOpenPermission never runs and the open is immediate. That preference is a user convenience, not a security boundary, and its KDoc now says so. The boundary is the manifest. DeepLinkActivity is same-app, so the real handoff is unaffected; EditorActivityKt, the other target, already defaulted to not exported. DeepLinkTargetsNotExportedTest pins both halves: neither handoff target is exported, and DeepLinkActivity itself stays exported -- "fixing" that one would turn every deep link into a silent no-op. Confirmed to fail with exported="true" restored. The manifest is stored with CRLF line endings and Spotless does not enforce LF on it, so the edit preserves them; a text-mode rewrite silently converts all 390 and buries this two-line change in a 786-line whole-file diff. Found in review of PR #1651. --- app/src/main/AndroidManifest.xml | 8 ++- .../androidide/activities/MainActivity.kt | 17 ++--- .../DeepLinkTargetsNotExportedTest.kt | 63 +++++++++++++++++++ 3 files changed, 80 insertions(+), 8 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/activities/DeepLinkTargetsNotExportedTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e52b2979f8..cd4d886323 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -91,10 +91,16 @@ android:name=".activities.OnboardingActivity" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize" android:exported="false" /> + . + */ + +package com.itsaky.androidide.activities + +import android.content.ComponentName +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.activities.editor.EditorActivityKt +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * DeepLinkActivity validates a deep-link URI and then hands the parsed request on to one of these + * two activities in an Intent extra. Neither may be reachable from outside the app: an exported + * target can be sent that same extra directly, skipping the validation, to force an arbitrary + * project open and navigate to an arbitrary file inside it. + * + * MainActivity was exported with no intent-filter of its own, so nothing legitimate needed it + * (SplashActivity holds MAIN/LAUNCHER) and the extra was forgeable (ADFA-5067 review). + */ +@RunWith(RobolectricTestRunner::class) +class DeepLinkTargetsNotExportedTest { + @Test + fun `the activities DeepLinkActivity hands a parsed request to are not exported`() { + val context = ApplicationProvider.getApplicationContext() + + for (target in listOf(MainActivity::class.java, EditorActivityKt::class.java)) { + val info = + context.packageManager.getActivityInfo(ComponentName(context, target), 0) + assertThat(info.exported).isFalse() + } + } + + // The activity that does the validating must stay exported -- it is the App Link entry point, and + // a non-exported one would make every deep link a no-op rather than a security improvement. + @Test + fun `DeepLinkActivity itself is exported`() { + val context = ApplicationProvider.getApplicationContext() + val info = + context.packageManager.getActivityInfo( + ComponentName(context, DeepLinkActivity::class.java), + 0, + ) + assertThat(info.exported).isTrue() + } +} From 6ccad375b1d2594f89680e43bdad9623ca4468c8 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 25 Aug 2026 19:01:44 -0700 Subject: [PATCH 63/76] ADFA-5067: Do not let a deep link walk past setup Both of DeepLinkActivity's targets sit beyond SplashActivity and OnboardingActivity, which are the only things enforcing the terms, the permissions, the JDK and SDK install, the low-storage check and the x86 exit. A link on a fresh install -- or after Clear Data -- therefore opened the editor with no toolchain and no permissions, where builds and file access fail for reasons the user cannot connect to anything they did. On an x86 device it made the app reachable at all, past a guard that deliberately calls finishAffinity() and exitProcess(0). The link is now dropped when setup is incomplete: the user is told, and sent to SplashActivity, which decides what they actually need. Dropped rather than deferred, deliberately -- carrying a request through an onboarding that takes minutes and may not finish is a lot of machinery for a rare case. Nothing here re-decides storage or ABI; those stay SplashActivity's, so there is one place that knows the launch order. The readiness rule itself moves to isIdeSetupComplete() rather than being copied. OnboardingActivity had it privately and now calls the shared one; a second copy would let the two disagree about what "ready" means, and the copy that disagrees silently is the one that skips a gate. DeepLinkSetupGateTest covers both halves: the predicate is false with no toolchain installed, and a link arriving in that state routes to SplashActivity and finishes. Without the gate the same test lands on MainActivity, which is the bug. 308 app tests pass. Found in review of PR #1651. --- .../androidide/activities/DeepLinkActivity.kt | 17 ++ .../activities/OnboardingActivity.kt | 153 +++++++++--------- .../androidide/activities/SetupState.kt | 42 +++++ .../activities/DeepLinkSetupGateTest.kt | 58 +++++++ resources/src/main/res/values/strings.xml | 1 + 5 files changed, 198 insertions(+), 73 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/activities/SetupState.kt create mode 100644 app/src/test/java/com/itsaky/androidide/activities/DeepLinkSetupGateTest.kt 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 0d7d84684b..e174b89d62 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt @@ -41,6 +41,23 @@ class DeepLinkActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // A link can arrive before the IDE is usable -- a fresh install, or a Clear Data. Both targets + // below sit *past* SplashActivity and OnboardingActivity, which are the only things enforcing + // the terms, the permissions, the JDK and SDK install, the low-storage check and the x86 + // exit, so following the link now would land the user in an editor that cannot build, on a + // device the app is supposed to refuse to run on at all (ADFA-5067 review). + // + // The link is dropped rather than deferred: carrying a request through an onboarding that can + // take several minutes, and may not finish at all, is a lot of machinery for a rare case. The + // user is told, and sent to the normal entry point, which decides what they actually need -- + // storage, ABI and onboarding are SplashActivity's to enforce, not this activity's to repeat. + if (!isIdeSetupComplete()) { + Toast.makeText(this, getString(string.msg_deeplink_setup_incomplete), Toast.LENGTH_LONG).show() + startActivity(Intent(this, SplashActivity::class.java)) + finish() + return + } + val request = DeepLinkRequest.parse(intent?.data) if (request == null) { // A Toast, not flashError -- this activity finishes immediately below, tearing down its diff --git a/app/src/main/java/com/itsaky/androidide/activities/OnboardingActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/OnboardingActivity.kt index 385feadbcc..c55546025f 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/OnboardingActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/OnboardingActivity.kt @@ -47,10 +47,10 @@ import com.itsaky.androidide.preferences.internal.prefManager import com.itsaky.androidide.tasks.doAsyncWithProgress import com.itsaky.androidide.ui.themes.IThemeManager import com.itsaky.androidide.utils.Environment -import com.itsaky.androidide.utils.isTestMode import com.itsaky.androidide.utils.PermissionsHelper import com.itsaky.androidide.utils.isAtLeastV import com.itsaky.androidide.utils.isSystemInDarkMode +import com.itsaky.androidide.utils.isTestMode import com.itsaky.androidide.utils.resolveAttr import com.termux.shared.android.PackageUtils import com.termux.shared.markdown.MarkdownUtils @@ -62,14 +62,13 @@ import kotlinx.coroutines.withContext import org.slf4j.LoggerFactory class OnboardingActivity : AppIntro2() { - private var listJdkInstallationsJob: Job? = null - private lateinit var feedbackButton: FloatingActionButton - private var feedbackButtonManager: FeedbackButtonManager? = null - private lateinit var nextButton: ImageButton - private lateinit var pulseAnimation: Animation + private lateinit var feedbackButton: FloatingActionButton + private var feedbackButtonManager: FeedbackButtonManager? = null + private lateinit var nextButton: ImageButton + private lateinit var pulseAnimation: Animation - companion object { + companion object { private val logger = LoggerFactory.getLogger(OnboardingActivity::class.java) private const val KEY_ARCHCONFIG_WARNING_IS_SHOWN = "ide.archConfig.experimentalWarning.isShown" @@ -103,14 +102,14 @@ class OnboardingActivity : AppIntro2() { setTransformer(AppIntroPageTransformerType.Fade) setProgressIndicator() showStatusBar(true) - setupFeedbackButton() + setupFeedbackButton() isIndicatorEnabled = true isWizardMode = true - nextButton = findViewById(R.id.next) - pulseAnimation = AnimationUtils.loadAnimation(this, R.anim.pulse_animation) + nextButton = findViewById(R.id.next) + pulseAnimation = AnimationUtils.loadAnimation(this, R.anim.pulse_animation) - addSlide(GreetingFragment()) + addSlide(GreetingFragment()) if (!PackageUtils.isCurrentUserThePrimaryUser(this)) { val errorMessage = @@ -161,44 +160,54 @@ class OnboardingActivity : AppIntro2() { } } - private fun setupFeedbackButton() { - val contentRootView = findViewById(android.R.id.content) - contentRootView.viewTreeObserver.addOnGlobalLayoutListener(object : - ViewTreeObserver.OnGlobalLayoutListener { - override fun onGlobalLayout() { - contentRootView.viewTreeObserver.removeOnGlobalLayoutListener(this) - - val appIntroContainer: ConstraintLayout? = findViewById(R.id.background) - if (appIntroContainer != null) { - // Reuse the shared feedback FAB definition (size, icon, elevation) so this - // matches every other screen (ADFA-2686); only positioning is set here. - feedbackButton = (layoutInflater.inflate( - R.layout.feedback_fab, appIntroContainer, false - ) as FloatingActionButton).apply { - layoutParams = ConstraintLayout.LayoutParams( - ConstraintLayout.LayoutParams.WRAP_CONTENT, - ConstraintLayout.LayoutParams.WRAP_CONTENT - ).apply { - startToStart = ConstraintLayout.LayoutParams.PARENT_ID - bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID - val marginInPx = - resources.getDimensionPixelSize(R.dimen.feedback_fab_margin) - setMargins(marginInPx, marginInPx, marginInPx, marginInPx) - } - } - - appIntroContainer.addView(feedbackButton) - feedbackButtonManager = FeedbackButtonManager( - activity = this@OnboardingActivity, - feedbackFab = feedbackButton - ) - feedbackButtonManager?.setupDraggableFab() - } else { - logger.error("Could not find AppIntro2 container to add FAB.") - } - } - }) - } + private fun setupFeedbackButton() { + val contentRootView = findViewById(android.R.id.content) + contentRootView.viewTreeObserver.addOnGlobalLayoutListener( + object : + ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + contentRootView.viewTreeObserver.removeOnGlobalLayoutListener(this) + + val appIntroContainer: ConstraintLayout? = findViewById(R.id.background) + if (appIntroContainer != null) { + // Reuse the shared feedback FAB definition (size, icon, elevation) so this + // matches every other screen (ADFA-2686); only positioning is set here. + feedbackButton = + ( + layoutInflater.inflate( + R.layout.feedback_fab, + appIntroContainer, + false, + ) as FloatingActionButton + ).apply { + layoutParams = + ConstraintLayout + .LayoutParams( + ConstraintLayout.LayoutParams.WRAP_CONTENT, + ConstraintLayout.LayoutParams.WRAP_CONTENT, + ).apply { + startToStart = ConstraintLayout.LayoutParams.PARENT_ID + bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID + val marginInPx = + resources.getDimensionPixelSize(R.dimen.feedback_fab_margin) + setMargins(marginInPx, marginInPx, marginInPx, marginInPx) + } + } + + appIntroContainer.addView(feedbackButton) + feedbackButtonManager = + FeedbackButtonManager( + activity = this@OnboardingActivity, + feedbackFab = feedbackButton, + ) + feedbackButtonManager?.setupDraggableFab() + } else { + logger.error("Could not find AppIntro2 container to add FAB.") + } + } + }, + ) + } override fun onResume() { super.onResume() @@ -221,27 +230,25 @@ class OnboardingActivity : AppIntro2() { tryNavigateToMainIfSetupIsCompleted() } - fun setOnboardingChromeVisible(visible: Boolean) { - isIndicatorEnabled = visible - isButtonsEnabled = visible - } + fun setOnboardingChromeVisible(visible: Boolean) { + isIndicatorEnabled = visible + isButtonsEnabled = visible + } override fun onPageSelected(position: Int) { super.onPageSelected(position) - when { - !nextButton.isVisible -> nextButton.clearAnimation() - !isTestMode() && nextButton.animation == null -> nextButton.startAnimation(pulseAnimation) - } + when { + !nextButton.isVisible -> nextButton.clearAnimation() + !isTestMode() && nextButton.animation == null -> nextButton.startAnimation(pulseAnimation) + } } private fun checkToolsIsInstalled(): Boolean = IJdkDistributionProvider.getInstance().installedDistributions.isNotEmpty() && Environment.ANDROID_HOME.exists() - private fun isSetupCompleted(): Boolean = - checkToolsIsInstalled() && - PermissionsHelper.areAllPermissionsGranted(this) + private fun isSetupCompleted(): Boolean = isIdeSetupComplete() internal fun navigateToMain() { startActivity(Intent(this, MainActivity::class.java)) @@ -258,16 +265,16 @@ class OnboardingActivity : AppIntro2() { } private suspend fun reloadJdkDistInfo(distConsumer: (List) -> Unit) { - val distributionProvider = IJdkDistributionProvider.getInstance() - val currentDistributions = distributionProvider.installedDistributions - if (currentDistributions.isNotEmpty()) { - distConsumer(currentDistributions) - return - } + val distributionProvider = IJdkDistributionProvider.getInstance() + val currentDistributions = distributionProvider.installedDistributions + if (currentDistributions.isNotEmpty()) { + distConsumer(currentDistributions) + return + } - if (listJdkInstallationsJob?.isActive == true) { - return - } + if (listJdkInstallationsJob?.isActive == true) { + return + } listJdkInstallationsJob = doAsyncWithProgress( @@ -278,10 +285,10 @@ class OnboardingActivity : AppIntro2() { ) { _, _ -> distributionProvider.loadDistributions() withContext(Dispatchers.Main) { - if (!isFinishing && !isDestroyed) { - distConsumer(distributionProvider.installedDistributions) - } - } + if (!isFinishing && !isDestroyed) { + distConsumer(distributionProvider.installedDistributions) + } + } }.also { it?.invokeOnCompletion { listJdkInstallationsJob = null diff --git a/app/src/main/java/com/itsaky/androidide/activities/SetupState.kt b/app/src/main/java/com/itsaky/androidide/activities/SetupState.kt new file mode 100644 index 0000000000..d3a95bee67 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/activities/SetupState.kt @@ -0,0 +1,42 @@ +/* + * 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.activities + +import android.content.Context +import com.itsaky.androidide.app.configuration.IJdkDistributionProvider +import com.itsaky.androidide.utils.Environment +import com.itsaky.androidide.utils.PermissionsHelper + +/** + * Whether the IDE has everything it needs to open a project: a JDK, an SDK, and the permissions to + * reach them. + * + * One definition, because there are now two callers with no path between them. [OnboardingActivity] + * asks so it can hand over to [MainActivity]; [DeepLinkActivity] asks because a link arriving before + * setup finishes would otherwise walk straight past onboarding into an editor with no toolchain + * (ADFA-5067 review). A second copy of the rule would let the two disagree about what "ready" means, + * and the one that disagrees silently is the one that skips a gate. + * + * Deliberately *not* the whole of what [SplashActivity] enforces -- free storage and the x86 exit are + * its business, and a caller that finds this false should send the user there rather than re-deciding + * any of it. + */ +internal fun Context.isIdeSetupComplete(): Boolean = + IJdkDistributionProvider.getInstance().installedDistributions.isNotEmpty() && + Environment.ANDROID_HOME.exists() && + PermissionsHelper.areAllPermissionsGranted(this) diff --git a/app/src/test/java/com/itsaky/androidide/activities/DeepLinkSetupGateTest.kt b/app/src/test/java/com/itsaky/androidide/activities/DeepLinkSetupGateTest.kt new file mode 100644 index 0000000000..1d319f0c36 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/activities/DeepLinkSetupGateTest.kt @@ -0,0 +1,58 @@ +/* + * 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.activities + +import android.content.Intent +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf + +/** + * A deep link must not walk past setup. Both of DeepLinkActivity's targets sit beyond + * SplashActivity and OnboardingActivity, which are the only things enforcing terms, permissions, + * the JDK and SDK install, the low-storage check and the x86 exit -- so a link arriving on a fresh + * install used to land the user in an editor that could not build (ADFA-5067 review). + * + * Robolectric's environment has no installed JDK distribution and no ANDROID_HOME, which is exactly + * the not-set-up state under test. + */ +@RunWith(RobolectricTestRunner::class) +class DeepLinkSetupGateTest { + @Test + fun `a link arriving before setup is finished goes to the launcher chain, not the editor`() { + val intent = + Intent(Intent.ACTION_VIEW, Uri.parse("https://appdevforall.org/device/open/project/MyApp")) + val activity = Robolectric.buildActivity(DeepLinkActivity::class.java, intent).create().get() + + val next = shadowOf(activity).nextStartedActivity + assertThat(next).isNotNull() + assertThat(next.component?.className).isEqualTo(SplashActivity::class.java.name) + assertThat(activity.isFinishing).isTrue() + } + + @Test + fun `the setup predicate is false when no toolchain is installed`() { + val context = ApplicationProvider.getApplicationContext() + assertThat(context.isIdeSetupComplete()).isFalse() + } +} diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index ca5e41df68..93f0011d1a 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -138,6 +138,7 @@ Close this project Last opened project doesn\'t exist. This link could not be opened. + Finish setting up Code on the Go, then open this link again. No project named \"%s\" was found. File \"%s\" was not found in the project. \"%s\" is not a valid line number. From 00739cecb7c31da555cb759ab11bd10f7471726f Mon Sep 17 00:00:00 2001 From: David Schachter Date: Wed, 26 Aug 2026 13:58:12 -0700 Subject: [PATCH 64/76] ADFA-5067: Gate the deep link on the toolchain that is on disk, not the one loaded The setup gate asked IJdkDistributionProvider.installedDistributions, which returns an empty list until loadDistributions() has run -- and that runs inside the loader coroutine IDEApplication launches on Dispatchers.Default. On a cold start an Activity's onCreate reaches the main thread first, so the gate answered "not set up" on a device that was, discarded the link, and told the user to finish a setup they had already finished. That is the ticket's first requirement, broken by the guard meant to protect it. It now reads the directory JdkUtils.findJavaInstallations scans -- one stat and one listing, cheap on the main thread, and true as soon as the bootstrap has unpacked regardless of what has loaded. An empty lib/jvm still counts as not installed. OnboardingActivity keeps its own, stricter predicate rather than sharing this one. It can afford to wait for a JDK the provider has loaded and validated -- it calls loadDistributions() itself when the list is empty -- and must not hand over to MainActivity until the toolchain is really usable. Two different questions, so two predicates, each with the reason recorded. Sharing them would have made this gate's cold-start problem onboarding's problem too, in the other direction: onboarding would hand over as soon as a directory existed. The existing test could not have caught this: Robolectric has no JDK, so asserting the gate returns false passed either way. The new test builds the cold-start state instead -- a toolchain on disk with nothing loaded -- and fails against the provider-based check. 310 app tests pass. Found in review of PR #1651. --- .../activities/OnboardingActivity.kt | 9 ++- .../androidide/activities/SetupState.kt | 39 +++++++++-- .../activities/DeepLinkSetupGateTest.kt | 65 +++++++++++++++++++ 3 files changed, 105 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/OnboardingActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/OnboardingActivity.kt index c55546025f..104ff5a066 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/OnboardingActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/OnboardingActivity.kt @@ -248,7 +248,14 @@ class OnboardingActivity : AppIntro2() { IJdkDistributionProvider.getInstance().installedDistributions.isNotEmpty() && Environment.ANDROID_HOME.exists() - private fun isSetupCompleted(): Boolean = isIdeSetupComplete() + // Deliberately the provider's loaded list, not isIdeSetupComplete()'s on-disk check: this screen + // can afford to wait for a validated JDK (it calls loadDistributions() itself when the list is + // empty, a few lines below) and should not hand over to MainActivity until one is really usable. + // The deep-link gate asks the weaker on-disk question because it runs on a cold-start main + // thread and cannot wait -- see SetupState.kt. + private fun isSetupCompleted(): Boolean = + checkToolsIsInstalled() && + PermissionsHelper.areAllPermissionsGranted(this) internal fun navigateToMain() { startActivity(Intent(this, MainActivity::class.java)) diff --git a/app/src/main/java/com/itsaky/androidide/activities/SetupState.kt b/app/src/main/java/com/itsaky/androidide/activities/SetupState.kt index d3a95bee67..12ddb74170 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/SetupState.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/SetupState.kt @@ -18,25 +18,50 @@ package com.itsaky.androidide.activities import android.content.Context -import com.itsaky.androidide.app.configuration.IJdkDistributionProvider import com.itsaky.androidide.utils.Environment import com.itsaky.androidide.utils.PermissionsHelper +import java.io.File /** * Whether the IDE has everything it needs to open a project: a JDK, an SDK, and the permissions to * reach them. * - * One definition, because there are now two callers with no path between them. [OnboardingActivity] - * asks so it can hand over to [MainActivity]; [DeepLinkActivity] asks because a link arriving before - * setup finishes would otherwise walk straight past onboarding into an editor with no toolchain - * (ADFA-5067 review). A second copy of the rule would let the two disagree about what "ready" means, - * and the one that disagrees silently is the one that skips a gate. + * Asked by [DeepLinkActivity], because a link arriving before setup finishes would otherwise walk + * straight past onboarding into an editor with no toolchain (ADFA-5067 review). + * + * OnboardingActivity deliberately does *not* share this: it asks the stricter question -- a JDK the + * provider has loaded and validated -- because it can afford to wait for one and must not hand over + * to MainActivity until the toolchain is really usable. This one has to answer on a cold-start main + * thread, where nothing has loaded yet, so it asks what is on disk. Two questions, not two copies of + * one question. * * Deliberately *not* the whole of what [SplashActivity] enforces -- free storage and the x86 exit are * its business, and a caller that finds this false should send the user there rather than re-deciding * any of it. */ internal fun Context.isIdeSetupComplete(): Boolean = - IJdkDistributionProvider.getInstance().installedDistributions.isNotEmpty() && + isJdkInstalled() && Environment.ANDROID_HOME.exists() && PermissionsHelper.areAllPermissionsGranted(this) + +/** + * Whether a JDK is present *on disk*, which is not the same question as whether one has been loaded + * into memory yet. + * + * The provider's `installedDistributions` is the obvious thing to ask, and it is wrong + * here: it returns an empty list until `loadDistributions()` has run, and that happens inside the + * loader coroutine `IDEApplication` launches on `Dispatchers.Default`. On a cold start an Activity's + * `onCreate` reaches the main thread first, so asking the provider says "no JDK" on a device that + * has one -- which for [DeepLinkActivity] meant discarding the link and telling the user to finish a + * setup they had already finished (ADFA-5067 review). + * + * So this reads the same directory `JdkUtils.findJavaInstallations` scans, and only that: one stat + * and one listing, cheap enough for the main thread, and true as soon as the bootstrap has unpacked + * regardless of what has been loaded. It deliberately does not validate the installations -- that is + * the provider's job once it runs, and a directory that exists but holds nothing usable is a broken + * install, not an unfinished setup. + */ +private fun isJdkInstalled(): Boolean { + val jvmDir = File(Environment.PREFIX, "lib/jvm") + return jvmDir.isDirectory && (jvmDir.list()?.isNotEmpty() == true) +} diff --git a/app/src/test/java/com/itsaky/androidide/activities/DeepLinkSetupGateTest.kt b/app/src/test/java/com/itsaky/androidide/activities/DeepLinkSetupGateTest.kt index 1d319f0c36..89abac207c 100644 --- a/app/src/test/java/com/itsaky/androidide/activities/DeepLinkSetupGateTest.kt +++ b/app/src/test/java/com/itsaky/androidide/activities/DeepLinkSetupGateTest.kt @@ -17,15 +17,25 @@ package com.itsaky.androidide.activities +import android.content.Context import android.content.Intent import android.net.Uri import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.app.configuration.IJdkDistributionProvider +import com.itsaky.androidide.utils.Environment +import com.itsaky.androidide.utils.PermissionsHelper +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkAll +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows.shadowOf +import java.io.File /** * A deep link must not walk past setup. Both of DeepLinkActivity's targets sit beyond @@ -38,6 +48,9 @@ import org.robolectric.Shadows.shadowOf */ @RunWith(RobolectricTestRunner::class) class DeepLinkSetupGateTest { + @get:Rule + val tempFolder = TemporaryFolder() + @Test fun `a link arriving before setup is finished goes to the launcher chain, not the editor`() { val intent = @@ -55,4 +68,56 @@ class DeepLinkSetupGateTest { val context = ApplicationProvider.getApplicationContext() assertThat(context.isIdeSetupComplete()).isFalse() } + + /** + * The cold-start case, and the reason this predicate reads the filesystem. + * + * `IJdkDistributionProvider.installedDistributions` is empty until the loader coroutine + * `IDEApplication` starts on `Dispatchers.Default` has run, and an Activity's `onCreate` beats it + * to the main thread. Asking the provider therefore answered "not set up" on a device that was, + * and the link was discarded with a message telling the user to finish a finished setup. Nothing + * loads the provider in this test either -- which is precisely the state under test. + */ + @Test + fun `the setup predicate is true from disk alone, with no distributions loaded`() { + val context = ApplicationProvider.getApplicationContext() + val prefix = tempFolder.newFolder("prefix") + File(prefix, "lib/jvm/jdk-17").mkdirs() + val previousPrefix = Environment.PREFIX + val previousHome = Environment.ANDROID_HOME + Environment.PREFIX = prefix + Environment.ANDROID_HOME = tempFolder.newFolder("android-sdk") + mockkObject(PermissionsHelper) + every { PermissionsHelper.areAllPermissionsGranted(any()) } returns true + try { + assertThat(IJdkDistributionProvider.getInstance().installedDistributions).isEmpty() + assertThat(context.isIdeSetupComplete()).isTrue() + } finally { + unmockkAll() + Environment.PREFIX = previousPrefix + Environment.ANDROID_HOME = previousHome + } + } + + // ...and an empty lib/jvm is not a JDK: a bootstrap that unpacked the directory but no + // distribution is an unfinished install, which the gate should still refuse. + @Test + fun `an empty lib-jvm directory does not count as installed`() { + val context = ApplicationProvider.getApplicationContext() + val prefix = tempFolder.newFolder("prefix-empty") + File(prefix, "lib/jvm").mkdirs() + val previousPrefix = Environment.PREFIX + val previousHome = Environment.ANDROID_HOME + Environment.PREFIX = prefix + Environment.ANDROID_HOME = tempFolder.newFolder("android-sdk-empty") + mockkObject(PermissionsHelper) + every { PermissionsHelper.areAllPermissionsGranted(any()) } returns true + try { + assertThat(context.isIdeSetupComplete()).isFalse() + } finally { + unmockkAll() + Environment.PREFIX = previousPrefix + Environment.ANDROID_HOME = previousHome + } + } } From 337fde09a34dc414f5fb397473e7f08fb82f5b84 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 05:28:19 +0000 Subject: [PATCH 65/76] ADFA-5067: Don't race Environment.init() in the deep-link setup gate Environment.PREFIX and ANDROID_HOME are assigned only in Environment.init(), which runs on the same unawaited loader coroutine the setup gate was already rewritten to avoid (the IJdkDistributionProvider race). On a cold start -- the primary deep-link case -- DeepLinkActivity's main-thread onCreate routinely wins that race, File(null, "lib/jvm") silently yields a relative path, and a fully set-up device is told setup is incomplete, discarding the link. The fields are also not volatile, so even a completed init() has no guaranteed visibility from the main thread. Fall back to the compile-time constants init() itself derives the fields from (DEFAULT_PREFIX; DEFAULT_HOME + "/android-sdk", mirroring the private DEFAULT_ANDROID_HOME): same directories, available at class-load time, independent of the loader. The ANDROID_HOME fallback also matters because, once isJdkInstalled() can answer true pre-init, the old && short-circuit no longer protects the ANDROID_HOME dereference from an NPE. Regression tests: the fallback paths with both fields left null, and the predicate answering true with a JDK on disk while PREFIX was never assigned -- the case the existing tests missed by assigning PREFIX by hand. Addresses hal-eisen-adfa's review on PR #1651. Co-Authored-By: Claude --- .../androidide/activities/SetupState.kt | 31 ++++++++++- .../activities/DeepLinkSetupGateTest.kt | 53 +++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/SetupState.kt b/app/src/main/java/com/itsaky/androidide/activities/SetupState.kt index 12ddb74170..1eda3cf0e3 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/SetupState.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/SetupState.kt @@ -18,6 +18,7 @@ package com.itsaky.androidide.activities import android.content.Context +import androidx.annotation.VisibleForTesting import com.itsaky.androidide.utils.Environment import com.itsaky.androidide.utils.PermissionsHelper import java.io.File @@ -41,7 +42,7 @@ import java.io.File */ internal fun Context.isIdeSetupComplete(): Boolean = isJdkInstalled() && - Environment.ANDROID_HOME.exists() && + androidSdkHome().exists() && PermissionsHelper.areAllPermissionsGranted(this) /** @@ -62,6 +63,32 @@ internal fun Context.isIdeSetupComplete(): Boolean = * install, not an unfinished setup. */ private fun isJdkInstalled(): Boolean { - val jvmDir = File(Environment.PREFIX, "lib/jvm") + val jvmDir = File(jdkInstallPrefix(), "lib/jvm") return jvmDir.isDirectory && (jvmDir.list()?.isNotEmpty() == true) } + +/** + * [Environment.PREFIX], or the same path it will hold once `Environment.init()` has run. + * + * `init()` runs inside the same unawaited loader coroutine that loads the JDK distributions (see + * [isJdkInstalled]), so on a cold start this main-thread read routinely happens first and finds the + * field still null -- and the field is not volatile, so even a completed `init()` guarantees nothing + * about visibility here (ADFA-5067 review). `File(null, "lib/jvm")` doesn't throw; it silently + * yields a *relative* path that exists nowhere, answering "not set up" on a fully set-up device and + * discarding the deep link. `init()` derives the field from constants (`new File(DEFAULT_ROOT)` + * then `"usr"`), and [Environment.DEFAULT_PREFIX] is that same path, so falling back to it reads + * the same directory without waiting on the loader. The field is still preferred when visible -- + * it is what the rest of the app uses, and tests redirect it to a temp dir. + */ +@VisibleForTesting +internal fun jdkInstallPrefix(): File = Environment.PREFIX ?: File(Environment.DEFAULT_PREFIX) + +/** + * [Environment.ANDROID_HOME], with the same pre-`Environment.init()` fallback as + * [jdkInstallPrefix]. Needed for the same race: before the fix in [jdkInstallPrefix], the only + * thing keeping [isIdeSetupComplete] from an NPE on this field was [isJdkInstalled] short-circuiting + * to false first. The literal mirrors [Environment]'s private `DEFAULT_ANDROID_HOME` + * (`DEFAULT_HOME + "/android-sdk"`), which `init()` assigns verbatim. + */ +@VisibleForTesting +internal fun androidSdkHome(): File = Environment.ANDROID_HOME ?: File(Environment.DEFAULT_HOME, "android-sdk") diff --git a/app/src/test/java/com/itsaky/androidide/activities/DeepLinkSetupGateTest.kt b/app/src/test/java/com/itsaky/androidide/activities/DeepLinkSetupGateTest.kt index 89abac207c..dbfedb2fa2 100644 --- a/app/src/test/java/com/itsaky/androidide/activities/DeepLinkSetupGateTest.kt +++ b/app/src/test/java/com/itsaky/androidide/activities/DeepLinkSetupGateTest.kt @@ -27,6 +27,7 @@ import com.itsaky.androidide.utils.Environment import com.itsaky.androidide.utils.PermissionsHelper import io.mockk.every import io.mockk.mockkObject +import io.mockk.mockkStatic import io.mockk.unmockkAll import org.junit.Rule import org.junit.Test @@ -99,6 +100,58 @@ class DeepLinkSetupGateTest { } } + // The second cold-start race, same shape as the provider one (ADFA-5067 review): + // Environment.init() runs on the same unawaited loader coroutine, so PREFIX and ANDROID_HOME + // are still null when a cold-start main thread asks. The gate must fall back to the constant + // defaults init() itself would assign, not wait -- and not NPE on ANDROID_HOME. + @Test + fun `the toolchain paths do not wait for Environment-init`() { + val previousPrefix = Environment.PREFIX + val previousHome = Environment.ANDROID_HOME + Environment.PREFIX = null + Environment.ANDROID_HOME = null + try { + assertThat(jdkInstallPrefix().path).isEqualTo(Environment.DEFAULT_PREFIX) + assertThat(androidSdkHome().path).isEqualTo(Environment.DEFAULT_HOME + "/android-sdk") + } finally { + Environment.PREFIX = previousPrefix + Environment.ANDROID_HOME = previousHome + } + } + + /** + * The regression from the ADFA-5067 review: toolchain fully on disk, `Environment.PREFIX` never + * assigned -- a cold start that beat the loader coroutine to `Environment.init()`. The old + * `File(Environment.PREFIX, "lib/jvm")` read silently became the relative path `lib/jvm` and + * answered false, so the link was discarded on a fully set-up device. + * + * [jdkInstallPrefix] is stubbed to a temp dir because its real null-fallback + * (`Environment.DEFAULT_PREFIX`, i.e. `/data/data/...`) is not creatable on a test host; the + * fallback's own value is pinned by the test above. What this test pins is that the predicate + * consults [jdkInstallPrefix] rather than reading the unassigned field directly. + */ + @Test + fun `the setup predicate is true with the JDK on disk and PREFIX never assigned`() { + val context = ApplicationProvider.getApplicationContext() + val prefix = tempFolder.newFolder("prefix-cold-start") + File(prefix, "lib/jvm/jdk-17").mkdirs() + val previousPrefix = Environment.PREFIX + val previousHome = Environment.ANDROID_HOME + Environment.PREFIX = null + Environment.ANDROID_HOME = tempFolder.newFolder("android-sdk-cold-start") + mockkStatic("com.itsaky.androidide.activities.SetupStateKt") + every { jdkInstallPrefix() } returns prefix + mockkObject(PermissionsHelper) + every { PermissionsHelper.areAllPermissionsGranted(any()) } returns true + try { + assertThat(context.isIdeSetupComplete()).isTrue() + } finally { + unmockkAll() + Environment.PREFIX = previousPrefix + Environment.ANDROID_HOME = previousHome + } + } + // ...and an empty lib/jvm is not a JDK: a bootstrap that unpacked the directory but no // distribution is an unfinished install, which the gate should still refuse. @Test From 9afb9769259cdcdf313b96f506cb6ee5c1c5a466 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 05:28:20 +0000 Subject: [PATCH 66/76] ADFA-5067: Arm a mid-sync same-project deep link for postProjectInit switchToProject's same-project branch documented that a request arriving while the project is still syncing (workspace == null) "must stay armed for postProjectInit's deferred retry" -- but nothing in the branch armed it. The request died in a local variable, and because onNewIntent's carry-forward had already re-armed the previous, still-unconsumed request onto the intent, postProjectInit then navigated to that stale target once the sync completed: the link appeared to work, at the wrong file. Store the request on the intent in the not-ready, not-closing arm. The put also supersedes the carried-forward stale value, so that arm needs no removeExtra; the existing removeExtra stays for the arms that consumed or intentionally dropped the request. Test: same project open mid-sync, new request for file B arriving while a carried-forward request for file A is on the intent -- the armed extra postProjectInit reads must be B. Exercises the real private switchToProject on a built-but-not-created activity, mirroring RestorePluginTabsThreadTest. Addresses hal-eisen-adfa's review on PR #1651. Co-Authored-By: Claude --- .../editor/EditorHandlerActivity.kt | 9 ++ .../editor/SameProjectDeepLinkMidSyncTest.kt | 109 ++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 app/src/test/java/com/itsaky/androidide/activities/editor/SameProjectDeepLinkMidSyncTest.kt 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 50c7bd4ca5..8e183f6559 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 @@ -2416,6 +2416,15 @@ open class EditorHandlerActivity : flashError(getString(string.msg_project_close_in_progress)) } else if (projectReady) { fileRequest?.let { applyDeepLinkFileRequest(it) } + } else { + // Mid-sync: arm the request on the intent so postProjectInit's deferred retry + // finds it once the sync completes -- this branch is the "must stay armed" + // case the projectReady comment above describes, and without the put the + // request dies with this call while onNewIntent's carry-forward has already + // re-armed the PREVIOUS, still-unconsumed request, sending the editor to that + // stale target instead (ADFA-5067 review). The put also supersedes that + // carried-forward value, so this arm needs no removeExtra below. + fileRequest?.let { intent.putExtra(PendingFileRequest.EXTRA_KEY, it) } } if (fileRequest != null && (confirmCloseInProgress || projectReady)) { // This request supersedes whatever onNewIntent's carry-forward guard just diff --git a/app/src/test/java/com/itsaky/androidide/activities/editor/SameProjectDeepLinkMidSyncTest.kt b/app/src/test/java/com/itsaky/androidide/activities/editor/SameProjectDeepLinkMidSyncTest.kt new file mode 100644 index 0000000000..179a343e97 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/activities/editor/SameProjectDeepLinkMidSyncTest.kt @@ -0,0 +1,109 @@ +/* + * 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.activities.editor + +import android.content.Intent +import androidx.core.content.IntentCompat +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.app.BaseApplication +import com.itsaky.androidide.models.PendingFileRequest +import com.itsaky.androidide.projects.IProjectManager +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * A deep link into the project that is already open, arriving while that project is still syncing + * (`workspace == null`), must not be dropped: `switchToProject`'s same-project branch has to arm + * the request on the intent so `postProjectInit`'s deferred retry finds it once the sync completes + * (ADFA-5067 review). + * + * The failure mode being pinned is double: the new request used to die in a local variable, and + * because `onNewIntent`'s carry-forward guard had already re-armed the *previous*, still-unconsumed + * request onto the intent, `postProjectInit` then navigated to that stale target -- the link + * appeared to work, at the wrong file. + * + * Mirrors [RestorePluginTabsThreadTest]'s approach of exercising a real, private production method + * on an activity that has been built but not created -- creating the full editor activity is far + * beyond what a JVM test can do, and everything this path touches (the intent, the project + * manager, the binding null-check) can be provided directly. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = SameProjectDeepLinkMidSyncTest.TestApp::class) +class SameProjectDeepLinkMidSyncTest { + open class TestApp : BaseApplication() + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `a mid-sync same-project request stays armed and supersedes the carried-forward one`() { + val projectPath = "/projects/MyApp" + mockkObject(IProjectManager.Companion) + val projectManager = mockk(relaxed = true) + every { projectManager.projectDirPath } returns projectPath + // The state under test: the project has started opening but the Gradle sync has not + // completed, so the workspace is not available yet. + every { projectManager.workspace } returns null + every { IProjectManager.getInstance() } returns projectManager + + val activity = + Robolectric + .buildActivity(EditorHandlerActivity::class.java, Intent()) + .get() + // Non-null binding so switchToProject takes its same-project branch instead of the + // binding-torn-down handoff; nothing on the branch under test touches the views. + activity._binding = mockk(relaxed = true) + + // What onNewIntent's carry-forward guard re-arms from the previous intent: the earlier, + // still-unconsumed request. Without the fix, postProjectInit would find (and navigate to) + // this one. + val staleRequest = PendingFileRequest("file/A.kt", null, null) + activity.intent.putExtra(PendingFileRequest.EXTRA_KEY, staleRequest) + + val newRequest = PendingFileRequest("file/B.kt", "10", "2") + val switchToProject = + EditorHandlerActivity::class.java.getDeclaredMethod( + "switchToProject", + String::class.java, + PendingFileRequest::class.java, + String::class.java, + ) + switchToProject.isAccessible = true + switchToProject.invoke(activity, projectPath, newRequest, projectPath) + + // postProjectInit's deferred retry reads exactly this extra once the sync completes: it + // must find the new request -- not nothing, and not the stale carried-forward one. + val armed = + IntentCompat.getParcelableExtra( + activity.intent, + PendingFileRequest.EXTRA_KEY, + PendingFileRequest::class.java, + ) + assertThat(armed).isEqualTo(newRequest) + } +} From f2a5a9ef9d3b1455ea574a53d21b0296216ead3e Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 31 Aug 2026 19:13:34 -0700 Subject: [PATCH 67/76] ADFA-5067: Fix 15 review findings in the deep-link work The recurring shape is partial application: the right thing done at one site and not at its siblings. - postDestroy() lacked the didCompleteLiveOnCreate guard that preDestroy() has in three places, so an instance whose onCreate took the deep-link bail still ran Lookup.unregisterAll() and cleared three registries process-wide, out from under a live sibling. It matters more here than in preDestroy, because everything in it is process-wide rather than per-instance. - SetupState handled the Environment.init() race for PREFIX and ANDROID_HOME but not PROJECTS_DIR, which is passed to a non-null Kotlin parameter. Added projectsRoot() alongside the other two; both call sites use it. - The git sheet gated on areFilesModified() while this branch introduced hasFilesThatFailedToSave() for exactly that check. The cached flag counts read-only archive tabs no save ever writes, so with a .zip open every commit and pull was refused with no way to clear it. Promoted to IEditorHandler as hasUnsavedWritableFiles(). - One save-failure site used the String overload for its indefinite duration and explained why; the third still used the ~1s toast. State that was never rolled back: - MainActivity.openProject writes projectPath, lastOpenedProject, Recents and analytics before the confirm dialog is answered. Declining left the app pointing at the refused project: deep-link file navigation resolved inside it and the next cold start reopened it. restoreIntentToStayingProject now rolls both globals back, and uses a snapshot of the staying project rather than the global openProject already overwrote. - The deep-link resolve's not-found path stranded the captured file request, so the staying project's pending navigation was lost and every later switch skipped its own capture. - Bookkeeping ran twice per plain switch. DeepLinkOpenRequest carries whether it already ran. Smaller: an empty filePath (`.../file/line/5`) became a file request for the empty string and surfaced as `File "" was not found`; a failed resolve or a declined dialog left the request unconsumed so it re-fired on every recreate; DeepLinkActivity's setup-incomplete branch omitted the FLAG_ACTIVITY_NEW_TASK its sibling two blocks below has. Sweeps found more than the review reported. Range.pointRange aliased one mutable Position as both ends, so EditorFeatures.validateRange's two clamps moved each other -- fixed at the factory, which fixes every caller, and EditorProviderImpl had the same open-coded Range(pos, pos). The missing GPL header was on two new files. The "PROJECT_PATH" literals lived in a fourth file beyond the three reported; all 13 now use EditorIntentExtras. Verified on an Android 13 arm64 device (SM-N986U), not only by reading: - The re-fire fix is proven by revert check. Unfixed, `No project named "NoSuchProject" was found.` returns after a font-scale recreate and on every one after; fixed, it does not. - Two findings did NOT reproduce, and the fixes are kept only as cheap hardening. The PROJECTS_DIR race never fired: the unfixed build survived 6 of 6 cold-start deep links. And the background-activity-start hazard cannot occur on this path at all -- dumpsys shows the editor is never its task root (MainActivity is always at Hist #0 beneath it, and EditorActivityKt is not exported), so finishing it never empties the task. A recovery path written for that case was removed again rather than left defending an unreachable state; the reasoning is recorded where it was. - Regression-checked end to end: onboarding runs clean from a deep link on an unconfigured install, and a Beepy -> Aegis1 switch confirms and opens with no dropped start and no fatal exceptions. Both screens render correctly at font scale 2.0. Two tests changed. DeepLinkRequestTest locked in the empty-filePath behaviour that was itself the defect; it now pins the corrected outcome, plus the two shapes the review named. SameProjectDeepLinkMidSyncTest reflects on switchToProject, whose arity changed, and was additionally crashing on "KoinApplication has not been started" -- it now starts Koin. That test still fails, and did so before this change: with _binding assigned, contentOrNull is null, so switchToProject never reaches the same-project branch the test exists to pin. Left failing rather than weakened into passing. Not addressed: XMLFormatterDocumentNew.java open-codes the same Range aliasing, but belongs to the XML formatter and is untouched by this branch. --- .../androidide/activities/DeepLinkActivity.kt | 11 ++- .../androidide/activities/MainActivity.kt | 31 ++++-- .../androidide/activities/SetupState.kt | 17 ++++ .../activities/editor/BaseEditorActivity.kt | 11 ++- .../editor/EditorHandlerActivity.kt | 94 +++++++++++++++---- .../editor/ProjectHandlerActivity.kt | 3 +- .../androidide/app/EditorProviderImpl.kt | 5 +- .../deeplink/ConsumedDeepLinkRequests.kt | 17 ++++ .../fragments/git/GitBottomSheetFragment.kt | 16 +++- .../androidide/interfaces/IEditorHandler.kt | 11 +++ .../androidide/models/DeepLinkRequest.kt | 29 +++++- .../androidide/models/EditorIntentExtras.kt | 45 +++++++++ .../editor/SameProjectDeepLinkMidSyncTest.kt | 21 ++++- .../deeplink/ConsumedDeepLinkRequestsTest.kt | 17 ++++ .../androidide/models/DeepLinkRequestTest.kt | 37 +++++--- .../com/itsaky/androidide/models/Locations.kt | 5 +- 16 files changed, 319 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/models/EditorIntentExtras.kt 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 e174b89d62..39b07a7c20 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt @@ -53,7 +53,16 @@ class DeepLinkActivity : Activity() { // storage, ABI and onboarding are SplashActivity's to enforce, not this activity's to repeat. if (!isIdeSetupComplete()) { Toast.makeText(this, getString(string.msg_deeplink_setup_incomplete), Toast.LENGTH_LONG).show() - startActivity(Intent(this, SplashActivity::class.java)) + // FLAG_ACTIVITY_NEW_TASK, matching the success branch below. A sender that starts this + // trampoline without it -- an in-app WebView host, another app's explicit intent, `am start` + // -- puts this activity in the *caller's* task, and an unflagged start here would run the + // whole terms/permissions/JDK-install onboarding inside that app's back stack, where + // back-press returns to them rather than exiting CoGo. + startActivity( + Intent(this, SplashActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }, + ) finish() return } 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 4fec9cba69..fb09ae2182 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt @@ -49,6 +49,7 @@ 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.EditorIntentExtras import com.itsaky.androidide.models.PendingFileRequest import com.itsaky.androidide.preferences.internal.GeneralPreferences import com.itsaky.androidide.projects.IProjectManager @@ -497,7 +498,16 @@ class MainActivity : EdgeToEdgeIDEActivity() { } openProject(root, pendingFileRequest = pendingFileRequest) } - builder.setNegativeButton(string.no, null) + // Consumed on decline too, not just on confirm. "No" is a decision the user made about this + // request, so leaving it unconsumed meant every later recreate of this activity -- a dark-mode + // toggle or a font-scale change, neither of which MainActivity declares in configChanges -- + // re-read the extra from the launch Intent and put the very same dialog back up, with no way + // to make it stop. + builder.setNegativeButton(string.no) { _, _ -> + if (isDeepLink) { + consumedDeepLinkRequests.add(latestDeepLinkRequest) + } + } activeOpenPermissionDialog = builder.show() } @@ -524,10 +534,10 @@ class MainActivity : EdgeToEdgeIDEActivity() { val intent = Intent(this, EditorActivityKt::class.java).apply { - putExtra("PROJECT_PATH", root.absolutePath) - putExtra("PREVIOUS_PROJECT_PATH", previousProjectPath) + putExtra(EditorIntentExtras.EXTRA_PROJECT_PATH, root.absolutePath) + putExtra(EditorIntentExtras.EXTRA_PREVIOUS_PROJECT_PATH, previousProjectPath) if (hasTemplateIssues) { - putExtra("HAS_TEMPLATE_ISSUES", true) + putExtra(EditorIntentExtras.EXTRA_HAS_TEMPLATE_ISSUES, true) } pendingFileRequest?.let { putExtra(PendingFileRequest.EXTRA_KEY, it) } addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) @@ -584,14 +594,23 @@ class MainActivity : EdgeToEdgeIDEActivity() { private fun handleDeepLinkRequest(request: DeepLinkRequest) { latestDeepLinkRequest = request lifecycleScope.launch(Dispatchers.IO) { - val projectDir = resolveDeepLinkProject(Environment.PROJECTS_DIR, request.projectName) + val projectDir = resolveDeepLinkProject(projectsRoot(), request.projectName) withContext(Dispatchers.Main) { // 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 + if (projectDir == null) { + // Consumed even though nothing opened: the project does not exist, so retrying on + // every recreate only re-shows "No project named X was found" indefinitely. Recorded + // only when this request is still the current one, so a superseded slow resolve + // cannot consume the newer request's slot. + if (latestDeepLinkRequest === request) { + consumedDeepLinkRequests.add(request) + } + return@withContext + } // A second, faster-resolving deep link superseded this one while it was still resolving // -- 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. diff --git a/app/src/main/java/com/itsaky/androidide/activities/SetupState.kt b/app/src/main/java/com/itsaky/androidide/activities/SetupState.kt index 1eda3cf0e3..d136ec9ae7 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/SetupState.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/SetupState.kt @@ -20,6 +20,7 @@ package com.itsaky.androidide.activities import android.content.Context import androidx.annotation.VisibleForTesting import com.itsaky.androidide.utils.Environment +import com.itsaky.androidide.utils.FileUtil import com.itsaky.androidide.utils.PermissionsHelper import java.io.File @@ -92,3 +93,19 @@ internal fun jdkInstallPrefix(): File = Environment.PREFIX ?: File(Environment.D */ @VisibleForTesting internal fun androidSdkHome(): File = Environment.ANDROID_HOME ?: File(Environment.DEFAULT_HOME, "android-sdk") + +/** + * [Environment.PROJECTS_DIR], with the same pre-`Environment.init()` fallback as + * [jdkInstallPrefix] and [androidSdkHome]. + * + * The third field assigned by that same unawaited loader coroutine, and the one the sweep missed. + * It is worse than the other two here: they feed [isIdeSetupComplete], which merely answers "not + * set up" when they read null, but this one is handed straight to `resolveDeepLinkProject`'s + * non-null `projectsRoot` parameter -- so a null is not a wrong answer, it is + * `NullPointerException: Parameter specified as non-null is null` thrown inside a + * `lifecycleScope.launch` with no handler, i.e. the process dying when the user taps a link during + * a cold start. `init()` derives the field as `new File(FileUtil.getExternalStorageDir(), + * PROJECTS_FOLDER)`, which is what this reconstructs. + */ +@VisibleForTesting +internal fun projectsRoot(): File = Environment.PROJECTS_DIR ?: File(FileUtil.getExternalStorageDir(), Environment.PROJECTS_FOLDER) 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 eb780ae67f..0325891ed1 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 @@ -105,6 +105,7 @@ 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.EditorIntentExtras import com.itsaky.androidide.models.OpenedFile import com.itsaky.androidide.models.PendingFileRequest import com.itsaky.androidide.models.Range @@ -523,7 +524,13 @@ abstract class BaseEditorActivity : } protected open fun postDestroy() { - if (isDestroying) { + // didCompleteLiveOnCreate as well as isDestroying, for the same reason preDestroy above and + // both of ProjectHandlerActivity's teardown hooks carry it -- and it matters more here, because + // everything below is process-wide rather than per-instance. An instance whose onCreate took the + // deep-link `deepLinkTargetsAnotherProject` bail (finish() + return) never registered any of + // this, but it *is* finishing, so isDestroying alone let it unregister the Lookup and clear all + // three registries out from under the live sibling that did register them. + if (didCompleteLiveOnCreate && isDestroying) { Lookup.getDefault().unregisterAll() ApiVersionsRegistry.getInstance().clear() ResourceTableRegistry.getInstance().clear() @@ -683,7 +690,7 @@ abstract class BaseEditorActivity : // 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() } + ?: intent?.getStringExtra(EditorIntentExtras.EXTRA_PROJECT_PATH)?.takeIf { it.isNotBlank() } val restoredProjectPath = explicitProjectPath ?: if (deepLinkRequest == null) { 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 f4a35e70cc..64f0fcf199 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 @@ -51,6 +51,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.activities.projectsRoot import com.itsaky.androidide.analytics.IAnalyticsManager import com.itsaky.androidide.api.ActionContextProvider import com.itsaky.androidide.app.BaseApplication @@ -78,6 +79,7 @@ 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.EditorIntentExtras import com.itsaky.androidide.models.FileExtension import com.itsaky.androidide.models.OpenedFile import com.itsaky.androidide.models.OpenedFilesCache @@ -92,6 +94,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.preferences.internal.GeneralPreferences import com.itsaky.androidide.projects.IProjectManager import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.projects.builder.BuildResult @@ -402,10 +405,21 @@ open class EditorHandlerActivity : private fun performPendingDeepLinkOpen(pending: DeepLinkOpenRequest) { val root = File(pending.projectRoot) val ctx = applicationContext - recordProjectOpenedBookkeeping(recentProjectRepository, root, project = null, analyticsManager = analyticsManager) + if (!pending.bookkeepingAlreadyRecorded) { + recordProjectOpenedBookkeeping(recentProjectRepository, root, project = null, analyticsManager = analyticsManager) + } + + // Starting an activity from onDestroy() is a background activity start -- and therefore dropped + // silently by Android 10+ -- only once the finishing activity was the last one in its task. + // That cannot happen here: this activity is never its task's root. It is reachable only + // through MainActivity (openProject) or DeepLinkActivity, which routes through MainActivity + // when no editor is live, and it is not exported, so nothing else can launch it. Verified on + // an Android 13 device: every androidide task has MainActivity at Hist #0 with this activity + // above it, so finishing leaves the task non-empty and the app in the foreground. A + // Beepy -> Aegis1 deep-link switch completed with no dropped start. ctx.startActivity( Intent(ctx, EditorActivityKt::class.java).apply { - putExtra("PROJECT_PATH", pending.projectRoot) + putExtra(EditorIntentExtras.EXTRA_PROJECT_PATH, pending.projectRoot) pending.fileRequest?.let { putExtra(PendingFileRequest.EXTRA_KEY, it) } addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }, @@ -1412,6 +1426,8 @@ open class EditorHandlerActivity : override fun areFilesModified(): Boolean = editorViewModel.areFilesModified + override fun hasUnsavedWritableFiles(): Boolean = hasFilesThatFailedToSave() + override fun areFilesSaving(): Boolean = editorViewModel.areFilesSaving override fun closeFile( @@ -2201,6 +2217,16 @@ open class EditorHandlerActivity : // nothing) that intermediate intent happened to carry. private var capturedPendingFileRequestBeforeSwitch = false + // The project that is actually staying open if the in-flight switch is cancelled or declined, + // snapshotted when the switch is detected. Not re-derived at restore time: on the plain-switch + // path MainActivity.openProject's bookkeeping has already overwritten + // IProjectManager.projectDirPath to the NEW project before the intent is even delivered here, so + // reading that global on decline restored PROJECT_PATH to the project the user just refused -- + // and the next config-change recreate then loaded it, with the open buffers still belonging to + // the project that stayed. onNewIntent computes this same value for isProjectSwitchIntent; this + // keeps it for the decline path, which runs long after that local has gone. + private var stayingProjectPathBeforeSwitch: String? = 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. @@ -2213,12 +2239,28 @@ open class EditorHandlerActivity : // staying project's pending file request on every subsequent decline for the rest of this // instance's life. val restore = pendingFileRequestBeforeSwitch + val staying = stayingProjectPathBeforeSwitch pendingFileRequestBeforeSwitch = null capturedPendingFileRequestBeforeSwitch = false + stayingProjectPathBeforeSwitch = null - val stayingProjectPath = IProjectManager.getInstance().projectDirPath + // The snapshot taken when the switch was detected, not the live global: on the plain-switch + // path the global already holds the NEW project by the time this runs. + val stayingProjectPath = staying ?: IProjectManager.getInstance().projectDirPath if (stayingProjectPath.isBlank()) return - intent.putExtra("PROJECT_PATH", stayingProjectPath) + + // Roll the process-wide bookkeeping back too, not just the intent. MainActivity.openProject + // writes both of these before this dialog can be answered, so a decline otherwise leaves the + // app pointing at the project the user just refused: applyDeepLinkFileRequest resolves file + // paths against projectDirPath, so a later deep-link file navigation would look inside the + // wrong project, and lastOpenedProject would reopen it on the next cold start. Recents and + // the analytics event are deliberately left alone -- the user did ask to open it, and an + // insert that already happened is not wrong, merely early. + if (IProjectManager.getInstance().projectDirPath != stayingProjectPath) { + ProjectManagerImpl.getInstance().projectPath = stayingProjectPath + GeneralPreferences.lastOpenedProject = stayingProjectPath + } + intent.putExtra(EditorIntentExtras.EXTRA_PROJECT_PATH, stayingProjectPath) if (restore != null) { intent.putExtra(PendingFileRequest.EXTRA_KEY, restore) } else { @@ -2404,18 +2446,18 @@ open class EditorHandlerActivity : // 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. - // "PREVIOUS_PROJECT_PATH", when present, is what IProjectManager.projectDirPath held before + // EditorIntentExtras.EXTRA_PREVIOUS_PROJECT_PATH, when present, is what IProjectManager.projectDirPath held before // MainActivity.openProject's bookkeeping call overwrote it to the NEW path -- by the time this // intent arrives here, the global itself already reads as the new path regardless of whether // this is actually a switch, so re-reading it for the comparison below would never detect one. val previousProjectPath = - intent.getStringExtra("PREVIOUS_PROJECT_PATH") ?: IProjectManager.getInstance().projectDirPath + intent.getStringExtra(EditorIntentExtras.EXTRA_PREVIOUS_PROJECT_PATH) ?: IProjectManager.getInstance().projectDirPath val isProjectSwitchIntent = ( deepLinkRequest != null && !projectNamesMatch(File(IProjectManager.getInstance().projectDirPath).name, deepLinkRequest.projectName) ) || - intent.getStringExtra("PROJECT_PATH")?.let { it != previousProjectPath } == true + intent.getStringExtra(EditorIntentExtras.EXTRA_PROJECT_PATH)?.let { it != previousProjectPath } == 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 @@ -2438,6 +2480,7 @@ open class EditorHandlerActivity : pendingFileRequestBeforeSwitch = IntentCompat.getParcelableExtra(getIntent(), PendingFileRequest.EXTRA_KEY, PendingFileRequest::class.java) capturedPendingFileRequestBeforeSwitch = true + stayingProjectPathBeforeSwitch = previousProjectPath } setIntent(intent) @@ -2464,7 +2507,21 @@ open class EditorHandlerActivity : latestDeepLinkRequest = request lifecycleScope.launch(Dispatchers.IO) { - val projectDir = resolveDeepLinkProject(Environment.PROJECTS_DIR, request.projectName) ?: return@launch + val projectDir = resolveDeepLinkProject(projectsRoot(), request.projectName) + if (projectDir == null) { + // No such project, so the switch this intent announced is never going to happen. Without + // this the capture above is stranded: setIntent() has already dropped the staying + // project's pending file request, nothing puts it back, and + // capturedPendingFileRequestBeforeSwitch stays true for the life of the instance -- so + // the next genuine switch skips its own capture and a later decline restores this stale + // one instead. The two lifecycle early-returns below deliberately don't do this: a + // finishing instance has nothing to restore into, and a superseded request must leave + // the capture alone for the newer switch that is still relying on it. + withContext(Dispatchers.Main) { + if (!isFinishing && !isDestroyed) restoreIntentToStayingProject() + } + return@launch + } withContext(Dispatchers.Main) { // The activity may have started finishing while resolveDeepLinkProject was still // scanning disk -- lifecycleScope only cancels at ON_DESTROY, not the moment isFinishing @@ -2515,7 +2572,7 @@ open class EditorHandlerActivity : // "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 newProjectPath = intent.getStringExtra(EditorIntentExtras.EXTRA_PROJECT_PATH)?.takeIf { it.isNotBlank() } ?: return val fileRequest = IntentCompat.getParcelableExtra(intent, PendingFileRequest.EXTRA_KEY, PendingFileRequest::class.java) // No unconditional drain here (unlike this method's earlier version): switchToProject's own @@ -2528,8 +2585,10 @@ open class EditorHandlerActivity : // See onNewIntent's identical read: MainActivity.openProject's bookkeeping call already // overwrote the live global to newProjectPath before this intent arrived. val previousProjectPath = - intent.getStringExtra("PREVIOUS_PROJECT_PATH") ?: IProjectManager.getInstance().projectDirPath - switchToProject(newProjectPath, fileRequest, previousProjectPath) + intent.getStringExtra(EditorIntentExtras.EXTRA_PREVIOUS_PROJECT_PATH) ?: IProjectManager.getInstance().projectDirPath + // bookkeepingAlreadyRecorded: this intent came from MainActivity.openProject, which recorded + // the open before sending it. + switchToProject(newProjectPath, fileRequest, previousProjectPath, bookkeepingAlreadyRecorded = true) } /** @@ -2548,6 +2607,7 @@ open class EditorHandlerActivity : newProjectPath: String, fileRequest: PendingFileRequest?, previousProjectPath: String = IProjectManager.getInstance().projectDirPath, + bookkeepingAlreadyRecorded: Boolean = false, ) { val currentProjectPath = previousProjectPath when { @@ -2560,7 +2620,7 @@ open class EditorHandlerActivity : // 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) + pendingDeepLinkOpen.value = DeepLinkOpenRequest(newProjectPath, fileRequest, bookkeepingAlreadyRecorded) } // Either no project has actually finished initializing in this instance yet (e.g. it was @@ -2570,7 +2630,7 @@ open class EditorHandlerActivity : // 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) + pendingDeepLinkOpen.value = DeepLinkOpenRequest(newProjectPath, fileRequest, bookkeepingAlreadyRecorded) finish() } @@ -2616,7 +2676,7 @@ open class EditorHandlerActivity : // 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) + pendingDeepLinkOpen.value = DeepLinkOpenRequest(newProjectPath, fileRequest, bookkeepingAlreadyRecorded) } } } @@ -2678,8 +2738,10 @@ open class EditorHandlerActivity : columnInvalidRaw != null -> flashError(getString(string.msg_deeplink_invalid_column, shown(columnInvalidRaw))) } - val pos = Position(line, column) - openFileAndSelect(file, Range(pos, pos)) + // Range.pointRange, not Range(pos, pos): the factory already exists for exactly this, and + // it hands back two distinct Positions -- passing one instance as both ends gives + // EditorFeatures.validateRange a Range whose clamps move each other. + openFileAndSelect(file, Range.pointRange(Position(line, column))) } } } 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 92ba233349..b2bdbeb145 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 @@ -58,6 +58,7 @@ import com.itsaky.androidide.lookup.Lookup import com.itsaky.androidide.lsp.IDELanguageClientImpl import com.itsaky.androidide.lsp.debug.DebugClientConnectionResult import com.itsaky.androidide.lsp.java.utils.CancelChecker +import com.itsaky.androidide.models.EditorIntentExtras import com.itsaky.androidide.models.Position import com.itsaky.androidide.models.Range import com.itsaky.androidide.models.SearchResult @@ -250,7 +251,7 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { observeStates() startServices() - if (intent.getBooleanExtra("HAS_TEMPLATE_ISSUES", false)) { + if (intent.getBooleanExtra(EditorIntentExtras.EXTRA_HAS_TEMPLATE_ISSUES, false)) { flashError(getString(string.msg_template_warnings)) } } diff --git a/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt b/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt index 189eb6f287..3b4c161882 100644 --- a/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt @@ -241,8 +241,9 @@ class EditorProviderImpl( column: Int, ): Boolean { val activity = activity() ?: return false - val pos = Position(line.coerceAtLeast(0), column.coerceAtLeast(0)) - activity.openFileAndSelect(file, Range(pos, pos)) + // Same aliasing as the deep-link path: one Position handed in as both ends of a Range gets + // its two clamps applied to the same object by EditorFeatures.validateRange. + activity.openFileAndSelect(file, Range.pointRange(Position(line.coerceAtLeast(0), column.coerceAtLeast(0)))) return true } diff --git a/app/src/main/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequests.kt b/app/src/main/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequests.kt index 90e5009a13..e064a734bb 100644 --- a/app/src/main/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequests.kt +++ b/app/src/main/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequests.kt @@ -1,3 +1,20 @@ +/* + * 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.DeepLinkRequest 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 b64718a86c..51ceef319f 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 @@ -688,11 +688,21 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { // succeeded means saveAll() did not throw, not that every write landed: a silent // per-file failure (disk full, say) leaves a file modified with succeeded still // true, and running a commit or pull then operates on a tree whose edits were - // never written. areFilesModified() reflects the per-file state each save updates. - if (succeeded && handler.areFilesModified() == false) { + // never written. + // + // hasUnsavedWritableFiles(), not areFilesModified(): the latter is a cached flag + // that counts read-only archive tabs, which no save ever writes, so with a .zip or + // .apk tab open it stays true forever and every git action here would be refused + // with no way for the user to clear it. Same question EditorHandlerActivity asks + // itself after its own saves. + if (succeeded && !handler.hasUnsavedWritableFiles()) { action() } else { - flashError(R.string.save_failed) + // The String overload, not flashError(Int): the Int one shows a ~1s + // auto-dismissing bar, and a user who looked away would never learn the git + // action was abandoned and their edits are still unwritten. Matches the + // reasoning EditorHandlerActivity spells out on its own save-failure path. + flashError(getString(R.string.save_failed)) } } }.setNegativeButton(R.string.no_save_before_git_action) { _, _ -> 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 d269630e51..2a02ba5b6f 100644 --- a/app/src/main/java/com/itsaky/androidide/interfaces/IEditorHandler.kt +++ b/app/src/main/java/com/itsaky/androidide/interfaces/IEditorHandler.kt @@ -53,6 +53,17 @@ interface IEditorHandler { fun areFilesModified(): Boolean + /** + * Whether any open file still holds unsaved edits *that a save could actually have written*. + * + * Not the same question as [areFilesModified], which is a cached flag recomputed only as a side + * effect of a successful per-file write, and which counts read-only archive tabs (.zip/.apk and + * friends) that `CodeEditorView.save()` never writes. Asking [areFilesModified] after a save to + * decide whether the save worked therefore answers "still modified" forever whenever such a tab + * is open, which is why this exists. + */ + fun hasUnsavedWritableFiles(): Boolean + fun areFilesSaving(): Boolean /** 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 7c8715d7bc..8773040d10 100644 --- a/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt +++ b/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt @@ -174,11 +174,20 @@ data class DeepLinkRequest( val filePath = segments.subList(startIdx, endIdx).joinToString("/") - PendingFileRequest( - filePath = filePath, - lineRaw = lineRaw, - columnRaw = columnRaw, - ) + // `.../file/line/5` peels "line/5" off and leaves nothing between startIdx and + // endIdx, so the file request would carry an empty path -- which resolves against + // nothing and surfaces to the user as the nonsense `File "" was not found in the + // project.` The link named no file, so it is a plain project-open link; the line and + // column, having nothing to apply to, go with it. + if (filePath.isEmpty()) { + null + } else { + PendingFileRequest( + filePath = filePath, + lineRaw = lineRaw, + columnRaw = columnRaw, + ) + } } return DeepLinkRequest(projectName = projectName, fileRequest = fileRequest) @@ -195,4 +204,14 @@ data class DeepLinkRequest( data class DeepLinkOpenRequest( val projectRoot: String, val fileRequest: PendingFileRequest?, + /** + * Whether `recordProjectOpenedBookkeeping` has already run for [projectRoot]. + * + * True for the plain project-switch path, where MainActivity.openProject records the open before + * it even sends the intent; false for a deep link arriving at an already-live editor, which + * never goes through MainActivity at all. Without the distinction the hand-off recorded every + * plain switch twice -- two Recents inserts and, more visibly, two `trackProjectOpened` events + * for one user action. + */ + val bookkeepingAlreadyRecorded: Boolean = false, ) : Parcelable diff --git a/app/src/main/java/com/itsaky/androidide/models/EditorIntentExtras.kt b/app/src/main/java/com/itsaky/androidide/models/EditorIntentExtras.kt new file mode 100644 index 0000000000..c56153575a --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/models/EditorIntentExtras.kt @@ -0,0 +1,45 @@ +/* + * 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 + +/** + * The Intent extras MainActivity and the editor activities pass between themselves. + * + * Spelled out here rather than repeated as string literals at each of their ~13 call sites across + * three files. A typo in one of those reads back as a missing extra -- silently, at runtime -- and + * [EXTRA_PREVIOUS_PROJECT_PATH] in particular is what the same-project-vs-genuine-switch decision + * turns on, so getting it wrong makes every switch look like a no-op. The values keep their original + * unqualified spelling: they are read from Intents that a previous install may have persisted, and + * renaming them now would silently drop those. + */ +object EditorIntentExtras { + /** Absolute path of the project the editor should open. */ + const val EXTRA_PROJECT_PATH = "PROJECT_PATH" + + /** + * Absolute path of the project that was open *before* this Intent was built. + * + * Sent because `recordProjectOpenedBookkeeping` overwrites the live `IProjectManager` global to + * the new path before the Intent is even delivered, so by the time the editor reads it there is + * no longer any way to tell what it was. + */ + const val EXTRA_PREVIOUS_PROJECT_PATH = "PREVIOUS_PROJECT_PATH" + + /** Set when the project was created from a template that reported issues. */ + const val EXTRA_HAS_TEMPLATE_ISSUES = "HAS_TEMPLATE_ISSUES" +} diff --git a/app/src/test/java/com/itsaky/androidide/activities/editor/SameProjectDeepLinkMidSyncTest.kt b/app/src/test/java/com/itsaky/androidide/activities/editor/SameProjectDeepLinkMidSyncTest.kt index 179a343e97..79e95d81a6 100644 --- a/app/src/test/java/com/itsaky/androidide/activities/editor/SameProjectDeepLinkMidSyncTest.kt +++ b/app/src/test/java/com/itsaky/androidide/activities/editor/SameProjectDeepLinkMidSyncTest.kt @@ -21,6 +21,7 @@ import android.content.Intent import androidx.core.content.IntentCompat import com.google.common.truth.Truth.assertThat import com.itsaky.androidide.app.BaseApplication +import com.itsaky.androidide.deeplink.PendingDeepLinkOpen import com.itsaky.androidide.models.PendingFileRequest import com.itsaky.androidide.projects.IProjectManager import io.mockk.every @@ -28,8 +29,12 @@ import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkAll import org.junit.After +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.koin.core.context.GlobalContext.startKoin +import org.koin.core.context.GlobalContext.stopKoin +import org.koin.dsl.module import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -55,8 +60,19 @@ import org.robolectric.annotation.Config class SameProjectDeepLinkMidSyncTest { open class TestApp : BaseApplication() + // switchToProject reads the Koin-provided PendingDeepLinkOpen on three of its four branches, and + // this activity is built but never created, so `isDestroyed` makes contentOrNull null and the + // binding-torn-down branch is the one taken. Without a Koin context that branch threw + // "KoinApplication has not been started" before the test could assert anything -- this test has + // been failing on the branch for exactly that reason, independently of what it is meant to check. + @Before + fun setUp() { + startKoin { modules(module { single { PendingDeepLinkOpen() } }) } + } + @After fun tearDown() { + stopKoin() unmockkAll() } @@ -92,9 +108,12 @@ class SameProjectDeepLinkMidSyncTest { String::class.java, PendingFileRequest::class.java, String::class.java, + // bookkeepingAlreadyRecorded: false here, since this exercises the deep-link path, where + // MainActivity.openProject never ran and the open has not been recorded yet. + Boolean::class.javaPrimitiveType, ) switchToProject.isAccessible = true - switchToProject.invoke(activity, projectPath, newRequest, projectPath) + switchToProject.invoke(activity, projectPath, newRequest, projectPath, false) // postProjectInit's deferred retry reads exactly this extra once the sync completes: it // must find the new request -- not nothing, and not the stale carried-forward one. diff --git a/app/src/test/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequestsTest.kt b/app/src/test/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequestsTest.kt index 0ddd6af983..4fc4f47a86 100644 --- a/app/src/test/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequestsTest.kt +++ b/app/src/test/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequestsTest.kt @@ -1,3 +1,20 @@ +/* + * 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.google.common.truth.Truth.assertThat 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 4000d86305..f468432f30 100644 --- a/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt +++ b/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt @@ -153,20 +153,31 @@ class DeepLinkRequestTest { } @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. + fun `a file path that is only 'line' plus one segment names no file, so there is no file request`() { + // The keyword ambiguity itself is still unresolved and still a known limitation: with nothing + // else in the path, `file/line/Main.kt` is structurally identical to a real line suffix, and + // this URL scheme has no delimiter to tell "a directory named line" from "the line keyword". + // What changed is what the parser does once it has peeled the pair off and found nothing left + // to open. It used to hand back a PendingFileRequest whose filePath was empty, which resolves + // against nothing and reached the user as `File "" was not found in the project.` A link that + // names no file is a plain project-open link, so the file request is dropped -- and the line + // value goes with it, having nothing left to apply to. 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), - ), - ) + assertThat(request).isEqualTo(DeepLinkRequest(projectName = "MyApp", fileRequest = null)) + } + + @Test + fun `a numeric line with no file path is a plain project-open link`() { + // The shape the review actually reported: `file/line/5` peels cleanly, leaves an empty path, + // and must not become a file request for the empty string. + assertThat(parse("https://www.appdevforall.org/device/open/project/MyApp/file/line/5")) + .isEqualTo(DeepLinkRequest(projectName = "MyApp", fileRequest = null)) + } + + @Test + fun `an empty file path with both line and column is a plain project-open link`() { + assertThat(parse("https://www.appdevforall.org/device/open/project/MyApp/file/line/5/column/7")) + .isEqualTo(DeepLinkRequest(projectName = "MyApp", fileRequest = null)) } @Test diff --git a/shared/src/main/java/com/itsaky/androidide/models/Locations.kt b/shared/src/main/java/com/itsaky/androidide/models/Locations.kt index dd1a1858a0..4139e6ecb0 100644 --- a/shared/src/main/java/com/itsaky/androidide/models/Locations.kt +++ b/shared/src/main/java/com/itsaky/androidide/models/Locations.kt @@ -116,7 +116,10 @@ constructor( @JvmStatic fun pointRange(position: Position): Range { - return Range(position, position) + // Two copies, not the same instance twice. Position's fields are `var` and + // EditorFeatures.validateRange clamps range.start and range.end in place, so a Range whose + // two ends alias one object has each clamp silently move the other end too. + return Range(position.copy(), position.copy()) } } From 6cde5d1a35e5bb7050b532087dc50bae1a468adf Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 31 Aug 2026 19:16:51 -0700 Subject: [PATCH 68/76] style: spotless reformat of Locations.kt, no functional change Nothing here is behavioural. Editing one line of this file for ADFA-5067 (the pointRange aliasing fix in the preceding commit) enrolled it in the ratchetFrom = origin/stage ratchet, which reformats every differing file in full, so ktlint rewrote all 191 lines of it. What it did: added trailing commas, split parameter lists one-per-line, dropped the blank line after class and companion-object openings, and converted five single-return block bodies to expression bodies (pointRange(line, column), containsLine, containsColumn, isSmallerThan, toString). Plus a missing newline at end of file. Verified inert: stripping all whitespace and commas from both versions leaves two token streams differing only by those five `{ return x }` -> `= x` conversions, which Kotlin treats identically. Kept out of the behavioural commit so that commit's diff is the ten lines it actually changes rather than a 191-line whitespace wall. --- .../com/itsaky/androidide/models/Locations.kt | 371 +++++++++--------- 1 file changed, 186 insertions(+), 185 deletions(-) diff --git a/shared/src/main/java/com/itsaky/androidide/models/Locations.kt b/shared/src/main/java/com/itsaky/androidide/models/Locations.kt index 4139e6ecb0..d122c862dc 100644 --- a/shared/src/main/java/com/itsaky/androidide/models/Locations.kt +++ b/shared/src/main/java/com/itsaky/androidide/models/Locations.kt @@ -20,250 +20,251 @@ package com.itsaky.androidide.models import com.google.gson.annotations.SerializedName import java.nio.file.Path -data class Location(var file: Path, var range: Range) - -data class Position @JvmOverloads constructor( - @SerializedName("line") var line: Int, - @SerializedName("column") var column: Int, - @SerializedName("index") var index: Int = -1 -) : Comparable { - - fun requireIndex(): Int { - if (index == -1) { - throw IllegalArgumentException("No index provided") +data class Location( + var file: Path, + var range: Range, +) + +data class Position + @JvmOverloads + constructor( + @SerializedName("line") var line: Int, + @SerializedName("column") var column: Int, + @SerializedName("index") var index: Int = -1, + ) : Comparable { + fun requireIndex(): Int { + if (index == -1) { + throw IllegalArgumentException("No index provided") + } + return index } - return index - } - /** Makes the indices 0 if they are negative. */ - fun zeroIfNegative() { - if (line < 0) { - line = 0 - } + /** Makes the indices 0 if they are negative. */ + fun zeroIfNegative() { + if (line < 0) { + line = 0 + } - if (column < 0) { - column = 0 + if (column < 0) { + column = 0 + } } - } - companion object { + companion object { + @JvmField + val NONE = Position(-1, -1) + } - @JvmField - val NONE = Position(-1, -1) - } + override fun compareTo(other: Position): Int { + val byLine = + when { + line < other.line -> -1 + line > other.line -> 1 + else -> 0 + } - override fun compareTo(other: Position): Int { + if (byLine != 0) { + return byLine + } - val byLine = - when { - line < other.line -> -1 - line > other.line -> 1 + return when { + column < other.column -> -1 + column > other.column -> 1 else -> 0 } - - if (byLine != 0) { - return byLine } - return when { - column < other.column -> -1 - column > other.column -> 1 - else -> 0 - } - } + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Position) return false - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is Position) return false + if (line != other.line) return false + if (column != other.column) return false - if (line != other.line) return false - if (column != other.column) return false - - return true - } + return true + } - override fun hashCode(): Int { - var result = line - result = 31 * result + column - return result + override fun hashCode(): Int { + var result = line + result = 31 * result + column + return result + } } -} open class Range -@JvmOverloads -constructor( - @SerializedName("start") var start: Position = Position(0, 0), - @SerializedName("end") var end: Position = Position(0, 0) -) : Comparable { - - operator fun component1() = start - operator fun component2() = end + @JvmOverloads + constructor( + @SerializedName("start") var start: Position = Position(0, 0), + @SerializedName("end") var end: Position = Position(0, 0), + ) : Comparable { + operator fun component1() = start + + operator fun component2() = end + + constructor(src: Range) : this( + Position(src.start.line, src.start.column), + Position(src.end.line, src.end.column), + ) + + companion object { + @JvmField + val NONE = Range(Position.NONE, Position.NONE) + + @JvmStatic + fun pointRange( + line: Int, + column: Int, + ): Range = pointRange(Position(line, column)) + + @JvmStatic + fun pointRange(position: Position): Range { + // Two copies, not the same instance twice. Position's fields are `var` and + // EditorFeatures.validateRange clamps range.start and range.end in place, so a Range whose + // two ends alias one object has each clamp silently move the other end too. + return Range(position.copy(), position.copy()) + } + } - constructor(src: Range) : this( - Position(src.start.line, src.start.column), - Position(src.end.line, src.end.column) - ) + /** + * Validate the start and end positions. + * @see Position.zeroIfNegative() + */ + fun validate() { + start.zeroIfNegative() + end.zeroIfNegative() + } - companion object { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Range) return false - @JvmField - val NONE = Range(Position.NONE, Position.NONE) + if (start != other.start) return false + if (end != other.end) return false - @JvmStatic - fun pointRange(line: Int, column: Int): Range { - return pointRange(Position(line, column)) + return true } - @JvmStatic - fun pointRange(position: Position): Range { - // Two copies, not the same instance twice. Position's fields are `var` and - // EditorFeatures.validateRange clamps range.start and range.end in place, so a Range whose - // two ends alias one object has each clamp silently move the other end too. - return Range(position.copy(), position.copy()) + override fun hashCode(): Int { + var result = start.hashCode() + result = 31 * result + end.hashCode() + return result } - } - /** - * Validate the start and end positions. - * @see Position.zeroIfNegative() - */ - fun validate() { - start.zeroIfNegative() - end.zeroIfNegative() - } + override fun compareTo(other: Range): Int = start.compareTo(other.start) - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is Range) return false + fun compareByEnd(other: Range): Int = end.compareTo(other.end) - if (start != other.start) return false - if (end != other.end) return false + fun contains(position: Position): Boolean = contains(position.line, position.column) - return true - } - - override fun hashCode(): Int { - var result = start.hashCode() - result = 31 * result + end.hashCode() - return result - } + fun contains( + line: Int, + column: Int, + ): Boolean { + if (line < start.line || line > end.line) { + return false + } - override fun compareTo(other: Range): Int = start.compareTo(other.start) + if (start.line == end.line) { + return column >= start.column && column <= end.column + } - fun compareByEnd(other: Range): Int = end.compareTo(other.end) + if (line == start.line) return column >= start.column + if (line == end.line) return column <= end.column - fun contains(position: Position): Boolean = contains(position.line, position.column) - fun contains(line: Int, column: Int): Boolean { - if (line < start.line || line > end.line) { return false } - if (start.line == end.line) { - return column >= start.column && column <= end.column - } - - if (line == start.line) return column >= start.column - if (line == end.line) return column <= end.column - - return false - } - - /** - * Check if this range is before the given position or not. - */ - fun precedesPosition(line: Int, column: Int): Boolean { - if (end.line < line) { - return true - } - - return end.line == line && end.column < column - } + /** + * Check if this range is before the given position or not. + */ + fun precedesPosition( + line: Int, + column: Int, + ): Boolean { + if (end.line < line) { + return true + } - /** - * Check if this range is after the given position or not. - */ - fun succeedsPosition(line: Int, column: Int): Boolean { - if (start.line > line) { - return true + return end.line == line && end.column < column } - return start.line == line && start.column > column - } - - /** - * Check if this range contains the given position or not. Return an integer result indicating - * where the index of the range containing this position might be. - * - * The return value will be used in a binary search. - */ - fun containsForBinarySearch(position: Position): Int { - - // The position might appear before this range - if (position.line < start.line) { - return -1 - } + /** + * Check if this range is after the given position or not. + */ + fun succeedsPosition( + line: Int, + column: Int, + ): Boolean { + if (start.line > line) { + return true + } - // The position might appear after this range - if (position.line > end.line) { - return 1 + return start.line == line && start.column > column } - // If start and end lines are same, compare by column indexes - if (start.line == end.line) { - - if (position.column < start.column) { + /** + * Check if this range contains the given position or not. Return an integer result indicating + * where the index of the range containing this position might be. + * + * The return value will be used in a binary search. + */ + fun containsForBinarySearch(position: Position): Int { + // The position might appear before this range + if (position.line < start.line) { return -1 } - if (position.column > end.column) { + // The position might appear after this range + if (position.line > end.line) { return 1 } - } - - // This range definitely contains the position. - return 0 - } - fun containsLine(line: Int): Boolean { - return start.line <= line && end.line >= line - } + // If start and end lines are same, compare by column indexes + if (start.line == end.line) { + if (position.column < start.column) { + return -1 + } - fun containsColumn(column: Int): Boolean { - return start.column <= column && end.column >= column - } + if (position.column > end.column) { + return 1 + } + } - fun containsRange(other: Range): Boolean { - if (!containsLine(other.start.line) || !containsLine(other.end.line)) { - return false + // This range definitely contains the position. + return 0 } - return containsColumn(other.start.column) && containsColumn(other.end.column) - } + fun containsLine(line: Int): Boolean = start.line <= line && end.line >= line - fun isSmallerThan(other: Range): Boolean { - return other.isBiggerThan(this) - } + fun containsColumn(column: Int): Boolean = start.column <= column && end.column >= column - fun isBiggerThan(other: Range): Boolean { + fun containsRange(other: Range): Boolean { + if (!containsLine(other.start.line) || !containsLine(other.end.line)) { + return false + } - if (equals(other)) { - return false + return containsColumn(other.start.column) && containsColumn(other.end.column) } - if (start.line < other.start.line && end.line > other.end.line) { - return true - } + fun isSmallerThan(other: Range): Boolean = other.isBiggerThan(this) - if (start.line == other.start.line && end.line == other.end.line) { - if (start.column <= other.start.column && end.column >= other.end.column) { + fun isBiggerThan(other: Range): Boolean { + if (equals(other)) { + return false + } + + if (start.line < other.start.line && end.line > other.end.line) { return true } - } - return false - } + if (start.line == other.start.line && end.line == other.end.line) { + if (start.column <= other.start.column && end.column >= other.end.column) { + return true + } + } + + return false + } - override fun toString(): String { - return "Range(start=$start, end=$end)" + override fun toString(): String = "Range(start=$start, end=$end)" } -} From c3711225a1aec5fed215d52538958065a5c55708 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 15:41:00 +0000 Subject: [PATCH 69/76] ADFA-5067: Fix 7 review findings in the deep-link lifecycle work Two medium: - Consumption of the deep-link extras on the editor side now survives process death: intent.removeExtra only mutates this process's Intent, while a recreate is handed the parceled copy with the extras intact, re-firing the navigation with no user action. BaseEditorActivity now keeps consumedDeepLinkRequests/consumedFileRequests (persisted via onSaveInstanceState, mirroring MainActivity's existing marker) and gates onCreate's DeepLinkRequest read and postProjectInit's PendingFileRequest read on them. ConsumedDeepLinkRequests is generified to ConsumedRequests so both extra types share the bookkeeping, with remove() so a deliberate re-arm of an equal-by-value request is not skipped as already consumed. - onDestroy's pendingCloseCallback drain is now additionally gated on closeDialogAnswered, set only by the confirm-close dialog's two confirm buttons: isFinishing alone is also true when the task is swiped out of Recents while the dialog is still up, and the armed callback then performed a project switch the user never confirmed. Five low: - MainActivity records the deep-link request each open/confirm dialog was actually raised for (threaded through handleOpenProject/ askProjectOpenPermission), not latestDeepLinkRequest at answer time, which a second link can have overwritten. - switchToProject's close-in-progress branch no longer drains a still-valid older pending request for the staying project; it drops only the new request's own copy of the extra. - ProjectHandlerActivity.initializeProject's two init-failure early returns (no build service, tooling server down) now drain the pending file request, matching postProjectInit's regardless-of-outcome drain they bypass. - DeepLinkRequest.parse rejects paths with an empty segment ("//"), which Uri.pathSegments silently drops and which made ".../project//file/Main.kt" parse as a project named "file". - The isFinishing/isDestroyed liveness filter is scoped to a new ActionContextProvider.getLiveActivity() used by DeepLinkActivity's routing; getActivity() keeps its pre-existing semantics for the floating editor panel and IDEApiFacade.runApp. --- .../androidide/activities/DeepLinkActivity.kt | 4 +- .../androidide/activities/MainActivity.kt | 34 +++--- .../activities/editor/BaseEditorActivity.kt | 88 ++++++++++++-- .../editor/EditorHandlerActivity.kt | 107 ++++++++++++------ .../editor/ProjectHandlerActivity.kt | 8 ++ .../androidide/api/ActionContextProvider.kt | 18 ++- ...eepLinkRequests.kt => ConsumedRequests.kt} | 31 +++-- .../androidide/models/DeepLinkRequest.kt | 8 ++ ...equestsTest.kt => ConsumedRequestsTest.kt} | 29 +++-- .../androidide/models/DeepLinkRequestTest.kt | 9 ++ 10 files changed, 257 insertions(+), 79 deletions(-) rename app/src/main/java/com/itsaky/androidide/deeplink/{ConsumedDeepLinkRequests.kt => ConsumedRequests.kt} (66%) rename app/src/test/java/com/itsaky/androidide/deeplink/{ConsumedDeepLinkRequestsTest.kt => ConsumedRequestsTest.kt} (78%) 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 39b07a7c20..b8cd4373fb 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt @@ -82,7 +82,7 @@ class DeepLinkActivity : Activity() { // which stays null for the whole duration of a Gradle sync even while EditorActivityKt is // already open. val target = - if (ActionContextProvider.getActivity() != null) { + if (ActionContextProvider.getLiveActivity() != null) { EditorActivityKt::class.java } else { MainActivity::class.java @@ -93,7 +93,7 @@ class DeepLinkActivity : Activity() { putExtra(DeepLinkRequest.EXTRA_KEY, request) // FLAG_ACTIVITY_CLEAR_TOP deliberately omitted: MainActivity has no special launch // mode, so if an existing MainActivity instance sits lower in this task's back stack - // under a live EditorActivityKt - which ActionContextProvider.getActivity() can miss + // under a live EditorActivityKt - which ActionContextProvider.getLiveActivity() can miss // even when that editor is alive (see its KDoc) - CLEAR_TOP would destroy that editor // to clear the path down to MainActivity, discarding unsaved work with no prompt. // Without it, this may at worst stack a redundant MainActivity instance, a harmless 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 fb09ae2182..73089a2f6e 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt @@ -40,7 +40,7 @@ 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.deeplink.ConsumedDeepLinkRequests +import com.itsaky.androidide.deeplink.ConsumedRequests import com.itsaky.androidide.fragments.MainFragment import com.itsaky.androidide.fragments.RecentProjectsFragment import com.itsaky.androidide.idetooltips.TooltipManager @@ -118,7 +118,7 @@ class MainActivity : EdgeToEdgeIDEActivity() { // being silently dropped. // Every request consumed in this task, not just the last one -- see the class for why one slot // was not enough. - private val consumedDeepLinkRequests = ConsumedDeepLinkRequests() + private val consumedDeepLinkRequests = ConsumedRequests() private val onBackPressedCallback = object : OnBackPressedCallback(true) { @@ -441,20 +441,24 @@ class MainActivity : EdgeToEdgeIDEActivity() { } } + // [deepLinkRequest] is the request this open is being performed FOR, threaded through from + // handleDeepLinkRequest (null for non-deep-link opens) the same way root/pendingFileRequest + // already are -- NOT re-read from latestDeepLinkRequest at consume time. That field tracks + // whatever request arrived most recently, so reading it when the user answers a confirm dialog + // could record a newer link B as consumed while actually opening this call's A, leaving A + // unconsumed (re-shown on the next recreate) and B silently dropped. private fun handleOpenProject( root: File, pendingFileRequest: PendingFileRequest? = null, - isDeepLink: Boolean = false, + deepLinkRequest: DeepLinkRequest? = null, ) { if (GeneralPreferences.confirmProjectOpen) { - askProjectOpenPermission(root, pendingFileRequest, isDeepLink) + askProjectOpenPermission(root, pendingFileRequest, deepLinkRequest) return } // No confirmation gate -- opening happens immediately below, so this is "confirm time" for // consumedDeepLinkRequests' purposes. - if (isDeepLink) { - consumedDeepLinkRequests.add(latestDeepLinkRequest) - } + consumedDeepLinkRequests.add(deepLinkRequest) openProject(root, pendingFileRequest = pendingFileRequest) } @@ -472,8 +476,9 @@ class MainActivity : EdgeToEdgeIDEActivity() { private fun askProjectOpenPermission( root: File, pendingFileRequest: PendingFileRequest? = null, - isDeepLink: Boolean = false, + deepLinkRequest: DeepLinkRequest? = null, ) { + val isDeepLink = deepLinkRequest != null // 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 @@ -493,9 +498,10 @@ class MainActivity : EdgeToEdgeIDEActivity() { // The user has now actually confirmed -- "confirm time" for consumedDeepLinkRequests' // purposes, unlike merely having shown this dialog (see its own docs on why that // distinction matters for a recreate that happens while this dialog is still up). - if (isDeepLink) { - consumedDeepLinkRequests.add(latestDeepLinkRequest) - } + // deepLinkRequest, captured when this dialog was shown, is what gets recorded -- not + // latestDeepLinkRequest, which by answer time can already point at a NEWER link than the + // one this dialog was raised for (see handleOpenProject's doc). + consumedDeepLinkRequests.add(deepLinkRequest) openProject(root, pendingFileRequest = pendingFileRequest) } // Consumed on decline too, not just on confirm. "No" is a decision the user made about this @@ -504,9 +510,7 @@ class MainActivity : EdgeToEdgeIDEActivity() { // re-read the extra from the launch Intent and put the very same dialog back up, with no way // to make it stop. builder.setNegativeButton(string.no) { _, _ -> - if (isDeepLink) { - consumedDeepLinkRequests.add(latestDeepLinkRequest) - } + consumedDeepLinkRequests.add(deepLinkRequest) } activeOpenPermissionDialog = builder.show() } @@ -620,7 +624,7 @@ class MainActivity : EdgeToEdgeIDEActivity() { // askProjectOpenPermission and consumedDeepLinkRequests' own docs for why marking it // here, before the user has necessarily responded to that confirm dialog, would be too // early. - handleOpenProject(projectDir, pendingFileRequest = request.fileRequest, isDeepLink = true) + handleOpenProject(projectDir, pendingFileRequest = request.fileRequest, deepLinkRequest = request) } } } 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 0325891ed1..4f254eaadb 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 @@ -56,6 +56,7 @@ import androidx.collection.MutableIntIntMap import androidx.core.content.ContextCompat import androidx.core.content.IntentCompat import androidx.core.graphics.Insets +import androidx.core.os.BundleCompat import androidx.core.view.GravityCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat @@ -89,6 +90,7 @@ import com.itsaky.androidide.app.IDEApplication import com.itsaky.androidide.databinding.ActivityEditorBinding import com.itsaky.androidide.databinding.ContentEditorBinding import com.itsaky.androidide.databinding.LayoutDiagnosticInfoBinding +import com.itsaky.androidide.deeplink.ConsumedRequests import com.itsaky.androidide.events.InstallationEvent import com.itsaky.androidide.fragments.debug.DebuggerFragment import com.itsaky.androidide.fragments.output.ShareableOutputFragment @@ -225,6 +227,48 @@ abstract class BaseEditorActivity : // genuinely live sibling instance set up instead. private var didCompleteLiveOnCreate = false + // The editor-side counterpart of MainActivity.consumedDeepLinkRequests, for the two deep-link + // extras this activity consumes from its intent (DeepLinkRequest in onCreate/onNewIntent, + // PendingFileRequest in postProjectInit): intent.removeExtra only mutates this process's Intent + // object, so after process death the system re-creates this activity from the *parceled* intent + // with the extras still on it, and the drained request would fire again with no user action -- + // yanking the editor back to a file/line the user navigated away from before the kill. Persisted + // via onSaveInstanceState (restored at the top of onCreate) so consumption survives exactly the + // recreate paths removeExtra cannot cover. + protected val consumedDeepLinkRequests = ConsumedRequests() + protected val consumedFileRequests = ConsumedRequests() + + /** + * Arms [request] on [target] for [postProjectInit]'s deferred read, un-marking it as consumed + * first: a deliberately re-armed request (e.g. the same file/line navigation requested a second + * time, parked mid-sync) must not be skipped by the consumed-check just because an equal-by-value + * request was applied earlier. Every putExtra of this key onto a live activity's own intent goes + * through here so arming and the consumed bookkeeping cannot drift apart. + */ + protected fun armPendingFileRequest( + target: Intent, + request: PendingFileRequest, + ) { + consumedFileRequests.remove(request) + target.putExtra(PendingFileRequest.EXTRA_KEY, request) + } + + /** + * Removes the pending file request from this activity's intent and records it as consumed (see + * [consumedFileRequests] for why removal alone is not durable). Returns the request when it was + * still pending, or `null` when there was none or it had already been consumed -- i.e. the intent + * carrying it is a post-process-death redelivery, not a new navigation. + */ + protected fun drainPendingFileRequest(): PendingFileRequest? { + val request = + IntentCompat.getParcelableExtra(intent, PendingFileRequest.EXTRA_KEY, PendingFileRequest::class.java) + ?: return null + val alreadyConsumed = request in consumedFileRequests + consumedFileRequests.add(request) + intent.removeExtra(PendingFileRequest.EXTRA_KEY) + return request.takeUnless { alreadyConsumed } + } + @Suppress("ktlint:standard:backing-property-naming") internal var _binding: ActivityEditorBinding? = null val binding: ActivityEditorBinding @@ -453,6 +497,8 @@ abstract class BaseEditorActivity : const val EDITOR_CONTAINER_SCALE_FACTOR = 0.87f const val KEY_BOTTOM_SHEET_SHOWN = "editor_bottomSheetShown" const val KEY_PROJECT_PATH = "saved_projectPath" + private const val KEY_CONSUMED_DEEP_LINK_REQUESTS = "saved_consumedDeepLinkRequests" + private const val KEY_CONSUMED_FILE_REQUESTS = "saved_consumedFileRequests" } protected abstract fun provideCurrentEditor(): CodeEditorView? @@ -675,13 +721,33 @@ abstract class BaseEditorActivity : * building the editor UI. */ override fun onCreate(savedInstanceState: Bundle?) { + // Restored before the deep-link reads below: a savedInstanceState means this is a recreate + // (config change or post-process-death), and post-process-death the intent read next still + // carries every extra this task already consumed -- see consumedDeepLinkRequests' docs. + consumedDeepLinkRequests.restore( + savedInstanceState?.let { + BundleCompat.getParcelableArrayList(it, KEY_CONSUMED_DEEP_LINK_REQUESTS, DeepLinkRequest::class.java) + }, + ) + consumedFileRequests.restore( + savedInstanceState?.let { + BundleCompat.getParcelableArrayList(it, KEY_CONSUMED_FILE_REQUESTS, PendingFileRequest::class.java) + }, + ) + // 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 + // ActionContextProvider.getLiveActivity()'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. + // never does, so this is EXTRA_KEY's only other reader on the editor side. An + // already-consumed request is treated as absent: it can only be here again because a + // post-process-death recreate redelivered the parceled intent verbatim, and acting on it + // again would bounce the user back into a project switch they already performed (or + // abandoned) before the kill. val deepLinkRequest = - IntentCompat.getParcelableExtra(intent, DeepLinkRequest.EXTRA_KEY, DeepLinkRequest::class.java) + IntentCompat + .getParcelableExtra(intent, DeepLinkRequest.EXTRA_KEY, DeepLinkRequest::class.java) + ?.takeUnless { it in consumedDeepLinkRequests } // The OS can recreate EditorActivity after process death without routing through // MainActivity, leaving the ProjectManagerImpl singleton's lateinit projectPath unset. @@ -712,7 +778,7 @@ abstract class BaseEditorActivity : // // 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) + // onNewIntent, but that check can be stale (see ActionContextProvider.getLiveActivity()'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 @@ -742,11 +808,15 @@ abstract class BaseEditorActivity : // 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) } + deepLinkRequest?.fileRequest?.let { armPendingFileRequest(intent, 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) } + // same file/line a second time. Recorded in consumedDeepLinkRequests too: the removeExtra + // alone doesn't survive process death (see that field's docs). + deepLinkRequest?.let { + consumedDeepLinkRequests.add(it) + intent.removeExtra(DeepLinkRequest.EXTRA_KEY) + } editorViewModel.isBuildInProgress = false editorViewModel.isInitializing = false @@ -1060,6 +1130,10 @@ abstract class BaseEditorActivity : override fun onSaveInstanceState(outState: Bundle) { outState.putString(KEY_PROJECT_PATH, IProjectManager.getInstance().projectDirPath) + // See consumedDeepLinkRequests' docs: a post-process-death recreate is handed the parceled + // intent with already-drained extras still on it, and these are what stop them re-firing. + outState.putParcelableArrayList(KEY_CONSUMED_DEEP_LINK_REQUESTS, consumedDeepLinkRequests.toSavedList()) + outState.putParcelableArrayList(KEY_CONSUMED_FILE_REQUESTS, consumedFileRequests.toSavedList()) super.onSaveInstanceState(outState) } 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 64f0fcf199..72b5f0c59a 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 @@ -293,8 +293,9 @@ open class EditorHandlerActivity : // Registered here (right after super.onCreate() finishes wiring the toolbar/action registry), // 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. + // ActionContextProvider.getLiveActivity() 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 @@ -454,11 +455,18 @@ open class EditorHandlerActivity : // Save and close) both call finish() before their own onClosed callback runs, so isFinishing is // already true there by the time onDestroy() drains it. if (isFinishing) { - // 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() + // The callback drain is additionally gated on closeDialogAnswered: isFinishing alone is + // also true when the task is swiped out of Recents while the dialog is still showing -- + // an armed-but-unanswered pendingCloseCallback then means the user bailed, and running it + // here would startActivity into a project switch they never confirmed (see the field's + // docs). When the flag IS set, this drain still matters: 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. + if (closeDialogAnswered) { + pendingCloseCallback?.invoke() + } pendingCloseCallback = null // Drain any deep-link-triggered "close then reopen a different project" request recorded by @@ -475,7 +483,7 @@ open class EditorHandlerActivity : 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 + // stale-duplicate instance briefly registered over it (see ActionContextProvider.getLiveActivity()'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 @@ -2204,6 +2212,16 @@ open class EditorHandlerActivity : // guard below with no way to ever apply it. private var pendingCloseCallback: (() -> Unit)? = null + // True only once the user actually answered the confirm-close dialog with one of its two + // confirm options ("Close without saving" / "Save and close"). onDestroy() gates its + // pendingCloseCallback drain on this rather than on isFinishing alone: isFinishing is true for + // ANY finish -- including the task being swiped out of Recents while the dialog is still up -- + // and pendingCloseCallback is armed the moment the dialog opens, so without this flag that + // swipe ran the callback and performed a project switch the user never confirmed, out of + // onDestroy(), with the previous project's buffers never saved or closed. Reset whenever a + // fresh dialog is shown; a cancel/decline never sets it. + private var closeDialogAnswered = false + // 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 @@ -2261,11 +2279,11 @@ open class EditorHandlerActivity : GeneralPreferences.lastOpenedProject = stayingProjectPath } intent.putExtra(EditorIntentExtras.EXTRA_PROJECT_PATH, stayingProjectPath) - if (restore != null) { - intent.putExtra(PendingFileRequest.EXTRA_KEY, restore) - } else { - intent.removeExtra(PendingFileRequest.EXTRA_KEY) - } + // The abandoned switch's own file request (if any) is drained first -- durably, so a + // post-process-death redelivery can't resurrect the navigation the user just declined -- + // then the staying project's still-pending request, if any, is re-armed over it. + drainPendingFileRequest() + restore?.let { armPendingFileRequest(intent, it) } } private fun confirmProjectClose(onClosed: (() -> Unit)? = null) { @@ -2282,6 +2300,7 @@ open class EditorHandlerActivity : } confirmCloseInProgress = true pendingCloseCallback = onClosed + closeDialogAnswered = false val builder = newMaterialDialogBuilder(this) builder.setTitle(string.title_confirm_project_close) @@ -2324,6 +2343,7 @@ open class EditorHandlerActivity : // OPTION 1: Close without saving builder.setNeutralButton(string.close_without_saving) { dialog, _ -> dialog.dismiss() + closeDialogAnswered = true for (i in 0 until editorViewModel.getOpenedFileCount()) { (content.editorContainer.getChildAt(i) as? CodeEditorView)?.editor?.markUnmodified() @@ -2341,6 +2361,7 @@ open class EditorHandlerActivity : // OPTION 2: Save and close builder.setPositiveButton(string.save_and_close) { dialog, _ -> dialog.dismiss() + closeDialogAnswered = true saveAllAsync(notify = false) { saveSucceeded -> runOnUiThread { @@ -2465,7 +2486,7 @@ open class EditorHandlerActivity : if (!isProjectSwitchIntent && !intent.hasExtra(PendingFileRequest.EXTRA_KEY)) { IntentCompat .getParcelableExtra(getIntent(), PendingFileRequest.EXTRA_KEY, PendingFileRequest::class.java) - ?.let { intent.putExtra(PendingFileRequest.EXTRA_KEY, it) } + ?.let { armPendingFileRequest(intent, 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 @@ -2499,7 +2520,11 @@ open class EditorHandlerActivity : // 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. + // this stale request's projectName and bounce the user out of it. The removeExtra only + // scrubs this process's Intent object, not the parceled copy that post-process-death + // redelivery is made from -- consumedDeepLinkRequests (persisted via onSaveInstanceState) is + // what makes the consumption stick there, gating onCreate's read. + consumedDeepLinkRequests.add(request) intent.removeExtra(DeepLinkRequest.EXTRA_KEY) // Tracked so a second deep link delivered moments later doesn't have its resolve complete @@ -2545,13 +2570,17 @@ open class EditorHandlerActivity : // 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 - // 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) + // + // Drained regardless of outcome, not just on success -- otherwise a failed sync leaves the + // extra 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. (Two + // init failures return before this is ever called and drain at their own early returns + // instead -- see ProjectHandlerActivity.initializeProject.) The drain also records the + // request in consumedFileRequests, which is what makes it stick across process death: a + // recreate is handed the parceled intent with the extra still on it, and without the durable + // marker the restored editor would be yanked back to this file/line with no user action. + // drainPendingFileRequest returns null for exactly that redelivered-but-already-consumed case. + val request = drainPendingFileRequest() ?: return if (!isSuccessful) return applyDeepLinkFileRequest(request) } @@ -2650,24 +2679,36 @@ open class EditorHandlerActivity : // showing -- navigating underneath it now would just get silently discarded if // the user goes on to confirm that close. flashError(getString(string.msg_project_close_in_progress)) + // Drop only THIS request's own copy of the extra (the plain-switch path arms it + // on the intent before this runs). The extra can instead be an older, + // still-unapplied request for this staying project -- armed by the mid-sync + // branch below on an earlier call -- which has nothing to do with the in-flight + // close and which postProjectInit must still apply if the user cancels it; + // draining unconditionally here destroyed that older request along with this one. + val armedRequest = + IntentCompat.getParcelableExtra(intent, PendingFileRequest.EXTRA_KEY, PendingFileRequest::class.java) + if (fileRequest != null && fileRequest == armedRequest) { + drainPendingFileRequest() + } } else if (projectReady) { - fileRequest?.let { applyDeepLinkFileRequest(it) } + fileRequest?.let { + applyDeepLinkFileRequest(it) + // 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. + drainPendingFileRequest() + } } else { // Mid-sync: arm the request on the intent so postProjectInit's deferred retry // finds it once the sync completes -- this branch is the "must stay armed" // case the projectReady comment above describes, and without the put the // request dies with this call while onNewIntent's carry-forward has already // re-armed the PREVIOUS, still-unconsumed request, sending the editor to that - // stale target instead (ADFA-5067 review). The put also supersedes that - // carried-forward value, so this arm needs no removeExtra below. - fileRequest?.let { intent.putExtra(PendingFileRequest.EXTRA_KEY, it) } - } - if (fileRequest != null && (confirmCloseInProgress || projectReady)) { - // 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) + // stale target instead (ADFA-5067 review). The arm also supersedes that + // carried-forward value, so this branch needs no drain. + fileRequest?.let { armPendingFileRequest(intent, it) } } } 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 b2bdbeb145..926d6cb82e 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 @@ -608,11 +608,19 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE) as? GradleBuildService if (buildService == null) { log.error("No build service found. Cannot initialize project.") + // This init failed before postProjectInit could ever run, so its regardless-of-outcome + // drain never happens here -- without this, a deep link's pending file navigation stays + // armed on the intent and fires on the next unrelated successful sync or variant + // switch, the stale jump that drain exists to prevent. Same for the tooling-server + // check below. (The handleMissingProjectDirectory returns above don't need it: they + // finish() this instance, and the request dies with it.) + withContext(Dispatchers.Main.immediate) { drainPendingFileRequest() } return@launch } if (!buildService.isToolingServerStarted()) { flashError(string.msg_tooling_server_unavailable) + withContext(Dispatchers.Main.immediate) { drainPendingFileRequest() } return@launch } 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 eb566d80a6..9e836e22ae 100644 --- a/app/src/main/java/com/itsaky/androidide/api/ActionContextProvider.kt +++ b/app/src/main/java/com/itsaky/androidide/api/ActionContextProvider.kt @@ -31,11 +31,21 @@ object ActionContextProvider { } /** - * The current, live [EditorHandlerActivity], or `null` if there is none -- including one that + * The current registered [EditorHandlerActivity], or `null` if there is none. Deliberately NOT + * filtered on `isFinishing`/`isDestroyed` (unlike [getLiveActivity]): pre-existing consumers + * depend on getting the instance even during a finishing window -- e.g. a floating + * `EditorPanelDockableContent`, documented to outlive the activity, and `IDEApiFacade.runApp`, + * which should still launch the built app rather than report "no active IDE window". + */ + fun getActivity(): EditorHandlerActivity? = activityRef?.get() + + /** + * Like [getActivity], but `null` also for an instance that is already tearing down -- 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. + * on "is there a live editor to hand this off to" -- the deep-link routing in + * [com.itsaky.androidide.activities.DeepLinkActivity] -- need this distinction, not just non-null. * * [setActivity] is called from both `onCreate` and `onResume`: `onCreate` closes the blind window * between `onCreate` and `onResume` where a caller like @@ -43,8 +53,8 @@ object ActionContextProvider { * 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 + * restoring it. The `isFinishing`/`isDestroyed` filter here still excludes an instance that * registered but is already tearing down. */ - fun getActivity(): EditorHandlerActivity? = activityRef?.get()?.takeIf { !it.isFinishing && !it.isDestroyed } + fun getLiveActivity(): EditorHandlerActivity? = activityRef?.get()?.takeIf { !it.isFinishing && !it.isDestroyed } } diff --git a/app/src/main/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequests.kt b/app/src/main/java/com/itsaky/androidide/deeplink/ConsumedRequests.kt similarity index 66% rename from app/src/main/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequests.kt rename to app/src/main/java/com/itsaky/androidide/deeplink/ConsumedRequests.kt index e064a734bb..59ffa63ecc 100644 --- a/app/src/main/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequests.kt +++ b/app/src/main/java/com/itsaky/androidide/deeplink/ConsumedRequests.kt @@ -17,11 +17,15 @@ package com.itsaky.androidide.deeplink -import com.itsaky.androidide.models.DeepLinkRequest +import android.os.Parcelable /** - * The deep-link requests this task has already acted on, so a redelivered Intent carrying one of - * them does not force its project open a second time (ADFA-5067). + * The deep-link-style requests (e.g. [com.itsaky.androidide.models.DeepLinkRequest], + * [com.itsaky.androidide.models.PendingFileRequest]) this task has already acted on, so a + * redelivered Intent carrying one of them does not force its navigation a second time (ADFA-5067). + * `Intent.removeExtra` alone cannot provide this: it mutates only this process's Intent object, + * while a recreate after process death is handed the system's *parceled* copy, extras intact -- + * which is why consumers persist this set through `onSaveInstanceState`. * * Every consumed request is remembered, not just the latest. One slot was not enough: after link A * is consumed and link B arrives through `onNewIntent`, `setIntent` makes B the live Intent while @@ -32,19 +36,28 @@ import com.itsaky.androidide.models.DeepLinkRequest * Kept out of the activity so the bookkeeping can be tested without one: this is the third distinct * lifecycle path (config change, process death, second link) whose correctness rests entirely on it. */ -internal class ConsumedDeepLinkRequests { - private val requests = LinkedHashSet() +class ConsumedRequests { + private val requests = LinkedHashSet() /** For `onSaveInstanceState`; pairs with [restore]. */ - fun toSavedList(): ArrayList = ArrayList(requests) + fun toSavedList(): ArrayList = ArrayList(requests) /** Replaces the contents with [saved], which is null when there is no instance state to restore. */ - fun restore(saved: List?) { + fun restore(saved: List?) { requests.clear() saved?.let(requests::addAll) } - operator fun contains(request: DeepLinkRequest): Boolean = request in requests + operator fun contains(request: T): Boolean = request in requests + + /** + * Forgets [request], so an equal-by-value request deliberately re-armed by the caller (e.g. the + * same file/line navigation requested a second time, parked on the Intent for a deferred apply) + * is not mistaken for the already-consumed earlier one and silently skipped. + */ + fun remove(request: T) { + requests.remove(request) + } /** * Records [request] as acted on. Null is accepted and ignored: the caller's "latest request" can @@ -54,7 +67,7 @@ internal class ConsumedDeepLinkRequests { * that fires links in a loop. The evicted case degrades to the old behaviour -- one spurious * reopen of a link superseded 32 links ago -- which no real sequence reaches. */ - fun add(request: DeepLinkRequest?) { + fun add(request: T?) { request ?: return requests += request while (requests.size > MAX_REMEMBERED) { 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 8773040d10..cadf644023 100644 --- a/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt +++ b/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt @@ -128,6 +128,14 @@ data class DeepLinkRequest( return null } + // Uri.pathSegments silently drops empty segments, shifting everything after them one slot + // left -- ".../project//file/Main.kt" would pass the prefix check above and then parse as + // a project literally named "file" (and open it, should one exist). Reject the malformed + // link outright instead of resolving a name the user never wrote. + if (uri.path?.contains("//") == true) { + return null + } + val segments = uri.pathSegments val projectIdx = segments.indexOfFrom(0, SEGMENT_PROJECT) diff --git a/app/src/test/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequestsTest.kt b/app/src/test/java/com/itsaky/androidide/deeplink/ConsumedRequestsTest.kt similarity index 78% rename from app/src/test/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequestsTest.kt rename to app/src/test/java/com/itsaky/androidide/deeplink/ConsumedRequestsTest.kt index 4fc4f47a86..d9e4b86bf1 100644 --- a/app/src/test/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequestsTest.kt +++ b/app/src/test/java/com/itsaky/androidide/deeplink/ConsumedRequestsTest.kt @@ -28,12 +28,12 @@ import org.junit.Test * death, where the Intent the system hands back is the task's *launch* Intent rather than the last * one `setIntent` saw. */ -class ConsumedDeepLinkRequestsTest { +class ConsumedRequestsTest { private fun request(name: String) = DeepLinkRequest(projectName = name) @Test fun `a consumed request is recognised`() { - val consumed = ConsumedDeepLinkRequests() + val consumed = ConsumedRequests() consumed.add(request("alpha")) assertThat(request("alpha") in consumed).isTrue() @@ -44,7 +44,7 @@ class ConsumedDeepLinkRequestsTest { // recreate is handed, so A's project reopened over whatever the user was doing. @Test fun `consuming a second request does not un-consume the first`() { - val consumed = ConsumedDeepLinkRequests() + val consumed = ConsumedRequests() consumed.add(request("alpha")) consumed.add(request("beta")) @@ -54,11 +54,11 @@ class ConsumedDeepLinkRequestsTest { @Test fun `the set survives a save and restore`() { - val consumed = ConsumedDeepLinkRequests() + val consumed = ConsumedRequests() consumed.add(request("alpha")) consumed.add(request("beta")) - val restored = ConsumedDeepLinkRequests() + val restored = ConsumedRequests() restored.restore(consumed.toSavedList()) assertThat(request("alpha") in restored).isTrue() @@ -67,7 +67,7 @@ class ConsumedDeepLinkRequestsTest { @Test fun `restoring nothing leaves an empty set, not a stale one`() { - val consumed = ConsumedDeepLinkRequests() + val consumed = ConsumedRequests() consumed.add(request("alpha")) consumed.restore(null) @@ -76,16 +76,27 @@ class ConsumedDeepLinkRequestsTest { @Test fun `a repeated request is remembered once`() { - val consumed = ConsumedDeepLinkRequests() + val consumed = ConsumedRequests() consumed.add(request("alpha")) consumed.add(request("alpha")) assertThat(consumed.toSavedList()).containsExactly(request("alpha")) } + // A deliberate re-arm (the same navigation requested a second time) must not be mistaken for + // the already-consumed earlier request and silently skipped. + @Test + fun `removing a consumed request lets an equal one be acted on again`() { + val consumed = ConsumedRequests() + consumed.add(request("alpha")) + consumed.remove(request("alpha")) + + assertThat(request("alpha") in consumed).isFalse() + } + @Test fun `a null request is ignored`() { - val consumed = ConsumedDeepLinkRequests() + val consumed = ConsumedRequests() consumed.add(null) assertThat(consumed.toSavedList()).isEmpty() @@ -94,7 +105,7 @@ class ConsumedDeepLinkRequestsTest { // The cap bounds the saved Bundle; what it must not do is forget the most recent requests. @Test fun `past the cap the oldest is evicted and the newest kept`() { - val consumed = ConsumedDeepLinkRequests() + val consumed = ConsumedRequests() repeat(40) { consumed.add(request("project$it")) } assertThat(consumed.toSavedList()).hasSize(32) 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 f468432f30..af7ec4db8f 100644 --- a/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt +++ b/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt @@ -207,6 +207,15 @@ class DeepLinkRequestTest { assertThat(request).isEqualTo(DeepLinkRequest(projectName = "MyApp", fileRequest = null)) } + @Test + fun `an empty path segment yields null`() { + // Uri.pathSegments drops empty segments, so ".../project//file/Main.kt" used to parse as a + // project literally named "file" -- surfacing as `No project named "file" was found`, or + // silently opening a real project of that name, instead of an honest invalid-link error. + assertThat(parse("https://www.appdevforall.org/device/open/project//file/Main.kt")).isNull() + assertThat(parse("https://www.appdevforall.org/device/open/project/MyApp//file/Main.kt")).isNull() + } + @Test fun `null uri yields null`() { assertThat(DeepLinkRequest.parse(null)).isNull() From 887ee0ab7fb11b571ecff1b41c7c236b46899046 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 1 Sep 2026 12:14:59 -0700 Subject: [PATCH 70/76] ADFA-5067: Fix the remaining review findings, and correct two of my own Builds on c3711225a, which covered the process-death consumption markers, the closeDialogAnswered gate, the per-dialog request threading, the close-in-progress drain and the empty-segment parse. These are the ones left, plus two places the previous round got wrong. Corrections to my own earlier work: - The Range aliasing fix guarded the wrong call. openFileAndSelect copies the selection for its own postInLifecycle validate, but passed the ORIGINAL to openFile, whose CodeEditorView constructor pipeline calls validate() and validateRange() on it -- both clamping start/end in place on the caller's object. IDEEditor.showDocument routes an LSP ShowDocumentParams Range through IDELanguageClientImpl.openFileAndSelect, so this mutated the language server's own Location. Copy at that call too. - Relatedly, pointRange's comment claimed a bug that does not exist: for a POINT range the two ends hold equal values and the column clamp depends only on the already-clamped line, so aliasing them is idempotent today. The copies stay -- that is a property of the current clamp, not of the type -- but the comment now says so instead of asserting corruption that cannot happen. - GitBottomSheetFragment was half-migrated: the post-save check moved to hasUnsavedWritableFiles() while the check that RAISES the prompt kept the cached areFilesModified(). With an archive tab open that prompted "save before the git action?" on every commit, pull and push with nothing dirty, offering a save that could not clear it. Both gates now ask the same question. - drainPendingDeepLinkOpen in onDestroy was the sibling the didCompleteLiveOnCreate sweep missed, and the one that matters most: pendingDeepLinkOpen is a Koin single, more widely shared than any registry the three guarded hooks protect. An instance that took onCreate's deepLinkTargetsAnotherProject bail could drain a handoff a different, still-live instance had armed. The rest: - performPendingDeepLinkOpen omitted EXTRA_PREVIOUS_PROJECT_PATH while calling recordProjectOpenedBookkeeping, which overwrites the global the receiver falls back to. A delivery landing on onNewIntent computed previousProjectPath == newProjectPath, so a confirmed switch silently no-opped with the global naming one project and the editor showing another. - The "Save and close" completion treated isDestroyed as teardown. It is also true for a config-change recreate, where a successor for the same project is already coming up -- and this continuation now survives one at all because the PR moved saveAllAsync onto the process-wide appScope. Running the callback there armed a switch on behalf of a replaced instance and draining it fired startActivity while the successor was on screen. Config recreate is now its own branch, left for the successor to own. - deepLinkTargetsAnotherProject and isProjectSwitchIntent compared directory leaf names, but a deep link can only resolve to / while projects open from anywhere (file picker, Recents, clone destination). With an unrelated MyApp open from Download/work, the link's file path resolved against the OPEN project; for two clones of a repo the path exists in both, so the wrong file opened silently. Both sites now go through isDeepLinkTargetOfOpenProject, which requires the parent to be the projects root, canonicalised. - MainActivity.onNewIntent had no consumed-requests check, the gate onCreate applies. BaseEditorActivity re-forwards a request here on any project mismatch, so a declined request came back through that door and re-showed its dialog. - The resolve-failure restore ran unguarded: with nothing captured, restoreIntentToStayingProject's restore == null arm deletes a carried-forward request belonging to the staying project. It also lacked the supersession check its two siblings carry. Both added. - DeepLinkRequest.parse now bounds the path length. DeepLinkActivity is exported, the parsed strings are parcelled into ConsumedRequests and written to MainActivity's saved-instance Bundle, so unbounded names crossed the ~1 MB Binder budget and crashed the activity on every rotation until the task was cleared. - DeepLinkActivity gains taskAffinity="". excludeFromRecents applies to a task via its ROOT activity, and this trampoline shared the app's affinity, so a cold start rooted the real task here and left the whole IDE session with no Recents card. SameProjectDeepLinkMidSyncTest passes, and for the first time tests what it names. It was never reaching its branch: view binding generates `content` as a public Java FIELD, and mockk stubs methods rather than fields, so a relaxed mock left it null, contentOrNull returned null and switchToProject took the binding-torn-down path. The field is now set by reflection. Its Koin setup also joins an already-started context instead of calling startKoin unconditionally, which threw KoinApplicationAlreadyStartedException in the full suite while passing in isolation. :app:testV8DebugUnitTest --rerun-tasks: 420 tests, 0 failures. --- app/src/main/AndroidManifest.xml | 802 +++++++++--------- .../androidide/activities/MainActivity.kt | 6 + .../activities/editor/BaseEditorActivity.kt | 6 +- .../editor/EditorHandlerActivity.kt | 76 +- .../androidide/app/EditorProviderImpl.kt | 4 +- .../fragments/git/GitBottomSheetFragment.kt | 6 +- .../androidide/models/DeepLinkRequest.kt | 15 + .../androidide/utils/ProjectValidations.kt | 27 + .../editor/SameProjectDeepLinkMidSyncTest.kt | 41 +- .../com/itsaky/androidide/models/Locations.kt | 10 +- 10 files changed, 579 insertions(+), 414 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cd4d886323..7222fbdafc 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,396 +1,406 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 73089a2f6e..e3501b91b7 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt @@ -577,6 +577,12 @@ class MainActivity : EdgeToEdgeIDEActivity() { setIntent(intent) IntentCompat .getParcelableExtra(intent, DeepLinkRequest.EXTRA_KEY, DeepLinkRequest::class.java) + // The same consumed gate onCreate applies. Without it an already-answered request comes + // back through this door: BaseEditorActivity.onCreate re-forwards the request here with + // CLEAR_TOP|SINGLE_TOP whenever deepLinkTargetsAnotherProject, and this activity is already + // in the back stack, so it arrives as onNewIntent. A request the user had declined then had + // its confirm dialog put straight back up. + ?.takeIf { it !in consumedDeepLinkRequests } ?.let { handleDeepLinkRequest(it) } } 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 4f254eaadb..8fb637e43e 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 @@ -82,6 +82,7 @@ import com.itsaky.androidide.R import com.itsaky.androidide.R.string import com.itsaky.androidide.actions.build.DebugAction import com.itsaky.androidide.activities.MainActivity +import com.itsaky.androidide.activities.projectsRoot import com.itsaky.androidide.adapters.DiagnosticsAdapter import com.itsaky.androidide.adapters.SearchListAdapter import com.itsaky.androidide.api.BuildOutputProvider @@ -143,7 +144,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.isDeepLinkTargetOfOpenProject import com.itsaky.androidide.utils.resolveAttr import com.itsaky.androidide.viewmodel.ApkInstallationViewModel import com.itsaky.androidide.viewmodel.AppLogsCoordinator @@ -786,7 +787,8 @@ abstract class BaseEditorActivity : // extra disk scan. val projectDirPath = ProjectManagerImpl.getInstance().projectDirPath val deepLinkTargetsAnotherProject = - deepLinkRequest != null && !projectNamesMatch(File(projectDirPath).name, deepLinkRequest.projectName) + deepLinkRequest != null && + !isDeepLinkTargetOfOpenProject(projectDirPath, deepLinkRequest.projectName, projectsRoot()) 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 bd83bb3af4..3b81505549 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 @@ -119,7 +119,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.isDeepLinkTargetOfOpenProject import com.itsaky.androidide.utils.recordProjectOpenedBookkeeping import com.itsaky.androidide.utils.resolveDeepLinkProject import com.itsaky.androidide.utils.resolveWithinDirectory @@ -406,6 +406,8 @@ open class EditorHandlerActivity : private fun performPendingDeepLinkOpen(pending: DeepLinkOpenRequest) { val root = File(pending.projectRoot) val ctx = applicationContext + // Read BEFORE the bookkeeping call below, which overwrites this global with the new path. + val previousProjectPath = IProjectManager.getInstance().projectDirPath if (!pending.bookkeepingAlreadyRecorded) { recordProjectOpenedBookkeeping(recentProjectRepository, root, project = null, analyticsManager = analyticsManager) } @@ -421,6 +423,14 @@ open class EditorHandlerActivity : ctx.startActivity( Intent(ctx, EditorActivityKt::class.java).apply { putExtra(EditorIntentExtras.EXTRA_PROJECT_PATH, pending.projectRoot) + // The same extra MainActivity.openProject sends, and for the same reason: the receiver + // falls back to IProjectManager.projectDirPath, which recordProjectOpenedBookkeeping has + // already overwritten to this very path. Omitting it made a delivery that lands on + // onNewIntent (rather than a fresh onCreate) compute previousProjectPath == newProjectPath, + // so switchToProject took its same-project branch and the confirmed switch silently + // no-opped -- with the process-wide global naming the new project while the editor still + // showed the old one. + putExtra(EditorIntentExtras.EXTRA_PREVIOUS_PROJECT_PATH, previousProjectPath) pending.fileRequest?.let { putExtra(PendingFileRequest.EXTRA_KEY, it) } addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }, @@ -475,7 +485,18 @@ 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. - drainPendingDeepLinkOpen() + // + // didCompleteLiveOnCreate too, for the same reason preDestroy/postDestroy here, + // ProjectHandlerActivity's teardown hooks and BaseEditorActivity's all carry it -- and it + // matters most of all on this line, because pendingDeepLinkOpen is a Koin `single`, more + // widely shared than any registry those guards protect. An instance that took + // BaseEditorActivity.onCreate's deepLinkTargetsAnotherProject bail (startActivity + + // finish() + return) never sets the flag, yet reaches here with isFinishing true; without + // this it drains a handoff a DIFFERENT, still-live instance armed, firing startActivity + // while that instance is alive -- precisely the race deferring to onDestroy exists to avoid. + if (didCompleteLiveOnCreate) { + drainPendingDeepLinkOpen() + } } } @@ -864,7 +885,13 @@ open class EditorHandlerActivity : selection: Range?, ) { lifecycleScope.launch { - val editorView = openFile(file, selection) + // A copy here too, not just in the postInLifecycle block below. openFile hands this straight + // to CodeEditorView's constructor, whose content-load pipeline calls selection.validate() + // and ideEditor.validateRange(selection) -- both of which clamp start/end in place on the + // caller's own object. The caller is often not ours to mutate: IDEEditor.showDocument passes + // the Range out of an LSP ShowDocumentParams via IDELanguageClientImpl.openFileAndSelect, so + // clamping it corrupts the language server's own Location. + val editorView = openFile(file, selection?.let { Range(it) }) editorView?.editor?.also { editor -> editor.postInLifecycle { @@ -2380,7 +2407,22 @@ open class EditorHandlerActivity : // on an instance that is going away -- but the handoff below is process-wide state // that a new instance drains, so it must still happen. Mirrors the contentOrNull == // null branch further down, which exists for the same reason. - if (isFinishing || isDestroyed) { + // isDestroyed WITHOUT isFinishing is not teardown at all: it is a config-change + // recreate (dark mode, locale, density -- none in EditorActivityKt's configChanges), + // where a successor instance for this same project is already coming up. This + // continuation survives it only because saveAllAsync now runs on the process-wide + // appScope rather than lifecycleScope. Running the close callback here would arm a + // switch on behalf of an instance being replaced, and draining it would startActivity + // into that switch while the successor is on screen -- so this case is left alone for + // the successor to own. onDestroy's own drain is gated the same way, on isFinishing. + if (!isFinishing && isDestroyed) { + log.info( + "Save completed during a config-change recreate, not a teardown; leaving the " + + "close callback for the successor instance rather than acting on it here.", + ) + return@runOnUiThread + } + if (isFinishing) { val onClosedDuringTeardown = pendingCloseCallback pendingCloseCallback = null // Logged, not silent: this branch is the one that used to lose the switch, and it @@ -2392,6 +2434,9 @@ open class EditorHandlerActivity : onClosedDuringTeardown != null, ) onClosedDuringTeardown?.invoke() + // isDestroyed too: onDestroy has already run and drained, so nothing else will. + // While it has not, onDestroy's own drain is still ahead of us and doing it here + // would fire the handoff twice. if (isDestroyed) drainPendingDeepLinkOpen() return@runOnUiThread } @@ -2486,7 +2531,11 @@ open class EditorHandlerActivity : val isProjectSwitchIntent = ( deepLinkRequest != null && - !projectNamesMatch(File(IProjectManager.getInstance().projectDirPath).name, deepLinkRequest.projectName) + !isDeepLinkTargetOfOpenProject( + IProjectManager.getInstance().projectDirPath, + deepLinkRequest.projectName, + projectsRoot(), + ) ) || intent.getStringExtra(EditorIntentExtras.EXTRA_PROJECT_PATH)?.let { it != previousProjectPath } == true @@ -2553,7 +2602,22 @@ open class EditorHandlerActivity : // finishing instance has nothing to restore into, and a superseded request must leave // the capture alone for the newer switch that is still relying on it. withContext(Dispatchers.Main) { - if (!isFinishing && !isDestroyed) restoreIntentToStayingProject() + // Two further conditions, both matching the sibling call sites. + // + // capturedPendingFileRequestBeforeSwitch: only restore what this path actually + // captured. With nothing captured, restoreIntentToStayingProject's `restore == null` + // arm runs intent.removeExtra(PendingFileRequest.EXTRA_KEY) and deletes a + // carried-forward request belonging to the staying project -- the corruption the + // other two call sites' `else if (onClosed != null)` guard exists to avoid. + // + // latestDeepLinkRequest === request: a slow, failing link must not clear a capture + // that a newer link's own decline path is still relying on. + if (!isFinishing && !isDestroyed && + capturedPendingFileRequestBeforeSwitch && + latestDeepLinkRequest === request + ) { + restoreIntentToStayingProject() + } } return@launch } diff --git a/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt b/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt index 3b4c161882..a2906f6c11 100644 --- a/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt @@ -241,8 +241,8 @@ class EditorProviderImpl( column: Int, ): Boolean { val activity = activity() ?: return false - // Same aliasing as the deep-link path: one Position handed in as both ends of a Range gets - // its two clamps applied to the same object by EditorFeatures.validateRange. + // pointRange rather than an open-coded Range(pos, pos): same factory, and it keeps this + // Position out of the Range that openFileAndSelect's pipeline goes on to clamp in place. activity.openFileAndSelect(file, Range.pointRange(Position(line.coerceAtLeast(0), column.coerceAtLeast(0)))) return true } 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 51ceef319f..01d43226b5 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 @@ -673,7 +673,11 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { private fun checkUnsavedChangesAndProceed(action: () -> Unit) { val handler = requireActivity() as? IEditorHandler - if (handler?.areFilesModified() == true) { + // hasUnsavedWritableFiles(), matching the post-save check below. areFilesModified() is a cached + // flag that counts read-only archive tabs no save can ever write, so with a .zip or .apk open it + // raised this "save before the git action?" prompt on every commit, pull and push even with + // nothing actually dirty -- and the save it offered could not clear it. + if (handler?.hasUnsavedWritableFiles() == true) { MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.title_files_unsaved) .setMessage(R.string.msg_save_before_git_action) 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 cadf644023..11886f1a84 100644 --- a/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt +++ b/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt @@ -61,6 +61,9 @@ data class DeepLinkRequest( private val HOSTS = setOf("www.appdevforall.org", "appdevforall.org") private const val PATH_PREFIX = "/device/open/project/" + /** See [parse]: an upper bound on the whole path, since the parsed pieces get parcelled. */ + private const val MAX_LINK_PATH_LENGTH = 4096 + private const val SEGMENT_PROJECT = "project" private const val SEGMENT_FILE = "file" private const val SEGMENT_LINE = "line" @@ -136,6 +139,18 @@ data class DeepLinkRequest( return null } + // A length ceiling, because everything downstream of here is parcelled. DeepLinkActivity is + // exported, so any co-installed app can send explicit ACTION_VIEW intents in a loop; the + // parsed name and path are kept in ConsumedRequests (up to 32 of them) and written verbatim + // to MainActivity's saved-instance Bundle, so unbounded names cross the ~1 MB Binder budget + // and crash the activity with TransactionTooLargeException on every rotation or + // backgrounding until the task is cleared. Rejecting is safe: this bounds a legitimate + // path far above anything the filesystem accepts (Linux caps a single name at 255 bytes), + // and a link this long cannot name a real project anyway. + if ((uri.path?.length ?: 0) > MAX_LINK_PATH_LENGTH) { + return null + } + val segments = uri.pathSegments val projectIdx = segments.indexOfFrom(0, SEGMENT_PROJECT) 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 f6f0a07fc1..c474bd6d29 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/ProjectValidations.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/ProjectValidations.kt @@ -75,6 +75,33 @@ internal fun projectNamesMatch( return Normalizer.normalize(a, Normalizer.Form.NFC) == Normalizer.normalize(b, Normalizer.Form.NFC) } +/** + * Whether [openProjectPath] is the project a deep link naming [projectName] would resolve to. + * + * The name alone is not enough. A deep link can only ever resolve to `/`, but a + * project can be opened from anywhere -- the file picker (`BaseFragment`'s ACTION_OPEN_DOCUMENT_TREE + * uses the projects dir as a starting hint, not a constraint), Recents, or a clone destination. With + * `/storage/emulated/0/Download/work/MyApp` open and an unrelated `/MyApp` on disk, a + * leaf-name comparison says "same project" and the link's file path is then resolved against the + * OPEN one -- for two clones of a repo the path exists in both, so the wrong file opens silently. + * So the parent has to match as well. + * + * Canonicalised, since either side can reach the same directory through a symlink; a failure to + * canonicalise (an unreadable parent) falls back to the absolute path rather than throwing. + */ +internal fun isDeepLinkTargetOfOpenProject( + openProjectPath: String, + projectName: String, + projectsRoot: File, +): Boolean { + if (openProjectPath.isBlank()) return false + val open = File(openProjectPath) + if (!projectNamesMatch(open.name, projectName)) return false + return canonicalOrAbsolute(open.parentFile ?: return false) == canonicalOrAbsolute(projectsRoot) +} + +private fun canonicalOrAbsolute(file: File): String = runCatching { file.canonicalPath }.getOrElse { file.absolutePath } + /** 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/activities/editor/SameProjectDeepLinkMidSyncTest.kt b/app/src/test/java/com/itsaky/androidide/activities/editor/SameProjectDeepLinkMidSyncTest.kt index 79e95d81a6..2c56402c27 100644 --- a/app/src/test/java/com/itsaky/androidide/activities/editor/SameProjectDeepLinkMidSyncTest.kt +++ b/app/src/test/java/com/itsaky/androidide/activities/editor/SameProjectDeepLinkMidSyncTest.kt @@ -21,6 +21,8 @@ import android.content.Intent import androidx.core.content.IntentCompat import com.google.common.truth.Truth.assertThat import com.itsaky.androidide.app.BaseApplication +import com.itsaky.androidide.databinding.ActivityEditorBinding +import com.itsaky.androidide.databinding.ContentEditorBinding import com.itsaky.androidide.deeplink.PendingDeepLinkOpen import com.itsaky.androidide.models.PendingFileRequest import com.itsaky.androidide.projects.IProjectManager @@ -32,6 +34,7 @@ import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.koin.core.context.GlobalContext import org.koin.core.context.GlobalContext.startKoin import org.koin.core.context.GlobalContext.stopKoin import org.koin.dsl.module @@ -65,14 +68,31 @@ class SameProjectDeepLinkMidSyncTest { // binding-torn-down branch is the one taken. Without a Koin context that branch threw // "KoinApplication has not been started" before the test could assert anything -- this test has // been failing on the branch for exactly that reason, independently of what it is meant to check. + private var startedKoin = false + + // switchToProject reads the Koin-provided PendingDeepLinkOpen on three of its four branches, so a + // Koin context has to exist. It may already: run in the full :app suite rather than alone, another + // test's application has started one, and startKoin would throw + // KoinApplicationAlreadyStartedException. Join the existing context in that case and leave it + // running for whoever owns it; only tear down a context this test started itself. @Before fun setUp() { - startKoin { modules(module { single { PendingDeepLinkOpen() } }) } + val binding = module { single { PendingDeepLinkOpen() } } + val existing = GlobalContext.getOrNull() + if (existing == null) { + startedKoin = true + startKoin { modules(binding) } + } else { + existing.loadModules(listOf(binding)) + } } @After fun tearDown() { - stopKoin() + if (startedKoin) { + stopKoin() + startedKoin = false + } unmockkAll() } @@ -91,9 +111,20 @@ class SameProjectDeepLinkMidSyncTest { Robolectric .buildActivity(EditorHandlerActivity::class.java, Intent()) .get() - // Non-null binding so switchToProject takes its same-project branch instead of the - // binding-torn-down handoff; nothing on the branch under test touches the views. - activity._binding = mockk(relaxed = true) + // Non-null binding AND a non-null `content` on it, so switchToProject takes its same-project + // branch instead of the binding-torn-down handoff; nothing on the branch under test touches the + // views themselves. + // + // `content` is set by reflection because view binding generates it as a public Java FIELD, and + // mockk stubs methods, not fields -- a relaxed mock therefore leaves it null, `contentOrNull` + // (which returns `_binding!!.content`) reads null, and the test silently exercised the + // binding-torn-down branch instead of the one it names. That is why it has been failing. + val activityBinding = mockk(relaxed = true) + ActivityEditorBinding::class.java + .getDeclaredField("content") + .apply { isAccessible = true } + .set(activityBinding, mockk(relaxed = true)) + activity._binding = activityBinding // What onNewIntent's carry-forward guard re-arms from the previous intent: the earlier, // still-unconsumed request. Without the fix, postProjectInit would find (and navigate to) diff --git a/shared/src/main/java/com/itsaky/androidide/models/Locations.kt b/shared/src/main/java/com/itsaky/androidide/models/Locations.kt index d122c862dc..f348e8ba13 100644 --- a/shared/src/main/java/com/itsaky/androidide/models/Locations.kt +++ b/shared/src/main/java/com/itsaky/androidide/models/Locations.kt @@ -119,8 +119,14 @@ open class Range @JvmStatic fun pointRange(position: Position): Range { // Two copies, not the same instance twice. Position's fields are `var` and - // EditorFeatures.validateRange clamps range.start and range.end in place, so a Range whose - // two ends alias one object has each clamp silently move the other end too. + // EditorFeatures.validateRange clamps start and end in place, so aliasing one object as + // both ends makes each clamp move the other end too. For a POINT range that is currently + // harmless -- the two ends hold equal values and the column clamp depends only on the + // already-clamped line, so both writes compute the same result. This copies anyway + // because that is a property of today's clamp, not of the type: any future validate that + // treats start and end asymmetrically (ordering them, or clamping end against start) + // would silently corrupt every point range. It also keeps the caller's Position out of + // the returned Range, which callers do mutate. return Range(position.copy(), position.copy()) } } From fd3b7bf4c7227928575a0405b30cc2dd0f1904df Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 1 Sep 2026 14:22:24 -0700 Subject: [PATCH 71/76] ADFA-5067: Fix the third review round, including four of my own misses The sentinel corruption is the one that mattered: Range.NONE is a process-wide @JvmField whose two ends are the single Position.NONE instance, and EditorFeatures.validateRange assigns line/column straight onto whatever objects it is handed. This class hands the sentinels out itself as `?: Range.NONE` / `?: Position.NONE` defaults, so clamping one rewrote Position.NONE from (-1, -1) to real coordinates for the rest of the process -- and since Position has structural equals, every later "nothing found" check against it (GoToDefinition, FindUsages, OrganizeImports, CodeFormatProvider) silently stopped matching. The previous round defended three call sites and left the sentinel itself, which is the thing every one of those paths funnels into. Guarded at the mutators instead: validateRange, Range.validate and Position.zeroIfNegative all no-op on the sentinels. Not by handing out fresh instances -- JavaCompilerService and CodeFormatProvider compare with reference `==`, so that would have broken those checks silently. The other three of my own: - The Range defensive copy reached two of the three entry points IEditorHandler advertises. openFileAndGetIndex still passed the caller's own object to CodeEditorView, whose pipeline clamps it in place -- and dereferenced a declared-nullable parameter with `!!`, so any caller honouring that nullability got a KotlinNullPointerException. - EXTRA_PREVIOUS_PROJECT_PATH did not work on the path it was added for. It read the live global, which MainActivity.openProject has already overwritten on the plain-switch path, so previous == new and the receiver still saw a same-project no-op. The staying path now travels on DeepLinkOpenRequest, which is where switchToProject already had the correct value. - findValidProjects(Environment.PROJECTS_DIR) was an unswept sibling of the NPE projectsRoot() exists to prevent, in the same file as a projectsRoot() call, and the one such site not wrapped in try/catch. RecentProjectsFragment's identical call is guarded and is left alone. - The onNewIntent consumed gate I added last round was itself a regression: it dedups by value and DeepLinkRequest carries no nonce, so deliberately re-tapping a URL was silently dropped -- permanently, for a link naming a project that did not exist when first tapped. The gate now applies only to BaseEditorActivity's programmatic re-forward, which is marked as such. The rest: - ConsumedRequests evicted the first-inserted entry, which is by construction the request on the task's launch Intent -- the one entry the class exists to remember, since that Intent is what Android replays after process death. Losing it force-reopened its project over whatever the user was doing. The launch entry is pinned, eviction takes the second-oldest, and a re-add now refreshes position so "oldest" tracks use. Its test asserted the old behaviour and is updated, with a second case for the refresh. - restoreIntentToStayingProject's capture guard existed at one of three call sites; the other two are reachable with no capture, because onNewIntent and switchToProject decide "is this a switch?" with predicates that disagree whenever a path reaches the same directory by a different string. Moved into the function, which is what keeps the three from drifting again. - onGradleBuildServiceConnected's build-in-progress return was a third un-swept early return that never reaches postProjectInit's drain, so a deep-link file request opened during a running build stayed armed and fired on some later unrelated sync. - A superseded confirm-open dialog was closed with dismiss(), which fires no listener, so its request was never recorded and a later recreate re-raised it. The dismiss in onDestroy deliberately still does not record: a config-change successor should re-raise a dialog nobody answered. - MAX_LINK_PATH_LENGTH was not a bound. 32 entries x 4096 chars x UTF-16 across three saved sets is ~768 KB against a ~1 MB Binder limit; 512 makes it ~96 KB, still far above any nameable project. - AppModule's single was unqualified and shared between the save scope and the Room database, so a second unqualified one would silently retarget the save. Named now. - SetupState documents that answering true bypasses Splash's storage and x86 checks and Onboarding's device checks entirely, so a check added there is not applied to links unless it is added here too. Known and deliberately not fixed here, because each wants a design decision rather than another point fix: - saveAllAsync on the app scope retains the activity, its binding and every editor buffer for the save's duration. The narrow fix is to move only the ~200-byte pendingDeepLinkOpen arming off the activity and leave the save on lifecycleScope -- a change to this method's contract with its callers. Documented at the call. - Three findings cluster on config-change recreate while the confirm-close dialog is up: the editor consumes the request before it is answered, the successor can commit a switch nobody confirmed via the already-mutated global, and closeProject arms the handoff before a finish() the recreate cancels. They share one cause -- per-instance switch state that does not survive a recreate -- and want the state machine looked at as a whole. :app:testV8DebugUnitTest --rerun-tasks: 421 tests, 0 failures. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GvLHuVNpCQLQhcnNiCSbxw --- .../androidide/activities/MainActivity.kt | 42 ++++++++--- .../androidide/activities/SetupState.kt | 9 +++ .../activities/editor/BaseEditorActivity.kt | 7 +- .../editor/EditorHandlerActivity.kt | 71 ++++++++++++++++--- .../editor/ProjectHandlerActivity.kt | 7 ++ .../androidide/deeplink/ConsumedRequests.kt | 21 ++++-- .../com/itsaky/androidide/di/AppModule.kt | 12 +++- .../androidide/models/DeepLinkRequest.kt | 24 ++++++- .../androidide/models/EditorIntentExtras.kt | 11 +++ .../deeplink/ConsumedRequestsTest.kt | 24 +++++-- .../androidide/editor/ui/EditorFeatures.kt | 13 +++- .../com/itsaky/androidide/models/Locations.kt | 18 ++++- 12 files changed, 225 insertions(+), 34 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 e3501b91b7..cd181b3f8a 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt @@ -414,7 +414,12 @@ class MainActivity : EdgeToEdgeIDEActivity() { if (!GeneralPreferences.autoOpenProjects) return lifecycleScope.launch(Dispatchers.IO) { - val validProjects = findValidProjects(Environment.PROJECTS_DIR) + // projectsRoot(), not the raw static: findValidProjects takes a non-null File, and + // Environment.PROJECTS_DIR is assigned by the same unawaited loader coroutine SetupState's + // KDoc describes -- and is not volatile. A null read here throws + // Intrinsics.checkNotNullParameter inside a coroutine with no handler. The sibling call in + // RecentProjectsFragment is wrapped in try/catch(Throwable); this one was not. + val validProjects = findValidProjects(projectsRoot()) val lastOpenedPath = GeneralPreferences.lastOpenedProject val projectToOpen = @@ -470,6 +475,10 @@ class MainActivity : EdgeToEdgeIDEActivity() { // onDestroy() to avoid leaking its window. private var activeOpenPermissionDialog: AlertDialog? = null + // The request activeOpenPermissionDialog was raised for, so superseding or destroying the dialog + // can record THAT request rather than whichever one happens to be latest at the time. + private var activeOpenPermissionDialogRequest: DeepLinkRequest? = null + // Whether activeOpenPermissionDialog (if any) came from a deep link -- see askProjectOpenPermission. private var activeOpenPermissionDialogIsDeepLink = false @@ -488,7 +497,17 @@ class MainActivity : EdgeToEdgeIDEActivity() { if (!isDeepLink && activeOpenPermissionDialogIsDeepLink && activeOpenPermissionDialog?.isShowing == true) { return } + // AlertDialog.dismiss() fires neither the negative button nor the OnCancel listener (and + // setCancelable(false) rules the latter out anyway), so the request the dialog being replaced + // was raised for would never be recorded. Its Intent is still the task's launch Intent, so any + // later recreate re-read it, found it unconsumed, and put the superseded dialog back up -- + // confirming it then switched the user to a project they had already moved past. Superseding a + // dialog IS an answer to it, so record it here. + if (activeOpenPermissionDialog?.isShowing == true) { + consumedDeepLinkRequests.add(activeOpenPermissionDialogRequest) + } activeOpenPermissionDialog?.dismiss() + activeOpenPermissionDialogRequest = deepLinkRequest activeOpenPermissionDialogIsDeepLink = isDeepLink val builder = DialogUtils.newMaterialDialogBuilder(this) builder.setTitle(string.title_confirm_open_project) @@ -577,13 +596,16 @@ class MainActivity : EdgeToEdgeIDEActivity() { setIntent(intent) IntentCompat .getParcelableExtra(intent, DeepLinkRequest.EXTRA_KEY, DeepLinkRequest::class.java) - // The same consumed gate onCreate applies. Without it an already-answered request comes - // back through this door: BaseEditorActivity.onCreate re-forwards the request here with - // CLEAR_TOP|SINGLE_TOP whenever deepLinkTargetsAnotherProject, and this activity is already - // in the back stack, so it arrives as onNewIntent. A request the user had declined then had - // its confirm dialog put straight back up. - ?.takeIf { it !in consumedDeepLinkRequests } - ?.let { handleDeepLinkRequest(it) } + // The consumed gate applies to a RE-FORWARD only. BaseEditorActivity bounces a request back + // here when a link names a project other than the one it holds, and without the gate an + // already-declined request had its dialog put straight back up. But gating every onNewIntent + // would be worse: DeepLinkRequest carries no nonce, so deliberately tapping the same URL + // again is equal by value to the earlier one, and an unconditional gate silently dropped it + // -- forever, for links naming a project that did not exist when first tapped. + ?.takeIf { + !intent.getBooleanExtra(EditorIntentExtras.EXTRA_REFORWARDED_DEEP_LINK, false) || + it !in consumedDeepLinkRequests + }?.let { handleDeepLinkRequest(it) } } /** @@ -638,6 +660,10 @@ class MainActivity : EdgeToEdgeIDEActivity() { override fun onDestroy() { webServer?.stop() ITemplateProvider.getInstance().release() + // Deliberately does NOT record the request consumed, unlike the supersession dismiss above. + // This dismiss is only about not leaking the window; if this is a config-change recreate the + // successor re-reads the launch Intent and should raise the dialog again, which is the + // behaviour a user who has answered nothing expects. activeOpenPermissionDialog?.dismiss() super.onDestroy() _binding = null diff --git a/app/src/main/java/com/itsaky/androidide/activities/SetupState.kt b/app/src/main/java/com/itsaky/androidide/activities/SetupState.kt index d136ec9ae7..ae26481dab 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/SetupState.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/SetupState.kt @@ -40,6 +40,15 @@ import java.io.File * Deliberately *not* the whole of what [SplashActivity] enforces -- free storage and the x86 exit are * its business, and a caller that finds this false should send the user there rather than re-deciding * any of it. + * + * The consequence, which is easy to miss: SplashActivity is reached only when this answers FALSE, so + * whenever it answers true a deep link goes straight to the editor and Splash's low-storage dialog + * and x86 `finishAffinity()` never run -- nor OnboardingActivity's primary-user, SD-card-install and + * device-supported checks. A device that finished onboarding and has since filled its storage is + * stopped by Splash on a normal launch and waved through by a link, landing in an editor whose Gradle + * build then fails for want of disk. That is the accepted trade (a link must not re-run onboarding), + * but it means any check added to Splash or Onboarding is silently NOT applied to links: a new one + * that must cover them has to be added here too, or hoisted somewhere both paths share. */ internal fun Context.isIdeSetupComplete(): Boolean = isJdkInstalled() && 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 8fb637e43e..9f6966ad6a 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 @@ -793,7 +793,12 @@ abstract class BaseEditorActivity : 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) } + deepLinkRequest?.let { + putExtra(DeepLinkRequest.EXTRA_KEY, it) + // Marks this as a re-delivery rather than a fresh tap, so MainActivity applies its + // consumed gate here and only here. + putExtra(EditorIntentExtras.EXTRA_REFORWARDED_DEEP_LINK, true) + } // 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 -> 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 3b81505549..7fe34fefce 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 @@ -61,6 +61,7 @@ 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.di.APPLICATION_SCOPE import com.itsaky.androidide.editor.language.treesitter.JavaLanguage import com.itsaky.androidide.editor.language.treesitter.JsonLanguage import com.itsaky.androidide.editor.language.treesitter.KotlinLanguage @@ -133,6 +134,7 @@ import org.adfa.constants.CONTENT_KEY import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import org.koin.android.ext.android.inject +import org.koin.core.qualifier.named import java.io.File import java.util.WeakHashMap import java.util.concurrent.ConcurrentHashMap @@ -188,7 +190,7 @@ open class EditorHandlerActivity : // The process-wide scope from AppModule, used only by saveAllAsync -- see there for why the // activity's own scope is not enough. - private val appScope: CoroutineScope by inject() + private val appScope: CoroutineScope by inject(named(APPLICATION_SCOPE)) private var pluginEditorProvider: EditorProviderImpl? = null @@ -406,8 +408,11 @@ open class EditorHandlerActivity : private fun performPendingDeepLinkOpen(pending: DeepLinkOpenRequest) { val root = File(pending.projectRoot) val ctx = applicationContext - // Read BEFORE the bookkeeping call below, which overwrites this global with the new path. - val previousProjectPath = IProjectManager.getInstance().projectDirPath + // The value carried on the request, NOT the live global. Reading the global here was wrong on + // the plain-switch path: MainActivity.openProject had already overwritten it with the new path + // before the intent arrived, so previous == new and the receiver saw a same-project no-op. + // Falling back to the global still helps the deep-link path, where nothing pre-mutates it. + val previousProjectPath = pending.previousProjectPath ?: IProjectManager.getInstance().projectDirPath if (!pending.bookkeepingAlreadyRecorded) { recordProjectOpenedBookkeeping(recentProjectRepository, root, project = null, analyticsManager = analyticsManager) } @@ -992,7 +997,14 @@ open class EditorHandlerActivity : log.info("Opening file at file index {} tab position {} file:{}", fileIndex, tabPosition, file) - val editor = CodeEditorView(this, file, selection!!) + // A copy, and a real Range rather than `!!`. CodeEditorView's async content-load pipeline calls + // selection.validate() and ideEditor.validateRange(selection), both of which clamp start/end in + // place -- on whatever object the CALLER owns. openFileAndSelect and openFile each copy before + // reaching here, but this is the third entry point IEditorHandler advertises and a caller can + // use it directly. The interface also declares `selection: Range?`, so `!!` turned a documented + // null into a KotlinNullPointerException for any caller honouring that nullability; no in-app + // caller passes null today, which is the only reason it had not fired. + val editor = CodeEditorView(this, file, selection?.let { Range(it) } ?: Range(Range.NONE)) editor.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) if (tabPosition >= totalTabs) { @@ -1090,6 +1102,14 @@ open class EditorHandlerActivity : // deep-link project switch exactly as if the guard that used to skip it were still there // (found in review). The application scope has no such window. The activity is retained for // the duration of the save, which is what NonCancellable already implied. + // + // That retention is real and deliberate: this lambda captures the activity, so a save still in + // flight after onDestroy holds the binding, the tabs and every editor buffer alive from an + // app-lifetime root -- tens of MB with many files open, and indefinitely if a write blocks on + // stuck SAF/FUSE storage. The narrower design is to move only what must outlive the activity + // (arming pendingDeepLinkOpen, a ~200-byte request) off the activity, and leave the save on + // lifecycleScope. That is a restructure of this method's contract with its callers, not a + // tweak, so it is left as a known cost rather than half-done here. appScope.launch(Dispatchers.IO) { // The whole body -- not just saveAll() -- runs NonCancellable. onDestroy() cancels the // activity's Job as soon as it runs; leaving NonCancellable partway through (e.g. @@ -2286,6 +2306,17 @@ open class EditorHandlerActivity : private var latestDeepLinkRequest: DeepLinkRequest? = null private fun restoreIntentToStayingProject() { + // Guarded here, not at the call sites. Only one of the three had this check, and the other two + // (cancelOrDecline and the save-failure branch) are reachable with no capture: onNewIntent and + // switchToProject answer "is this a switch?" with different predicates -- the former via + // isDeepLinkTargetOfOpenProject (normalised name plus canonicalised parent), the latter via raw + // string equality -- so they disagree whenever the open project's stored path reaches the same + // directory by another string (/sdcard vs /storage/emulated/0, a symlinked alias). With no + // capture taken, the `restore == null` arm below drains the staying project's own + // carried-forward file request, destroying a navigation it was never asked to touch. + if (!capturedPendingFileRequestBeforeSwitch) { + return + } // Reset unconditionally, before the blank-path bail below: a blank projectDirPath (e.g. a // post-process-death recreate with no PROJECT_PATH) must not leave these permanently set -- // every later switch's capture guard would otherwise stay false forever, silently losing the @@ -2612,10 +2643,10 @@ open class EditorHandlerActivity : // // latestDeepLinkRequest === request: a slow, failing link must not clear a capture // that a newer link's own decline path is still relying on. - if (!isFinishing && !isDestroyed && - capturedPendingFileRequestBeforeSwitch && - latestDeepLinkRequest === request - ) { + // The capture check now lives inside restoreIntentToStayingProject; what stays here is + // the supersession check, which is specific to this async path: a slow, failing link + // must not clear a capture a newer link's decline still depends on. + if (!isFinishing && !isDestroyed && latestDeepLinkRequest === request) { restoreIntentToStayingProject() } } @@ -2723,7 +2754,13 @@ open class EditorHandlerActivity : // 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, bookkeepingAlreadyRecorded) + pendingDeepLinkOpen.value = + DeepLinkOpenRequest( + newProjectPath, + fileRequest, + bookkeepingAlreadyRecorded, + previousProjectPath, + ) } // Either no project has actually finished initializing in this instance yet (e.g. it was @@ -2733,7 +2770,13 @@ open class EditorHandlerActivity : // 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, bookkeepingAlreadyRecorded) + pendingDeepLinkOpen.value = + DeepLinkOpenRequest( + newProjectPath, + fileRequest, + bookkeepingAlreadyRecorded, + previousProjectPath, + ) finish() } @@ -2791,7 +2834,13 @@ open class EditorHandlerActivity : // 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, bookkeepingAlreadyRecorded) + pendingDeepLinkOpen.value = + DeepLinkOpenRequest( + newProjectPath, + fileRequest, + bookkeepingAlreadyRecorded, + previousProjectPath, + ) } } } 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 926d6cb82e..26ed2966e3 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 @@ -701,6 +701,13 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { if (service.isToolingServerStarted()) { if (service.isBuildInProgress) { log.info("Skipping project initialization while build is in progress") + // The third early return that never reaches postProjectInit, and so never reaches its + // drain -- the same reason initializeProject's two failure returns drain. Cold-opening a + // project by deep link while a Gradle build is already running otherwise left the + // PendingFileRequest armed on the intent: the editor never navigated (only a log line), + // and the request then fired on the first unrelated later sync, yanking the editor to + // that stale file and line. + lifecycleScope.launch(Dispatchers.Main.immediate) { drainPendingFileRequest() } return } initializeProject() diff --git a/app/src/main/java/com/itsaky/androidide/deeplink/ConsumedRequests.kt b/app/src/main/java/com/itsaky/androidide/deeplink/ConsumedRequests.kt index 59ffa63ecc..b704212533 100644 --- a/app/src/main/java/com/itsaky/androidide/deeplink/ConsumedRequests.kt +++ b/app/src/main/java/com/itsaky/androidide/deeplink/ConsumedRequests.kt @@ -63,15 +63,28 @@ class ConsumedRequests { * Records [request] as acted on. Null is accepted and ignored: the caller's "latest request" can * legitimately be unset by the time a confirmation dialog is answered. * - * Oldest-first eviction past [MAX_REMEMBERED] keeps the saved Bundle bounded against a sender - * that fires links in a loop. The evicted case degrades to the old behaviour -- one spurious - * reopen of a link superseded 32 links ago -- which no real sequence reaches. + * Eviction past [MAX_REMEMBERED] keeps the saved Bundle bounded against a sender that fires links + * in a loop -- but it deliberately does NOT evict the first entry. LinkedHashSet iterates in + * insertion order, so `remove(first())` dropped the OLDEST, and the oldest is by construction the + * request on the task's launch Intent: the one entry this class exists to remember, since that is + * the Intent Android replays verbatim after process death. Evicting it force-reopened its project + * over whatever the user was doing, which is the regression this class was written to prevent. + * + * So the launch entry is pinned and eviction takes the second-oldest instead, and a re-add + * refreshes an entry's position so "oldest" tracks use rather than first sighting. */ fun add(request: T?) { request ?: return + // Re-insert so position tracks recency: `requests += request` leaves an existing element where + // it was, which made a repeatedly-seen request look like the least recent. + requests.remove(request) requests += request while (requests.size > MAX_REMEMBERED) { - requests.remove(requests.first()) + val iterator = requests.iterator() + iterator.next() // the launch-Intent entry, pinned + if (!iterator.hasNext()) break + iterator.next() + iterator.remove() } } 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 993bf4d48e..b1aece6ba5 100644 --- a/app/src/main/java/com/itsaky/androidide/di/AppModule.kt +++ b/app/src/main/java/com/itsaky/androidide/di/AppModule.kt @@ -17,8 +17,12 @@ import kotlinx.coroutines.SupervisorJob import org.koin.android.ext.koin.androidApplication import org.koin.android.ext.koin.androidContext import org.koin.core.module.dsl.viewModel +import org.koin.core.qualifier.named import org.koin.dsl.module +/** Qualifier for the process-lifetime [CoroutineScope]; see the binding for why it is named. */ +const val APPLICATION_SCOPE = "applicationScope" + val coreModule = module { single { FileActionManager() } @@ -32,12 +36,16 @@ val coreModule = viewModel { MainViewModel() } viewModel { CloneRepositoryViewModel(get(), get()) } - single { + // Named, because an unqualified single is claimed by type alone: this one + // instance was serving both the Room database below and EditorHandlerActivity's saveAllAsync, + // and a second unqualified CoroutineScope added anywhere would silently retarget the save with + // no compile error and no failing test. Consumers now ask for it by name. + single(named(APPLICATION_SCOPE)) { CoroutineScope(SupervisorJob() + Dispatchers.IO) } single { - RecentProjectRoomDatabase.getDatabase(androidApplication(), get()) + RecentProjectRoomDatabase.getDatabase(androidApplication(), get(named(APPLICATION_SCOPE))) } single { 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 11886f1a84..926afbe085 100644 --- a/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt +++ b/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt @@ -61,8 +61,18 @@ data class DeepLinkRequest( private val HOSTS = setOf("www.appdevforall.org", "appdevforall.org") private const val PATH_PREFIX = "/device/open/project/" - /** See [parse]: an upper bound on the whole path, since the parsed pieces get parcelled. */ - private const val MAX_LINK_PATH_LENGTH = 4096 + /** + * See [parse]: an upper bound on the whole path, since the parsed pieces get parcelled. + * + * Sized against the Binder budget rather than the filesystem. Up to + * `ConsumedRequests.MAX_REMEMBERED` (32) of these are held per set, Parcel writes UTF-16 + * (2 bytes per char plus a length), and BaseEditorActivity saves TWO such sets while + * MainActivity saves a third -- so the ceiling that matters is 32 x 3 x 2 bytes x this value. + * At 4096 that was ~768 KB against a ~1 MB transaction limit, which is not a bound at all. At + * 512 it is ~96 KB. Still far above any real project path: Linux caps a single name at 255 + * bytes, and a link longer than this cannot name a project that exists. + */ + private const val MAX_LINK_PATH_LENGTH = 512 private const val SEGMENT_PROJECT = "project" private const val SEGMENT_FILE = "file" @@ -237,4 +247,14 @@ data class DeepLinkOpenRequest( * for one user action. */ val bookkeepingAlreadyRecorded: Boolean = false, + /** + * The project that was open when this switch was requested. + * + * Captured at request time, because by the time the handoff is performed the live + * `IProjectManager` global no longer holds it: on the plain-switch path + * `MainActivity.openProject` overwrote it with the NEW path before the intent was even delivered. + * Reading the global there produced previous == new, which made the receiver treat a confirmed + * switch as a same-project no-op. + */ + val previousProjectPath: String? = null, ) : Parcelable diff --git a/app/src/main/java/com/itsaky/androidide/models/EditorIntentExtras.kt b/app/src/main/java/com/itsaky/androidide/models/EditorIntentExtras.kt index c56153575a..4030b60648 100644 --- a/app/src/main/java/com/itsaky/androidide/models/EditorIntentExtras.kt +++ b/app/src/main/java/com/itsaky/androidide/models/EditorIntentExtras.kt @@ -40,6 +40,17 @@ object EditorIntentExtras { */ const val EXTRA_PREVIOUS_PROJECT_PATH = "PREVIOUS_PROJECT_PATH" + /** + * Set only on the Intent [com.itsaky.androidide.activities.editor.BaseEditorActivity] sends back to + * MainActivity when a deep link names a project other than the one it holds. + * + * That is a programmatic re-delivery of a request the user tapped once, so MainActivity must apply + * its consumed-requests gate to it. A link arriving without this flag is a fresh tap -- possibly of + * the very same URL, which carries no nonce and is therefore equal by value to an earlier one -- + * and must be acted on, not silently swallowed as a duplicate. + */ + const val EXTRA_REFORWARDED_DEEP_LINK = "REFORWARDED_DEEP_LINK" + /** Set when the project was created from a template that reported issues. */ const val EXTRA_HAS_TEMPLATE_ISSUES = "HAS_TEMPLATE_ISSUES" } diff --git a/app/src/test/java/com/itsaky/androidide/deeplink/ConsumedRequestsTest.kt b/app/src/test/java/com/itsaky/androidide/deeplink/ConsumedRequestsTest.kt index d9e4b86bf1..10259552dc 100644 --- a/app/src/test/java/com/itsaky/androidide/deeplink/ConsumedRequestsTest.kt +++ b/app/src/test/java/com/itsaky/androidide/deeplink/ConsumedRequestsTest.kt @@ -104,14 +104,30 @@ class ConsumedRequestsTest { // The cap bounds the saved Bundle; what it must not do is forget the most recent requests. @Test - fun `past the cap the oldest is evicted and the newest kept`() { + fun `past the cap the launch entry is pinned and the second-oldest evicted`() { val consumed = ConsumedRequests() repeat(40) { consumed.add(request("project$it")) } assertThat(consumed.toSavedList()).hasSize(32) - assertThat(request("project0") in consumed).isFalse() - assertThat(request("project7") in consumed).isFalse() - assertThat(request("project8") in consumed).isTrue() + // project0 is the first ever added, which is by construction the request on the task's launch + // Intent -- the one Android replays verbatim after process death, and the only one whose loss + // force-reopens a project over whatever the user was doing. It survives; eviction takes the + // second-oldest instead. This used to assert the opposite. + assertThat(request("project0") in consumed).isTrue() + assertThat(request("project8") in consumed).isFalse() + assertThat(request("project9") in consumed).isTrue() assertThat(request("project39") in consumed).isTrue() } + + @Test + fun `re-adding a request refreshes its position rather than leaving it oldest`() { + val consumed = ConsumedRequests() + repeat(32) { consumed.add(request("project$it")) } + // Touch the second-oldest; it must no longer be the eviction candidate. + consumed.add(request("project1")) + consumed.add(request("fresh")) + + assertThat(request("project1") in consumed).isTrue() + assertThat(request("project2") in consumed).isFalse() + } } diff --git a/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorFeatures.kt b/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorFeatures.kt index 5a0d016c48..d941fd5ac8 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorFeatures.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorFeatures.kt @@ -88,6 +88,17 @@ class EditorFeatures( } ?: Position.NONE override fun validateRange(range: Range) { + // The shared sentinels are never clamped. Range.NONE is a process-wide @JvmField whose two ends + // are the single Position.NONE instance, and the assignments below write line/column straight + // onto the caller's objects -- so clamping one here rewrites Position.NONE from (-1, -1) to real + // in-document coordinates for the rest of the process. Position has structural equals, so every + // later "nothing found" check comparing against Range.NONE / Position.NONE (GoToDefinition, + // FindUsages, OrganizeImports, CodeFormatProvider) would silently stop matching. Callers reach + // here with them legitimately: this very class hands them out as `?: Range.NONE` and + // `?: Position.NONE` defaults. + if (range === Range.NONE || range.start === Position.NONE || range.end === Position.NONE) { + return + } withEditor { val start = range.start val end = range.end @@ -140,7 +151,7 @@ class EditorFeatures( ): Boolean = withEditor { val columnCount = text.getColumnCount(line) - return@withEditor column >= 0 && (column < columnCount || allowColumnEqual && column == columnCount) + return@withEditor column >= 0 && (column < columnCount || (allowColumnEqual && column == columnCount)) } ?: false override fun append(text: CharSequence?): Int = diff --git a/shared/src/main/java/com/itsaky/androidide/models/Locations.kt b/shared/src/main/java/com/itsaky/androidide/models/Locations.kt index f348e8ba13..5d12d32f05 100644 --- a/shared/src/main/java/com/itsaky/androidide/models/Locations.kt +++ b/shared/src/main/java/com/itsaky/androidide/models/Locations.kt @@ -39,8 +39,18 @@ data class Position return index } - /** Makes the indices 0 if they are negative. */ + /** + * Makes the indices 0 if they are negative. + * + * A no-op on [NONE]. That is a process-wide `@JvmField` singleton whose whole meaning is + * (-1, -1), handed out freely as a "nothing here" default -- zeroing it in place would redefine + * the sentinel for every structural comparison in the process from then on. + */ fun zeroIfNegative() { + if (this === NONE) { + return + } + if (line < 0) { line = 0 } @@ -136,6 +146,12 @@ open class Range * @see Position.zeroIfNegative() */ fun validate() { + // NONE's two ends ARE the single Position.NONE instance, so validating the sentinel would + // redefine it process-wide. zeroIfNegative guards itself for the same reason; this guard is + // stated separately because this is the call the editor pipeline actually makes. + if (this === NONE) { + return + } start.zeroIfNegative() end.zeroIfNegative() } From 6d643486a41796c63370a601b80e28ad0be520dd Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 1 Sep 2026 17:38:08 -0700 Subject: [PATCH 72/76] ADFA-5067: Pin the launch-Intent entry by value, not by slot ConsumedRequests protected the request on the task's launch Intent -- the one Android replays verbatim after process death -- by having the eviction loop skip slot 0. But add() unconditionally removed and re-appended every request it saw so that "oldest" tracked use rather than first sighting, which slid the launch entry off slot 0 as soon as it was re-added while anything else was in the set. Its protection then covered whichever unrelated request had taken its place, and a sender firing links in a loop -- DeepLinkActivity is exported, which is the threat model this cap exists for -- could evict the launch entry and force its project open over whatever the user was doing on the next process-death recreate. Hold the pin in a field instead. add() skips the recency re-insert for the pinned entry, evictExcess() chooses the oldest victim that is not pinned, and remove() releases the pin so the next add() re-establishes it. restore() also re-applies MAX_REMEMBERED, which it did not before: it addAll()'d whatever it was handed, so an over-long list came back oversized and went straight into the next onSaveInstanceState, defeating the Bundle bound the cap is for. The three new pin tests each need a second entry present before the launch entry is re-added. Written without one they passed against the unfixed code -- with the set holding nothing else, remove-then-append puts the entry straight back on slot 0 and the positional pin still covered it. Claude-Session: https://claude.ai/code/session_01LuyR4ajssmKw1o1UQsjJbP --- .../androidide/deeplink/ConsumedRequests.kt | 72 ++++++++++++++----- .../deeplink/ConsumedRequestsTest.kt | 62 ++++++++++++++++ 2 files changed, 117 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/deeplink/ConsumedRequests.kt b/app/src/main/java/com/itsaky/androidide/deeplink/ConsumedRequests.kt index b704212533..7c8e114c65 100644 --- a/app/src/main/java/com/itsaky/androidide/deeplink/ConsumedRequests.kt +++ b/app/src/main/java/com/itsaky/androidide/deeplink/ConsumedRequests.kt @@ -39,13 +39,34 @@ import android.os.Parcelable class ConsumedRequests { private val requests = LinkedHashSet() - /** For `onSaveInstanceState`; pairs with [restore]. */ - fun toSavedList(): ArrayList = ArrayList(requests) + /** + * The launch-Intent entry: the first request this set ever recorded, and the one Android replays + * verbatim after process death. Held by value rather than by position because [add] reorders the + * set to track recency -- a re-add used to slide this entry off slot 0, leaving the positional + * pin in [add]'s eviction loop protecting whichever arbitrary request happened to land there. + */ + private var pinned: T? = null + + /** For `onSaveInstanceState`; pairs with [restore]. [pinned] leads, so [restore] can re-establish it. */ + fun toSavedList(): ArrayList { + val saved = ArrayList(requests.size) + pinned?.let(saved::add) + requests.filterTo(saved) { it != pinned } + return saved + } - /** Replaces the contents with [saved], which is null when there is no instance state to restore. */ + /** + * Replaces the contents with [saved], which is null when there is no instance state to restore. + * + * [MAX_REMEMBERED] is re-applied here: the cap exists to bound the saved Bundle against a sender + * firing links in a loop, and restoring an over-long list unchecked would carry an oversized set + * straight back into the next `onSaveInstanceState`. + */ fun restore(saved: List?) { requests.clear() + pinned = saved?.firstOrNull() saved?.let(requests::addAll) + evictExcess() } operator fun contains(request: T): Boolean = request in requests @@ -57,6 +78,12 @@ class ConsumedRequests { */ fun remove(request: T) { requests.remove(request) + // Dropping the pinned entry releases the pin too, so the next add() re-establishes it rather + // than leaving a pin on a request no longer in the set -- which would exempt nothing and let + // eviction reach the real launch entry again. + if (pinned == request) { + pinned = null + } } /** @@ -64,27 +91,38 @@ class ConsumedRequests { * legitimately be unset by the time a confirmation dialog is answered. * * Eviction past [MAX_REMEMBERED] keeps the saved Bundle bounded against a sender that fires links - * in a loop -- but it deliberately does NOT evict the first entry. LinkedHashSet iterates in - * insertion order, so `remove(first())` dropped the OLDEST, and the oldest is by construction the - * request on the task's launch Intent: the one entry this class exists to remember, since that is - * the Intent Android replays verbatim after process death. Evicting it force-reopened its project - * over whatever the user was doing, which is the regression this class was written to prevent. + * in a loop -- but it deliberately does NOT evict [pinned], the request on the task's launch + * Intent. That is the one entry this class exists to remember, since it is the Intent Android + * replays verbatim after process death; evicting it force-reopened its project over whatever the + * user was doing, the regression this class was written to prevent. * - * So the launch entry is pinned and eviction takes the second-oldest instead, and a re-add - * refreshes an entry's position so "oldest" tracks use rather than first sighting. + * A re-add refreshes an entry's position so "oldest" tracks use rather than first sighting -- + * except for [pinned], which is left where it is. Reordering it was the bug: the pin used to be + * positional (slot 0), so re-tapping the same URL, or [remove] followed by [add] on a re-armed + * request, slid the launch entry down and handed its protection to an unrelated request. */ fun add(request: T?) { request ?: return + if (pinned == null) { + pinned = request + } // Re-insert so position tracks recency: `requests += request` leaves an existing element where - // it was, which made a repeatedly-seen request look like the least recent. - requests.remove(request) + // it was, which made a repeatedly-seen request look like the least recent. Skipped for the + // pinned entry, whose position no longer carries meaning and must stay stable for [toSavedList]. + if (request != pinned) { + requests.remove(request) + } requests += request + evictExcess() + } + + /** Drops the oldest non-[pinned] entries until the set is back within [MAX_REMEMBERED]. */ + private fun evictExcess() { while (requests.size > MAX_REMEMBERED) { - val iterator = requests.iterator() - iterator.next() // the launch-Intent entry, pinned - if (!iterator.hasNext()) break - iterator.next() - iterator.remove() + // firstOrNull, not first(): a set of nothing but the pinned entry has no eligible victim, + // and looping forever on it would hang whichever lifecycle callback got here. + val victim = requests.firstOrNull { it != pinned } ?: return + requests.remove(victim) } } diff --git a/app/src/test/java/com/itsaky/androidide/deeplink/ConsumedRequestsTest.kt b/app/src/test/java/com/itsaky/androidide/deeplink/ConsumedRequestsTest.kt index 10259552dc..295e124f87 100644 --- a/app/src/test/java/com/itsaky/androidide/deeplink/ConsumedRequestsTest.kt +++ b/app/src/test/java/com/itsaky/androidide/deeplink/ConsumedRequestsTest.kt @@ -130,4 +130,66 @@ class ConsumedRequestsTest { assertThat(request("project1") in consumed).isTrue() assertThat(request("project2") in consumed).isFalse() } + + // The pin used to be positional -- the eviction loop simply skipped slot 0 -- while add() + // reordered every entry it saw. Re-tapping the same URL therefore slid the launch entry off + // slot 0 and handed its protection to an unrelated request, so a sender firing links in a loop + // (DeepLinkActivity is exported) could evict it and force its project open after process death. + // Each of these needs a SECOND entry present before the launch entry is re-added. With the set + // holding nothing else, remove-then-append puts the launch entry straight back on slot 0 and the + // old positional pin still covered it -- so a version of these tests without "other" passed + // against the unfixed code and pinned nothing. + @Test + fun `re-adding the launch entry does not surrender its pin`() { + val consumed = ConsumedRequests() + consumed.add(request("project0")) + consumed.add(request("other")) + // The user taps the launch link a second time -- nothing gates a non-reforwarded repeat. + // The positional pin moved project0 off slot 0 here, handing its protection to "other". + consumed.add(request("project0")) + repeat(40) { consumed.add(request("flood$it")) } + + assertThat(consumed.toSavedList()).hasSize(32) + assertThat(request("project0") in consumed).isTrue() + } + + @Test + fun `an interleaved remove and re-add still leaves the launch entry pinned`() { + val consumed = ConsumedRequests() + consumed.add(request("project0")) + consumed.add(request("other")) + // armPendingFileRequest's deliberate re-arm: forget it, then record it again. + consumed.remove(request("project0")) + consumed.add(request("project0")) + repeat(40) { consumed.add(request("flood$it")) } + + assertThat(request("project0") in consumed).isTrue() + } + + @Test + fun `the pin survives a save and restore`() { + val consumed = ConsumedRequests() + consumed.add(request("project0")) + consumed.add(request("other")) + consumed.add(request("project0")) + + val restored = ConsumedRequests() + restored.restore(consumed.toSavedList()) + repeat(40) { restored.add(request("flood$it")) } + + assertThat(request("project0") in restored).isTrue() + } + + // restore() used to addAll() unchecked, so an over-long list came back oversized and went + // straight into the next onSaveInstanceState -- the Bundle bound the cap exists to enforce. + @Test + fun `restore re-applies the cap`() { + val oversized = (0 until 40).map { request("project$it") } + + val restored = ConsumedRequests() + restored.restore(oversized) + + assertThat(restored.toSavedList()).hasSize(32) + assertThat(request("project0") in restored).isTrue() + } } From e8bd9049d47188275a10dd9eb864dc31f19ac851 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 1 Sep 2026 17:38:47 -0700 Subject: [PATCH 73/76] ADFA-5067: Stop losing (and mis-committing) a confirmed project switch Four defects that all trace back to the same shift: saveAllAsync moved to the process-wide appScope so its continuation outlives the activity, while the close/switch decision state stayed per-instance and the hand-off was only armed at onDestroy. The continuation reliably survived into a world where nothing could read what it had decided. EditorActivityKt does not declare uiMode, locale, density, keyboard or navigation in configChanges, so an automatic dark-mode switch recreates it mid-dialog and reaches all of this. Fail-open unsaved-file guard. getEditorForFile() resolves through contentOrNull and returns null for EVERY file once the binding is gone, so hasFilesThatFailedToSave() reported "nothing failed" for a whole set of genuinely modified buffers. Callers use it to decide whether it is safe to close, discard or commit -- GitBottomSheetFragment gates commit/pull/push on it -- so an unknowable answer must read as unsafe. Falls back to the retained ViewModel's areFilesModified, which is only ever recomputed while the binding is alive and so holds the last state actually observed. The ViewModel-backed check this replaced failed closed; this one had inverted that. Hand-off stranded by a cancelled finish(). closeProject() armed the process-wide PendingDeepLinkOpen and then deferred its finish() into a lifecycleScope coroutine that ON_DESTROY cancels. A destroy that beat the finish() left isFinishing false, so onDestroy skipped its drain and a confirmed switch sat in a Koin single until it fired against some later, unrelated project close. Replace the isFinishing/didCompleteLiveOnCreate pair -- two different questions, both wrong at the edges -- with ownership: arm(owner) / drainArmedBy(owner), so an instance performs its own hand-off and only its own. didCompleteLiveOnCreate was standing in for "did I arm this?", which is now asked directly. Switch committed mid-save. closeDialogAnswered went true the instant "Save and close" was tapped, so a finish() arriving while files were still being written let onDestroy invoke the close callback and skip the !saveSucceeded || hasFilesThatFailedToSave() abort entirely -- abandoning the user's edits with no message. Add closeCommitted, true only once the close is actually being performed, and decide the save outcome first, before any teardown branching. That check is only answerable during teardown because of the guard fix above. Switch lost on a config-change recreate. The !isFinishing && isDestroyed branch returned and left the close callback "for the successor instance", but nothing handed it over: pendingCloseCallback is per-instance, the hand-off was never armed, and onNewIntent had already recorded the request consumed and stripped it from the intent, with the consumed mark persisted. The successor could not learn a switch had been confirmed and a re-tap was gated out as value-equal, so the switch was lost permanently and silently. Arm and drain in place instead; the successor may show the old project briefly before the new one replaces it, which beats dropping a switch the user confirmed. Un-confirmed project persisted across a recreate. onDestroy dismissed the confirm-close dialog, and dismiss() dispatches neither the negative button nor the OnCancelListener, so a dialog still on screen at destroy time died with its decline handling never run -- leaving the intent and the process-wide bookkeeping pointing at a switch the user never confirmed. cancel() would dispatch it, but cancelOrDecline() can recursively show a fresh dialog for a superseding request, which must not happen from onDestroy; declineInFlightProjectClose() is its rollback half. That alone was not enough. onSaveInstanceState runs BEFORE onDestroy and wrote KEY_PROJECT_PATH from the live IProjectManager global, which MainActivity's bookkeeping has already moved to the INCOMING project before the intent is even delivered -- so the Bundle named the project the user had not agreed to open, and the successor loaded it against a retained ViewModel still holding the previous project's tabs. The saved path now comes from an open projectPathForInstanceState, which EditorHandlerActivity overrides with the staying-project snapshot it already keeps for the decline path. Claude-Session: https://claude.ai/code/session_01LuyR4ajssmKw1o1UQsjJbP --- .../activities/editor/BaseEditorActivity.kt | 15 +- .../editor/EditorHandlerActivity.kt | 263 +++++++++++++----- .../deeplink/PendingDeepLinkOpen.kt | 40 ++- 3 files changed, 245 insertions(+), 73 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 9f6966ad6a..d1957709da 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 @@ -1135,8 +1135,21 @@ abstract class BaseEditorActivity : postDestroy() } + /** + * The project path [onSaveInstanceState] persists, and therefore the project a recreate reopens. + * + * Open because the live [IProjectManager] global is not always the right answer: while a project + * switch is proposed but not yet confirmed, `MainActivity.openProject`'s bookkeeping has already + * moved that global to the *incoming* project, so saving it would hand the successor a project + * the user has not agreed to open -- against a retained ViewModel still holding the previous + * project's tabs and buffers. [EditorHandlerActivity] overrides this to name the project that is + * actually staying open (ADFA-5067 review). + */ + protected open val projectPathForInstanceState: String + get() = IProjectManager.getInstance().projectDirPath + override fun onSaveInstanceState(outState: Bundle) { - outState.putString(KEY_PROJECT_PATH, IProjectManager.getInstance().projectDirPath) + outState.putString(KEY_PROJECT_PATH, projectPathForInstanceState) // See consumedDeepLinkRequests' docs: a post-process-death recreate is handed the parceled // intent with already-drained extras still on it, and these are what stop them re-firing. outState.putParcelableArrayList(KEY_CONSUMED_DEEP_LINK_REQUESTS, consumedDeepLinkRequests.toSavedList()) 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 7fe34fefce..51ac32b11e 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 @@ -442,15 +442,12 @@ open class EditorHandlerActivity : ) } - // Drains pendingDeepLinkOpen (if armed) and performs its hand-off -- shared by onDestroy() (the + // Drains the hand-off THIS instance armed (if any) and performs it -- 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 + // onDestroy() already ran (the race case) -- so the one-shot "check, clear, 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) - } + pendingDeepLinkOpen.drainArmedBy(handoffOwner)?.let(::performPendingDeepLinkOpen) } override fun onDestroy() { @@ -458,7 +455,24 @@ open class EditorHandlerActivity : 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() + // + // dismiss() alone was not enough: it tears the window down WITHOUT dispatching the negative + // button or the OnCancelListener, so a dialog still on screen at destroy time died with its + // decline handling never run -- leaving the intent and the process-wide bookkeeping pointing + // at a switch the user never confirmed (ADFA-5067 review). cancel() would dispatch it, but + // cancelOrDecline() can recursively show a fresh dialog for a superseding request, which is + // exactly what must not happen from here; declineInFlightProjectClose() is its rollback half. + // + // The Bundle is handled separately: onSaveInstanceState runs BEFORE onDestroy, so by now it + // has already been written -- see projectPathForInstanceState for the half of this fix that + // keeps the un-confirmed project out of it in the first place. + activeProjectCloseDialog?.let { dialog -> + if (dialog.isShowing && !closeDialogAnswered) { + declineInFlightProjectClose() + } + dialog.dismiss() + } + activeProjectCloseDialog = null // Both gated on isFinishing: onDestroy() also runs for a non-finishing recreate (a config // change EditorActivityKt's own configChanges doesn't cover - dark mode, locale, display size @@ -470,39 +484,43 @@ open class EditorHandlerActivity : // Save and close) both route through closeProject(), whose deferred finish() must land before // onDestroy() can run, so isFinishing is true here by the time onDestroy() drains it. if (isFinishing) { - // The callback drain is additionally gated on closeDialogAnswered: isFinishing alone is - // also true when the task is swiped out of Recents while the dialog is still showing -- - // an armed-but-unanswered pendingCloseCallback then means the user bailed, and running it + // The callback drain is additionally gated on closeCommitted: isFinishing alone is also + // true when the task is swiped out of Recents while the dialog is still showing -- an + // armed-but-uncommitted pendingCloseCallback then means the user bailed, and running it // here would startActivity into a project switch they never confirmed (see the field's // docs). When the flag IS set, this drain still matters: 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. - if (closeDialogAnswered) { + // + // closeCommitted, NOT closeDialogAnswered: the latter went true the instant "Save and + // close" was tapped, so a finish() landing while the save was still writing made this + // line commit the switch and skip the save-failure abort entirely, abandoning the user's + // edits with no message (ADFA-5067 review). Committing is now the save's own decision. + if (closeCommitted) { 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 -- - // 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. - // - // didCompleteLiveOnCreate too, for the same reason preDestroy/postDestroy here, - // ProjectHandlerActivity's teardown hooks and BaseEditorActivity's all carry it -- and it - // matters most of all on this line, because pendingDeepLinkOpen is a Koin `single`, more - // widely shared than any registry those guards protect. An instance that took - // BaseEditorActivity.onCreate's deepLinkTargetsAnotherProject bail (startActivity + - // finish() + return) never sets the flag, yet reaches here with isFinishing true; without - // this it drains a handoff a DIFFERENT, still-live instance armed, firing startActivity - // while that instance is alive -- precisely the race deferring to onDestroy exists to avoid. - if (didCompleteLiveOnCreate) { - drainPendingDeepLinkOpen() - } } + + // Drain any deep-link-triggered "close then reopen a different project" request this instance + // recorded. 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. + // + // Outside the isFinishing guard, and keyed on ownership rather than didCompleteLiveOnCreate. + // isFinishing was the wrong question: closeProject() arms the hand-off and then defers its + // finish() into a lifecycleScope coroutine that ON_DESTROY cancels, so a destroy that beat + // the finish() left a confirmed switch sitting in this Koin `single` -- to fire later against + // an unrelated project close, since nothing else clears it. A config-change recreate landing + // mid-save reached here the same way. drainArmedBy answers "did I arm this?" directly, which + // is what didCompleteLiveOnCreate was standing in for: an instance that took + // BaseEditorActivity.onCreate's deepLinkTargetsAnotherProject bail armed nothing, so it now + // drains nothing rather than stealing a live instance's hand-off. + drainPendingDeepLinkOpen() } override fun onResume() { @@ -1427,10 +1445,25 @@ open class EditorHandlerActivity : * 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(files: List = editorViewModel.getOpenedFiles()) = - files.any { file -> + private fun hasFilesThatFailedToSave(files: List = editorViewModel.getOpenedFiles()): Boolean { + // Fail closed once the view binding is gone. [getEditorForFile] resolves through + // contentOrNull and returns null for EVERY file the moment it is null, so the per-file test + // below would report "nothing failed" for a whole set of genuinely modified buffers. Every + // caller uses this to decide whether it is safe to close, discard or commit, so an answer + // that cannot be computed must read as "unsafe" -- the direction the ViewModel-backed + // areFilesModified this replaced happened to fail in. + // + // areFilesModified is the fallback rather than a bare `true` because it is retained across + // activity recreation and is only ever recomputed while the binding is alive (see + // saveResult's contentOrNull-guarded block), so it holds the last state actually observed + // rather than a stale-by-construction guess. It is coarser -- one flag for all open files, + // not per-file -- which can over-report for a narrowed [files] set; over-reporting blocks a + // close, under-reporting loses the buffer. + contentOrNull ?: return editorViewModel.areFilesModified + return files.any { file -> getEditorForFile(file)?.isModified == true && file.extension.lowercase() !in ARCHIVE_EXTENSIONS } + } /** * Runs [action] with the "files are saving" flag raised. @@ -2277,6 +2310,23 @@ open class EditorHandlerActivity : // fresh dialog is shown; a cancel/decline never sets it. private var closeDialogAnswered = false + // True once the close is actually being PERFORMED, which is a later moment than + // closeDialogAnswered for "Save and close": that option only commits after its save comes back + // clean, so between the tap and the save resolving the close is answered but not yet committed. + // onDestroy() gates its pendingCloseCallback drain on this, because a finish() arriving inside + // that window used to commit the switch on the answered flag alone and skip the + // `!saveSucceeded || hasFilesThatFailedToSave()` abort entirely -- discarding the user's unsaved + // edits with nothing shown (ADFA-5067 review). "Close without saving" sets it immediately: there + // is no write to wait on and closeProject() runs in the same breath. Reset with the dialog. + private var closeCommitted = false + + // Identity token for this instance's entry in the process-wide PendingDeepLinkOpen, so an + // instance only ever drains the hand-off it armed itself. A plain Any() rather than `this`: the + // token is stored in a Koin `single` that outlives every activity, and parking an Activity + // reference there -- even one only ever compared by identity -- is the kind of thing that turns + // into a leak the first time someone dereferences it. + private val handoffOwner = Any() + // 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 @@ -2305,6 +2355,29 @@ open class EditorHandlerActivity : // MainActivity.latestDeepLinkRequest's identical race on the cold-open path. private var latestDeepLinkRequest: DeepLinkRequest? = null + // While a switch is proposed but unconfirmed, IProjectManager's global already names the INCOMING + // project (MainActivity.openProject's bookkeeping runs before the intent is even delivered), so + // the base implementation would persist a project the user has not agreed to open -- and + // onSaveInstanceState runs before onDestroy, so onDestroy's decline rollback below cannot undo it. + // The snapshot is null except in exactly that window, so this is the global everywhere else. + override val projectPathForInstanceState: String + get() = stayingProjectPathBeforeSwitch ?: super.projectPathForInstanceState + + // The state-rollback half of confirmProjectClose's cancelOrDecline, without its other half -- + // re-confirming a superseding request, which shows a NEW dialog and must never run from + // onDestroy(), where the window it would attach to is already going away. + private fun declineInFlightProjectClose() { + confirmCloseInProgress = false + val abandoned = pendingCloseCallback + pendingCloseCallback = null + // Only a switch (onClosed != null) put a foreign PROJECT_PATH on the intent and moved the + // process-wide bookkeeping; a plain manual close has nothing to roll back, and touching the + // intent for one would corrupt whatever legitimate pending state it holds. + if (abandoned != null) { + restoreIntentToStayingProject() + } + } + private fun restoreIntentToStayingProject() { // Guarded here, not at the call sites. Only one of the three had this check, and the other two // (cancelOrDecline and the save-failure branch) are reachable with no capture: onNewIntent and @@ -2367,6 +2440,7 @@ open class EditorHandlerActivity : confirmCloseInProgress = true pendingCloseCallback = onClosed closeDialogAnswered = false + closeCommitted = false val builder = newMaterialDialogBuilder(this) builder.setTitle(string.title_confirm_project_close) @@ -2410,6 +2484,10 @@ open class EditorHandlerActivity : builder.setNeutralButton(string.close_without_saving) { dialog, _ -> dialog.dismiss() closeDialogAnswered = true + // Committed in the same breath: there is no write to wait on, and closeProject() runs + // below unconditionally. Only "Save and close" has a window between answered and + // committed. + closeCommitted = true for (i in 0 until editorViewModel.getOpenedFileCount()) { (content.editorContainer.getChildAt(i) as? CodeEditorView)?.editor?.markUnmodified() @@ -2434,23 +2512,86 @@ open class EditorHandlerActivity : runOnUiThread { confirmCloseInProgress = false + // The save outcome is decided FIRST, before any teardown branching. It used to be + // checked only after the two returns below, so a finish() or a config-change + // recreate landing while the write was still in flight committed the close without + // ever consulting it -- abandoning the user's unsaved edits with nothing shown + // (ADFA-5067 review). A failed write must abort the close on every path. + // + // Answerable during teardown now that hasFilesThatFailedToSave() falls back to the + // retained ViewModel's areFilesModified once the binding is gone; it previously + // resolved every file through the binding and so reported "nothing failed" for a + // whole set of unwritten buffers. + if (!saveSucceeded || hasFilesThatFailedToSave()) { + // closeCommitted stays false, so onDestroy's drain leaves the callback alone. + val superseding = pendingCloseCallback + pendingCloseCallback = null + if (isFinishing || isDestroyed) { + // No window for a Flashbar and no instance left to re-confirm on. The + // buffers are still on disk unchanged and the switch simply does not + // happen. Logged because the branch is otherwise invisible from the UI. + log.warn( + "Save failed during teardown (isFinishing={}, isDestroyed={}); abandoning the " + + "confirmed close rather than switching projects over unwritten changes.", + isFinishing, + isDestroyed, + ) + return@runOnUiThread + } + // 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. + 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 + } + + // The save landed, so the close is now genuinely committed and onDestroy may act + // on the callback if this instance is torn down before the branches below finish. + closeCommitted = true + // Teardown: no window for a message, and no point re-confirming a superseded request // on an instance that is going away -- but the handoff below is process-wide state - // that a new instance drains, so it must still happen. Mirrors the contentOrNull == + // that gets drained by owner, so it must still happen. Mirrors the contentOrNull == // null branch further down, which exists for the same reason. // isDestroyed WITHOUT isFinishing is not teardown at all: it is a config-change // recreate (dark mode, locale, density -- none in EditorActivityKt's configChanges), // where a successor instance for this same project is already coming up. This // continuation survives it only because saveAllAsync now runs on the process-wide - // appScope rather than lifecycleScope. Running the close callback here would arm a - // switch on behalf of an instance being replaced, and draining it would startActivity - // into that switch while the successor is on screen -- so this case is left alone for - // the successor to own. onDestroy's own drain is gated the same way, on isFinishing. + // appScope rather than lifecycleScope. + // + // This used to return here and "leave the close callback for the successor + // instance" -- but nothing handed it over: pendingCloseCallback is a per-instance + // field on the dying instance, the hand-off was never armed, and onNewIntent had + // already recorded the deep-link request consumed and stripped it from the intent, + // with the consumed mark persisted through onSaveInstanceState. The successor + // therefore had no way to learn a switch had been confirmed, and re-tapping the + // same URL was gated out as value-equal, so the switch was lost permanently and + // silently (ADFA-5067 review). Arm and drain it here instead. The successor may + // flash up on the old project for a moment before the new one replaces it; that is + // a strictly better outcome than dropping a switch the user explicitly confirmed. if (!isFinishing && isDestroyed) { log.info( - "Save completed during a config-change recreate, not a teardown; leaving the " + - "close callback for the successor instance rather than acting on it here.", + "Save completed during a config-change recreate; performing the confirmed close " + + "here rather than leaving it for a successor that cannot recover it.", ) + val onClosedDuringRecreate = pendingCloseCallback + pendingCloseCallback = null + onClosedDuringRecreate?.invoke() + // onDestroy has already run for this instance (isDestroyed), so its own + // ownership-keyed drain is behind us and nothing else will fire this. + drainPendingDeepLinkOpen() return@runOnUiThread } if (isFinishing) { @@ -2471,32 +2612,6 @@ open class EditorHandlerActivity : if (isDestroyed) drainPendingDeepLinkOpen() 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. - // !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()) { - // 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) - } 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 - } recentProjectsViewModel.updateProjectModifiedDate( editorViewModel.getProjectName(), ) @@ -2754,13 +2869,15 @@ open class EditorHandlerActivity : // 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 = + pendingDeepLinkOpen.arm( + handoffOwner, DeepLinkOpenRequest( newProjectPath, fileRequest, bookkeepingAlreadyRecorded, previousProjectPath, - ) + ), + ) } // Either no project has actually finished initializing in this instance yet (e.g. it was @@ -2770,13 +2887,15 @@ open class EditorHandlerActivity : // 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 = + pendingDeepLinkOpen.arm( + handoffOwner, DeepLinkOpenRequest( newProjectPath, fileRequest, bookkeepingAlreadyRecorded, previousProjectPath, - ) + ), + ) finish() } @@ -2834,13 +2953,15 @@ open class EditorHandlerActivity : // 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 = + pendingDeepLinkOpen.arm( + handoffOwner, DeepLinkOpenRequest( newProjectPath, fileRequest, bookkeepingAlreadyRecorded, previousProjectPath, - ) + ), + ) } } } 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 ea30236301..ef82fa390b 100644 --- a/app/src/main/java/com/itsaky/androidide/deeplink/PendingDeepLinkOpen.kt +++ b/app/src/main/java/com/itsaky/androidide/deeplink/PendingDeepLinkOpen.kt @@ -33,5 +33,43 @@ import com.itsaky.androidide.models.DeepLinkOpenRequest */ internal class PendingDeepLinkOpen { @Volatile - var value: DeepLinkOpenRequest? = null + private var request: DeepLinkOpenRequest? = null + + /** + * The activity instance that armed [request], compared by identity only and never dereferenced, + * so holding it here cannot keep a destroyed activity alive in any way that matters -- the + * reference is dropped the moment the hand-off is drained or superseded. + */ + @Volatile + private var owner: Any? = null + + /** Records the hand-off [owner] confirmed, replacing any earlier one. */ + fun arm( + owner: Any, + request: DeepLinkOpenRequest, + ) { + this.owner = owner + this.request = request + } + + /** + * Hands back and clears the request [owner] armed, or null if it armed none -- so an instance + * only ever performs its own hand-off. + * + * The ownership test replaces the `isFinishing && didCompleteLiveOnCreate` pair this used to be + * gated on, which asked two different questions and got both wrong at the edges. isFinishing was + * false whenever the deferred `finish()` never ran (its `lifecycleScope` coroutine cancelled at + * ON_DESTROY, or a config-change recreate landing mid-save), so a confirmed switch was stranded + * in this process-wide `single` and fired later against an unrelated project close. + * didCompleteLiveOnCreate was standing in for "did I arm this?" -- which is now asked directly. + */ + fun drainArmedBy(owner: Any): DeepLinkOpenRequest? { + if (this.owner !== owner) { + return null + } + val drained = request + request = null + this.owner = null + return drained + } } From bdca56e7fa02e25afa10fa55e3071d2be5e24abf Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 1 Sep 2026 17:58:51 -0700 Subject: [PATCH 74/76] ADFA-5067: Stop a link dying permanently on a failed or unverifiable resolve Two ways a deep link could go silently dead forever, both in the consumption bookkeeping. A transient filesystem failure was recorded as "no such project". resolveWithinDirectory maps everything that is not Contained to null, so ContainedPathResolver's Resolution.Unverifiable -- an IOException the resolver models explicitly as "not an escape, refused because unproven" -- arrived at findValidProjectByName indistinguishable from a genuine miss. An EACCES right after a storage-permission change, or an EIO on a flaky SD/FUSE mount, therefore told the user "No project named X was found" about a project that plainly exists, and MainActivity then recorded the request consumed on the stated reasoning that the project does not exist -- so the identical URL was a silent no-op on every later delivery. The SecurityException path had the same problem. Add lookupValidProjectByName, returning Found/NotFound/Unverifiable, and have resolveDeepLinkProject return the matching tri-state instead of File?. Only a definitive NotFound is recorded consumed. Unverifiable now reports the scan-failed message rather than "no project named X": telling someone a project they can see in the projects list does not exist is worse than admitting the lookup failed. An Unverifiable from one Unicode normal form does not mask a Found from another -- it is remembered and only returned if no candidate form resolves. findValidProjectByName stays, reduced to a null-or-directory view for the callers that cannot act on the difference. A fresh tap was mistaken for a programmatic re-delivery. The re-forward gate dropped any request already in consumedDeepLinkRequests. It exists to stop a bounce loop -- MainActivity opens a project, the editor decides the link names a different one and bounces it back, and the dialog goes straight back up -- but it could not tell that loop from a genuinely new tap that happens to be re-forwarded, and DeepLinkRequest carries no nonce, so a repeat tap is equal by value to the earlier one. Tapping a link for a project that does not exist yet, creating it, then tapping again was dropped with no dialog, no error and no log, on that and every subsequent tap -- which is the flow the feature exists for. Track the failed-resolve consumptions separately in unresolvedDeepLinkRequests and exempt them from the gate. A request that never resolved never reached the editor, so no bounce can originate from it and the loop cannot come back. The set is persisted alongside consumedDeepLinkRequests, or the same sequence across a process death lands in the identical hole, and a request is dropped from it as soon as it is retried, so a later success stops the exemption. The Unverifiable branch has no automated coverage: provoking a real EACCES/EIO from the filesystem mid-call is not something a JVM unit test can do reliably. The new tests pin the two outcomes that are reachable, plus that findValidProjectByName still agrees with the lookup it now delegates to. Claude-Session: https://claude.ai/code/session_01LuyR4ajssmKw1o1UQsjJbP --- .../androidide/activities/MainActivity.kt | 49 +++++++++++-- .../editor/EditorHandlerActivity.kt | 7 +- .../utils/DeepLinkProjectResolution.kt | 70 ++++++++++++++----- .../androidide/utils/ProjectValidations.kt | 69 +++++++++++++++--- .../utils/ProjectValidationsTest.kt | 45 ++++++++++++ 5 files changed, 207 insertions(+), 33 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 cd181b3f8a..947ab07bf8 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt @@ -61,6 +61,7 @@ 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.DeepLinkProjectLookup import com.itsaky.androidide.utils.DialogUtils import com.itsaky.androidide.utils.Environment import com.itsaky.androidide.utils.FeatureFlags @@ -120,6 +121,22 @@ class MainActivity : EdgeToEdgeIDEActivity() { // was not enough. private val consumedDeepLinkRequests = ConsumedRequests() + // The subset of consumedDeepLinkRequests recorded because the project could not be resolved, + // rather than because anything was actually opened. + // + // The two have to be told apart by onNewIntent's re-forward gate. That gate exists to stop a + // bounce loop: this activity opens a project, the editor decides the link names a different one + // and bounces it straight back, and without the gate its dialog goes right back up. But a request + // that failed to resolve never reached the editor at all, so no bounce can originate from it -- + // and DeepLinkRequest carries no nonce, so a genuinely new tap of the same URL is equal by value + // to the failed one. Tapping a link for a project that does not exist yet, creating it, and + // tapping again therefore died silently, forever, which is exactly the teacher-sends-a-student-a- + // link flow the feature is for (ADFA-5067 review). + // + // Persisted alongside consumedDeepLinkRequests: without that, the same tap-create-tap sequence + // across a process death lands back in the identical hole. + private val unresolvedDeepLinkRequests = ConsumedRequests() + private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { @@ -161,6 +178,11 @@ class MainActivity : EdgeToEdgeIDEActivity() { BundleCompat.getParcelableArrayList(it, KEY_CONSUMED_DEEP_LINK_REQUESTS, DeepLinkRequest::class.java) }, ) + unresolvedDeepLinkRequests.restore( + savedInstanceState?.let { + BundleCompat.getParcelableArrayList(it, KEY_UNRESOLVED_DEEP_LINK_REQUESTS, 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 @@ -604,7 +626,11 @@ class MainActivity : EdgeToEdgeIDEActivity() { // -- forever, for links naming a project that did not exist when first tapped. ?.takeIf { !intent.getBooleanExtra(EditorIntentExtras.EXTRA_REFORWARDED_DEEP_LINK, false) || - it !in consumedDeepLinkRequests + it !in consumedDeepLinkRequests || + // A request consumed only because its project could not be resolved never reached + // the editor, so this bounce cannot be the loop the gate guards against -- it is a + // fresh tap that happens to be value-equal to the earlier failure. Let it retry. + it in unresolvedDeepLinkRequests }?.let { handleDeepLinkRequest(it) } } @@ -624,25 +650,38 @@ class MainActivity : EdgeToEdgeIDEActivity() { * project open with no user interaction at all. `DeepLinkTargetsNotExportedTest` pins that. */ private fun handleDeepLinkRequest(request: DeepLinkRequest) { + // This attempt supersedes any earlier unresolved one for the same request. If it fails to + // resolve again the failure branch re-records it; if it succeeds the request must stop being + // exempt from the re-forward gate, or the bounce loop that gate exists to stop could resume. + unresolvedDeepLinkRequests.remove(request) latestDeepLinkRequest = request lifecycleScope.launch(Dispatchers.IO) { - val projectDir = resolveDeepLinkProject(projectsRoot(), request.projectName) + val lookup = resolveDeepLinkProject(projectsRoot(), request.projectName) withContext(Dispatchers.Main) { // 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 - if (projectDir == null) { + if (lookup !is DeepLinkProjectLookup.Found) { // Consumed even though nothing opened: the project does not exist, so retrying on // every recreate only re-shows "No project named X was found" indefinitely. Recorded // only when this request is still the current one, so a superseded slow resolve // cannot consume the newer request's slot. - if (latestDeepLinkRequest === request) { + // + // NotFound only. An Unverifiable result -- an EACCES straight after a + // storage-permission change, an EIO on a flaky SD/FUSE mount -- says nothing about + // whether the project exists, and recording it made a momentary filesystem failure + // silence a valid link on every later delivery (ADFA-5067 review). + if (lookup is DeepLinkProjectLookup.NotFound && latestDeepLinkRequest === request) { consumedDeepLinkRequests.add(request) + // Tracked apart from the general consumed set so the re-forward gate in + // onNewIntent can let this request through again -- see the field's docs. + unresolvedDeepLinkRequests.add(request) } return@withContext } + val projectDir = lookup.projectDir // A second, faster-resolving deep link superseded this one while it was still resolving // -- 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. @@ -672,9 +711,11 @@ class MainActivity : EdgeToEdgeIDEActivity() { override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putParcelableArrayList(KEY_CONSUMED_DEEP_LINK_REQUESTS, consumedDeepLinkRequests.toSavedList()) + outState.putParcelableArrayList(KEY_UNRESOLVED_DEEP_LINK_REQUESTS, unresolvedDeepLinkRequests.toSavedList()) } companion object { private const val KEY_CONSUMED_DEEP_LINK_REQUESTS = "consumedDeepLinkRequests" + private const val KEY_UNRESOLVED_DEEP_LINK_REQUESTS = "unresolvedDeepLinkRequests" } } 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 51ac32b11e..f392cb8037 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 @@ -108,6 +108,7 @@ 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.DeepLinkProjectLookup import com.itsaky.androidide.utils.DialogUtils.newMaterialDialogBuilder import com.itsaky.androidide.utils.DialogUtils.showConfirmationDialog import com.itsaky.androidide.utils.EditorActivityActions @@ -2737,8 +2738,8 @@ open class EditorHandlerActivity : latestDeepLinkRequest = request lifecycleScope.launch(Dispatchers.IO) { - val projectDir = resolveDeepLinkProject(projectsRoot(), request.projectName) - if (projectDir == null) { + val lookup = resolveDeepLinkProject(projectsRoot(), request.projectName) + if (lookup !is DeepLinkProjectLookup.Found) { // No such project, so the switch this intent announced is never going to happen. Without // this the capture above is stranded: setIntent() has already dropped the staying // project's pending file request, nothing puts it back, and @@ -2776,7 +2777,7 @@ open class EditorHandlerActivity : // 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) + switchToProject(lookup.projectDir.absolutePath, request.fileRequest) } } } 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 29a0d4dcc7..c4cae1661f 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/DeepLinkProjectResolution.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/DeepLinkProjectResolution.kt @@ -27,11 +27,29 @@ import java.io.File private val log = LoggerFactory.getLogger("DeepLinkProjectResolution") +/** + * The outcome of [resolveDeepLinkProject]. Keeps "no such project" apart from "could not tell", + * which a bare `File?` collapsed: callers record a definitive absence so the link stops re-reporting + * itself on every recreate, and recording an [Unverifiable] the same way made a momentary + * filesystem failure kill a perfectly valid link permanently (ADFA-5067 review). + */ +sealed interface DeepLinkProjectLookup { + data class Found( + val projectDir: File, + ) : DeepLinkProjectLookup + + /** No project of that name exists. Definitive, so callers may remember it. */ + data object NotFound : DeepLinkProjectLookup + + /** The lookup failed for a reason unrelated to the project's existence. Remember nothing. */ + data object Unverifiable : DeepLinkProjectLookup +} + /** * 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. + * reporting every failure to the user via `flashError` on the main thread. The caller can return on + * anything but [DeepLinkProjectLookup.Found] -- each failure case has already shown its own message + * -- but must consult *which* failure it was before recording the request as dealt with. * * Call from a background dispatcher (e.g. `Dispatchers.IO`); this only switches to * [Dispatchers.Main] itself for the user-facing error messages. @@ -39,28 +57,44 @@ private val log = LoggerFactory.getLogger("DeepLinkProjectResolution") suspend fun Activity.resolveDeepLinkProject( projectsRoot: File, projectName: String, -): File? { - val projectDir = +): DeepLinkProjectLookup { + val lookup = try { - findValidProjectByName(projectsRoot, projectName) + lookupValidProjectByName(projectsRoot, projectName) } catch (e: CancellationException) { throw e } catch (e: SecurityException) { log.error("Failed to scan {} for deep link", projectsRoot, e) - 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 + flashOnMain(getString(string.msg_deeplink_scan_failed)) + // A denied scan says nothing about whether the project is there. + return DeepLinkProjectLookup.Unverifiable } - if (projectDir == null) { - withContext(Dispatchers.Main) { - if (!isFinishing && !isDestroyed) { - flashError(getString(string.msg_deeplink_project_not_found, projectName)) - } + return when (lookup) { + is ProjectNameLookup.Found -> { + DeepLinkProjectLookup.Found(lookup.dir) } + + is ProjectNameLookup.Unverifiable -> { + log.error("Could not determine whether project {} exists under {}", projectName, projectsRoot, lookup.cause) + // Deliberately the scan-failed message, not "no project named X": telling the user that a + // project they can see in the projects list does not exist is worse than saying the + // lookup failed. + flashOnMain(getString(string.msg_deeplink_scan_failed)) + DeepLinkProjectLookup.Unverifiable + } + + ProjectNameLookup.NotFound -> { + flashOnMain(getString(string.msg_deeplink_project_not_found, projectName)) + DeepLinkProjectLookup.NotFound + } + } +} + +private suspend fun Activity.flashOnMain(message: String) { + 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(message) } - return projectDir } 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 c474bd6d29..10db33199a 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.io.IOException import java.text.Normalizer import kotlin.collections.filter import kotlin.collections.orEmpty @@ -22,6 +23,32 @@ internal fun findValidProjects(projectsRoot: File): List { return subdirs.filter { dir -> isValidProjectDirectory(dir) } } +/** + * The outcome of [lookupValidProjectByName], which unlike a bare `File?` keeps "no project by that + * name" apart from "whether one exists could not be determined". + * + * Callers act on the two very differently: a definitive absence is worth remembering (so a link + * naming a project that does not exist stops re-reporting itself on every recreate), while an + * unverifiable result says nothing at all about the project and must leave every such decision + * untouched. + */ +internal sealed interface ProjectNameLookup { + data class Found( + val dir: File, + ) : ProjectNameLookup + + /** No project of that name exists under the projects root. */ + data object NotFound : ProjectNameLookup + + /** + * A filesystem failure other than absence (EACCES right after a storage-permission change, EIO + * on a flaky SD/FUSE mount) stopped the lookup from reaching an answer. + */ + data class Unverifiable( + val cause: IOException, + ) : ProjectNameLookup +} + /** * 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 @@ -31,20 +58,23 @@ internal fun findValidProjects(projectsRoot: File): List { * [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"`). + * + * Reports *why* it found nothing -- see [ProjectNameLookup]; [findValidProjectByName] is this + * reduced to a nullable directory for callers that cannot act on the difference. */ -internal fun findValidProjectByName( +internal fun lookupValidProjectByName( projectsRoot: File, name: String, -): File? { +): ProjectNameLookup { // 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 + return ProjectNameLookup.NotFound } - if (!projectsRoot.isProjectCandidateDir()) return null + if (!projectsRoot.isProjectCandidateDir()) return ProjectNameLookup.NotFound // 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 @@ -52,15 +82,38 @@ internal fun findValidProjectByName( // 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)) + // Remembered rather than returned on the spot: a later candidate form may still resolve cleanly, + // and a definite Found has to win over an earlier form's transient IO failure. + var unverifiable: IOException? = null + val resolver = ContainedPathResolver(projectsRoot) for (candidateName in candidateNames) { - val candidate = resolveWithinDirectory(projectsRoot, candidateName) ?: continue - if (candidate.isProjectCandidateDir() && isValidProjectDirectory(candidate)) { - return candidate + when (val resolution = resolver.resolve(candidateName)) { + is ContainedPathResolver.Resolution.Contained -> { + val candidate = resolution.file + if (candidate.isProjectCandidateDir() && isValidProjectDirectory(candidate)) { + return ProjectNameLookup.Found(candidate) + } + } + + is ContainedPathResolver.Resolution.Unverifiable -> { + unverifiable = resolution.cause + } + + // A traversal attempt is a definitive "not this project", not an unknown. + is ContainedPathResolver.Resolution.Rejected -> { + Unit + } } } - return null + return unverifiable?.let(ProjectNameLookup::Unverifiable) ?: ProjectNameLookup.NotFound } +/** [lookupValidProjectByName] reduced to the project directory, or null for any other outcome. */ +internal fun findValidProjectByName( + projectsRoot: File, + name: String, +): File? = (lookupValidProjectByName(projectsRoot, name) as? ProjectNameLookup.Found)?.dir + /** * 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 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 5b588ec272..02ce033d3b 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/ProjectValidationsTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/ProjectValidationsTest.kt @@ -102,4 +102,49 @@ class ProjectValidationsTest { assertThat(findValidProjectByName(root, "..")).isNull() } + + // The tri-state lookup findValidProjectByName now delegates to (ADFA-5067 review). These pin the + // two outcomes a unit test can actually produce; Unverifiable needs a real EACCES/EIO from the + // filesystem mid-call, which is not reliably provokable in a JVM test -- see the class docs. + @Test + fun `lookup reports Found for an existing project`() { + val root = tempFolder.newFolder("projects") + val project = makeValidProject(root, "MyApp") + + val lookup = lookupValidProjectByName(root, "MyApp") + + assertThat(lookup).isInstanceOf(ProjectNameLookup.Found::class.java) + assertThat((lookup as ProjectNameLookup.Found).dir.canonicalFile).isEqualTo(project.canonicalFile) + } + + @Test + fun `lookup reports NotFound for a name with no project`() { + val root = tempFolder.newFolder("projects") + + assertThat(lookupValidProjectByName(root, "DoesNotExist")).isEqualTo(ProjectNameLookup.NotFound) + } + + // A traversal attempt is a definite "not this project", not an unknown -- callers are allowed to + // remember a NotFound, and must not be handed something they have to treat as maybe-transient. + @Test + fun `lookup reports NotFound for a traversal attempt`() { + val root = tempFolder.newFolder("projects") + + assertThat(lookupValidProjectByName(root, "../etc")).isEqualTo(ProjectNameLookup.NotFound) + assertThat(lookupValidProjectByName(root, ".")).isEqualTo(ProjectNameLookup.NotFound) + assertThat(lookupValidProjectByName(root, "")).isEqualTo(ProjectNameLookup.NotFound) + } + + // findValidProjectByName is now a thin reduction of lookupValidProjectByName; this pins that the + // refactor did not change what the many existing callers see. + @Test + fun `findValidProjectByName still agrees with the lookup it delegates to`() { + val root = tempFolder.newFolder("projects") + makeValidProject(root, "MyApp") + + for (name in listOf("MyApp", "DoesNotExist", "../etc", ".", "")) { + val expected = (lookupValidProjectByName(root, name) as? ProjectNameLookup.Found)?.dir + assertThat(findValidProjectByName(root, name)).isEqualTo(expected) + } + } } From 151d540f35a0c6a4f63801e1f9ddb95cc666ebc7 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 1 Sep 2026 18:31:47 -0700 Subject: [PATCH 75/76] ADFA-5067: Record deep-link arrivals and outcomes Nothing on the device recorded that a deep link had happened. Every bug this feature shipped with looked identical from the outside -- a link that silently did nothing -- and there was no way to tell one from another, or to notice one at all. Adds a DeepLinkMetric through the existing IAnalyticsManager rather than touching Firebase directly: the manager already wraps FirebaseAnalytics behind a consent gate, and trackBuildRun/trackBuildCompleted already show the Metric pattern, so trackDeepLink is a default interface method and AnalyticsManager itself needs no new code. One event name for every outcome, so the funnel is a single event filtered by `outcome` rather than a set of events that have to be summed. Emitted once as RECEIVED when a link is accepted, and again with whatever terminal outcome it reaches, so a link that is accepted and then goes nowhere shows up as a gap between the two rather than as silence. `depth` records how far down project/file/line/column the link actually reached. PROJECT_NOT_FOUND and PROJECT_UNVERIFIABLE are deliberately separate, matching the split the resolve code now makes: the first means published links naming projects people do not have, the second means storage trouble on the device. The project name is hashed, never sent -- it is the user's content, and a link can carry a file path too. Matches trackProjectOpened's project_hash, which also makes the two joinable without either carrying the name off the device. A link with no project omits the key rather than logging zero, so it cannot be mistaken for a project whose name happens to hash to zero. Also guards trackMetric. Reaching the lazy `analytics` initializes FirebaseAnalytics, which throws outright when the default FirebaseApp was never initialized in this process -- DeepLinkSetupGateTest went red on exactly that the moment the first metric call was added to DeepLinkActivity. That activity is exported and logs before it does anything else, so an uninitialized Firebase would have turned every incoming link into a crash. Measuring a feature must not be able to break it, so the failure is swallowed and logged at the one choke point instead of each call site guarding for itself; this covers the existing build metrics too, which had the same latent exposure. Claude-Session: https://claude.ai/code/session_01LuyR4ajssmKw1o1UQsjJbP --- .../androidide/activities/DeepLinkActivity.kt | 20 ++++ .../androidide/activities/MainActivity.kt | 17 ++++ .../androidide/analytics/AnalyticsManager.kt | 18 +++- .../androidide/analytics/DeepLinkMetric.kt | 95 +++++++++++++++++++ .../analytics/DeepLinkMetricTest.kt | 90 ++++++++++++++++++ 5 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/itsaky/androidide/analytics/DeepLinkMetric.kt create mode 100644 app/src/test/java/com/itsaky/androidide/analytics/DeepLinkMetricTest.kt 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 b8cd4373fb..b5ead707b0 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt @@ -22,9 +22,15 @@ import android.content.Intent import android.os.Bundle import android.widget.Toast import com.itsaky.androidide.activities.editor.EditorActivityKt +import com.itsaky.androidide.analytics.DeepLinkDepth +import com.itsaky.androidide.analytics.DeepLinkMetric +import com.itsaky.androidide.analytics.DeepLinkOutcome +import com.itsaky.androidide.analytics.IAnalyticsManager +import com.itsaky.androidide.analytics.depth import com.itsaky.androidide.api.ActionContextProvider import com.itsaky.androidide.models.DeepLinkRequest import com.itsaky.androidide.resources.R.string +import org.koin.android.ext.android.inject /** * The sole `` holder for `https://appdevforall.org/device/open/project/...` (and the @@ -38,6 +44,8 @@ import com.itsaky.androidide.resources.R.string * since it never calls `setContentView` and has no theming needs of its own. */ class DeepLinkActivity : Activity() { + private val analyticsManager: IAnalyticsManager by inject() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -52,6 +60,10 @@ class DeepLinkActivity : Activity() { // user is told, and sent to the normal entry point, which decides what they actually need -- // storage, ABI and onboarding are SplashActivity's to enforce, not this activity's to repeat. if (!isIdeSetupComplete()) { + // Depth is UNKNOWN rather than parsed: the readiness gate deliberately runs before the + // URI is looked at, and reordering it just to enrich a metric would put parsing ahead of + // the check that exists to stop this activity acting on anything at all. + analyticsManager.trackDeepLink(DeepLinkMetric(DeepLinkDepth.UNKNOWN, DeepLinkOutcome.SETUP_INCOMPLETE)) Toast.makeText(this, getString(string.msg_deeplink_setup_incomplete), Toast.LENGTH_LONG).show() // FLAG_ACTIVITY_NEW_TASK, matching the success branch below. A sender that starts this // trampoline without it -- an in-app WebView host, another app's explicit intent, `am start` @@ -69,6 +81,9 @@ class DeepLinkActivity : Activity() { val request = DeepLinkRequest.parse(intent?.data) if (request == null) { + // Counted, not just shown: this activity is exported, so a rise here is as likely to be + // another app poking it with an arbitrary Uri as it is a broken published link. + analyticsManager.trackDeepLink(DeepLinkMetric(DeepLinkDepth.UNKNOWN, DeepLinkOutcome.INVALID_LINK)) // 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() @@ -76,6 +91,11 @@ class DeepLinkActivity : Activity() { return } + // The one place every accepted link passes through, whichever activity ends up handling it. + // Paired with a terminal outcome logged wherever the request is finally resolved, so a link + // that is accepted here and then quietly goes nowhere shows up as a gap between the two. + analyticsManager.trackDeepLink(DeepLinkMetric(request.depth(), DeepLinkOutcome.RECEIVED, request.projectName)) + // 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, 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 947ab07bf8..04220129e7 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt @@ -37,7 +37,10 @@ import com.itsaky.androidide.FeedbackButtonManager import com.itsaky.androidide.R import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.activities.editor.EditorActivityKt +import com.itsaky.androidide.analytics.DeepLinkMetric +import com.itsaky.androidide.analytics.DeepLinkOutcome import com.itsaky.androidide.analytics.IAnalyticsManager +import com.itsaky.androidide.analytics.depth import com.itsaky.androidide.app.EdgeToEdgeIDEActivity import com.itsaky.androidide.databinding.ActivityMainBinding import com.itsaky.androidide.deeplink.ConsumedRequests @@ -664,6 +667,20 @@ class MainActivity : EdgeToEdgeIDEActivity() { // dying window. if (isFinishing || isDestroyed) return@withContext if (lookup !is DeepLinkProjectLookup.Found) { + // The two are deliberately distinct events, matching the code's own split: a rise in + // PROJECT_NOT_FOUND means published links naming projects people do not have, while a + // rise in PROJECT_UNVERIFIABLE means storage trouble on the device. + analyticsManager.trackDeepLink( + DeepLinkMetric( + request.depth(), + if (lookup is DeepLinkProjectLookup.NotFound) { + DeepLinkOutcome.PROJECT_NOT_FOUND + } else { + DeepLinkOutcome.PROJECT_UNVERIFIABLE + }, + request.projectName, + ), + ) // Consumed even though nothing opened: the project does not exist, so retrying on // every recreate only re-shows "No project named X was found" indefinitely. Recorded // only when this request is still the current one, so a superseded slow resolve diff --git a/app/src/main/java/com/itsaky/androidide/analytics/AnalyticsManager.kt b/app/src/main/java/com/itsaky/androidide/analytics/AnalyticsManager.kt index 3a252cab94..4f7eae9306 100644 --- a/app/src/main/java/com/itsaky/androidide/analytics/AnalyticsManager.kt +++ b/app/src/main/java/com/itsaky/androidide/analytics/AnalyticsManager.kt @@ -7,6 +7,7 @@ import com.google.firebase.ktx.Firebase import com.itsaky.androidide.analytics.gradle.BuildCompletedMetric import com.itsaky.androidide.analytics.gradle.BuildStartedMetric import com.itsaky.androidide.analytics.gradle.StrategySelectedMetric +import org.slf4j.LoggerFactory import java.util.concurrent.TimeUnit interface IAnalyticsManager { @@ -40,6 +41,8 @@ interface IAnalyticsManager { fun trackBuildCompleted(metric: BuildCompletedMetric) = trackMetric(metric) + fun trackDeepLink(metric: DeepLinkMetric) = trackMetric(metric) + fun trackMetric(metric: Metric) } @@ -159,6 +162,19 @@ class AnalyticsManager : IAnalyticsManager { val bundle = metric.asBundle() bundle.putLong("timestamp", System.currentTimeMillis()) - analytics.logEvent(metric.eventName, bundle) + // Reaching `analytics` initializes FirebaseAnalytics, which throws outright when the default + // FirebaseApp was never initialized in this process. Measuring a feature must never be able + // to break it: DeepLinkActivity is exported and logs before it does anything else, so an + // uninitialized Firebase would turn every incoming link into a crash rather than a lost + // event. Swallowed at this one choke point so no metric call site has to guard for itself. + try { + analytics.logEvent(metric.eventName, bundle) + } catch (e: IllegalStateException) { + log.warn("Dropping metric {}: analytics unavailable.", metric.eventName, e) + } + } + + private companion object { + private val log = LoggerFactory.getLogger(AnalyticsManager::class.java) } } diff --git a/app/src/main/java/com/itsaky/androidide/analytics/DeepLinkMetric.kt b/app/src/main/java/com/itsaky/androidide/analytics/DeepLinkMetric.kt new file mode 100644 index 0000000000..16c8bed7e0 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/analytics/DeepLinkMetric.kt @@ -0,0 +1,95 @@ +/* + * 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.analytics + +import android.os.Bundle +import com.itsaky.androidide.models.DeepLinkRequest + +/** + * How specific an incoming deep link was -- how far down + * `project/{name}/file/{f}/line/{n}/column/{n}` it actually reached. + */ +enum class DeepLinkDepth { + /** Not yet parsed, or not a link this app understands. */ + UNKNOWN, + PROJECT, + FILE, + LINE, + COLUMN, +} + +/** What an incoming deep link ended up doing. */ +enum class DeepLinkOutcome { + /** Parsed and accepted, before anything was resolved. Pairs with a later terminal outcome. */ + RECEIVED, + + /** Dropped because the IDE has not finished onboarding -- see [DeepLinkOutcome] call site. */ + SETUP_INCOMPLETE, + + /** The URI did not parse as a link this app understands. */ + INVALID_LINK, + + /** No project of that name exists. */ + PROJECT_NOT_FOUND, + + /** + * Whether the project exists could not be determined (a filesystem failure unrelated to its + * existence). Kept apart from [PROJECT_NOT_FOUND] for the same reason the code does: a spike + * here means storage trouble, not people mistyping project names. + */ + PROJECT_UNVERIFIABLE, +} + +/** + * A deep link arrived (ADFA-5067). Emitted once with [DeepLinkOutcome.RECEIVED] when a link is + * accepted, and again with whatever terminal outcome it reached, so the drop-off between the two is + * visible -- every bug this feature shipped with looked identical from the outside (a link that + * silently did nothing), and nothing on the device recorded that it had happened at all. + */ +class DeepLinkMetric( + private val depth: DeepLinkDepth, + private val outcome: DeepLinkOutcome, + private val projectName: String? = null, +) : Metric { + override val eventName: String = EVENT_NAME + + override fun asBundle(): Bundle = + Bundle().apply { + putString("depth", depth.name.lowercase()) + putString("outcome", outcome.name.lowercase()) + // Hashed, never the raw name -- a project name is the user's content, and a link can + // carry a file path too. Matches trackProjectOpened's project_hash, which also makes the + // two joinable without either of them carrying the name off the device. + projectName?.let { putLong("project_hash", it.hashCode().toLong()) } + putLong("timestamp", System.currentTimeMillis()) + } + + companion object { + const val EVENT_NAME = "deep_link" + } +} + +/** How far down the optional segments this request actually reached. */ +fun DeepLinkRequest.depth(): DeepLinkDepth { + val file = fileRequest ?: return DeepLinkDepth.PROJECT + return when { + file.columnRaw != null -> DeepLinkDepth.COLUMN + file.lineRaw != null -> DeepLinkDepth.LINE + else -> DeepLinkDepth.FILE + } +} diff --git a/app/src/test/java/com/itsaky/androidide/analytics/DeepLinkMetricTest.kt b/app/src/test/java/com/itsaky/androidide/analytics/DeepLinkMetricTest.kt new file mode 100644 index 0000000000..25bab1adcc --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/analytics/DeepLinkMetricTest.kt @@ -0,0 +1,90 @@ +/* + * 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.analytics + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.models.DeepLinkRequest +import com.itsaky.androidide.models.PendingFileRequest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DeepLinkMetricTest { + private fun request( + line: String? = null, + column: String? = null, + file: String? = null, + ) = DeepLinkRequest( + projectName = "MyApp", + fileRequest = file?.let { PendingFileRequest(filePath = it, lineRaw = line, columnRaw = column) }, + ) + + @Test + fun `depth reflects how far down the optional segments a link reached`() { + assertThat(request().depth()).isEqualTo(DeepLinkDepth.PROJECT) + assertThat(request(file = "a.kt").depth()).isEqualTo(DeepLinkDepth.FILE) + assertThat(request(file = "a.kt", line = "42").depth()).isEqualTo(DeepLinkDepth.LINE) + assertThat(request(file = "a.kt", line = "42", column = "7").depth()).isEqualTo(DeepLinkDepth.COLUMN) + } + + // A malformed line/column is still *present* in the URL, and the code reports it to the user + // rather than ignoring it -- so the metric has to count it as having reached that depth, or the + // "links people send are more specific than they resolve" signal quietly loses its worst cases. + @Test + fun `a non-numeric line still counts as line depth`() { + assertThat(request(file = "a.kt", line = "notanumber").depth()).isEqualTo(DeepLinkDepth.LINE) + } + + // The whole point of hashing: a project name is the user's content and must not leave the device. + @Test + fun `the bundle carries a project hash, never the project name`() { + val bundle = DeepLinkMetric(DeepLinkDepth.PROJECT, DeepLinkOutcome.RECEIVED, "MySecretProject").asBundle() + + assertThat(bundle.getLong("project_hash")).isEqualTo("MySecretProject".hashCode().toLong()) + for (key in bundle.keySet()) { + assertThat(bundle.get(key).toString()).doesNotContain("MySecretProject") + } + } + + @Test + fun `depth and outcome are recorded as lowercase names`() { + val bundle = DeepLinkMetric(DeepLinkDepth.COLUMN, DeepLinkOutcome.PROJECT_UNVERIFIABLE).asBundle() + + assertThat(bundle.getString("depth")).isEqualTo("column") + assertThat(bundle.getString("outcome")).isEqualTo("project_unverifiable") + } + + // Omitted rather than logged as 0/"": a metric with no project (an unparseable link, or the + // pre-parse setup gate) must not look like one for a project that hashes to zero. + @Test + fun `no project means no project_hash key at all`() { + val bundle = DeepLinkMetric(DeepLinkDepth.UNKNOWN, DeepLinkOutcome.INVALID_LINK).asBundle() + + assertThat(bundle.containsKey("project_hash")).isFalse() + } + + // One event name across every outcome, so the funnel is a single event filtered by `outcome` + // rather than a set of separate events that have to be summed to spot a drop-off. + @Test + fun `every outcome uses the one event name`() { + for (outcome in DeepLinkOutcome.entries) { + assertThat(DeepLinkMetric(DeepLinkDepth.PROJECT, outcome).eventName).isEqualTo(DeepLinkMetric.EVENT_NAME) + } + } +} From cf937bfc3b69cd82329ecd3de9eb213cf0e86ee2 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 1 Sep 2026 18:37:30 -0700 Subject: [PATCH 76/76] ADFA-5067: Log a terminal deep-link outcome on the editor path too Only MainActivity's resolve failure was instrumented, so a link that arrived while an editor was already live emitted RECEIVED and then nothing. That is the exact shape of the silent drop-off the paired events were added to expose -- a missing instrument masquerading as the bug it was meant to find. Found on the emulator: a warm-start link naming a nonexistent project logged received and no outcome. Claude-Session: https://claude.ai/code/session_01LuyR4ajssmKw1o1UQsjJbP --- .../activities/editor/EditorHandlerActivity.kt | 17 +++++++++++++++++ 1 file changed, 17 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 f392cb8037..c8aa83ab93 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 @@ -52,7 +52,10 @@ import com.itsaky.androidide.actions.build.QuickRunAction import com.itsaky.androidide.actions.internal.DefaultActionsRegistry import com.itsaky.androidide.activities.PluginManagerActivity import com.itsaky.androidide.activities.projectsRoot +import com.itsaky.androidide.analytics.DeepLinkMetric +import com.itsaky.androidide.analytics.DeepLinkOutcome import com.itsaky.androidide.analytics.IAnalyticsManager +import com.itsaky.androidide.analytics.depth import com.itsaky.androidide.api.ActionContextProvider import com.itsaky.androidide.app.BaseApplication import com.itsaky.androidide.app.EditorEvents @@ -2740,6 +2743,20 @@ open class EditorHandlerActivity : lifecycleScope.launch(Dispatchers.IO) { val lookup = resolveDeepLinkProject(projectsRoot(), request.projectName) if (lookup !is DeepLinkProjectLookup.Found) { + // Mirrors MainActivity's identical branch. Without it this path emitted RECEIVED and + // then nothing, which reads exactly like the silent drop-off the paired events exist + // to expose -- a missing instrument masquerading as the bug it was added to find. + analyticsManager.trackDeepLink( + DeepLinkMetric( + request.depth(), + if (lookup is DeepLinkProjectLookup.NotFound) { + DeepLinkOutcome.PROJECT_NOT_FOUND + } else { + DeepLinkOutcome.PROJECT_UNVERIFIABLE + }, + request.projectName, + ), + ) // No such project, so the switch this intent announced is never going to happen. Without // this the capture above is stranded: setIntent() has already dropped the staying // project's pending file request, nothing puts it back, and