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" + ] + } + } +] diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3ecaccc691..aa5701db3e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 `` 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. @@ -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. > diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cf216f8b6c..6f4b45c240 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 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 + * 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 = + 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() + } +} 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 7f51981128..f2d661ef05 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,8 @@ 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 import androidx.core.view.isVisible @@ -44,10 +46,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 @@ -60,11 +64,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 @@ -89,10 +93,15 @@ 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) } + // 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() { @@ -127,8 +136,21 @@ 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) + // 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() + } } if (FeatureFlags.isExperimentsEnabled) { @@ -397,47 +419,63 @@ class MainActivity : EdgeToEdgeIDEActivity() { } } - private fun handleOpenProject(root: File) { + private fun handleOpenProject( + root: File, + pendingFileRequest: PendingFileRequest? = null, + isDeepLink: Boolean = false, + ) { if (GeneralPreferences.confirmProjectOpen) { - askProjectOpenPermission(root) + askProjectOpenPermission(root, pendingFileRequest, isDeepLink) return } - openProject(root) + openProject(root, pendingFileRequest = pendingFileRequest) } - private fun askProjectOpenPermission(root: File) { + // 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 + + // 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)) builder.setCancelable(false) - builder.setPositiveButton(string.yes) { _, _ -> openProject(root) } + builder.setPositiveButton(string.yes) { _, _ -> openProject(root, pendingFileRequest = pendingFileRequest) } builder.setNegativeButton(string.no, null) - builder.show() + activeOpenPermissionDialog = builder.show() } internal fun openProject( 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) + // 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 @@ -449,6 +487,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) } @@ -479,11 +518,56 @@ 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. + * + * 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) { + 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 + // 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, isDeepLink = true) + } + } } 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 2d8f88dc70..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 @@ -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,8 +103,10 @@ 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.PendingFileRequest import com.itsaky.androidide.models.Range import com.itsaky.androidide.models.SearchResult import com.itsaky.androidide.plugins.extensions.FileTabMenuItem @@ -137,6 +140,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 @@ -653,14 +657,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,14 +688,48 @@ 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. - if (ProjectManagerImpl.getInstance().projectDirPath.isBlank()) { - log.warn("No project path available in EditorActivity.onCreate(); returning to MainActivity") - startActivity(Intent(this, MainActivity::class.java)) + // 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. + // + // 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 && !projectNamesMatch(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) } + // 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) } + // 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 ecd7ff984f..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 @@ -29,7 +29,9 @@ 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 import androidx.core.view.GravityCompat import androidx.core.view.doOnNextLayout @@ -48,6 +50,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 +58,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 +75,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,24 +91,34 @@ 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.roomData.recentproject.RecentProjectDao 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.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 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.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 import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable @@ -109,6 +127,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,8 +176,17 @@ open class EditorHandlerActivity : } private val shortcutManager by lazy { ShortcutManager(applicationContext) } + private val analyticsManager: IAnalyticsManager by inject() + private val recentProjectDao: RecentProjectDao by inject() + private val pendingDeepLinkOpen: PendingDeepLinkOpen by inject() + 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 @@ -210,10 +238,23 @@ 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() - 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 } @@ -228,6 +269,28 @@ 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 + } + didCompleteLiveOnCreate = true + + // 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. + // 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() @@ -325,13 +388,66 @@ 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) + }, + ) + } + + // 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) + // 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() + + // 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() } 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() @@ -711,8 +827,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) } } } @@ -723,7 +853,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) @@ -876,14 +1014,34 @@ open class EditorHandlerActivity : requestSync: Boolean, processResources: Boolean, progressConsumer: ((Int, Int) -> Unit)?, - runAfter: (() -> 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. + // 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) { - saveAll(notify, requestSync, processResources, progressConsumer) - } - withContext(Dispatchers.Main) { - runAfter?.invoke() + 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) + } } } } @@ -1011,6 +1169,21 @@ 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. + * + * @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(files: List = editorViewModel.getOpenedFiles()) = + files.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 { @@ -1210,7 +1383,26 @@ 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 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. + // 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 + } + invokeAfter.run() + } + }, + ) }, ) { dialog, _ -> dialog.dismiss() @@ -1731,7 +1923,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) { @@ -1757,15 +1952,111 @@ open class EditorHandlerActivity : if (manualFinish) { finish() } + onClosed?.invoke() + } + + // 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 + 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 + + // 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 + + // 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. + 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 + capturedPendingFileRequestBeforeSwitch = false + if (restore != null) { + intent.putExtra(PendingFileRequest.EXTRA_KEY, restore) + } else { + intent.removeExtra(PendingFileRequest.EXTRA_KEY) + } } - private fun confirmProjectClose() { + private fun confirmProjectClose(onClosed: (() -> Unit)? = null) { val content = contentOrNull ?: return + if (confirmCloseInProgress) { + // 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(getString(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.setNegativeButton(string.cancel_project_text, null) + // 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) + } 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 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). + restoreIntentToStayingProject() + } + } + + builder.setOnCancelListener { cancelOrDecline() } + + builder.setNegativeButton(string.cancel_project_text) { dialog, _ -> + dialog.dismiss() + cancelOrDecline() + } // OPTION 1: Close without saving builder.setNeutralButton(string.close_without_saving) { dialog, _ -> @@ -1775,24 +2066,358 @@ open class EditorHandlerActivity : (content.editorContainer.getChildAt(i) as? CodeEditorView)?.editor?.markUnmodified() } - performCloseAllFiles(manualFinish = true) + // 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 builder.setPositiveButton(string.save_and_close) { dialog, _ -> dialog.dismiss() - saveAllAsync(notify = false) { + saveAllAsync(notify = false) { saveSucceeded -> runOnUiThread { - if (contentOrNull == null) return@runOnUiThread - performCloseAllFiles(manualFinish = true) + 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. + // !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(), + ) + // 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 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 = onClosedNow) + } else { + 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 + // 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() + } + } } - recentProjectsViewModel.updateProjectModifiedDate( - editorViewModel.getProjectName(), - ) } } - builder.show() + activeProjectCloseDialog = builder.show() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + + 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 = + ( + 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 + // 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 (!isProjectSwitchIntent && !intent.hasExtra(PendingFileRequest.EXTRA_KEY)) { + IntentCompat + .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. + // 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) + + 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 + // 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) + + // 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) { + // 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 + // 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) + } + } + } + + override fun postProjectInit( + isSuccessful: Boolean, + failure: TaskExecutionResult.Failure?, + ) { + super.postProjectInit(isSuccessful, failure) + + // 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) + if (!isSuccessful) return + 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) { + // 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) + // 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 { + // 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 + // 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() + } + + // 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 -> { + 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 { + 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 -> { + // 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) + } + } + } + } + + /** + * 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. + * + * [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) { + lifecycleScope.launch(Dispatchers.IO) { + val projectDir = File(IProjectManager.getInstance().projectDirPath) + 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 + // 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 + } + + // URL line/column are 1-based; internal Position is 0-based. + 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)) + } + } + } + + /** + * 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 zeroBasedOrInvalid(raw: String?): Pair { + raw ?: return 0 to null + val parsed = raw.toIntOrNull() + return if (parsed == null || parsed <= 0) 0 to raw else (parsed - 1) to null } } 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/api/ActionContextProvider.kt b/app/src/main/java/com/itsaky/androidide/api/ActionContextProvider.kt index e5972bbeb1..eb566d80a6 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,43 @@ import java.lang.ref.WeakReference * to allow decoupled services to trigger UI actions. */ object ActionContextProvider { - private var activityRef: WeakReference? = null + // 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) { - 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. + * + * [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/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/deeplink/PendingDeepLinkOpen.kt b/app/src/main/java/com/itsaky/androidide/deeplink/PendingDeepLinkOpen.kt new file mode 100644 index 0000000000..ea30236301 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/deeplink/PendingDeepLinkOpen.kt @@ -0,0 +1,37 @@ +/* + * 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. + * + * 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 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/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index 51a0acda05..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 @@ -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 @@ -38,420 +39,460 @@ 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 { 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 + } + // 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) + } + } + }.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..d269630e51 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,93 @@ 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 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( + notify: Boolean = true, + requestSync: Boolean = true, + processResources: Boolean = false, + progressConsumer: ((progress: Int, total: Int) -> Unit)? = null, + runAfter: ((succeeded: Boolean) -> 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) +} 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..fafcc0c3c8 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt @@ -0,0 +1,194 @@ +/* + * 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 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" + 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 + } + + /** + * 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 + * 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? { + // 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 + } + + val segments = uri.pathSegments + + val projectIdx = segments.indexOfFrom(0, SEGMENT_PROJECT) + if (projectIdx < 0 || projectIdx + 1 >= segments.size) { + return null + } + val projectName = segments[projectIdx + 1] + + val fileIdx = segments.indexOfFrom(projectIdx + 2, SEGMENT_FILE) + val fileRequest = + fileIdx.takeIf { it >= 0 }?.let { fIdx -> + val startIdx = fIdx + 1 + if (startIdx >= segments.size) { + return@let null + } + + // line/column are trailing modifiers, so -- unlike the project/file lookup above -- + // 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 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 + 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("/") + + PendingFileRequest( + filePath = filePath, + lineRaw = lineRaw, + columnRaw = columnRaw, + ) + } + + 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/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/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..29a0d4dcc7 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/DeepLinkProjectResolution.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.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) { + // 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) { + withContext(Dispatchers.Main) { + if (!isFinishing && !isDestroyed) { + flashError(getString(string.msg_deeplink_project_not_found, projectName)) + } + } + } + return projectDir +} 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..677e174ab6 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/PathTraversal.kt @@ -0,0 +1,87 @@ +/* + * 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.io.IOException +import java.nio.file.Files +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. + * + * Three layers, mirroring the zip-slip guard in + * [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 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 + * 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 + * 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.isEmpty() || 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)) { + 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 (_: InvalidPathException) { + null + } catch (_: IOException) { + 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..fa07a79474 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt @@ -0,0 +1,93 @@ +/* + * 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 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.RecentProjectDao +import com.itsaky.androidide.templates.Language +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 + * [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]). + * + * [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( + recentProjectDao: RecentProjectDao, + root: File, + project: RecentProject?, + analyticsManager: IAnalyticsManager, +) { + ProjectManagerImpl.getInstance().projectPath = root.absolutePath + GeneralPreferences.lastOpenedProject = root.absolutePath + + ProcessLifecycleOwner.get().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), + ) + 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) + if (!recentProject.language.equals(Language.Unknown.lang, ignoreCase = true)) { + recentProjectDao.updateLanguage(recentProject.location, recentProject.language) + } + } catch (e: CancellationException) { + throw e + } 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 + // 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. 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) + } + } + + analyticsManager.trackProjectOpened(root.absolutePath) +} 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..f6f0a07fc1 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 @@ -11,14 +12,69 @@ 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. + * + * [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? { + // 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 + + // 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 +} + +/** + * 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)) { @@ -56,4 +112,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 +} 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 4d59706af4..6736fd9511 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/MainViewModel.kt @@ -17,34 +17,42 @@ package com.itsaky.androidide.viewmodel -import android.database.SQLException import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData 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.Language import com.itsaky.androidide.templates.Template -import kotlinx.coroutines.CancellationException -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 /** - * [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 */ -class MainViewModel( - private val recentProjectDao: RecentProjectDao, -) : ViewModel() { +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. @@ -60,8 +68,6 @@ class MainViewModel( 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) @@ -116,22 +122,4 @@ class MainViewModel( action.run() } } - - fun saveProjectToRecents(project: RecentProject) { - viewModelScope.launch(Dispatchers.IO) { - try { - // Insert is IGNOREd for projects already in recents, so refresh the - // detected language separately - but never clobber a stored value - // with a failed detection. - recentProjectDao.insert(project) - if (!project.language.equals(Language.Unknown.lang, ignoreCase = true)) { - recentProjectDao.updateLanguage(project.location, project.language) - } - } catch (e: CancellationException) { - throw e - } catch (e: SQLException) { - logger.warn("Failed to save project to recents", e) - } - } - } } 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..de223e6b70 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt @@ -0,0 +1,295 @@ +/* + * 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 com.google.common.truth.Truth.assertThat +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") + 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") + 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") + assertThat(request) + .isEqualTo( + DeepLinkRequest( + projectName = "MyApp", + fileRequest = PendingFileRequest(filePath = "Main.kt", lineRaw = "42", columnRaw = null), + ), + ) + } + + @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", + ) + assertThat(request) + .isEqualTo( + DeepLinkRequest( + projectName = "MyApp", + fileRequest = PendingFileRequest(filePath = "Main.kt", lineRaw = "42", columnRaw = "7"), + ), + ) + } + + @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", + ) + assertThat(request?.fileRequest?.filePath).isEqualTo("app/src/main/Main.kt") + assertThat(request?.fileRequest?.lineRaw).isEqualTo("1") + } + + @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") + 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") + 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") + 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 + 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", + ) + assertThat(request?.fileRequest?.lineRaw).isEqualTo("abc") + assertThat(request?.fileRequest?.columnRaw).isEqualTo("xyz") + } + + @Test + fun `missing project segment yields null`() { + assertThat(parse("https://www.appdevforall.org/device/open/MyApp")).isNull() + } + + @Test + fun `project segment with no name yields null`() { + 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") + assertThat(request).isEqualTo(DeepLinkRequest(projectName = "MyApp", fileRequest = null)) + } + + @Test + 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 `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- + // (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 + // 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"), + ), + ) + } + + @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), + ), + ) + } +} 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..3dde85d43b --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/PathTraversalTest.kt @@ -0,0 +1,135 @@ +/* + * 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.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 { + 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") + assertThat(resolved).isEqualTo(File(baseDir, "src/Main.kt").absoluteFile) + } + + @Test + fun `literal dot-dot is rejected`() { + assertThat(resolveWithinDirectory(baseDir, "../../etc/passwd")).isNull() + } + + @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. + assertThat(resolveWithinDirectory(baseDir, "")).isNull() + } + + @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. + assertThat(resolveWithinDirectory(baseDir, "foo/../../etc/passwd")).isNull() + } + + @Test + fun `leading slash is rejected`() { + assertThat(resolveWithinDirectory(baseDir, "/etc/passwd")).isNull() + } + + @Test + fun `leading backslash is rejected`() { + assertThat(resolveWithinDirectory(baseDir, "\\Windows\\System32")).isNull() + } + + @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. + assertThat(resolveWithinDirectory(baseDir, "foo" + nulCharacter + ".txt")).isNull() + } + + @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() + } + + @Test + fun `multi-segment path resolves and normalizes redundant separators`() { + val resolved = resolveWithinDirectory(baseDir, "app/src/main/Main.kt") + assertThat(resolved).isEqualTo(File("/project/root/app/src/main/Main.kt")) + } + + @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") + assertThat(resolved?.canonicalFile).isEqualTo(target.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") + + 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/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..5b588ec272 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/ProjectValidationsTest.kt @@ -0,0 +1,105 @@ +/* + * 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 +import java.text.Normalizer + +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 `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 + // 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. + // + // 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() + } +} 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 { 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..70a5cbd812 100644 --- a/common/src/test/java/com/itsaky/androidide/utils/ZipUtilsTest.kt +++ b/common/src/test/java/com/itsaky/androidide/utils/ZipUtilsTest.kt @@ -2,11 +2,14 @@ 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 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 @@ -58,4 +61,42 @@ 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() + val symlinkCreated = + try { + 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 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. + 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. + 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") + } } diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 97d441fbbb..5c059e6108 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -137,6 +137,14 @@ 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. + (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 Open a saved project Delete a saved project