diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1002ead379..7e2d1cc5a9 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://appdevforall.org/device/open/project/...` (and the identical `www` subdomain). It never renders anything — it parses the URI into a `DeepLinkRequest` (project name plus an optional file/line/column), checks whether an editor is already on screen (`ActionContextProvider.getActivity()`, the live `EditorHandlerActivity` tracker -- not `IProjectManager`'s `workspace`, which stays null for the whole duration of a Gradle sync even while the editor is already open), and routes to `MainActivity` (nothing open) or the live, `singleTask` `EditorActivityKt`/`EditorHandlerActivity` (a project is open — reused via `onNewIntent`), then finishes itself. This avoids a visible flash of `MainActivity`'s real UI when the actual destination is the already-running editor. + +`EditorHandlerActivity.onNewIntent` then branches on `projectDirPath` (set as soon as a project starts opening) rather than `workspace` so the mid-sync case still matches correctly: **same project already open** — no project-wise work, just navigate to the requested file (`applyDeepLinkFileRequest`); **a different project is open** — the existing, unmodified `confirmProjectClose()` dialog runs (it also guards against a second confirm-close request overlapping a manual close or an in-flight save, and a *third* overlapping request supersedes the second's pending callback rather than being dropped), and only once the user actually confirms does an `onDestroy()`-triggered hand-off (`PendingDeepLinkOpen`, Koin-provided) start the new project — deliberately deferred to `onDestroy()`, not fired synchronously after `finish()`, so the new `PROJECT_PATH` can't race a `singleTask` re-delivery to the dying instance; `projectDirPath` **is still blank** — this instance never actually finished initializing a project (e.g. recreated after process death with no `PROJECT_PATH` extra), so `confirmProjectClose()` would silently no-op (`contentOrNull` is null); this case reuses the same `onDestroy()`-deferred hand-off instead of showing a close dialog for a project that was never really open; **nothing was open** — `MainActivity.openProject`/`EditorHandlerActivity.postProjectInit` apply the pending file request once the cold-opened project's sync succeeds. + +`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`, `ProjectInfoBottomSheet`, and `ProjectCreationManager` directly, and by `MainActivity`/`EditorHandlerActivity` indirectly through `RecentProjectRepository` (`repositories/RecentProjectRepository.kt`) -- kept behind that interface, rather than injecting the DAO into those two Activities directly, per this section's own UI -> ViewModel -> Repository -> data source layering. > > **Raw SQLite is allowed only when** the database is prebuilt and opened read-only, the data is performance/allocation-critical and needs granular schema control, or the schema is shared across a process/component boundary. Current exceptions: symbol indexing (`lsp/indexing/SQLiteIndex.kt`), tooltips (`idetooltips/ToolTipManager.kt`), in-app/plugin help (`plugin-manager/.../documentation/PluginDocumentationManager.kt`), and the local web server (`app/.../localWebServer/WebServer.kt`). The `androidx.room:*` strings in `editor`'s `GroovyAutoComplete` are autocomplete suggestions for the *user's* code, not CoGo persistence. > diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 521b7867a1..cd4d886323 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -91,11 +91,37 @@ android:name=".activities.OnboardingActivity" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize" android:exported="false" /> + + + + + + + + + + . + */ + +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://appdevforall.org/device/open/project/...` (and the + * identical `www` subdomain) App Links. Never shows any UI -- it only parses the incoming + * [android.net.Uri], decides whether a project is already loaded, and hands off to whichever real + * activity owns that scenario: + * [MainActivity] if nothing is open yet, or the already-running [EditorActivityKt] (via its + * `singleTask` `onNewIntent`) if one is. + * + * 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) + + // A link can arrive before the IDE is usable -- a fresh install, or a Clear Data. Both targets + // below sit *past* SplashActivity and OnboardingActivity, which are the only things enforcing + // the terms, the permissions, the JDK and SDK install, the low-storage check and the x86 + // exit, so following the link now would land the user in an editor that cannot build, on a + // device the app is supposed to refuse to run on at all (ADFA-5067 review). + // + // The link is dropped rather than deferred: carrying a request through an onboarding that can + // take several minutes, and may not finish at all, is a lot of machinery for a rare case. The + // user is told, and sent to the normal entry point, which decides what they actually need -- + // storage, ABI and onboarding are SplashActivity's to enforce, not this activity's to repeat. + if (!isIdeSetupComplete()) { + Toast.makeText(this, getString(string.msg_deeplink_setup_incomplete), Toast.LENGTH_LONG).show() + startActivity(Intent(this, SplashActivity::class.java)) + finish() + return + } + + val request = DeepLinkRequest.parse(intent?.data) + if (request == null) { + // A Toast, not flashError -- this activity finishes immediately below, tearing down its + // 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) + // FLAG_ACTIVITY_CLEAR_TOP deliberately omitted: MainActivity has no special launch + // mode, so if an existing MainActivity instance sits lower in this task's back stack + // under a live EditorActivityKt - which ActionContextProvider.getActivity() can miss + // even when that editor is alive (see its KDoc) - CLEAR_TOP would destroy that editor + // to clear the path down to MainActivity, discarding unsaved work with no prompt. + // Without it, this may at worst stack a redundant MainActivity instance, a harmless + // nuisance; EditorActivityKt is singleTask, so it always reuses its live instance via + // onNewIntent regardless of these flags. + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP, + ) + }, + ) + 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..4fec9cba69 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt @@ -23,7 +23,10 @@ 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.os.BundleCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope @@ -37,6 +40,7 @@ import com.itsaky.androidide.activities.editor.EditorActivityKt import com.itsaky.androidide.analytics.IAnalyticsManager import com.itsaky.androidide.app.EdgeToEdgeIDEActivity import com.itsaky.androidide.databinding.ActivityMainBinding +import com.itsaky.androidide.deeplink.ConsumedDeepLinkRequests import com.itsaky.androidide.fragments.MainFragment import com.itsaky.androidide.fragments.RecentProjectsFragment import com.itsaky.androidide.idetooltips.TooltipManager @@ -44,8 +48,11 @@ 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.projects.IProjectManager +import com.itsaky.androidide.repositories.RecentProjectRepository import com.itsaky.androidide.resources.R.string import com.itsaky.androidide.roomData.recentproject.RecentProject import com.itsaky.androidide.shortcuts.IdeShortcutActions @@ -60,11 +67,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 +96,29 @@ class MainActivity : EdgeToEdgeIDEActivity() { @Suppress("ktlint:standard:backing-property-naming") private var _binding: ActivityMainBinding? = null private val analyticsManager: IAnalyticsManager by inject() + private val recentProjectRepository: RecentProjectRepository by inject() private var feedbackButtonManager: FeedbackButtonManager? = null private var webServer: WebServer? = null private val shortcutManager by lazy { ShortcutManager(applicationContext) } + // 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 + + // The last deep-link request actually opened (or, if GeneralPreferences.confirmProjectOpen is on, + // actually confirmed) -- see handleOpenProject/askProjectOpenPermission. Persisted via + // onSaveInstanceState rather than signalled by removing the Intent's own extra: a genuine process + // death redelivers the ORIGINAL, unmutated launch Intent (extras and all) once the user returns to + // the task, so an Intent-mutation-based "already handled" signal doesn't survive it and this same + // request force-reopens a project the user has since navigated away from. A config-change + // recreate, in contrast, preserves this field across the recreate but correctly leaves it unset + // if the recreate happens before the user actually responds to the confirm dialog, so that dialog + // (destroyed along with the old instance) gets a fresh retry on the new one instead of the link + // being silently dropped. + // Every request consumed in this task, not just the last one -- see the class for why one slot + // was not enough. + private val consumedDeepLinkRequests = ConsumedDeepLinkRequests() + private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { @@ -127,7 +153,24 @@ class MainActivity : EdgeToEdgeIDEActivity() { // Start WebServer after installation is complete startWebServer() - if (savedInstanceState == null) { + val deepLinkRequest = + IntentCompat.getParcelableExtra(intent, DeepLinkRequest.EXTRA_KEY, DeepLinkRequest::class.java) + consumedDeepLinkRequests.restore( + savedInstanceState?.let { + BundleCompat.getParcelableArrayList(it, KEY_CONSUMED_DEEP_LINK_REQUESTS, DeepLinkRequest::class.java) + }, + ) + // A config change this activity doesn't declare (e.g. font scale, day/night) recreates it with + // savedInstanceState != null while handleDeepLinkRequest's resolve may still be in flight -- + // the old instance's lifecycleScope (and its coroutine) is cancelled with it. Gating solely on + // savedInstanceState == null would silently lose a not-yet-consumed request instead of + // retrying it on the new instance; comparing against consumedDeepLinkRequests (restored above) + // rather than just checking deepLinkRequest != null is what tells a genuinely new/not-yet-acted- + // on request apart from the system redelivering the same original launch Intent verbatim after + // this same request was already fully handled (see consumedDeepLinkRequests' own docs). + if (deepLinkRequest != null && deepLinkRequest !in consumedDeepLinkRequests) { + handleDeepLinkRequest(deepLinkRequest) + } else if (savedInstanceState == null) { openLastProject() } @@ -397,47 +440,83 @@ 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) + // No confirmation gate -- opening happens immediately below, so this is "confirm time" for + // consumedDeepLinkRequests' purposes. + if (isDeepLink) { + consumedDeepLinkRequests.add(latestDeepLinkRequest) + } + 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) { _, _ -> + // The user has now actually confirmed -- "confirm time" for consumedDeepLinkRequests' + // purposes, unlike merely having shown this dialog (see its own docs on why that + // distinction matters for a recreate that happens while this dialog is still up). + if (isDeepLink) { + consumedDeepLinkRequests.add(latestDeepLinkRequest) + } + 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 + // Captured before the bookkeeping call below overwrites it: EditorHandlerActivity.onNewIntent + // (already-live singleTask instance) needs to know what project WAS open to tell a genuine + // switch from a same-project no-op, but recordProjectOpenedBookkeeping's synchronous + // ProjectManagerImpl.projectPath write below makes that global read the NEW path by the time + // onNewIntent runs -- comparing against it there would always see "already open". + val previousProjectPath = IProjectManager.getInstance().projectDirPath - 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(recentProjectRepository, root, project, analyticsManager) if (isFinishing) { return @@ -446,9 +525,11 @@ class MainActivity : EdgeToEdgeIDEActivity() { val intent = Intent(this, EditorActivityKt::class.java).apply { putExtra("PROJECT_PATH", root.absolutePath) + putExtra("PREVIOUS_PROJECT_PATH", previousProjectPath) if (hasTemplateIssues) { putExtra("HAS_TEMPLATE_ISSUES", true) } + pendingFileRequest?.let { putExtra(PendingFileRequest.EXTRA_KEY, it) } addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) } @@ -479,12 +560,66 @@ 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] rather than calling [openProject] directly, so an + * open arriving by link is gated the same way as one from the project list when the user has + * asked for that gate ([GeneralPreferences.confirmProjectOpen]). + * + * That preference is *not* what makes this extra safe to trust -- it defaults to `false`. The + * boundary is the manifest: [MainActivity] is not exported, so this extra can only have come + * from [DeepLinkActivity], which parsed and validated the URI it came from. It was previously + * exported despite declaring no intent-filter of its own (`SplashActivity` holds the actual + * MAIN/LAUNCHER), which let any co-installed app send this extra directly and force an arbitrary + * project open with no user interaction at all. `DeepLinkTargetsNotExportedTest` pins that. + */ + private fun handleDeepLinkRequest(request: DeepLinkRequest) { + latestDeepLinkRequest = request + lifecycleScope.launch(Dispatchers.IO) { + val projectDir = resolveDeepLinkProject(Environment.PROJECTS_DIR, request.projectName) + withContext(Dispatchers.Main) { + // The activity may have started finishing while resolveDeepLinkProject was still + // scanning disk -- lifecycleScope only cancels at ON_DESTROY, not the moment isFinishing + // first flips true, so this continuation can otherwise still run and show a dialog on a + // dying window. + if (isFinishing || isDestroyed) return@withContext + projectDir ?: return@withContext + // A second, faster-resolving deep link superseded this one while it was still resolving + // -- this stale, slower request must not now bounce the user back to its own (older) + // target after they've already been taken to the newer one. + if (latestDeepLinkRequest !== request) return@withContext + // the request is recorded as consumed once this request is actually opened (or confirmed, if + // GeneralPreferences.confirmProjectOpen is on) -- see handleOpenProject/ + // askProjectOpenPermission and consumedDeepLinkRequests' own docs for why marking it + // here, before the user has necessarily responded to that confirm dialog, would be too + // early. + handleOpenProject(projectDir, pendingFileRequest = request.fileRequest, isDeepLink = true) + } + } } override fun onDestroy() { webServer?.stop() ITemplateProvider.getInstance().release() + activeOpenPermissionDialog?.dismiss() super.onDestroy() _binding = null } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putParcelableArrayList(KEY_CONSUMED_DEEP_LINK_REQUESTS, consumedDeepLinkRequests.toSavedList()) + } + + companion object { + private const val KEY_CONSUMED_DEEP_LINK_REQUESTS = "consumedDeepLinkRequests" + } } diff --git a/app/src/main/java/com/itsaky/androidide/activities/OnboardingActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/OnboardingActivity.kt index 385feadbcc..104ff5a066 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/OnboardingActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/OnboardingActivity.kt @@ -47,10 +47,10 @@ import com.itsaky.androidide.preferences.internal.prefManager import com.itsaky.androidide.tasks.doAsyncWithProgress import com.itsaky.androidide.ui.themes.IThemeManager import com.itsaky.androidide.utils.Environment -import com.itsaky.androidide.utils.isTestMode import com.itsaky.androidide.utils.PermissionsHelper import com.itsaky.androidide.utils.isAtLeastV import com.itsaky.androidide.utils.isSystemInDarkMode +import com.itsaky.androidide.utils.isTestMode import com.itsaky.androidide.utils.resolveAttr import com.termux.shared.android.PackageUtils import com.termux.shared.markdown.MarkdownUtils @@ -62,14 +62,13 @@ import kotlinx.coroutines.withContext import org.slf4j.LoggerFactory class OnboardingActivity : AppIntro2() { - private var listJdkInstallationsJob: Job? = null - private lateinit var feedbackButton: FloatingActionButton - private var feedbackButtonManager: FeedbackButtonManager? = null - private lateinit var nextButton: ImageButton - private lateinit var pulseAnimation: Animation + private lateinit var feedbackButton: FloatingActionButton + private var feedbackButtonManager: FeedbackButtonManager? = null + private lateinit var nextButton: ImageButton + private lateinit var pulseAnimation: Animation - companion object { + companion object { private val logger = LoggerFactory.getLogger(OnboardingActivity::class.java) private const val KEY_ARCHCONFIG_WARNING_IS_SHOWN = "ide.archConfig.experimentalWarning.isShown" @@ -103,14 +102,14 @@ class OnboardingActivity : AppIntro2() { setTransformer(AppIntroPageTransformerType.Fade) setProgressIndicator() showStatusBar(true) - setupFeedbackButton() + setupFeedbackButton() isIndicatorEnabled = true isWizardMode = true - nextButton = findViewById(R.id.next) - pulseAnimation = AnimationUtils.loadAnimation(this, R.anim.pulse_animation) + nextButton = findViewById(R.id.next) + pulseAnimation = AnimationUtils.loadAnimation(this, R.anim.pulse_animation) - addSlide(GreetingFragment()) + addSlide(GreetingFragment()) if (!PackageUtils.isCurrentUserThePrimaryUser(this)) { val errorMessage = @@ -161,44 +160,54 @@ class OnboardingActivity : AppIntro2() { } } - private fun setupFeedbackButton() { - val contentRootView = findViewById(android.R.id.content) - contentRootView.viewTreeObserver.addOnGlobalLayoutListener(object : - ViewTreeObserver.OnGlobalLayoutListener { - override fun onGlobalLayout() { - contentRootView.viewTreeObserver.removeOnGlobalLayoutListener(this) - - val appIntroContainer: ConstraintLayout? = findViewById(R.id.background) - if (appIntroContainer != null) { - // Reuse the shared feedback FAB definition (size, icon, elevation) so this - // matches every other screen (ADFA-2686); only positioning is set here. - feedbackButton = (layoutInflater.inflate( - R.layout.feedback_fab, appIntroContainer, false - ) as FloatingActionButton).apply { - layoutParams = ConstraintLayout.LayoutParams( - ConstraintLayout.LayoutParams.WRAP_CONTENT, - ConstraintLayout.LayoutParams.WRAP_CONTENT - ).apply { - startToStart = ConstraintLayout.LayoutParams.PARENT_ID - bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID - val marginInPx = - resources.getDimensionPixelSize(R.dimen.feedback_fab_margin) - setMargins(marginInPx, marginInPx, marginInPx, marginInPx) - } - } - - appIntroContainer.addView(feedbackButton) - feedbackButtonManager = FeedbackButtonManager( - activity = this@OnboardingActivity, - feedbackFab = feedbackButton - ) - feedbackButtonManager?.setupDraggableFab() - } else { - logger.error("Could not find AppIntro2 container to add FAB.") - } - } - }) - } + private fun setupFeedbackButton() { + val contentRootView = findViewById(android.R.id.content) + contentRootView.viewTreeObserver.addOnGlobalLayoutListener( + object : + ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + contentRootView.viewTreeObserver.removeOnGlobalLayoutListener(this) + + val appIntroContainer: ConstraintLayout? = findViewById(R.id.background) + if (appIntroContainer != null) { + // Reuse the shared feedback FAB definition (size, icon, elevation) so this + // matches every other screen (ADFA-2686); only positioning is set here. + feedbackButton = + ( + layoutInflater.inflate( + R.layout.feedback_fab, + appIntroContainer, + false, + ) as FloatingActionButton + ).apply { + layoutParams = + ConstraintLayout + .LayoutParams( + ConstraintLayout.LayoutParams.WRAP_CONTENT, + ConstraintLayout.LayoutParams.WRAP_CONTENT, + ).apply { + startToStart = ConstraintLayout.LayoutParams.PARENT_ID + bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID + val marginInPx = + resources.getDimensionPixelSize(R.dimen.feedback_fab_margin) + setMargins(marginInPx, marginInPx, marginInPx, marginInPx) + } + } + + appIntroContainer.addView(feedbackButton) + feedbackButtonManager = + FeedbackButtonManager( + activity = this@OnboardingActivity, + feedbackFab = feedbackButton, + ) + feedbackButtonManager?.setupDraggableFab() + } else { + logger.error("Could not find AppIntro2 container to add FAB.") + } + } + }, + ) + } override fun onResume() { super.onResume() @@ -221,27 +230,32 @@ class OnboardingActivity : AppIntro2() { tryNavigateToMainIfSetupIsCompleted() } - fun setOnboardingChromeVisible(visible: Boolean) { - isIndicatorEnabled = visible - isButtonsEnabled = visible - } + fun setOnboardingChromeVisible(visible: Boolean) { + isIndicatorEnabled = visible + isButtonsEnabled = visible + } override fun onPageSelected(position: Int) { super.onPageSelected(position) - when { - !nextButton.isVisible -> nextButton.clearAnimation() - !isTestMode() && nextButton.animation == null -> nextButton.startAnimation(pulseAnimation) - } + when { + !nextButton.isVisible -> nextButton.clearAnimation() + !isTestMode() && nextButton.animation == null -> nextButton.startAnimation(pulseAnimation) + } } private fun checkToolsIsInstalled(): Boolean = IJdkDistributionProvider.getInstance().installedDistributions.isNotEmpty() && Environment.ANDROID_HOME.exists() + // Deliberately the provider's loaded list, not isIdeSetupComplete()'s on-disk check: this screen + // can afford to wait for a validated JDK (it calls loadDistributions() itself when the list is + // empty, a few lines below) and should not hand over to MainActivity until one is really usable. + // The deep-link gate asks the weaker on-disk question because it runs on a cold-start main + // thread and cannot wait -- see SetupState.kt. private fun isSetupCompleted(): Boolean = checkToolsIsInstalled() && - PermissionsHelper.areAllPermissionsGranted(this) + PermissionsHelper.areAllPermissionsGranted(this) internal fun navigateToMain() { startActivity(Intent(this, MainActivity::class.java)) @@ -258,16 +272,16 @@ class OnboardingActivity : AppIntro2() { } private suspend fun reloadJdkDistInfo(distConsumer: (List) -> Unit) { - val distributionProvider = IJdkDistributionProvider.getInstance() - val currentDistributions = distributionProvider.installedDistributions - if (currentDistributions.isNotEmpty()) { - distConsumer(currentDistributions) - return - } + val distributionProvider = IJdkDistributionProvider.getInstance() + val currentDistributions = distributionProvider.installedDistributions + if (currentDistributions.isNotEmpty()) { + distConsumer(currentDistributions) + return + } - if (listJdkInstallationsJob?.isActive == true) { - return - } + if (listJdkInstallationsJob?.isActive == true) { + return + } listJdkInstallationsJob = doAsyncWithProgress( @@ -278,10 +292,10 @@ class OnboardingActivity : AppIntro2() { ) { _, _ -> distributionProvider.loadDistributions() withContext(Dispatchers.Main) { - if (!isFinishing && !isDestroyed) { - distConsumer(distributionProvider.installedDistributions) - } - } + if (!isFinishing && !isDestroyed) { + distConsumer(distributionProvider.installedDistributions) + } + } }.also { it?.invokeOnCompletion { listJdkInstallationsJob = null diff --git a/app/src/main/java/com/itsaky/androidide/activities/SetupState.kt b/app/src/main/java/com/itsaky/androidide/activities/SetupState.kt new file mode 100644 index 0000000000..1eda3cf0e3 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/activities/SetupState.kt @@ -0,0 +1,94 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.activities + +import android.content.Context +import androidx.annotation.VisibleForTesting +import com.itsaky.androidide.utils.Environment +import com.itsaky.androidide.utils.PermissionsHelper +import java.io.File + +/** + * Whether the IDE has everything it needs to open a project: a JDK, an SDK, and the permissions to + * reach them. + * + * Asked by [DeepLinkActivity], because a link arriving before setup finishes would otherwise walk + * straight past onboarding into an editor with no toolchain (ADFA-5067 review). + * + * OnboardingActivity deliberately does *not* share this: it asks the stricter question -- a JDK the + * provider has loaded and validated -- because it can afford to wait for one and must not hand over + * to MainActivity until the toolchain is really usable. This one has to answer on a cold-start main + * thread, where nothing has loaded yet, so it asks what is on disk. Two questions, not two copies of + * one question. + * + * Deliberately *not* the whole of what [SplashActivity] enforces -- free storage and the x86 exit are + * its business, and a caller that finds this false should send the user there rather than re-deciding + * any of it. + */ +internal fun Context.isIdeSetupComplete(): Boolean = + isJdkInstalled() && + androidSdkHome().exists() && + PermissionsHelper.areAllPermissionsGranted(this) + +/** + * Whether a JDK is present *on disk*, which is not the same question as whether one has been loaded + * into memory yet. + * + * The provider's `installedDistributions` is the obvious thing to ask, and it is wrong + * here: it returns an empty list until `loadDistributions()` has run, and that happens inside the + * loader coroutine `IDEApplication` launches on `Dispatchers.Default`. On a cold start an Activity's + * `onCreate` reaches the main thread first, so asking the provider says "no JDK" on a device that + * has one -- which for [DeepLinkActivity] meant discarding the link and telling the user to finish a + * setup they had already finished (ADFA-5067 review). + * + * So this reads the same directory `JdkUtils.findJavaInstallations` scans, and only that: one stat + * and one listing, cheap enough for the main thread, and true as soon as the bootstrap has unpacked + * regardless of what has been loaded. It deliberately does not validate the installations -- that is + * the provider's job once it runs, and a directory that exists but holds nothing usable is a broken + * install, not an unfinished setup. + */ +private fun isJdkInstalled(): Boolean { + val jvmDir = File(jdkInstallPrefix(), "lib/jvm") + return jvmDir.isDirectory && (jvmDir.list()?.isNotEmpty() == true) +} + +/** + * [Environment.PREFIX], or the same path it will hold once `Environment.init()` has run. + * + * `init()` runs inside the same unawaited loader coroutine that loads the JDK distributions (see + * [isJdkInstalled]), so on a cold start this main-thread read routinely happens first and finds the + * field still null -- and the field is not volatile, so even a completed `init()` guarantees nothing + * about visibility here (ADFA-5067 review). `File(null, "lib/jvm")` doesn't throw; it silently + * yields a *relative* path that exists nowhere, answering "not set up" on a fully set-up device and + * discarding the deep link. `init()` derives the field from constants (`new File(DEFAULT_ROOT)` + * then `"usr"`), and [Environment.DEFAULT_PREFIX] is that same path, so falling back to it reads + * the same directory without waiting on the loader. The field is still preferred when visible -- + * it is what the rest of the app uses, and tests redirect it to a temp dir. + */ +@VisibleForTesting +internal fun jdkInstallPrefix(): File = Environment.PREFIX ?: File(Environment.DEFAULT_PREFIX) + +/** + * [Environment.ANDROID_HOME], with the same pre-`Environment.init()` fallback as + * [jdkInstallPrefix]. Needed for the same race: before the fix in [jdkInstallPrefix], the only + * thing keeping [isIdeSetupComplete] from an NPE on this field was [isJdkInstalled] short-circuiting + * to false first. The literal mirrors [Environment]'s private `DEFAULT_ANDROID_HOME` + * (`DEFAULT_HOME + "/android-sdk"`), which `init()` assigns verbatim. + */ +@VisibleForTesting +internal fun androidSdkHome(): File = Environment.ANDROID_HOME ?: File(Environment.DEFAULT_HOME, "android-sdk") diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index 2d8f88dc70..eb780ae67f 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -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 @@ -211,6 +215,15 @@ abstract class BaseEditorActivity : val appLogsViewModel by viewModels() var appLogsCoordinator: AppLogsCoordinator? = null + // Mirrors EditorHandlerActivity's/ProjectHandlerActivity's own same-named, independently-tracked + // flags: set only once onCreate reaches its end without bailing out early (the "no matching + // project" doomed-duplicate-instance branch above returns before this runs). preDestroy() checks + // it before touching the process-wide singletons this onCreate registers this instance with + // (BuildOutputProvider, the plugin snippet-refresh listener) -- a doomed instance never actually + // registered as their owner, so clearing them on its teardown would wipe out whatever a + // genuinely live sibling instance set up instead. + private var didCompleteLiveOnCreate = false + @Suppress("ktlint:standard:backing-property-naming") internal var _binding: ActivityEditorBinding? = null val binding: ActivityEditorBinding @@ -459,9 +472,11 @@ abstract class BaseEditorActivity : internal abstract fun doOpenHelp() protected open fun preDestroy() { - BuildOutputProvider.clearBottomSheet() + if (didCompleteLiveOnCreate) { + BuildOutputProvider.clearBottomSheet() - IDEApplication.getPluginManager()?.setSnippetRefreshListener(null) + IDEApplication.getPluginManager()?.setSnippetRefreshListener(null) + } Shizuku.removeBinderReceivedListener(shizukuBinderReceivedListener) if (isAtLeastR()) wadbConnectionViewModel.stop(this) @@ -653,14 +668,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 +699,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 @@ -755,6 +820,8 @@ abstract class BaseEditorActivity : observeFileOperations() setupGestureDetector() + + didCompleteLiveOnCreate = true } override fun onConfigurationChanged(newConfig: Configuration) { diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index ecd7ff984f..8e183f6559 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -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,25 +91,36 @@ 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.repositories.RecentProjectRepository 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.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch @@ -109,6 +128,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 +177,21 @@ open class EditorHandlerActivity : } private val shortcutManager by lazy { ShortcutManager(applicationContext) } + private val analyticsManager: IAnalyticsManager by inject() + private val recentProjectRepository: RecentProjectRepository by inject() + private val pendingDeepLinkOpen: PendingDeepLinkOpen by inject() + + // The process-wide scope from AppModule, used only by saveAllAsync -- see there for why the + // activity's own scope is not enough. + private val appScope: CoroutineScope by inject() + private var pluginEditorProvider: EditorProviderImpl? = null + // True once onCreate() has completed past its isFinishing check -- see there and preDestroy() + // 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 +243,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 +274,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 +393,77 @@ 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(recentProjectRepository, 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() + + // Both gated on isFinishing: onDestroy() also runs for a non-finishing recreate (a config + // change EditorActivityKt's own configChanges doesn't cover - dark mode, locale, display size + // - or "Don't keep activities"), which can land while the confirm-close dialog above is still + // showing and pendingCloseCallback is already armed for it (set the moment the dialog opens, + // not once the user actually chooses an option - see confirmProjectClose). Without this guard, + // a config change the user never asked for silently confirms that pending close/switch and + // discards the project it was showing. The two legitimate confirm paths (Close without saving, + // Save and close) both call finish() before their own onClosed callback runs, so isFinishing is + // already true there by the time onDestroy() drains it. + if (isFinishing) { + // A "Close without saving" confirm deliberately leaves confirmCloseInProgress stuck true + // (see there) since this instance is finishing either way -- a later request that arrived in + // the window before onDestroy() actually ran got parked here with nothing else left to read + // it. Run and clear it now instead of silently orphaning it. + pendingCloseCallback?.invoke() + pendingCloseCallback = null + + // Drain any deep-link-triggered "close then reopen a different project" request recorded by + // onNewIntent's confirmProjectClose(onClosed) callback. This deliberately waits until onDestroy -- + // which only runs once the framework has committed to tearing this singleTask instance down -- + // rather than firing startActivity() synchronously right after finish(), because the two calls + // racing could otherwise have the new PROJECT_PATH redelivered to this dying instance via + // onNewIntent (which never reads it) instead of a genuinely new instance's onCreate. + drainPendingDeepLinkOpen() + } } 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() @@ -367,6 +499,11 @@ open class EditorHandlerActivity : editorView.markAsSaved() fileTimestamps[file.absolutePath] = currentTimestamp updateTabs() + // Without this, areFilesModified() (a cached flag, only ever recomputed as a side + // effect of a successful per-file write - see saveResultInternal) can stay + // stale-true after this reload+markAsSaved: nothing else here reflects that the + // buffer this loop just cleaned is no longer modified. + editorViewModel.areFilesModified = hasUnsavedFiles() } } } @@ -711,8 +848,18 @@ 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(selection) + editor.validateRange(safeSelection) + editor.setSelection(safeSelection) } } } @@ -723,7 +870,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(Range.NONE) val isImage = withContext(Dispatchers.IO) { ImageUtils.isImage(file) } if (isImage) { openImage(this@EditorHandlerActivity, file) @@ -876,14 +1031,52 @@ open class EditorHandlerActivity : requestSync: Boolean, processResources: Boolean, progressConsumer: ((Int, Int) -> Unit)?, - runAfter: (() -> Unit)?, + runAfter: ((Boolean) -> Unit)?, ) { - lifecycleScope.launch(Dispatchers.IO) { + // Not lifecycleScope: NonCancellable protects the body only once it has started running, and + // a launch on the IO dispatcher can still be queued when onDestroy() cancels the activity's + // scope -- in which case the body never starts and runAfter never runs, losing a confirmed + // deep-link project switch exactly as if the guard that used to skip it were still there + // (found in review). The application scope has no such window. The activity is retained for + // the duration of the save, which is what NonCancellable already implied. + appScope.launch(Dispatchers.IO) { + // The whole body -- not just saveAll() -- runs NonCancellable. onDestroy() cancels the + // activity's Job as soon as it runs; leaving NonCancellable partway through (e.g. + // right before invoking runAfter) would let that cancellation surface at the next + // suspension point and drop runAfter entirely instead of running it. Callers rely on it + // always running (e.g. confirmProjectClose's onClosed, which arms a pending deep-link + // 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) { + // NonCancellable above means this whole block, including this Main-dispatcher hop, + // keeps running even after onDestroy() -- unlike before this method wrapped the + // entire body in NonCancellable, when that hop was simply dropped on teardown. + // + // runAfter is invoked unconditionally, teardown included. A liveness check here used + // to skip it wholesale, which silently dropped the *non-UI* half of a callback's + // work: confirmProjectClose's onClosed arms a process-wide pending deep-link switch + // (ADFA-5067) that has to outlive this instance, so losing it means a confirmed + // "Save and close" never opens the project the link asked for, with nothing logged. + // Each callback decides for itself what needs a live window -- see the teardown + // branches at the two call sites in this file, and GitBottomSheetFragment's own + // _binding check. + runAfter?.invoke(saveSucceeded) + } } } } @@ -1011,6 +1204,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 +1418,30 @@ 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 { + // Nothing in this tail survives teardown usefully: flashError needs a live + // window, and invokeAfter closes tabs on a binding that is going away. The + // write itself already completed in saveAllAsync. + if (isFinishing || isDestroyed) return@runOnUiThread + // Matches confirmProjectClose's identical check: saveAllAsync's succeeded + // only means saveAll() didn't throw, not that every file's write actually + // landed (a silent per-file failure, e.g. disk full, leaves isModified + // 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 +1962,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 +1991,117 @@ 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() { + // Reset unconditionally, before the blank-path bail below: a blank projectDirPath (e.g. a + // post-process-death recreate with no PROJECT_PATH) must not leave these permanently set -- + // every later switch's capture guard would otherwise stay false forever, silently losing the + // staying project's pending file request on every subsequent decline for the rest of this + // instance's life. + val restore = pendingFileRequestBeforeSwitch + pendingFileRequestBeforeSwitch = null + capturedPendingFileRequestBeforeSwitch = false + + val stayingProjectPath = IProjectManager.getInstance().projectDirPath + if (stayingProjectPath.isBlank()) return + intent.putExtra("PROJECT_PATH", stayingProjectPath) + if (restore != null) { + intent.putExtra(PendingFileRequest.EXTRA_KEY, restore) + } else { + 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 +2111,412 @@ 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 + + // Teardown: no window for a message, and no point re-confirming a superseded request + // on an instance that is going away -- but the handoff below is process-wide state + // that a new instance drains, so it must still happen. Mirrors the contentOrNull == + // null branch further down, which exists for the same reason. + if (isFinishing || isDestroyed) { + val onClosedDuringTeardown = pendingCloseCallback + pendingCloseCallback = null + // Logged, not silent: this branch is the one that used to lose the switch, and it + // is invisible from the UI -- the only symptom was a project that never opened. + log.info( + "Save completed during teardown (isFinishing={}, isDestroyed={}); running the close callback anyway: {}.", + isFinishing, + isDestroyed, + onClosedDuringTeardown != null, + ) + onClosedDuringTeardown?.invoke() + if (isDestroyed) drainPendingDeepLinkOpen() + return@runOnUiThread + } + // 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. + // "PREVIOUS_PROJECT_PATH", when present, is what IProjectManager.projectDirPath held before + // MainActivity.openProject's bookkeeping call overwrote it to the NEW path -- by the time this + // intent arrives here, the global itself already reads as the new path regardless of whether + // this is actually a switch, so re-reading it for the comparison below would never detect one. + val previousProjectPath = + intent.getStringExtra("PREVIOUS_PROJECT_PATH") ?: IProjectManager.getInstance().projectDirPath + val isProjectSwitchIntent = + ( + deepLinkRequest != null && + !projectNamesMatch(File(IProjectManager.getInstance().projectDirPath).name, deepLinkRequest.projectName) + ) || + intent.getStringExtra("PROJECT_PATH")?.let { it != previousProjectPath } == true + + // Preserve a not-yet-applied file-navigation request from the previous intent -- postProjectInit + // reads it lazily once a sync completes, and setIntent() below would otherwise silently drop it + // 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) + // No unconditional drain here (unlike this method's earlier version): switchToProject's own + // same-project branch now drains this only once the request is actually applied or + // intentionally dropped, so a request arriving mid-sync stays armed for postProjectInit's + // deferred retry instead of being silently lost. The other branches (isFinishing, blank path, + // different project) don't read the intent's own copy at all -- they thread fileRequest + // through DeepLinkOpenRequest to a brand-new intent instead. + + // See onNewIntent's identical read: MainActivity.openProject's bookkeeping call already + // overwrote the live global to newProjectPath before this intent arrived. + val previousProjectPath = + intent.getStringExtra("PREVIOUS_PROJECT_PATH") ?: IProjectManager.getInstance().projectDirPath + switchToProject(newProjectPath, fileRequest, previousProjectPath) + } + + /** + * 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. + * + * [previousProjectPath] defaults to the live [IProjectManager] global, which is accurate for the + * deep-link caller (nothing pre-mutates it before onNewIntent runs there); the plain-switch caller + * passes its own pre-mutation snapshot instead, since by the time its intent arrives, + * MainActivity.openProject's bookkeeping has already overwritten that global to [newProjectPath]. + */ + private fun switchToProject( + newProjectPath: String, + fileRequest: PendingFileRequest?, + previousProjectPath: String = IProjectManager.getInstance().projectDirPath, + ) { + val currentProjectPath = previousProjectPath + when { + // This instance is already finishing (e.g. it just armed pendingDeepLinkOpen for an + // earlier switch and called finish() below, awaiting its own onDestroy()) -- comparing + // 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 -> { + // Gates both applying now and draining the intent's own copy below: a request + // arriving while the project is still syncing (workspace == null) must stay armed for + // postProjectInit's deferred retry once that sync completes, or it's lost for good -- + // applyDeepLinkFileRequest resolves against files a still-in-progress sync may not + // have settled yet. + val projectReady = IProjectManager.getInstance().workspace != null + if (confirmCloseInProgress) { + // A close-confirmation dialog for a *different* project switch is already + // showing -- navigating underneath it now would just get silently discarded if + // the user goes on to confirm that close. + flashError(getString(string.msg_project_close_in_progress)) + } else if (projectReady) { + fileRequest?.let { applyDeepLinkFileRequest(it) } + } else { + // Mid-sync: arm the request on the intent so postProjectInit's deferred retry + // finds it once the sync completes -- this branch is the "must stay armed" + // case the projectReady comment above describes, and without the put the + // request dies with this call while onNewIntent's carry-forward has already + // re-armed the PREVIOUS, still-unconsumed request, sending the editor to that + // stale target instead (ADFA-5067 review). The put also supersedes that + // carried-forward value, so this arm needs no removeExtra below. + fileRequest?.let { intent.putExtra(PendingFileRequest.EXTRA_KEY, it) } + } + if (fileRequest != null && (confirmCloseInProgress || projectReady)) { + // This request supersedes whatever onNewIntent's carry-forward guard just + // 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/ConsumedDeepLinkRequests.kt b/app/src/main/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequests.kt new file mode 100644 index 0000000000..90e5009a13 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequests.kt @@ -0,0 +1,51 @@ +package com.itsaky.androidide.deeplink + +import com.itsaky.androidide.models.DeepLinkRequest + +/** + * The deep-link requests this task has already acted on, so a redelivered Intent carrying one of + * them does not force its project open a second time (ADFA-5067). + * + * Every consumed request is remembered, not just the latest. One slot was not enough: after link A + * is consumed and link B arrives through `onNewIntent`, `setIntent` makes B the live Intent while + * the task still holds A as its launch Intent -- and that is the Intent a recreate after process + * death is given. A then failed a "same as the last consumed request" test and reopened its project, + * which is the very loss the single field existed to prevent. + * + * Kept out of the activity so the bookkeeping can be tested without one: this is the third distinct + * lifecycle path (config change, process death, second link) whose correctness rests entirely on it. + */ +internal class ConsumedDeepLinkRequests { + private val requests = LinkedHashSet() + + /** For `onSaveInstanceState`; pairs with [restore]. */ + fun toSavedList(): ArrayList = ArrayList(requests) + + /** Replaces the contents with [saved], which is null when there is no instance state to restore. */ + fun restore(saved: List?) { + requests.clear() + saved?.let(requests::addAll) + } + + operator fun contains(request: DeepLinkRequest): Boolean = request in requests + + /** + * Records [request] as acted on. Null is accepted and ignored: the caller's "latest request" can + * legitimately be unset by the time a confirmation dialog is answered. + * + * Oldest-first eviction past [MAX_REMEMBERED] keeps the saved Bundle bounded against a sender + * that fires links in a loop. The evicted case degrades to the old behaviour -- one spurious + * reopen of a link superseded 32 links ago -- which no real sequence reaches. + */ + fun add(request: DeepLinkRequest?) { + request ?: return + requests += request + while (requests.size > MAX_REMEMBERED) { + requests.remove(requests.first()) + } + } + + private companion object { + const val MAX_REMEMBERED = 32 + } +} diff --git a/app/src/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..993bf4d48e 100644 --- a/app/src/main/java/com/itsaky/androidide/di/AppModule.kt +++ b/app/src/main/java/com/itsaky/androidide/di/AppModule.kt @@ -1,10 +1,12 @@ 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.repositories.RecentProjectRepository +import com.itsaky.androidide.repositories.RecentProjectRepositoryImpl import com.itsaky.androidide.roomData.recentproject.RecentProjectRoomDatabase import com.itsaky.androidide.viewmodel.CloneRepositoryViewModel import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel @@ -14,8 +16,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 +27,28 @@ 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 { + RecentProjectRepositoryImpl(get()) + } - 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 689eab81e1..b64718a86c 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 @@ -33,6 +33,7 @@ import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.idetooltips.attachTooltip 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 @@ -677,7 +678,23 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { .setTitle(R.string.title_files_unsaved) .setMessage(R.string.msg_save_before_git_action) .setPositiveButton(R.string.save_before_git_action) { _, _ -> - handler.saveAllAsync { action() } + handler.saveAllAsync { succeeded -> + // saveAllAsync is owned by the activity's lifecycle and can still invoke this + // callback after onDestroyView() clears _binding -- the user navigating away while + // the save is in flight -- and action() dereferences binding, so bail out first. + if (_binding == null) { + return@saveAllAsync + } + // succeeded means saveAll() did not throw, not that every write landed: a silent + // per-file failure (disk full, say) leaves a file modified with succeeded still + // true, and running a commit or pull then operates on a tree whose edits were + // never written. areFilesModified() reflects the per-file state each save updates. + if (succeeded && handler.areFilesModified() == false) { + action() + } else { + flashError(R.string.save_failed) + } + } }.setNegativeButton(R.string.no_save_before_git_action) { _, _ -> action() }.setNeutralButton(android.R.string.cancel, null) diff --git a/app/src/main/java/com/itsaky/androidide/interfaces/IEditorHandler.kt b/app/src/main/java/com/itsaky/androidide/interfaces/IEditorHandler.kt index 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..7c8715d7bc --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt @@ -0,0 +1,198 @@ +/* + * 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://appdevforall.org/device/open/project/{projectName}[/file/{filename}[/line/{n}[/column/{n}]]]` + * (the `www` subdomain works identically -- see [HOSTS]). + */ +@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" + + // Both hosts serve an identical, verified assetlinks.json (see AndroidManifest.xml's matching + // pair of elements on DeepLinkActivity's intent-filter) -- kept in sync with that list. + private val HOSTS = setOf("www.appdevforall.org", "appdevforall.org") + private const val PATH_PREFIX = "/device/open/project/" + + private const val SEGMENT_PROJECT = "project" + 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) || + HOSTS.none { it.equals(uri.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/repositories/RecentProjectRepository.kt b/app/src/main/java/com/itsaky/androidide/repositories/RecentProjectRepository.kt new file mode 100644 index 0000000000..2c863b552b --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/repositories/RecentProjectRepository.kt @@ -0,0 +1,36 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.repositories + +import com.itsaky.androidide.roomData.recentproject.RecentProject + +/** + * Repository for recording a project's presence in Recents -- keeps [RecentProjectDao] (a Room + * data source) out of the UI layer, per ARCHITECTURE.md's UI -> ViewModel -> Repository -> data + * source layering. + */ +interface RecentProjectRepository { + /** Inserts [recentProject] into Recents; a no-op if a row for its location already exists. */ + suspend fun insert(recentProject: RecentProject) + + /** Updates the detected language for the Recents row at [location]. */ + suspend fun updateLanguage( + location: String, + language: String, + ) +} diff --git a/app/src/main/java/com/itsaky/androidide/repositories/RecentProjectRepositoryImpl.kt b/app/src/main/java/com/itsaky/androidide/repositories/RecentProjectRepositoryImpl.kt new file mode 100644 index 0000000000..617cbec725 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/repositories/RecentProjectRepositoryImpl.kt @@ -0,0 +1,32 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.repositories + +import com.itsaky.androidide.roomData.recentproject.RecentProject +import com.itsaky.androidide.roomData.recentproject.RecentProjectDao + +class RecentProjectRepositoryImpl( + private val recentProjectDao: RecentProjectDao, +) : RecentProjectRepository { + override suspend fun insert(recentProject: RecentProject) = recentProjectDao.insert(recentProject) + + override suspend fun updateLanguage( + location: String, + language: String, + ) = recentProjectDao.updateLanguage(location, language) +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt b/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt index 4324eec039..e50f376c59 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt @@ -90,7 +90,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", PLUGIN_ARCHIVE_EXTENSION, TEMPLATE_ARCHIVE_EXTENSION, "zip") + +/** File extensions [CodeEditorView.save] never writes -- these are opened read-only. */ +internal val ARCHIVE_EXTENSIONS = setOf("apk", PLUGIN_ARCHIVE_EXTENSION, TEMPLATE_ARCHIVE_EXTENSION, "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..407f5d1d06 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/PathTraversal.kt @@ -0,0 +1,104 @@ +/* + * 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 +import java.nio.file.LinkOption + +/** + * 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, a `..` *segment*, or a leading `/` or `\` -- cheap, + * catches the common case outright. Per segment, not as a substring: `notes..txt` and + * `a..b/c.kt` are legitimate filenames, and a deep link to one has no business failing. Only a + * literal `..` segment can name a parent directory, so nothing is lost -- and layers 2 and 3 + * below are what actually enforce containment in any case. (The sibling zip guards reached the + * same conclusion for the same reason; `ZipUtils.unzipFile` spells it out.) An empty string is + * rejected explicitly: [java.nio.file.Path.resolve] treats it as a no-op and returns [baseDir] + * itself unchanged, which would otherwise trivially pass the containment check below and violate + * this function's own "returns null" contract. + * 2. Resolve + normalize against [baseDir] and verify with [java.nio.file.Path.startsWith] (not + * string prefix matching, which would wrongly accept `/project` as inside `/project-evil`) -- + * this operates on Java's own resolved path, so it isn't fooled by however `..` made it into the + * 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? { + // Split on both separators: '\' is not a path separator on Android, but a caller handing over a + // Windows-style path should not have it silently treated as one long filename. + if (relativePath.isEmpty() || + relativePath.startsWith("/") || + relativePath.startsWith("\\") || + relativePath.split('/', '\\').any { it == ".." } + ) { + return null + } + + 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 + // NOFOLLOW_LINKS: plain Files.exists() follows symlinks, so a *dangling* symlink (one whose + // target doesn't currently exist) would otherwise read as absent here, walking straight past + // it to its parent instead of stopping to verify it -- toRealPath() below throws IOException + // (caught at the bottom) for a genuinely dangling target, correctly rejecting the path instead + // of silently trusting whatever ends up at the far side of it later. + while (!Files.exists(existingAncestor, LinkOption.NOFOLLOW_LINKS)) { + existingAncestor = existingAncestor.parent ?: return null + } + if (!existingAncestor.toRealPath().startsWith(realBase)) null else resolved.toFile() + } 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..83c7f4aba3 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt @@ -0,0 +1,96 @@ +/* + * 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.repositories.RecentProjectRepository +import com.itsaky.androidide.roomData.recentproject.RecentProject +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]). + * + * [recentProjectRepository] is the caller's Koin-provided instance (`by inject()`) -- per ADR + * 0001/0006, persistence is always acquired through Koin, never by re-deriving the database + * directly, and kept behind this repository interface (not the raw DAO) so callers in the UI layer + * (both [com.itsaky.androidide.activities.MainActivity] and + * [com.itsaky.androidide.activities.editor.EditorHandlerActivity]) don't depend on a Room data + * source directly, per ARCHITECTURE.md's UI -> ViewModel -> Repository -> data source layering. + * + * Uses [ProcessLifecycleOwner]'s scope rather than a per-activity one, since this can run from an + * activity's `onDestroy()` after its own `lifecycleScope` has already been cancelled. + */ +fun recordProjectOpenedBookkeeping( + recentProjectRepository: RecentProjectRepository, + 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. + recentProjectRepository.insert(recentProject) + if (!recentProject.language.equals(Language.Unknown.lang, ignoreCase = true)) { + recentProjectRepository.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/activities/DeepLinkSetupGateTest.kt b/app/src/test/java/com/itsaky/androidide/activities/DeepLinkSetupGateTest.kt new file mode 100644 index 0000000000..dbfedb2fa2 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/activities/DeepLinkSetupGateTest.kt @@ -0,0 +1,176 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.activities + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.app.configuration.IJdkDistributionProvider +import com.itsaky.androidide.utils.Environment +import com.itsaky.androidide.utils.PermissionsHelper +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import java.io.File + +/** + * A deep link must not walk past setup. Both of DeepLinkActivity's targets sit beyond + * SplashActivity and OnboardingActivity, which are the only things enforcing terms, permissions, + * the JDK and SDK install, the low-storage check and the x86 exit -- so a link arriving on a fresh + * install used to land the user in an editor that could not build (ADFA-5067 review). + * + * Robolectric's environment has no installed JDK distribution and no ANDROID_HOME, which is exactly + * the not-set-up state under test. + */ +@RunWith(RobolectricTestRunner::class) +class DeepLinkSetupGateTest { + @get:Rule + val tempFolder = TemporaryFolder() + + @Test + fun `a link arriving before setup is finished goes to the launcher chain, not the editor`() { + val intent = + Intent(Intent.ACTION_VIEW, Uri.parse("https://appdevforall.org/device/open/project/MyApp")) + val activity = Robolectric.buildActivity(DeepLinkActivity::class.java, intent).create().get() + + val next = shadowOf(activity).nextStartedActivity + assertThat(next).isNotNull() + assertThat(next.component?.className).isEqualTo(SplashActivity::class.java.name) + assertThat(activity.isFinishing).isTrue() + } + + @Test + fun `the setup predicate is false when no toolchain is installed`() { + val context = ApplicationProvider.getApplicationContext() + assertThat(context.isIdeSetupComplete()).isFalse() + } + + /** + * The cold-start case, and the reason this predicate reads the filesystem. + * + * `IJdkDistributionProvider.installedDistributions` is empty until the loader coroutine + * `IDEApplication` starts on `Dispatchers.Default` has run, and an Activity's `onCreate` beats it + * to the main thread. Asking the provider therefore answered "not set up" on a device that was, + * and the link was discarded with a message telling the user to finish a finished setup. Nothing + * loads the provider in this test either -- which is precisely the state under test. + */ + @Test + fun `the setup predicate is true from disk alone, with no distributions loaded`() { + val context = ApplicationProvider.getApplicationContext() + val prefix = tempFolder.newFolder("prefix") + File(prefix, "lib/jvm/jdk-17").mkdirs() + val previousPrefix = Environment.PREFIX + val previousHome = Environment.ANDROID_HOME + Environment.PREFIX = prefix + Environment.ANDROID_HOME = tempFolder.newFolder("android-sdk") + mockkObject(PermissionsHelper) + every { PermissionsHelper.areAllPermissionsGranted(any()) } returns true + try { + assertThat(IJdkDistributionProvider.getInstance().installedDistributions).isEmpty() + assertThat(context.isIdeSetupComplete()).isTrue() + } finally { + unmockkAll() + Environment.PREFIX = previousPrefix + Environment.ANDROID_HOME = previousHome + } + } + + // The second cold-start race, same shape as the provider one (ADFA-5067 review): + // Environment.init() runs on the same unawaited loader coroutine, so PREFIX and ANDROID_HOME + // are still null when a cold-start main thread asks. The gate must fall back to the constant + // defaults init() itself would assign, not wait -- and not NPE on ANDROID_HOME. + @Test + fun `the toolchain paths do not wait for Environment-init`() { + val previousPrefix = Environment.PREFIX + val previousHome = Environment.ANDROID_HOME + Environment.PREFIX = null + Environment.ANDROID_HOME = null + try { + assertThat(jdkInstallPrefix().path).isEqualTo(Environment.DEFAULT_PREFIX) + assertThat(androidSdkHome().path).isEqualTo(Environment.DEFAULT_HOME + "/android-sdk") + } finally { + Environment.PREFIX = previousPrefix + Environment.ANDROID_HOME = previousHome + } + } + + /** + * The regression from the ADFA-5067 review: toolchain fully on disk, `Environment.PREFIX` never + * assigned -- a cold start that beat the loader coroutine to `Environment.init()`. The old + * `File(Environment.PREFIX, "lib/jvm")` read silently became the relative path `lib/jvm` and + * answered false, so the link was discarded on a fully set-up device. + * + * [jdkInstallPrefix] is stubbed to a temp dir because its real null-fallback + * (`Environment.DEFAULT_PREFIX`, i.e. `/data/data/...`) is not creatable on a test host; the + * fallback's own value is pinned by the test above. What this test pins is that the predicate + * consults [jdkInstallPrefix] rather than reading the unassigned field directly. + */ + @Test + fun `the setup predicate is true with the JDK on disk and PREFIX never assigned`() { + val context = ApplicationProvider.getApplicationContext() + val prefix = tempFolder.newFolder("prefix-cold-start") + File(prefix, "lib/jvm/jdk-17").mkdirs() + val previousPrefix = Environment.PREFIX + val previousHome = Environment.ANDROID_HOME + Environment.PREFIX = null + Environment.ANDROID_HOME = tempFolder.newFolder("android-sdk-cold-start") + mockkStatic("com.itsaky.androidide.activities.SetupStateKt") + every { jdkInstallPrefix() } returns prefix + mockkObject(PermissionsHelper) + every { PermissionsHelper.areAllPermissionsGranted(any()) } returns true + try { + assertThat(context.isIdeSetupComplete()).isTrue() + } finally { + unmockkAll() + Environment.PREFIX = previousPrefix + Environment.ANDROID_HOME = previousHome + } + } + + // ...and an empty lib/jvm is not a JDK: a bootstrap that unpacked the directory but no + // distribution is an unfinished install, which the gate should still refuse. + @Test + fun `an empty lib-jvm directory does not count as installed`() { + val context = ApplicationProvider.getApplicationContext() + val prefix = tempFolder.newFolder("prefix-empty") + File(prefix, "lib/jvm").mkdirs() + val previousPrefix = Environment.PREFIX + val previousHome = Environment.ANDROID_HOME + Environment.PREFIX = prefix + Environment.ANDROID_HOME = tempFolder.newFolder("android-sdk-empty") + mockkObject(PermissionsHelper) + every { PermissionsHelper.areAllPermissionsGranted(any()) } returns true + try { + assertThat(context.isIdeSetupComplete()).isFalse() + } finally { + unmockkAll() + Environment.PREFIX = previousPrefix + Environment.ANDROID_HOME = previousHome + } + } +} diff --git a/app/src/test/java/com/itsaky/androidide/activities/DeepLinkTargetsNotExportedTest.kt b/app/src/test/java/com/itsaky/androidide/activities/DeepLinkTargetsNotExportedTest.kt new file mode 100644 index 0000000000..52f35a070b --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/activities/DeepLinkTargetsNotExportedTest.kt @@ -0,0 +1,63 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.activities + +import android.content.ComponentName +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.activities.editor.EditorActivityKt +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * DeepLinkActivity validates a deep-link URI and then hands the parsed request on to one of these + * two activities in an Intent extra. Neither may be reachable from outside the app: an exported + * target can be sent that same extra directly, skipping the validation, to force an arbitrary + * project open and navigate to an arbitrary file inside it. + * + * MainActivity was exported with no intent-filter of its own, so nothing legitimate needed it + * (SplashActivity holds MAIN/LAUNCHER) and the extra was forgeable (ADFA-5067 review). + */ +@RunWith(RobolectricTestRunner::class) +class DeepLinkTargetsNotExportedTest { + @Test + fun `the activities DeepLinkActivity hands a parsed request to are not exported`() { + val context = ApplicationProvider.getApplicationContext() + + for (target in listOf(MainActivity::class.java, EditorActivityKt::class.java)) { + val info = + context.packageManager.getActivityInfo(ComponentName(context, target), 0) + assertThat(info.exported).isFalse() + } + } + + // The activity that does the validating must stay exported -- it is the App Link entry point, and + // a non-exported one would make every deep link a no-op rather than a security improvement. + @Test + fun `DeepLinkActivity itself is exported`() { + val context = ApplicationProvider.getApplicationContext() + val info = + context.packageManager.getActivityInfo( + ComponentName(context, DeepLinkActivity::class.java), + 0, + ) + assertThat(info.exported).isTrue() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/activities/editor/SameProjectDeepLinkMidSyncTest.kt b/app/src/test/java/com/itsaky/androidide/activities/editor/SameProjectDeepLinkMidSyncTest.kt new file mode 100644 index 0000000000..179a343e97 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/activities/editor/SameProjectDeepLinkMidSyncTest.kt @@ -0,0 +1,109 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.activities.editor + +import android.content.Intent +import androidx.core.content.IntentCompat +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.app.BaseApplication +import com.itsaky.androidide.models.PendingFileRequest +import com.itsaky.androidide.projects.IProjectManager +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * A deep link into the project that is already open, arriving while that project is still syncing + * (`workspace == null`), must not be dropped: `switchToProject`'s same-project branch has to arm + * the request on the intent so `postProjectInit`'s deferred retry finds it once the sync completes + * (ADFA-5067 review). + * + * The failure mode being pinned is double: the new request used to die in a local variable, and + * because `onNewIntent`'s carry-forward guard had already re-armed the *previous*, still-unconsumed + * request onto the intent, `postProjectInit` then navigated to that stale target -- the link + * appeared to work, at the wrong file. + * + * Mirrors [RestorePluginTabsThreadTest]'s approach of exercising a real, private production method + * on an activity that has been built but not created -- creating the full editor activity is far + * beyond what a JVM test can do, and everything this path touches (the intent, the project + * manager, the binding null-check) can be provided directly. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = SameProjectDeepLinkMidSyncTest.TestApp::class) +class SameProjectDeepLinkMidSyncTest { + open class TestApp : BaseApplication() + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `a mid-sync same-project request stays armed and supersedes the carried-forward one`() { + val projectPath = "/projects/MyApp" + mockkObject(IProjectManager.Companion) + val projectManager = mockk(relaxed = true) + every { projectManager.projectDirPath } returns projectPath + // The state under test: the project has started opening but the Gradle sync has not + // completed, so the workspace is not available yet. + every { projectManager.workspace } returns null + every { IProjectManager.getInstance() } returns projectManager + + val activity = + Robolectric + .buildActivity(EditorHandlerActivity::class.java, Intent()) + .get() + // Non-null binding so switchToProject takes its same-project branch instead of the + // binding-torn-down handoff; nothing on the branch under test touches the views. + activity._binding = mockk(relaxed = true) + + // What onNewIntent's carry-forward guard re-arms from the previous intent: the earlier, + // still-unconsumed request. Without the fix, postProjectInit would find (and navigate to) + // this one. + val staleRequest = PendingFileRequest("file/A.kt", null, null) + activity.intent.putExtra(PendingFileRequest.EXTRA_KEY, staleRequest) + + val newRequest = PendingFileRequest("file/B.kt", "10", "2") + val switchToProject = + EditorHandlerActivity::class.java.getDeclaredMethod( + "switchToProject", + String::class.java, + PendingFileRequest::class.java, + String::class.java, + ) + switchToProject.isAccessible = true + switchToProject.invoke(activity, projectPath, newRequest, projectPath) + + // postProjectInit's deferred retry reads exactly this extra once the sync completes: it + // must find the new request -- not nothing, and not the stale carried-forward one. + val armed = + IntentCompat.getParcelableExtra( + activity.intent, + PendingFileRequest.EXTRA_KEY, + PendingFileRequest::class.java, + ) + assertThat(armed).isEqualTo(newRequest) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequestsTest.kt b/app/src/test/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequestsTest.kt new file mode 100644 index 0000000000..0ddd6af983 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/deeplink/ConsumedDeepLinkRequestsTest.kt @@ -0,0 +1,89 @@ +package com.itsaky.androidide.deeplink + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.models.DeepLinkRequest +import org.junit.Test + +/** + * ADFA-5067: a redelivered Intent must not force its project open twice. + * + * The scenario that matters is the one a single stored request got wrong -- two links, then process + * death, where the Intent the system hands back is the task's *launch* Intent rather than the last + * one `setIntent` saw. + */ +class ConsumedDeepLinkRequestsTest { + private fun request(name: String) = DeepLinkRequest(projectName = name) + + @Test + fun `a consumed request is recognised`() { + val consumed = ConsumedDeepLinkRequests() + consumed.add(request("alpha")) + + assertThat(request("alpha") in consumed).isTrue() + assertThat(request("beta") in consumed).isFalse() + } + + // The single-slot bug: consuming B made A look unconsumed again, and A is what a post-process-death + // recreate is handed, so A's project reopened over whatever the user was doing. + @Test + fun `consuming a second request does not un-consume the first`() { + val consumed = ConsumedDeepLinkRequests() + consumed.add(request("alpha")) + consumed.add(request("beta")) + + assertThat(request("alpha") in consumed).isTrue() + assertThat(request("beta") in consumed).isTrue() + } + + @Test + fun `the set survives a save and restore`() { + val consumed = ConsumedDeepLinkRequests() + consumed.add(request("alpha")) + consumed.add(request("beta")) + + val restored = ConsumedDeepLinkRequests() + restored.restore(consumed.toSavedList()) + + assertThat(request("alpha") in restored).isTrue() + assertThat(request("beta") in restored).isTrue() + } + + @Test + fun `restoring nothing leaves an empty set, not a stale one`() { + val consumed = ConsumedDeepLinkRequests() + consumed.add(request("alpha")) + consumed.restore(null) + + assertThat(request("alpha") in consumed).isFalse() + } + + @Test + fun `a repeated request is remembered once`() { + val consumed = ConsumedDeepLinkRequests() + consumed.add(request("alpha")) + consumed.add(request("alpha")) + + assertThat(consumed.toSavedList()).containsExactly(request("alpha")) + } + + @Test + fun `a null request is ignored`() { + val consumed = ConsumedDeepLinkRequests() + consumed.add(null) + + assertThat(consumed.toSavedList()).isEmpty() + } + + // The cap bounds the saved Bundle; what it must not do is forget the most recent requests. + @Test + fun `past the cap the oldest is evicted and the newest kept`() { + val consumed = ConsumedDeepLinkRequests() + repeat(40) { consumed.add(request("project$it")) } + + assertThat(consumed.toSavedList()).hasSize(32) + assertThat(request("project0") in consumed).isFalse() + assertThat(request("project7") in consumed).isFalse() + assertThat(request("project8") in consumed).isTrue() + assertThat(request("project39") in consumed).isTrue() + } +} 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..4000d86305 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt @@ -0,0 +1,303 @@ +/* + * 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 `apex host without the www subdomain also matches`() { + // Both hosts serve an identical, verified assetlinks.json - see AndroidManifest.xml's matching + // pair of elements on DeepLinkActivity's intent-filter. + val request = parse("https://appdevforall.org/device/open/project/MyApp") + assertThat(request).isEqualTo(DeepLinkRequest(projectName = "MyApp")) + } + + @Test + fun `wrong path prefix yields null`() { + assertThat(parse("https://www.appdevforall.org/some/other/path/project/MyApp")).isNull() + } + + @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..d24ab8efc8 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/PathTraversalTest.kt @@ -0,0 +1,160 @@ +/* + * 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() + } + + // This used to be rejected, on the reasoning that project files never legitimately need + // consecutive dots. They do -- and a deep link to one failing with no explanation is a bug, not + // a safe trade-off. Nothing is given up: only a literal ".." *segment* can name a parent, and + // the tests below cover every way of writing one. + @Test + fun `a filename containing dot-dot is resolved, not rejected`() { + assertThat(resolveWithinDirectory(baseDir, "notes..txt")) + .isEqualTo(File(baseDir, "notes..txt").absoluteFile) + assertThat(resolveWithinDirectory(baseDir, "a..b/c.kt")) + .isEqualTo(File(baseDir, "a..b/c.kt").absoluteFile) + assertThat(resolveWithinDirectory(baseDir, "....gitignore")) + .isEqualTo(File(baseDir, "....gitignore").absoluteFile) + } + + // The segment itself, in every position, is still refused. + @Test + fun `a dot-dot segment is rejected wherever it appears`() { + assertThat(resolveWithinDirectory(baseDir, "..")).isNull() + assertThat(resolveWithinDirectory(baseDir, "../x")).isNull() + assertThat(resolveWithinDirectory(baseDir, "a/../b")).isNull() + assertThat(resolveWithinDirectory(baseDir, "a/..")).isNull() + assertThat(resolveWithinDirectory(baseDir, "a\\..\\b")).isNull() + } + + // Percent-decoding happens in Uri.pathSegments before this function sees the string, so an + // encoded traversal arrives as a literal ".." segment and is caught above. A double-encoded one + // arrives as the harmless filename "%2e%2e", which cannot name a parent directory. + @Test + fun `a double-encoded dot-dot is an ordinary filename`() { + assertThat(resolveWithinDirectory(baseDir, "%2e%2e/x")) + .isEqualTo(File(baseDir, "%2e%2e/x").absoluteFile) + } + + @Test + 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..13ba3c2678 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,30 @@ object ZipUtils { val entries = zip.entries() while (entries.hasMoreElements()) { val entry = entries.nextElement() + + // Per-segment, not a bare substring match: "notes..txt" or "a..b/c.txt" are harmless + // names that a substring check would wrongly abort the whole archive over. + if (entry.name.startsWith("/") || entry.name.startsWith("\\") || + entry.name.split('/', '\\').any { it == ".." } + ) { + throw IOException("Zip entry contains dangerous path components: ${entry.name}") + } + 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. A user symlinking e.g. gradlew or + // gradle/wrapper to a shared location is legitimate, so skip this one entry (leaving + // their symlink as-is) rather than aborting the whole extraction over it. + if (Files.isSymbolicLink(outFile.toPath())) { + continue + } + 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..b21ff2bf06 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,70 @@ class ZipUtilsTest { val escapedFile = File(destDir.parentFile, "evil.txt") assertThat(escapedFile.exists()).isFalse() } + + @Test + fun `unzipFile skips an entry that would extract over an existing symlink, without aborting the rest`() { + val destDir = tempFolder.newFolder("dest") + val realFile = File(destDir, "real.txt").apply { writeText("original") } + val linkPath = File(destDir, "link.txt").toPath() + 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. A second, + // unrelated entry proves a skip doesn't abort the whole archive (e.g. a user's legitimately + // symlinked gradlew alongside a normal Gradle wrapper zip entry). + val zipFile = tempFolder.newFile("archive.zip") + ZipOutputStream(zipFile.outputStream()).use { zip -> + zip.putNextEntry(ZipEntry("link.txt")) + zip.write("payload".toByteArray()) + zip.closeEntry() + + zip.putNextEntry(ZipEntry("unrelated.txt")) + zip.write("unrelated content".toByteArray()) + zip.closeEntry() + } + + val extracted = ZipUtils.unzipFile(zipFile, destDir) + + assertThat(Files.isSymbolicLink(linkPath)).isTrue() + assertThat(realFile.readText()).isEqualTo("original") + assertThat(File(destDir, "unrelated.txt").readText()).isEqualTo("unrelated content") + assertThat(extracted.map { it.name }).containsExactly("unrelated.txt") + } + + @Test + fun `unzipFile allows a harmless double-dot inside a path segment`() { + val zipFile = tempFolder.newFile("archive.zip") + ZipOutputStream(zipFile.outputStream()).use { zip -> + zip.putNextEntry(ZipEntry("notes..txt")) + zip.write("note content".toByteArray()) + zip.closeEntry() + + zip.putNextEntry(ZipEntry("a..b/c.txt")) + zip.write("nested content".toByteArray()) + zip.closeEntry() + } + + val destDir = tempFolder.newFolder("dest") + ZipUtils.unzipFile(zipFile, destDir) + + assertThat(File(destDir, "notes..txt").readText()).isEqualTo("note content") + assertThat(File(destDir, "a..b/c.txt").readText()).isEqualTo("nested content") + } } diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 6bdd55490b..fd336f3ce6 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -137,6 +137,15 @@ 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. + Finish setting up Code on the Go, then open this link again. + No project named \"%s\" was found. + File \"%s\" was not found in the project. + \"%s\" is not a valid line number. + \"%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