Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
2df5a56
ADFA-5067 | Add deep-link request models, path-traversal guard, and b…
davidschachterADFA Aug 10, 2026
6b96c84
ADFA-5067 | Add DeepLinkActivity as the sole App Link entry point
davidschachterADFA Aug 10, 2026
8c42c35
ADFA-5067 | Handle deep links with no project open in MainActivity
davidschachterADFA Aug 10, 2026
0df3845
ADFA-5067 | Handle deep links to an already-open project in EditorHan…
davidschachterADFA Aug 10, 2026
1109bf1
ADFA-5067 | Add RFC 5785 .well-known/assetlinks.json for App Links ve…
davidschachterADFA Aug 10, 2026
a0790b2
ADFA-5067 | Document the deep-link entry point in ARCHITECTURE.md
davidschachterADFA Aug 10, 2026
1078604
ADFA-5067 | Fix deep-link routing race in DeepLinkActivity
davidschachterADFA Aug 11, 2026
aea677b
ADFA-5067 | Guard MainActivity's deep-link handling against recreation
davidschachterADFA Aug 11, 2026
3e8fd65
ADFA-5067 | Prevent stacked confirm-close dialogs from dropping a dee…
davidschachterADFA Aug 11, 2026
045aa00
ADFA-5067 | Fix reserved-keyword collision in DeepLinkRequest.parse()
davidschachterADFA Aug 11, 2026
ab4be5e
ADFA-5067 | Close symlink escape in resolveWithinDirectory
davidschachterADFA Aug 11, 2026
3ad035b
ADFA-5067 | Sync ARCHITECTURE.md with the DeepLinkActivity routing fix
davidschachterADFA Aug 11, 2026
0f5b682
ADFA-5067 | Acquire RecentProjectDao through Koin, not a raw DB call
davidschachterADFA Aug 11, 2026
ee35586
ADFA-5067 | Show a Toast when a deep link fails to parse
davidschachterADFA Aug 11, 2026
4196a34
ADFA-5067 | Handle SecurityException scanning projects for a deep link
davidschachterADFA Aug 11, 2026
cc74e65
ADFA-5067 | Don't let a Recents-write failure crash the app
davidschachterADFA Aug 11, 2026
b68b50a
ADFA-5067 | Name deliberately-unused catch bindings "_"
davidschachterADFA Aug 11, 2026
45d94cd
ADFA-5067 | Add more reserved-keyword-collision regression cases
davidschachterADFA Aug 11, 2026
a451470
ADFA-5067 | Fix three deep-link close/open correctness gaps
davidschachterADFA Aug 11, 2026
df705c9
ADFA-5067 | Match line/column from the end of the path, not the start
davidschachterADFA Aug 11, 2026
de0e9e8
ADFA-5067 | Add embedded-keyword regression tests; use Truth in this …
davidschachterADFA Aug 11, 2026
86c1f70
ADFA-5067 | Block a new confirm-close while a save-and-close is in fl…
davidschachterADFA Aug 11, 2026
9741df7
ADFA-5067 | Remove dead saveProjectToRecents(); Koin-provide PendingD…
davidschachterADFA Aug 11, 2026
e9a1afb
ADFA-5067 | Look up a deep-linked project by name directly, not by sc…
davidschachterADFA Aug 11, 2026
f8cb2c9
ADFA-5067 | Deduplicate deep-link line/column parsing
davidschachterADFA Aug 11, 2026
11d1988
ADFA-5067 | Fix path traversal introduced by findValidProjectByName
davidschachterADFA Aug 11, 2026
a44feeb
ADFA-5067 | Narrow the Recents-insert catch to SQLException
davidschachterADFA Aug 11, 2026
6a92920
ADFA-5067 | Document MainViewModel's screen-state and event contracts
davidschachterADFA Aug 11, 2026
7e92715
ADFA-5067 | Dismiss the confirm-close dialog in onDestroy()
davidschachterADFA Aug 12, 2026
fa73614
ADFA-5067 | Always invoke saveAllAsync's runAfter, even if saveAll th…
davidschachterADFA Aug 12, 2026
2a9c28a
ADFA-5067 | Reject overlapping confirm-close requests instead of hija…
davidschachterADFA Aug 12, 2026
3b7afd7
ADFA-5067 | Fix same-project fast path; dedupe deep-link project lookup
davidschachterADFA Aug 12, 2026
dbf4f55
ADFA-5067 | Drain the pending file request even when sync fails
davidschachterADFA Aug 12, 2026
8eb75ca
ADFA-5067 | ActionContextProvider never hands back a finishing activity
davidschachterADFA Aug 12, 2026
06751ad
ADFA-5067 | Add CLEAR_TOP so repeated deep links don't stack MainActi…
davidschachterADFA Aug 12, 2026
df7d7b4
ADFA-5067 | Reject "." and embedded separators in a deep-link project…
davidschachterADFA Aug 12, 2026
8343ea9
ADFA-5067 | Widen the Recents-insert catch back to Throwable
davidschachterADFA Aug 12, 2026
de62fac
ADFA-5067 | Document the full deep-link routing/file-open flow
davidschachterADFA Aug 12, 2026
de7ad7f
Merge remote-tracking branch 'origin/stage' into task/ADFA-5067-deep-…
davidschachterADFA Aug 12, 2026
7a89bd6
ADFA-5067 | Use the inherited SLF4J logger, not android.util.Log
davidschachterADFA Aug 12, 2026
cfb708e
Merge branch 'stage' into task/ADFA-5067-deep-links
davidschachterADFA Aug 13, 2026
44d0dbc
Merge branch 'stage' into task/ADFA-5067-deep-links
davidschachterADFA Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .well-known/README.md
Original file line number Diff line number Diff line change
@@ -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 <release.jks>` (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.
12 changes: 12 additions & 0 deletions .well-known/assetlinks.json
Original file line number Diff line number Diff line change
@@ -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"
]
Comment thread
davidschachterADFA marked this conversation as resolved.
}
}
]
6 changes: 6 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ 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 `<intent-filter>` 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

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.
Expand Down
16 changes: 16 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,22 @@
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize"
android:exported="true"
android:theme="@style/Theme.AndroidIDE" />
<activity
android:name=".activities.DeepLinkActivity"
android:exported="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="www.appdevforall.org"
android:pathPrefix="/device/open/project/" />
</intent-filter>
</activity>
Comment thread
davidschachterADFA marked this conversation as resolved.
<activity
android:name=".activities.editor.EditorActivityKt"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|fontScale"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/

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 `<intent-filter>` 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) {
// 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
}

// 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 =
Comment thread
davidschachterADFA marked this conversation as resolved.
if (ActionContextProvider.getActivity() != null) {
EditorActivityKt::class.java
} else {
MainActivity::class.java
}

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.
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_SINGLE_TOP or
Intent.FLAG_ACTIVITY_CLEAR_TOP,
)
},
)
finish()
}
}
61 changes: 38 additions & 23 deletions app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import android.os.Bundle
import android.view.KeyEvent
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.core.content.IntentCompat
import androidx.core.graphics.Insets
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
Expand All @@ -44,10 +45,12 @@ 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.roomData.recentproject.RecentProjectDao
import com.itsaky.androidide.shortcuts.IdeShortcutActions
import com.itsaky.androidide.shortcuts.ShortcutContext
import com.itsaky.androidide.shortcuts.ShortcutExecutionContext
Expand All @@ -60,11 +63,11 @@ 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.getCreatedTime
import com.itsaky.androidide.utils.getLastModifiedTime
import com.itsaky.androidide.utils.hasVisibleDialog
import com.itsaky.androidide.utils.readProjectLanguage
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
Expand All @@ -89,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) }
Expand Down Expand Up @@ -128,7 +132,14 @@ class MainActivity : EdgeToEdgeIDEActivity() {
startWebServer()

if (savedInstanceState == null) {
openLastProject()
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) {
Expand Down Expand Up @@ -419,25 +430,9 @@ class MainActivity : EdgeToEdgeIDEActivity() {
root: File,
project: RecentProject? = null,
hasTemplateIssues: Boolean = false,
pendingFileRequest: PendingFileRequest? = null,
) {
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(),
language = readProjectLanguage(root),
)
viewModel.saveProjectToRecents(recentProject)
}

// Track project open in Firebase Analytics
analyticsManager.trackProjectOpened(root.absolutePath)
recordProjectOpenedBookkeeping(recentProjectDao, root, project, analyticsManager)

if (isFinishing) {
return
Expand All @@ -449,6 +444,7 @@ class MainActivity : EdgeToEdgeIDEActivity() {
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)
}

Expand Down Expand Up @@ -479,6 +475,25 @@ 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 = resolveDeepLinkProject(Environment.PROJECTS_DIR, request.projectName) ?: return@launch
withContext(Dispatchers.Main) {
openProject(projectDir, pendingFileRequest = request.fileRequest)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

override fun onDestroy() {
Expand Down
Loading
Loading