diff --git a/.well-known/README.md b/.well-known/README.md new file mode 100644 index 0000000000..a4e94b30d1 --- /dev/null +++ b/.well-known/README.md @@ -0,0 +1,20 @@ +# `.well-known` (ADFA-5067) + +`assetlinks.json` in this directory is the [RFC 5785](https://www.rfc-editor.org/rfc/rfc5785) / +[Digital Asset Links](https://developers.google.com/digital-asset-links) file required for Android +App Links to `https://www.appdevforall.org/device/open/project/...` to auto-verify. + +This directory lives in the repo only until the actual website exists. To activate it: + +1. Copy this directory verbatim to the web server root, so it serves at + `https://www.appdevforall.org/.well-known/assetlinks.json` with `Content-Type: application/json`. +2. Replace the `TODO_REPLACE_WITH_RELEASE_SIGNING_SHA256_FINGERPRINT` placeholder with the SHA-256 + fingerprint of the certificate that actually signs the released APK/AAB — get it via + `keytool -list -v -keystore ` (whoever holds the release keystore), or from the Play + Console under **App integrity > App signing key certificate** if Play App Signing is used. This + cannot be filled in from source; it's a secret held by release engineering, not derivable from this + repository. + +Until both steps are done, `android:autoVerify="true"` on `DeepLinkActivity`'s intent-filter will fail +Digital Asset Links verification, and Android may show a disambiguation chooser instead of opening the +app directly when a link is tapped. This is expected for now. diff --git a/.well-known/assetlinks.json b/.well-known/assetlinks.json new file mode 100644 index 0000000000..51c327cb12 --- /dev/null +++ b/.well-known/assetlinks.json @@ -0,0 +1,12 @@ +[ + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "com.itsaky.androidide", + "sha256_cert_fingerprints": [ + "TODO_REPLACE_WITH_RELEASE_SIGNING_SHA256_FINGERPRINT" + ] + } + } +] diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3ecaccc691..d9312d6b1e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -51,6 +51,12 @@ Feature code layers as **UI → ViewModel → Repository → data source**, with **EventBus is a deliberate side-channel.** Long-running, cross-module signals (build/install lifecycle, editor events) are broadcast via GreenRobot EventBus (`@Subscribe(threadMode = ThreadMode.MAIN)`) and the `eventbus-events` module's shared event types. Treat it as the integration bus *between* subsystems; don't use it to replace a ViewModel's own state inside a single screen. +**App Links enter through a UI-less trampoline, not `MainActivity` directly.** `DeepLinkActivity` (`app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt`) is the sole `` holder for `https://www.appdevforall.org/device/open/project/...`. It never renders anything — it parses the URI into a `DeepLinkRequest` (project name plus an optional file/line/column), checks whether an editor is already on screen (`ActionContextProvider.getActivity()`, the live `EditorHandlerActivity` tracker -- not `IProjectManager`'s `workspace`, which stays null for the whole duration of a Gradle sync even while the editor is already open), and routes to `MainActivity` (nothing open) or the live, `singleTask` `EditorActivityKt`/`EditorHandlerActivity` (a project is open — reused via `onNewIntent`), then finishes itself. This avoids a visible flash of `MainActivity`'s real UI when the actual destination is the already-running editor. + +`EditorHandlerActivity.onNewIntent` then branches three ways, comparing `projectDirPath` (set as soon as a project starts opening) rather than `workspace` so the mid-sync case still matches correctly: **same project already open** — no project-wise work, just navigate to the requested file (`applyDeepLinkFileRequest`); **a different project is open** — the existing, unmodified `confirmProjectClose()` dialog runs (it also guards against a second confirm-close request overlapping a manual close or an in-flight save), and only once the user actually confirms does an `onDestroy()`-triggered hand-off (`PendingDeepLinkOpen`, Koin-provided) start the new project — deliberately deferred to `onDestroy()`, not fired synchronously after `finish()`, so the new `PROJECT_PATH` can't race a `singleTask` re-delivery to the dying instance; **nothing was open** — `MainActivity.openProject`/`EditorHandlerActivity.postProjectInit` apply the pending file request once the cold-opened project's sync succeeds. + +The optional file path is attacker-controllable (a URL segment), so it's resolved through `PathTraversal.resolveWithinDirectory`'s traversal/symlink guard rather than a bare `File` join, both when opening a file in the already-open project and when matching the requested project name to a directory under `Environment.PROJECTS_DIR` (`findValidProjectByName`). + ## Module Structure Strategy: **layer-and-subsystem based**, not feature-by-feature. The Gradle build has ~80 modules (`settings.gradle.kts`) plus three included composite builds. `app` is the integration point; the rest are libraries it composes. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cf216f8b6c..6f4b45c240 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -96,6 +96,22 @@ android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize" android:exported="true" android:theme="@style/Theme.AndroidIDE" /> + + + + + + + + . + */ + +package com.itsaky.androidide.activities + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import com.itsaky.androidide.activities.editor.EditorActivityKt +import com.itsaky.androidide.api.ActionContextProvider +import com.itsaky.androidide.models.DeepLinkRequest +import com.itsaky.androidide.resources.R.string + +/** + * The sole `` holder for `https://www.appdevforall.org/device/open/project/...` App + * Links. Never shows any UI -- it only parses the incoming [android.net.Uri], decides whether a + * project is already loaded, and hands off to whichever real activity owns that scenario: + * [MainActivity] if nothing is open yet, or the already-running [EditorActivityKt] (via its + * `singleTask` `onNewIntent`) if one is. + * + * Kept as a plain [Activity] (like [SplashActivity]), not [com.itsaky.androidide.app.BaseIDEActivity], + * since it never calls `setContentView` and has no theming needs of its own. + */ +class DeepLinkActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val request = DeepLinkRequest.parse(intent?.data) + if (request == null) { + // A Toast, not flashError -- this activity finishes immediately below, tearing down its + // window before a view-based Flashbar could ever render. + Toast.makeText(this, getString(string.msg_deeplink_invalid_link), Toast.LENGTH_LONG).show() + finish() + return + } + + // ActionContextProvider tracks the live EditorHandlerActivity instance (set in its + // onResume, cleared in onDestroy) -- this reflects "is an editor actually on screen", + // unlike IProjectManager's workspace, which stays null for the whole duration of a + // Gradle sync even while EditorActivityKt is already open and visible. + val target = + if (ActionContextProvider.getActivity() != null) { + EditorActivityKt::class.java + } else { + MainActivity::class.java + } + + startActivity( + Intent(this, target).apply { + putExtra(DeepLinkRequest.EXTRA_KEY, request) + // If `target` is MainActivity and one already exists in the task, reuse it via + // onNewIntent instead of stacking a second instance -- SINGLE_TOP alone isn't enough + // here, since DeepLinkActivity (not MainActivity) is what's actually on top of the + // stack at this exact call, so SINGLE_TOP's "already at the top" check never matches; + // CLEAR_TOP finds MainActivity anywhere in the task and reuses it via onNewIntent + // (combined with SINGLE_TOP, rather than the destroy-and-recreate CLEAR_TOP alone + // would do). EditorActivityKt is singleTask, so it always reuses its live instance + // regardless of these flags. + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP or + Intent.FLAG_ACTIVITY_CLEAR_TOP, + ) + }, + ) + finish() + } +} diff --git a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt index 7f51981128..e60fc667c2 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt @@ -23,6 +23,7 @@ import android.os.Bundle import android.view.KeyEvent import android.view.View import androidx.activity.OnBackPressedCallback +import androidx.core.content.IntentCompat import androidx.core.graphics.Insets import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible @@ -44,10 +45,12 @@ import com.itsaky.androidide.idetooltips.TooltipTag.PROJECT_RECENT_TOP import com.itsaky.androidide.idetooltips.TooltipTag.SETUP_OVERVIEW import com.itsaky.androidide.localWebServer.ServerConfig import com.itsaky.androidide.localWebServer.WebServer +import com.itsaky.androidide.models.DeepLinkRequest +import com.itsaky.androidide.models.PendingFileRequest import com.itsaky.androidide.preferences.internal.GeneralPreferences -import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.resources.R.string import com.itsaky.androidide.roomData.recentproject.RecentProject +import com.itsaky.androidide.roomData.recentproject.RecentProjectDao import com.itsaky.androidide.shortcuts.IdeShortcutActions import com.itsaky.androidide.shortcuts.ShortcutContext import com.itsaky.androidide.shortcuts.ShortcutExecutionContext @@ -60,11 +63,11 @@ import com.itsaky.androidide.utils.MainScreenActions import com.itsaky.androidide.utils.UrlManager import com.itsaky.androidide.utils.applyBottomWindowInsetsPadding import com.itsaky.androidide.utils.findValidProjects +import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashInfo -import com.itsaky.androidide.utils.getCreatedTime -import com.itsaky.androidide.utils.getLastModifiedTime import com.itsaky.androidide.utils.hasVisibleDialog -import com.itsaky.androidide.utils.readProjectLanguage +import com.itsaky.androidide.utils.recordProjectOpenedBookkeeping +import com.itsaky.androidide.utils.resolveDeepLinkProject import com.itsaky.androidide.viewmodel.MainViewModel import com.itsaky.androidide.viewmodel.MainViewModel.Companion.SCREEN_CLONE_REPO import com.itsaky.androidide.viewmodel.MainViewModel.Companion.SCREEN_DELETE_PROJECTS @@ -89,6 +92,7 @@ class MainActivity : EdgeToEdgeIDEActivity() { @Suppress("ktlint:standard:backing-property-naming") private var _binding: ActivityMainBinding? = null private val analyticsManager: IAnalyticsManager by inject() + private val recentProjectDao: RecentProjectDao by inject() private var feedbackButtonManager: FeedbackButtonManager? = null private var webServer: WebServer? = null private val shortcutManager by lazy { ShortcutManager(applicationContext) } @@ -128,7 +132,14 @@ class MainActivity : EdgeToEdgeIDEActivity() { startWebServer() if (savedInstanceState == null) { - openLastProject() + val deepLinkRequest = + IntentCompat.getParcelableExtra(intent, DeepLinkRequest.EXTRA_KEY, DeepLinkRequest::class.java) + if (deepLinkRequest != null) { + intent.removeExtra(DeepLinkRequest.EXTRA_KEY) // don't reapply on a later config-change recreate + handleDeepLinkRequest(deepLinkRequest) + } else { + openLastProject() + } } if (FeatureFlags.isExperimentsEnabled) { @@ -419,25 +430,9 @@ class MainActivity : EdgeToEdgeIDEActivity() { root: File, project: RecentProject? = null, hasTemplateIssues: Boolean = false, + pendingFileRequest: PendingFileRequest? = null, ) { - ProjectManagerImpl.getInstance().projectPath = root.absolutePath - GeneralPreferences.lastOpenedProject = root.absolutePath - - lifecycleScope.launch(Dispatchers.IO) { - val location = root.absolutePath - val recentProject = - project ?: RecentProject( - name = root.name, - location = location, - createdAt = getCreatedTime(location).toString(), - lastModified = getLastModifiedTime(location).toString(), - language = readProjectLanguage(root), - ) - viewModel.saveProjectToRecents(recentProject) - } - - // Track project open in Firebase Analytics - analyticsManager.trackProjectOpened(root.absolutePath) + recordProjectOpenedBookkeeping(recentProjectDao, root, project, analyticsManager) if (isFinishing) { return @@ -449,6 +444,7 @@ class MainActivity : EdgeToEdgeIDEActivity() { if (hasTemplateIssues) { putExtra("HAS_TEMPLATE_ISSUES", true) } + pendingFileRequest?.let { putExtra(PendingFileRequest.EXTRA_KEY, it) } addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) } @@ -479,6 +475,25 @@ class MainActivity : EdgeToEdgeIDEActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + setIntent(intent) + IntentCompat + .getParcelableExtra(intent, DeepLinkRequest.EXTRA_KEY, DeepLinkRequest::class.java) + ?.let { handleDeepLinkRequest(it) } + } + + /** + * Resolves [request]'s project name to an on-disk project directory and opens it -- called when + * [DeepLinkActivity] has already determined no project is currently loaded. A deep-link-triggered + * open bypasses [GeneralPreferences.confirmProjectOpen]: tapping the link is itself an explicit + * request for this specific project, so re-confirming it would be redundant friction. + */ + private fun handleDeepLinkRequest(request: DeepLinkRequest) { + lifecycleScope.launch(Dispatchers.IO) { + val projectDir = resolveDeepLinkProject(Environment.PROJECTS_DIR, request.projectName) ?: return@launch + withContext(Dispatchers.Main) { + openProject(projectDir, pendingFileRequest = request.fileRequest) + } + } } override fun onDestroy() { 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..1b15aa72b7 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -29,7 +29,10 @@ import android.view.KeyEvent import android.view.View import android.view.ViewGroup.LayoutParams import android.widget.TextView +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog import androidx.collection.MutableIntObjectMap +import androidx.core.content.IntentCompat import androidx.core.content.res.ResourcesCompat import androidx.core.view.GravityCompat import androidx.core.view.doOnNextLayout @@ -48,6 +51,7 @@ import com.itsaky.androidide.actions.ActionsRegistry.Companion.getInstance import com.itsaky.androidide.actions.build.QuickRunAction import com.itsaky.androidide.actions.internal.DefaultActionsRegistry import com.itsaky.androidide.activities.PluginManagerActivity +import com.itsaky.androidide.analytics.IAnalyticsManager import com.itsaky.androidide.api.ActionContextProvider import com.itsaky.androidide.app.BaseApplication import com.itsaky.androidide.app.EditorEvents @@ -55,6 +59,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 +76,13 @@ import com.itsaky.androidide.fragments.sidebar.EditorSidebarFragment import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.interfaces.IEditorHandler +import com.itsaky.androidide.models.DeepLinkOpenRequest +import com.itsaky.androidide.models.DeepLinkRequest import com.itsaky.androidide.models.FileExtension import com.itsaky.androidide.models.OpenedFile import com.itsaky.androidide.models.OpenedFilesCache +import com.itsaky.androidide.models.PendingFileRequest +import com.itsaky.androidide.models.Position import com.itsaky.androidide.models.Range import com.itsaky.androidide.models.SaveResult import com.itsaky.androidide.plugins.manager.build.PluginBuildActionManager @@ -83,24 +92,33 @@ import com.itsaky.androidide.plugins.manager.ui.PluginEditorTabManager import com.itsaky.androidide.plugins.manager.ui.PluginToolbarHost import com.itsaky.androidide.plugins.manager.ui.PluginUiActionManager import com.itsaky.androidide.preferences.internal.EditorPreferences +import com.itsaky.androidide.projects.IProjectManager import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.projects.builder.BuildResult +import com.itsaky.androidide.roomData.recentproject.RecentProjectDao import com.itsaky.androidide.shortcuts.IdeShortcutActions import com.itsaky.androidide.shortcuts.ShortcutContext import com.itsaky.androidide.shortcuts.ShortcutExecutionContext import com.itsaky.androidide.shortcuts.ShortcutManager import com.itsaky.androidide.tasks.executeAsync +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult +import com.itsaky.androidide.ui.ARCHIVE_EXTENSIONS import com.itsaky.androidide.ui.CodeEditorView import com.itsaky.androidide.utils.DialogUtils.newMaterialDialogBuilder import com.itsaky.androidide.utils.DialogUtils.showConfirmationDialog import com.itsaky.androidide.utils.EditorActivityActions import com.itsaky.androidide.utils.EditorSidebarActions +import com.itsaky.androidide.utils.Environment import com.itsaky.androidide.utils.ImageUtils import com.itsaky.androidide.utils.IntentUtils.openImage import com.itsaky.androidide.utils.UniqueNameBuilder +import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.utils.forEachViewRecursively import com.itsaky.androidide.utils.hasVisibleDialog +import com.itsaky.androidide.utils.recordProjectOpenedBookkeeping +import com.itsaky.androidide.utils.resolveDeepLinkProject +import com.itsaky.androidide.utils.resolveWithinDirectory import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable @@ -109,6 +127,7 @@ import kotlinx.coroutines.withContext import org.adfa.constants.CONTENT_KEY import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode +import org.koin.android.ext.android.inject import java.io.File import java.util.WeakHashMap import java.util.concurrent.ConcurrentHashMap @@ -157,6 +176,10 @@ open class EditorHandlerActivity : } private val shortcutManager by lazy { ShortcutManager(applicationContext) } + private val analyticsManager: IAnalyticsManager by inject() + private val recentProjectDao: RecentProjectDao by inject() + private val pendingDeepLinkOpen: PendingDeepLinkOpen by inject() + private var pluginEditorProvider: EditorProviderImpl? = null private fun getTabPositionForFileIndex(fileIndex: Int): Int { @@ -328,6 +351,29 @@ open class EditorHandlerActivity : override fun onDestroy() { super.onDestroy() ActionContextProvider.clearActivity(this) + // Not dismissing this would leak the dialog's window (WindowLeaked) past this activity's + // death -- e.g. a rotation while the confirm-close dialog is showing. + activeProjectCloseDialog?.dismiss() + + // Drain any deep-link-triggered "close then reopen a different project" request recorded by + // confirmProjectCloseThenOpen's onClosed callback. This deliberately waits until onDestroy -- + // which only runs once the framework has committed to tearing this singleTask instance down -- + // rather than firing startActivity() synchronously right after finish(), because the two calls + // racing could otherwise have the new PROJECT_PATH redelivered to this dying instance via + // onNewIntent (which never reads it) instead of a genuinely new instance's onCreate. + pendingDeepLinkOpen.value?.let { pending -> + pendingDeepLinkOpen.value = null + val root = File(pending.projectRoot) + val ctx = applicationContext + recordProjectOpenedBookkeeping(recentProjectDao, root, project = null, analyticsManager = analyticsManager) + ctx.startActivity( + Intent(ctx, EditorActivityKt::class.java).apply { + putExtra("PROJECT_PATH", pending.projectRoot) + pending.fileRequest?.let { putExtra(PendingFileRequest.EXTRA_KEY, it) } + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }, + ) + } } override fun onResume() { @@ -711,8 +757,22 @@ open class EditorHandlerActivity : editor.setSelection(0, 0) return@postInLifecycle } - editor.validateRange(selection) - editor.setSelection(selection) + // EditorFeatures.validateRange mutates Position in place. For a file that was + // just opened (new CodeEditorView), that same `selection` instance was also handed + // to the view's constructor, whose own async content-load pipeline calls + // validateRange/setSelection on it again once the file finishes reading. If this + // call runs first -- while the document is still the freshly-constructed empty + // one line -- it clamps the shared Position down to (0,0) *before* the real + // content loads, permanently corrupting the value the constructor's own pipeline + // later relies on. Validate/apply a defensive copy here instead, so this call can + // never corrupt the shared instance regardless of which side runs first. + val safeSelection = + Range( + Position(selection.start.line, selection.start.column), + Position(selection.end.line, selection.end.column), + ) + editor.validateRange(safeSelection) + editor.setSelection(safeSelection) } } } @@ -879,8 +939,18 @@ open class EditorHandlerActivity : runAfter: (() -> Unit)?, ) { lifecycleScope.launch(Dispatchers.IO) { - withContext(NonCancellable) { - saveAll(notify, requestSync, processResources, progressConsumer) + try { + withContext(NonCancellable) { + saveAll(notify, requestSync, processResources, progressConsumer) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // A write failure here (e.g. CodeEditorView.save()'s IOException) must not skip + // runAfter below -- callers rely on it always running to know the save attempt is + // over, successful or not (e.g. confirmProjectClose's confirmCloseInProgress guard, + // which would otherwise stay stuck true and permanently block closing this activity). + log.error("saveAll failed", e) } withContext(Dispatchers.Main) { runAfter?.invoke() @@ -1011,6 +1081,16 @@ open class EditorHandlerActivity : getEditorForFile(file)?.isModified == true } + /** + * Like [hasUnsavedFiles], but excludes files [CodeEditorView.save] never actually writes (an + * [ARCHIVE_EXTENSIONS] extension, opened read-only) -- those can never leave the "modified" + * state through a save, so counting them as a save failure would block "Save and close" forever. + */ + private fun hasFilesThatFailedToSave() = + editorViewModel.getOpenedFiles().any { file -> + getEditorForFile(file)?.isModified == true && file.extension.lowercase() !in ARCHIVE_EXTENSIONS + } + private suspend inline fun performFileSave(crossinline action: suspend () -> T): T { setFilesSaving(true) try { @@ -1731,7 +1811,10 @@ open class EditorHandlerActivity : confirmProjectClose() } - private fun performCloseAllFiles(manualFinish: Boolean) { + private fun performCloseAllFiles( + manualFinish: Boolean, + onClosed: (() -> Unit)? = null, + ) { val pluginManager = IDEApplication.getPluginManager() val fileCount = editorViewModel.getOpenedFileCount() for (i in 0 until fileCount) { @@ -1756,16 +1839,38 @@ open class EditorHandlerActivity : if (manualFinish) { finish() + onClosed?.invoke() } } - private fun confirmProjectClose() { + // 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 + + private fun confirmProjectClose(onClosed: (() -> Unit)? = null) { val content = contentOrNull ?: return + if (confirmCloseInProgress) { + flashError(string.msg_project_close_in_progress) + return + } + confirmCloseInProgress = true + val builder = newMaterialDialogBuilder(this) builder.setTitle(string.title_confirm_project_close) builder.setMessage(string.msg_confirm_project_close) + builder.setOnCancelListener { confirmCloseInProgress = false } - builder.setNegativeButton(string.cancel_project_text, null) + builder.setNegativeButton(string.cancel_project_text) { dialog, _ -> + dialog.dismiss() + confirmCloseInProgress = false + } // OPTION 1: Close without saving builder.setNeutralButton(string.close_without_saving) { dialog, _ -> @@ -1775,7 +1880,8 @@ open class EditorHandlerActivity : (content.editorContainer.getChildAt(i) as? CodeEditorView)?.editor?.markUnmodified() } - performCloseAllFiles(manualFinish = true) + // Activity is finishing either way; no need to reset confirmCloseInProgress. + performCloseAllFiles(manualFinish = true, onClosed = onClosed) } // OPTION 2: Save and close @@ -1784,8 +1890,16 @@ open class EditorHandlerActivity : saveAllAsync(notify = false) { runOnUiThread { + confirmCloseInProgress = false if (contentOrNull == null) return@runOnUiThread - performCloseAllFiles(manualFinish = true) + // saveAll()'s return value is gradleSaved (whether a build file changed), not + // "everything saved successfully" -- check actual editor state instead, so a + // failed write (disk full, permission) doesn't silently discard unsaved changes. + if (hasFilesThatFailedToSave()) { + flashError(string.save_failed) + return@runOnUiThread + } + performCloseAllFiles(manualFinish = true, onClosed = onClosed) } recentProjectsViewModel.updateProjectModifiedDate( editorViewModel.getProjectName(), @@ -1793,6 +1907,108 @@ open class EditorHandlerActivity : } } - builder.show() + activeProjectCloseDialog = builder.show() + } + + /** + * Entry point used only by the deep-link [onNewIntent] routing below: shows the same, + * unmodified confirm-close dialog as [doConfirmProjectClose], but [onClosed] runs once the user + * actually confirms a close (save-or-discard) -- never on Cancel, which leaves the current + * project open exactly as it was. + */ + private fun confirmProjectCloseThenOpen(onClosed: () -> Unit) { + confirmProjectClose(onClosed) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + + val request = + IntentCompat.getParcelableExtra(intent, DeepLinkRequest.EXTRA_KEY, DeepLinkRequest::class.java) + ?: return + + lifecycleScope.launch(Dispatchers.IO) { + val projectDir = resolveDeepLinkProject(Environment.PROJECTS_DIR, request.projectName) ?: return@launch + withContext(Dispatchers.Main) { + // projectDirPath is set as soon as a project starts opening -- unlike workspace, which + // stays null for the whole duration of a Gradle sync -- so this correctly matches the + // "already in this project" case even mid-sync, instead of falling through to the + // disruptive close-and-reopen confirmation below for a no-op. + if (projectDir.absolutePath == IProjectManager.getInstance().projectDirPath) { + // Requirement #2: same project already open -- no-op project-wise, just navigate. + request.fileRequest?.let { applyDeepLinkFileRequest(it) } + return@withContext + } + + // Requirement #3: a different project is open. Reuse the existing, unmodified + // confirm-close dialog; only record the pending open if the user actually confirms -- + // see onDestroy() for why the reopen itself waits until this instance is torn down. + confirmProjectCloseThenOpen { + pendingDeepLinkOpen.value = DeepLinkOpenRequest(projectDir.absolutePath, request.fileRequest) + } + } + } + } + + override fun postProjectInit( + isSuccessful: Boolean, + failure: TaskExecutionResult.Failure?, + ) { + super.postProjectInit(isSuccessful, failure) + + // 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) + } + + /** + * Applies a deep-link file/line/column request to the *currently open* project. [request]'s + * file path is attacker-controllable URL input, so it's resolved through + * [resolveWithinDirectory] rather than a bare [File] constructor -- see that function's docs for + * why a lexical `..` check alone isn't enough. + */ + private fun applyDeepLinkFileRequest(request: PendingFileRequest) { + val projectDir = File(IProjectManager.getInstance().projectDirPath) + val file = resolveWithinDirectory(projectDir, request.filePath) + if (file == null || !file.isFile) { + flashError(getString(string.msg_deeplink_file_not_found, request.filePath)) + return + } + + // URL line/column are 1-based; internal Position is 0-based. + val line = zeroBasedOrFlashError(request.lineRaw, string.msg_deeplink_invalid_line) + val column = zeroBasedOrFlashError(request.columnRaw, string.msg_deeplink_invalid_column) + + val pos = Position(line, column) + openFileAndSelect(file, Range(pos, pos)) + } + + /** + * Converts a 1-based deep-link line/column value to 0-based. A `null` [raw] (segment absent from + * the URL) silently defaults to 0; a present-but-invalid [raw] (fails [String.toIntOrNull] or + * non-positive) also defaults to 0 but reports [invalidMsgRes] to the user -- see + * [PendingFileRequest]'s docs for why those two cases are distinguished upstream. + */ + private fun zeroBasedOrFlashError( + raw: String?, + @StringRes invalidMsgRes: Int, + ): Int { + raw ?: return 0 + val parsed = raw.toIntOrNull() + if (parsed == null || parsed <= 0) { + flashError(getString(invalidMsgRes, raw)) + return 0 + } + return parsed - 1 } } diff --git a/app/src/main/java/com/itsaky/androidide/api/ActionContextProvider.kt b/app/src/main/java/com/itsaky/androidide/api/ActionContextProvider.kt index e5972bbeb1..1e7fdd9d38 100644 --- a/app/src/main/java/com/itsaky/androidide/api/ActionContextProvider.kt +++ b/app/src/main/java/com/itsaky/androidide/api/ActionContextProvider.kt @@ -8,24 +8,29 @@ import java.lang.ref.WeakReference * to allow decoupled services to trigger UI actions. */ object ActionContextProvider { - private var activityRef: WeakReference? = null + private var activityRef: WeakReference? = null - fun setActivity(activity: EditorHandlerActivity) { - this.activityRef = WeakReference(activity) - } + fun setActivity(activity: EditorHandlerActivity) { + this.activityRef = WeakReference(activity) + } - fun clearActivity() { - this.activityRef?.clear() - this.activityRef = null - } + fun clearActivity() { + this.activityRef?.clear() + this.activityRef = null + } - fun clearActivity(activity: EditorHandlerActivity) { - if (this.activityRef?.get() === activity) { - clearActivity() - } - } + fun clearActivity(activity: EditorHandlerActivity) { + if (this.activityRef?.get() === activity) { + clearActivity() + } + } - fun getActivity(): EditorHandlerActivity? { - return activityRef?.get() - } -} \ No newline at end of file + /** + * The current, live [EditorHandlerActivity], or `null` if there is none -- including one that + * called `finish()` but hasn't run `onDestroy()` (and cleared itself via [clearActivity]) yet. + * Android delivers `singleTask` intents to a finishing instance's [android.app.Activity.onNewIntent] + * inconsistently (a genuinely new instance can be created instead), so callers that route based + * on "is there a live editor to hand this off to" need this distinction, not just non-null. + */ + fun getActivity(): EditorHandlerActivity? = activityRef?.get()?.takeIf { !it.isFinishing && !it.isDestroyed } +} diff --git a/app/src/main/java/com/itsaky/androidide/deeplink/PendingDeepLinkOpen.kt b/app/src/main/java/com/itsaky/androidide/deeplink/PendingDeepLinkOpen.kt new file mode 100644 index 0000000000..ea30236301 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/deeplink/PendingDeepLinkOpen.kt @@ -0,0 +1,37 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.deeplink + +import com.itsaky.androidide.models.DeepLinkOpenRequest + +/** + * In-memory, process-lifetime handoff for "the user confirmed closing the current project via a + * deep link; once this activity instance is actually destroyed, open the requested project." + * + * Deliberately not acted on synchronously inside the close-confirmation dialog's button callback -- + * see [com.itsaky.androidide.activities.editor.EditorHandlerActivity.onDestroy] for why the hand-off + * must wait until the old, `singleTask` activity instance is guaranteed torn down. + * + * Koin-provided (`single` in `di/AppModule.kt`) rather than a Kotlin `object`, per ADR 0006 -- + * still one process-wide instance either way, but this keeps it substitutable in tests and out of + * the "hand-rolled singleton" pattern the ADR asks new code to avoid. + */ +internal class PendingDeepLinkOpen { + @Volatile + var value: DeepLinkOpenRequest? = null +} diff --git a/app/src/main/java/com/itsaky/androidide/di/AppModule.kt b/app/src/main/java/com/itsaky/androidide/di/AppModule.kt index 0e3b3f65f4..c63f37f09b 100644 --- a/app/src/main/java/com/itsaky/androidide/di/AppModule.kt +++ b/app/src/main/java/com/itsaky/androidide/di/AppModule.kt @@ -1,9 +1,9 @@ package com.itsaky.androidide.di - import com.itsaky.androidide.actions.FileActionManager import com.itsaky.androidide.analytics.AnalyticsManager import com.itsaky.androidide.analytics.IAnalyticsManager +import com.itsaky.androidide.deeplink.PendingDeepLinkOpen import com.itsaky.androidide.git.core.GitCredentialsManager import com.itsaky.androidide.roomData.recentproject.RecentProjectRoomDatabase import com.itsaky.androidide.viewmodel.CloneRepositoryViewModel @@ -14,8 +14,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import org.koin.android.ext.koin.androidApplication import org.koin.android.ext.koin.androidContext -import org.koin.dsl.module import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module val coreModule = module { @@ -25,24 +25,24 @@ val coreModule = single { AnalyticsManager() } viewModel { - GitBottomSheetViewModel(get()) + GitBottomSheetViewModel(get()) } - viewModel { MainViewModel(get()) } - viewModel { CloneRepositoryViewModel(get(), get()) } - + viewModel { MainViewModel() } + viewModel { CloneRepositoryViewModel(get(), get()) } - single { - CoroutineScope(SupervisorJob() + Dispatchers.IO) - } + single { + CoroutineScope(SupervisorJob() + Dispatchers.IO) + } - single { - RecentProjectRoomDatabase.getDatabase(androidApplication(), get()) - } + single { + RecentProjectRoomDatabase.getDatabase(androidApplication(), get()) + } - single { - get().recentProjectDao() - } + single { + get().recentProjectDao() + } - single { GitCredentialsManager(get()) } + single { GitCredentialsManager(get()) } + single { PendingDeepLinkOpen() } } diff --git a/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt b/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt new file mode 100644 index 0000000000..18e001608a --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/models/DeepLinkRequest.kt @@ -0,0 +1,139 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.models + +import android.net.Uri +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * A request to open a file at an optional line/column, carried as part of a [DeepLinkRequest] or a + * [DeepLinkOpenRequest]. + * + * [lineRaw]/[columnRaw] are kept as raw strings rather than parsed [Int]s so that callers can + * distinguish "segment absent from the URL" (`null`) from "segment present but not a valid positive + * integer" (non-null, fails [String.toIntOrNull] or non-positive) -- the latter must be reported to the + * user, the former must not. + */ +@Parcelize +data class PendingFileRequest( + val filePath: String, + val lineRaw: String?, + val columnRaw: String?, +) : Parcelable { + companion object { + const val EXTRA_KEY = "com.itsaky.androidide.PENDING_FILE_REQUEST" + } +} + +/** + * A parsed (but not yet resolved-to-a-path) request for + * `https://www.appdevforall.org/device/open/project/{projectName}[/file/{filename}[/line/{n}[/column/{n}]]]`. + */ +@Parcelize +data class DeepLinkRequest( + val projectName: String, + val fileRequest: PendingFileRequest? = null, +) : Parcelable { + companion object { + const val EXTRA_KEY = "com.itsaky.androidide.DEEP_LINK_REQUEST" + + private const val SEGMENT_PROJECT = "project" + private const val SEGMENT_FILE = "file" + private const val SEGMENT_LINE = "line" + private const val SEGMENT_COLUMN = "column" + + /** First index at or after [from] holding [segment], or -1. Unlike [List.indexOf], never + * matches an already-consumed segment earlier in the path -- e.g. a project name that + * happens to equal `"line"` can't be mistaken for the `line` keyword that follows it. */ + private fun List.indexOfFrom( + from: Int, + segment: String, + ): Int { + for (i in from until size) { + if (this[i] == segment) return i + } + return -1 + } + + /** + * Parses a deep-link [Uri] of the form described in [DeepLinkRequest]'s docs. Returns `null` if + * the URI does not contain a `project` segment followed by a name -- i.e. it isn't a deep link + * this app understands, not merely a deep link with missing optional parts. + */ + fun parse(uri: Uri?): DeepLinkRequest? { + val segments = uri?.pathSegments ?: return null + + 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 first, then line in + // whatever remains), never by searching for the keyword's first occurrence. That + // makes a literal "line"/"column" segment earlier in the file path (e.g. a directory + // named "line") part of the filename rather than misread as metadata, as long as a + // real trailing pair follows it. The one shape this can't resolve: a file path whose + // *entire* content is just "line"/"column" plus one more segment, with nothing else + // following -- e.g. `file/line/Main.kt` alone -- is indistinguishable from an actual + // line suffix; this URL scheme has no delimiter to tell the two apart, so it's read + // as the keyword (existing behavior, unchanged). + var endIdx = segments.size + val columnIdx = + (endIdx - 2) + .takeIf { it >= startIdx && segments[it] == SEGMENT_COLUMN } + ?.also { endIdx = it } + val lineIdx = + (endIdx - 2) + .takeIf { it >= startIdx && segments[it] == SEGMENT_LINE } + ?.also { endIdx = it } + + val filePath = segments.subList(startIdx, endIdx).joinToString("/") + + PendingFileRequest( + filePath = filePath, + lineRaw = lineIdx?.let { segments.getOrNull(it + 1) }, + columnRaw = columnIdx?.let { segments.getOrNull(it + 1) }, + ) + } + + return DeepLinkRequest(projectName = projectName, fileRequest = fileRequest) + } + } +} + +/** + * The resolved-path counterpart to [DeepLinkRequest], used once the project name has been resolved to + * an absolute directory -- e.g. when handing a pending "close current project, then open this one" off + * across activities via [com.itsaky.androidide.deeplink.PendingDeepLinkOpen]. + */ +@Parcelize +data class DeepLinkOpenRequest( + val projectRoot: String, + val fileRequest: PendingFileRequest?, +) : Parcelable diff --git a/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt b/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt index 5a219cfedd..1e46c0898e 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt @@ -88,7 +88,9 @@ import kotlin.math.abs private const val MIN_FONT_SIZE = EditorPreferences.FONT_SIZE_MIN private const val DEFAULT_FONT_SIZE = EditorPreferences.FONT_SIZE_DEFAULT private const val MAX_FONT_SIZE = EditorPreferences.FONT_SIZE_MAX -private val ARCHIVE_EXTENSIONS = setOf("apk", "cgp", "zip") + +/** File extensions [CodeEditorView.save] never writes -- these are opened read-only. */ +internal val ARCHIVE_EXTENSIONS = setOf("apk", "cgp", "zip") /** * A view that handles opened code editor. diff --git a/app/src/main/java/com/itsaky/androidide/utils/DeepLinkProjectResolution.kt b/app/src/main/java/com/itsaky/androidide/utils/DeepLinkProjectResolution.kt new file mode 100644 index 0000000000..05f368ee1e --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/DeepLinkProjectResolution.kt @@ -0,0 +1,60 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import android.app.Activity +import com.itsaky.androidide.resources.R.string +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.slf4j.LoggerFactory +import java.io.File + +private val log = LoggerFactory.getLogger("DeepLinkProjectResolution") + +/** + * Resolves [projectName] to a validated project directory under [projectsRoot] for a deep link, + * handling the [SecurityException] [findValidProjectByName] can throw and reporting both "not + * found" and "scan failed" to the user via `flashError` on the main thread. A `null` result means + * the caller can just return -- either failure case already flashed its own message. + * + * Call from a background dispatcher (e.g. `Dispatchers.IO`); this only switches to + * [Dispatchers.Main] itself for the user-facing error messages. + */ +suspend fun Activity.resolveDeepLinkProject( + projectsRoot: File, + projectName: String, +): File? { + val projectDir = + try { + findValidProjectByName(projectsRoot, projectName) + } catch (e: CancellationException) { + throw e + } catch (e: SecurityException) { + log.error("Failed to scan {} for deep link", projectsRoot, e) + withContext(Dispatchers.Main) { flashError(getString(string.msg_deeplink_scan_failed)) } + return null + } + + if (projectDir == null) { + withContext(Dispatchers.Main) { + flashError(getString(string.msg_deeplink_project_not_found, projectName)) + } + } + return projectDir +} 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..2f77d47964 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/PathTraversal.kt @@ -0,0 +1,81 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.InvalidPathException + +/** + * Resolves [relativePath] against [baseDir], rejecting any attempt to escape outside it. Intended + * for attacker-controllable input (e.g. the `{filename}` segment of a deep-link URL) that must never + * be allowed to read/write outside a known root directory. + * + * Three layers, mirroring the zip-slip guard in + * [com.itsaky.androidide.assets.AssetsInstallationHelper.extractZipToDir]: + * 1. A lexical reject of `..`/a leading `/` or `\` -- cheap, catches the common case outright. + * 2. Resolve + normalize against [baseDir] and verify with [java.nio.file.Path.startsWith] (not + * string prefix matching, which would wrongly accept `/project` as inside `/project-evil`) -- + * this operates on Java's own resolved path, so it isn't fooled by however `..` made it into the + * string (a literal `..` segment is the only way a path can name a parent directory at all, + * however it got decoded). + * 3. If [baseDir] exists on disk, resolve the nearest existing ancestor of the normalized path to + * its real, on-disk path via [java.nio.file.Path.toRealPath] and re-verify containment -- layer 2 + * is purely lexical and won't catch a symlink already present inside [baseDir] (e.g. a project + * cloned with git, which supports symlinks) that points outside it. Walking up to the nearest + * *existing* ancestor (rather than the resolved path itself) handles callers resolving a path + * that doesn't exist yet. Skipped when [baseDir] itself doesn't exist -- there is nothing on disk + * to symlink-escape through, so the lexical check above is already authoritative. + * + * Returns `null` if [relativePath] is invalid or escapes [baseDir] -- including when it's not a + * representable path at all (e.g. containing a decoded NUL byte, `Uri.pathSegments` percent-decodes + * before this function ever sees the string, so `%00` arrives as a literal NUL character, which + * [java.nio.file.Path] rejects with [InvalidPathException] rather than silently ignoring). + */ +fun resolveWithinDirectory( + baseDir: File, + relativePath: String, +): File? { + if (relativePath.contains("..") || relativePath.startsWith("/") || relativePath.startsWith("\\")) { + return null + } + + return try { + val base = baseDir.toPath().toAbsolutePath().normalize() + val resolved = base.resolve(relativePath).normalize() + if (!resolved.startsWith(base)) { + return null + } + + if (!Files.exists(base)) { + return resolved.toFile() + } + + val realBase = base.toRealPath() + var existingAncestor = resolved + while (!Files.exists(existingAncestor)) { + existingAncestor = existingAncestor.parent ?: return null + } + if (!existingAncestor.toRealPath().startsWith(realBase)) null else resolved.toFile() + } catch (_: InvalidPathException) { + null + } catch (_: IOException) { + null + } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt b/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt new file mode 100644 index 0000000000..1fc3162271 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/ProjectOpenBookkeeping.kt @@ -0,0 +1,91 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.lifecycle.lifecycleScope +import com.itsaky.androidide.analytics.IAnalyticsManager +import com.itsaky.androidide.preferences.internal.GeneralPreferences +import com.itsaky.androidide.projects.ProjectManagerImpl +import com.itsaky.androidide.roomData.recentproject.RecentProject +import com.itsaky.androidide.roomData.recentproject.RecentProjectDao +import com.itsaky.androidide.templates.Language +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory +import java.io.File + +private val log = LoggerFactory.getLogger("ProjectOpenBookkeeping") + +/** + * Marks [root] as the currently open project (singleton state + last-opened pref), records it in + * Recents, and tracks the open in analytics -- the same bookkeeping + * [com.itsaky.androidide.activities.MainActivity.openProject] does for a normal manual open, + * extracted so a deep-link-triggered project switch gets it too even though that path bypasses + * `openProject` entirely (see + * [com.itsaky.androidide.activities.editor.EditorHandlerActivity.onDestroy]). + * + * [recentProjectDao] is the caller's Koin-provided instance (`by inject()`), the same one + * `di/AppModule.kt` wires into `MainViewModel`/`RecentProjectsViewModel` -- per ADR 0001/0006, + * persistence is always acquired through Koin, never by re-deriving the database directly. + * + * Uses [ProcessLifecycleOwner]'s scope rather than a per-activity one, since this can run from an + * activity's `onDestroy()` after its own `lifecycleScope` has already been cancelled. + */ +fun recordProjectOpenedBookkeeping( + recentProjectDao: RecentProjectDao, + root: File, + project: RecentProject?, + analyticsManager: IAnalyticsManager, +) { + ProjectManagerImpl.getInstance().projectPath = root.absolutePath + GeneralPreferences.lastOpenedProject = root.absolutePath + + ProcessLifecycleOwner.get().lifecycleScope.launch(Dispatchers.IO) { + val location = root.absolutePath + val recentProject = + project ?: RecentProject( + name = root.name, + location = location, + createdAt = getCreatedTime(location).toString(), + lastModified = getLastModifiedTime(location).toString(), + language = readProjectLanguage(root), + ) + try { + // Insert is IGNOREd for a project already in Recents, so refresh the detected language + // separately -- but never clobber a stored value with a failed ("Unknown") detection. + recentProjectDao.insert(recentProject) + if (!recentProject.language.equals(Language.Unknown.lang, ignoreCase = true)) { + recentProjectDao.updateLanguage(recentProject.location, recentProject.language) + } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + // This runs on ProcessLifecycleOwner's permanent, app-wide scope, which has no + // CoroutineExceptionHandler -- unlike the ViewModel-scoped version this replaced, ANY + // escaping exception here (not just SQLException; Room's generated insert can also throw + // e.g. IllegalStateException from an already-closed database) crashes the whole process, + // not just fails to record one Recents entry. The project-open state above is already set + // synchronously, so a Recents-write failure doesn't affect it. + log.warn("Failed to record opened project '{}' in Recents", recentProject.name, e) + } + } + + 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..a18480e6e0 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/ProjectValidations.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/ProjectValidations.kt @@ -11,14 +11,43 @@ 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 + val candidate = resolveWithinDirectory(projectsRoot, name) ?: return null + return candidate.takeIf { it.isProjectCandidateDir() && isValidProjectDirectory(it) } +} + /** Determines if the directory contains a valid Android project structure. */ fun isValidProjectDirectory(selectedDir: File): Boolean { if (isPluginProject(selectedDir)) { @@ -56,4 +85,4 @@ internal fun isPluginProject(dir: File): Boolean { val pluginApiJar = File(dir, "libs/plugin-api.jar") val buildGradle = File(dir, "build.gradle.kts") return pluginApiJar.exists() && buildGradle.exists() -} \ No newline at end of file +} diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/MainViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/MainViewModel.kt index 4d59706af4..6736fd9511 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/MainViewModel.kt @@ -17,34 +17,42 @@ package com.itsaky.androidide.viewmodel -import android.database.SQLException import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.itsaky.androidide.roomData.recentproject.RecentProject -import com.itsaky.androidide.roomData.recentproject.RecentProjectDao -import com.itsaky.androidide.templates.Language import com.itsaky.androidide.templates.Template -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch -import org.slf4j.Logger -import org.slf4j.LoggerFactory import java.util.concurrent.atomic.AtomicInteger /** - * [ViewModel] for main activity. + * [ViewModel] for [com.itsaky.androidide.activities.MainActivity] -- holds the single-Activity, + * multi-"screen" navigation state (see the `SCREEN_*` constants) plus one-shot events unrelated to + * persisted UI state. + * + * **Threading:** all mutable state here ([currentScreen], [isTransitionInProgress]) is backed by + * [MutableLiveData] set via direct `.value =` assignment, never `postValue` -- every mutator + * ([setScreen], the [isTransitionInProgress] setter) must run on the main thread. + * + * **Screen state:** [currentScreen]/[previousScreen] are mutually exclusive, identified by one of + * the `SCREEN_*` constants; `-1` is the sentinel for "no screen yet" rather than `null`, since both + * are non-nullable `Int`. [setScreen] records the outgoing screen as [previousScreen] before + * advancing [currentScreen] -- there's no history beyond that one step back. [postTransition] runs + * its `action` immediately unless [isTransitionInProgress] is true, in which case it defers `action` + * until the next transition-complete signal, then detaches its observer (fires at most once). + * + * **Clone-request event:** [requestCloneRepository] is a one-shot, single-consumer event, not + * persisted state -- delivered through a buffered [Channel] exposed as [cloneRepositoryEvent] via + * [kotlinx.coroutines.flow.receiveAsFlow]. A URL sent before any collector attaches is buffered, not + * dropped, but if more than one collector attaches, only one of them receives a given element. * * @author Akash Yadav */ -class MainViewModel( - private val recentProjectDao: RecentProjectDao, -) : ViewModel() { +class MainViewModel : ViewModel() { companion object { // The values assigned to these variables reflect the order in which the screens are presented // to the user. A screen with a lower value is displayed before a screen with a higher value. @@ -60,8 +68,6 @@ class MainViewModel( const val SCREEN_SAVED_PROJECTS = 4 const val SCREEN_DELETE_PROJECTS = 5 const val SCREEN_CLONE_REPO = 6 - - val logger: Logger = LoggerFactory.getLogger(MainViewModel::class.java) } private val _currentScreen = MutableLiveData(-1) @@ -116,22 +122,4 @@ class MainViewModel( action.run() } } - - fun saveProjectToRecents(project: RecentProject) { - viewModelScope.launch(Dispatchers.IO) { - try { - // Insert is IGNOREd for projects already in recents, so refresh the - // detected language separately - but never clobber a stored value - // with a failed detection. - recentProjectDao.insert(project) - if (!project.language.equals(Language.Unknown.lang, ignoreCase = true)) { - recentProjectDao.updateLanguage(project.location, project.language) - } - } catch (e: CancellationException) { - throw e - } catch (e: SQLException) { - logger.warn("Failed to save project to recents", e) - } - } - } } diff --git a/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt b/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt new file mode 100644 index 0000000000..66fe0796de --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/models/DeepLinkRequestTest.kt @@ -0,0 +1,203 @@ +/* + * 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() + } +} 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..d220cfcc61 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/PathTraversalTest.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.utils + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.nio.file.Files + +class PathTraversalTest { + private val baseDir = File("/project/root") + private val nulCharacter = 0.toChar() + + @JvmField + @Rule + val tempFolder = TemporaryFolder() + + @Test + fun `plain relative path resolves inside base`() { + val resolved = resolveWithinDirectory(baseDir, "src/Main.kt") + assertEquals(File("/project/root/src/Main.kt"), resolved) + } + + @Test + fun `literal dot-dot is rejected`() { + assertNull(resolveWithinDirectory(baseDir, "../../etc/passwd")) + } + + @Test + fun `dot-dot buried in the middle of a path is rejected`() { + // The shape produced once android.net.Uri decodes a single raw segment containing an + // encoded slash, e.g. the URL segment "foo%2f..%2f..%2fetc%2fpasswd" -- decoded to one + // string, but still containing ".." once decoded. + assertNull(resolveWithinDirectory(baseDir, "foo/../../etc/passwd")) + } + + @Test + fun `leading slash is rejected`() { + assertNull(resolveWithinDirectory(baseDir, "/etc/passwd")) + } + + @Test + fun `leading backslash is rejected`() { + assertNull(resolveWithinDirectory(baseDir, "\\Windows\\System32")) + } + + @Test + fun `embedded NUL character is rejected instead of throwing`() { + // android.net.Uri.pathSegments percent-decodes before this function ever sees the string, so + // a URL's "%00" arrives here as a literal NUL character. java.nio.file.Path throws + // InvalidPathException for that -- must be caught, not left to crash the caller. + assertNull(resolveWithinDirectory(baseDir, "foo" + nulCharacter + ".txt")) + } + + @Test + fun `a filename merely containing dot-dot as a substring is rejected too`() { + // Intentionally the stricter, simpler substring reject rather than a proper per-segment + // check -- project files never legitimately need consecutive dots in a name, so treating + // "a..b.txt" the same as an actual ".." traversal segment is an acceptable, safe trade-off. + assertNull(resolveWithinDirectory(baseDir, "a..b.txt")) + } + + @Test + fun `multi-segment path resolves and normalizes redundant separators`() { + val resolved = resolveWithinDirectory(baseDir, "app/src/main/Main.kt") + assertEquals(File("/project/root/app/src/main/Main.kt"), resolved) + } + + @Test + fun `plain file inside a real base directory still resolves`() { + val root = tempFolder.newFolder("real-project") + File(root, "src").mkdirs() + val target = File(root, "src/Main.kt").apply { writeText("fun main() {}") } + + val resolved = resolveWithinDirectory(root, "src/Main.kt") + assertEquals(target.canonicalFile, resolved?.canonicalFile) + } + + @Test + fun `symlink inside base pointing outside it is rejected`() { + // Regression test: the lexical/normalize check alone doesn't catch a symlink physically + // present inside the project directory (e.g. from a git clone, which supports symlinks) that + // points outside it -- resolveWithinDirectory must also verify the real, on-disk path. + val root = tempFolder.newFolder("real-project") + val outside = tempFolder.newFolder("outside") + File(outside, "secret.txt").writeText("secret") + Files.createSymbolicLink(File(root, "evil").toPath(), outside.toPath()) + + assertNull(resolveWithinDirectory(root, "evil/secret.txt")) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/ProjectValidationsTest.kt b/app/src/test/java/com/itsaky/androidide/utils/ProjectValidationsTest.kt new file mode 100644 index 0000000000..5c21faf2d8 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/ProjectValidationsTest.kt @@ -0,0 +1,67 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class ProjectValidationsTest { + @JvmField + @Rule + val tempFolder = TemporaryFolder() + + private fun makeValidProject( + parent: File, + name: String, + ): File { + val project = File(parent, name).apply { mkdirs() } + val appDir = File(project, "app").apply { mkdirs() } + File(appDir, "build.gradle.kts").writeText("// stub") + return project + } + + @Test + fun `resolves an existing project by name`() { + val root = tempFolder.newFolder("projects") + val project = makeValidProject(root, "MyApp") + + assertThat(findValidProjectByName(root, "MyApp")?.canonicalFile).isEqualTo(project.canonicalFile) + } + + @Test + fun `unknown project name yields null`() { + val root = tempFolder.newFolder("projects") + assertThat(findValidProjectByName(root, "DoesNotExist")).isNull() + } + + @Test + fun `dot-dot traversal outside projectsRoot is rejected`() { + // Regression test: a bare File(projectsRoot, name) join let `name` escape projectsRoot + // entirely (e.g. name = "../outside"). A real deep link supplies this as a decoded URL + // segment, so a project sitting just outside the configured projects root must never be + // resolvable via a crafted project name. + val base = tempFolder.newFolder("base") + val root = File(base, "projects").apply { mkdirs() } + makeValidProject(base, "outside") + + assertThat(findValidProjectByName(root, "../outside")).isNull() + } +} diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index b8e7660fef..1e07dc6c34 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -135,6 +135,13 @@ Do you want to open the last opened project? The project was:\n%s Close this project Last opened project doesn\'t exist. + This link could not be opened. + No project named \"%s\" was found. + File \"%s\" was not found in the project. + \"%s\" is not a valid line number. + \"%s\" is not a valid column number. + Could not scan projects for this link. + A project close is already in progress. Try again in a moment. Create new project Open a saved project Delete a saved project