Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 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
49c0cd3
ADFA-5067: Validate deep-link scheme/host; fix silent line/column par…
davidschachterADFA Aug 14, 2026
8b8150f
ADFA-5067: Fix deep-link project-open/close race conditions from code…
davidschachterADFA Aug 14, 2026
232d249
ADFA-5067: Document the blank-projectDirPath branch in onNewIntent
davidschachterADFA Aug 14, 2026
17d29d1
Merge branch 'stage' into task/ADFA-5067-deep-links
davidschachterADFA Aug 14, 2026
e2e4f03
ADFA-5067: Reindent GitBottomSheetFragment.kt and IEditorHandler.kt t…
davidschachterADFA Aug 15, 2026
47a6eef
ADFA-5067: Fix third-round /code-review xhigh findings
davidschachterADFA Aug 15, 2026
dd21d62
ADFA-5067: Fix /code-review max findings
davidschachterADFA Aug 15, 2026
277435d
ADFA-5067: Fix second /code-review max findings pass
davidschachterADFA Aug 15, 2026
84bc0de
ADFA-5067: Fix CodeRabbit findings from the dd21d62b7 review round
davidschachterADFA Aug 15, 2026
696fc4e
ADFA-5067: Fix real findings from another /code-review max pass
davidschachterADFA Aug 15, 2026
b8e1c43
ADFA-5067: Fix CodeRabbit nitpicks from the 84bc0de35 review round
davidschachterADFA Aug 15, 2026
656a236
ADFA-5067: Fix test-isolation gap in the single-segment dot-dot test
davidschachterADFA Aug 15, 2026
3590380
ADFA-5067: Fix real findings from a third /code-review max pass
davidschachterADFA Aug 16, 2026
85877e2
ADFA-5067: Fix real findings from a fourth /code-review max pass
davidschachterADFA Aug 16, 2026
dcfeb90
Merge branch 'stage' into task/ADFA-5067-deep-links
davidschachterADFA Aug 16, 2026
40abc0a
ADFA-5067: Fix real findings from a fifth /code-review max pass
davidschachterADFA Aug 17, 2026
b162f58
Merge remote-tracking branch 'origin/task/ADFA-5067-deep-links' into …
davidschachterADFA Aug 17, 2026
5528e14
ADFA-5067: Fix real findings from a sixth /code-review max pass
davidschachterADFA Aug 17, 2026
cf4277c
Merge branch 'stage' into task/ADFA-5067-deep-links
davidschachterADFA Aug 17, 2026
8618ca6
ADFA-5067: Address remaining open CodeRabbit test nitpicks
davidschachterADFA Aug 17, 2026
44e4daa
Merge remote-tracking branch 'origin/task/ADFA-5067-deep-links' into …
davidschachterADFA Aug 17, 2026
6d9c8d9
Merge branch 'stage' into task/ADFA-5067-deep-links
davidschachterADFA Aug 18, 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.
Comment on lines +1 to +20

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this entire file. It has been handled in #1693

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.
]
Comment thread
davidschachterADFA marked this conversation as resolved.
}
}
]
Comment on lines +1 to +12

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this entire file. It has been handled in #1693

10 changes: 9 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ 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 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`).

## 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 Expand Up @@ -100,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.
>
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"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This declares www only, so https://appdevforall.org/device/open/project/{name} (no www) will open in a browser rather than the app. Intent matching happens on the URL as given — if the site redirects apex to www, that redirect is followed by the browser, after the app has already lost its chance to handle the intent.

Worth deciding rather than inheriting: the ticket's example URLs are all www, so www-only may well be intended. But apex links are easy to produce by hand, and they'll silently fail to deep-link.

If you do want apex, it's free at this point — the server side already supports it. assetlinks.json is now served from an R2 bucket via a Cloudflare Worker (merged in #1693), and both hosts are live and verified:

OK    https://appdevforall.org/.well-known/assetlinks.json (application/json)
OK    https://www.appdevforall.org/.well-known/assetlinks.json (application/json)

The published file uses delegate_permission/common.handle_all_urls, and Android verifies the host, not the path — so the file as published already satisfies a second host with no change. It's one added element in this same filter:

<data
    android:scheme="https"
    android:host="appdevforall.org"
    android:pathPrefix="/device/open/project/" />

Note that adding it does mean autoVerify must succeed for both hosts or Android marks verification failed for the app as a whole — but both already return 200 with application/json and no redirect, so that's satisfied today.

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,84 @@
/*
* 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 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 =
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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLEAR_TOP here can silently destroy unsaved work.

This branch is taken when ActionContextProvider.getActivity() returns null -- which happens not only when no editor exists, but also when a second instance registered over a live one and then cleared the slot on its own destroy (clearActivity matches on identity and nulls the slot outright). A backgrounded-but-live editor has not re-asserted itself in onResume yet.

CLEAR_TOP then makes the framework finish that live editor. Its teardown runs checkIsDestroying -> isDestroying = true -> ProjectHandlerActivity.preDestroy -> doCloseAll -> closeAll, and notifyFilesUnsaved (EditorHandlerActivity.kt:1368) sees isDestroying and calls markUnmodified() on every editor (line 1372) before discarding.

Every unsaved buffer is dropped, with no prompt.

)
},
)
finish()
}
}
Loading
Loading