diff --git a/.claude/skills/architecture-review/SKILL.md b/.claude/skills/architecture-review/SKILL.md index 89d7f128df..e609b40f04 100644 --- a/.claude/skills/architecture-review/SKILL.md +++ b/.claude/skills/architecture-review/SKILL.md @@ -56,8 +56,9 @@ For each changed first-party file, check the applicable rules. Each rule cites i | 9 | **Dependency substitution:** don't add a Maven coordinate for something already vendored/substituted (`build-deps*`); don't add a new dependency without checking `gradle/libs.versions.toml` first. | ADR 0003 | | 10 | **Strings** live in the `:resources` module's `strings.xml` (not per-module, not inline literals). | REVIEW.md §7 | | 11 | **UI never drawn over the two system bars** (top status bar, bottom navigation bar). | CLAUDE.md | +| 12 | **Text scales:** new/changed screens verified at font scale 1.0 and 2.0 — `sp` for text and `dp` for spacing (no `sp` dimen used as margin/padding), no text boxed in a fixed `dp` size, a scroll container on content that can grow, and `maxLines`/`singleLine`/`ellipsize` only on genuinely disposable text. | CLAUDE.md, REVIEW.md §8 | -Rules 1, 2, 6 apply to UI changes; 4, 5 to data/model changes; 8, 9 to Gradle changes. Judge by what the diff touches — don't flag rules a file doesn't engage. +Rules 1, 2, 6, 10, 11, 12 apply to UI changes; 4, 5 to data/model changes; 8, 9 to Gradle changes; 3 wherever a dependency, singleton, or ViewModel is introduced; 7 wherever a module's dependencies or cross-module imports change. Any rule not listed here is still checked whenever the diff touches its subject. Judge by what the diff touches — don't flag rules a file doesn't engage. For a **large diff (~15+ first-party files)**, fan out: spawn a subagent per dimension (UI/state, DI, persistence, Gradle/modules), each instructed to read the relevant ADR and report only its dimension's findings; then merge. For a small diff, do it inline. diff --git a/.github/workflows/deploy-well-known-worker.yml b/.github/workflows/deploy-well-known-worker.yml new file mode 100644 index 0000000000..bad3fa6809 --- /dev/null +++ b/.github/workflows/deploy-well-known-worker.yml @@ -0,0 +1,91 @@ +name: Deploy well-known Worker + +# Deploys infra/well-known-worker, which serves +# https:///.well-known/assetlinks.json out of the private "well-known" R2 +# bucket. The object itself is written by signing-fingerprint.yml; this workflow +# owns only the code that reads it, and does not verify the served result - +# signing-fingerprint.yml already does that end to end when it deploys. +# +# A Cloudflare Origin Rule cannot do this job on the Free plan: host header, SNI +# and DNS record overrides are Enterprise-only, and R2 selects a bucket from the +# Host header. The Worker uses an R2 binding instead, so the bucket stays private. +# +# Requires a CLOUDFLARE_WORKERS_DEPLOY_TOKEN secret with: +# Account -> Workers Scripts -> Edit (upload the script) +# Account -> Workers R2 Storage -> Read (see below) +# Zone -> Workers Routes -> Edit (attach the routes on appdevforall.org) +# The R2 read scope is not optional: wrangler resolves the bucket named in the +# r2_buckets binding via GET /accounts//r2/buckets/well-known and fails the +# deploy with "Authentication error [code: 10000]" without it. Note that a scope +# added to an existing token takes a few minutes to take effect - that same error +# persists across an immediate re-run, so wait before concluding the scope is wrong. +# The existing CLOUDFLARE_KEY_ID / CLOUDFLARE_SECRET_ACCESS_KEY pair is an R2 +# S3-compatible credential and cannot deploy a Worker. + +on: + workflow_dispatch: + # Dispatch is only offered for workflows on the default branch, so run on push + # to let this work from a feature branch before it reaches stage. Note that a + # push on any branch therefore deploys the live Worker. + push: + paths: + - 'infra/well-known-worker/**' + - '.github/workflows/deploy-well-known-worker.yml' + +permissions: + contents: read + +# One deploy at a time: concurrent uploads of the same script race on the routes. +concurrency: + group: deploy-well-known-worker + cancel-in-progress: false + +jobs: + deploy: + name: Deploy Worker + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Check Cloudflare credentials + env: + CLOUDFLARE_WORKERS_DEPLOY_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_DEPLOY_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -euo pipefail + + # wrangler reports a missing token as an opaque auth error, so name the + # actual gap here. Required scopes are listed at the top of this file. + for var in CLOUDFLARE_WORKERS_DEPLOY_TOKEN CLOUDFLARE_ACCOUNT_ID; do + if [ -z "${!var:-}" ]; then + echo "ERROR: $var is not set. See the header of this workflow." >&2 + exit 1 + fi + done + + - name: Deploy with Wrangler + uses: cloudflare/wrangler-action@v4 + with: + apiToken: ${{ secrets.CLOUDFLARE_WORKERS_DEPLOY_TOKEN }} + accountId: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + workingDirectory: infra/well-known-worker + wranglerVersion: '4.124.0' + command: deploy + + - name: Write job summary + run: | + set -euo pipefail + { + echo "## well-known Worker deployed" + echo + echo "Routes now served from the \`well-known\` R2 bucket:" + echo + echo "- \`https://appdevforall.org/.well-known/assetlinks.json\`" + echo "- \`https://www.appdevforall.org/.well-known/assetlinks.json\`" + echo + echo "A route with no matching object falls through to the site origin." + echo "Run **Print release signing certificate fingerprint** with \`deploy\` enabled to publish the object and verify it end to end." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/signing-fingerprint.yml b/.github/workflows/signing-fingerprint.yml new file mode 100644 index 0000000000..d78b65fc8c --- /dev/null +++ b/.github/workflows/signing-fingerprint.yml @@ -0,0 +1,341 @@ +name: Print release signing certificate fingerprint + +# Emits the SHA-256 certificate fingerprint of the release signing key, plus a +# ready-to-deploy Digital Asset Links file for Android App Link verification. +# +# The keystore only exists inside CI: SigningKeyUtils.downloadSigningKey() +# base64-decodes IDE_SIGNING_KEY_BIN into build/signing/signing-key.jks. This +# workflow decodes the same secret directly, so no Gradle build is needed. +# +# A certificate fingerprint is public data - it is published in assetlinks.json. +# No private key material is printed. +# +# With deploy=true the file is written to the "well-known" R2 bucket at key +# .well-known/assetlinks.json. The Worker in infra/well-known-worker serves that +# bucket at https:///.well-known/assetlinks.json, deriving the object key +# from the request path, so the key has to match the path exactly. The Worker is +# deployed by deploy-well-known-worker.yml; this workflow owns only the object. + +on: + workflow_dispatch: + inputs: + hosts: + description: 'Hosts serving assetlinks.json (comma-separated). Drives the deploy checklist and, when deploy is enabled, which hosts are verified. The file itself is host-independent.' + required: false + default: 'appdevforall.org,www.appdevforall.org' + extra_fingerprints: + description: 'Additional SHA-256 fingerprints to include (comma-separated, colon-hex). Use for developer debug keys when testing App Links locally.' + required: false + default: '' + deploy: + description: 'Upload the generated file to R2 and verify it is served. Leave off to only produce the artifact.' + type: boolean + required: false + default: false + # Dispatch is only offered for workflows on the default branch, so run on push + # of this file to let it work from a feature branch before it reaches stage. + push: + paths: + - '.github/workflows/signing-fingerprint.yml' + +permissions: + contents: read + +jobs: + fingerprint: + name: Fingerprint release signing key + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + IDE_SIGNING_ALIAS: ${{ secrets.IDE_SIGNING_ALIAS }} + IDE_SIGNING_STORE_PASS: ${{ secrets.IDE_SIGNING_STORE_PASS }} + IDE_SIGNING_KEY_BIN: ${{ secrets.IDE_SIGNING_KEY_BIN }} + R2_BUCKET: well-known + R2_KEY: .well-known/assetlinks.json + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Materialize keystore from IDE_SIGNING_KEY_BIN + run: | + set -euo pipefail + + # The runner context is step-scoped, so derive these here rather than in + # the job-level env block, where ${{ runner.temp }} would be empty. + echo "KEYSTORE=$RUNNER_TEMP/signing-key.jks" >> "$GITHUB_ENV" + echo "ASSETLINKS=$RUNNER_TEMP/assetlinks.json" >> "$GITHUB_ENV" + KEYSTORE="$RUNNER_TEMP/signing-key.jks" + + for var in IDE_SIGNING_KEY_BIN IDE_SIGNING_ALIAS IDE_SIGNING_STORE_PASS; do + if [ -z "${!var:-}" ]; then + echo "ERROR: $var is not set. Check the repository secrets." >&2 + exit 1 + fi + done + + # Keystore lives outside the workspace so it cannot be picked up by an + # artifact upload or a later checkout. + printf '%s' "$IDE_SIGNING_KEY_BIN" | base64 -d > "$KEYSTORE" + chmod 600 "$KEYSTORE" + + if [ ! -s "$KEYSTORE" ]; then + echo "ERROR: decoded keystore is empty - IDE_SIGNING_KEY_BIN is not valid base64." >&2 + exit 1 + fi + echo "Decoded keystore: $(stat -c %s "$KEYSTORE") bytes" + + - name: Extract SHA-256 fingerprint + id: fp + run: | + set -euo pipefail + + if ! keytool -list -v \ + -keystore "$KEYSTORE" \ + -storepass "$IDE_SIGNING_STORE_PASS" \ + -alias "$IDE_SIGNING_ALIAS" > "$RUNNER_TEMP/keytool.txt" 2>&1; then + echo "ERROR: keytool failed. Alias in IDE_SIGNING_ALIAS may not match the keystore." >&2 + sed 's/^/ /' "$RUNNER_TEMP/keytool.txt" >&2 + echo "Entries present in the keystore:" >&2 + keytool -list -keystore "$KEYSTORE" -storepass "$IDE_SIGNING_STORE_PASS" \ + | grep -E 'Entry|entry' >&2 || true + exit 1 + fi + + fingerprint=$(awk '/SHA256:/ { print $2; exit }' "$RUNNER_TEMP/keytool.txt") + if [ -z "$fingerprint" ]; then + echo "ERROR: no SHA256 line in keytool output." >&2 + exit 1 + fi + + # Cross-check via OpenSSL against the exported certificate. A wrong + # fingerprint fails App Link verification silently, so verify it twice. + openssl_fp=$(keytool -exportcert -rfc \ + -keystore "$KEYSTORE" \ + -storepass "$IDE_SIGNING_STORE_PASS" \ + -alias "$IDE_SIGNING_ALIAS" \ + | openssl x509 -noout -fingerprint -sha256 \ + | cut -d= -f2) + + if [ "$fingerprint" != "$openssl_fp" ]; then + echo "ERROR: keytool and openssl disagree:" >&2 + echo " keytool: $fingerprint" >&2 + echo " openssl: $openssl_fp" >&2 + exit 1 + fi + + echo "SHA-256: $fingerprint" + echo "fingerprint=$fingerprint" >> "$GITHUB_OUTPUT" + + # Certificate identity, useful for confirming this is the key you expect. + grep -E '^(Owner|Issuer|Valid from):' "$RUNNER_TEMP/keytool.txt" || true + + - name: Resolve application ID + id: pkg + run: | + set -euo pipefail + config=composite-builds/build-logic/common/src/main/java/com/itsaky/androidide/build/config/BuildConfig.kt + pkg=$(sed -n 's/.*PACKAGE_NAME[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "$config" | head -1) + if [ -z "$pkg" ]; then + echo "ERROR: could not read PACKAGE_NAME from $config" >&2 + exit 1 + fi + echo "Application ID: $pkg" + echo "package=$pkg" >> "$GITHUB_OUTPUT" + + - name: Generate assetlinks.json + env: + PKG: ${{ steps.pkg.outputs.package }} + FINGERPRINT: ${{ steps.fp.outputs.fingerprint }} + EXTRA: ${{ inputs.extra_fingerprints }} + run: | + set -euo pipefail + + # Normalize to uppercase colon-separated hex, drop blanks and duplicates. + # Strip spaces/tabs/CR only - deleting newlines here would splice the + # fingerprints into one unmatchable string. + fingerprints=$(printf '%s,%s' "$FINGERPRINT" "$EXTRA" \ + | tr ',' '\n' \ + | tr -d ' \t\r' \ + | tr '[:lower:]' '[:upper:]' \ + | grep -E '^([0-9A-F]{2}:){31}[0-9A-F]{2}$' \ + | awk '!seen[$0]++' \ + | jq -R . | jq -s .) + + jq -n --arg pkg "$PKG" --argjson fps "$fingerprints" '[ + { + relation: ["delegate_permission/common.handle_all_urls"], + target: { + namespace: "android_app", + package_name: $pkg, + sha256_cert_fingerprints: $fps + } + } + ]' > "$ASSETLINKS" + + cat "$ASSETLINKS" + + - name: Validate assetlinks.json + env: + PKG: ${{ steps.pkg.outputs.package }} + run: | + set -euo pipefail + + # A malformed or mismatched file fails App Link verification silently on + # device, so assert the full shape here rather than discover it later. + jq -e 'type == "array" and length == 1' "$ASSETLINKS" > /dev/null \ + || { echo "ERROR: expected a single-entry array." >&2; exit 1; } + + jq -e --arg pkg "$PKG" ' + .[0] as $e + | ($e.relation | index("delegate_permission/common.handle_all_urls")) != null + and $e.target.namespace == "android_app" + and $e.target.package_name == $pkg + and ($e.target.sha256_cert_fingerprints | length) >= 1 + and ($e.target.sha256_cert_fingerprints + | all(test("^([0-9A-F]{2}:){31}[0-9A-F]{2}$"))) + ' "$ASSETLINKS" > /dev/null \ + || { echo "ERROR: entry does not describe $PKG with valid SHA-256 fingerprints." >&2; exit 1; } + + echo "Validated: $(jq -r '.[0].target.sha256_cert_fingerprints | length' "$ASSETLINKS") fingerprint(s) for $PKG" + + - name: Upload assetlinks.json + uses: actions/upload-artifact@v4 + with: + name: assetlinks + path: ${{ env.ASSETLINKS }} + if-no-files-found: error + + - name: Deploy assetlinks.json to R2 + if: github.event_name == 'workflow_dispatch' && inputs.deploy + env: + AWS_ACCESS_KEY_ID: ${{ vars.CLOUDFLARE_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + R2_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + # AWS CLI v2 sends CRC32 integrity headers by default, which R2 rejects + # with "Header 'x-amz-checksum-algorithm' ... not implemented". + AWS_REQUEST_CHECKSUM_CALCULATION: when_required + AWS_RESPONSE_CHECKSUM_VALIDATION: when_required + run: | + set -euo pipefail + + # The Worker derives the object key from the request path, so the key + # must stay ".well-known/assetlinks.json". + aws s3 cp "$ASSETLINKS" "s3://$R2_BUCKET/$R2_KEY" \ + --endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \ + --content-type application/json + + - name: Verify the deployed file is served + if: github.event_name == 'workflow_dispatch' && inputs.deploy + env: + HOSTS: ${{ inputs.hosts || 'appdevforall.org,www.appdevforall.org' }} + run: | + set -euo pipefail + + expected=$(jq -S -c . "$ASSETLINKS") + failed=0 + + for host in ${HOSTS//,/ }; do + url="https://$host/$R2_KEY" + hdr="$RUNNER_TEMP/hdr" body="$RUNNER_TEMP/body" + + # Cloudflare can take a moment to pick up a freshly written object. + served=0 + for _ in 1 2 3 4 5; do + if curl -fsS --max-time 20 -D "$hdr" -o "$body" "$url"; then + served=1 + break + fi + sleep 5 + done + + if [ "$served" -ne 1 ]; then + echo "FAIL $url did not return 200. The R2 upload succeeded, so the likely cause is the well-known Worker not being deployed or not routed for this host (see deploy-well-known-worker.yml)." >&2 + failed=1 + continue + fi + + if [ "$(jq -S -c . < "$body")" != "$expected" ]; then + echo "FAIL $url served content that differs from what was uploaded (stale edge cache, or the Worker route points elsewhere)." >&2 + failed=1 + continue + fi + + ctype=$(tr -d '\r' < "$hdr" | awk -F': ' 'tolower($1) == "content-type" { print tolower($2) }' | tail -1) + case "$ctype" in + application/json*) + echo "OK $url ($ctype)" + ;; + *) + echo "FAIL $url served Content-Type '$ctype'; Digital Asset Links requires application/json." >&2 + failed=1 + ;; + esac + done + + exit "$failed" + + - name: Write job summary + env: + PKG: ${{ steps.pkg.outputs.package }} + FINGERPRINT: ${{ steps.fp.outputs.fingerprint }} + HOSTS: ${{ inputs.hosts || 'appdevforall.org,www.appdevforall.org' }} + DEPLOYED: ${{ github.event_name == 'workflow_dispatch' && inputs.deploy }} + run: | + set -euo pipefail + { + echo "## Release signing certificate" + echo + echo "| | |" + echo "|---|---|" + echo "| Application ID | \`$PKG\` |" + echo "| SHA-256 | \`$FINGERPRINT\` |" + echo + echo "### assetlinks.json" + echo + echo '```json' + cat "$ASSETLINKS" + echo '```' + echo + echo "### Deploy" + echo + if [ "$DEPLOYED" = "true" ]; then + echo "Written to \`s3://$R2_BUCKET/$R2_KEY\` and confirmed served on:" + echo + for host in ${HOSTS//,/ }; do + echo "- \`https://$host/$R2_KEY\`" + done + else + echo "Not deployed. Re-run with **deploy** enabled, or publish the artifact by hand. The file is host-independent - the same bytes serve every host in the intent filter:" + echo + for host in ${HOSTS//,/ }; do + echo "- \`https://$host/$R2_KEY\` - HTTPS, \`Content-Type: application/json\`, no redirect" + done + fi + echo + echo "### Verify on device" + echo + echo '```bash' + for host in ${HOSTS//,/ }; do + echo "curl -sSI https://$host/$R2_KEY # expect 200, application/json, no 3xx" + done + echo "adb shell pm verify-app-links --re-verify $PKG" + echo "adb shell pm get-app-links $PKG" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Remove keystore + if: always() + run: | + # KEYSTORE may be unset if the decode step failed before exporting it. + ks="${KEYSTORE:-$RUNNER_TEMP/signing-key.jks}" + shred -u "$ks" 2>/dev/null || rm -f "$ks" + rm -f "$RUNNER_TEMP/keytool.txt" diff --git a/.github/workflows/strip-rovo-nag.yml b/.github/workflows/strip-rovo-nag.yml new file mode 100644 index 0000000000..484cc24698 --- /dev/null +++ b/.github/workflows/strip-rovo-nag.yml @@ -0,0 +1,132 @@ +name: Strip Rovo Dev Nags + +# Atlassian's GitHub integration advertises Rovo Dev in two places on every pull +# request: it appends a "Rovo Dev code review status" block to the description, +# and atlassian[bot] posts a comment asking you to link your GitHub account. +# Strip both back out. +# +# Runs on pull_request_target so it can edit pull requests from forks. +# It must never check out or execute code from the pull request. +# +# Run it manually (workflow_dispatch) to sweep pull requests that were opened +# before this workflow landed, or that the bot nagged while it was broken. +on: + pull_request_target: + types: [ edited ] + issue_comment: + types: [ created ] + workflow_dispatch: + +permissions: + pull-requests: write + issues: write + +jobs: + strip_rovo_nag: + name: Remove the Rovo Dev advertising + # Filter comment events here so an ordinary comment never starts a runner. + # Pull requests only, matching the sweep below, which walks only pull requests. + if: >- + github.event_name != 'issue_comment' || + (github.event.issue.pull_request && + github.event.comment.user.login == 'atlassian[bot]' && + contains(github.event.comment.body, 'Rovo Dev code review')) + runs-on: ubuntu-latest + steps: + - name: Strip the Rovo Dev block and delete the Rovo Dev comment + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + with: + script: | + const { owner, repo } = context.repo; + + // Atlassian wraps the description block in HTML comment markers. + // Anchoring on those catches every wording of the message, not just + // "not activated", and swallows the horizontal rule tucked inside. + const marked = /\n*[\s\S]*?[ \t]*/gi; + + // Fallback for the day Atlassian drops the markers. + const bare = /\n*(?:-{3,}[ \t]*\n)?[ \t]*(?:\*\*|)?\s*Rovo Dev code review:[\s\S]*?Atlassian organization admin needs to activate Rovo Dev\.[ \t]*/gi; + + // Deliberately narrow: a real Rovo Dev code review would also come + // from atlassian[bot], and deleting one of those would lose content. + const adPhrases = [ + /to enable rovo dev code reviews/i, + /link your github account to your atlassian account/i, + ]; + + const isAtlassianBot = (user) => /^atlassian(\[bot\])?$/i.test(user?.login ?? ''); + const isRovoAd = (comment) => + isAtlassianBot(comment.user) && adPhrases.some((re) => re.test(comment.body ?? '')); + + async function stripBody(pr) { + const body = pr.body ?? ''; + const cleaned = body.replace(marked, '').replace(bare, '').trimEnd(); + if (cleaned === body.trimEnd()) { + return false; + } + // This update runs as GITHUB_TOKEN, which does not trigger further + // workflow runs, so stripping the block cannot loop back on itself. + await github.rest.pulls.update({ owner, repo, pull_number: pr.number, body: cleaned }); + core.info(`Stripped the Rovo Dev block from the body of PR #${pr.number}.`); + return true; + } + + async function deleteComment(comment, number) { + await github.rest.issues.deleteComment({ owner, repo, comment_id: comment.id }); + core.info(`Deleted Rovo Dev comment ${comment.id} on #${number}.`); + } + + async function deleteAdComments(number) { + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: number, + per_page: 100, + }); + const ads = comments.filter(isRovoAd); + for (const comment of ads) { + await deleteComment(comment, number); + } + return ads.length; + } + + if (context.eventName === 'pull_request_target') { + const pr = context.payload.pull_request; + if (!(await stripBody(pr))) { + core.info( + `No Rovo Dev block in PR #${pr.number} (edited by ${context.payload.sender.login}); nothing to strip.` + ); + } + return; + } + + if (context.eventName === 'issue_comment') { + const comment = context.payload.comment; + const number = context.payload.issue.number; + // The job filter already matched; re-check so a wording change to + // a real Rovo Dev review can never slip past it. + if (!isRovoAd(comment)) { + core.info(`Comment ${comment.id} on #${number} is not the Rovo Dev ad; leaving it alone.`); + return; + } + await deleteComment(comment, number); + return; + } + + const pulls = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'open', + per_page: 100, + }); + let bodies = 0; + let comments = 0; + for (const pr of pulls) { + if (await stripBody(pr)) { + bodies += 1; + } + comments += await deleteAdComments(pr.number); + } + core.info( + `Swept ${pulls.length} open pull requests: stripped ${bodies} bodies, deleted ${comments} comments.` + ); diff --git a/.gitignore b/.gitignore index af44d5bf1c..7bf2398743 100755 --- a/.gitignore +++ b/.gitignore @@ -104,8 +104,6 @@ sentry.properties .DS_Store # Generated files for tooling API -tests/test-home -/tests/**/.cg/init/model.jar /composite-builds/build-deps-common/constants/build/ /composite-builds/build-deps/build/ @@ -193,3 +191,8 @@ NATIVE_*.md TEST_*.md assets-*.zip dynamic_libs/*.aar.br + +# Per-project cache the IDE writes (models, sync metadata, locks). The test project's copy was +# tracked and every test run rewrote it with the local machine's absolute paths, so it arrived in +# unrelated commits -- a 12 MB binary among them (ADFA-5264). +testing/resources/test-project/.cg/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4be5ac177e..1002ead379 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,16 +6,16 @@ Code On The Go (CoGo) is a full Android IDE that runs **on the device** — it edits, builds, and deploys real Android apps offline, embedding a Termux toolchain and running an actual Gradle build in a separate process via the `tooling-api`. It is the maintained successor to AndroidIDE, so the codebase namespace is still `com.itsaky.androidide`. -There is **no single architectural philosophy** across the whole app. This large, layered application is still **predominantly View-based**: newer feature surfaces (plugin manager, AI agent, git, project list) follow a deliberate **Unidirectional Data Flow (UDF)** with Koin DI, `ViewModel` + `StateFlow`, sealed UI-state/effect types, and repositories, while older surfaces still use `LiveData` and talk to GreenRobot EventBus directly. New work follows the UDF pattern documented below, and new UI is built in **Jetpack Compose** ([ADR 0009](docs/adr/0009-jetpack-compose-for-new-ui.md)) — Compose replaces the view layer only; the UDF stack (ViewModel + `StateFlow`, Koin, repositories) is unchanged. Existing XML/View screens remain until substantially reworked. +There is **no single architectural philosophy** across the whole app. This large, layered application is still **predominantly View-based**: newer feature surfaces (plugin manager, AI agent, git, project list) follow a deliberate **Unidirectional Data Flow (UDF)** with Koin DI, `ViewModel` + `StateFlow`, sealed UI-state/effect types, and repositories, while older surfaces still use `LiveData` and talk to GreenRobot EventBus directly. New work follows the UDF pattern documented below, and new UI is built in **Jetpack Compose** ([ADR 0009](docs/adr/0009-jetpack-compose-for-new-ui.md)) — Compose replaces the view layer only; the UDF stack (ViewModel + `StateFlow`, Koin, repositories) is unchanged. Existing XML/View screens remain until substantially reworked. The first production example is the **Manager** screen (`PluginManagerActivity`) — merged Plugins/Templates tabs built with `Scaffold`/`TabRow`/`HorizontalPager` (ADFA-4928). ## Core Architecture & Data Flow -Feature code layers as **UI → ViewModel → Repository → data source**, with state flowing up and events/intents flowing down. Koin provides dependencies (`coreModule`, `pluginModule`), constructor-injected into ViewModels. +Feature code layers as **UI → ViewModel → Repository → data source**, with state flowing up and events/intents flowing down. Koin provides dependencies (`coreModule`, `pluginModule`, `templateModule`), constructor-injected into ViewModels. - **Data sources** — Room (`RecentProjectRoomDatabase` + DAO, `suspend` functions), raw SQLite (`SQLiteOpenHelper`, e.g. `localWebServer/WebServer`), the filesystem/preferences, the embedded `tooling-api` (on-device Gradle), and external clients (Gemini via the Google GenAI SDK, on-device llama.cpp, JGit). Most are exposed through `suspend` functions. -- **Repositories** — e.g. `agent/repository/GeminiRepository`, `repositories/PluginRepository`, `repositories/BreakpointRepository`. They wrap data sources and hide threading/IO from the ViewModel. +- **Repositories** — e.g. `agent/repository/GeminiRepository`, `repositories/PluginRepository`, `repositories/TemplateRepository`, `repositories/BreakpointRepository`. They wrap data sources and hide threading/IO from the ViewModel. - **ViewModels** — run work in `viewModelScope` on `Dispatchers.IO`, hold a private `MutableStateFlow`/`MutableSharedFlow`, and expose read-only `StateFlow`/`SharedFlow`. One-shot effects (toasts, navigation, dialogs) go through a separate `SharedFlow` of a sealed `*UiEffect` type. -- **UI (Fragments / Activities / Views)** — collect state in a lifecycle-aware coroutine and render it; user actions return to the ViewModel as method calls or sealed `*UiEvent` intents. The existing UI is **Android Views + Fragments + RecyclerView adapters**; new UI is Jetpack Compose ([ADR 0009](docs/adr/0009-jetpack-compose-for-new-ui.md)). (`compose-preview` previews the *user's* Compose code, not CoGo's own.) +- **UI (Fragments / Activities / Views)** — collect state in a lifecycle-aware coroutine and render it; user actions return to the ViewModel as method calls or sealed `*UiEvent` intents. The existing UI is **Android Views + Fragments + RecyclerView adapters**; new UI is Jetpack Compose ([ADR 0009](docs/adr/0009-jetpack-compose-for-new-ui.md)) — first used in the Manager screen (`ui/compose/ManagerScreen.kt`, ADFA-4928). (`compose-preview` previews the *user's* Compose code, not CoGo's own.) ``` ┌─────────────────────────────────────────────┐ @@ -64,7 +64,7 @@ Strategy: **layer-and-subsystem based**, not feature-by-feature. The Gradle buil | Shell | `termux:{termux-app,termux-shared,termux-view,termux-emulator}` | Embedded Termux shell and terminal. | | Plugin system | `plugin-api`, `plugin-api:plugin-builder`, `plugin-manager` | In-app plugin SDK + manager — `AndroidManifest.xml` `` contract, permissions, extensions. See [plugin-api.md](docs/plugin-api.md) for the API surface & compatibility policy. | | On-device AI | `llama-api`, `llama-impl` | llama.cpp integration, shipped as a per-flavor native AAR. | -| Cross-cutting | `eventbus`, `eventbus-android`, `eventbus-events`, `common`, `common-ui`, `logger`, `resources`, `preferences`, `shared` | Shared infra and the event bus. | +| Cross-cutting | `eventbus`, `eventbus-android`, `eventbus-events`, `common`, `common-ui`, `common-compose`, `logger`, `resources`, `preferences`, `shared` | Shared infra and the event bus. `common-compose` holds the Compose theming any module can opt into (see [ADR 0009](docs/adr/0009-jetpack-compose-for-new-ui.md)); it is a leaf so modules that aren't Compose pay nothing. | | Testing | `testing:{android,unit,lsp,tooling,common}` | Shared test harnesses, split by what's under test. | **Dependency rules (enforced):** @@ -83,14 +83,14 @@ These structural facts shape every module. Day-to-day build *commands* live in ` - **SDK levels** (`build-logic/.../build/config/BuildConfig.kt`): `COMPILE_SDK=36`, `MIN_SDK=28`, `TARGET_SDK=28`. **`TARGET_SDK` is deliberately pinned at 28:** higher targets enforce W^X (write-xor-execute), which blocks executing code from app-writable files. That is fatal for an on-device IDE that compiles and runs code (Gradle, `javac`, Termux binaries), so it is a hard requirement, not tech debt. `MIN_SDK_FOR_APPS_BUILT_WITH_COGO=16` is the floor for the apps a *user* builds with CoGo — distinct from CoGo's own `MIN_SDK`. - **Native asset bundling.** The on-device LLM (`llama-impl`) ships as a per-flavor native AAR, wired through the root `build.gradle.kts` (`bundleLlamaV8Assets` / `assembleV8Assets`, …); prebuilt per-flavor assets live under `assets/release/v7/` and `assets/release/v8/`. - **Native lib compression** (ADFA-2306, ADFA-4729). The app manifest hard-codes `android:extractNativeLibs="true"` (required: the installer must materialize libs in `nativeLibraryDir`, e.g. `libshizuku.so` is an executable the adb shell runs from there). That attribute overrides the `jniLibs.useLegacyPackaging` DSL, so AGP packages `lib//*.so` deflate-compressed in **every** APK — ~5.9 MB smaller (`libtree-sitter-kotlin.so` alone is 4.18 MB → 339 kB). The trap is the `recompressApk` post-step (release always, debug in CI only): its no-compress lists in `app/build.gradle.kts` must NOT contain `"so"`, or it silently re-stores the libs and undoes the saving — which is what ADFA-2306 fixed for release and ADFA-4729 for CI debug. Locally built debug APKs (including the e2e farm's) never run that step and were always fine. -- **`app` package layout is by concern, not feature:** `activities`, `fragments`, `services`, `di`, `agent`, `viewmodel(s)`, `repositories`, `roomData`, `localWebServer`, `preferences`, `ui`, `utils`, …. +- **`app` package layout is by concern, not feature:** `activities`, `fragments`, `services`, `di`, `agent`, `viewmodel(s)`, `repositories`, `roomData`, `localWebServer`, `preferences`, `ui` (Compose screens live under `ui/compose`), `templates/manager` (the Manager screen's `.cgt`-parsing data layer, with direct filesystem access to `Environment.TEMPLATES_DIR` — distinct from the plugin-facing `IdeTemplateService` in `plugin-api`/`plugin-manager`), `utils`, …. ## Technology Stack | Concern | Library / Approach | |---|---| -| UI | **Jetpack Compose for all new UI** ([ADR 0009](docs/adr/0009-jetpack-compose-for-new-ui.md)). The existing majority is still Android Views + Fragments + `RecyclerView` (Material Components); those legacy screens stay until reworked, but new IDE UI is Compose-only. | -| Dependency Injection | **Koin** (`org.koin`) — `coreModule`/`pluginModule`, `startKoin` in `IDEApplication`, plus a `ServiceLocator : KoinComponent` for lazy post-startup access. No Hilt/Dagger. | +| UI | **Jetpack Compose for all new UI** ([ADR 0009](docs/adr/0009-jetpack-compose-for-new-ui.md)); first production screen is the Manager screen (Plugins/Templates tabs, `app/.../ui/compose/`, ADFA-4928). The existing majority is still Android Views + Fragments + `RecyclerView` (Material Components); those legacy screens stay until reworked, but new IDE UI is Compose-only. | +| Dependency Injection | **Koin** (`org.koin`) — `coreModule`/`pluginModule`/`templateModule`, `startKoin` in `IDEApplication`, plus a `ServiceLocator : KoinComponent` for lazy post-startup access. No Hilt/Dagger. | | Asynchronous work | **Kotlin Coroutines + Flow** (`StateFlow`/`SharedFlow`, `viewModelScope`, app-scoped `CoroutineScope(SupervisorJob() + Dispatchers.IO)`); **GreenRobot EventBus** for cross-subsystem events. | | Networking | Offline-first; no general REST layer. External I/O is **Google GenAI SDK** (Gemini), **on-device llama.cpp**, and **JGit** (git). Retrofit is in the catalog but effectively unused in app code. | | Database / Persistence | **Room** is the default for relational/queryable data; **filesystem + preferences (DataStore)** for non-relational settings. **Raw SQLite** (`SQLiteDatabase` / `SupportSQLiteOpenHelper`) only for justified exceptions (see policy below). | @@ -102,7 +102,9 @@ These structural facts shape every module. Day-to-day build *commands* live in ` > > **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`. > -> **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`). `idetooltips` also declares unused Room Gradle deps (remove them), and the `androidx.room:*` strings in `editor`'s `GroovyAutoComplete` are autocomplete suggestions for the *user's* code, not CoGo persistence. +> **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. +> +> The tooltip, in-app/plugin-help, and local-web-server exceptions all read `documentation.db`, the prebuilt Tier 1/2/3 help database — see [docs/documentation-database.md](docs/documentation-database.md) for its schema and how each consumer queries it. ## State Management @@ -145,7 +147,7 @@ data class PluginManagerUiState( sealed class PluginManagerUiEvent { object LoadPlugins : PluginManagerUiEvent() data class EnablePlugin(val pluginId: String) : PluginManagerUiEvent() - data class InstallPlugin(val uri: Uri, val deleteSourceAfterInstall: Boolean) : PluginManagerUiEvent() + data class InstallPlugin(val source: PluginInstallSource, val deleteSourceAfterInstall: Boolean) : PluginManagerUiEvent() // ... } diff --git a/CLAUDE.md b/CLAUDE.md index 22b9a794b6..34a25504f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,22 @@ Every module carries `v7` (`armeabi-v7a`) and `v8` (`arm64-v8a`) flavors, so bui At least one Android emulator or device is available. Find it with `adb devices -l | grep -v offline`, then target it with the `ANDROID_SERIAL` env var. Note the app is **arm-only** (`v7`/`v8` flavors, no x86) — an x86_64 emulator can't run it (not always even via a translation layer), so testing often needs a **physical arm device** or an arm-translation emulator. +**Font-scale check.** Read the current value first so you can put it back. Each change recreates the activity (only `EditorActivityKt` declares `fontScale` in `configChanges`), so this doubles as a state-restoration test: + +```bash +orig=$(adb shell settings get system font_scale | tr -d '\r') # "null" if never set +trap 'if [ "$orig" = null ]; then + adb shell settings delete system font_scale + else + adb shell settings put system font_scale "$orig" + fi' EXIT + +adb shell settings put system font_scale 2.0 +adb exec-out screencap -p > /tmp/scale-2.0.png +``` + +At 2.0, look for text cut off mid-word, labels overrunning their control, actions pushed off the bottom with no way to scroll to them, and overlapping rows. + ## Architecture See **[ARCHITECTURE.md](ARCHITECTURE.md)** — the single source of truth for the module map, layering/data flow, dependency rules, tech stack (DI, async, persistence, networking), state management, and testing strategy. Don't re-document those here; update ARCHITECTURE.md. @@ -37,7 +53,9 @@ See **[ARCHITECTURE.md](ARCHITECTURE.md)** — the single source of truth for th - **Avoid new dependencies** — the build almost certainly already has what's needed. Check `gradle/libs.versions.toml` and `build.gradle.kts` first. - **Persistence:** prefer **Room** for relational data and the filesystem/preferences for settings; raw SQLite only for justified exceptions — see [ADR 0001](docs/adr/0001-prefer-room-for-persistence.md). +- **Don't treat a large binary asset's on-disk content as ground truth without checking its provenance first.** Run `git ls-files ` / `git check-ignore -v `, and grep the build files for how it's provisioned, before relying on its current schema or row content. Several assets here (e.g. `assets/documentation.db`, and the SDK/bootstrap/Gradle zips alongside it) are `.gitignore`d and fetched by a Gradle task from an external URL (see the `Asset(...)` list in `app/build.gradle.kts`) — a locally-cached copy can be stale independent of git commit history and silently diverge from the maintained original. - **Protect the two Android system bars** in any UI work: the top status bar (clock, notifications, status icons) and the bottom navigation bar (home, back, recents). Don't draw over or intercept them. +- **Every screen must survive 2x font scale.** Users with low vision run large system fonts, and a screen that clips or hides content at 2.0 is broken for them. Verify any new or changed screen at font scale **1.0 and 2.0** (see Build & test, Emulator / device) and say in the PR that you did. Text grows, so: use `sp` for text and `dp` for spacing — never an `sp` dimen as a margin or padding; don't box text in a fixed `dp` height or width; give content that can grow somewhere to scroll; and reserve `maxLines`/`singleLine`/`ellipsize` for text that is genuinely disposable. - **Plan and size before building.** Prefer **one PR per ticket/use case** — don't force-split a coherent change (splitting has its own overhead when later edits span the pieces). When a change is large, break it into **reviewable commits** — mechanical/refactor commits separate from behavioral ones — and offer review-by-commit. Treat ~500 LOC / ~10 files as a signal to reach for that commit structure, not a hard cap; the ceiling rises as LLM-assisted review matures. For staged multi-commit refactors (e.g. removing a dependency across many files/modules), order stages easiest-to-hardest and independently compile/test each stage (see Build & test's fast-iteration guidance) before moving to the next, so a failure is isolated to the stage that caused it. - **Keep docs in step with code.** When you change code, update the docs that describe it in the same change — a module's `README.md`, `ARCHITECTURE.md`, or an ADR — so a doc never outlives the API it documents (see REVIEW.md, Code quality). If the doc fix is out of scope, file a ticket rather than let it drift. - `.androidide_root` is a sentinel file tests use to locate the project root — don't delete it. diff --git a/README.md b/README.md index 1af4474a5f..7cd420ab6f 100755 --- a/README.md +++ b/README.md @@ -15,8 +15,9 @@

Report a bug or request a feature   •   - Join our support and discussions forum  •   - Telegram channel + Support and discussions forum  •   + Telegram channel   •   +Knowledge base

## Code on the Go and AndroidIDE diff --git a/REVIEW.md b/REVIEW.md index 39de210c00..dec26df2a1 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -22,7 +22,7 @@ A review isn't done because it *looks* fine; it's done when you can **show what | §4 Security | Which untrusted inputs were validated; secrets checked | | §5 Tests & coverage | JaCoCo numbers for new non-UI code (line & branch) | | §7 Code quality | Duplication/cohesion pass done; no reimplementation of existing helpers | -| §8–§9 A11y & help | contentDescription + long-press help on new interactive elements | +| §8–§9 A11y & help | contentDescription + long-press help on new interactive elements; font scale 1.0/2.0 verified on new or changed screens | | §10 Architecture | Checklist below, each item pass/fail | | §13 Plugins | API-surface touched? impact check result | @@ -40,6 +40,7 @@ Keep it proportional — a two-line change needs a two-line ledger. - [ ] **Docs:** public classes/functions have KDoc/Javadoc explaining *why*, not *what*; any module `README`/`ARCHITECTURE.md`/ADR the change affects is updated in the same PR. - [ ] **Strings** are in the **`:resources`** module's `strings.xml` (not per-module, not inline literals) — keeps localization centralized. - [ ] **Accessibility:** every actionable view has a `contentDescription` (XML *or* programmatic); decorative views are marked `importantForAccessibility="no"`. +- [ ] **Font scale:** new or changed screens verified at **1.0 and 2.0** — nothing clipped, nothing unreachable — or explicitly noted as not applicable. - [ ] **Contextual help:** new interactive elements (and any new screen/panel) have long-press help wired to the 3-tier tooltip system. - [ ] **Analytics:** meaningful user/build actions emit an event (see below). - [ ] **Scope/size:** PR is focused on one ticket/use case; if large, it's split into **reviewable commits** (mechanical separate from behavioral) rather than force-split into multiple PRs (`CLAUDE.md`). @@ -142,7 +143,7 @@ Keep event names/params stable and low-cardinality; **no PII, file paths with us - **Strings in `strings.xml`.** User-facing text must be a string resource, never an inline literal — lint flags `HardcodedText`, and externalized strings feed our Crowdin translation flow. Use plurals/`getQuantityString` and positional args for formatting. Log messages and analytics keys are *not* user-facing and stay in code. - **Dependencies:** don't add one without checking `gradle/libs.versions.toml` first — we probably already have it (`CLAUDE.md`). -## 8. Accessibility — every actionable view speaks +## 8. Accessibility — every actionable view speaks, and every screen scales CoGo serves visually-impaired developers, so TalkBack support is a correctness requirement, not a nice-to-have (pattern set by ADFA-2667). New UI is Compose ([§10](#10-architecture-alignment) / [ADR 0009](docs/adr/0009-jetpack-compose-for-new-ui.md)), so each rule gives the View and Compose form — the requirement is the same in either. @@ -159,6 +160,15 @@ CoGo serves visually-impaired developers, so TalkBack support is a correctness r - **Externalize, with the `cd_` convention.** Content descriptions live in `strings.xml` as `cd_*` — greppable, translatable, reusable; check for an existing one first. `HardcodedText` lint does **not** catch Compose literals, so reviewers must. - **Bonus — it stabilizes tests.** Screen-reader semantics are what UI tests match on (`ACTION_CLICK` for Views, `onNodeWithContentDescription(…)` for Compose), so a11y and reliable instrumentation tests are the same work. +**Text scales, so layouts must too.** Low vision means large system fonts as often as it means TalkBack. A screen isn't done until it works at **2x**. + +- **Verify new or changed screens at font scale 1.0 and 2.0**, and put the result in the PR — screenshots at both scales, or one line naming both scales and what you checked. Recipe in `CLAUDE.md` (Build & test → Emulator / device). "No visual change" or "no text on this surface" is a valid one-line opt-out; silence is not. +- **Spacing in `dp`, text in `sp`.** An `sp` dimension used as a margin or padding grows with the font scale and squeezes the text it was meant to frame — as `layout-land/fragment_onboarding_greeting.xml` does today with `@dimen/_32sp`. +- **Don't box text in a fixed size.** A control sized `40dp x 40dp` can't hold a label that doubled. Let the container wrap its content and set a `minWidth`/`minHeight` for the touch target instead of a fixed one. + - *Compose:* the same trap is `Modifier.height(44.dp)`/`.size(36.dp)` on chrome that contains text — use `defaultMinSize` and let it grow. +- **Give growth somewhere to go.** Content that can reflow past the viewport needs a `NestedScrollView` (Compose: `verticalScroll`/`LazyColumn`). Only 14 of the 108 layouts in `app/src/main/res/layout/` have one today — don't add to the pile. +- **`maxLines`/`singleLine`/`ellipsize` are a decision, not a default.** Clamping is fine for a preview line, wrong for anything the user must read to proceed. `ellipsize="none"` with `maxLines` clips mid-glyph and is almost never what you want. + ## 9. Contextual help — long-press works everywhere Help in CoGo is reached by **long-press**, anywhere: a progressive three-tier experience — **Tiers 1 & 2 are tooltips** (anchored popups from `idetooltips`), **Tier 3 is a full help web page** via the tooltip's "See More" link. A long-press should never be met with silence. diff --git a/actions/build.gradle.kts b/actions/build.gradle.kts index 981e779eb1..759aac46c9 100644 --- a/actions/build.gradle.kts +++ b/actions/build.gradle.kts @@ -44,4 +44,5 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.google.material) + testImplementation(projects.testing.unit) } diff --git a/actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt b/actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt index 5597f0ac0c..82c9f5506d 100644 --- a/actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt +++ b/actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt @@ -1,243 +1,243 @@ -/* - * 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.actions - -import android.graphics.ColorFilter -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter -import android.graphics.drawable.Drawable -import android.view.Menu -import android.view.View -import androidx.annotation.CallSuper -import com.itsaky.androidide.idetooltips.TooltipCategory -import com.itsaky.androidide.utils.resolveAttr - -/** - * An action that can be registered using the [ActionsRegistry] - * [com.itsaky.androidide.actions.ActionsRegistry] - * - * @author Akash Yadav - */ -interface ActionItem { - - /** - * A unique ID for this action. - */ - val id: String - - /** - * The label for this action. - */ - var label: String - - /** - * Whether the action should be visible to the user or not. - */ - var visible: Boolean - - /** - * Whether the action should be enabled. - */ - var enabled: Boolean - - /** - * Icon for this action. - */ - var icon: Drawable? - - /** - * Whether the [execAction] method of this action must be executed on UI thread. - */ - var requiresUIThread: Boolean - - /** - * The location of this [ActionItem]. - */ - var location: Location - - /** - * The tooltip tag of this [ActionItem]. - */ - var tooltipTag: String - get() = "" - set(_) {} - - /** - * Retrieves the tooltip tag for this [ActionItem]. - * - * This function allows the action to provide a context-specific tooltip. For example, - * the "Copy" action can have a different tooltip in a standard code editor - * versus a read-only output panel where the user can only view, copy, and share content. - * - * @param isReadOnlyContext `true` if the action is displayed in a context where the - * content is read-only (e.g., a build output or logcat panel), `false` otherwise. - * @return The appropriate tooltip tag for the given context, or an empty string if - * no tooltip is available. - */ - fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = "" - - /** - * Retrieves the tooltip category for this [ActionItem]. The default is - * [TooltipCategory.CATEGORY_IDE]; plugin-contributed actions override this - * to point at their own `plugin_` category so the lookup hits - * tooltip rows the plugin installed via [DocumentationExtension]. - */ - fun retrieveTooltipCategory(): String = TooltipCategory.CATEGORY_IDE - - /** - * The order of this action item. This is used only at some locations and not everywhere. - * - * @see android.view.MenuItem.getOrder - */ - val order: Int - get() = Menu.NONE - - /** - * The item ID that will be set to the menu item. - */ - val itemId: Int - get() = id.hashCode() - - /** - * Whether the editor toolbar should fully remove this action when [visible] is false, - * instead of the legacy behaviour of keeping it and only greying out when disabled. - * Built-in actions keep the legacy behaviour (default false); plugin-contributed - * toolbar actions opt in by overriding this to true. - */ - val honorVisibility: Boolean - get() = false - - /** - * Prepare the action. Subclasses can modify the visual properties of this action here. - * - * @param data The data containing various information about the event. - */ - @CallSuper - fun prepare(data: ActionData) { - visible = true - enabled = true - } - - /** - * Execute the action. The action executed in a background thread by default. - * - * @param data The data containing various information about the event. - * @return `true` if this action was executed successfully, `false` otherwise. - */ - suspend fun execAction(data: ActionData): Any - - /** - * Called just after the [execAction] method executes **successfully** (i.e. returns `true`). - * Subclasses are free to do UI related work here as this method is called on UI thread. - * - * @param data The data containing various information about the event. - */ - fun postExec(data: ActionData, result: Any) = Unit - - /** - * Called when the action item is to be destroyed. Any resource references must be released if - * held. - */ - fun destroy() = Unit - - /** - * Return the show as action flags for the menu item. - * - * @return The show as action flags. - */ - fun getShowAsActionFlags(data: ActionData): Int = -1 - - /** - * Create custom action view for this action item. - * - * @return The custom action view or `null`. - */ - fun createActionView(data: ActionData): View? = null - - /** - * Creates the color filter for this action's icon drawable. - * - * The default implementation returns a [PorterDuffColorFilter] instance with color [R.attr.colorOnSurface]. - */ - fun createColorFilter(data: ActionData): ColorFilter? { - return data.getContext()?.let { - PorterDuffColorFilter(it.resolveAttr(R.attr.colorOnSurface), PorterDuff.Mode.SRC_ATOP) - } - } - - /** Location where an action item will be shown. */ - enum class Location(val id: String) { - - /** - * Location marker for the action items shown in the debugger (both overlay window and the - * bottom sheet). - */ - DEBUGGER_ACTIONS("ide.debugger"), - - /** Location marker for action items shown in editor activity's toolbar. */ - EDITOR_TOOLBAR("ide.editor.toolbar"), - - /** Location marker for action items shown in editor activity's toolbar submenu. - * FindInFileAction and FindInProjectAction will use this location so - * they don't show in the editor activity's toolbar*/ - EDITOR_FIND_ACTION_MENU("ide.editor.toolbar.find.menu"), - - /** - * Location marker for action items shown in editor activity's sidebar (navigation rail in the drawer). - */ - EDITOR_SIDEBAR("ide.editor.sidebar"), - EDITOR_RIGHT_SIDEBAR("ide.editor.right.sidebar"), - - /** - * Location marker for action items shown in the default category of editor activity's sidebar (navigation rail in the drawer). - */ - EDITOR_SIDEBAR_DEFAULT_ITEMS("ide.editor.sidebar.defaultItems"), - - /** Location marker for action items shown in editor's text action menu. */ - EDITOR_TEXT_ACTIONS("ide.editor.textActions"), - - /** - * Location marker for action items shown in 'Code actions' submenu in editor's text action - * menu. - */ - EDITOR_CODE_ACTIONS("ide.editor.codeActions"), - - /** Location marker for action items shown when file tabs are reselected. */ - EDITOR_FILE_TABS("ide.editor.fileTabs"), - - /** - * Location marker for action items that are shown when the files in the editor activity's file - * tree are long clicked. - */ - EDITOR_FILE_TREE("ide.editor.fileTree"), - - /** Location marker for action items shown in UI Designer activity's toolbar. */ - UI_DESIGNER_TOOLBAR("ide.uidesigner.toolbar"), - - /** Location marker for action items shown on the main screen. */ - MAIN_SCREEN("ide.main.screen"); - - override fun toString(): String { - return id - } - - fun forId(id: String): Location { - return entries.first { it.id == id } - } - } -} +/* + * 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.actions + +import android.graphics.ColorFilter +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.graphics.drawable.Drawable +import android.view.Menu +import android.view.View +import androidx.annotation.CallSuper +import com.itsaky.androidide.idetooltips.TooltipCategory +import com.itsaky.androidide.utils.resolveAttr + +/** + * An action that can be registered using the [ActionsRegistry] + * [com.itsaky.androidide.actions.ActionsRegistry] + * + * @author Akash Yadav + */ +interface ActionItem { + /** + * A unique ID for this action. + */ + val id: String + + /** + * The label for this action. + */ + var label: String + + /** + * Whether the action should be visible to the user or not. + */ + var visible: Boolean + + /** + * Whether the action should be enabled. + */ + var enabled: Boolean + + /** + * Icon for this action. + */ + var icon: Drawable? + + /** + * Whether the [execAction] method of this action must be executed on UI thread. + */ + var requiresUIThread: Boolean + + /** + * The location of this [ActionItem]. + */ + var location: Location + + /** + * The tooltip tag of this [ActionItem]. + */ + var tooltipTag: String + get() = "" + set(_) {} + + /** + * Retrieves the tooltip tag for this [ActionItem]. + * + * This function allows the action to provide a context-specific tooltip. For example, + * the "Copy" action can have a different tooltip in a standard code editor + * versus a read-only output panel where the user can only view, copy, and share content. + * + * @param isReadOnlyContext `true` if the action is displayed in a context where the + * content is read-only (e.g., a build output or logcat panel), `false` otherwise. + * @return The appropriate tooltip tag for the given context, or an empty string if + * no tooltip is available. Defaults to [tooltipTag], so an action may override either + * member and every consumer sees the same value (ADFA-4510). + */ + fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = tooltipTag + + /** + * Retrieves the tooltip category for this [ActionItem]. The default is + * [TooltipCategory.CATEGORY_IDE]; plugin-contributed actions override this + * to point at their own `plugin_` category so the lookup hits + * tooltip rows the plugin installed via [DocumentationExtension]. + */ + fun retrieveTooltipCategory(): String = TooltipCategory.CATEGORY_IDE + + /** + * The order of this action item. This is used only at some locations and not everywhere. + * + * @see android.view.MenuItem.getOrder + */ + val order: Int + get() = Menu.NONE + + /** + * The item ID that will be set to the menu item. + */ + val itemId: Int + get() = id.hashCode() + + /** + * Whether the editor toolbar should fully remove this action when [visible] is false, + * instead of the legacy behaviour of keeping it and only greying out when disabled. + * Built-in actions keep the legacy behaviour (default false); plugin-contributed + * toolbar actions opt in by overriding this to true. + */ + val honorVisibility: Boolean + get() = false + + /** + * Prepare the action. Subclasses can modify the visual properties of this action here. + * + * @param data The data containing various information about the event. + */ + @CallSuper + fun prepare(data: ActionData) { + visible = true + enabled = true + } + + /** + * Execute the action. The action executed in a background thread by default. + * + * @param data The data containing various information about the event. + * @return `true` if this action was executed successfully, `false` otherwise. + */ + suspend fun execAction(data: ActionData): Any + + /** + * Called just after the [execAction] method executes **successfully** (i.e. returns `true`). + * Subclasses are free to do UI related work here as this method is called on UI thread. + * + * @param data The data containing various information about the event. + */ + fun postExec( + data: ActionData, + result: Any, + ) = Unit + + /** + * Called when the action item is to be destroyed. Any resource references must be released if + * held. + */ + fun destroy() = Unit + + /** + * Return the show as action flags for the menu item. + * + * @return The show as action flags. + */ + fun getShowAsActionFlags(data: ActionData): Int = -1 + + /** + * Create custom action view for this action item. + * + * @return The custom action view or `null`. + */ + fun createActionView(data: ActionData): View? = null + + /** + * Creates the color filter for this action's icon drawable. + * + * The default implementation returns a [PorterDuffColorFilter] instance with color [R.attr.colorOnSurface]. + */ + fun createColorFilter(data: ActionData): ColorFilter? = + data.getContext()?.let { + PorterDuffColorFilter(it.resolveAttr(R.attr.colorOnSurface), PorterDuff.Mode.SRC_ATOP) + } + + /** Location where an action item will be shown. */ + enum class Location( + val id: String, + ) { + /** + * Location marker for the action items shown in the debugger (both overlay window and the + * bottom sheet). + */ + DEBUGGER_ACTIONS("ide.debugger"), + + /** Location marker for action items shown in editor activity's toolbar. */ + EDITOR_TOOLBAR("ide.editor.toolbar"), + + /** Location marker for action items shown in editor activity's toolbar submenu. + * FindInFileAction and FindInProjectAction will use this location so + * they don't show in the editor activity's toolbar*/ + EDITOR_FIND_ACTION_MENU("ide.editor.toolbar.find.menu"), + + /** + * Location marker for action items shown in editor activity's sidebar (navigation rail in the drawer). + */ + EDITOR_SIDEBAR("ide.editor.sidebar"), + EDITOR_RIGHT_SIDEBAR("ide.editor.right.sidebar"), + + /** + * Location marker for action items shown in the default category of editor activity's sidebar (navigation rail in the drawer). + */ + EDITOR_SIDEBAR_DEFAULT_ITEMS("ide.editor.sidebar.defaultItems"), + + /** Location marker for action items shown in editor's text action menu. */ + EDITOR_TEXT_ACTIONS("ide.editor.textActions"), + + /** + * Location marker for action items shown in 'Code actions' submenu in editor's text action + * menu. + */ + EDITOR_CODE_ACTIONS("ide.editor.codeActions"), + + /** Location marker for action items shown when file tabs are reselected. */ + EDITOR_FILE_TABS("ide.editor.fileTabs"), + + /** + * Location marker for action items that are shown when the files in the editor activity's file + * tree are long clicked. + */ + EDITOR_FILE_TREE("ide.editor.fileTree"), + + /** Location marker for action items shown in UI Designer activity's toolbar. */ + UI_DESIGNER_TOOLBAR("ide.uidesigner.toolbar"), + + /** Location marker for action items shown on the main screen. */ + MAIN_SCREEN("ide.main.screen"), + ; + + override fun toString(): String = id + + fun forId(id: String): Location = entries.first { it.id == id } + } +} diff --git a/actions/src/main/java/com/itsaky/androidide/actions/ActionMenu.kt b/actions/src/main/java/com/itsaky/androidide/actions/ActionMenu.kt index d3c1884c54..8b773e5c2f 100644 --- a/actions/src/main/java/com/itsaky/androidide/actions/ActionMenu.kt +++ b/actions/src/main/java/com/itsaky/androidide/actions/ActionMenu.kt @@ -1,59 +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.actions - -/** - * An action menu is an action which can contain child actions. - * @author Akash Yadav - */ -interface ActionMenu : ActionItem { - - val children: MutableSet - - fun addAction(action: ActionItem) = children.add(action) - - fun removeAction(action: ActionItem) = children.remove(action) - - /** - * Find the action item with the given action ID. - * - * @return The action item or `null` if not found. - */ - fun findAction(id: String): ActionItem? { - return children.find { it.id == id } - } - - override fun prepare(data: ActionData) { - super.prepare(data) - visible = children.isNotEmpty() && isAtLeastOneChildVisible(data) - enabled = visible - } - - /** Action menus are not supposed to perform any action */ - override suspend fun execAction(data: ActionData): Boolean { - return false - } - - /** - * Calls [ActionItem.prepare] on each child action and returns `true` if at least one of them - * is [visible][ActionItem.visible]. - */ - fun isAtLeastOneChildVisible(data: ActionData) : Boolean { - return children.firstOrNull { it.prepare(data); it.visible } != null - } -} +/* + * 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.actions + +/** + * An action menu is an action which can contain child actions. + * @author Akash Yadav + */ +interface ActionMenu : ActionItem { + val children: MutableSet + + fun addAction(action: ActionItem) = children.add(action) + + fun removeAction(action: ActionItem) = children.remove(action) + + /** + * Find the action item with the given action ID. + * + * @return The action item or `null` if not found. + */ + fun findAction(id: String): ActionItem? = children.find { it.id == id } + + /** + * Find the child action with the given menu item ID. + * + * Child actions are not registered with the [ActionsRegistry], so the registry cannot resolve + * them; a submenu's renderer must look them up here (ADFA-4510). + * + * @return The action item or `null` if not found. + */ + fun findAction(itemId: Int): ActionItem? = children.find { it.itemId == itemId } + + override fun prepare(data: ActionData) { + super.prepare(data) + visible = children.isNotEmpty() && isAtLeastOneChildVisible(data) + enabled = visible + } + + /** Action menus are not supposed to perform any action */ + override suspend fun execAction(data: ActionData): Boolean = false + + /** + * Calls [ActionItem.prepare] on each child action and returns `true` if at least one of them + * is [visible][ActionItem.visible]. + */ + fun isAtLeastOneChildVisible(data: ActionData): Boolean = + children.firstOrNull { + it.prepare(data) + it.visible + } != null +} diff --git a/actions/src/test/java/com/itsaky/androidide/actions/ActionTooltipResolutionTest.kt b/actions/src/test/java/com/itsaky/androidide/actions/ActionTooltipResolutionTest.kt new file mode 100644 index 0000000000..b1340fbc4b --- /dev/null +++ b/actions/src/test/java/com/itsaky/androidide/actions/ActionTooltipResolutionTest.kt @@ -0,0 +1,100 @@ +/* + * 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.actions + +import android.graphics.drawable.Drawable +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Covers the two halves of code-action tooltip resolution that failed in ADFA-4510: finding a + * submenu child by its menu item id, and reading a tag from whichever member the action overrode. + * + * Code actions are children of CodeActionsMenu and are never registered with the registry, so the + * render path can only reach them through [ActionMenu.findAction]. They override the `tooltipTag` + * property while the render path reads `retrieveTooltipTag()`, so both must resolve to the same + * value. + */ +class ActionTooltipResolutionTest { + private open class FakeAction( + override val id: String, + ) : ActionItem { + override var label: String = id + override var visible: Boolean = true + override var enabled: Boolean = true + override var icon: Drawable? = null + override var requiresUIThread: Boolean = false + override var location: ActionItem.Location = ActionItem.Location.EDITOR_CODE_ACTIONS + + override suspend fun execAction(data: ActionData): Any = true + } + + private class PropertyOnlyAction : FakeAction("fake.propertyOnly") { + override var tooltipTag: String = "editor.codeactions.comment" + } + + private class FunctionOnlyAction : FakeAction("fake.functionOnly") { + override fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = "editor.codeactions.gotodef" + } + + private class UntaggedAction : FakeAction("fake.untagged") + + private class FakeMenu : ActionMenu { + override val children: MutableSet = mutableSetOf() + override val id: String = "fake.menu" + override var label: String = "Fake menu" + override var visible: Boolean = true + override var enabled: Boolean = true + override var icon: Drawable? = null + override var requiresUIThread: Boolean = false + override var location: ActionItem.Location = ActionItem.Location.EDITOR_TEXT_ACTIONS + } + + private fun menuOf(vararg actions: ActionItem) = FakeMenu().apply { actions.forEach(::addAction) } + + @Test + fun `findAction by itemId returns the matching child`() { + val child = PropertyOnlyAction() + val menu = menuOf(UntaggedAction(), child) + + assertThat(menu.findAction(child.itemId)).isSameInstanceAs(child) + } + + @Test + fun `findAction by itemId returns null when no child matches`() { + val menu = menuOf(UntaggedAction()) + + assertThat(menu.findAction("nothing.registered".hashCode())).isNull() + } + + @Test + fun `retrieveTooltipTag reads an action that overrides only the property`() { + assertThat(PropertyOnlyAction().retrieveTooltipTag(false)) + .isEqualTo("editor.codeactions.comment") + } + + @Test + fun `retrieveTooltipTag reads an action that overrides only the function`() { + assertThat(FunctionOnlyAction().retrieveTooltipTag(false)) + .isEqualTo("editor.codeactions.gotodef") + } + + @Test + fun `retrieveTooltipTag is empty when the action overrides neither member`() { + assertThat(UntaggedAction().retrieveTooltipTag(false)).isEmpty() + } +} diff --git a/apk-viewer-plugin/src/main/res/values-in/strings.xml b/apk-viewer-plugin/src/main/res/values-in/strings.xml new file mode 100644 index 0000000000..3118ad0ff1 --- /dev/null +++ b/apk-viewer-plugin/src/main/res/values-in/strings.xml @@ -0,0 +1,52 @@ + + + Penganalisis APK + Penganalisis APK + Analisis struktur APK, ukuran file, dan metadata + + Penganalisis APK + Analisis struktur dan isi APK + Analisis APK + Pilih APK untuk Dianalisis + + Menganalisis APK\u2026 + Gagal menganalisis APK: %s + + Struktur APK + File Utama + Pustaka Native (%d) + Direktori Sumber Daya (%d) + File Besar (>100KB) + Metadata APK + + Properti + Nilai + File + Mentah + Terkompresi + Rasio + Nama + Jumlah + Direktori + File + + Ukuran File APK + Total Entri + Ukuran Tidak Terkompresi + Ukuran Terkompresi + Rasio Kompresi + Direktori + File + Skema Tanda Tangan + Multi-DEX + Obfuskasi Kode + + Ya + Tidak + Terdeteksi + Tidak terdeteksi + v1 (penandatanganan JAR) + v2+ atau tidak ditandatangani + T/A + Tampilkan semua (%d) + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2f4fdf7ddc..fff65bc79d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,10 @@ import com.itsaky.androidide.build.config.BuildConfig import com.itsaky.androidide.desugaring.utils.JavaIOReplacements.applyJavaIOReplacements import com.itsaky.androidide.plugins.AndroidIDEAssetsPlugin +import org.adfa.constants.GRADLE_API_NAME_JAR_BR +import org.adfa.constants.GRADLE_API_NAME_JAR_ZIP +import org.adfa.constants.GRADLE_DISTRIBUTION_ARCHIVE_NAME +import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform import org.json.JSONObject import java.io.BufferedOutputStream import java.io.ByteArrayInputStream @@ -34,6 +38,7 @@ plugins { // Sentry gradle plugin; the SDK it wires up reports to our GlitchTip backend. alias(libs.plugins.sentry) alias(libs.plugins.google.services) + alias(libs.plugins.kotlin.compose) } fun propOrEnv(name: String): String = @@ -93,6 +98,9 @@ android { // Skip TreeSitter native library loading in tests it.systemProperty("java.library.path", System.getProperty("java.library.path")) it.systemProperty("androidide.test.mode", "true") + // JUnit Platform, so JUnit Jupiter tests run; the vintage engine dependency + // below keeps existing JUnit 4/Robolectric tests running unchanged. + it.useJUnitPlatform() } } } @@ -102,6 +110,10 @@ android { generateLocaleConfig = true } + buildFeatures { + compose = true + } + sourceSets { getByName("androidTest") { manifest.srcFile("src/androidTest/AndroidManifest.xml") @@ -209,6 +221,55 @@ configurations.configureEach { exclude(group = "com.google.auto.value", module = "auto-value") } +// brotli4j ships its native decoder as a per-OS/arch artifact, so the JVM unit tests need the one +// matching whoever is building. Mirrors build-logic/plugins' dispatch, but degrades to null on an +// unrecognized host instead of throwing: this runs at configuration time, so throwing would fail +// every task in the build -- including :app:assembleV8Debug, which needs no desktop native at all +// -- rather than only the JVM unit-test tasks that actually consume this dependency. +fun brotli4jNativeForHost(): Provider? { + val arch = DefaultNativePlatform.getCurrentArchitecture() + val os = DefaultNativePlatform.getCurrentOperatingSystem() + val native = + when { + os.isMacOsX -> { + when { + arch.isArm64 -> libs.brotli4j.osx.aarch64 + arch.isAmd64 -> libs.brotli4j.osx.x64 + else -> null + } + } + + os.isWindows -> { + when { + arch.isArm64 -> libs.brotli4j.windows.aarch64 + arch.isAmd64 -> libs.brotli4j.windows.x64 + else -> null + } + } + + os.isLinux -> { + when { + arch.isArm64 -> libs.brotli4j.linux.aarch64 + arch.isAmd64 -> libs.brotli4j.linux.x64 + else -> null + } + } + + else -> { + null + } + } + if (native == null) { + logger.warn( + "brotli4j: no native decoder for {}/{} -- brotli4j-backed JVM unit tests " + + "(e.g. BrotliDictionaryDecodeTest) will fail with UnsatisfiedLinkError on this host.", + os, + arch, + ) + } + return native +} + dependencies { debugImplementation(libs.common.leakcanary) @@ -241,6 +302,17 @@ dependencies { // Git implementation(libs.git.jgit) + // Compose (ADR 0009 - new IDE dialogs/screens are Compose) + implementation(platform(libs.compose.bom)) + implementation(libs.compose.runtime) + implementation(libs.compose.ui) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + implementation(libs.compose.activity) + implementation(libs.compose.lifecycle.runtime) + implementation(libs.compose.ui.tooling.preview) + debugImplementation(libs.compose.ui.tooling) + // AndroidX implementation(libs.androidx.splashscreen) implementation(libs.androidx.annotation) @@ -325,6 +397,10 @@ dependencies { testImplementation(projects.testing.unit) testImplementation(libs.core.tests.anroidx.arch) + testImplementation(libs.tests.junit.jupiter) + testRuntimeOnly(libs.tests.junit.platformLauncher) + // Keeps existing JUnit 4/Robolectric tests running under the JUnit Platform. + testRuntimeOnly(libs.tests.junit.vintageEngine) androidTestImplementation(projects.common) androidTestImplementation(projects.testing.android) { exclude(group = "com.google.protobuf", module = "protobuf-lite") @@ -337,6 +413,13 @@ dependencies { // brotli4j implementation(libs.brotli4j) + // JVM unit tests (e.g. BrotliDictionaryDecodeTest) run brotli4j's real native decoder, not an + // Android target -- without a desktop native on the test classpath, Brotli4jLoader has nothing + // to load and every such test fails with UnsatisfiedLinkError. Pick the native for whoever is + // building, so the suite runs off a Linux x64 CI runner too (same dispatch as build-logic/plugins'). + // Null on an unrecognized host just means those specific tests fail there -- see + // brotli4jNativeForHost's own warning -- not that this whole build should refuse to configure. + brotli4jNativeForHost()?.let { testImplementation(it) } implementation(libs.common.markwon.core) implementation(libs.common.markwon.linkify) @@ -552,8 +635,8 @@ fun createAssetsZip(arch: String) { arrayOf( androidSdkName, "localMvnRepository.zip", - "gradle-8.14.3-bin.zip", - "gradle-api-8.14.3.jar.zip", + "$GRADLE_DISTRIBUTION_ARCHIVE_NAME", + "$GRADLE_API_NAME_JAR_ZIP", "documentation.db", bootstrapName, "plugin-artifacts.zip", @@ -1197,15 +1280,15 @@ val debugAssets = "debug", ), Asset( - "assets/gradle-8.14.3-bin.zip", - "https://appdevforall.org/dev-assets/debug/gradle-8.14.3-bin.zip", - "gradle-8.14.3-bin.zip", + "assets/$GRADLE_DISTRIBUTION_ARCHIVE_NAME", + "https://appdevforall.org/dev-assets/debug/$GRADLE_DISTRIBUTION_ARCHIVE_NAME", + "$GRADLE_DISTRIBUTION_ARCHIVE_NAME", "debug", ), Asset( - "assets/gradle-api-8.14.3.jar.zip", - "https://appdevforall.org/dev-assets/debug/gradle-api-8.14.3.jar.zip", - "gradle-api-8.14.3.jar.zip", + "assets/$GRADLE_API_NAME_JAR_ZIP", + "https://appdevforall.org/dev-assets/debug/$GRADLE_API_NAME_JAR_ZIP", + "$GRADLE_API_NAME_JAR_ZIP", "debug", ), Asset( @@ -1225,15 +1308,15 @@ val debugAssets = val releaseAssets = listOf( Asset( - "assets/release/common/data/common/gradle-8.14.3-bin.zip.br", - "https://appdevforall.org/dev-assets/release/gradle-8.14.3-bin.zip.br", - "gradle-8.14.3-bin.zip.br", + "assets/release/common/data/common/$GRADLE_DISTRIBUTION_ARCHIVE_NAME.br", + "https://appdevforall.org/dev-assets/release/$GRADLE_DISTRIBUTION_ARCHIVE_NAME.br", + "$GRADLE_DISTRIBUTION_ARCHIVE_NAME.br", "release", ), Asset( - "assets/release/common/data/common/gradle-api-8.14.3.jar.br", - "https://appdevforall.org/dev-assets/release/gradle-api-8.14.3.jar.br", - "gradle-api-8.14.3.jar.br", + "assets/release/common/data/common/$GRADLE_API_NAME_JAR_BR", + "https://appdevforall.org/dev-assets/release/$GRADLE_API_NAME_JAR_BR", + "$GRADLE_API_NAME_JAR_BR", "release", ), Asset( diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 7d9f1ad3da..c5c01850b9 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -193,6 +193,17 @@ -keep class io.sentry.** { *; } -dontwarn io.sentry.** +# ADFA-5156: TEMPORARY ROLLBACK of the R8 shrinking re-enabled in ADFA-3604. +# Plugins load parent-first through a stock DexClassLoader, so they resolve +# kotlin.** from the app's dex rather than their own bundled stdlib. R8 cannot +# see plugin call sites, so it strips every stdlib member the IDE itself does +# not call and plugins die with NoSuchMethodError at runtime (Sketch to UI: +# ArraysKt.maxOrNull([F)). With -dontobfuscate and -dontoptimize already set, +# this restores R8 to a pass-through and returns the release build to the +# configuration shipped before ADFA-3604. Revert once ADFA-5156 lands a +# targeted fix (keep rules for kotlin.**/kotlinx.coroutines.**). +-dontshrink + ## Plugin SPI ## Plugins are loaded dynamically via DexClassLoader, so R8 cannot see their ## implementations of these interfaces. Without these rules, R8 narrows the diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/helper/HandlePrivacyDisclosureHelper.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/HandlePrivacyDisclosureHelper.kt index 1721e62e3b..9a766b8765 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/helper/HandlePrivacyDisclosureHelper.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/HandlePrivacyDisclosureHelper.kt @@ -3,7 +3,8 @@ package com.itsaky.androidide.helper import android.util.Log import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiSelector -import com.itsaky.androidide.preferences.internal.prefManager +import com.itsaky.androidide.preferences.internal.StatPreferences +import com.itsaky.androidide.preferences.internal.TelemetryConsent import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -11,38 +12,32 @@ import com.itsaky.androidide.resources.R as ResourcesR private const val TAG = "PrivacyDisclosure" -// Mirrors PermissionsFragment.KEY_PRIVACY_DISCLOSURE_SHOWN (private there). -// If the dialog unexpectedly appears on a rerun or is expected but absent, -// check that the fragment's key has not been renamed. -private const val KEY_PRIVACY_DISCLOSURE_SHOWN = "privacy.disclosure.shown" private const val PRIVACY_DIALOG_APPEAR_TIMEOUT_MS = 10_000L private const val PRIVACY_DIALOG_ABSENT_TIMEOUT_MS = 2_000L private const val PRIVACY_FLAG_PERSIST_TIMEOUT_MS = 5_000L /** - * Verifies and dismisses the privacy disclosure dialog on the onboarding + * Verifies and accepts the telemetry consent dialog on the onboarding * permissions screen. * - * The app shows the dialog only while the persisted - * `privacy.disclosure.shown` flag is unset, so the flow - * branches on that flag instead of on whether the dialog happened to render in - * time: a fresh install hard-asserts the dialog appears and accepts it, while a - * rerun on a device that already accepted asserts it stays hidden. + * The app shows the dialog only while the persisted telemetry consent is + * [TelemetryConsent.UNSET], so the flow branches on that value instead of on + * whether the dialog happened to render in time: a fresh install hard-asserts + * the dialog appears and accepts it, while a rerun on a device that already + * answered asserts it stays hidden. */ fun TestContext.handlePrivacyDisclosure() { val targetContext = InstrumentationRegistry.getInstrumentation().targetContext val dialogTitle = targetContext.getString(ResourcesR.string.privacy_disclosure_title) - val expectDialog = - !prefManager.getBoolean(KEY_PRIVACY_DISCLOSURE_SHOWN, false) + val expectDialog = StatPreferences.telemetryConsent == TelemetryConsent.UNSET if (expectDialog) { - Log.i(TAG, "Privacy disclosure flag unset; expecting dialog and accepting it") + Log.i(TAG, "Telemetry consent unset; expecting dialog and accepting it") step("Verify and accept privacy disclosure") { val d = device.uiDevice val acceptText = targetContext.getString(ResourcesR.string.privacy_disclosure_accept) - val learnMoreText = - targetContext.getString(ResourcesR.string.privacy_disclosure_learn_more) + val declineText = targetContext.getString(ResourcesR.string.privacy_disclosure_decline) assertTrue( "Dialog title missing", @@ -52,24 +47,24 @@ fun TestContext.handlePrivacyDisclosure() { ) assertTrue("Accept button missing", d.findObject(UiSelector().text(acceptText)).exists()) assertTrue( - "Learn more button missing", - d.findObject(UiSelector().text(learnMoreText)).exists(), + "Keep offline button missing", + d.findObject(UiSelector().text(declineText)).exists(), ) clickFirstAccessibilityNodeByText(acceptText) d.waitForIdle() // The accessibility click is dispatched asynchronously; retry until the - // dialog's positive-button listener has persisted the flag. + // dialog's positive-button listener has persisted the consent. flakySafely(timeoutMs = PRIVACY_FLAG_PERSIST_TIMEOUT_MS) { assertTrue( - "Accepting the disclosure did not persist the shown flag", - prefManager.getBoolean(KEY_PRIVACY_DISCLOSURE_SHOWN, false), + "Accepting the disclosure did not persist the consent", + StatPreferences.telemetryConsent == TelemetryConsent.GRANTED, ) } } } else { - Log.i(TAG, "Privacy disclosure already accepted (flag set); verifying dialog stays hidden") + Log.i(TAG, "Telemetry consent already answered; verifying dialog stays hidden") } step("Verify privacy dialog is not shown") { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2cd24756d1..521b7867a1 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -104,9 +104,19 @@ + + android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|locale|fontScale|density|keyboard|keyboardHidden|navigation" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + apkInstallationViewModel.installApk( + context = this, + apk = file, + launchInDebugMode = false, + ) + } - override fun EditorHandlerActivity.doAction(data: ActionData): Boolean { - val file = editorViewModel.getCurrentFile() ?: return false - when (file.extension.lowercase()) { - "apk" -> apkInstallationViewModel.installApk( - context = this, apk = file, launchInDebugMode = false - ) - "cgp" -> lifecycleScope.launch { - val repo = GlobalContext.get().get() - repo.installPluginFromFile(file) - .onSuccess { - flashSuccess(getString(R.string.msg_plugin_installed_restart)) - DialogUtils.showRestartPrompt(this@doAction) - } - .onFailure { e -> - flashError(getString(R.string.msg_plugin_install_failed, e.message)) - } - } - } - return true - } + PLUGIN_ARCHIVE_EXTENSION -> { + lifecycleScope.launch { + val repo = GlobalContext.get().get() + repo + .installPluginFromFile(file) + .onSuccess { + flashSuccess(getString(R.string.msg_plugin_installed_restart)) + DialogUtils.showRestartPrompt(this@doAction) + }.onFailure { e -> + flashError(getString(R.string.msg_plugin_install_failed, e.message)) + } + } + } + } + return true + } } diff --git a/app/src/main/java/com/itsaky/androidide/actions/filetree/NewFileAction.kt b/app/src/main/java/com/itsaky/androidide/actions/filetree/NewFileAction.kt index 7beba57b3f..bd5c19e234 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/filetree/NewFileAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/filetree/NewFileAction.kt @@ -24,7 +24,6 @@ import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.actions.FileActionManager import com.itsaky.androidide.actions.observers.FileActionObserver import com.itsaky.androidide.actions.requireFile -import com.itsaky.androidide.adapters.viewholders.FileTreeViewHolder import com.itsaky.androidide.databinding.LayoutCreateFileJavaBinding import com.itsaky.androidide.eventbus.events.file.FileCreationEvent import com.itsaky.androidide.idetooltips.TooltipTag @@ -208,10 +207,6 @@ class NewFileAction( .showWithLongPressTooltip( context = context, tooltipTag = TooltipTag.PROJECT_FOLDER_NEWTYPE, - binding.typeClass, - binding.typeActivity, - binding.typeInterface, - binding.typeEnum, ) } diff --git a/app/src/main/java/com/itsaky/androidide/actions/filetree/RenameAction.kt b/app/src/main/java/com/itsaky/androidide/actions/filetree/RenameAction.kt index bf765963e0..71357eb3a5 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/filetree/RenameAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/filetree/RenameAction.kt @@ -57,39 +57,45 @@ class RenameAction( builder.setTitle(R.string.rename_file) builder.setMessage(R.string.msg_rename_file) builder.setView(binding.root) + builder.setCancelable(false) builder.setNegativeButton(android.R.string.cancel, null) builder.setPositiveButton(R.string.rename_file) { dialogInterface, _ -> - val fileManagerViewModel: FileManagerViewModel by context.viewModels() - val name: String = binding.name.editText?.text.toString().trim() - when { - name.isEmpty() -> { - flashError(R.string.msg_invalid_name) - return@setPositiveButton - } - name.length > 40 -> { - flashError(R.string.file_name_too_long) - return@setPositiveButton - } - } + val fileManagerViewModel: FileManagerViewModel by context.viewModels() + val name: String = + binding.name.editText + ?.text + .toString() + .trim() + when { + name.isEmpty() -> { + flashError(R.string.msg_invalid_name) + return@setPositiveButton + } - dialogInterface.dismiss() - fileManagerViewModel.renameFile(file, name, context) { renamed -> - if (!renamed) return@renameFile + name.length > 40 -> { + flashError(R.string.file_name_too_long) + return@setPositiveButton + } + } - val parent = lastHeld?.parent + dialogInterface.dismiss() + fileManagerViewModel.renameFile(file, name, context) { renamed -> + if (!renamed) return@renameFile - if (parent != null) { - requestCollapseNode(parent, false) - requestExpandNode(parent) - } else { - requestFileListing() - } - } + val parent = lastHeld?.parent + + if (parent != null) { + requestCollapseNode(parent, false) + requestExpandNode(parent) + } else { + requestFileListing() + } + } } - builder.showWithLongPressTooltip( - context = context, - tooltipTag = TooltipTag.PROJECT_RENAME_DIALOG - ) + builder.showWithLongPressTooltip( + context = context, + tooltipTag = TooltipTag.PROJECT_RENAME_DIALOG, + ) } } diff --git a/app/src/main/java/com/itsaky/androidide/activities/AboutActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/AboutActivity.kt index 0ccb37c0bf..a19d59df68 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/AboutActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/AboutActivity.kt @@ -67,6 +67,8 @@ class AboutActivity : EdgeToEdgeIDEActivity() { private val ACTION_EMAIL = id++ private val ACTION_TG_CHANNEL = id++ private val ACTION_GH_FORUM = id++ + private val ACTION_YOUTUBE = id++ + private val ACTION_BILIBILI = id++ } override fun onCreate(savedInstanceState: Bundle?) { @@ -119,6 +121,8 @@ class AboutActivity : EdgeToEdgeIDEActivity() { ACTION_EMAIL -> UrlManager.openUrl(getString(R.string.mail_to_adfa), null, this) ACTION_GH_FORUM -> UrlManager.openUrl(getString(R.string.github_discussions_url), context = this) ACTION_TG_CHANNEL -> UrlManager.openUrl(getString(R.string.telegram_channel_url), "org.telegram.messenger", this) + ACTION_YOUTUBE -> UrlManager.openUrl(getString(R.string.youtube_channel_url), context = this) + ACTION_BILIBILI -> UrlManager.openUrl(getString(R.string.bilibili_video_url), context = this) } } @@ -160,6 +164,24 @@ class AboutActivity : EdgeToEdgeIDEActivity() { getString(R.string.telegram_channel_url), ), ) + add( + createSimpleIconTextItem( + this@AboutActivity, + ACTION_YOUTUBE, + R.drawable.ic_youtube, + R.string.about_option_youtube, + getString(R.string.youtube_channel_url), + ), + ) + add( + createSimpleIconTextItem( + this@AboutActivity, + ACTION_BILIBILI, + R.drawable.ic_bilibili, + R.string.about_option_bilibili, + getString(R.string.bilibili_video_url), + ), + ) } private fun createSimpleIconTextItem( diff --git a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallActivity.kt new file mode 100644 index 0000000000..9ff9b806cc --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallActivity.kt @@ -0,0 +1,55 @@ +package com.itsaky.androidide.activities + +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.compose.ui.platform.ComposeView +import com.itsaky.androidide.app.IDEActivity +import com.itsaky.androidide.viewmodels.ExternalFileInstallViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel + +/** + * Trampoline activity that receives a `.cgp`/`.cgt` file opened from outside the app (e.g. an + * email attachment), prompts to install it, and finishes - it has no content of its own beyond + * the dialogs [ExternalFileInstallScreen] shows. + * + * `singleTask` (manifest) + [onNewIntent] collapse a rapid double-tap on the same external file + * into this one instance/ViewModel, where [ExternalFileInstallViewModel]'s `receivedUriGate` + * already dedupes by Uri - without it, `standard` launch mode would spin up a second + * Activity+ViewModel pair minting an independent temp file, which `PluginManagerViewModel`'s + * path-based dedup guard can't recognize as the same source. + */ +class ExternalFileInstallActivity : IDEActivity() { + private val viewModel: ExternalFileInstallViewModel by viewModel() + + override fun bindLayout(): View = + ComposeView(this).apply { + setContent { ExternalFileInstallScreen(viewModel) } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + handleIntent() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleIntent() + } + + private fun handleIntent() { + val uri = intent?.data + if (uri == null) { + finish() + return + } + + // No savedInstanceState guard here: onReceived() is idempotent per ViewModel instance + // (a rotation, or a re-delivered intent via onNewIntent, keeps the same instance, so this + // is a no-op there), and calling it unconditionally means a process-death-recreated + // instance - which starts fresh and would otherwise never see the restored intent's data - + // still gets processed. + viewModel.onReceived(uri) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt new file mode 100644 index 0000000000..530f115285 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt @@ -0,0 +1,340 @@ +package com.itsaky.androidide.activities + +import android.app.Activity +import android.content.Intent +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.itsaky.androidide.R +import com.itsaky.androidide.floating.ui.FloatingTheme +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.repositories.TemplateCollectionRepository +import com.itsaky.androidide.ui.compose.longPressTooltip +import com.itsaky.androidide.ui.models.ExternalFileInstallUiEffect +import com.itsaky.androidide.ui.models.ExternalFileInstallUiEvent +import com.itsaky.androidide.utils.flashErrorAwaitShown +import com.itsaky.androidide.utils.flashSuccessAwaitShown +import com.itsaky.androidide.viewmodels.ExternalFileInstallViewModel +import java.io.File + +private sealed interface DialogUiState { + object None : DialogUiState + + data class InstallConfirm( + val info: TemplateCollectionRepository.CollectionInfo, + val tempFile: File, + val suggestedBaseName: String, + ) : DialogUiState + + data class NameConflict( + val existingName: String, + val info: TemplateCollectionRepository.CollectionInfo, + val tempFile: File, + ) : DialogUiState + + data class Rename( + val existingName: String, + val tempFile: File, + ) : DialogUiState +} + +/** + * Standalone host: the activity opened for a `.cgp`/`.cgt` from outside the app, which forwards + * plugins to [PluginManagerActivity] and finishes itself when the flow ends. + */ +@Suppress("ktlint:compose:vm-forwarding-check") +@Composable +fun ExternalFileInstallScreen(viewModel: ExternalFileInstallViewModel) { + val context = LocalContext.current + ExternalFileInstallDialogs( + viewModel = viewModel, + onForwardPlugin = { filePath -> + context.startActivity( + Intent(context, PluginManagerActivity::class.java) + // A Plugin Manager instance may already be running/backgrounded (e.g. + // the user had it open, then opened a .cgp attachment) - these flags + // reuse that instance via onNewIntent() instead of stacking a second + // one on top of it. + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + .putExtra(PluginManagerActivity.EXTRA_PENDING_INSTALL_FILE_PATH, filePath), + ) + (context as? Activity)?.finish() + }, + onFinish = { (context as? Activity)?.finish() }, + ) +} + +/** + * The `.cgt` install flow's dialogs (confirm -> name conflict -> rename), driven by + * [ExternalFileInstallViewModel]. Split out from [ExternalFileInstallScreen] so the Extensions + * Manager's "add" button can reuse the same flow in-place instead of duplicating it: the only + * host-specific behaviours are [onForwardPlugin] and [onFinish]. + */ +@Composable +fun ExternalFileInstallDialogs( + viewModel: ExternalFileInstallViewModel, + onForwardPlugin: (String) -> Unit, + onFinish: () -> Unit, +) { + val context = LocalContext.current + var dialogState by remember { mutableStateOf(DialogUiState.None) } + val isInstalling by viewModel.isInstalling.collectAsStateWithLifecycle() + val currentOnForwardPlugin by rememberUpdatedState(onForwardPlugin) + val currentOnFinish by rememberUpdatedState(onFinish) + + LaunchedEffect(viewModel) { + viewModel.uiEffect.collect { effect -> + when (effect) { + is ExternalFileInstallUiEffect.ForwardToPluginManager -> { + currentOnForwardPlugin(effect.filePath) + } + + is ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation -> { + dialogState = + DialogUiState.InstallConfirm(effect.info, effect.tempFile, effect.suggestedBaseName) + } + + is ExternalFileInstallUiEffect.ShowTemplateNameConflict -> { + dialogState = DialogUiState.NameConflict(effect.existingName, effect.info, effect.tempFile) + } + + is ExternalFileInstallUiEffect.ShowError -> { + // Deliberately doesn't touch dialogState: on an install failure the ViewModel + // sends ShowError without a following Finish, so whichever dialog is open + // (install-confirm / name-conflict / rename) stays open for the user to retry. + // Awaits the bar's entrance animation instead of returning immediately: this + // suspends the collect{} loop above, so a Finish effect buffered right after + // this one (see sendErrorAndFinish()) isn't processed - and doesn't tear the + // window down - until the message has actually finished appearing. + flashErrorAwaitShown(context.getString(effect.messageResId, *effect.formatArgs.toTypedArray())) + } + + is ExternalFileInstallUiEffect.ShowSuccess -> { + flashSuccessAwaitShown(context.getString(effect.messageResId, *effect.formatArgs.toTypedArray())) + } + + is ExternalFileInstallUiEffect.Finish -> { + dialogState = DialogUiState.None + currentOnFinish() + } + } + } + } + + FloatingTheme { + when (val state = dialogState) { + is DialogUiState.InstallConfirm -> { + InstallConfirmationDialog( + state = state, + installEnabled = !isInstalling, + onInstall = { + viewModel.onEvent( + ExternalFileInstallUiEvent.ConfirmTemplateInstall( + tempFile = state.tempFile, + targetBaseName = state.suggestedBaseName, + overwrite = false, + ), + ) + }, + onDismiss = { + viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(state.tempFile)) + }, + ) + } + + is DialogUiState.NameConflict -> { + NameConflictDialog( + state = state, + installEnabled = !isInstalling, + onOverwrite = { + viewModel.onEvent( + ExternalFileInstallUiEvent.ConfirmTemplateInstall( + tempFile = state.tempFile, + targetBaseName = state.existingName, + overwrite = true, + ), + ) + }, + onRename = { dialogState = DialogUiState.Rename(state.existingName, state.tempFile) }, + onDismiss = { + viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(state.tempFile)) + }, + ) + } + + is DialogUiState.Rename -> { + RenameDialog( + state = state, + installEnabled = !isInstalling, + suggestName = viewModel::suggestUniqueBaseName, + onConfirm = { newName -> + viewModel.onEvent( + ExternalFileInstallUiEvent.ConfirmTemplateInstall( + tempFile = state.tempFile, + targetBaseName = viewModel.sanitizeBaseName(newName), + overwrite = false, + ), + ) + }, + onDismiss = { + viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(state.tempFile)) + }, + ) + } + + DialogUiState.None -> { + Unit + } + } + } +} + +/** Comma-joined display list of a collection's template names, shared by both confirm dialogs. */ +private fun TemplateCollectionRepository.CollectionInfo.displayTemplateNames(): String = templateNames.joinToString(", ") + +@Composable +private fun InstallConfirmationDialog( + state: DialogUiState.InstallConfirm, + installEnabled: Boolean, + onInstall: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + // Gated on installEnabled (== !isInstalling): once Install is tapped, the ViewModel + // starts copying/replacing tempFile on viewModelScope - dismissing here would race + // IgnoreTemplateInstall's own delete of that same file against the in-progress install. + onDismissRequest = { if (installEnabled) onDismiss() }, + title = { + Text( + stringResource(R.string.title_install_template_collection), + modifier = Modifier.longPressTooltip(TooltipTag.EXTERNAL_FILE_INSTALL), + ) + }, + text = { + Text( + stringResource( + R.string.msg_template_install_confirm, + state.suggestedBaseName, + state.info.displayTemplateNames(), + ), + ) + }, + confirmButton = { + TextButton(onClick = onInstall, enabled = installEnabled) { Text(stringResource(R.string.btn_install)) } + }, + dismissButton = { + TextButton(onClick = onDismiss, enabled = installEnabled) { Text(stringResource(android.R.string.cancel)) } + }, + ) +} + +@Composable +private fun NameConflictDialog( + state: DialogUiState.NameConflict, + installEnabled: Boolean, + onOverwrite: () -> Unit, + onRename: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = { if (installEnabled) onDismiss() }, + title = { + Text( + stringResource(R.string.title_template_already_installed), + modifier = Modifier.longPressTooltip(TooltipTag.EXTERNAL_FILE_INSTALL), + ) + }, + text = { + Text( + stringResource( + R.string.msg_template_name_conflict, + state.existingName, + state.info.displayTemplateNames(), + ), + ) + }, + // Three actions don't fit in AlertDialog's default single-row confirm/dismiss layout + // without wrapping awkwardly (e.g. two buttons stacked oddly against the third) - stack + // them vertically instead, right-aligned, all within the confirmButton slot (dismissButton + // left unset). + confirmButton = { + Column(horizontalAlignment = Alignment.End) { + TextButton(onClick = onOverwrite, enabled = installEnabled) { Text(stringResource(R.string.btn_overwrite)) } + TextButton(onClick = onRename, enabled = installEnabled) { Text(stringResource(R.string.btn_rename_and_install)) } + TextButton(onClick = onDismiss, enabled = installEnabled) { Text(stringResource(android.R.string.cancel)) } + } + }, + ) +} + +@Composable +private fun RenameDialog( + state: DialogUiState.Rename, + installEnabled: Boolean, + suggestName: suspend (String) -> String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var name by remember { mutableStateOf(TextFieldValue(state.existingName)) } + var userEdited by remember { mutableStateOf(false) } + var suggestionReady by remember { mutableStateOf(false) } + val currentSuggestName by rememberUpdatedState(suggestName) + + LaunchedEffect(state.existingName) { + val suggested = currentSuggestName(state.existingName) + // Only apply the suggestion if the user hasn't already started typing their own name - + // this resolves asynchronously and must not clobber in-progress input. + if (!userEdited) { + name = TextFieldValue(suggested, selection = TextRange(suggested.length)) + } + suggestionReady = true + } + + AlertDialog( + onDismissRequest = { if (installEnabled) onDismiss() }, + title = { + Text( + stringResource(R.string.btn_rename_and_install), + modifier = Modifier.longPressTooltip(TooltipTag.EXTERNAL_FILE_INSTALL), + ) + }, + text = { + OutlinedTextField( + value = name, + onValueChange = { + name = it + userEdited = true + }, + label = { Text(stringResource(R.string.hint_new_template_collection_name)) }, + singleLine = true, + ) + }, + confirmButton = { + TextButton( + onClick = { onConfirm(name.text) }, + enabled = installEnabled && suggestionReady && name.text.isNotBlank(), + ) { + Text(stringResource(R.string.btn_install)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss, enabled = installEnabled) { Text(stringResource(android.R.string.cancel)) } + }, + ) +} 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 de2731000f..7f51981128 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt @@ -20,11 +20,9 @@ package com.itsaky.androidide.activities import android.content.Intent import android.content.res.Configuration import android.os.Bundle -import android.util.Log import android.view.KeyEvent import android.view.View import androidx.activity.OnBackPressedCallback -import org.koin.androidx.viewmodel.ext.android.viewModel import androidx.core.graphics.Insets import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible @@ -34,35 +32,39 @@ import androidx.transition.doOnEnd import com.google.android.material.transition.MaterialSharedAxis import com.itsaky.androidide.FeedbackButtonManager import com.itsaky.androidide.R -import com.itsaky.androidide.activities.editor.EditorActivityKt import com.itsaky.androidide.actions.ActionData +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.fragments.MainFragment +import com.itsaky.androidide.fragments.RecentProjectsFragment import com.itsaky.androidide.idetooltips.TooltipManager 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.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.shortcuts.IdeShortcutActions +import com.itsaky.androidide.shortcuts.ShortcutContext +import com.itsaky.androidide.shortcuts.ShortcutExecutionContext +import com.itsaky.androidide.shortcuts.ShortcutManager import com.itsaky.androidide.templates.ITemplateProvider import com.itsaky.androidide.utils.DialogUtils import com.itsaky.androidide.utils.Environment import com.itsaky.androidide.utils.FeatureFlags +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.flashInfo -import com.itsaky.androidide.utils.applyBottomWindowInsetsPadding -import com.itsaky.androidide.utils.MainScreenActions -import com.itsaky.androidide.fragments.MainFragment -import com.itsaky.androidide.fragments.RecentProjectsFragment -import com.itsaky.androidide.roomData.recentproject.RecentProject -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.utils.getCreatedTime import com.itsaky.androidide.utils.getLastModifiedTime +import com.itsaky.androidide.utils.hasVisibleDialog +import com.itsaky.androidide.utils.readProjectLanguage 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 @@ -74,12 +76,10 @@ import com.itsaky.androidide.viewmodel.MainViewModel.Companion.TOOLTIPS_WEB_VIEW import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import com.itsaky.androidide.localWebServer.ServerConfig -import com.itsaky.androidide.localWebServer.WebServer import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel import org.slf4j.LoggerFactory import java.io.File -import com.itsaky.androidide.utils.hasVisibleDialog class MainActivity : EdgeToEdgeIDEActivity() { private val log = LoggerFactory.getLogger(MainActivity::class.java) @@ -119,7 +119,7 @@ class MainActivity : EdgeToEdgeIDEActivity() { private val binding: ActivityMainBinding get() = checkNotNull(_binding) - override fun onCreate(savedInstanceState: Bundle?) { + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) MainScreenActions.register(this) @@ -127,7 +127,9 @@ class MainActivity : EdgeToEdgeIDEActivity() { // Start WebServer after installation is complete startWebServer() - if (savedInstanceState == null) { openLastProject() } + if (savedInstanceState == null) { + openLastProject() + } if (FeatureFlags.isExperimentsEnabled) { binding.codeOnTheGoLabel.title = getString(R.string.app_name) + "." @@ -172,21 +174,21 @@ class MainActivity : EdgeToEdgeIDEActivity() { } } - override fun dispatchKeyEvent(event: KeyEvent): Boolean { - return shortcutManager.dispatch( + override fun dispatchKeyEvent(event: KeyEvent): Boolean = + shortcutManager.dispatch( event = event, context = ShortcutContext.MAIN, focusView = currentFocus, hasModal = supportFragmentManager.hasVisibleDialog(), executionContext = mainShortcutExecutionContext, ) || super.dispatchKeyEvent(event) - } private val mainShortcutExecutionContext by lazy { ShortcutExecutionContext( - ideShortcutActions = IdeShortcutActions { - ActionData.create(this) - }, + ideShortcutActions = + IdeShortcutActions { + ActionData.create(this) + }, ) } @@ -245,17 +247,23 @@ class MainActivity : EdgeToEdgeIDEActivity() { */ private fun recreateVisibleFragmentView() { when (viewModel.currentScreen.value) { - SCREEN_MAIN -> - supportFragmentManager.beginTransaction() + SCREEN_MAIN -> { + supportFragmentManager + .beginTransaction() .setReorderingAllowed(true) .replace(R.id.main, MainFragment()) .commitNow() - SCREEN_SAVED_PROJECTS -> - supportFragmentManager.beginTransaction() + } + + SCREEN_SAVED_PROJECTS -> { + supportFragmentManager + .beginTransaction() .setReorderingAllowed(true) .replace(R.id.saved_projects_view, RecentProjectsFragment()) .commitNow() - else -> { } + } + + else -> {} } } @@ -318,7 +326,7 @@ class MainActivity : EdgeToEdgeIDEActivity() { TOOLTIPS_WEB_VIEW -> binding.tooltipWebView SCREEN_SAVED_PROJECTS -> binding.savedProjectsView SCREEN_DELETE_PROJECTS -> binding.deleteProjectsView - SCREEN_CLONE_REPO -> binding.cloneRepositoryView + SCREEN_CLONE_REPO -> binding.cloneRepositoryView else -> throw IllegalArgumentException("Invalid screen id: '$screen'") } @@ -329,7 +337,7 @@ class MainActivity : EdgeToEdgeIDEActivity() { binding.tooltipWebView, binding.savedProjectsView, binding.deleteProjectsView, - binding.cloneRepositoryView, + binding.cloneRepositoryView, )) { fragment.isVisible = fragment == currentFragment } @@ -365,20 +373,25 @@ class MainActivity : EdgeToEdgeIDEActivity() { val validProjects = findValidProjects(Environment.PROJECTS_DIR) val lastOpenedPath = GeneralPreferences.lastOpenedProject - val projectToOpen = validProjects.find { it.absolutePath == lastOpenedPath } - ?: validProjects.maxByOrNull { it.lastModified() } + val projectToOpen = + validProjects.find { it.absolutePath == lastOpenedPath } + ?: validProjects.maxByOrNull { it.lastModified() } withContext(Dispatchers.Main) { when { - projectToOpen != null -> handleOpenProject(projectToOpen) + projectToOpen != null -> { + handleOpenProject(projectToOpen) + } - lastOpenedPath.isNotBlank() && lastOpenedPath != GeneralPreferences.NO_OPENED_PROJECT -> { - if (!File(lastOpenedPath).exists()) { - flashInfo(string.msg_opened_project_does_not_exist) - } - } + lastOpenedPath.isNotBlank() && lastOpenedPath != GeneralPreferences.NO_OPENED_PROJECT -> { + if (!File(lastOpenedPath).exists()) { + flashInfo(string.msg_opened_project_does_not_exist) + } + } - else -> Unit + else -> { + Unit + } } } } @@ -402,23 +415,29 @@ class MainActivity : EdgeToEdgeIDEActivity() { builder.show() } - internal fun openProject(root: File, project: RecentProject? = null, hasTemplateIssues: Boolean = false) { + internal fun openProject( + root: File, + project: RecentProject? = null, + hasTemplateIssues: Boolean = false, + ) { 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() - ) - viewModel.saveProjectToRecents(recentProject) - } + 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) + analyticsManager.trackProjectOpened(root.absolutePath) if (isFinishing) { return @@ -427,21 +446,27 @@ class MainActivity : EdgeToEdgeIDEActivity() { val intent = Intent(this, EditorActivityKt::class.java).apply { putExtra("PROJECT_PATH", root.absolutePath) - if (hasTemplateIssues) { - putExtra("HAS_TEMPLATE_ISSUES", true) - } + if (hasTemplateIssues) { + putExtra("HAS_TEMPLATE_ISSUES", true) + } addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) } startActivity(intent) } - private fun startWebServer() { + private fun startWebServer() { lifecycleScope.launch(Dispatchers.IO) { try { val dbFile = Environment.DOC_DB log.info("Starting WebServer - using database file from: {}", dbFile.absolutePath) - val server = WebServer(ServerConfig(databasePath = dbFile.absolutePath, fileDirPath = applicationContext.filesDir.absolutePath)) + val server = + WebServer( + ServerConfig( + databasePath = dbFile.absolutePath, + fileDirPath = applicationContext.filesDir.absolutePath, + ), + ) webServer = server server.start() } catch (e: Exception) { diff --git a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt index a3129fbffb..9dcc09bbbe 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt @@ -1,51 +1,29 @@ - - package com.itsaky.androidide.activities -import android.content.ClipData -import android.content.ClipboardManager import android.content.Intent -import android.net.Uri import android.os.Bundle -import android.util.Log -import android.view.Menu -import android.view.MenuItem import android.view.View -import android.widget.CheckBox -import androidx.activity.result.contract.ActivityResultContracts import androidx.core.graphics.Insets -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.itsaky.androidide.FeedbackButtonManager import com.itsaky.androidide.R -import com.itsaky.androidide.adapters.PluginListAdapter import com.itsaky.androidide.app.EdgeToEdgeIDEActivity import com.itsaky.androidide.databinding.ActivityPluginManagerBinding -import com.itsaky.androidide.idetooltips.TooltipManager -import com.itsaky.androidide.idetooltips.TooltipTag -import com.itsaky.androidide.plugins.PluginInfo -import com.itsaky.androidide.ui.models.PluginManagerUiEffect -import com.itsaky.androidide.ui.models.PluginManagerUiEvent -import com.itsaky.androidide.utils.DURATION_INDEFINITE -import com.itsaky.androidide.utils.DialogUtils.showRestartPrompt -import com.itsaky.androidide.utils.UrlManager -import com.itsaky.androidide.utils.errorIcon +import com.itsaky.androidide.ui.compose.ManagerScreen +import com.itsaky.androidide.ui.compose.theme.ManagerTheme import com.itsaky.androidide.utils.flashError -import com.itsaky.androidide.utils.flashSuccess -import com.itsaky.androidide.utils.flashbarBuilder -import com.itsaky.androidide.utils.getFileName -import com.itsaky.androidide.utils.showOnUiThread +import com.itsaky.androidide.viewmodels.ExternalFileInstallViewModel import com.itsaky.androidide.viewmodels.PluginManagerViewModel -import kotlinx.coroutines.launch +import com.itsaky.androidide.viewmodels.TemplateManagerViewModel import org.koin.androidx.viewmodel.ext.android.viewModel class PluginManagerActivity : EdgeToEdgeIDEActivity() { companion object { - private const val TAG = "PluginManagerActivity" - private const val PLUGIN_EXTENSION = ".cgp" + /** + * Absolute path of a `.cgp` file forwarded from [ExternalFileInstallActivity] - a plain + * path rather than a `content://` Uri, since both activities run in this same process and + * already trust filesDir paths, letting the install skip a redundant ContentResolver copy. + */ + const val EXTRA_PENDING_INSTALL_FILE_PATH = "pending_install_file_path" } @Suppress("ktlint:standard:backing-property-naming") @@ -53,31 +31,14 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { private val binding: ActivityPluginManagerBinding get() = checkNotNull(_binding) { "Activity has been destroyed" } - private lateinit var adapter: PluginListAdapter private var feedbackButtonManager: FeedbackButtonManager? = null - private val viewModel: PluginManagerViewModel by viewModel() - - private val pluginPickerLauncher = - registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> - uri?.let { - try { - contentResolver.takePersistableUriPermission( - it, - Intent.FLAG_GRANT_READ_URI_PERMISSION, - ) - } catch (e: SecurityException) { - Log.w(TAG, "Could not take persistable URI permission", e) - } - - if (!it.isSupportedPluginFile()) { - flashError(getString(R.string.msg_unsupported_plugin_file)) - return@let - } + private val pluginViewModel: PluginManagerViewModel by viewModel() + private val templateViewModel: TemplateManagerViewModel by viewModel() - showInstallConfirmation(it) - } - } + // Drives the .cgt half of the add-extension flow; the same ViewModel ExternalFileInstallActivity + // uses, so a template picked here goes through the identical confirm/conflict/rename path. + private val externalFileInstallViewModel: ExternalFileInstallViewModel by viewModel() override fun bindLayout(): View { _binding = ActivityPluginManagerBinding.inflate(layoutInflater) @@ -88,21 +49,32 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { try { super.onCreate(savedInstanceState) - setSupportActionBar(binding.toolbar) - supportActionBar?.apply { - title = getString(R.string.title_plugin_manager) - setDisplayHomeAsUpEnabled(true) - } - - binding.toolbar.setNavigationOnClickListener { - onBackPressedDispatcher.onBackPressed() + // setContent only registers the composable; its lambda runs later, at first + // layout, after onCreate has returned - by which point this try/catch can no + // longer see it. Force the Koin `by viewModel()` delegates to resolve here instead, + // so a failure (e.g. Environment.TEMPLATES_DIR still null after a partial + // DeviceProtectedApplicationLoader init) is caught below rather than crashing. + val resolvedPluginViewModel = pluginViewModel + val resolvedTemplateViewModel = templateViewModel + val resolvedExternalFileInstallViewModel = externalFileInstallViewModel + + binding.composeView.setContent { + ManagerTheme { + ManagerScreen( + activity = this, + pluginViewModel = resolvedPluginViewModel, + templateViewModel = resolvedTemplateViewModel, + externalFileInstallViewModel = resolvedExternalFileInstallViewModel, + ) + } } - setupRecyclerView() - setupFab() - setupTooltipLongPress() setupFeedbackButton() - observeViewModel() + + // Safe to emit before the Compose collector attaches: the ViewModel's uiEffect + // channel is buffered precisely so a decision made synchronously in onCreate() + // isn't dropped on the floor. + handlePendingInstallExtra() } catch (e: Exception) { // Log the error and finish the activity if something goes wrong e.printStackTrace() @@ -111,33 +83,30 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { } } - override fun onResume() { - super.onResume() - feedbackButtonManager?.loadFabPosition() + // ForwardToPluginManager's launch Intent carries FLAG_ACTIVITY_CLEAR_TOP/SINGLE_TOP so a + // forwarded install reuses an already-running instance instead of stacking a duplicate one - + // which routes the extra through onNewIntent() rather than a fresh onCreate(). + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handlePendingInstallExtra() } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.menu_plugin_manager, menu) - binding.toolbar.post { - binding.toolbar.findViewById(R.id.action_discover_plugins)?.setOnLongClickListener { view -> - TooltipManager.showIdeCategoryTooltip(this, view, TooltipTag.PLUGIN_MANAGER) - true - } + /** + * Hands a forwarded `.cgp` path to the ViewModel, which gates against re-showing the dialog, + * probes the file off the main thread, and emits the same install-confirmation effect the SAF + * pick uses - so [ManagerScreen]'s Plugins tab renders one dialog for both entry points. + */ + private fun handlePendingInstallExtra() { + intent.getStringExtra(EXTRA_PENDING_INSTALL_FILE_PATH)?.let { filePath -> + pluginViewModel.onPendingInstallFile(filePath) } - return true } - override fun onOptionsItemSelected(item: MenuItem): Boolean = - when (item.itemId) { - R.id.action_discover_plugins -> { - UrlManager.openUrl(getString(R.string.url_discover_plugins), null, this) - true - } - - else -> { - super.onOptionsItemSelected(item) - } - } + override fun onResume() { + super.onResume() + feedbackButtonManager?.loadFabPosition() + } override fun onDestroy() { super.onDestroy() @@ -153,51 +122,6 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { ) } - private fun setupRecyclerView() { - adapter = - PluginListAdapter { plugin, action -> - when (action) { - PluginListAdapter.Action.ENABLE -> viewModel.onEvent(PluginManagerUiEvent.EnablePlugin(plugin.metadata.id)) - PluginListAdapter.Action.DISABLE -> viewModel.onEvent(PluginManagerUiEvent.DisablePlugin(plugin.metadata.id)) - PluginListAdapter.Action.UNINSTALL -> viewModel.onEvent(PluginManagerUiEvent.UninstallPlugin(plugin.metadata.id)) - PluginListAdapter.Action.DETAILS -> viewModel.onEvent(PluginManagerUiEvent.ShowPluginDetails(plugin)) - } - } - - binding.recyclerView.apply { - layoutManager = LinearLayoutManager(this@PluginManagerActivity) - adapter = this@PluginManagerActivity.adapter - } - } - - private fun setupFab() { - binding.fabInstallPlugin.setOnClickListener { - viewModel.onEvent(PluginManagerUiEvent.OpenFilePicker) - } - } - - private fun setupTooltipLongPress() { - val showTooltip: (View) -> Unit = { view -> - TooltipManager.showIdeCategoryTooltip(this, view, TooltipTag.PLUGIN_MANAGER) - } - binding.toolbar.setOnLongClickListener { - showTooltip(it) - true - } - binding.fabInstallPlugin.setOnLongClickListener { - showTooltip(it) - true - } - binding.emptyState.setOnLongClickListener { - showTooltip(it) - true - } - binding.recyclerView.setOnLongClickListener { - showTooltip(it) - true - } - } - private fun setupFeedbackButton() { feedbackButtonManager = FeedbackButtonManager( @@ -206,157 +130,4 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { ) feedbackButtonManager?.setupDraggableFab() } - - private fun observeViewModel() { - // Observe UI state - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState.collect { state -> - updateUI(state) - } - } - } - - // Observe UI effects - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiEffect.collect { effect -> - handleUiEffect(effect) - } - } - } - } - - private fun updateUI(state: com.itsaky.androidide.ui.models.PluginManagerUiState) { - // Update plugin list - adapter.submitList(state.plugins) - - // Update empty state - if (state.showEmptyState) { - binding.recyclerView.visibility = View.GONE - binding.emptyState.visibility = View.VISIBLE - } else { - binding.recyclerView.visibility = View.VISIBLE - binding.emptyState.visibility = View.GONE - } - - // Update install button state - binding.fabInstallPlugin.isEnabled = !state.isInstalling - } - - private fun handleUiEffect(effect: PluginManagerUiEffect) { - when (effect) { - is PluginManagerUiEffect.ShowError -> { - val errorMessage = getString(effect.messageResId, *effect.formatArgs.toTypedArray()) - val builder = - flashbarBuilder(duration = if (effect.formatArgs.isEmpty()) 5000L else DURATION_INDEFINITE) - .errorIcon() - .message(errorMessage) - if (effect.formatArgs.isNotEmpty()) { - builder - .positiveActionText(R.string.copy) - .positiveActionTapListener { bar -> - (getSystemService(ClipboardManager::class.java)) - ?.setPrimaryClip(ClipData.newPlainText(getString(R.string.msg_plugin_error_clip_label), errorMessage)) - bar.dismiss() - } - } - builder.showOnUiThread() - } - - is PluginManagerUiEffect.ShowSuccess -> { - flashSuccess(getString(effect.messageResId)) - } - - is PluginManagerUiEffect.ShowPluginDetails -> { - showPluginDetails(effect.plugin) - } - - is PluginManagerUiEffect.OpenFilePicker -> { - openFilePicker() - } - - is PluginManagerUiEffect.ShowUninstallConfirmation -> { - showUninstallConfirmation(effect.plugin) - } - - is PluginManagerUiEffect.ShowRestartPrompt -> { - showRestartPrompt(this) - } - - is PluginManagerUiEffect.ShowOverwriteConfirmation -> { - showOverwriteConfirmation(effect) - } - } - } - - private fun openFilePicker() { - try { - pluginPickerLauncher.launch(arrayOf("*/*")) - } catch (_: Exception) { - flashError(getString(R.string.msg_no_file_manager)) - } - } - - private fun Uri.isSupportedPluginFile(): Boolean = getFileName(this@PluginManagerActivity).endsWith(PLUGIN_EXTENSION, ignoreCase = true) - - private fun showInstallConfirmation(uri: Uri) { - val dialogView = layoutInflater.inflate(R.layout.dialog_install_plugin, null) - val deleteCheckBox = dialogView.findViewById(R.id.checkbox_delete_source) - - MaterialAlertDialogBuilder(this) - .setTitle(R.string.title_install_plugin) - .setView(dialogView) - .setPositiveButton(R.string.btn_install) { _, _ -> - viewModel.onEvent(PluginManagerUiEvent.InstallPlugin(uri, deleteCheckBox.isChecked)) - }.setNegativeButton(android.R.string.cancel, null) - .show() - } - - private fun showOverwriteConfirmation(effect: PluginManagerUiEffect.ShowOverwriteConfirmation) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.title_plugin_already_installed) - .setMessage( - getString( - R.string.msg_plugin_overwrite_confirm, - effect.existing.metadata.name, - effect.existing.metadata.version, - effect.incomingMetadata.version, - ), - ).setPositiveButton(R.string.replace) { _, _ -> - viewModel.onEvent( - PluginManagerUiEvent.ConfirmOverwrite(effect.uri, effect.deleteSourceAfterInstall), - ) - }.setNegativeButton(android.R.string.cancel, null) - .show() - } - - private fun showUninstallConfirmation(plugin: PluginInfo) { - MaterialAlertDialogBuilder(this) - .setTitle("Uninstall Plugin") - .setMessage("Are you sure you want to uninstall '${plugin.metadata.name}'?") - .setPositiveButton("Uninstall") { _, _ -> - viewModel.confirmUninstallPlugin(plugin.metadata.id) - }.setNegativeButton("Cancel", null) - .show() - } - - private fun showPluginDetails(plugin: PluginInfo) { - val details = - buildString { - append("Name: ${plugin.metadata.name}\n") - append("Plugin ID: ${plugin.metadata.id}\n") - append("Version: ${plugin.metadata.version}\n") - append("Author: ${plugin.metadata.author}\n") - append("Description: ${plugin.metadata.description}\n") - append("Min IDE Version: ${plugin.metadata.minIdeVersion}\n") - append("Permissions: ${plugin.metadata.permissions.joinToString(", ")}\n") - } - - MaterialAlertDialogBuilder(this) - .setTitle(plugin.metadata.name) - .setMessage(details) - .setPositiveButton("OK", null) - .show() - } } diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/PeerCursorOverlayManager.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/PeerCursorOverlayManager.kt new file mode 100644 index 0000000000..fdd9f4c4b9 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/PeerCursorOverlayManager.kt @@ -0,0 +1,139 @@ +package com.itsaky.androidide.activities.editor + +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.view.Gravity +import android.view.View +import android.widget.TextView +import io.github.rosemoe.sora.widget.CodeEditor +import io.github.rosemoe.sora.widget.base.EditorPopupWindow +import java.io.File + +/** + * Renders remote-collaborator presence as small named caret badges floating in the editor, + * one per (file, peerId). Markers are positioned in content coordinates and track scrolling + * via [EditorPopupWindow.FEATURE_SCROLL_AS_CONTENT]; the plugin repositions a marker by + * calling [addMarker] again with a new line/column on each cursor move. + * + * All methods must be called on the main thread (the editor view is touched directly). + */ +class PeerCursorOverlayManager( + private val editorForFile: (File) -> CodeEditor?, +) { + private val markers: HashMap> = HashMap() + + fun addMarker( + file: File, + line: Int, + column: Int, + peerId: String, + peerName: String, + peerColor: Int, + ): Boolean { + val editor = editorForFile(file) ?: return false + val content = editor.text + if (line !in 0 until content.lineCount) return false + val safeColumn = column.coerceIn(0, content.getColumnCount(line)) + val byPeer = markers.getOrPut(file.absolutePath) { HashMap() } + val existing = byPeer[peerId] + val window = + if (existing != null && existing.boundEditor === editor) { + existing + } else { + existing?.dismiss() + PeerCursorWindow(editor).also { byPeer[peerId] = it } + } + window.update(peerName, peerColor, line, safeColumn) + return true + } + + fun removeMarker( + file: File, + peerId: String, + ): Boolean { + val removed = markers[file.absolutePath]?.remove(peerId) ?: return false + removed.dismiss() + return true + } + + fun clear(file: File) { + markers.remove(file.absolutePath)?.values?.forEach { it.dismiss() } + } + + fun clearAll() { + markers.values.forEach { byPeer -> byPeer.values.forEach { it.dismiss() } } + markers.clear() + } +} + +class PeerCursorWindow( + val boundEditor: CodeEditor, +) : EditorPopupWindow( + boundEditor, + FEATURE_SCROLL_AS_CONTENT or FEATURE_SHOW_OUTSIDE_VIEW_ALLOWED, + ) { + private val density = boundEditor.context.resources.displayMetrics.density + + private val label = + TextView(boundEditor.context).apply { + textSize = 11f + gravity = Gravity.CENTER + maxLines = 1 + includeFontPadding = false + val padH = (8 * density).toInt() + val padV = (3 * density).toInt() + setPadding(padH, padV, padH, padV) + } + + init { + popup.isClippingEnabled = false + setContentView(label) + } + + fun update( + peerName: String, + peerColor: Int, + line: Int, + column: Int, + ) { + // getOffset returns the on-screen x. If the caret is past the visible width, add a + // direction arrow so the badge (clamped to the edge below) signals where the peer is. + val rawX = boundEditor.getOffset(line, column).toInt() + label.text = + when { + rawX > boundEditor.width -> "$peerName →" + rawX < 0 -> "← $peerName" + else -> peerName + } + label.setTextColor(contrastingTextColor(peerColor)) + label.background = + GradientDrawable().apply { + setColor(peerColor) + cornerRadius = 4 * density + } + + label.measure( + View.MeasureSpec.makeMeasureSpec(boundEditor.width, View.MeasureSpec.AT_MOST), + View.MeasureSpec.makeMeasureSpec(boundEditor.height, View.MeasureSpec.AT_MOST), + ) + val width = label.measuredWidth + val height = label.measuredHeight + setSize(width, height) + + // Clamp into the visible width so a caret scrolled off to the right pins at the edge + // instead of vanishing. Only clamp when the editor has a known width. + val maxX = boundEditor.width - width + val x = if (maxX >= 0) rawX.coerceIn(0, maxX) else rawX + val y = (boundEditor.rowHeight * line) - boundEditor.offsetY - height + setLocationAbsolutely(x, y) + if (!isShowing) show() + } + + private fun contrastingTextColor(background: Int): Int { + val r = Color.red(background) / 255.0 + val g = Color.green(background) / 255.0 + val b = Color.blue(background) / 255.0 + val luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b + return if (luminance > 0.6) Color.parseColor("#0A0A0A") else Color.WHITE + } +} 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 e8e0494c11..b63a3e6540 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 @@ -927,7 +927,7 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() } val dialog = builder.create() - dialog.onLongPress { view -> + dialog.onLongPress(includeEditTexts = true) { view -> if ( view is EditText ) { diff --git a/app/src/main/java/com/itsaky/androidide/adapters/PluginListAdapter.kt b/app/src/main/java/com/itsaky/androidide/adapters/PluginListAdapter.kt deleted file mode 100644 index a0a39f460b..0000000000 --- a/app/src/main/java/com/itsaky/androidide/adapters/PluginListAdapter.kt +++ /dev/null @@ -1,172 +0,0 @@ - -package com.itsaky.androidide.adapters - -import android.view.LayoutInflater -import android.view.Menu -import android.view.View -import android.view.ViewGroup -import android.widget.PopupMenu -import androidx.annotation.StringRes -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.Glide -import com.bumptech.glide.signature.ObjectKey -import com.itsaky.androidide.R -import com.itsaky.androidide.databinding.ItemPluginBinding -import com.itsaky.androidide.idetooltips.TooltipManager -import com.itsaky.androidide.idetooltips.TooltipTag -import com.itsaky.androidide.plugins.PluginInfo -import com.itsaky.androidide.utils.isSystemInDarkMode -import java.io.File - -class PluginListAdapter( - private val onActionClick: (PluginInfo, Action) -> Unit, -) : ListAdapter(PluginDiffCallback()) { - enum class Action( - @StringRes val labelRes: Int, - ) { - ENABLE(R.string.enable_plugin), - DISABLE(R.string.disable_plugin), - UNINSTALL(R.string.uninstall_plugin), - DETAILS(R.string.plugin_action_details), - } - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): PluginViewHolder { - val binding = - ItemPluginBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false, - ) - return PluginViewHolder(binding) - } - - override fun onBindViewHolder( - holder: PluginViewHolder, - position: Int, - ) { - holder.bind(getItem(position)) - } - - inner class PluginViewHolder( - private val binding: ItemPluginBinding, - ) : RecyclerView.ViewHolder(binding.root) { - fun bind(plugin: PluginInfo) { - binding.apply { - pluginName.text = plugin.metadata.name - pluginDescription.text = plugin.metadata.description - val version = plugin.metadata.version - val segments = version.split('.') - pluginVersion.text = - if (segments.size > 3) { - "v${segments.take(3).joinToString(".")}..." - } else { - "v$version" - } - pluginAuthor.text = - itemView.context.getString(R.string.plugin_author_by, plugin.metadata.author) - - val iconPath = - if (itemView.context.isSystemInDarkMode()) { - plugin.metadata.iconNightPath - } else { - plugin.metadata.iconDayPath - } - - pluginIcon.background = null - pluginIcon.imageTintList = null - val iconFile = iconPath?.let(::File)?.takeIf { it.exists() } - if (iconFile != null) { - Glide - .with(pluginIcon) - .load(iconFile) - .signature(ObjectKey(iconFile.lastModified())) - .placeholder(R.drawable.ic_extension) - .error(R.drawable.ic_extension) - .into(pluginIcon) - } else { - Glide.with(pluginIcon).clear(pluginIcon) - pluginIcon.setImageResource(R.drawable.ic_extension) - } - - val statusText = - when { - !plugin.isLoaded -> R.string.plugin_status_not_loaded - !plugin.isEnabled -> R.string.plugin_status_disabled - else -> R.string.plugin_status_enabled - } - pluginStatus.setText(statusText) - - val statusColor = - when { - !plugin.isLoaded -> R.color.error - !plugin.isEnabled -> R.color.warning - else -> R.color.success - } - pluginStatus.setTextColor( - itemView.context.getColor(statusColor), - ) - - // Setup menu button - btnMenu.setOnClickListener { view -> - showPopupMenu(view, plugin) - } - - // Setup item click for details - root.setOnClickListener { - onActionClick(plugin, Action.DETAILS) - } - - // Long-press for Plugin Manager tooltip - root.setOnLongClickListener { - TooltipManager.showIdeCategoryTooltip(it.context, it, TooltipTag.PLUGIN_MANAGER) - true - } - } - } - - private fun showPopupMenu( - view: View, - plugin: PluginInfo, - ) { - val popup = PopupMenu(view.context, view) - val actions = menuActionsFor(plugin) - - actions.forEachIndexed { index, action -> - popup.menu.add(Menu.NONE, index, index, action.labelRes) - } - - popup.setOnMenuItemClickListener { menuItem -> - onActionClick(plugin, actions[menuItem.itemId]) - true - } - - popup.show() - } - - private fun menuActionsFor(plugin: PluginInfo): List = - buildList { - if (plugin.isLoaded) { - add(if (plugin.isEnabled) Action.DISABLE else Action.ENABLE) - add(Action.UNINSTALL) - } - add(Action.DETAILS) - } - } -} - -class PluginDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: PluginInfo, - newItem: PluginInfo, - ): Boolean = oldItem.metadata.id == newItem.metadata.id - - override fun areContentsTheSame( - oldItem: PluginInfo, - newItem: PluginInfo, - ): Boolean = oldItem == newItem -} diff --git a/app/src/main/java/com/itsaky/androidide/analytics/AnalyticsManager.kt b/app/src/main/java/com/itsaky/androidide/analytics/AnalyticsManager.kt index 3c5218df40..3a252cab94 100644 --- a/app/src/main/java/com/itsaky/androidide/analytics/AnalyticsManager.kt +++ b/app/src/main/java/com/itsaky/androidide/analytics/AnalyticsManager.kt @@ -44,15 +44,20 @@ interface IAnalyticsManager { } class AnalyticsManager : IAnalyticsManager { + @Volatile + private var consentGranted = false + private val analytics: FirebaseAnalytics by lazy { Firebase.analytics.apply { - setAnalyticsCollectionEnabled(true) + setAnalyticsCollectionEnabled(consentGranted) } } private var sessionStartTime: Long = 0 override fun initialize() { + consentGranted = true + analytics.setAnalyticsCollectionEnabled(true) trackAppOpen() startSession() } diff --git a/app/src/main/java/com/itsaky/androidide/analytics/AttachedDevicesCollector.kt b/app/src/main/java/com/itsaky/androidide/analytics/AttachedDevicesCollector.kt new file mode 100644 index 0000000000..dd3409ad8b --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/analytics/AttachedDevicesCollector.kt @@ -0,0 +1,117 @@ +package com.itsaky.androidide.analytics + +import android.content.Context +import android.hardware.display.DisplayManager +import android.os.Build +import android.view.Display +import android.view.InputDevice +import org.slf4j.LoggerFactory + +enum class AttachedDeviceClass { + MOUSE, + EXTERNAL_KEYBOARD, + TOUCHPAD, + STYLUS, + GAMEPAD, +} + +data class AttachedDevicesSnapshot( + val mouseCount: Int, + val externalKeyboardCount: Int, + val touchpadCount: Int, + val stylusCount: Int, + val gamepadCount: Int, + val externalDisplayCount: Int, +) + +object AttachedDevicesCollector { + private val logger = LoggerFactory.getLogger(AttachedDevicesCollector::class.java) + + private val DEVICE_CLASS_BY_SOURCE = + mapOf( + InputDevice.SOURCE_MOUSE to AttachedDeviceClass.MOUSE, + InputDevice.SOURCE_TOUCHPAD to AttachedDeviceClass.TOUCHPAD, + InputDevice.SOURCE_STYLUS to AttachedDeviceClass.STYLUS, + InputDevice.SOURCE_BLUETOOTH_STYLUS to AttachedDeviceClass.STYLUS, + InputDevice.SOURCE_GAMEPAD to AttachedDeviceClass.GAMEPAD, + InputDevice.SOURCE_JOYSTICK to AttachedDeviceClass.GAMEPAD, + ) + + fun classify( + sources: Int, + keyboardType: Int, + isVirtual: Boolean, + isExternal: Boolean?, + ): Set { + if (isVirtual || isExternal == false) { + return emptySet() + } + if (isExternal == null && sources.supportsSource(InputDevice.SOURCE_TOUCHSCREEN)) { + return emptySet() + } + val matched = + DEVICE_CLASS_BY_SOURCE + .filterKeys { sources.supportsSource(it) } + .values + .toSet() + return if (sources.supportsSource(InputDevice.SOURCE_KEYBOARD) && + keyboardType == InputDevice.KEYBOARD_TYPE_ALPHABETIC + ) { + matched + AttachedDeviceClass.EXTERNAL_KEYBOARD + } else { + matched + } + } + + fun collect(context: Context): AttachedDevicesSnapshot { + val classCounts = + try { + countInputDeviceClasses() + } catch (e: RuntimeException) { + logger.warn("Failed to count input devices", e) + emptyMap() + } + val externalDisplays = + try { + countExternalDisplays(context) + } catch (e: RuntimeException) { + logger.warn("Failed to count external displays", e) + 0 + } + return AttachedDevicesSnapshot( + mouseCount = classCounts[AttachedDeviceClass.MOUSE] ?: 0, + externalKeyboardCount = classCounts[AttachedDeviceClass.EXTERNAL_KEYBOARD] ?: 0, + touchpadCount = classCounts[AttachedDeviceClass.TOUCHPAD] ?: 0, + stylusCount = classCounts[AttachedDeviceClass.STYLUS] ?: 0, + gamepadCount = classCounts[AttachedDeviceClass.GAMEPAD] ?: 0, + externalDisplayCount = externalDisplays, + ) + } + + private fun countInputDeviceClasses(): Map = + InputDevice + .getDeviceIds() + .map { InputDevice.getDevice(it) } + .filterNotNull() + .flatMap { device -> + classify( + sources = device.sources, + keyboardType = device.keyboardType, + isVirtual = device.isVirtual, + isExternal = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + device.isExternal + } else { + null + }, + ) + }.groupingBy { it } + .eachCount() + + private fun countExternalDisplays(context: Context): Int = + requireNotNull(context.getSystemService(DisplayManager::class.java)) + .displays + .count { it.displayId != Display.DEFAULT_DISPLAY } + + private fun Int.supportsSource(source: Int): Boolean = (this and source) == source +} diff --git a/app/src/main/java/com/itsaky/androidide/analytics/AttachedDevicesMetric.kt b/app/src/main/java/com/itsaky/androidide/analytics/AttachedDevicesMetric.kt new file mode 100644 index 0000000000..820f88c212 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/analytics/AttachedDevicesMetric.kt @@ -0,0 +1,19 @@ +package com.itsaky.androidide.analytics + +import android.os.Bundle + +class AttachedDevicesMetric( + private val snapshot: AttachedDevicesSnapshot, +) : Metric { + override val eventName = "attached_devices" + + override fun asBundle(): Bundle = + Bundle().apply { + putLong("mouse_count", snapshot.mouseCount.toLong()) + putLong("external_keyboard_count", snapshot.externalKeyboardCount.toLong()) + putLong("touchpad_count", snapshot.touchpadCount.toLong()) + putLong("stylus_count", snapshot.stylusCount.toLong()) + putLong("gamepad_count", snapshot.gamepadCount.toLong()) + putLong("external_display_count", snapshot.externalDisplayCount.toLong()) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt b/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt index f9bef8288b..7af94d9e9b 100644 --- a/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt +++ b/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt @@ -79,6 +79,13 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { return } + // Storage is confirmed accessible here, so it's safe to warm IDEApplication.cachedFilesDir + // now for devices that were still locked (Direct Boot) when onCreate() ran its own warmup. + // by lazy caches the value, not a failure, so swallowing errors here just means the first + // real read pays the syscall - it never poisons the cache or blocks the retry. + runCatching { withContext(Dispatchers.IO) { IDEApplication.cachedFilesDir } } + .onFailure { logger.warn("Failed to warm cachedFilesDir; first read will hit disk", it) } + if (!_isLoaded.compareAndSet(false, true)) { // Another call already claimed initialization (e.g. a concurrent retry after // user unlock); avoid running the rest of this method twice. diff --git a/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt b/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt index 9cf18fcdb8..a5a1ed921c 100644 --- a/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt +++ b/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt @@ -6,6 +6,8 @@ import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner import com.itsaky.androidide.BuildConfig +import com.itsaky.androidide.analytics.AttachedDevicesCollector +import com.itsaky.androidide.analytics.AttachedDevicesMetric import com.itsaky.androidide.analytics.IAnalyticsManager import com.itsaky.androidide.app.strictmode.StrictModeConfig import com.itsaky.androidide.app.strictmode.StrictModeManager @@ -17,6 +19,8 @@ import com.itsaky.androidide.events.ProjectsApiEventsIndex import com.itsaky.androidide.handlers.CrashEventSubscriber import com.itsaky.androidide.handlers.GlitchTipDiagnosticsContext import com.itsaky.androidide.logging.provider.IdeLogRouter +import com.itsaky.androidide.preferences.internal.StatPreferences +import com.itsaky.androidide.preferences.internal.TelemetryConsent import com.itsaky.androidide.syntax.colorschemes.SchemeAndroidIDE import com.itsaky.androidide.ui.themes.IThemeManager import com.itsaky.androidide.utils.Environment @@ -37,6 +41,7 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.slf4j.LoggerFactory import org.slf4j.event.Level +import java.util.concurrent.atomic.AtomicBoolean import kotlin.system.exitProcess /** @@ -51,6 +56,10 @@ internal object DeviceProtectedApplicationLoader : private val crashEventSubscriber = CrashEventSubscriber() val analyticsManager: IAnalyticsManager by inject() + private val telemetryInitialized = AtomicBoolean(false) + + private const val KEY_LEGACY_PRIVACY_DISCLOSURE_SHOWN = "privacy.disclosure.shown" + override suspend fun load(app: IDEApplication) { logger.info("Loading device protected storage context components...") @@ -73,6 +82,41 @@ internal object DeviceProtectedApplicationLoader : ), ) + migrateLegacyConsent(app) + initTelemetryIfConsented(app) + + ShizukuSettings.initialize() + + EventBus + .builder() + .addIndex(AppEventsIndex()) + .addIndex(EditorEventsIndex()) + .addIndex(ProjectsApiEventsIndex()) + .addIndex(LspApiEventsIndex()) + .addIndex(LspJavaEventsIndex()) + .installDefaultEventBus(true) + + EventBus.getDefault().register(crashEventSubscriber) + + EditorColorScheme.setDefault(SchemeAndroidIDE.newInstance(null)) + + ReflectionUtils.bypassHiddenAPIReflectionRestrictions() + + app.coroutineScope.launch(Dispatchers.IO) { + IThemeManager.getInstance() + } + } + + suspend fun initTelemetryIfConsented(app: IDEApplication) { + if (StatPreferences.telemetryConsent != TelemetryConsent.GRANTED) { + logger.info("Telemetry not initialized (consent={})", StatPreferences.telemetryConsent) + return + } + + if (!telemetryInitialized.compareAndSet(false, true)) { + return + } + runCatching { // Initialize the Sentry SDK; it reports to our GlitchTip backend // (GlitchTip is Sentry-protocol-compatible), so the SDK types stay io.sentry. @@ -117,30 +161,30 @@ internal object DeviceProtectedApplicationLoader : logger.error("Failed to initialize crash and log reporting", it) } - ShizukuSettings.initialize() - - EventBus - .builder() - .addIndex(AppEventsIndex()) - .addIndex(EditorEventsIndex()) - .addIndex(ProjectsApiEventsIndex()) - .addIndex(LspApiEventsIndex()) - .addIndex(LspJavaEventsIndex()) - .installDefaultEventBus(true) - - EventBus.getDefault().register(crashEventSubscriber) - - EditorColorScheme.setDefault(SchemeAndroidIDE.newInstance(null)) + withContext(Dispatchers.Main) { + initializeAnalytics() + } - ReflectionUtils.bypassHiddenAPIReflectionRestrictions() + trackAttachedDevicesMetric(app) + } - app.coroutineScope.launch(Dispatchers.IO) { - // early-init theme manager since it may need to perform disk reads - IThemeManager.getInstance() + fun onTelemetryConsentGranted(app: IDEApplication) { + app.coroutineScope.launch(Dispatchers.Default) { + initTelemetryIfConsented(app) } + } - withContext(Dispatchers.Main) { - initializeAnalytics() + internal fun shouldMigrateLegacyConsent( + currentConsent: TelemetryConsent, + legacyDisclosureShown: Boolean, + ): Boolean = currentConsent == TelemetryConsent.UNSET && legacyDisclosureShown + + private fun migrateLegacyConsent(app: IDEApplication) { + val legacyDisclosureShown = + app.prefManager.getBoolean(KEY_LEGACY_PRIVACY_DISCLOSURE_SHOWN, false) + if (shouldMigrateLegacyConsent(StatPreferences.telemetryConsent, legacyDisclosureShown)) { + logger.info("Migrating legacy privacy disclosure acceptance to telemetry consent") + StatPreferences.telemetryConsent = TelemetryConsent.GRANTED } } @@ -154,6 +198,16 @@ internal object DeviceProtectedApplicationLoader : } } + private fun trackAttachedDevicesMetric(app: IDEApplication) { + try { + analyticsManager.trackMetric( + AttachedDevicesMetric(AttachedDevicesCollector.collect(app)), + ) + } catch (e: Exception) { + logger.error("Failed to report attached devices metric", e) + } + } + fun handleUncaughtException( thread: Thread, exception: Throwable, diff --git a/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt b/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt index 78b3fae7e9..4fc16ff0fc 100644 --- a/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt @@ -4,11 +4,12 @@ import android.os.Handler import android.os.Looper import androidx.lifecycle.lifecycleScope import com.itsaky.androidide.activities.editor.EditorHandlerActivity +import com.itsaky.androidide.activities.editor.PeerCursorOverlayManager +import com.itsaky.androidide.editor.ui.IDEEditor +import com.itsaky.androidide.eventbus.events.editor.DocumentChangeEvent import com.itsaky.androidide.models.Position import com.itsaky.androidide.models.Range import com.itsaky.androidide.models.SaveResult -import com.itsaky.androidide.editor.ui.IDEEditor -import com.itsaky.androidide.eventbus.events.editor.DocumentChangeEvent import com.itsaky.androidide.plugins.manager.services.IdeEditorServiceImpl import com.itsaky.androidide.plugins.services.CursorPosition import com.itsaky.androidide.plugins.services.SelectionRange @@ -35,389 +36,461 @@ import java.util.concurrent.atomic.AtomicReference * Activity reference is held weakly so a leaked provider can never keep the activity alive. */ class EditorProviderImpl( - activity: EditorHandlerActivity, + activity: EditorHandlerActivity, ) : IdeEditorServiceImpl.EditorProvider { - - private val activityRef = WeakReference(activity) - private val mainHandler = Handler(Looper.getMainLooper()) - private val fileCallbacks = java.util.concurrent.CopyOnWriteArrayList<(File?) -> Unit>() - private val contentCallbacks = - java.util.concurrent.CopyOnWriteArrayList<(String, Int, Int, String) -> Unit>() - - private val internalListener: (File?) -> Unit = { file -> - fileCallbacks.forEach { cb -> - try { - cb(file) - } catch (_: Exception) { - } - } - } - - init { - EditorEvents.addFileChangeListener(internalListener) - // Content changes reach us via the editor's existing DocumentChangeEvent (posted to the - // global EventBus on every edit); we fan them out to plugin-registered callbacks. - EventBus.getDefault().register(this) - } - - /** - * Detaches from EditorEvents / EventBus and clears any plugin-registered callbacks. Called - * by the activity in `onDestroy`. - */ - fun dispose() { - EditorEvents.removeFileChangeListener(internalListener) - EventBus.getDefault().unregister(this) - fileCallbacks.clear() - contentCallbacks.clear() - activityRef.clear() - } - - /** - * Bridges the editor's per-keystroke [DocumentChangeEvent] to the plugin content-change - * contract `(fileContent, cursorLine, cursorColumn, language)`. Line/column are 0-indexed, - * matching what plugins expect. Runs on the main thread so the editor cursor is current. - */ - @Subscribe(threadMode = ThreadMode.MAIN) - fun onDocumentChange(event: DocumentChangeEvent) { - if (contentCallbacks.isEmpty()) return - val file = event.file.toFile() - // Only fan out changes for the focused file; ghost text can only target the visible editor. - if (file.absolutePath != getCurrentFile()?.absolutePath) return - val editor = activity()?.getEditorForFile(file)?.editor - val content = event.newText ?: editor?.text?.toString() ?: return - // Prefer the live cursor; fall back to the change's end position (also 0-indexed). - val cursor = editor?.cursor - val line = cursor?.leftLine ?: event.changeRange.end.line - val column = cursor?.leftColumn ?: event.changeRange.end.column - val language = languageIdForFile(file) ?: file.extension.lowercase() - contentCallbacks.forEach { cb -> - try { - cb(content, line, column, language) - } catch (_: Exception) { - } - } - } - - private fun activity(): EditorHandlerActivity? = activityRef.get()?.takeIf { !it.isDestroyed } - - // --- File state --------------------------------------------------------- - - override fun getCurrentFile(): File? { - val activity = activity() ?: return null - val direct = activity.editorViewModel.getCurrentFile() - if (direct != null) return direct - - // Active tab may be a plugin tab; fall back to the last real file we saw, - // but only if it's still actually open. - val fallback = EditorEvents.lastActiveFile ?: return null - val opened = activity.editorViewModel.getOpenedFiles() - val target = fallback.absolutePath - return if (opened.any { it.absolutePath == target }) fallback else null - } - - override fun getOpenFiles(): List = - activity()?.editorViewModel?.getOpenedFiles() ?: emptyList() - - override fun isFileOpen(file: File): Boolean { - val opened = activity()?.editorViewModel?.getOpenedFiles() ?: return false - val target = file.absolutePath - return opened.any { it.absolutePath == target } - } - - override fun isFileModified(file: File): Boolean = - activity()?.getEditorForFile(file)?.isModified == true - - override fun getModifiedFiles(): List { - val activity = activity() ?: return emptyList() - return activity.editorViewModel.getOpenedFiles() - .filter { activity.getEditorForFile(it)?.isModified == true } - } - - // --- Cursor / selection / line text ------------------------------------ - - // When a plugin tab is on top, `getCurrentEditor()` is null; fall back to the editor - // for the last real file so plugins can still inspect cursor/selection/content. - private fun inspectableEditor(): CodeEditor? { - val activity = activity() ?: return null - activity.getCurrentEditor()?.editor?.let { return it } - val file = getCurrentFile() ?: return null - return activity.getEditorForFile(file)?.editor - } - - override fun getCurrentSelection(): String? { - val editor = inspectableEditor() ?: return null - val cursor = editor.cursor - if (!cursor.isSelected) return null - return editor.text.subSequence(cursor.left, cursor.right).toString() - } - - override fun getCurrentFileContent(): String? = - inspectableEditor()?.text?.toString() - - override fun getFileContent(file: File): String? = - activity()?.getEditorForFile(file)?.editor?.text?.toString() - - override fun getCurrentCursorPosition(): CursorPosition? { - val editor = inspectableEditor() ?: return null - val cursor = editor.cursor - return CursorPosition(cursor.leftLine, cursor.leftColumn, cursor.left) - } - - override fun getCurrentSelectionRange(): SelectionRange? { - val editor = inspectableEditor() ?: return null - val cursor = editor.cursor - if (!cursor.isSelected) return null - return SelectionRange(cursor.leftLine, cursor.leftColumn, cursor.rightLine, cursor.rightColumn) - } - - override fun getCurrentLineText(): String? { - val editor = inspectableEditor() ?: return null - val line = editor.cursor.leftLine - val text = editor.text - if (line !in 0 until text.lineCount) return null - return text.getLine(line).toString() - } - - override fun getLineText(file: File, lineNumber: Int): String? { - val editor = activity()?.getEditorForFile(file)?.editor ?: return null - val text = editor.text - if (lineNumber !in 0 until text.lineCount) return null - return text.getLine(lineNumber).toString() - } - - override fun getLineCount(file: File): Int = - activity()?.getEditorForFile(file)?.editor?.text?.lineCount ?: 0 - - override fun getWordAtCursor(): String? { - val editor = inspectableEditor() ?: return null - val cursor = editor.cursor - val text = editor.text - val line = cursor.leftLine - if (line !in 0 until text.lineCount) return null - val lineText = text.getLine(line).toString() - val column = cursor.leftColumn.coerceIn(0, lineText.length) - var start = column - while (start > 0 && lineText[start - 1].isWordChar()) start-- - var end = column - while (end < lineText.length && lineText[end].isWordChar()) end++ - if (start == end) return null - return lineText.substring(start, end) - } - - override fun getCurrentLanguageId(): String? = - getCurrentFile()?.let { languageIdForFile(it) } - - override fun getFileLanguageId(file: File): String? = languageIdForFile(file) - - // --- Tab control -------------------------------------------------------- - - override fun openFile(file: File): Boolean { - val activity = activity() ?: return false - activity.openFileAsync(file) {} - return true - } - - override fun openFileAt(file: File, line: Int, column: Int): Boolean { - val activity = activity() ?: return false - val pos = Position(line.coerceAtLeast(0), column.coerceAtLeast(0)) - activity.openFileAndSelect(file, Range(pos, pos)) - return true - } - - override fun saveCurrentFile(): Boolean { - val activity = activity() ?: return false - val index = activity.editorViewModel.getCurrentFileIndex() - if (index < 0) return false - activity.lifecycleScope.launch { - activity.saveResult(index, SaveResult()) - } - return true - } - - // --- Buffer edits ------------------------------------------------------- - - override fun insertTextAtCursor(text: String): Boolean = onMain { - val editor = inspectableEditor() ?: return@onMain false - val cursor = editor.cursor - editor.text.runEdit { - if (cursor.isSelected) { - replace(cursor.leftLine, cursor.leftColumn, cursor.rightLine, cursor.rightColumn, text) - } else { - insert(cursor.leftLine, cursor.leftColumn, text) - } - } - true - } - - override fun replaceSelection(text: String): Boolean = onMain { - val editor = inspectableEditor() ?: return@onMain false - val cursor = editor.cursor - if (!cursor.isSelected) return@onMain false - editor.text.runEdit { - replace(cursor.leftLine, cursor.leftColumn, cursor.rightLine, cursor.rightColumn, text) - } - true - } - - override fun appendToLine(file: File, line: Int, text: String): Boolean = - lineEdit(file, line, existing = true) { insert(line, getColumnCount(line), text) } - - override fun prependToLine(file: File, line: Int, text: String): Boolean = - lineEdit(file, line, existing = true) { insert(line, 0, text) } - - override fun replaceLine(file: File, line: Int, newText: String): Boolean = - lineEdit(file, line, existing = true) { replace(line, 0, line, getColumnCount(line), newText) } - - override fun insertLineBefore(file: File, line: Int, text: String): Boolean { - val payload = if (text.endsWith("\n")) text else "$text\n" - return lineEdit(file, line, existing = false) { insert(line, 0, payload) } - } - - override fun deleteLine(file: File, line: Int): Boolean = - lineEdit(file, line, existing = true) { - if (line < lineCount - 1) { - delete(line, 0, line + 1, 0) - } else if (line > 0) { - delete(line - 1, getColumnCount(line - 1), line, getColumnCount(line)) - } else { - delete(line, 0, line, getColumnCount(line)) - } - } - - override fun replaceRange(file: File, range: SelectionRange, newText: String): Boolean = onMain { - val editor = activity()?.getEditorForFile(file)?.editor ?: return@onMain false - val content = editor.text - val maxLine = content.lineCount - 1 - if (range.startLine !in 0..maxLine || range.endLine !in 0..maxLine) return@onMain false - content.runEdit { - replace(range.startLine, range.startColumn, range.endLine, range.endColumn, newText) - } - true - } - - /** - * Resolves the editor for [file], validates [line] (bounds differ between edits that - * mutate an existing line and those that insert a new one), and runs [block] inside a - * single batched edit on the main thread. `existing = true` requires 0 ≤ line < lineCount; - * `existing = false` allows line == lineCount for "insert at end". - */ - private inline fun lineEdit( - file: File, - line: Int, - existing: Boolean, - crossinline block: Content.() -> Unit, - ): Boolean = onMain { - val editor = activity()?.getEditorForFile(file)?.editor ?: return@onMain false - val content = editor.text - val valid = if (existing) line in 0 until content.lineCount else line in 0..content.lineCount - if (!valid) return@onMain false - content.runEdit(block) - true - } - - - override fun addFileChangeCallback(callback: (File?) -> Unit) { - fileCallbacks.addIfAbsent(callback) - } - - override fun removeFileChangeCallback(callback: (File?) -> Unit) { - fileCallbacks.remove(callback) - } - - override fun addContentChangeCallback(callback: (String, Int, Int, String) -> Unit) { - contentCallbacks.addIfAbsent(callback) - } - - override fun removeContentChangeCallback(callback: (String, Int, Int, String) -> Unit) { - contentCallbacks.remove(callback) - } - - // --- Inline suggestions ------------------------------------------------- - - override fun showInlineSuggestion(pluginId: String, text: String) { - mainHandler.post { - (inspectableEditor() as? IDEEditor)?.showInlineSuggestion(pluginId, text) - } - } - - override fun dismissInlineSuggestion(pluginId: String) { - mainHandler.post { - (inspectableEditor() as? IDEEditor)?.dismissInlineSuggestion(pluginId) - } - } - - // --- Helpers ------------------------------------------------------------ - - private inline fun Content.runEdit(block: Content.() -> T): T { - beginBatchEdit() - try { - return block() - } finally { - endBatchEdit() - } - } - - /** - * Posts [block] to the main thread and blocks the caller until it finishes. If the main - * thread doesn't process the edit within [MAIN_EDIT_TIMEOUT_SECONDS] the call logs a - * warning and returns `false` rather than hanging the plugin's thread or throwing - * through to an uncaught-exception handler — a deadlocked UI should not be able to take - * the IDE down with it. - */ - private inline fun onMain(crossinline block: () -> Boolean): Boolean { - if (Looper.myLooper() === mainHandler.looper) return block() - val latch = CountDownLatch(1) - val resultRef = AtomicReference(false) - val errorRef = AtomicReference(null) - mainHandler.post { - try { - resultRef.set(block()) - } catch (t: Throwable) { - errorRef.set(t) - } finally { - latch.countDown() - } - } - if (!latch.await(MAIN_EDIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { - log.warn( - "Main thread did not process plugin edit within {}s; aborting", - MAIN_EDIT_TIMEOUT_SECONDS, - ) - return false - } - errorRef.get()?.let { throw it } - return resultRef.get() - } - - private fun Char.isWordChar(): Boolean = isLetterOrDigit() || this == '_' - - private fun languageIdForFile(file: File): String? { - val ext = file.extension.lowercase() - return when (ext) { - "" -> null - "kt", "kts" -> "kotlin" - "java" -> "java" - "xml" -> "xml" - "json" -> "json" - "gradle" -> "groovy" - "groovy" -> "groovy" - "md", "markdown" -> "markdown" - "yml", "yaml" -> "yaml" - "properties" -> "properties" - "sh", "bash" -> "shell" - "c" -> "c" - "cpp", "cc", "cxx", "h", "hpp" -> "cpp" - "py" -> "python" - "js" -> "javascript" - "ts" -> "typescript" - "html", "htm" -> "html" - "css" -> "css" - else -> ext - } - } - - companion object { - private const val MAIN_EDIT_TIMEOUT_SECONDS = 5L - private val log = LoggerFactory.getLogger(EditorProviderImpl::class.java) - } + private val activityRef = WeakReference(activity) + private val mainHandler = Handler(Looper.getMainLooper()) + private val fileCallbacks = java.util.concurrent.CopyOnWriteArrayList<(File?) -> Unit>() + private val contentCallbacks = + java.util.concurrent.CopyOnWriteArrayList<(String, Int, Int, String) -> Unit>() + private val peerPresenceOverlay = + PeerCursorOverlayManager { file -> + activity()?.getEditorForFile(file)?.editor + } + + private val internalListener: (File?) -> Unit = { file -> + fileCallbacks.forEach { cb -> + try { + cb(file) + } catch (_: Exception) { + } + } + } + + init { + EditorEvents.addFileChangeListener(internalListener) + // Content changes reach us via the editor's existing DocumentChangeEvent (posted to the + // global EventBus on every edit); we fan them out to plugin-registered callbacks. + EventBus.getDefault().register(this) + } + + /** + * Detaches from EditorEvents / EventBus and clears any plugin-registered callbacks. Called + * by the activity in `onDestroy`. + */ + fun dispose() { + EditorEvents.removeFileChangeListener(internalListener) + EventBus.getDefault().unregister(this) + fileCallbacks.clear() + contentCallbacks.clear() + onMain { + peerPresenceOverlay.clearAll() + true + } + activityRef.clear() + } + + /** + * Bridges the editor's per-keystroke [DocumentChangeEvent] to the plugin content-change + * contract `(fileContent, cursorLine, cursorColumn, language)`. Line/column are 0-indexed, + * matching what plugins expect. Runs on the main thread so the editor cursor is current. + */ + @Subscribe(threadMode = ThreadMode.MAIN) + fun onDocumentChange(event: DocumentChangeEvent) { + if (contentCallbacks.isEmpty()) return + val file = event.file.toFile() + // Only fan out changes for the focused file; ghost text can only target the visible editor. + if (file.absolutePath != getCurrentFile()?.absolutePath) return + val editor = activity()?.getEditorForFile(file)?.editor + val content = event.newText ?: editor?.text?.toString() ?: return + // Prefer the live cursor; fall back to the change's end position (also 0-indexed). + val cursor = editor?.cursor + val line = cursor?.leftLine ?: event.changeRange.end.line + val column = cursor?.leftColumn ?: event.changeRange.end.column + val language = languageIdForFile(file) ?: file.extension.lowercase() + contentCallbacks.forEach { cb -> + try { + cb(content, line, column, language) + } catch (_: Exception) { + } + } + } + + private fun activity(): EditorHandlerActivity? = activityRef.get()?.takeIf { !it.isDestroyed } + + // --- File state --------------------------------------------------------- + + override fun getCurrentFile(): File? { + val activity = activity() ?: return null + val direct = activity.editorViewModel.getCurrentFile() + if (direct != null) return direct + + // Active tab may be a plugin tab; fall back to the last real file we saw, + // but only if it's still actually open. + val fallback = EditorEvents.lastActiveFile ?: return null + val opened = activity.editorViewModel.getOpenedFiles() + val target = fallback.absolutePath + return if (opened.any { it.absolutePath == target }) fallback else null + } + + override fun getOpenFiles(): List = activity()?.editorViewModel?.getOpenedFiles() ?: emptyList() + + override fun isFileOpen(file: File): Boolean { + val opened = activity()?.editorViewModel?.getOpenedFiles() ?: return false + val target = file.absolutePath + return opened.any { it.absolutePath == target } + } + + override fun isFileModified(file: File): Boolean = activity()?.getEditorForFile(file)?.isModified == true + + override fun getModifiedFiles(): List { + val activity = activity() ?: return emptyList() + return activity.editorViewModel + .getOpenedFiles() + .filter { activity.getEditorForFile(it)?.isModified == true } + } + + // --- Cursor / selection / line text ------------------------------------ + + // When a plugin tab is on top, `getCurrentEditor()` is null; fall back to the editor + // for the last real file so plugins can still inspect cursor/selection/content. + private fun inspectableEditor(): CodeEditor? { + val activity = activity() ?: return null + activity.getCurrentEditor()?.editor?.let { return it } + val file = getCurrentFile() ?: return null + return activity.getEditorForFile(file)?.editor + } + + override fun getCurrentSelection(): String? { + val editor = inspectableEditor() ?: return null + val cursor = editor.cursor + if (!cursor.isSelected) return null + return editor.text.subSequence(cursor.left, cursor.right).toString() + } + + override fun getCurrentFileContent(): String? = inspectableEditor()?.text?.toString() + + override fun getFileContent(file: File): String? = + activity() + ?.getEditorForFile(file) + ?.editor + ?.text + ?.toString() + + override fun getCurrentCursorPosition(): CursorPosition? { + val editor = inspectableEditor() ?: return null + val cursor = editor.cursor + return CursorPosition(cursor.leftLine, cursor.leftColumn, cursor.left) + } + + override fun getCurrentSelectionRange(): SelectionRange? { + val editor = inspectableEditor() ?: return null + val cursor = editor.cursor + if (!cursor.isSelected) return null + return SelectionRange(cursor.leftLine, cursor.leftColumn, cursor.rightLine, cursor.rightColumn) + } + + override fun getCurrentLineText(): String? { + val editor = inspectableEditor() ?: return null + val line = editor.cursor.leftLine + val text = editor.text + if (line !in 0 until text.lineCount) return null + return text.getLine(line).toString() + } + + override fun getLineText( + file: File, + lineNumber: Int, + ): String? { + val editor = activity()?.getEditorForFile(file)?.editor ?: return null + val text = editor.text + if (lineNumber !in 0 until text.lineCount) return null + return text.getLine(lineNumber).toString() + } + + override fun getLineCount(file: File): Int = + activity() + ?.getEditorForFile(file) + ?.editor + ?.text + ?.lineCount ?: 0 + + override fun getWordAtCursor(): String? { + val editor = inspectableEditor() ?: return null + val cursor = editor.cursor + val text = editor.text + val line = cursor.leftLine + if (line !in 0 until text.lineCount) return null + val lineText = text.getLine(line).toString() + val column = cursor.leftColumn.coerceIn(0, lineText.length) + var start = column + while (start > 0 && lineText[start - 1].isWordChar()) start-- + var end = column + while (end < lineText.length && lineText[end].isWordChar()) end++ + if (start == end) return null + return lineText.substring(start, end) + } + + override fun getCurrentLanguageId(): String? = getCurrentFile()?.let { languageIdForFile(it) } + + override fun getFileLanguageId(file: File): String? = languageIdForFile(file) + + // --- Tab control -------------------------------------------------------- + + override fun openFile(file: File): Boolean { + val activity = activity() ?: return false + activity.openFileAsync(file) {} + return true + } + + override fun openFileAt( + file: File, + line: Int, + column: Int, + ): Boolean { + val activity = activity() ?: return false + val pos = Position(line.coerceAtLeast(0), column.coerceAtLeast(0)) + activity.openFileAndSelect(file, Range(pos, pos)) + return true + } + + override fun saveCurrentFile(): Boolean { + val activity = activity() ?: return false + val index = activity.editorViewModel.getCurrentFileIndex() + if (index < 0) return false + activity.lifecycleScope.launch { + activity.saveResult(index, SaveResult()) + } + return true + } + + // --- Buffer edits ------------------------------------------------------- + + override fun insertTextAtCursor(text: String): Boolean = + onMain { + val editor = inspectableEditor() ?: return@onMain false + val cursor = editor.cursor + editor.text.runEdit { + if (cursor.isSelected) { + replace(cursor.leftLine, cursor.leftColumn, cursor.rightLine, cursor.rightColumn, text) + } else { + insert(cursor.leftLine, cursor.leftColumn, text) + } + } + true + } + + override fun replaceSelection(text: String): Boolean = + onMain { + val editor = inspectableEditor() ?: return@onMain false + val cursor = editor.cursor + if (!cursor.isSelected) return@onMain false + editor.text.runEdit { + replace(cursor.leftLine, cursor.leftColumn, cursor.rightLine, cursor.rightColumn, text) + } + true + } + + override fun appendToLine( + file: File, + line: Int, + text: String, + ): Boolean = lineEdit(file, line, existing = true) { insert(line, getColumnCount(line), text) } + + override fun prependToLine( + file: File, + line: Int, + text: String, + ): Boolean = lineEdit(file, line, existing = true) { insert(line, 0, text) } + + override fun replaceLine( + file: File, + line: Int, + newText: String, + ): Boolean = lineEdit(file, line, existing = true) { replace(line, 0, line, getColumnCount(line), newText) } + + override fun insertLineBefore( + file: File, + line: Int, + text: String, + ): Boolean { + val payload = if (text.endsWith("\n")) text else "$text\n" + return lineEdit(file, line, existing = false) { insert(line, 0, payload) } + } + + override fun deleteLine( + file: File, + line: Int, + ): Boolean = + lineEdit(file, line, existing = true) { + if (line < lineCount - 1) { + delete(line, 0, line + 1, 0) + } else if (line > 0) { + delete(line - 1, getColumnCount(line - 1), line, getColumnCount(line)) + } else { + delete(line, 0, line, getColumnCount(line)) + } + } + + override fun replaceRange( + file: File, + range: SelectionRange, + newText: String, + ): Boolean = + onMain { + val editor = activity()?.getEditorForFile(file)?.editor ?: return@onMain false + val content = editor.text + val maxLine = content.lineCount - 1 + if (range.startLine !in 0..maxLine || range.endLine !in 0..maxLine) return@onMain false + content.runEdit { + replace(range.startLine, range.startColumn, range.endLine, range.endColumn, newText) + } + true + } + + override fun showPeerCursor( + file: File, + line: Int, + column: Int, + peerId: String, + peerName: String, + peerColor: Int, + ): Boolean = + onMain { + peerPresenceOverlay.addMarker(file, line, column, peerId, peerName, peerColor) + } + + override fun hidePeerCursor( + file: File, + peerId: String, + ): Boolean = + onMain { + peerPresenceOverlay.removeMarker(file, peerId) + } + + override fun clearPeerCursors(file: File) { + onMain { + peerPresenceOverlay.clear(file) + true + } + } + + /** + * Resolves the editor for [file], validates [line] (bounds differ between edits that + * mutate an existing line and those that insert a new one), and runs [block] inside a + * single batched edit on the main thread. `existing = true` requires 0 <= line < lineCount; + * `existing = false` allows line == lineCount for "insert at end". + */ + private inline fun lineEdit( + file: File, + line: Int, + existing: Boolean, + crossinline block: Content.() -> Unit, + ): Boolean = + onMain { + val editor = activity()?.getEditorForFile(file)?.editor ?: return@onMain false + val content = editor.text + val valid = if (existing) line in 0 until content.lineCount else line in 0..content.lineCount + if (!valid) return@onMain false + content.runEdit(block) + true + } + + override fun addFileChangeCallback(callback: (File?) -> Unit) { + fileCallbacks.addIfAbsent(callback) + } + + override fun removeFileChangeCallback(callback: (File?) -> Unit) { + fileCallbacks.remove(callback) + } + + override fun addContentChangeCallback(callback: (String, Int, Int, String) -> Unit) { + contentCallbacks.addIfAbsent(callback) + } + + override fun removeContentChangeCallback(callback: (String, Int, Int, String) -> Unit) { + contentCallbacks.remove(callback) + } + + // --- Inline suggestions ------------------------------------------------- + + override fun showInlineSuggestion( + pluginId: String, + text: String, + ) { + mainHandler.post { + (inspectableEditor() as? IDEEditor)?.showInlineSuggestion(pluginId, text) + } + } + + override fun dismissInlineSuggestion(pluginId: String) { + mainHandler.post { + (inspectableEditor() as? IDEEditor)?.dismissInlineSuggestion(pluginId) + } + } + + // --- Helpers ------------------------------------------------------------ + + private inline fun Content.runEdit(block: Content.() -> T): T { + beginBatchEdit() + try { + return block() + } finally { + endBatchEdit() + } + } + + /** + * Posts [block] to the main thread and blocks the caller until it finishes. If the main + * thread doesn't process the edit within [MAIN_EDIT_TIMEOUT_SECONDS] the call logs a + * warning and returns `false` rather than hanging the plugin's thread or throwing + * through to an uncaught-exception handler - a deadlocked UI should not be able to take + * the IDE down with it. + */ + private inline fun onMain(crossinline block: () -> Boolean): Boolean { + if (Looper.myLooper() === mainHandler.looper) return block() + val latch = CountDownLatch(1) + val resultRef = AtomicReference(false) + val errorRef = AtomicReference(null) + mainHandler.post { + try { + resultRef.set(block()) + } catch (t: Throwable) { + errorRef.set(t) + } finally { + latch.countDown() + } + } + if (!latch.await(MAIN_EDIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + log.warn( + "Main thread did not process plugin edit within {}s; aborting", + MAIN_EDIT_TIMEOUT_SECONDS, + ) + return false + } + errorRef.get()?.let { throw it } + return resultRef.get() + } + + private fun Char.isWordChar(): Boolean = isLetterOrDigit() || this == '_' + + private fun languageIdForFile(file: File): String? { + val ext = file.extension.lowercase() + return when (ext) { + "" -> null + "kt", "kts" -> "kotlin" + "java" -> "java" + "xml" -> "xml" + "json" -> "json" + "gradle" -> "groovy" + "groovy" -> "groovy" + "md", "markdown" -> "markdown" + "yml", "yaml" -> "yaml" + "properties" -> "properties" + "sh", "bash" -> "shell" + "c" -> "c" + "cpp", "cc", "cxx", "h", "hpp" -> "cpp" + "py" -> "python" + "js" -> "javascript" + "ts" -> "typescript" + "html", "htm" -> "html" + "css" -> "css" + else -> ext + } + } + + companion object { + private const val MAIN_EDIT_TIMEOUT_SECONDS = 5L + private val log = LoggerFactory.getLogger(EditorProviderImpl::class.java) + } } diff --git a/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt b/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt index a4364353cb..d987d688aa 100755 --- a/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt +++ b/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt @@ -29,6 +29,7 @@ import androidx.work.Configuration import com.itsaky.androidide.BuildConfig import com.itsaky.androidide.di.coreModule import com.itsaky.androidide.di.pluginModule +import com.itsaky.androidide.di.templateModule import com.itsaky.androidide.handlers.GlitchTipDiagnosticsContext import com.itsaky.androidide.plugins.manager.core.PluginManager import com.itsaky.androidide.treesitter.TreeSitter @@ -48,11 +49,13 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.plus +import kotlinx.coroutines.runBlocking import org.koin.android.ext.koin.androidContext import org.koin.core.context.GlobalContext import org.koin.core.context.startKoin import org.lsposed.hiddenapibypass.HiddenApiBypass import org.slf4j.LoggerFactory +import java.io.File import java.lang.Thread.UncaughtExceptionHandler const val EXIT_CODE_CRASH = 1 @@ -141,6 +144,17 @@ class IDEApplication : @JvmStatic fun getPluginManager(): PluginManager? = CredentialProtectedApplicationLoader.pluginManager + + /** + * [Context.getFilesDir] does a real disk check (`File.exists()`) on every call, not just + * the first - callers on the main thread (e.g. Koin's [pluginModule] resolving on first + * navigation to the Extensions Manager) trip StrictMode's DiskReadViolation. Cache it once, + * off-main, before Koin starts (see the `onCreate()` warmup) so later reads are a plain + * field access instead of a syscall, and pluginModule/templateModule can never be the + * first to trigger the underlying disk read. + */ + @JvmStatic + val cachedFilesDir: File by lazy { instance.filesDir } } override fun onActivityPostPaused(activity: Activity) { @@ -182,6 +196,19 @@ class IDEApplication : // https://appdevforall.atlassian.net/browse/ADFA-2026 // https://appdevforall-inc-9p.sentry.io/issues/6860179170/events/7177c576e7b3491c9e9746c76f806d37/ + // Warm cachedFilesDir on an IO thread before Koin starts, so pluginModule/templateModule + // (resolved on the main thread on first navigation to the Extensions Manager) can never + // race the disk read - see cachedFilesDir's doc. The disk access itself runs off-main; + // this only blocks onCreate() waiting for that fast, one-time result. Only safe when + // credential-protected storage is already unlocked - instance.filesDir uses the default + // (credential-protected) Context and throws during Direct Boot. When locked, the warmup + // instead runs from CredentialProtectedApplicationLoader.load(), which only proceeds once + // that storage is confirmed accessible. + if (isUserUnlocked) { + runCatching { runBlocking(Dispatchers.IO) { cachedFilesDir } } + .onFailure { logger.warn("Failed to warm cachedFilesDir; first read will hit disk", it) } + } + ensureKoinStarted() coroutineScope.launch(Dispatchers.Default) { @@ -208,7 +235,7 @@ class IDEApplication : runCatching { GlobalContext.get() }.getOrNull()?.let { return } startKoin { androidContext(this@IDEApplication) - modules(coreModule, pluginModule) + modules(coreModule, pluginModule, templateModule) } } diff --git a/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt b/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt index 0152cc285b..abc42ee177 100644 --- a/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt +++ b/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt @@ -3,6 +3,9 @@ package com.itsaky.androidide.di import com.itsaky.androidide.app.IDEApplication import com.itsaky.androidide.repositories.PluginRepository import com.itsaky.androidide.repositories.PluginRepositoryImpl +import com.itsaky.androidide.repositories.TemplateCollectionRepository +import com.itsaky.androidide.repositories.TemplateCollectionRepositoryImpl +import com.itsaky.androidide.viewmodels.ExternalFileInstallViewModel import com.itsaky.androidide.viewmodels.PluginManagerViewModel import org.koin.android.ext.koin.androidContext import org.koin.androidx.viewmodel.dsl.viewModel @@ -12,22 +15,36 @@ import java.io.File /** * Koin module for plugin-related dependencies */ -val pluginModule = module { +val pluginModule = + module { - // Repository - single { - PluginRepositoryImpl( - pluginManagerProvider = { IDEApplication.getPluginManager() }, - pluginsDir = File(androidContext().filesDir, "plugins") - ) - } + // Repository + single { + PluginRepositoryImpl( + pluginManagerProvider = { IDEApplication.getPluginManager() }, + pluginsDir = File(IDEApplication.cachedFilesDir, "plugins"), + ) + } - // ViewModel - viewModel { - PluginManagerViewModel( - pluginRepository = get(), - contentResolver = androidContext().contentResolver, - filesDir = androidContext().filesDir - ) - } -} \ No newline at end of file + single { + TemplateCollectionRepositoryImpl() + } + + // ViewModel + viewModel { + PluginManagerViewModel( + pluginRepository = get(), + contentResolver = androidContext().contentResolver, + filesDir = IDEApplication.cachedFilesDir, + ) + } + + viewModel { + ExternalFileInstallViewModel( + pluginRepository = get(), + templateCollectionRepository = get(), + contentResolver = androidContext().contentResolver, + filesDir = IDEApplication.cachedFilesDir, + ) + } + } diff --git a/app/src/main/java/com/itsaky/androidide/di/TemplateModule.kt b/app/src/main/java/com/itsaky/androidide/di/TemplateModule.kt new file mode 100644 index 0000000000..efddd25b84 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/di/TemplateModule.kt @@ -0,0 +1,30 @@ +package com.itsaky.androidide.di + +import com.itsaky.androidide.repositories.TemplateRepository +import com.itsaky.androidide.repositories.TemplateRepositoryImpl +import com.itsaky.androidide.utils.Environment +import com.itsaky.androidide.viewmodels.TemplateManagerViewModel +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module + +/** + * Koin module for template-related dependencies + */ +val templateModule = + module { + + // Repository + single { + TemplateRepositoryImpl( + templatesDir = Environment.TEMPLATES_DIR, + downloadDir = Environment.DOWNLOAD_DIR, + ) + } + + // ViewModel + viewModel { + TemplateManagerViewModel( + templateRepository = get(), + ) + } + } diff --git a/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt b/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt index 3d27258387..1fa28ebf90 100644 --- a/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt +++ b/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt @@ -7,50 +7,49 @@ import android.content.Context import android.net.Uri import android.view.DragEvent import androidx.core.net.toUri +import com.itsaky.androidide.utils.fileProviderAuthority /** * Checks if the [DragEvent] contains any URIs that can be imported into the project. */ fun DragEvent.hasImportableContent(context: Context): Boolean { - if (localState != null) return false - - return when (action) { - DragEvent.ACTION_DROP -> { - val clip = clipData ?: return false - (0 until clip.itemCount).any { index -> - clip.getItemAt(index).toImportableExternalUris(context).isNotEmpty() - } - } - - else -> clipDescription?.hasImportableMimeType() == true - } + if (localState != null) return false + + return when (action) { + DragEvent.ACTION_DROP -> { + val clip = clipData ?: return false + (0 until clip.itemCount).any { index -> + clip.getItemAt(index).toImportableExternalUris(context).isNotEmpty() + } + } + + else -> { + clipDescription?.hasImportableMimeType() == true + } + } } /** * Resolves the [ClipData.Item] to a list of external [Uri]s, ignoring internal application URIs. */ -fun ClipData.Item.toImportableExternalUris(context: Context): List { - return toExternalUris().filterNot { it.isInternalDragUri(context) } -} +fun ClipData.Item.toImportableExternalUris(context: Context): List = toExternalUris().filterNot { it.isInternalDragUri(context) } -private fun Uri.isInternalDragUri(context: Context): Boolean { - return authority == "${context.packageName}.providers.fileprovider" -} +private fun Uri.isInternalDragUri(context: Context): Boolean = authority == context.fileProviderAuthority() private fun ClipData.Item.toExternalUris(): List { - uri?.let { return listOf(it) } + uri?.let { return listOf(it) } - val textContent = text?.toString() ?: return emptyList() + val textContent = text?.toString() ?: return emptyList() - return textContent.lineSequence() - .map { it.trim() } - .map { it.toUri() } - .filter { it.scheme == ContentResolver.SCHEME_CONTENT || it.scheme == ContentResolver.SCHEME_FILE } - .toList() + return textContent + .lineSequence() + .map { it.trim() } + .map { it.toUri() } + .filter { it.scheme == ContentResolver.SCHEME_CONTENT || it.scheme == ContentResolver.SCHEME_FILE } + .toList() } -private fun ClipDescription.hasImportableMimeType(): Boolean { - return hasMimeType(ClipDescription.MIMETYPE_TEXT_URILIST) || - hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) || - hasMimeType("*/*") -} +private fun ClipDescription.hasImportableMimeType(): Boolean = + hasMimeType(ClipDescription.MIMETYPE_TEXT_URILIST) || + hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) || + hasMimeType("*/*") diff --git a/app/src/main/java/com/itsaky/androidide/dnd/FileDragStarter.kt b/app/src/main/java/com/itsaky/androidide/dnd/FileDragStarter.kt index cb30e0c79f..7498137eb0 100644 --- a/app/src/main/java/com/itsaky/androidide/dnd/FileDragStarter.kt +++ b/app/src/main/java/com/itsaky/androidide/dnd/FileDragStarter.kt @@ -5,89 +5,97 @@ import android.content.Context import android.net.Uri import android.view.View import android.webkit.MimeTypeMap -import androidx.core.content.FileProvider import androidx.core.view.ViewCompat +import com.itsaky.androidide.utils.fileProviderUriFor import java.io.File import java.util.Locale sealed interface FileDragResult { - data object Started : FileDragResult - data class Failed(val error: FileDragError) : FileDragResult + data object Started : FileDragResult + + data class Failed( + val error: FileDragError, + ) : FileDragResult } sealed interface FileDragError { - data object FileNotFound : FileDragError - data object NotAFile : FileDragError - data object SystemRejected : FileDragError - data class Exception(val throwable: Throwable) : FileDragError + data object FileNotFound : FileDragError + + data object NotAFile : FileDragError + + data object SystemRejected : FileDragError + + data class Exception( + val throwable: Throwable, + ) : FileDragError } class FileDragStarter( - private val context: Context, + private val context: Context, ) { + fun startDrag( + sourceView: View, + file: File, + ): FileDragResult { + if (!file.exists()) { + return FileDragResult.Failed(FileDragError.FileNotFound) + } - fun startDrag(sourceView: View, file: File): FileDragResult { - if (!file.exists()) { - return FileDragResult.Failed(FileDragError.FileNotFound) - } - - if (!file.isFile) { - return FileDragResult.Failed(FileDragError.NotAFile) - } - - return runCatching { - val contentUri = buildContentUri(file) - val mimeType = resolveMimeType(file) - val clipData = buildClipData(file, contentUri, mimeType) - val dragShadow = View.DragShadowBuilder(sourceView) + if (!file.isFile) { + return FileDragResult.Failed(FileDragError.NotAFile) + } - ViewCompat.startDragAndDrop( - sourceView, - clipData, - dragShadow, - null, - DRAG_FLAGS, - ) - }.fold( - onSuccess = { started -> - if (started) FileDragResult.Started - else FileDragResult.Failed(FileDragError.SystemRejected) - }, - onFailure = { throwable -> - FileDragResult.Failed(FileDragError.Exception(throwable)) - }, - ) - } + return runCatching { + val contentUri = buildContentUri(file) + val mimeType = resolveMimeType(file) + val clipData = buildClipData(file, contentUri, mimeType) + val dragShadow = View.DragShadowBuilder(sourceView) - private fun buildContentUri(file: File): Uri { - return FileProvider.getUriForFile(context, fileProviderAuthority, file) - } + ViewCompat.startDragAndDrop( + sourceView, + clipData, + dragShadow, + null, + DRAG_FLAGS, + ) + }.fold( + onSuccess = { started -> + if (started) { + FileDragResult.Started + } else { + FileDragResult.Failed(FileDragError.SystemRejected) + } + }, + onFailure = { throwable -> + FileDragResult.Failed(FileDragError.Exception(throwable)) + }, + ) + } - private fun resolveMimeType(file: File): String { - val extension = file.extension.lowercase(Locale.ROOT) - return MimeTypeMap.getSingleton() - .getMimeTypeFromExtension(extension) - ?: DEFAULT_MIME_TYPE - } + private fun buildContentUri(file: File): Uri = context.fileProviderUriFor(file) - private fun buildClipData( - file: File, - contentUri: Uri, - mimeType: String, - ): ClipData { - return ClipData( - file.name, - arrayOf(mimeType), - ClipData.Item(contentUri), - ) - } + private fun resolveMimeType(file: File): String { + val extension = file.extension.lowercase(Locale.ROOT) + return MimeTypeMap + .getSingleton() + .getMimeTypeFromExtension(extension) + ?: DEFAULT_MIME_TYPE + } - private val fileProviderAuthority: String - get() = "${context.packageName}.providers.fileprovider" + private fun buildClipData( + file: File, + contentUri: Uri, + mimeType: String, + ): ClipData = + ClipData( + file.name, + arrayOf(mimeType), + ClipData.Item(contentUri), + ) - private companion object { - private const val DEFAULT_MIME_TYPE = "application/octet-stream" - private const val DRAG_FLAGS = - View.DRAG_FLAG_GLOBAL or View.DRAG_FLAG_GLOBAL_URI_READ - } + private companion object { + private const val DEFAULT_MIME_TYPE = "application/octet-stream" + private const val DRAG_FLAGS = + View.DRAG_FLAG_GLOBAL or View.DRAG_FLAG_GLOBAL_URI_READ + } } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/onboarding/PermissionsFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/onboarding/PermissionsFragment.kt index 750b6ac2fb..577d77529f 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/onboarding/PermissionsFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/onboarding/PermissionsFragment.kt @@ -23,12 +23,13 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.provider.Settings +import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.animation.Animation import android.view.animation.AnimationUtils import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.net.toUri +import androidx.appcompat.app.AlertDialog import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.repeatOnLifecycle @@ -37,26 +38,32 @@ import androidx.recyclerview.widget.RecyclerView import com.github.appintro.SlidePolicy import com.github.appintro.SlideSelectionListener import com.google.android.material.button.MaterialButton -import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.itsaky.androidide.R import com.itsaky.androidide.activities.OnboardingActivity import com.itsaky.androidide.adapters.onboarding.OnboardingPermissionsAdapter +import com.itsaky.androidide.app.DeviceProtectedApplicationLoader +import com.itsaky.androidide.app.IDEApplication import com.itsaky.androidide.buildinfo.BuildInfo +import com.itsaky.androidide.databinding.LayoutDialogPrivacyConsentBinding import com.itsaky.androidide.databinding.LayoutOnboardingPermissionsBinding import com.itsaky.androidide.events.InstallationEvent -import com.itsaky.androidide.preferences.internal.prefManager +import com.itsaky.androidide.preferences.internal.StatPreferences +import com.itsaky.androidide.preferences.internal.TelemetryConsent import com.itsaky.androidide.tasks.doAsyncWithProgress +import com.itsaky.androidide.utils.DialogUtils import com.itsaky.androidide.utils.OverlayPermissionGuide import com.itsaky.androidide.utils.PermissionsHelper import com.itsaky.androidide.utils.flashError -import com.itsaky.androidide.utils.isTestMode import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.utils.isAtLeastR +import com.itsaky.androidide.utils.isTestMode import com.itsaky.androidide.utils.viewLifecycleScope +import com.itsaky.androidide.utils.viewLifecycleScopeOrNull import com.itsaky.androidide.viewmodel.InstallationState import com.itsaky.androidide.viewmodel.InstallationViewModel import io.sentry.Sentry import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -74,7 +81,7 @@ class PermissionsFragment : private var permissionsBinding: LayoutOnboardingPermissionsBinding? = null private var recyclerView: RecyclerView? = null private var finishButton: MaterialButton? = null - private lateinit var pulseAnimation: Animation + private lateinit var pulseAnimation: Animation private val storagePermissionRequestLauncher = registerForActivityResult( @@ -94,9 +101,12 @@ class PermissionsFragment : PermissionsHelper.getRequiredPermissions(requireContext()) } + private var privacyDialog: AlertDialog? = null + private var consentResolutionJob: Job? = null + private var isSlideSelected = false + companion object { private val logger = LoggerFactory.getLogger(PermissionsFragment::class.java) - private const val KEY_PRIVACY_DISCLOSURE_SHOWN = "privacy.disclosure.shown" private var awaitingOverlayGrantResult = false @@ -152,14 +162,17 @@ class PermissionsFragment : override fun onResume() { super.onResume() - (activity as? OnboardingActivity)?.setOnboardingChromeVisible(false) + (activity as? OnboardingActivity)?.setOnboardingChromeVisible(false) onPermissionsUpdated() + if (isSlideSelected) { + showPrivacyDialogIfNeeded() + } } - override fun onPause() { - (activity as? OnboardingActivity)?.setOnboardingChromeVisible(true) - super.onPause() - } + override fun onPause() { + (activity as? OnboardingActivity)?.setOnboardingChromeVisible(true) + super.onPause() + } private fun observeViewModelState() { viewLifecycleScope.launch { @@ -176,7 +189,10 @@ class PermissionsFragment : viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.events.collect { event -> when (event) { - is InstallationEvent.ShowError -> activity?.flashError(event.message) + is InstallationEvent.ShowError -> { + activity?.flashError(event.message) + } + is InstallationEvent.InstallationResultEvent -> {} } } @@ -189,18 +205,22 @@ class PermissionsFragment : is InstallationState.InstallationPending -> { disableFinishButton() } + is InstallationState.InstallationGranted -> { enableFinishButton() } + is InstallationState.Installing -> { - disableFinishButton() + disableFinishButton() } + is InstallationState.InstallationComplete -> { finishButton?.text = getString(R.string.finish_installation) activity?.flashSuccess(getString(R.string.ide_setup_complete)) } + is InstallationState.InstallationError -> { - enableFinishButton() + enableFinishButton() finishButton?.text = getString(R.string.finish_installation) } } @@ -208,6 +228,9 @@ class PermissionsFragment : override fun onDestroyView() { super.onDestroyView() + privacyDialog?.dismiss() + privacyDialog = null + consentResolutionJob = null permissionsBinding = null recyclerView = null finishButton = null @@ -228,20 +251,20 @@ class PermissionsFragment : viewModel.onPermissionsUpdated(allGranted) } - private fun handlePostOverlayPermissionState() { - if (!awaitingOverlayGrantResult) { - return - } - awaitingOverlayGrantResult = false - - viewLifecycleScope.launch { - viewLifecycleOwner.withResumed { - if (!PermissionsHelper.canDrawOverlays(requireContext())) { - OverlayPermissionGuide.showRestrictedSettingsDialog(requireContext()) - } - } - } - } + private fun handlePostOverlayPermissionState() { + if (!awaitingOverlayGrantResult) { + return + } + awaitingOverlayGrantResult = false + + viewLifecycleScope.launch { + viewLifecycleOwner.withResumed { + if (!PermissionsHelper.canDrawOverlays(requireContext())) { + OverlayPermissionGuide.showRestrictedSettingsDialog(requireContext()) + } + } + } + } private fun startIdeSetup() { viewLifecycleScope.launch { @@ -261,13 +284,14 @@ class PermissionsFragment : builder.title(getString(R.string.ide_setup_in_progress)) }, ) { flashbar, _ -> - val progressJob = launch(Dispatchers.Main) { - viewModel.installationProgress.collect { progress -> - if (progress.isNotEmpty()) { - flashbar.flashbarView.setMessage(progress) + val progressJob = + launch(Dispatchers.Main) { + viewModel.installationProgress.collect { progress -> + if (progress.isNotEmpty()) { + flashbar.flashbarView.setMessage(progress) + } } } - } viewModel.startIdeSetup(requireContext()) @@ -280,8 +304,14 @@ class PermissionsFragment : } true } - is InstallationState.InstallationError -> true - else -> false + + is InstallationState.InstallationError -> { + true + } + + else -> { + false + } } } } finally { @@ -293,34 +323,44 @@ class PermissionsFragment : private fun requestPermission(permission: String) { when (permission) { - Manifest.permission_group.STORAGE -> requestStoragePermission() - Manifest.permission.REQUEST_INSTALL_PACKAGES -> + Manifest.permission_group.STORAGE -> { + requestStoragePermission() + } + + Manifest.permission.REQUEST_INSTALL_PACKAGES -> { requestSettingsTogglePermission( Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, ) + } - Manifest.permission.SYSTEM_ALERT_WINDOW -> requestOverlayPermission() - Manifest.permission.POST_NOTIFICATIONS -> + Manifest.permission.SYSTEM_ALERT_WINDOW -> { + requestOverlayPermission() + } + + Manifest.permission.POST_NOTIFICATIONS -> { requestSettingsTogglePermission( Settings.ACTION_APP_NOTIFICATION_SETTINGS, setData = false, ) + } } } - private fun requestOverlayPermission() { - val state = PermissionsHelper.getOverlayPermissionState(requireContext()) + private fun requestOverlayPermission() { + val state = PermissionsHelper.getOverlayPermissionState(requireContext()) - when (state) { - PermissionsHelper.OverlayPermissionState.UNSUPPORTED -> { - flashError(getString(R.string.permission_overlay_unsupported_hint)) - } - PermissionsHelper.OverlayPermissionState.REQUESTABLE -> { - awaitingOverlayGrantResult = requestSettingsTogglePermission(Settings.ACTION_MANAGE_OVERLAY_PERMISSION) - } - PermissionsHelper.OverlayPermissionState.GRANTED -> {} - } - } + when (state) { + PermissionsHelper.OverlayPermissionState.UNSUPPORTED -> { + flashError(getString(R.string.permission_overlay_unsupported_hint)) + } + + PermissionsHelper.OverlayPermissionState.REQUESTABLE -> { + awaitingOverlayGrantResult = requestSettingsTogglePermission(Settings.ACTION_MANAGE_OVERLAY_PERMISSION) + } + + PermissionsHelper.OverlayPermissionState.GRANTED -> {} + } + } private fun requestStoragePermission() { if (isAtLeastR()) { @@ -367,57 +407,68 @@ class PermissionsFragment : } override fun onSlideSelected() { - if (!isPrivacyDisclosureShown()) { - showPrivacyDialog() - } + isSlideSelected = true + showPrivacyDialogIfNeeded() } override fun onSlideDeselected() { + isSlideSelected = false } - private fun showPrivacyDialog() { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(com.itsaky.androidide.resources.R.string.privacy_disclosure_title) - .setMessage(com.itsaky.androidide.resources.R.string.privacy_disclosure_message) - .setPositiveButton(com.itsaky.androidide.resources.R.string.privacy_disclosure_accept) { dialog, _ -> - markPrivacyDisclosureAsShown() - dialog.dismiss() - } - .setNeutralButton(com.itsaky.androidide.resources.R.string.privacy_disclosure_learn_more) { _, _ -> - openPrivacyPolicy() - markPrivacyDisclosureAsShown() + private fun showPrivacyDialogIfNeeded() { + if (privacyDialog != null || consentResolutionJob?.isActive == true) { + return + } + + val scope = viewLifecycleScopeOrNull ?: return + consentResolutionJob = + scope.launch { + val consent = withContext(Dispatchers.IO) { StatPreferences.telemetryConsent } + if (consent == TelemetryConsent.UNSET && privacyDialog == null) { + showPrivacyDialog() + } } - .setCancelable(false) - .show() } - private fun isPrivacyDisclosureShown(): Boolean { - return prefManager.getBoolean(KEY_PRIVACY_DISCLOSURE_SHOWN, false) - } + private fun showPrivacyDialog() { + val builder = DialogUtils.newMaterialDialogBuilder(requireContext()) + + // Inflate from the builder's themed context so the dialog-scoped attributes the + // layout refers to (body text style, preferred padding) resolve. + val binding = LayoutDialogPrivacyConsentBinding.inflate(LayoutInflater.from(builder.context)) + + val dialog = + builder + .setTitle(com.itsaky.androidide.resources.R.string.privacy_disclosure_title) + .setView(binding.root) + .setCancelable(false) + .create() + + binding.privacyAccept.setOnClickListener { + StatPreferences.telemetryConsent = TelemetryConsent.GRANTED + DeviceProtectedApplicationLoader.onTelemetryConsentGranted(IDEApplication.instance) + dialog.dismiss() + } - private fun markPrivacyDisclosureAsShown() { - prefManager.putBoolean(KEY_PRIVACY_DISCLOSURE_SHOWN, true) + binding.privacyDecline.setOnClickListener { + StatPreferences.telemetryConsent = TelemetryConsent.DECLINED + Sentry.close() + dialog.dismiss() + } + + privacyDialog = dialog + dialog.show() } - private fun openPrivacyPolicy() { - try { - val privacyPolicyUrl = getString(R.string.privacy_policy_url) - val intent = Intent(Intent.ACTION_VIEW, privacyPolicyUrl.toUri()) - startActivity(intent) - } catch (e: Exception) { - Sentry.captureException(e) + private fun enableFinishButton() { + finishButton?.isEnabled = true + if (!isTestMode()) { + finishButton?.startAnimation(pulseAnimation) } } - private fun enableFinishButton() { - finishButton?.isEnabled = true - if (!isTestMode()) { - finishButton?.startAnimation(pulseAnimation) - } - } - - private fun disableFinishButton() { - finishButton?.isEnabled = false - finishButton?.clearAnimation() - } + private fun disableFinishButton() { + finishButton?.isEnabled = false + finishButton?.clearAnimation() + } } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt index b38ea58fb8..db8aa1c5c5 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt @@ -45,6 +45,7 @@ import com.itsaky.androidide.utils.viewLifecycleScope import com.itsaky.androidide.viewmodel.LogViewModel import io.github.rosemoe.sora.widget.style.CursorAnimator import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -62,6 +63,9 @@ abstract class LogViewFragment : WrappableOutputFragment { companion object { private val log = LoggerFactory.getLogger(LogViewFragment::class.java) + + /** Max time to wait for the editor's layout pass before falling back to a re-sync. */ + private const val LAYOUT_TIMEOUT_MS = 2000L } override val currentEditor: IDEEditor? get() = _binding?.editor @@ -183,12 +187,16 @@ abstract class LogViewFragment : } private suspend fun observeLogs() { - // Wait for the editor's first layout pass. The sora-editor's + // Give the editor a chance at its first layout pass. The sora-editor's // LineBreakLayout populates its line-width tracker asynchronously after // layout; appending before that races BlockIntList.set on an empty list. - _binding?.editor?.awaitLayout( - onForceVisible = { emptyStateViewModel.setEmpty(false) }, - ) + _binding?.editor?.let { editor -> + withTimeoutOrNull(LAYOUT_TIMEOUT_MS) { + editor.awaitLayout( + onForceVisible = { emptyStateViewModel.setEmpty(false) }, + ) + } + } viewModel.uiEvents.collect { event -> when (event) { @@ -277,16 +285,29 @@ abstract class LogViewFragment : onContentReplaced() } - @UiThread - private fun append(chars: CharSequence?) { + private suspend fun append(chars: CharSequence?) { if (chars == null) { return } val editor = _binding?.editor ?: return - if (!editor.isReadyToAppend) return - editor.appendBatch(chars.toString()) - emptyStateViewModel.setEmpty(false) + + // Flip to the content child BEFORE waiting for layout + updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) + + val laidOut = + withTimeoutOrNull(LAYOUT_TIMEOUT_MS) { + editor.awaitLayout( + onForceVisible = { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) }, + ) + } + + if (laidOut != null && editor.appendBatch(chars.toString())) { + return + } else { + log.warn("Editor append failed; requesting log re-sync") + viewModel.resync() + } } @UiThread diff --git a/app/src/main/java/com/itsaky/androidide/handlers/FileTreeActionHandler.kt b/app/src/main/java/com/itsaky/androidide/handlers/FileTreeActionHandler.kt index 82309f0c9f..690aa71373 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/FileTreeActionHandler.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/FileTreeActionHandler.kt @@ -26,6 +26,7 @@ import com.itsaky.androidide.actions.ActionMenu import com.itsaky.androidide.actions.ActionsRegistry import com.itsaky.androidide.actions.internal.DefaultActionsRegistry import com.itsaky.androidide.activities.editor.EditorHandlerActivity +import com.itsaky.androidide.app.IDEApplication import com.itsaky.androidide.eventbus.events.filetree.FileClickEvent import com.itsaky.androidide.eventbus.events.filetree.FileLongClickEvent import com.itsaky.androidide.events.CollapseTreeNodeRequestEvent @@ -34,12 +35,13 @@ import com.itsaky.androidide.events.FileContextMenuItemClickEvent import com.itsaky.androidide.events.FileContextMenuItemLongClickEvent import com.itsaky.androidide.fragments.sheets.OptionsListFragment import com.itsaky.androidide.idetooltips.TooltipManager -import com.itsaky.androidide.app.IDEApplication import com.itsaky.androidide.models.SheetOption import com.itsaky.androidide.plugins.extensions.FileTabMenuItem import com.itsaky.androidide.utils.flashError import com.unnamed.b.atv.model.TreeNode import kotlinx.coroutines.launch +import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION +import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode.MAIN @@ -52,153 +54,157 @@ import java.io.File */ @Suppress("unused") class FileTreeActionHandler : BaseEventHandler() { - - private var lastHeld: TreeNode? = null - - companion object { - - const val TAG_FILE_OPTIONS_FRAGMENT = "file_options_fragment" - const val MB_10: Long = 10 * 1024 * 1024 - } - - @Subscribe(threadMode = MAIN) - fun onFileClicked(event: FileClickEvent) { - if (!checkIsEditorActivity(event)) { - logCannotHandle(event) - return - } - - if (event.file.isDirectory) { - return - } - - val context = event[Context::class.java]!! as EditorHandlerActivity - context.binding.editorDrawerLayout.closeDrawer(GravityCompat.START) - - val isArchive = event.file.extension.lowercase() in setOf("apk", "cgp", "zip") - if (!isArchive && MB_10 < event.file.length()) { - flashError("File is too big!") - log.warn( - "Cannot open {} as it is too big. File size: {} bytes", event.file, event.file.length()) - return - } - - context.lifecycleScope.launch { - context.openFile(event.file) - } - } - - @Subscribe(threadMode = MAIN) - fun onFileLongClicked(event: FileLongClickEvent) { - if (!checkIsEditorActivity(event)) { - logCannotHandle(event) - return - } - - this.lastHeld = event[TreeNode::class.java] - val context = event[Context::class.java]!! as EditorHandlerActivity - createFileOptionsFragment(context, event.file) - .show(context.supportFragmentManager, TAG_FILE_OPTIONS_FRAGMENT) - } - - private fun createFileOptionsFragment( - context: EditorHandlerActivity, - file: File - ): OptionsListFragment { - val fragment = OptionsListFragment() - val registry = ActionsRegistry.getInstance() - val actions = registry.getActions(EDITOR_FILE_TREE) - val data = ActionData.create(context) - data.apply { - put(File::class.java, file) - put(TreeNode::class.java, lastHeld) - } - - for (action in actions.values) { - - check(action !is ActionMenu) { "File tree actions do not support action menus" } - - action.prepare(data) - if (!action.enabled || !action.visible) { - continue - } - - fragment.addOption( - SheetOption(action.id, action.icon, action.label, file).apply { this.extra = data } - ) - } - - IDEApplication.getPluginManager() - ?.getFileTabMenuItems(file) - ?.filter { it.isEnabled && it.isVisible } - ?.forEach { item -> - fragment.addOption(SheetOption("plugin.file.${item.id}", null, item.title, item)) - } - - return fragment - } - - @Subscribe(threadMode = MAIN) - internal fun onFileOptionClicked(event: FileContextMenuItemClickEvent) { - val option = event.option - if (option.extra is FileTabMenuItem) { - try { (option.extra as FileTabMenuItem).action() } catch (e: Exception) { log.error("Plugin file menu action failed", e) } - return - } - if (option.extra !is ActionData) { - return - } - - val data = option.extra!! as ActionData - val registry = ActionsRegistry.getInstance() as DefaultActionsRegistry - val action = registry.findAction(EDITOR_FILE_TREE, option.id) - - checkNotNull(action) { - "Invalid FileContextMenuItemClickEvent received. No action item registered with id '${option.id}'" - } - - registry.executeAction(action, data) - } - - @Subscribe(threadMode = MAIN) - internal fun onFileOptionLongClicked(event: FileContextMenuItemLongClickEvent) { - val option = event.option - val actionData = option.extra - if (actionData !is ActionData) { - return - } - - val registry = ActionsRegistry.getInstance() as DefaultActionsRegistry - val action = registry.findAction(EDITOR_FILE_TREE, option.id) - - checkNotNull(action) { - "Invalid FileContextMenuItemClickEvent received. No action item registered with id '${option.id}'" - } - val tag = action.retrieveTooltipTag(actionData.get(File::class.java)?.isDirectory == true) - tag.isNotEmpty() || return - val activity = event[Context::class.java] as? EditorHandlerActivity - activity?.let { act -> - TooltipManager.showIdeCategoryTooltip( - context = act, - anchorView = act.window.decorView, - tag = tag, - ) - } - } - - private fun requestExpandHeldNode() { - requestExpandNode(lastHeld!!) - } - - private fun requestCollapseHeldNode() { - requestCollapseNode(lastHeld!!, true) - } - - private fun requestExpandNode(node: TreeNode) { - EventBus.getDefault().post(ExpandTreeNodeRequestEvent(node)) - } - - private fun requestCollapseNode(node: TreeNode, includeSubnodes: Boolean) { - EventBus.getDefault().post(CollapseTreeNodeRequestEvent(node, includeSubnodes)) - } + private var lastHeld: TreeNode? = null + + companion object { + const val TAG_FILE_OPTIONS_FRAGMENT = "file_options_fragment" + const val MB_10: Long = 10 * 1024 * 1024 + } + + @Subscribe(threadMode = MAIN) + fun onFileClicked(event: FileClickEvent) { + if (!checkIsEditorActivity(event)) { + logCannotHandle(event) + return + } + + if (event.file.isDirectory) { + return + } + + val context = event[Context::class.java]!! as EditorHandlerActivity + context.binding.editorDrawerLayout.closeDrawer(GravityCompat.START) + + val isArchive = event.file.extension.lowercase() in setOf("apk", PLUGIN_ARCHIVE_EXTENSION, TEMPLATE_ARCHIVE_EXTENSION, "zip") + if (!isArchive && MB_10 < event.file.length()) { + flashError("File is too big!") + log.warn("Cannot open {} as it is too big. File size: {} bytes", event.file, event.file.length()) + return + } + + context.lifecycleScope.launch { + context.openFile(event.file) + } + } + + @Subscribe(threadMode = MAIN) + fun onFileLongClicked(event: FileLongClickEvent) { + if (!checkIsEditorActivity(event)) { + logCannotHandle(event) + return + } + + this.lastHeld = event[TreeNode::class.java] + val context = event[Context::class.java]!! as EditorHandlerActivity + createFileOptionsFragment(context, event.file) + .show(context.supportFragmentManager, TAG_FILE_OPTIONS_FRAGMENT) + } + + private fun createFileOptionsFragment( + context: EditorHandlerActivity, + file: File, + ): OptionsListFragment { + val fragment = OptionsListFragment() + val registry = ActionsRegistry.getInstance() + val actions = registry.getActions(EDITOR_FILE_TREE) + val data = ActionData.create(context) + data.apply { + put(File::class.java, file) + put(TreeNode::class.java, lastHeld) + } + + for (action in actions.values) { + check(action !is ActionMenu) { "File tree actions do not support action menus" } + + action.prepare(data) + if (!action.enabled || !action.visible) { + continue + } + + fragment.addOption( + SheetOption(action.id, action.icon, action.label, file).apply { this.extra = data }, + ) + } + + IDEApplication + .getPluginManager() + ?.getFileTabMenuItems(file) + ?.filter { it.isEnabled && it.isVisible } + ?.forEach { item -> + fragment.addOption(SheetOption("plugin.file.${item.id}", null, item.title, item)) + } + + return fragment + } + + @Subscribe(threadMode = MAIN) + internal fun onFileOptionClicked(event: FileContextMenuItemClickEvent) { + val option = event.option + if (option.extra is FileTabMenuItem) { + try { + (option.extra as FileTabMenuItem).action() + } catch (e: Exception) { + log.error("Plugin file menu action failed", e) + } + return + } + if (option.extra !is ActionData) { + return + } + + val data = option.extra!! as ActionData + val registry = ActionsRegistry.getInstance() as DefaultActionsRegistry + val action = registry.findAction(EDITOR_FILE_TREE, option.id) + + checkNotNull(action) { + "Invalid FileContextMenuItemClickEvent received. No action item registered with id '${option.id}'" + } + + registry.executeAction(action, data) + } + + @Subscribe(threadMode = MAIN) + internal fun onFileOptionLongClicked(event: FileContextMenuItemLongClickEvent) { + val option = event.option + val actionData = option.extra + if (actionData !is ActionData) { + return + } + + val registry = ActionsRegistry.getInstance() as DefaultActionsRegistry + val action = registry.findAction(EDITOR_FILE_TREE, option.id) + + checkNotNull(action) { + "Invalid FileContextMenuItemClickEvent received. No action item registered with id '${option.id}'" + } + val tag = action.retrieveTooltipTag(actionData.get(File::class.java)?.isDirectory == true) + tag.isNotEmpty() || return + val activity = event[Context::class.java] as? EditorHandlerActivity + activity?.let { act -> + TooltipManager.showIdeCategoryTooltip( + context = act, + anchorView = act.window.decorView, + tag = tag, + ) + } + } + + private fun requestExpandHeldNode() { + requestExpandNode(lastHeld!!) + } + + private fun requestCollapseHeldNode() { + requestCollapseNode(lastHeld!!, true) + } + + private fun requestExpandNode(node: TreeNode) { + EventBus.getDefault().post(ExpandTreeNodeRequestEvent(node)) + } + + private fun requestCollapseNode( + node: TreeNode, + includeSubnodes: Boolean, + ) { + EventBus.getDefault().post(CollapseTreeNodeRequestEvent(node, includeSubnodes)) + } } diff --git a/app/src/main/java/com/itsaky/androidide/handlers/GlitchTipDiagnosticsContext.kt b/app/src/main/java/com/itsaky/androidide/handlers/GlitchTipDiagnosticsContext.kt index 09e151b921..19c59e7a5f 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/GlitchTipDiagnosticsContext.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/GlitchTipDiagnosticsContext.kt @@ -20,6 +20,7 @@ package com.itsaky.androidide.handlers import android.content.pm.ApplicationInfo import android.os.SystemClock +import com.itsaky.androidide.analytics.AttachedDevicesCollector import com.itsaky.androidide.app.IDEApplication import com.itsaky.androidide.app.configuration.IDEBuildConfigProvider import com.itsaky.androidide.buildinfo.BuildInfo @@ -180,6 +181,18 @@ object GlitchTipDiagnosticsContext { mapOf("count" to plugins.size, "plugins" to plugins) } + context(event, "attached_devices") { + val snapshot = AttachedDevicesCollector.collect(app) + mapOf( + "mouse_count" to snapshot.mouseCount, + "external_keyboard_count" to snapshot.externalKeyboardCount, + "touchpad_count" to snapshot.touchpadCount, + "stylus_count" to snapshot.stylusCount, + "gamepad_count" to snapshot.gamepadCount, + "external_display_count" to snapshot.externalDisplayCount, + ) + } + // A — release identifier + version code. tag(event, "app_version_name") { BuildInfo.VERSION_NAME_SIMPLE } tag(event, "app_version_code") { IDEApplication.instance.getAppVersionCode().toString() } diff --git a/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt b/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt index 7ef3ac76e3..a978a8f286 100644 --- a/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt +++ b/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt @@ -2,798 +2,1091 @@ package com.itsaky.androidide.localWebServer import android.database.Cursor import android.database.sqlite.SQLiteDatabase +import android.net.TrafficStats +import android.os.Environment.getExternalStorageDirectory +import com.aayushatharva.brotli4j.Brotli4jLoader import com.aayushatharva.brotli4j.decoder.BrotliInputStream +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.ToNumberPolicy +import com.google.gson.reflect.TypeToken import com.itsaky.androidide.utils.DatabaseVersionResolver +import io.pebbletemplates.pebble.PebbleEngine +import io.pebbletemplates.pebble.loader.StringLoader +import io.pebbletemplates.pebble.template.PebbleTemplate +import okio.ByteString.Companion.toByteString import org.slf4j.LoggerFactory import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File +import java.io.IOException import java.io.InputStream import java.io.PrintWriter +import java.io.SequenceInputStream import java.io.StringWriter -import android.net.TrafficStats import java.net.InetSocketAddress import java.net.ServerSocket import java.net.Socket import java.net.URLDecoder +import java.nio.ByteBuffer import java.sql.Date import java.text.SimpleDateFormat +import java.util.Collections import java.util.Locale +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicReference -import io.pebbletemplates.pebble.PebbleEngine -import io.pebbletemplates.pebble.loader.StringLoader -import java.util.concurrent.ConcurrentHashMap -import io.pebbletemplates.pebble.template.PebbleTemplate -import android.os.Environment.getExternalStorageDirectory -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import com.google.gson.ToNumberPolicy -import com.google.gson.reflect.TypeToken -import okio.ByteString.Companion.toByteString - data class ServerConfig( - val port: Int = 6174, - val databasePath: String, - val fileDirPath: String, - val bindName: String = "localhost", - val debugDatabasePath: String = getExternalStorageDirectory().toString() + - "/Download/documentation.db", - val debugEnablePath: String = getExternalStorageDirectory().toString() + - "/Download/CodeOnTheGo.webserver.debug", - val experimentsEnablePath: String = getExternalStorageDirectory().toString() + - "/Download/CodeOnTheGo.exp", // TODO: Centralize this concept. --DS, 9-Feb-2026 - val clearCacheEnablePath: String = getExternalStorageDirectory().toString() + - "/Download/CodeOnTheGo.webserver.cs0", - -// Yes, this is hack code. - val projectDatabasePath: String = "/data/data/com.itsaky.androidide/databases/RecentProject_database" + val port: Int = 6174, + val databasePath: String, + val fileDirPath: String, + val bindName: String = "localhost", + val debugDatabasePath: String = + getExternalStorageDirectory().toString() + + "/Download/documentation.db", + val debugEnablePath: String = + getExternalStorageDirectory().toString() + + "/Download/CodeOnTheGo.webserver.debug", + val experimentsEnablePath: String = + getExternalStorageDirectory().toString() + + "/Download/CodeOnTheGo.exp", + // TODO: Centralize this concept. --DS, 9-Feb-2026 + val clearCacheEnablePath: String = + getExternalStorageDirectory().toString() + + "/Download/CodeOnTheGo.webserver.cs0", + // Yes, this is hack code. + val projectDatabasePath: String = "/data/data/com.itsaky.androidide/databases/RecentProject_database", ) data class JavaExecutionResult( - val compileOutput: String, - val runOutput: String, - val timedOut: Boolean, - val compileTimeMs: Long, - val timeoutLimit: Long + val compileOutput: String, + val runOutput: String, + val timedOut: Boolean, + val compileTimeMs: Long, + val timeoutLimit: Long, ) -class WebServer(private val config: ServerConfig) { - private lateinit var serverSocket : ServerSocket - private lateinit var database : SQLiteDatabase - private var databaseTimestamp : Long = -1 - private val log = LoggerFactory.getLogger(WebServer::class.java) - private val debugEnabled : Boolean = File(config.debugEnablePath).exists() - // TODO: Use the centralized experiments flag instead of this ad-hoc check. --DS, 10-Feb-2026 - private val experimentsEnabled : Boolean = File(config.experimentsEnablePath).exists() // Frozen at startup. Restart server if needed. - private val clearCacheEnabled : Boolean = File(config.clearCacheEnablePath).exists() // Frozen at startup. Restart server if needed. - private val encodingHeader : String = "Accept-Encoding" - private val brotliCompression : String = "br" - private val pebbleEngine = PebbleEngine.Builder().loader(StringLoader()).build() - private val templateCache = ConcurrentHashMap() - private val gson: Gson = GsonBuilder() - .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) - .create() - private val dbContextType = object : TypeToken>() {}.type - private var bookshelfTemplateId : Int = -1; - private val HTTP_INTERNAL_SERVER_ERROR = 500 - private val HTTP_NOT_FOUND = 404 - - private val contentChunkSize = 1024 * 1024 - - - //function to obtain the last modified date of a documentation.db database - // this is used to see if there is a newer version of the database on the sdcard - fun getDatabaseTimestamp(pathname: String, silent: Boolean = false): Long { - val dbFile = File(pathname) - var timestamp: Long = -1 - - if (dbFile.exists()) { - timestamp = dbFile.lastModified() - - if (!silent) { - val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) - - if (debugEnabled) log.debug("{} was last modified at {}.", pathname, dateFormat.format(Date(timestamp))) - } - } - - return timestamp - } - - fun logDatabaseLastChanged() { - try { - log.debug("Database last change: {}.", DatabaseVersionResolver.resolveDatabaseVersion(database)) - } catch (e: Exception) { - log.error("Could not retrieve database last change info: {}", e.message) - } - } - - /** - * Stops the server by closing the listening socket. Safe to call from any thread. - * Causes [start]'s accept loop to exit. No-op if not started or already stopped. - */ - fun stop() { - if (!::serverSocket.isInitialized) return - try { - serverSocket.close() - - } catch (e: Exception) { - log.error("Cannot close server socket: {}", e.message) - } - } - - fun start() { - // Hal Eisen: Required to fix StrictMode.VmPolicy.Builder.detectUntaggedSockets() - TrafficStats.setThreadStatsTag(0xC0DE) - try { - log.info( - "Starting WebServer on {}, port {}, debugEnabled={}, debugEnablePath='{}', debugDatabasePath='{}', experimentsEnabled={}, experimentsEnablePath='{}'.", - config.bindName, - config.port, - debugEnabled, - config.debugEnablePath, - config.debugDatabasePath, - experimentsEnabled, - config.experimentsEnablePath - ) - - databaseTimestamp = getDatabaseTimestamp(config.databasePath) - - try { - database = SQLiteDatabase.openDatabase(config.databasePath, null, SQLiteDatabase.OPEN_READONLY) - } catch (e: Exception) { - log.error("Cannot open database: {}", e.message) - return - } - - // NEW FEATURE: Log database metadata when debug is enabled - if (debugEnabled) logDatabaseLastChanged() - - serverSocket = ServerSocket().apply { reuseAddress = true } - serverSocket.bind(InetSocketAddress(config.bindName, config.port)) - log.info("WebServer started successfully on '{}', port {}.", config.bindName, config.port) - - while (true) { - var clientSocket: Socket? = null - try { - try { - if (debugEnabled) log.debug("About to call accept() on the server socket, {}.", serverSocket) - clientSocket = serverSocket.accept() - - if (debugEnabled) log.debug("Returned from socket accept(), clientSocket is {}.", clientSocket) - - } catch (e: java.net.SocketException) { - if (debugEnabled) log.debug("Caught java.net.SocketException '$e'.") // SLF4J placeholders produce wrong formatting here. --DS, 23-Feb-2026 - - if (e.message?.contains("Closed", ignoreCase = true) == true) { - if (debugEnabled) log.debug("WebServer socket closed, shutting down.") - break - } - log.error("Accept() failed: {}", e.message) - continue - } - try { - clientSocket?.let { handleClient(it) } - - } catch (e: Exception) { - if (debugEnabled) log.debug("Caught exception '$e'.") // SLF4J placeholders produce wrong formatting here. --DS, 23-Feb-2026 - - if (e is java.net.SocketException && e.message?.contains("Closed", ignoreCase = true) == true) { - if (debugEnabled) log.debug("Client disconnected: {}", e.message) - - } else { - log.error("Error handling client: {}", e.message) - clientSocket?.let { socket -> - try { - val output = socket.outputStream - - sendError(PrintWriter(output, true), output, HTTP_INTERNAL_SERVER_ERROR, "Internal Server Error 1") - - } catch (e2: Exception) { - log.error("Error sending error response: {}", e2.message) - } - } - } - } - - } finally { - clientSocket?.close() - - // CodeRabbit objects to the following line because clientSocket may print out as "null." This is intentional. --DS - if (debugEnabled) log.debug("clientSocket was {}.", clientSocket) - } - } - - } catch (e: Exception) { - log.error("Error: {}", e.message) - - } finally { - if (::serverSocket.isInitialized) { - serverSocket.close() - } - TrafficStats.clearThreadStatsTag() - } - } - - /** - * Reads a single line from the stream (bytes until newline). Same stream is used for headers - * and body so POST body bytes are not lost to a separate buffered reader. HTTP header lines are ASCII. - */ - private fun readLineFromStream(input: InputStream): String? { - val baos = ByteArrayOutputStream() - while (true) { - val b = input.read() - if (b == -1) return if (baos.size() == 0) null else baos.toString(Charsets.ISO_8859_1).trimEnd('\r') - if (b == '\n'.code) break - baos.write(b) - } - val bytes = baos.toByteArray() - val len = if (bytes.isNotEmpty() && bytes[bytes.size - 1] == '\r'.code.toByte()) bytes.size - 1 else bytes.size - return String(bytes, 0, len, Charsets.ISO_8859_1) - } - - private fun handleClient(clientSocket: Socket) { - if (debugEnabled) log.debug("In handleClient(), socket is {}.", clientSocket) - - val input = clientSocket.getInputStream() - if (debugEnabled) log.debug(" input is {}.", input) - - val output = clientSocket.getOutputStream() - if (debugEnabled) log.debug(" output is {}.", output) - - val writer = PrintWriter(output, true) - if (debugEnabled) log.debug(" writer is {}.", writer) - - var brotliSupported = false //assume nothing - - // Read the request method line, it is always the first line of the request - var requestLine = readLineFromStream(input) - if (requestLine == null) { - if (debugEnabled) log.debug("requestLine is null. Returning from handleClient() early.") - return - } - if (debugEnabled) log.debug("Request is {}", requestLine) - - // Parse the request - // Request line should look like "GET /a/b/c.html HTTP/1.1" - val parts = requestLine.split(" ") - if (parts.size != 3) { - return sendError(writer, output, 400, "Bad Request") - } - - //extract the request method (e.g. GET, POST, PUT) - val method = parts[0] - var path = parts[1].split("?")[0] // Discard any HTTP query parameters. - path = path.substring(1) - - // Read all headers until blank line (needed for Content-Length on POST and Accept-Encoding on GET) - val headers = mutableMapOf() - while (true) { - requestLine = readLineFromStream(input) ?: break - if (requestLine.isEmpty()) break - if (debugEnabled) log.debug("Header: {}", requestLine) - val colon = requestLine.indexOf(':') - if (colon > 0) { - headers[requestLine.substring(0, colon).trim().lowercase()] = requestLine.substring(colon + 1).trim() - } - } - brotliSupported = headers["accept-encoding"]?.contains(brotliCompression) == true - - // Playground endpoint: POST only, handled before GET-only check - if (false && path == "playground/execute") { - return handlePlaygroundExecute(input, writer, output, method, headers) - } - - // we only support teh GET method, return an error page for anything else - if (method != "GET") { - return sendError(writer, output, 501, "Not Implemented") - } - - //check to see if there is a newer version of the documentation.db database on the sdcard - // if there is use that for our responses - val debugDatabaseTimestamp = getDatabaseTimestamp(config.debugDatabasePath, true) - if (debugDatabaseTimestamp > databaseTimestamp) { - bookshelfTemplateId = -1 - database.close() - database = SQLiteDatabase.openDatabase(config.debugDatabasePath, null, SQLiteDatabase.OPEN_READONLY) - databaseTimestamp = debugDatabaseTimestamp - } - - // Handle the special "pr" endpoint with highest priority - if (path.startsWith("pr/", false)) { - if (debugEnabled) log.debug("Found a pr/ path, '{}'.", path) - - return when (path) { - "pr/bs" -> handleBsEndpoint(writer, output) - "pr/db" -> handleDbEndpoint(writer, output) - "pr/pr" -> handlePrEndpoint(writer, output) - "pr/ex" -> handleExEndpoint(writer, output) - else -> sendError(writer, output, HTTP_NOT_FOUND, "Not Found", "Path requested: '$path'.") - } - } - - // Database fetch - val query = """ - SELECT C.content, CT.value, CT.compression, C.templateId - FROM Content C, ContentTypes CT - WHERE C.contentTypeID = CT.id - AND C.path = ? - """ - val cursor = database.rawQuery(query, arrayOf(path)) - - // Process database fetch - try { - if (cursor.count != 1) { - return if (cursor.count == 0) sendError(writer, output, HTTP_NOT_FOUND, "Not Found") - else sendError(writer, output, HTTP_INTERNAL_SERVER_ERROR, "Corrupt database - multiple records found when unique record expected, Path requested: '$path'.") - } - - cursor.moveToFirst() - var dbContent = cursor.getBlob(0) - val dbMimeType = cursor.getString(1) - var compression = cursor.getString(2) - val templateId = cursor.getInt(3) - - // Fragment handling for large content (> 1MB) - if (dbContent.size == contentChunkSize) { - val query2 = "SELECT content FROM Content WHERE path = ? AND languageId = 1" - var fragmentNumber = 1 - val combined = ByteArrayOutputStream().apply {write(dbContent)} - var dbContent2 = dbContent - while (dbContent2.size == contentChunkSize) { - val path2 = "$path-$fragmentNumber" - val cursor2 = database.rawQuery(query2, arrayOf(path2)) - try { - if (cursor2.moveToFirst()) { - dbContent2 = cursor2.getBlob(0) - combined.write(dbContent2) - fragmentNumber++ - } else break - } finally { cursor2.close() } - } - dbContent = combined.toByteArray() - } - - // If a document is stored in brotli form and the client doesn't support that encoding - // decompress and send that to the client. - // Pebble templates have to be in string form so the retrieved database content may need to be - // decompressed. - if (compression == "brotli" && (!brotliSupported || templateId > 0)) { - dbContent = BrotliInputStream(ByteArrayInputStream(dbContent)).use { it.readBytes() } - compression = "none" - } else if (compression == "brotli") { - compression = "br" - } - - // If the file is associated with a template, instantiate that template and send the result to the client - if (templateId > 0) { - dbContent = instantiatePebbleTemplate(templateId, dbContent, path, dbMimeType, compression) - } - - writer.println("HTTP/1.1 200 OK") - writer.println("Content-Type: $dbMimeType") - writer.println("Content-Length: ${dbContent.size}") - if (compression != "none") writer.println("Content-Encoding: $compression") - writer.println("Connection: close") - writer.println() - writer.flush() - output.write(dbContent) - output.flush() - } catch (e: Exception) { - log.error("Error processing request: {}", e.message) - sendError(writer, output, HTTP_INTERNAL_SERVER_ERROR, "Internal Server Error", e.message ?: "") - } finally { - cursor.close() - } - } - - /** - * Renders a Pebble template identified by `templateId` using the provided JSON data and returns the rendered output as bytes. - * - * @param templateId The database ID of the Pebble template to load and compile. - * @param dbContent JSON bytes that will be parsed and supplied as the template context. - * @param path The request/content path associated with this template (used for diagnostic/logging purposes). - * @param dbMimeType The MIME type of the stored content (used for diagnostic/logging purposes). - * @param compression The compression label of the stored content (e.g., "br", "none") (used for diagnostic/logging purposes). - * @return The rendered template encoded as UTF-8 bytes. - * @throws Exception If the template ID is not found, is duplicated in the database, or if template lookup/instantiation fails. - */ - private fun instantiatePebbleTemplate(templateId: Int, dbContent: ByteArray, path: String, dbMimeType: String, compression: String): ByteArray { - if (debugEnabled) log.debug("Processing template for templateId={}", templateId) - - // 1. Get or Compile Template from Cache - val compiledTemplate = templateCache.getOrPut(templateId) { - if (debugEnabled) log.debug( - "Template cache miss for ID {}, path {}, MIME type {}, compression {}}", - templateId, - path, - dbMimeType, - compression - ) - - val tQuery = "SELECT content FROM Templates WHERE id = ?" - val tCursor = database.rawQuery(tQuery, arrayOf(templateId.toString())) - tCursor.use { cursor -> - when { - cursor.count == 0 -> { - log.debug( - "Template not found, for ID {}, path {}, MIME type {}, compression {}", - templateId, - path, - dbMimeType, - compression - ) - throw Exception("Template ID $templateId not found in the database") - } - cursor.count > 1 -> { - log.debug( - "More than one template found, for ID {}, path {}, MIME type {}, compression {}", - templateId, - path, - dbMimeType, - compression - ) - throw Exception("Template ID $templateId is shared by more than one template") - } - !cursor.moveToFirst() -> { - log.debug( - "Template not found, for ID {}, path {}, MIME type {}, compression {}", - templateId, - path, - dbMimeType, - compression - ) - throw Exception("Template ID $templateId not found in database.") - } - else -> { - val templateBlob = cursor.getBlob(0) - if (debugEnabled) log.debug("templateBlob = '${String(templateBlob)}'") - pebbleEngine.getTemplate(templateBlob.toString(Charsets.UTF_8)) - } - } - } - } - - // Load JSON data into a template context Map<> for instantiation - val dbContentStr = dbContent.toString(Charsets.UTF_8) - if (dbContentStr.isBlank() || dbContentStr.trim() == "null") - throw Exception("Template ID $templateId has empty or null JSON context") - val context: Map = gson.fromJson(dbContentStr, dbContextType) - - // Evaluate template with loaded data and return the output - val sw = StringWriter() - compiledTemplate.evaluate(sw, context) - return sw.toString().toByteArray() - } - - - /** - * Serve an HTML page showing the 20 most recent rows of the `LastChange` table. - * - * Queries the table schema to determine column names, selects the latest 20 rows - * ordered by `changeTime`, escapes cell values for HTML, assembles an HTML table, - * and writes a normal 200 HTML response to the client. On database or rendering - * errors a 500 error response is sent. All database cursors are closed before returning. - */ - private fun handleDbEndpoint(writer: PrintWriter, output: java.io.OutputStream) { - if (debugEnabled) log.debug("Entering handleDbEndpoint().") - - var html : String - - try { - // First, get the schema of the LastChange table to determine column count - val schemaQuery = "PRAGMA table_info(LastChange)" - val schemaCursor = database.rawQuery(schemaQuery, arrayOf()) - - var columnCount: Int - var selectColumns: String - - html = getTableHtml("LastChange Table", "LastChange Table (20 Most Recent Rows)") - - try { - columnCount = schemaCursor.count - val columnNames = mutableListOf() - - while (schemaCursor.moveToNext()) { - // Values come from schema introspection, therefore not subject to a SQL injection attack. - columnNames.add(schemaCursor.getString(1)) // Column name is at index 1 - } - - if (debugEnabled) log.debug( - "LastChange table has {} columns: {}", - columnCount, - columnNames - ) - - // Build the SELECT query for the 20 most recent rows - selectColumns = columnNames.joinToString(", ") - - // Add header row - html += """""" - for (columnName in columnNames) { - html += """${escapeHtml(columnName)}""" - } - html += """""" - - } finally { - schemaCursor.close() - } - - val dataQuery = - "SELECT $selectColumns FROM LastChange ORDER BY changeTime DESC LIMIT 20" - - val dataCursor = database.rawQuery(dataQuery, arrayOf()) - - try { - val rowCount = dataCursor.count - - if (debugEnabled) log.debug("Retrieved {} rows from LastChange table", rowCount) - - // Add data rows - while (dataCursor.moveToNext()) { - html += """""" - for (i in 0 until columnCount) { - html += """${escapeHtml(dataCursor.getString(i) ?: "")}""" - } - html += """""" - } - - html += """""" - - } finally { - dataCursor.close() - } - - if (debugEnabled) log.debug("html is '{}'.", html) - } catch (e: Exception) { - log.error("Error creating output for /pr/db endpoint: {}", e.message) - sendError( - writer, - output, - HTTP_INTERNAL_SERVER_ERROR, - "Internal Server Error 4.1", - "Error creating output." - ) - return - } - - try { - writeNormalToClient(writer, output, html) - - if (debugEnabled) log.debug("Leaving handleDbEndpoint().") - - } catch (e: Exception) { - log.error("Error handling /pr/db endpoint: {}", e.message) - sendError(writer, output, HTTP_INTERNAL_SERVER_ERROR, "Internal Server Error 4", "Error generating database table.", true) - } - } - - /** - * Handles the /pr/bs endpoint by invoking the bookshelf generator and sending a 500 error if generation fails. - * - * Calls realHandleBsEndpoint to produce and write the response body; if an exception occurs, sends an HTTP 500 - * error using the reported output-start state so no additional headers/body are written after output has begun. - * - * @param writer PrintWriter used for writing textual HTTP response headers. - * @param output Raw OutputStream used for writing the response body bytes. - */ - private fun handleBsEndpoint(writer: PrintWriter, output: java.io.OutputStream) { - if (debugEnabled) log.debug("Entering handleBsEndpoint().") - if(clearCacheEnabled) templateCache.clear() - - var outputStarted = false - - try { - - outputStarted = realHandleBsEndpoint(writer, output) - - } catch (e: Exception) { - log.error("Error handling /pr/bs endpoint: {}", e.message) - sendError(writer, output, HTTP_INTERNAL_SERVER_ERROR, "Internal Server Error 6", "Error generating bookshelf HTML.", outputStarted) - } - - if (debugEnabled) log.debug("Leaving handleBsEndpoint().") - } - - - /** - * Writes a small CSS response that shows or hides elements with the - * `.code_on_the_go_experiment` class depending on the server's - * `experimentsEnabled` flag. - */ - private fun handleExEndpoint(writer: PrintWriter, output: java.io.OutputStream) { - val flag = if (experimentsEnabled) "{}" else "{display: none;}" - - if (debugEnabled) log.debug("Experiment flag='{}'.", flag) - - sendCSS(writer, output, ".code_on_the_go_experiment $flag") - } - - /** - * Handle the /pr/pr endpoint by opening the project database, delegating page generation to realHandlePrEndpoint, and sending an HTTP 500 error if generation fails. - * - * @param writer PrintWriter used to write response headers. - * @param output OutputStream used to write response body bytes. - */ - private fun handlePrEndpoint(writer: PrintWriter, output: java.io.OutputStream) { - if (debugEnabled) log.debug("Entering handlePrEndpoint().") - - var projectDatabase : SQLiteDatabase? = null - var outputStarted = false - - try { - projectDatabase = SQLiteDatabase.openDatabase(config.projectDatabasePath, - null, - SQLiteDatabase.OPEN_READONLY) - - /* I disagree with CodeRabbit's message, reproduced below. However, the - IDE's "Problems" window says that outputStarted is "always false." - - While writeNormalToClient() can fail in the middle of execution, - making the error reporting code more complicated is likely to - introduce more bugs, rather than helping fix existing ones. --DS, 23-Feb-2026 - - 482-494: ⚠️ Potential issue | 🟡 Minor - -outputStarted is set too late to protect error handling. - -If writeNormalToClient throws after headers are written, outputStarted remains false and the catch path will send a second response. Set/propagate this flag before the first write (e.g., via a mutable flag passed into realHandlePrEndpoint or by setting it just before writeNormalToClient and preserving it on exceptions). - -Also applies to: 502-557 - -🤖 Prompt for AI Agents - -Verify each finding against the current code and only fix it if needed. - -In `@app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt` around -lines 482 - 494, The catch block can send a second response because -outputStarted is only set after realHandlePrEndpoint returns; ensure the -"response started" flag is set before any write occurs by changing -realHandlePrEndpoint to accept and update a mutable flag (e.g., pass a -BooleanWrapper/MutableBoolean or an AtomicBoolean named outputStarted into -realHandlePrEndpoint) or by setting outputStarted immediately before the first -call to writeNormalToClient inside realHandlePrEndpoint; then have -realHandlePrEndpoint update that flag as soon as headers/body begin to be -written so sendError(writer, ...) checks the accurate flag and avoids sending a -second response. - */ - outputStarted = realHandlePrEndpoint(writer, output, projectDatabase) - - } catch (e: Exception) { - log.error("Error handling /pr/pr endpoint: {}", e.message) - sendError(writer, output, HTTP_INTERNAL_SERVER_ERROR, "Internal Server Error 6", "Error generating database table.", outputStarted) - - } finally { - projectDatabase?.close() - } - - if (debugEnabled) log.debug("Leaving handlePrEndpoint().") - } - - /** - * Builds the Bookshelf content, renders it with the `bookshelf` template, and sends the resulting response to the client. - * - * @param writer PrintWriter for sending HTTP headers and control output. - * @param output OutputStream for writing the response body bytes. - * @return `true` if the templated response was written to the client, `false` if an error response was sent or no output was produced. - */ - private fun realHandleBsEndpoint(writer: PrintWriter, output: java.io.OutputStream) : Boolean { - if (debugEnabled) log.debug("Entering realHandleBsEndpoint().") - - // Database fetch - val sql_query = +/** + * Copies [bytes] into a direct [ByteBuffer] -- brotli4j's `attachDictionary` requires a direct + * buffer, a heap-backed one throws `IllegalArgumentException`. + * + * The capacity must be exactly [bytes]`.size`: `attachDictionary` reads the whole capacity and + * ignores position/limit, so trailing slack from an over-allocated buffer is treated as dictionary + * content and every decode then fails with `IOException: corrupted input`. + */ +internal fun toDirectByteBuffer(bytes: ByteArray): ByteBuffer = + ByteBuffer.allocateDirect(bytes.size).apply { + put(bytes) + flip() + } + +/** + * Reads [chunks] back to back as one stream, without concatenating them into a new array. + * Cheap to build twice, which the no-dictionary retry in `decompressBrotli` relies on. + */ +internal fun chunksAsStream(chunks: List): InputStream = + SequenceInputStream(Collections.enumeration(chunks.map { ByteArrayInputStream(it) })) + +/** + * Joins [chunks] into one exactly-sized array. A ByteArrayOutputStream would repeatedly double its + * buffer and then hand back a second full copy -- avoidable here since the total is known up front. + * Returns the sole element as-is when there is nothing to join. + */ +internal fun joinChunks(chunks: List): ByteArray { + if (chunks.size == 1) { + return chunks[0] + } + val joined = ByteArray(chunks.sumOf { it.size }) + var offset = 0 + for (chunk in chunks) { + chunk.copyInto(joined, offset) + offset += chunk.size + } + return joined +} + +class WebServer( + private val config: ServerConfig, +) { + // Guards serverSocket's creation/bind (in start(), on a background thread) against a + // concurrent close (in stop(), typically from the main thread on Activity#onDestroy()). + // Without this, a stop() arriving before start() reaches bind() finds serverSocket not + // yet initialized and is a silent no-op (see stop()'s isInitialized check below) -- the + // socket then binds anyway a moment later, orphaned, and holds the port until the process + // dies. The next start() attempt on that port then fails with "Address already in use." + private val lifecycleLock = Any() + private var stopRequested = false + private lateinit var serverSocket: ServerSocket + private lateinit var database: SQLiteDatabase + private var databaseTimestamp: Long = -1 + + // Timestamp of a debug database whose swap already failed, so a corrupt or unreadable one + // isn't reopened on every single request (it is checked per request). A newer copy has a + // different timestamp and is retried, which is the case that matters -- the developer + // replacing the file is exactly how they'd fix it. + private var failedDebugSwapTimestamp: Long = -1 + + // The shared dictionary Content's brotli-compressed rows are compressed against (see + // ADFA-5153). Lazily (re)loaded on demand, right before the first content fetch that needs + // it after `database` changes -- see compressionDictionaryStale -- rather than eagerly at + // database-open/swap time, but still cached (not reloaded per-request) once loaded for the + // currently active database. Null (no dictionary attached, plain-brotli decode) unless the + // active database declares MAJOR >= MAJOR_VERSION_WITH_COMPRESSION_DICTIONARY in ADFA-5220's + // version table. + private var compressionDictionary: ByteBuffer? = null + + // Set whenever `database` changes (see switchToDatabase); cleared once compressionDictionary + // has been (re)loaded for that database. Lets the dictionary stay lazily loaded -- only right + // before the first content fetch that actually needs it -- while still loading at most once + // per database change rather than once per request. + private var compressionDictionaryStale = true + private val log = LoggerFactory.getLogger(WebServer::class.java) + private val debugEnabled: Boolean = File(config.debugEnablePath).exists() + + // TODO: Use the centralized experiments flag instead of this ad-hoc check. --DS, 10-Feb-2026 + // Frozen at startup; restart the server to pick up a change. + private val experimentsEnabled: Boolean = File(config.experimentsEnablePath).exists() + + // Frozen at startup; restart the server to pick up a change. + private val clearCacheEnabled: Boolean = File(config.clearCacheEnablePath).exists() + private val pebbleEngine = PebbleEngine.Builder().loader(StringLoader()).build() + private val templateCache = ConcurrentHashMap() + private val gson: Gson = + GsonBuilder() + .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) + .create() + private val dbContextType = object : TypeToken>() {}.type + private var bookshelfTemplateId: Int = -1 + private val httpInternalServerError = 500 + private val httpNotFound = 404 + + private val contentChunkSize = 1024 * 1024 + + // function to obtain the last modified date of a documentation.db database + // this is used to see if there is a newer version of the database on the sdcard + fun getDatabaseTimestamp( + pathname: String, + silent: Boolean = false, + ): Long { + val dbFile = File(pathname) + var timestamp: Long = -1 + + if (dbFile.exists()) { + timestamp = dbFile.lastModified() + + if (!silent) { + val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + + if (debugEnabled) log.debug("{} was last modified at {}.", pathname, dateFormat.format(Date(timestamp))) + } + } + + return timestamp + } + + fun logDatabaseLastChanged() { + try { + log.debug("Database last change: {}.", DatabaseVersionResolver.resolveDatabaseVersion(database)) + } catch (e: Exception) { + log.error("Could not retrieve database last change info: {}", e.message) + } + } + + /** + * Loads the shared Brotli dictionary most Content rows are compressed against (see ADFA-5153). + * Returns null (logged) when the database *definitively* has no dictionary -- so callers fall + * back to plain, dictionary-free brotli decode (see [decompressBrotli]). + * + * The gate is the MAJOR version the database declares in ADFA-5220's version table, not the + * presence of a `CompressionDictionary` table: table sniffing infers a whole content format + * from one table's existence, and gets it wrong in both directions -- a database carrying the + * table but *unmigrated* content makes every plain row pay a failed dictionary decode before + * its plain one, on every request. Below + * [DatabaseVersionResolver.MAJOR_VERSION_WITH_COMPRESSION_DICTIONARY] the dictionary is neither + * read nor attached. + * + * The `CompressionDictionary` checks below still run, for a database that declares a new-enough + * version but has no usable dictionary row: without them the data query would raise "no such + * table", which the caller correctly reads as transient and would then retry on every request. + * + * Deliberately does *not* catch exceptions itself: an unexpected `SQLiteException`/IO failure is + * likely transient, and the caller (see [handleClient]) must not cache that as "no dictionary" + * the way it does a definitive absence, or a transient failure would permanently disable + * dictionary decoding for the rest of this database's lifetime. + */ + private fun loadCompressionDictionary(db: SQLiteDatabase): ByteBuffer? { + val majorVersion = DatabaseVersionResolver.resolveMajorVersion(db) + if (majorVersion == null || majorVersion < DatabaseVersionResolver.MAJOR_VERSION_WITH_COMPRESSION_DICTIONARY) { + log.warn( + "Database declares documentation version {}, below {}; decoding brotli content without a dictionary.", + majorVersion ?: "none", + DatabaseVersionResolver.MAJOR_VERSION_WITH_COMPRESSION_DICTIONARY, + ) + return null + } + + val tableExists = + db + .rawQuery( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'CompressionDictionary'", + null, + ).use { it.moveToFirst() } + if (!tableExists) { + log.warn("CompressionDictionary table not found; decoding brotli content without a dictionary.") + return null + } + + return db.rawQuery("SELECT data FROM CompressionDictionary WHERE id = 1", null).use { cursor -> + if (!cursor.moveToFirst()) { + log.warn("CompressionDictionary table is empty; decoding brotli content without a dictionary.") + return null + } + val bytes = cursor.getBlob(0) + if (bytes == null) { + log.warn("CompressionDictionary row has a NULL data column; decoding brotli content without a dictionary.") + return null + } + // An empty blob would yield a 0-capacity buffer, which attachDictionary rejects -- + // every row's dictionary decode would then fail with nothing above DEBUG to say why. + if (bytes.isEmpty()) { + log.warn("CompressionDictionary row has an empty data column; decoding brotli content without a dictionary.") + return null + } + toDirectByteBuffer(bytes) + } + } + + /** + * Opens [path] as the active database, refreshing every piece of state that depends on which + * database file is active -- [databaseTimestamp] and the per-database caches + * [bookshelfTemplateId]/[templateCache] -- as one atomic operation. Does *not* load + * [compressionDictionary] itself -- a different database can have a different dictionary (or + * none) -- it only marks [compressionDictionaryStale] so the next content fetch that needs it + * loads it lazily then (see [handleClient]), at most once per database change rather than + * once per request. Only closes the previous database once the new one has opened + * successfully, so a failed swap (this throws) leaves the previous, still-open database + * serving requests rather than leaving [database] referencing an already-closed handle. + */ + private fun switchToDatabase( + path: String, + timestamp: Long, + ) { + val newDatabase = SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY) + if (::database.isInitialized) { + try { + database.close() + } catch (e: Exception) { + log.error("Cannot close previous database: {}", e.message) + } + } + database = newDatabase + databaseTimestamp = timestamp + compressionDictionaryStale = true + bookshelfTemplateId = -1 + templateCache.clear() + } + + /** + * Loads brotli4j's native library if nothing else has yet, and turns its absence into a failed + * request rather than a dead app. + * + * Nothing here owns that load: it happens as a side effect of `AssetsInstallationHelper`'s + * install or `ToolsManager`'s tooling-jar update, neither of which runs on an ordinary cold + * start. A process that skips both -- Android restarting the app straight into the editor, say -- + * reaches the first brotli row with the natives unregistered, and `DecoderJNI.nativeCreate` + * raises `UnsatisfiedLinkError`. Being an Error rather than an Exception, that escapes + * [handleClient]'s catch and kills the app from a coroutine worker instead of failing one + * request (observed on-device, 20-Aug). + * + * Referencing [Brotli4jLoader] triggers the static init that performs the load, so this call is + * the warm-up; afterwards `ensureAvailability` is a single static null-check, cheap enough to + * leave on the per-decode path rather than tracking "warmed" state of our own. + */ + private fun ensureBrotliAvailable() { + try { + Brotli4jLoader.ensureAvailability() + } catch (e: UnsatisfiedLinkError) { + throw IOException("brotli4j's native library is unavailable, so brotli content cannot be decoded", e) + } + } + + /** + * Decompresses one Brotli-compressed Content row. Tries the shared dictionary first, since every + * ADFA-5153-migrated row requires it, then falls back to a plain decode for rows that were never + * dictionary-compressed: plugin-contributed Tier 3 docs (PluginDocumentationManager/BrotliCompressor + * compress with no dictionary) or any row served from a pre-migration database. Attaching a + * dictionary to a stream that wasn't compressed against one reliably fails to decode rather than + * silently producing wrong bytes (verified empirically -- see docs/documentation-database.md), so + * this ordering never lets a dictionary-compressed row fall through to the plain path by accident. + */ + private fun decompressBrotli(chunks: List): ByteArray { + ensureBrotliAvailable() + val dictionary = compressionDictionary + if (dictionary != null) { + try { + return BrotliInputStream(chunksAsStream(chunks)).use { stream -> + stream.attachDictionary(dictionary) + stream.readBytes() + } + } catch (e: IOException) { + log.debug( + "Dictionary decode failed for a brotli row (likely dictionary-free plugin content); retrying without a dictionary: {}", + e.message, + ) + } + } + return BrotliInputStream(chunksAsStream(chunks)).use { it.readBytes() } + } + + /** + * Stops the server by closing the listening socket. Safe to call from any thread. + * Causes [start]'s accept loop to exit. If [start] hasn't bound the socket yet -- + * including if it hasn't been called at all -- this still records that a stop was + * requested, so [start] aborts before binding instead of leaving an orphaned, + * unstoppable listener; only the socket-close side of shutdown is a no-op then. + */ + fun stop() { + synchronized(lifecycleLock) { + stopRequested = true + if (!::serverSocket.isInitialized) return + try { + serverSocket.close() + } catch (e: Exception) { + log.error("Cannot close server socket: {}", e.message) + } + } + } + + fun start() { + // Hal Eisen: Required to fix StrictMode.VmPolicy.Builder.detectUntaggedSockets() + TrafficStats.setThreadStatsTag(0xC0DE) + try { + log.info( + "Starting WebServer on {}, port {}, debugEnabled={}, debugEnablePath='{}', " + + "debugDatabasePath='{}', experimentsEnabled={}, experimentsEnablePath='{}'.", + config.bindName, + config.port, + debugEnabled, + config.debugEnablePath, + config.debugDatabasePath, + experimentsEnabled, + config.experimentsEnablePath, + ) + + try { + switchToDatabase(config.databasePath, getDatabaseTimestamp(config.databasePath)) + } catch (e: Exception) { + log.error("Cannot open database: {}", e.message) + return + } + + // NEW FEATURE: Log database metadata when debug is enabled + if (debugEnabled) logDatabaseLastChanged() + + synchronized(lifecycleLock) { + if (stopRequested) { + log.info("WebServer start() aborted: stop() was called before the socket could be bound.") + return + } + serverSocket = ServerSocket().apply { reuseAddress = true } + serverSocket.bind(InetSocketAddress(config.bindName, config.port)) + } + log.info("WebServer started successfully on '{}', port {}.", config.bindName, config.port) + + while (true) { + var clientSocket: Socket? = null + try { + try { + if (debugEnabled) log.debug("About to call accept() on the server socket, {}.", serverSocket) + clientSocket = serverSocket.accept() + + if (debugEnabled) log.debug("Returned from socket accept(), clientSocket is {}.", clientSocket) + } catch (e: java.net.SocketException) { + // SLF4J placeholders produce wrong formatting here. --DS, 23-Feb-2026 + if (debugEnabled) log.debug("Caught java.net.SocketException '$e'.") + + if (e.message?.contains("Closed", ignoreCase = true) == true) { + if (debugEnabled) log.debug("WebServer socket closed, shutting down.") + break + } + log.error("Accept() failed: {}", e.message) + continue + } + try { + clientSocket?.let { handleClient(it) } + } catch (e: Exception) { + // SLF4J placeholders produce wrong formatting here. --DS, 23-Feb-2026 + if (debugEnabled) log.debug("Caught exception '$e'.") + + if (e is java.net.SocketException && e.message?.contains("Closed", ignoreCase = true) == true) { + if (debugEnabled) log.debug("Client disconnected: {}", e.message) + } else { + log.error("Error handling client: {}", e.message) + clientSocket?.let { socket -> + try { + val output = socket.outputStream + + sendError(PrintWriter(output, true), output, httpInternalServerError, "Internal Server Error 1") + } catch (e2: Exception) { + log.error("Error sending error response: {}", e2.message) + } + } + } + } + } finally { + clientSocket?.close() + + // CodeRabbit objects to the following line because clientSocket may print out as "null." This is intentional. --DS + if (debugEnabled) log.debug("clientSocket was {}.", clientSocket) + } + } + } catch (e: Exception) { + log.error("Error: {}", e.message) + } finally { + if (::serverSocket.isInitialized) { + serverSocket.close() + } + // database is opened before the stopRequested check that can abort start() + // early (and before the accept loop on every other exit path), so it must be + // closed here too, not just serverSocket -- isInitialized guards the case + // where opening it above failed and this finally still runs. + if (::database.isInitialized) { + try { + database.close() + } catch (e: Exception) { + log.error("Cannot close database: {}", e.message) + } + } + TrafficStats.clearThreadStatsTag() + } + } + + /** + * Reads a single line from the stream (bytes until newline). Same stream is used for headers + * and body so POST body bytes are not lost to a separate buffered reader. HTTP header lines are ASCII. + */ + private fun readLineFromStream(input: InputStream): String? { + val baos = ByteArrayOutputStream() + while (true) { + val b = input.read() + if (b == -1) return if (baos.size() == 0) null else baos.toString(Charsets.ISO_8859_1).trimEnd('\r') + if (b == '\n'.code) break + baos.write(b) + } + val bytes = baos.toByteArray() + val len = if (bytes.isNotEmpty() && bytes[bytes.size - 1] == '\r'.code.toByte()) bytes.size - 1 else bytes.size + return String(bytes, 0, len, Charsets.ISO_8859_1) + } + + private fun handleClient(clientSocket: Socket) { + if (debugEnabled) log.debug("In handleClient(), socket is {}.", clientSocket) + + val input = clientSocket.getInputStream() + if (debugEnabled) log.debug(" input is {}.", input) + + val output = clientSocket.getOutputStream() + if (debugEnabled) log.debug(" output is {}.", output) + + val writer = PrintWriter(output, true) + if (debugEnabled) log.debug(" writer is {}.", writer) + + // Read the request method line, it is always the first line of the request + var requestLine = readLineFromStream(input) + if (requestLine == null) { + if (debugEnabled) log.debug("requestLine is null. Returning from handleClient() early.") + return + } + if (debugEnabled) log.debug("Request is {}", requestLine) + + // Parse the request + // Request line should look like "GET /a/b/c.html HTTP/1.1" + val parts = requestLine.split(" ") + if (parts.size != 3) { + return sendError(writer, output, 400, "Bad Request") + } + + // extract the request method (e.g. GET, POST, PUT) + val method = parts[0] + var path = parts[1].split("?")[0] // Discard any HTTP query parameters. + path = path.substring(1) + + // Read all headers until blank line (needed for Content-Length on POST) + val headers = mutableMapOf() + while (true) { + requestLine = readLineFromStream(input) ?: break + if (requestLine.isEmpty()) break + if (debugEnabled) log.debug("Header: {}", requestLine) + val colon = requestLine.indexOf(':') + if (colon > 0) { + headers[requestLine.substring(0, colon).trim().lowercase()] = requestLine.substring(colon + 1).trim() + } + } + + // Playground endpoint: POST only, handled before GET-only check + if (false && path == "playground/execute") { + return handlePlaygroundExecute(input, writer, output, method, headers) + } + + // we only support teh GET method, return an error page for anything else + if (method != "GET") { + return sendError(writer, output, 501, "Not Implemented") + } + + // check to see if there is a newer version of the documentation.db database on the sdcard + // if there is use that for our responses + val debugDatabaseTimestamp = getDatabaseTimestamp(config.debugDatabasePath, true) + if (debugDatabaseTimestamp > databaseTimestamp && debugDatabaseTimestamp != failedDebugSwapTimestamp) { + try { + switchToDatabase(config.debugDatabasePath, debugDatabaseTimestamp) + failedDebugSwapTimestamp = -1 + } catch (e: Exception) { + failedDebugSwapTimestamp = debugDatabaseTimestamp + log.error( + "Cannot swap to debug database '{}'; ignoring it until it changes: {}", + config.debugDatabasePath, + e.message, + ) + } + } + + // Handle the special "pr" endpoint with highest priority + if (path.startsWith("pr/", false)) { + if (debugEnabled) log.debug("Found a pr/ path, '{}'.", path) + + return when (path) { + "pr/bs" -> handleBsEndpoint(writer, output) + "pr/db" -> handleDbEndpoint(writer, output) + "pr/pr" -> handlePrEndpoint(writer, output) + "pr/ex" -> handleExEndpoint(writer, output) + else -> sendError(writer, output, httpNotFound, "Not Found", "Path requested: '$path'.") + } + } + + // Lazily (re)loaded here -- the one place the dictionary is actually consumed (see + // decompressBrotli) -- rather than eagerly at database-open/swap time, but only once per + // database change: a swap (just above) marks compressionDictionaryStale rather than + // reloading immediately, so this only hits the database again when that flag is set. + // Only clears the flag on a clean load (definitive dictionary or definitive absence) -- + // an unexpected exception leaves it set so the next request retries, rather than caching + // a transient failure as "no dictionary" for the rest of this database's lifetime. + if (compressionDictionaryStale) { + try { + compressionDictionary = loadCompressionDictionary(database) + compressionDictionaryStale = false + } catch (e: Exception) { + log.error("Could not load compression dictionary; will retry on the next request: {}", e.message) + } + } + + // Database fetch + val query = """ + SELECT C.content, CT.value, CT.compression, C.templateId + FROM Content C, ContentTypes CT + WHERE C.contentTypeID = CT.id + AND C.path = ? + """ + val cursor = database.rawQuery(query, arrayOf(path)) + + // Process database fetch + try { + if (cursor.count != 1) { + return if (cursor.count == 0) { + sendError(writer, output, httpNotFound, "Not Found") + } else { + sendError( + writer, + output, + httpInternalServerError, + "Corrupt database - multiple records found when unique record expected, Path requested: '$path'.", + ) + } + } + + cursor.moveToFirst() + val firstChunk = cursor.getBlob(0) + val dbMimeType = cursor.getString(1) + var compression = cursor.getString(2) + val templateId = cursor.getInt(3) + + // Fragment handling for large content (> 1MB). The chunks stay a list rather than + // being eagerly concatenated: the old accumulate-into-a-ByteArrayOutputStream-then-copy + // held both the doubling buffer and its toByteArray() copy of the *compressed* chunks + // live at once, on top of the decompressed output that follows -- for the largest + // bundled PDF (8.8 MB over 9 chunks) that's a real, if partial, reduction: the + // decompressed output still goes through a comparable accumulate-then-copy in + // decompressBrotli's own readBytes() call, so the compressed-side saving here doesn't + // eliminate that separate transient. + val chunks = mutableListOf(firstChunk) + if (firstChunk.size == contentChunkSize) { + val query2 = "SELECT content FROM Content WHERE path = ? AND languageId = 1" + var fragmentNumber = 1 + var nextChunk = firstChunk + while (nextChunk.size == contentChunkSize) { + val path2 = "$path-$fragmentNumber" + val cursor2 = database.rawQuery(query2, arrayOf(path2)) + try { + if (cursor2.moveToFirst()) { + nextChunk = cursor2.getBlob(0) + chunks.add(nextChunk) + fragmentNumber++ + } else { + break + } + } finally { + cursor2.close() + } + } + } + + // Content is compressed at rest with brotli -- most rows against the shared dictionary + // loaded into compressionDictionary (see ADFA-5153), but plugin-contributed Tier 3 docs + // (PluginDocumentationManager/BrotliCompressor) are plain brotli with no dictionary. + // This server always decompresses before responding, so it never needs to negotiate + // Content-Encoding with the client. + var dbContent = + if (compression == "brotli") { + compression = "none" + decompressBrotli(chunks) + } else { + joinChunks(chunks) + } + + // If the file is associated with a template, instantiate that template and send the result to the client + if (templateId > 0) { + dbContent = instantiatePebbleTemplate(templateId, dbContent, path, dbMimeType, compression) + } + + writer.println("HTTP/1.1 200 OK") + writer.println("Content-Type: $dbMimeType") + writer.println("Content-Length: ${dbContent.size}") + writer.println("Connection: close") + writer.println() + writer.flush() + output.write(dbContent) + output.flush() + } catch (e: Exception) { + log.error("Error processing request: {}", e.message) + sendError(writer, output, httpInternalServerError, "Internal Server Error", e.message ?: "") + } finally { + cursor.close() + } + } + + /** + * Renders a Pebble template identified by `templateId` using the provided JSON data and returns the rendered output as bytes. + * + * @param templateId The database ID of the Pebble template to load and compile. + * @param dbContent JSON bytes that will be parsed and supplied as the template context. + * @param path The request/content path associated with this template (used for diagnostic/logging purposes). + * @param dbMimeType The MIME type of the stored content (used for diagnostic/logging purposes). + * @param compression The compression label of the stored content (always "none" by this point, since decompression already happened) (used for diagnostic/logging purposes). + * @return The rendered template encoded as UTF-8 bytes. + * @throws Exception If the template ID is not found, is duplicated in the database, or if template lookup/instantiation fails. + */ + private fun instantiatePebbleTemplate( + templateId: Int, + dbContent: ByteArray, + path: String, + dbMimeType: String, + compression: String, + ): ByteArray { + if (debugEnabled) log.debug("Processing template for templateId={}", templateId) + + // 1. Get or Compile Template from Cache + val compiledTemplate = + templateCache.getOrPut(templateId) { + if (debugEnabled) { + log.debug( + "Template cache miss for ID {}, path {}, MIME type {}, compression {}}", + templateId, + path, + dbMimeType, + compression, + ) + } + + val tQuery = "SELECT content FROM Templates WHERE id = ?" + val tCursor = database.rawQuery(tQuery, arrayOf(templateId.toString())) + tCursor.use { cursor -> + when { + cursor.count == 0 -> { + log.debug( + "Template not found, for ID {}, path {}, MIME type {}, compression {}", + templateId, + path, + dbMimeType, + compression, + ) + throw Exception("Template ID $templateId not found in the database") + } + + cursor.count > 1 -> { + log.debug( + "More than one template found, for ID {}, path {}, MIME type {}, compression {}", + templateId, + path, + dbMimeType, + compression, + ) + throw Exception("Template ID $templateId is shared by more than one template") + } + + !cursor.moveToFirst() -> { + log.debug( + "Template not found, for ID {}, path {}, MIME type {}, compression {}", + templateId, + path, + dbMimeType, + compression, + ) + throw Exception("Template ID $templateId not found in database.") + } + + else -> { + val templateBlob = cursor.getBlob(0) + if (debugEnabled) log.debug("templateBlob = '${String(templateBlob)}'") + pebbleEngine.getTemplate(templateBlob.toString(Charsets.UTF_8)) + } + } + } + } + + // Load JSON data into a template context Map<> for instantiation + val dbContentStr = dbContent.toString(Charsets.UTF_8) + if (dbContentStr.isBlank() || dbContentStr.trim() == "null") { + throw Exception("Template ID $templateId has empty or null JSON context") + } + val context: Map = gson.fromJson(dbContentStr, dbContextType) + + // Evaluate template with loaded data and return the output + val sw = StringWriter() + compiledTemplate.evaluate(sw, context) + return sw.toString().toByteArray() + } + + /** + * Serve an HTML page showing the 20 most recent rows of the `LastChange` table. + * + * Queries the table schema to determine column names, selects the latest 20 rows + * ordered by `changeTime`, escapes cell values for HTML, assembles an HTML table, + * and writes a normal 200 HTML response to the client. On database or rendering + * errors a 500 error response is sent. All database cursors are closed before returning. + */ + private fun handleDbEndpoint( + writer: PrintWriter, + output: java.io.OutputStream, + ) { + if (debugEnabled) log.debug("Entering handleDbEndpoint().") + + var html: String + + try { + // First, get the schema of the LastChange table to determine column count + val schemaQuery = "PRAGMA table_info(LastChange)" + val schemaCursor = database.rawQuery(schemaQuery, arrayOf()) + + var columnCount: Int + var selectColumns: String + + html = getTableHtml("LastChange Table", "LastChange Table (20 Most Recent Rows)") + + try { + columnCount = schemaCursor.count + val columnNames = mutableListOf() + + while (schemaCursor.moveToNext()) { + // Values come from schema introspection, therefore not subject to a SQL injection attack. + columnNames.add(schemaCursor.getString(1)) // Column name is at index 1 + } + + if (debugEnabled) { + log.debug( + "LastChange table has {} columns: {}", + columnCount, + columnNames, + ) + } + + // Build the SELECT query for the 20 most recent rows + selectColumns = columnNames.joinToString(", ") + + // Add header row + html += """""" + for (columnName in columnNames) { + html += """${escapeHtml(columnName)}""" + } + html += """""" + } finally { + schemaCursor.close() + } + + val dataQuery = + "SELECT $selectColumns FROM LastChange ORDER BY changeTime DESC LIMIT 20" + + val dataCursor = database.rawQuery(dataQuery, arrayOf()) + + try { + val rowCount = dataCursor.count + + if (debugEnabled) log.debug("Retrieved {} rows from LastChange table", rowCount) + + // Add data rows + while (dataCursor.moveToNext()) { + html += """""" + for (i in 0 until columnCount) { + html += """${escapeHtml(dataCursor.getString(i) ?: "")}""" + } + html += """""" + } + + html += """""" + } finally { + dataCursor.close() + } + + if (debugEnabled) log.debug("html is '{}'.", html) + } catch (e: Exception) { + log.error("Error creating output for /pr/db endpoint: {}", e.message) + sendError( + writer, + output, + httpInternalServerError, + "Internal Server Error 4.1", + "Error creating output.", + ) + return + } + + try { + writeNormalToClient(writer, output, html) + + if (debugEnabled) log.debug("Leaving handleDbEndpoint().") + } catch (e: Exception) { + log.error("Error handling /pr/db endpoint: {}", e.message) + sendError(writer, output, httpInternalServerError, "Internal Server Error 4", "Error generating database table.", true) + } + } + + /** + * Handles the /pr/bs endpoint by invoking the bookshelf generator and sending a 500 error if generation fails. + * + * Calls realHandleBsEndpoint to produce and write the response body; if an exception occurs, sends an HTTP 500 + * error using the reported output-start state so no additional headers/body are written after output has begun. + * + * @param writer PrintWriter used for writing textual HTTP response headers. + * @param output Raw OutputStream used for writing the response body bytes. + */ + private fun handleBsEndpoint( + writer: PrintWriter, + output: java.io.OutputStream, + ) { + if (debugEnabled) log.debug("Entering handleBsEndpoint().") + if (clearCacheEnabled) templateCache.clear() + + var outputStarted = false + + try { + outputStarted = realHandleBsEndpoint(writer, output) { outputStarted = true } + } catch (e: Exception) { + log.error("Error handling /pr/bs endpoint: {}", e.message) + sendError(writer, output, httpInternalServerError, "Internal Server Error 6", "Error generating bookshelf HTML.", outputStarted) + } + + if (debugEnabled) log.debug("Leaving handleBsEndpoint().") + } + + /** + * Writes a small CSS response that shows or hides elements with the + * `.code_on_the_go_experiment` class depending on the server's + * `experimentsEnabled` flag. + */ + private fun handleExEndpoint( + writer: PrintWriter, + output: java.io.OutputStream, + ) { + val flag = if (experimentsEnabled) "{}" else "{display: none;}" + + if (debugEnabled) log.debug("Experiment flag='{}'.", flag) + + sendCSS(writer, output, ".code_on_the_go_experiment $flag") + } + + /** + * Handle the /pr/pr endpoint by opening the project database, delegating page generation + * to realHandlePrEndpoint, and sending an HTTP 500 error if generation fails. + * + * @param writer PrintWriter used to write response headers. + * @param output OutputStream used to write response body bytes. + */ + private fun handlePrEndpoint( + writer: PrintWriter, + output: java.io.OutputStream, + ) { + if (debugEnabled) log.debug("Entering handlePrEndpoint().") + + var projectDatabase: SQLiteDatabase? = null + var outputStarted = false + + try { + projectDatabase = + SQLiteDatabase.openDatabase( + config.projectDatabasePath, + null, + SQLiteDatabase.OPEN_READONLY, + ) + + outputStarted = realHandlePrEndpoint(writer, output, projectDatabase) { outputStarted = true } + } catch (e: Exception) { + log.error("Error handling /pr/pr endpoint: {}", e.message) + sendError(writer, output, httpInternalServerError, "Internal Server Error 6", "Error generating database table.", outputStarted) + } finally { + projectDatabase?.close() + } + + if (debugEnabled) log.debug("Leaving handlePrEndpoint().") + } + + /** + * Builds the Bookshelf content, renders it with the `bookshelf` template, and sends the resulting response to the client. + * + * @param writer PrintWriter for sending HTTP headers and control output. + * @param output OutputStream for writing the response body bytes. + * @param markOutputStarted Invoked right before the first response byte is written, so the + * caller's "did we already respond" flag is accurate even if the write itself then fails + * partway through -- not just after this function returns. + * @return `true` if the templated response was written to the client, `false` if an error response was sent or no output was produced. + */ + private fun realHandleBsEndpoint( + writer: PrintWriter, + output: java.io.OutputStream, + markOutputStarted: () -> Unit, + ): Boolean { + if (debugEnabled) log.debug("Entering realHandleBsEndpoint().") + + // Database fetch + val sqlQuery = """ SELECT '{"result" : [' || group_concat(Item) || ']}' FROM ( - SELECT - JSON_OBJECT( - 'category', IFNULL(BC.category, 'General'), - 'description', BC.description, - 'books', JSON_GROUP_ARRAY(JSON_OBJECT( - 'title', IFNULL(B.title, C.path), - 'description', B.description, - 'link', C.path, - 'pdf', IIF(SUBSTR(C.path, -4) == '.pdf', 1, 0) ) - ) - ) AS Item - FROM Content AS C, - Bookshelf AS B, - BookCategories AS BC - WHERE C.id = B.contentID - AND B.bookCategoryID = BC.id - GROUP BY BC.category - ORDER BY BC.category, - B.title +SELECT + JSON_OBJECT( + 'category', IFNULL(BC.category, 'General'), + 'description', BC.description, + 'books', JSON_GROUP_ARRAY(JSON_OBJECT( + 'title', IFNULL(B.title, C.path), + 'description', B.description, + 'link', C.path, + 'pdf', IIF(SUBSTR(C.path, -4) == '.pdf', 1, 0) ) + ) + ) AS Item +FROM Content AS C, + Bookshelf AS B, + BookCategories AS BC +WHERE C.id = B.contentID +AND B.bookCategoryID = BC.id +GROUP BY BC.category +ORDER BY BC.category, + B.title ); """.trimIndent() - var cursor = database.rawQuery(sql_query, arrayOf()) - lateinit var jsonText : ByteArray - - // Process database fetch - try { - if(!isCursorOneRow(cursor, writer, output)) { - return false - } - - //get the JSON from the bookshelf table - cursor.moveToFirst() - jsonText = cursor.getBlob(0) - if (debugEnabled) log.debug("json content = '${String(jsonText)}'.") - if (debugEnabled) log.debug("before fetch bookshelf template ID = '${bookshelfTemplateId}'") - - //Have we already fetched the template - if (bookshelfTemplateId == -1) { - /* safety first, close the cursor */ - cursor.close() - cursor = database.rawQuery("SELECT id FROM Templates WHERE name = 'bookshelf'", arrayOf()) - - if (!isCursorOneRow(cursor, writer, output)) { - return false - } - - cursor.moveToFirst() - bookshelfTemplateId = cursor.getInt(0); - if (debugEnabled) log.debug("after the fetch bookshelf template ID = '${bookshelfTemplateId}'") - - } - - } catch (e: Exception) { - log.error("Error processing request: {}", e.message) - sendError(writer, output, HTTP_INTERNAL_SERVER_ERROR, "Internal Server Error", e.message ?: "") - return false - } finally { - cursor.close() - } - - val result = instantiatePebbleTemplate(bookshelfTemplateId, jsonText, "/bookshelf", "application/json", "none") - - if (debugEnabled) log.debug("Bookshelf result is '{}'.", String(result)) - - writeNormalToClient(writer, output, String(result)) - - if (debugEnabled) log.debug("Leaving realHandleBsEndpoint().") - - return true - } - - - private fun isCursorOneRow(cursor: Cursor, writer: PrintWriter, output: java.io.OutputStream) : Boolean { - if (cursor.count == 1) { - return true - } - if (cursor.count == 0) - sendError(writer, output, HTTP_NOT_FOUND, "Corrupt database, no rows found, expected one.") - else - sendError(writer, output, HTTP_INTERNAL_SERVER_ERROR, "Corrupt database - found ${cursor.count} rows when 1 was expected.") - return false - } - - - /** - * Builds an HTML table of recent projects from the provided project database and writes it to the client. - * - * @param writer PrintWriter used for writing HTTP response headers. - * @param output OutputStream used for writing the HTTP response body. - * @param projectDatabase Read-only SQLiteDatabase containing the `recent_project_table`. - * @return `true` if an HTML response was written to the client. - */ - private fun realHandlePrEndpoint(writer: PrintWriter, output: java.io.OutputStream, projectDatabase: SQLiteDatabase) : Boolean { - if (debugEnabled) log.debug("Entering realHandlePrEndpoint().") - - val query = """ + var cursor = database.rawQuery(sqlQuery, arrayOf()) + lateinit var jsonText: ByteArray + + // Process database fetch + try { + if (!isCursorOneRow(cursor, writer, output)) { + return false + } + + // get the JSON from the bookshelf table + cursor.moveToFirst() + jsonText = cursor.getBlob(0) + if (debugEnabled) log.debug("json content = '${String(jsonText)}'.") + if (debugEnabled) log.debug("before fetch bookshelf template ID = '$bookshelfTemplateId'") + + // Have we already fetched the template + if (bookshelfTemplateId == -1) { + // safety first, close the cursor + cursor.close() + cursor = database.rawQuery("SELECT id FROM Templates WHERE name = 'bookshelf'", arrayOf()) + + if (!isCursorOneRow(cursor, writer, output)) { + return false + } + + cursor.moveToFirst() + bookshelfTemplateId = cursor.getInt(0) + if (debugEnabled) log.debug("after the fetch bookshelf template ID = '$bookshelfTemplateId'") + } + } catch (e: Exception) { + log.error("Error processing request: {}", e.message) + sendError(writer, output, httpInternalServerError, "Internal Server Error", e.message ?: "") + return false + } finally { + cursor.close() + } + + val result = instantiatePebbleTemplate(bookshelfTemplateId, jsonText, "/bookshelf", "application/json", "none") + + if (debugEnabled) log.debug("Bookshelf result is '{}'.", String(result)) + + markOutputStarted() + writeNormalToClient(writer, output, String(result)) + + if (debugEnabled) log.debug("Leaving realHandleBsEndpoint().") + + return true + } + + private fun isCursorOneRow( + cursor: Cursor, + writer: PrintWriter, + output: java.io.OutputStream, + ): Boolean { + if (cursor.count == 1) { + return true + } + if (cursor.count == 0) { + sendError(writer, output, httpNotFound, "Corrupt database, no rows found, expected one.") + } else { + sendError(writer, output, httpInternalServerError, "Corrupt database - found ${cursor.count} rows when 1 was expected.") + } + return false + } + + /** + * Builds an HTML table of recent projects from the provided project database and writes it to the client. + * + * @param writer PrintWriter used for writing HTTP response headers. + * @param output OutputStream used for writing the HTTP response body. + * @param projectDatabase Read-only SQLiteDatabase containing the `recent_project_table`. + * @param markOutputStarted Invoked right before the first response byte is written, so the + * caller's "did we already respond" flag is accurate even if the write itself then fails + * partway through -- not just after this function returns. + * @return `true` if an HTML response was written to the client. + */ + private fun realHandlePrEndpoint( + writer: PrintWriter, + output: java.io.OutputStream, + projectDatabase: SQLiteDatabase, + markOutputStarted: () -> Unit, + ): Boolean { + if (debugEnabled) log.debug("Entering realHandlePrEndpoint().") + + val query = """ SELECT id, - name, - DATETIME(create_at / 1000, 'unixepoch'), - DATETIME(last_modified / 1000, 'unixepoch'), - location, - template_name, - language + name, + DATETIME(create_at / 1000, 'unixepoch'), + DATETIME(last_modified / 1000, 'unixepoch'), + location, + template_name, + language FROM recent_project_table ORDER BY last_modified DESC""" - var html = getTableHtml("Projects", "Projects") + """ + var html = + getTableHtml("Projects", "Projects") + """ Id Name @@ -804,13 +1097,13 @@ ORDER BY last_modified DESC""" Language """ - val cursor = projectDatabase.rawQuery(query, arrayOf()) + val cursor = projectDatabase.rawQuery(query, arrayOf()) - try { - if (debugEnabled) log.debug("Retrieved {} rows.", cursor.count) + try { + if (debugEnabled) log.debug("Retrieved {} rows.", cursor.count) - while (cursor.moveToNext()) { - html += """ + while (cursor.moveToNext()) { + html += """ ${escapeHtml(cursor.getString(0) ?: "")} ${escapeHtml(cursor.getString(1) ?: "")} ${escapeHtml(cursor.getString(2) ?: "")} @@ -819,30 +1112,34 @@ ORDER BY last_modified DESC""" ${escapeHtml(cursor.getString(5) ?: "")} ${escapeHtml(cursor.getString(6) ?: "")} """ - } + } - html += "" + html += "" + } finally { + cursor.close() + } - } finally { - cursor.close() - } + // May output a lot of stuff but better too much than too little. --DS, 23-Feb-2026 + if (debugEnabled) log.debug("html is '{}'.", html) - if (debugEnabled) log.debug("html is '{}'.", html) // May output a lot of stuff but better too much than too little. --DS, 23-Feb-2026 + markOutputStarted() + writeNormalToClient(writer, output, html) - writeNormalToClient(writer, output, html) + if (debugEnabled) log.debug("Leaving realHandlePrEndpoint().") - if (debugEnabled) log.debug("Leaving realHandlePrEndpoint().") + return true + } - return true - } + /** + * Get HTML for table response page. + */ + private fun getTableHtml( + title: String, + tableName: String, + ): String { + if (debugEnabled) log.debug("Entering getTableHtml(), title='{}', tableName='{}'.", title, tableName) - /** - * Get HTML for table response page. - */ - private fun getTableHtml(title: String, tableName: String): String { - if (debugEnabled) log.debug("Entering getTableHtml(), title='{}', tableName='{}'.", title, tableName) - - return """ + return """ ${escapeHtml(title)} @@ -855,243 +1152,279 @@ th { background-color: #f2f2f2; }

${escapeHtml(tableName)}

""" - } - - /** - * Tail of writing table data back to client. - */ - private fun writeNormalToClient(writer: PrintWriter, output: java.io.OutputStream, html: String) { - if (debugEnabled) log.debug("Entering writeNormalToClient(), html='{}'.", html.take(200)) - - val htmlBytes = html.toByteArray(Charsets.UTF_8) - - /* - println() is intentional: the triple-quoted string ends with a single '\n' (after "Connection: close"), - and println() appends the second '\n' to form the required blank-line HTTP header terminator ("\n\n"). --DS, 22-Feb-2026 - */ - writer.println("""HTTP/1.1 200 OK + } + + /** + * Tail of writing table data back to client. + */ + private fun writeNormalToClient( + writer: PrintWriter, + output: java.io.OutputStream, + html: String, + ) { + if (debugEnabled) log.debug("Entering writeNormalToClient(), html='{}'.", html.take(200)) + + val htmlBytes = html.toByteArray(Charsets.UTF_8) + + /* + println() is intentional: the triple-quoted string ends with a single '\n' (after "Connection: close"), + and println() appends the second '\n' to form the required blank-line HTTP header terminator ("\n\n"). --DS, 22-Feb-2026 + */ + writer.println( + """HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 Content-Length: ${htmlBytes.size} Connection: close -""") - - output.write(htmlBytes) - output.flush() - } - - /** - * Escapes HTML special characters to prevent XSS attacks. - * Converts <, >, &, ", and ' to their HTML entity equivalents. - */ - private fun escapeHtml(text: String): String { +""", + ) + + output.write(htmlBytes) + output.flush() + } + + /** + * Escapes HTML special characters to prevent XSS attacks. + * Converts <, >, &, ", and ' to their HTML entity equivalents. + */ + private fun escapeHtml(text: String): String { // if (debugEnabled) log.debug("Entering escapeHtml(), text='{}'.", text) - return text - .replace("&", "&") // Must be first to avoid double-escaping - .replace("<", "<") - .replace(">", ">") - .replace("\"", """) - .replace("'", "'") - } - - private fun sendError(writer: PrintWriter, output: java.io.OutputStream, code: Int, message: String, details: String = "", outputStarted: Boolean = false) { - if (debugEnabled) log.debug("Entering sendError(), code={}, message='{}', details='{}', outputStarted={}.", code, message, details, outputStarted) - - val messageString = "$code $message" + if (details.isEmpty()) "" else "\n$details" - val bodyBytes = messageString.toByteArray(Charsets.UTF_8) - - if (!outputStarted) { - writer.println( - """HTTP/1.1 $code $message + return text + .replace("&", "&") // Must be first to avoid double-escaping + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") + } + + private fun sendError( + writer: PrintWriter, + output: java.io.OutputStream, + code: Int, + message: String, + details: String = "", + outputStarted: Boolean = false, + ) { + if (debugEnabled) { + log.debug( + "Entering sendError(), code={}, message='{}', details='{}', outputStarted={}.", + code, + message, + details, + outputStarted, + ) + } + + val messageString = "$code $message" + if (details.isEmpty()) "" else "\n$details" + val bodyBytes = messageString.toByteArray(Charsets.UTF_8) + + if (!outputStarted) { + writer.println( + """HTTP/1.1 $code $message Content-Type: text/plain; charset=utf-8 Content-Length: ${bodyBytes.size} Connection: close -""" - ) - output.write(bodyBytes) - output.flush() - } - if (debugEnabled) log.debug("Leaving sendError().") - } - - private fun sendCSS(writer: PrintWriter, output: java.io.OutputStream, message: String) { - if (debugEnabled) log.debug("Entering sendCSS(), message='{}'.", message) - - val bodyBytes = message.toByteArray(Charsets.UTF_8) - - writer.println("""HTTP/1.1 200 OK +""", + ) + output.write(bodyBytes) + output.flush() + } + if (debugEnabled) log.debug("Leaving sendError().") + } + + private fun sendCSS( + writer: PrintWriter, + output: java.io.OutputStream, + message: String, + ) { + if (debugEnabled) log.debug("Entering sendCSS(), message='{}'.", message) + + val bodyBytes = message.toByteArray(Charsets.UTF_8) + + writer.println( + """HTTP/1.1 200 OK Content-Type: text/css; charset=utf-8 Content-Length: ${bodyBytes.size} Cache-Control: no-store Connection: close -""") - - output.write(bodyBytes) - output.flush() - - if (debugEnabled) log.debug("Leaving sendCSS().") - } - - private fun handlePlaygroundExecute( - input: java.io.InputStream, - writer: PrintWriter, - output: java.io.OutputStream, - method: String, - headers: Map - ) { - if (method != "POST") { - return sendError(writer, output, 405, "Method Not Allowed") - } - val contentLengthStr = headers["content-length"] ?: run { - return sendError(writer, output, 400, "Bad Request", "Missing Content-Length") - } - val contentLength = contentLengthStr.toIntOrNull() ?: run { - return sendError(writer, output, 400, "Bad Request", "Invalid Content-Length") - } - if (contentLength <= 0) { - return sendError(writer, output, 400, "Bad Request", "Content-Length must be positive") - } - if (contentLength > 10_000) { - return sendError(writer, output, 413, "Payload Too Large") - } - val body = ByteArray(contentLength) - var offset = 0 - while (offset < contentLength) { - val read = input.read(body, offset, contentLength - offset) - if (read <= 0) { - return sendError(writer, output, 400, "Bad Request", "Input stream interrupted prematurely") - } - offset += read - } - val data = parseFormDataField(body, "data") ?: run { - return sendError(writer, output, 400, "Bad Request", "Missing or empty form field 'data'") - } - if (data.size > 10_000) { - return sendError(writer, output, 413, "Payload Too Large") - } - val workDir = - File(config.fileDirPath, "playground_${System.nanoTime()}_${java.util.UUID.randomUUID()}") - .apply { mkdirs() } - try { - val sourceFile = createFileFromPost(data, workDir) - val result = compileAndRunJava(sourceFile) - val sourceString = data.toString(Charsets.UTF_8) - val responseBody = sourceString + result - val responseBytes = responseBody.toByteArray(Charsets.UTF_8) - writer.println("HTTP/1.1 200 OK") - writer.println("Content-Type: text/plain; charset=utf-8") - writer.println("Content-Length: ${responseBytes.size}") - writer.println() - writer.flush() - output.write(responseBytes) - output.flush() - } finally { - workDir.deleteRecursively() - } - } - - private fun parseFormDataField(body: ByteArray, fieldName: String): ByteArray? { - val bodyStr = body.toString(Charsets.UTF_8) - val pairs = bodyStr.split("&") - for (pair in pairs) { - val eq = pair.indexOf('=') - if (eq < 0) continue - val key = URLDecoder.decode(pair.substring(0, eq), "UTF-8") - if (key != fieldName) continue - val value = pair.substring(eq + 1) - val decoded = URLDecoder.decode(value, "UTF-8") - if (decoded.isEmpty()) return null - return decoded.toByteArray(Charsets.UTF_8) - } - return null - } - - private fun createFileFromPost(data: ByteArray, workDir: File): File { - require(data.size <= 10_000) { "data exceeds 10000 bytes" } - val file = File(workDir, "Playground.java") - file.writeBytes(data) - return file - } - - private fun compileAndRunJava(sourceFile: File): String { - val dir = sourceFile.parentFile - val fileName = sourceFile.nameWithoutExtension - val classFile = File(dir, "$fileName.class") - classFile.delete() - val directoryPath = config.fileDirPath - val javacPath = "$directoryPath/usr/bin/javac" - val javaPath = "$directoryPath/usr/bin/java" - val filePath = sourceFile.absolutePath - - val compileTimeoutSec = 60L - val runTimeoutSec = 120L - val destroyWaitSec = 5L - - try { - val javac = ProcessBuilder(javacPath, filePath) - .directory(dir) - .redirectErrorStream(true) - .start() - javac.outputStream.close() - val compileOutputRef = AtomicReference("") - val compileReader = - Thread { - compileOutputRef.set( - javac.inputStream.bufferedReader().readText() - ) - } - compileReader.start() - val compileDone = - javac.waitFor(compileTimeoutSec, TimeUnit.SECONDS) - if (!compileDone) { - javac.destroyForcibly() - javac.waitFor(destroyWaitSec, TimeUnit.SECONDS) - compileReader.join(1000) - return "Compilation timed out after ${compileTimeoutSec}s:\n${compileOutputRef.get()}" - } - compileReader.join(2000) - val compileOutput = compileOutputRef.get() - if (javac.exitValue() != 0) { - return "Compilation failed:\n$compileOutput" - } - - val java = - ProcessBuilder( - javaPath, - "-cp", - dir?.absolutePath ?: "", - fileName - ) - .directory(dir) - .redirectErrorStream(true) - .start() - java.outputStream.close() - val runOutputRef = AtomicReference("") - val runReader = - Thread { - runOutputRef.set( - java.inputStream.bufferedReader().readText() - ) - } - runReader.start() - val runDone = java.waitFor(runTimeoutSec, TimeUnit.SECONDS) - if (!runDone) { - java.destroyForcibly() - java.waitFor(destroyWaitSec, TimeUnit.SECONDS) - runReader.join(1000) - return "Execution timed out after ${runTimeoutSec}s:\n${runOutputRef.get()}" - } - runReader.join(2000) - val runOutput = runOutputRef.get() - - return if (compileOutput.isNotBlank()) { - "Compile output\n $compileOutput\n Program output\n$runOutput" - } else { - "Program output\n $runOutput" - } - } catch (e: InterruptedException) { - Thread.currentThread().interrupt() - return "Compilation or execution interrupted." - } - } -} \ No newline at end of file +""", + ) + + output.write(bodyBytes) + output.flush() + + if (debugEnabled) log.debug("Leaving sendCSS().") + } + + private fun handlePlaygroundExecute( + input: java.io.InputStream, + writer: PrintWriter, + output: java.io.OutputStream, + method: String, + headers: Map, + ) { + if (method != "POST") { + return sendError(writer, output, 405, "Method Not Allowed") + } + val contentLengthStr = + headers["content-length"] ?: run { + return sendError(writer, output, 400, "Bad Request", "Missing Content-Length") + } + val contentLength = + contentLengthStr.toIntOrNull() ?: run { + return sendError(writer, output, 400, "Bad Request", "Invalid Content-Length") + } + if (contentLength <= 0) { + return sendError(writer, output, 400, "Bad Request", "Content-Length must be positive") + } + if (contentLength > 10_000) { + return sendError(writer, output, 413, "Payload Too Large") + } + val body = ByteArray(contentLength) + var offset = 0 + while (offset < contentLength) { + val read = input.read(body, offset, contentLength - offset) + if (read <= 0) { + return sendError(writer, output, 400, "Bad Request", "Input stream interrupted prematurely") + } + offset += read + } + val data = + parseFormDataField(body, "data") ?: run { + return sendError(writer, output, 400, "Bad Request", "Missing or empty form field 'data'") + } + if (data.size > 10_000) { + return sendError(writer, output, 413, "Payload Too Large") + } + val workDir = + File(config.fileDirPath, "playground_${System.nanoTime()}_${java.util.UUID.randomUUID()}") + .apply { mkdirs() } + try { + val sourceFile = createFileFromPost(data, workDir) + val result = compileAndRunJava(sourceFile) + val sourceString = data.toString(Charsets.UTF_8) + val responseBody = sourceString + result + val responseBytes = responseBody.toByteArray(Charsets.UTF_8) + writer.println("HTTP/1.1 200 OK") + writer.println("Content-Type: text/plain; charset=utf-8") + writer.println("Content-Length: ${responseBytes.size}") + writer.println() + writer.flush() + output.write(responseBytes) + output.flush() + } finally { + workDir.deleteRecursively() + } + } + + private fun parseFormDataField( + body: ByteArray, + fieldName: String, + ): ByteArray? { + val bodyStr = body.toString(Charsets.UTF_8) + val pairs = bodyStr.split("&") + for (pair in pairs) { + val eq = pair.indexOf('=') + if (eq < 0) continue + val key = URLDecoder.decode(pair.substring(0, eq), "UTF-8") + if (key != fieldName) continue + val value = pair.substring(eq + 1) + val decoded = URLDecoder.decode(value, "UTF-8") + if (decoded.isEmpty()) return null + return decoded.toByteArray(Charsets.UTF_8) + } + return null + } + + private fun createFileFromPost( + data: ByteArray, + workDir: File, + ): File { + require(data.size <= 10_000) { "data exceeds 10000 bytes" } + val file = File(workDir, "Playground.java") + file.writeBytes(data) + return file + } + + private fun compileAndRunJava(sourceFile: File): String { + val dir = sourceFile.parentFile + val fileName = sourceFile.nameWithoutExtension + val classFile = File(dir, "$fileName.class") + classFile.delete() + val directoryPath = config.fileDirPath + val javacPath = "$directoryPath/usr/bin/javac" + val javaPath = "$directoryPath/usr/bin/java" + val filePath = sourceFile.absolutePath + + val compileTimeoutSec = 60L + val runTimeoutSec = 120L + val destroyWaitSec = 5L + + try { + val javac = + ProcessBuilder(javacPath, filePath) + .directory(dir) + .redirectErrorStream(true) + .start() + javac.outputStream.close() + val compileOutputRef = AtomicReference("") + val compileReader = + Thread { + compileOutputRef.set( + javac.inputStream.bufferedReader().readText(), + ) + } + compileReader.start() + val compileDone = + javac.waitFor(compileTimeoutSec, TimeUnit.SECONDS) + if (!compileDone) { + javac.destroyForcibly() + javac.waitFor(destroyWaitSec, TimeUnit.SECONDS) + compileReader.join(1000) + return "Compilation timed out after ${compileTimeoutSec}s:\n${compileOutputRef.get()}" + } + compileReader.join(2000) + val compileOutput = compileOutputRef.get() + if (javac.exitValue() != 0) { + return "Compilation failed:\n$compileOutput" + } + + val java = + ProcessBuilder( + javaPath, + "-cp", + dir?.absolutePath ?: "", + fileName, + ).directory(dir) + .redirectErrorStream(true) + .start() + java.outputStream.close() + val runOutputRef = AtomicReference("") + val runReader = + Thread { + runOutputRef.set( + java.inputStream.bufferedReader().readText(), + ) + } + runReader.start() + val runDone = java.waitFor(runTimeoutSec, TimeUnit.SECONDS) + if (!runDone) { + java.destroyForcibly() + java.waitFor(destroyWaitSec, TimeUnit.SECONDS) + runReader.join(1000) + return "Execution timed out after ${runTimeoutSec}s:\n${runOutputRef.get()}" + } + runReader.join(2000) + val runOutput = runOutputRef.get() + + return if (compileOutput.isNotBlank()) { + "Compile output\n $compileOutput\n Program output\n$runOutput" + } else { + "Program output\n $runOutput" + } + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + return "Compilation or execution interrupted." + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/logs/LogBuffer.kt b/app/src/main/java/com/itsaky/androidide/logs/LogBuffer.kt index dfeb793c4e..a17b3efd32 100644 --- a/app/src/main/java/com/itsaky/androidide/logs/LogBuffer.kt +++ b/app/src/main/java/com/itsaky/androidide/logs/LogBuffer.kt @@ -73,12 +73,15 @@ class LogBuffer( * Render all entries matching [filter] into a single string. * * @return The rendered text and the sequence number of the newest entry in the - * buffer at snapshot time (0 if the buffer is empty), regardless of whether - * that entry matched the filter. + * buffer at snapshot time, regardless of whether that entry matched the filter. + * When the buffer is empty, this is the last seq ever issued: an empty buffer + * means every issued entry has been discarded (e.g. by [clear]), so none of + * them may stitch in after the snapshot -- returning 0 here would let a live + * stream's replay cache re-deliver cleared lines. */ @Synchronized fun snapshotFiltered(filter: LogFilter): Pair { - val lastSeq = entries.lastOrNull()?.seq ?: 0L + val lastSeq = entries.lastOrNull()?.seq ?: (nextSeq - 1) val text = buildString { for (entry in entries) { diff --git a/app/src/main/java/com/itsaky/androidide/lsp/IDELanguageClientImpl.java b/app/src/main/java/com/itsaky/androidide/lsp/IDELanguageClientImpl.java index 64bc5de9fa..449386aebd 100755 --- a/app/src/main/java/com/itsaky/androidide/lsp/IDELanguageClientImpl.java +++ b/app/src/main/java/com/itsaky/androidide/lsp/IDELanguageClientImpl.java @@ -44,23 +44,22 @@ import com.itsaky.androidide.models.SearchResult; import com.itsaky.androidide.tasks.TaskExecutor; import com.itsaky.androidide.ui.CodeEditorView; -import com.itsaky.androidide.utils.FileIOUtils; import com.itsaky.androidide.utils.FileUtils; import com.itsaky.androidide.utils.FlashbarActivityUtilsKt; import com.itsaky.androidide.utils.FlashbarUtilsKt; import com.itsaky.androidide.utils.LSPUtils; import io.github.rosemoe.sora.lang.diagnostic.DiagnosticsContainer; -import io.github.rosemoe.sora.text.Content; import java.io.File; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Set; import java.util.TreeSet; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import kotlin.Unit; import org.slf4j.Logger; @@ -107,6 +106,9 @@ public static void shutdown() { private final Map> diagnostics = new HashMap<>(); + /** Identifies the most recent {@link #showLocations(List)} request; older ones must not publish. */ + private final AtomicInteger showLocationsRequest = new AtomicInteger(); + protected EditorHandlerActivity activity; private IDELanguageClientImpl(EditorHandlerActivity provider) { @@ -271,56 +273,74 @@ public void showLocations(List locations) { return; } - boolean error = locations == null || locations.isEmpty(); - activity.handleSearchResultVisibility(error); + // Claims the panel for this request. The publish below is asynchronous, so without this a slow + // request that started first would land last and overwrite the newer search the user is looking at. + final int request = showLocationsRequest.incrementAndGet(); + boolean error = locations == null || locations.isEmpty(); if (error) { + activity.handleSearchResultVisibility(true); activity .setSearchResultAdapter( new SearchListAdapter(Collections.emptyMap(), this::noOp, this::noOp)); return; } - final Map> results = new HashMap<>(); - for (int i = 0; i < locations.size(); i++) { - try { - final Location loc = locations.get(i); - if (loc == null) { - continue; - } + // Group by file first. Reads then cost one pass per file instead of one full read per hit, which + // is what this used to do - and it did it on this thread. See SearchResultGrouping. + final Map> byFile = new LinkedHashMap<>(); + for (final Location loc : locations) { + if (loc == null) { + continue; + } + byFile.computeIfAbsent(loc.getFile().toFile(), f -> new ArrayList<>()).add(loc); + } - final File file = loc.getFile().toFile(); - if (!file.exists() || !file.isFile()) { - continue; + // A file with an open editor is resolved here, on the UI thread: its Content is live UI state + // that a background thread must not touch, and pulling a few lines out of it is substring work + // with no I/O. Everything else is read off this thread below. + final Map> fromEditors = new HashMap<>(); + final Map> onDisk = new LinkedHashMap<>(); + for (final Map.Entry> entry : byFile.entrySet()) { + final var frag = findEditorByFile(entry.getKey()); + if (frag != null && frag.getEditor() != null) { + final List rows = SearchResultGrouping.INSTANCE.resultsFor( + entry.getKey(), entry.getValue(), frag.getEditor().getText()); + if (!rows.isEmpty()) { + fromEditors.put(entry.getKey(), rows); } - var frag = findEditorByFile(file); - Content content; - if (frag != null && frag.getEditor() != null) { - content = frag.getEditor().getText(); - } else { - content = new Content(FileIOUtils.readFile2String(file)); - } - final List matches = results.containsKey(file) ? results.get(file) : new ArrayList<>(); - Objects.requireNonNull(matches) - .add( - new SearchResult( - loc.getRange(), - file, - content.getLineString(loc.getRange().getStart().getLine()), - content - .subContent( - loc.getRange().getStart().getLine(), - loc.getRange().getStart().getColumn(), - loc.getRange().getEnd().getLine(), - loc.getRange().getEnd().getColumn()) - .toString())); - results.put(file, matches); - } catch (Throwable th) { - LOG.error("Failed to show file location", th); + } else { + onDisk.put(entry.getKey(), entry.getValue()); } } - activity.handleSearchResults(results); + if (onDisk.isEmpty()) { + publishLocations(fromEditors); + return; + } + + // Some other search may publish (and bump the generation) while the read is in flight; capture it + // here so this request does not overwrite whatever replaced it. + final int generation = activity.getEditorViewModel().getCurrentSearchGeneration(); + + TaskExecutor.executeAsyncProvideError( + () -> SearchResultGrouping.INSTANCE.readFromDisk(onDisk), + (result, throwable) -> { + if (!canUseActivity() + || request != showLocationsRequest.get() + || generation != activity.getEditorViewModel().getCurrentSearchGeneration()) { + // Superseded, or the activity went away. Leave the panel to whoever owns it now: this + // request's results would be an answer to a question no longer on screen. + return; + } + final Map> merged = new HashMap<>(fromEditors); + if (result != null) { + merged.putAll(result); + } else { + LOG.error("Failed to read search result files", throwable); + } + publishLocations(merged); + }); } private Boolean applyActionEdits(@Nullable final IDEEditor editor, final CodeActionItem action) { @@ -476,4 +496,14 @@ private List mapAsGroup(Map> map) { private Unit noOp(final Object obj) { return Unit.INSTANCE; } + + /** + * Shows {@code results} in the search panel. + * + * Visibility and rows are committed together: a publish that never happens - superseded, or the activity recreated mid-read - must not leave the panel open with the "no results" placeholder hidden over the previous query's rows. + */ + private void publishLocations(final Map> results) { + activity.handleSearchResultVisibility(results.isEmpty()); + activity.handleSearchResults(results); + } } diff --git a/app/src/main/java/com/itsaky/androidide/lsp/SearchResultGrouping.kt b/app/src/main/java/com/itsaky/androidide/lsp/SearchResultGrouping.kt new file mode 100644 index 0000000000..797113bf90 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/lsp/SearchResultGrouping.kt @@ -0,0 +1,146 @@ +package com.itsaky.androidide.lsp + +import com.itsaky.androidide.models.Location +import com.itsaky.androidide.models.Range +import com.itsaky.androidide.models.SearchResult +import io.github.rosemoe.sora.text.Content +import org.slf4j.LoggerFactory +import java.io.BufferedReader +import java.io.File + +/** + * Builds the search-results panel's rows for a set of [Location]s. + * + * Exists because the panel used to read every result file **in full, once per hit, on the main + * thread**: a file with twelve usages was read and materialised twelve times. Find usages made that a + * real cost rather than a latent one. + * + * A row needs only two short strings per hit - the hit's line, and the matched text - so nothing here + * retains a file's contents. Reads are one sequential pass per file, and peak memory is one line rather + * than one file. A per-file content cache would fix the repeated reads but hold every result file's text + * at once, which is the wrong trade on a phone. + */ +internal object SearchResultGrouping { + private val logger = LoggerFactory.getLogger(SearchResultGrouping::class.java) + + /** + * Rows for [locations] in [file], built from already-available [lines] (0-based line number to text). + * + * A location whose lines are not all present is dropped: a stale location can point past the end of + * a file that has since been edited, and a row referring to a line that no longer exists is worse + * than no row. + */ + fun resultsFor( + file: File, + locations: List, + lines: Map, + ): List = + locations.mapNotNull { location -> + val range = location.range + val lineText = lines[range.start.line] ?: return@mapNotNull null + val match = matchedText(lineText, range, lines) + if (match == null) { + logger.debug("Dropping stale search result in {}", file.name) + return@mapNotNull null + } + SearchResult(range, file, lineText, match) + } + + /** Rows for [locations] in [file], read from the live editor buffer [content]. */ + fun resultsFor( + file: File, + locations: List, + content: Content, + ): List { + val lines = + linesNeededBy(locations) + .filter { it >= 0 && it < content.lineCount } + .associateWith { content.getLineString(it) } + + return resultsFor(file, locations, lines) + } + + /** Rows for every file in [byFile], reading each file exactly once. */ + fun readFromDisk(byFile: Map>): Map> = + byFile + .mapValues { (file, locations) -> resultsFor(file, locations, readLines(file, linesNeededBy(locations))) } + .filterValues { it.isNotEmpty() } + + /** Every 0-based line number whose text [locations] need. */ + fun linesNeededBy(locations: List): Set = + locations + .flatMapTo(mutableSetOf()) { location -> + location.range.start.line..location.range.end.line + } + + /** + * The text of just the [wanted] lines of [file], in one sequential pass. + * + * Stops as soon as the last wanted line has been seen, and never holds more than the current line, + * so a hit near the top of a large file does not read the rest of it. Missing lines - a file shorter + * than the location claims, or an unreadable file - are simply absent from the result. + */ + fun readLines( + file: File, + wanted: Set, + ): Map { + if (wanted.isEmpty()) { + return emptyMap() + } + + val last = wanted.max() + val lines = HashMap(wanted.size) + return try { + file.bufferedReader().use { reader -> + reader.collectLines(wanted, last, lines) + } + lines + } catch (e: Exception) { + // A result file that has been deleted or is unreadable drops its rows, which is what the + // previous implementation did too by way of an exists() check per hit. + logger.debug("Could not read search result file {}", file, e) + lines + } + } + + private fun BufferedReader.collectLines( + wanted: Set, + last: Int, + into: MutableMap, + ) { + var number = 0 + while (number <= last) { + val line = readLine() ?: return + if (number in wanted) { + into[number] = line + } + number++ + } + } + + /** + * The text [range] covers, given [firstLine] (the text of the line it starts on) and [lines] for the + * rest. Null when any line it spans is missing. + */ + private fun matchedText( + firstLine: String, + range: Range, + lines: Map, + ): String? { + val start = range.start + val end = range.end + if (start.line == end.line) { + val from = start.column.coerceIn(0, firstLine.length) + return firstLine.substring(from, end.column.coerceIn(from, firstLine.length)) + } + + return buildString { + append(firstLine.substring(start.column.coerceIn(0, firstLine.length))) + for (line in (start.line + 1) until end.line) { + append('\n').append(lines[line] ?: return null) + } + val lastLine = lines[end.line] ?: return null + append('\n').append(lastLine.substring(0, end.column.coerceIn(0, lastLine.length))) + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/preferences/generalPrefExts.kt b/app/src/main/java/com/itsaky/androidide/preferences/generalPrefExts.kt index bdd2c517ec..478d90edae 100644 --- a/app/src/main/java/com/itsaky/androidide/preferences/generalPrefExts.kt +++ b/app/src/main/java/com/itsaky/androidide/preferences/generalPrefExts.kt @@ -32,183 +32,163 @@ import kotlinx.parcelize.Parcelize @Parcelize class GeneralPreferencesScreen( - override val key: String = "idepref_general", - override val title: Int = string.title_general, - override val summary: Int? = string.idepref_general_summary, - override val children: List = mutableListOf() +override val key: String = "idepref_general", +override val title: Int = string.title_general, +override val summary: Int? = string.idepref_general_summary, +override val children: List = mutableListOf() ) : IPreferenceScreen() { - init { - addPreference(InterfaceConfig()) - addPreference(ProjectConfig()) - } +init { + addPreference(InterfaceConfig()) + addPreference(ProjectConfig()) +} } @Parcelize class InterfaceConfig( - override val key: String = "idepref_general_interface", - override val title: Int = string.title_interface, - override val children: List = mutableListOf(), +override val key: String = "idepref_general_interface", +override val title: Int = string.title_interface, +override val children: List = mutableListOf(), ) : IPreferenceGroup() { - init { - addPreference(UiMode()) - addPreference(LocaleSelector()) - } +init { + addPreference(UiMode()) + addPreference(LocaleSelector()) +} } @Parcelize class ProjectConfig( - override val key: String = "idepref_general_project", - override val title: Int = R.string.idepref_general_projectConfig, - override val children: List = mutableListOf(), +override val key: String = "idepref_general_project", +override val title: Int = R.string.idepref_general_projectConfig, +override val children: List = mutableListOf(), ) : IPreferenceGroup() { - init { - addPreference(OpenLastProject()) - addPreference(ConfirmProjectOpen()) - } +init { + addPreference(OpenLastProject()) + addPreference(ConfirmProjectOpen()) +} } @Parcelize class UiMode( - override val key: String = GeneralPreferences.UI_MODE, - override val title: Int = R.string.idepref_general_uiMode, - override val summary: Int? = R.string.idepref_general_uiMode_summary, - override val icon: Int? = R.drawable.ic_ui_mode +override val key: String = GeneralPreferences.UI_MODE, +override val title: Int = R.string.idepref_general_uiMode, +override val summary: Int? = R.string.idepref_general_uiMode_summary, +override val icon: Int? = R.drawable.ic_ui_mode ) : SingleChoicePreference() { - @IgnoredOnParcel - override val tooltipTag: String = PREFS_GENERAL +@IgnoredOnParcel +override val tooltipTag: String = PREFS_GENERAL - override fun getEntries(preference: Preference): Array { - val context = preference.context - val currentUiMode = GeneralPreferences.uiMode +override fun getEntries(preference: Preference): Array { + val context = preference.context + val currentUiMode = GeneralPreferences.uiMode - return Array(3) { index -> - val (label, mode) = when (index) { - 0 -> context.getString(R.string.uiMode_light) to AppCompatDelegate.MODE_NIGHT_NO - 1 -> context.getString(R.string.uiMode_dark) to AppCompatDelegate.MODE_NIGHT_YES - 2 -> context.getString(R.string.uiMode_system) to AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM - else -> throw IllegalStateException("Invalid index") - } + return Array(3) { index -> + val (label, mode) = when (index) { + 0 -> context.getString(R.string.uiMode_light) to AppCompatDelegate.MODE_NIGHT_NO + 1 -> context.getString(R.string.uiMode_dark) to AppCompatDelegate.MODE_NIGHT_YES + 2 -> context.getString(R.string.uiMode_system) to AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + else -> throw IllegalStateException("Invalid index") + } - PreferenceChoices.Entry(label, currentUiMode == mode, mode) - } - } + PreferenceChoices.Entry(label, currentUiMode == mode, mode) + } +} - override fun onChoiceConfirmed( - preference: Preference, - entry: PreferenceChoices.Entry?, - position: Int - ) { - GeneralPreferences.uiMode = (entry?.data as? Int?) ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM - } +override fun onChoiceConfirmed( + preference: Preference, + entry: PreferenceChoices.Entry?, + position: Int +) { + GeneralPreferences.uiMode = (entry?.data as? Int?) ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM +} } @Parcelize class LocaleSelector( - override val key: String = GeneralPreferences.SELECTED_LOCALE, - override val title: Int = R.string.idepref_general_localeSelector_title, - override val summary: Int? = R.string.idepref_general_localeSelector_summary, - override val icon: Int? = R.drawable.ic_translate +override val key: String = GeneralPreferences.SELECTED_LOCALE, +override val title: Int = R.string.idepref_general_localeSelector_title, +override val summary: Int? = R.string.idepref_general_localeSelector_summary, +override val icon: Int? = R.drawable.ic_translate ) : SingleChoicePreference() { - @IgnoredOnParcel - override val tooltipTag: String = PREFS_GENERAL - - override fun getEntries(preference: Preference): Array { - val context = preference.context - val currentLocale = GeneralPreferences.selectedLocale - val supportedLocales = LocaleProvider.SUPPORTED_LOCALES.keys.toList() - return Array(supportedLocales.size + 1) { index -> - if (index == 0) { - PreferenceChoices.Entry( - label = ContextCompat.getString(context, R.string.locale_system_default), - _isChecked = GeneralPreferences.selectedLocale == null, - data = 0 - ) - } else { - val localeKey = supportedLocales[index - 1] - val locale = LocaleProvider.getLocale(localeKey)!! - PreferenceChoices.Entry( - label = locale.getDisplayName(locale), - _isChecked = currentLocale == localeKey, - data = localeKey - ) - } - } - } - - override fun onChoiceConfirmed( - preference: Preference, - entry: PreferenceChoices.Entry?, - position: Int - ) { - GeneralPreferences.selectedLocale = entry?.data?.let { localeKey -> - if (localeKey is Int) null else localeKey as String - } - } +@IgnoredOnParcel +override val tooltipTag: String = PREFS_GENERAL + +override fun getEntries(preference: Preference): Array { + val context = preference.context + val currentLocale = GeneralPreferences.selectedLocale + val supportedLocales = LocaleProvider.SUPPORTED_LOCALES.keys.toList() + return Array(supportedLocales.size + 1) { index -> + if (index == 0) { + PreferenceChoices.Entry( + label = ContextCompat.getString(context, R.string.locale_system_default), + _isChecked = GeneralPreferences.selectedLocale == null, + data = 0 + ) + } else { + val localeKey = supportedLocales[index - 1] + val locale = LocaleProvider.getLocale(localeKey)!! + PreferenceChoices.Entry( + label = locale.getDisplayName(locale), + _isChecked = currentLocale == localeKey, + data = localeKey + ) + } + } +} + +override fun onChoiceConfirmed( + preference: Preference, + entry: PreferenceChoices.Entry?, + position: Int +) { + GeneralPreferences.selectedLocale = entry?.data?.let { localeKey -> + if (localeKey is Int) null else localeKey as String + } +} } @Parcelize class OpenLastProject( - override val key: String = GeneralPreferences.OPEN_PROJECTS, - override val title: Int = string.title_open_projects, - override val summary: Int? = string.msg_open_projects, - override val icon: Int? = drawable.ic_open_project +override val key: String = GeneralPreferences.OPEN_PROJECTS, +override val title: Int = string.title_open_projects, +override val summary: Int? = string.msg_open_projects, +override val icon: Int? = drawable.ic_open_project ) : SwitchPreference() { - override fun onCreatePreference(context: Context): Preference { - val pref = super.onCreatePreference(context) as androidx.preference.SwitchPreference - pref.isChecked = GeneralPreferences.autoOpenProjects - return pref - } +override fun onCreatePreference(context: Context): Preference { + val pref = super.onCreatePreference(context) as androidx.preference.SwitchPreference + pref.isChecked = GeneralPreferences.autoOpenProjects + return pref +} - override fun onPreferenceChanged(preference: Preference, newValue: Any?): Boolean { - GeneralPreferences.autoOpenProjects = newValue as Boolean? - ?: GeneralPreferences.autoOpenProjects - return true - } +override fun onPreferenceChanged(preference: Preference, newValue: Any?): Boolean { + GeneralPreferences.autoOpenProjects = newValue as Boolean? + ?: GeneralPreferences.autoOpenProjects + return true +} } @Parcelize class ConfirmProjectOpen( - override val key: String = GeneralPreferences.CONFIRM_PROJECT_OPEN, - override val title: Int = string.title_confirm_project_open, - override val summary: Int? = string.msg_confirm_project_open, - override val icon: Int? = drawable.ic_open_project +override val key: String = GeneralPreferences.CONFIRM_PROJECT_OPEN, +override val title: Int = string.title_confirm_project_open, +override val summary: Int? = string.msg_confirm_project_open, +override val icon: Int? = drawable.ic_open_project ) : SwitchPreference() { - override fun onCreatePreference(context: Context): Preference { - val pref = super.onCreatePreference(context) as androidx.preference.SwitchPreference - pref.isChecked = GeneralPreferences.confirmProjectOpen - return pref - } - - override fun onPreferenceChanged(preference: Preference, newValue: Any?): Boolean { - GeneralPreferences.confirmProjectOpen = newValue as Boolean? - ?: GeneralPreferences.confirmProjectOpen - return true - } +override fun onCreatePreference(context: Context): Preference { + val pref = super.onCreatePreference(context) as androidx.preference.SwitchPreference + pref.isChecked = GeneralPreferences.confirmProjectOpen + return pref } -@Parcelize -class UseSytemShell( - override val key: String = GeneralPreferences.TERMINAL_USE_SYSTEM_SHELL, - override val title: Int = string.title_default_shell, - override val summary: Int? = string.msg_default_shell, - override val icon: Int? = drawable.ic_bash_commands -) : SwitchPreference() { - - override fun onCreatePreference(context: Context): Preference { - val pref = super.onCreatePreference(context) as androidx.preference.SwitchPreference - pref.isChecked = GeneralPreferences.useSystemShell - return pref - } - - override fun onPreferenceChanged(preference: Preference, newValue: Any?): Boolean { - GeneralPreferences.useSystemShell = newValue as Boolean? ?: GeneralPreferences.useSystemShell - return true - } +override fun onPreferenceChanged(preference: Preference, newValue: Any?): Boolean { + GeneralPreferences.confirmProjectOpen = newValue as Boolean? + ?: GeneralPreferences.confirmProjectOpen + return true +} } diff --git a/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt b/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt index e2c2d1d024..8c3c29860e 100644 --- a/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt @@ -7,6 +7,7 @@ import com.itsaky.androidide.plugins.manager.core.PluginManager import com.itsaky.androidide.plugins.manager.loaders.toPluginMetadata import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION import java.io.File /** @@ -14,155 +15,183 @@ import java.io.File * Handles all plugin-related data operations */ class PluginRepositoryImpl( - private val pluginManagerProvider: () -> PluginManager?, - private val pluginsDir: File + private val pluginManagerProvider: () -> PluginManager?, + private val pluginsDir: File, ) : PluginRepository { - - private companion object { - private const val TAG = "PluginRepository" - } - - private val pluginManager: PluginManager? - get() = pluginManagerProvider() - - override suspend fun getAllPlugins(): Result> = withContext(Dispatchers.IO) { - runCatching { - val manager = pluginManager - ?: throw IllegalStateException("Plugin system not available") - manager.getAllPlugins() - }.onFailure { exception -> - Log.e(TAG, "Failed to get all plugins", exception) - } - } - - override suspend fun enablePlugin(pluginId: String): Result = withContext(Dispatchers.IO) { - runCatching { - val manager = pluginManager - ?: throw IllegalStateException("Plugin system not available") - val result = manager.enablePlugin(pluginId) - result - }.onFailure { exception -> - Log.e(TAG, "Failed to enable plugin: $pluginId", exception) - } - } - - override suspend fun disablePlugin(pluginId: String): Result = withContext(Dispatchers.IO) { - runCatching { - val manager = pluginManager - ?: throw IllegalStateException("Plugin system not available") - val result = manager.disablePlugin(pluginId) - result - }.onFailure { exception -> - Log.e(TAG, "Failed to disable plugin: $pluginId", exception) - } - } - - override suspend fun uninstallPlugin(pluginId: String): Result = withContext(Dispatchers.IO) { - runCatching { - val manager = pluginManager - ?: throw IllegalStateException("Plugin system not available") - - Log.d(TAG, "Uninstalling plugin: $pluginId") - val result = manager.uninstallPlugin(pluginId) - result - }.onFailure { exception -> - Log.e(TAG, "Failed to uninstall plugin: $pluginId", exception) - } - } - - override suspend fun getPluginMetadataFromFile(pluginFile: File): Result = withContext(Dispatchers.IO) { - runCatching { - val manager = pluginManager - ?: throw IllegalStateException("Plugin system not available") - manager.getPluginMetadataOnly(pluginFile).getOrThrow().toPluginMetadata() - } - } - - override suspend fun haveMatchingSignatures(incomingFile: File, existingPluginId: String): Result = - withContext(Dispatchers.IO) { - runCatching { - pluginManager?.haveMatchingSignatures(incomingFile, existingPluginId) - ?: throw IllegalStateException("Plugin system not available") - } - } - - override suspend fun installPluginFromFile(pluginFile: File): Result = withContext(Dispatchers.IO) { - runCatching { - val manager = pluginManager - ?: throw IllegalStateException("Plugin system not available") - - val validationResult = manager.getPluginValidation(pluginFile) - if (validationResult.isFailure) { - pluginFile.delete() - throw validationResult.exceptionOrNull() - ?: Exception("Failed to read plugin metadata") - } - - val validation = validationResult.getOrNull()!! - val metadata = validation.manifest - val pluginId = metadata.id - - if (validation.isDebug) { - val missing = listOfNotNull( - "icon_day".takeIf { - metadata.iconDay == null || !validation.iconDayEntryExists - }, - "icon_night".takeIf { - metadata.iconNight == null || !validation.iconNightEntryExists - } - ).joinToString(" and ") { "\"$it\"" } - if (missing.isNotEmpty()) { - pluginFile.delete() - throw IllegalArgumentException( - "[$pluginId] Missing $missing for debug plugin. Debug plugins must declare and ship both icon_day and icon_night assets." - ) - } - } - - try { - manager.uninstallPlugin(pluginId) - Log.d(TAG, "Uninstalled existing version of plugin: $pluginId") - } catch (e: Exception) { - Log.w(TAG, "Error uninstalling existing plugin: ${e.message}") - } - - val fileExtension = if (pluginFile.name.endsWith(".cgp")) ".cgp" else ".apk" - val finalFileName = "${pluginId}$fileExtension" - - if (!pluginsDir.exists()) { - pluginsDir.mkdirs() - } - - val finalFile = File(pluginsDir, finalFileName) - - try { - pluginFile.copyTo(finalFile, overwrite = true) - Log.d(TAG, "Plugin file copied to: ${finalFile.absolutePath}") - pluginFile.delete() - } catch (e: Exception) { - Log.e(TAG, "Failed to copy plugin file to plugins directory", e) - throw e - } - - manager.loadPlugins() - }.onFailure { exception -> - Log.e(TAG, "Failed to install plugin from file: ${pluginFile.absolutePath}", exception) - } - } - - override suspend fun reloadPlugins(): Result = withContext(Dispatchers.IO) { - runCatching { - val manager = pluginManager - ?: throw IllegalStateException("Plugin system not available") - - manager.loadPlugins() - }.onFailure { exception -> - Log.e(TAG, "Failed to reload plugins", exception) - } - } - - override fun isPluginManagerAvailable(): Boolean { - val available = pluginManager != null - return available - } -} \ No newline at end of file + private companion object { + private const val TAG = "PluginRepository" + } + + private val pluginManager: PluginManager? + get() = pluginManagerProvider() + + override suspend fun getAllPlugins(): Result> = + withContext(Dispatchers.IO) { + runCatching { + val manager = + pluginManager + ?: throw IllegalStateException("Plugin system not available") + manager.getAllPlugins() + }.onFailure { exception -> + Log.e(TAG, "Failed to get all plugins", exception) + } + } + + override suspend fun enablePlugin(pluginId: String): Result = + withContext(Dispatchers.IO) { + runCatching { + val manager = + pluginManager + ?: throw IllegalStateException("Plugin system not available") + val result = manager.enablePlugin(pluginId) + result + }.onFailure { exception -> + Log.e(TAG, "Failed to enable plugin: $pluginId", exception) + } + } + + override suspend fun disablePlugin(pluginId: String): Result = + withContext(Dispatchers.IO) { + runCatching { + val manager = + pluginManager + ?: throw IllegalStateException("Plugin system not available") + val result = manager.disablePlugin(pluginId) + result + }.onFailure { exception -> + Log.e(TAG, "Failed to disable plugin: $pluginId", exception) + } + } + + override suspend fun uninstallPlugin(pluginId: String): Result = + withContext(Dispatchers.IO) { + runCatching { + val manager = + pluginManager + ?: throw IllegalStateException("Plugin system not available") + + Log.d(TAG, "Uninstalling plugin: $pluginId") + val result = manager.uninstallPlugin(pluginId) + result + }.onFailure { exception -> + Log.e(TAG, "Failed to uninstall plugin: $pluginId", exception) + } + } + + override suspend fun getPluginMetadataFromFile(pluginFile: File): Result = + withContext(Dispatchers.IO) { + runCatching { + val manager = + pluginManager + ?: throw IllegalStateException("Plugin system not available") + manager.getPluginMetadataOnly(pluginFile).getOrThrow().toPluginMetadata() + } + } + + override suspend fun haveMatchingSignatures( + incomingFile: File, + existingPluginId: String, + ): Result = + withContext(Dispatchers.IO) { + runCatching { + pluginManager?.haveMatchingSignatures(incomingFile, existingPluginId) + ?: throw IllegalStateException("Plugin system not available") + } + } + + override suspend fun installPluginFromFile(pluginFile: File): Result = + withContext(Dispatchers.IO) { + runCatching { + val manager = + pluginManager + ?: throw IllegalStateException("Plugin system not available") + + val validationResult = manager.getPluginValidation(pluginFile) + if (validationResult.isFailure) { + pluginFile.delete() + throw validationResult.exceptionOrNull() + ?: Exception("Failed to read plugin metadata") + } + + val validation = validationResult.getOrNull()!! + val metadata = validation.manifest + val pluginId = metadata.id + + if (validation.isDebug) { + val missing = + listOfNotNull( + "icon_day".takeIf { + metadata.iconDay == null || !validation.iconDayEntryExists + }, + "icon_night".takeIf { + metadata.iconNight == null || !validation.iconNightEntryExists + }, + ).joinToString(" and ") { "\"$it\"" } + if (missing.isNotEmpty()) { + pluginFile.delete() + throw IllegalArgumentException( + "[$pluginId] Missing $missing for debug plugin. Debug plugins must declare and ship both icon_day and icon_night assets.", + ) + } + } + + try { + manager.uninstallPlugin(pluginId) + Log.d(TAG, "Uninstalled existing version of plugin: $pluginId") + } catch (e: Exception) { + Log.w(TAG, "Error uninstalling existing plugin: ${e.message}") + } + + val fileExtension = + if (pluginFile.name.endsWith(".$PLUGIN_ARCHIVE_EXTENSION", ignoreCase = true)) ".$PLUGIN_ARCHIVE_EXTENSION" else ".apk" + val finalFileName = "${pluginId}$fileExtension" + + if (!pluginsDir.exists()) { + pluginsDir.mkdirs() + } + + val finalFile = File(pluginsDir, finalFileName) + + try { + pluginFile.copyTo(finalFile, overwrite = true) + Log.d(TAG, "Plugin file copied to: ${finalFile.absolutePath}") + pluginFile.delete() + } catch (e: Exception) { + Log.e(TAG, "Failed to copy plugin file to plugins directory", e) + throw e + } + + manager.loadPlugins() + + if (manager.getPlugin(pluginId) == null) { + // The new package replaced the previous one but failed to load. Remove the broken + // artifact so subsequent loadPlugins() calls don't keep retrying it. + finalFile.delete() + throw IllegalStateException( + manager.getLoadError(pluginId) + ?: "Plugin \"$pluginId\" was installed but failed to load.", + ) + } + }.onFailure { exception -> + Log.e(TAG, "Failed to install plugin from file: ${pluginFile.absolutePath}", exception) + } + } + + override suspend fun reloadPlugins(): Result = + withContext(Dispatchers.IO) { + runCatching { + val manager = + pluginManager + ?: throw IllegalStateException("Plugin system not available") + + manager.loadPlugins() + }.onFailure { exception -> + Log.e(TAG, "Failed to reload plugins", exception) + } + } + + override fun isPluginManagerAvailable(): Boolean { + val available = pluginManager != null + return available + } +} diff --git a/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepository.kt b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepository.kt new file mode 100644 index 0000000000..29cdccc75d --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepository.kt @@ -0,0 +1,38 @@ +package com.itsaky.androidide.repositories + +import java.io.File + +/** + * Repository interface for template-collection (.cgt) operations. + */ +interface TemplateCollectionRepository { + data class CollectionInfo( + val templateNames: List, + ) + + /** + * Parse and validate a candidate .cgt archive without installing it. + */ + suspend fun inspectCollection(candidateFile: File): Result + + /** + * Returns the filename (without extension) of an already-installed template collection + * matching [baseName] case-insensitively, or `null` if there is no collision. + */ + suspend fun findExistingCollision(baseName: String): String? + + /** + * Install [candidateFile] into the templates directory under [targetBaseName], reloading + * the template provider afterwards. + */ + suspend fun installCollection( + candidateFile: File, + targetBaseName: String, + overwrite: Boolean, + ): Result + + /** + * Check if the templates system is available (i.e. IDE setup has completed). + */ + fun isTemplatesFeatureAvailable(): Boolean +} diff --git a/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt new file mode 100644 index 0000000000..c3900bbac7 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt @@ -0,0 +1,227 @@ +package com.itsaky.androidide.repositories + +import com.itsaky.androidide.templates.ITemplateProvider +import com.itsaky.androidide.templates.TemplateRecipe +import com.itsaky.androidide.templates.impl.TemplateWarning +import com.itsaky.androidide.templates.impl.zip.ZipTemplateReader +import com.itsaky.androidide.utils.Environment +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION +import org.adfa.constants.TEMPLATE_CORE_ARCHIVE +import org.slf4j.LoggerFactory +import java.io.File +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * Implementation of [TemplateCollectionRepository]. Templates are pure data (a zip archive + * copied into [Environment.TEMPLATES_DIR]) so, unlike plugins, installing one never requires an + * app restart - [ITemplateProvider.getInstance] just needs to be reloaded. + * + * All suspend functions here hop to [Dispatchers.IO] internally, so callers don't need to. On + * failure, [installCollection] always leaves its `candidateFile` argument untouched (see that + * function's kdoc) so the caller can retry with the same file. + */ +class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { + private companion object { + private val log = LoggerFactory.getLogger(TemplateCollectionRepositoryImpl::class.java) + + /** Base filename of the bundled default templates archive - reserved, never a user collection. */ + private val RESERVED_BASE_NAME = File(TEMPLATE_CORE_ARCHIVE).nameWithoutExtension + + /** Case-insensitive match by base filename - the only stable "collection identity" available. */ + private fun findCollisionFile( + templatesDir: File, + baseName: String, + ): File? = + templatesDir + .listFiles { file -> file.extension.equals(TEMPLATE_ARCHIVE_EXTENSION, ignoreCase = true) } + ?.firstOrNull { it.nameWithoutExtension.equals(baseName, ignoreCase = true) } + + /** + * Renames [src] to [dst], falling back to copy+delete - renameTo() is unreliable on-device + * even for a same-directory move (confirmed during this PR). [src] is gone on success + * either way; left untouched on failure. + */ + private fun moveFile( + src: File, + dst: File, + ): Boolean = + src.renameTo(dst) || + runCatching { src.copyTo(dst, overwrite = true) }.isSuccess.also { copied -> if (copied) src.delete() } + + // Serializes installCollection() calls targeting the same case-insensitive base name - + // random staging/backup filenames already prevent two concurrent installs from colliding + // on an intermediate path, but without this, both could still pass the collision check + // before either writes destFile, so the later swap would silently clobber the earlier one. + private val installLocks = ConcurrentHashMap() + + private fun installLock(baseName: String): Mutex = installLocks.computeIfAbsent(baseName.lowercase()) { Mutex() } + } + + override suspend fun inspectCollection(candidateFile: File): Result = + withContext(Dispatchers.IO) { + runCatching { + val warnings = mutableListOf() + val templates = + ZipTemplateReader.read(candidateFile, warnings) { _, _, _, _, _ -> + TemplateRecipe { null } + } + + if (templates.isEmpty()) { + warnings.forEach { log.warn("Template read warning: resId={}, args={}", it.resId, it.args) } + throw IllegalArgumentException("No valid templates found in archive: ${candidateFile.name}") + } + + TemplateCollectionRepository.CollectionInfo( + templateNames = templates.map { it.templateNameStr }, + ) + }.onFailure { exception -> + if (exception is CancellationException) throw exception + log.error("Failed to inspect template collection: {}", candidateFile.name, exception) + } + } + + override suspend fun findExistingCollision(baseName: String): String? = + withContext(Dispatchers.IO) { + try { + Environment.TEMPLATES_DIR?.let { findCollisionFile(it, baseName) }?.nameWithoutExtension + } catch (e: CancellationException) { + throw e + } catch (exception: Exception) { + log.error("Failed to check for an existing template collection: {}", baseName, exception) + null + } + } + + /** + * Installs [candidateFile] as `.cgt` in [Environment.TEMPLATES_DIR]. On any + * failure (including a validation error), [candidateFile] is left untouched so the caller can + * retry - it's only deleted once the install has fully succeeded. + */ + override suspend fun installCollection( + candidateFile: File, + targetBaseName: String, + overwrite: Boolean, + ): Result = + withContext(Dispatchers.IO) { + installLock(targetBaseName).withLock { + runCatching { + if (targetBaseName.equals(RESERVED_BASE_NAME, ignoreCase = true)) { + throw IllegalStateException("\"$targetBaseName\" is a reserved name and cannot be used") + } + + // targetBaseName ends up as a single path segment below; reject anything that + // could make it span multiple segments (or escape templatesDir entirely) before + // it ever reaches a File constructor. + if (targetBaseName.isBlank() || + targetBaseName.contains('/') || + targetBaseName.contains('\\') || + targetBaseName == "." || + targetBaseName == ".." + ) { + throw IllegalArgumentException("Invalid template collection name: \"$targetBaseName\"") + } + + val templatesDir = + Environment.TEMPLATES_DIR + ?: throw IllegalStateException("Templates system not available") + + // Reuse the same case-insensitive lookup findExistingCollision() uses, so a + // case-variant match (e.g. installing "mytemplates" when "MyTemplates.cgt" is + // already there) is caught here too instead of silently creating a duplicate. + val existingMatch = findCollisionFile(templatesDir, targetBaseName) + if (existingMatch != null && !overwrite) { + throw IllegalStateException( + "A template collection named \"$targetBaseName\" already exists", + ) + } + + // Overwrite the existing case-variant file in place (preserving its casing) + // rather than create a second, case-differing duplicate. + val destFile = existingMatch ?: File(templatesDir, "$targetBaseName.$TEMPLATE_ARCHIVE_EXTENSION") + + // Belt-and-braces against the character check above: confirm the resolved path + // still lands directly inside templatesDir once symlinks/".." are resolved. + if (destFile.canonicalFile.parentFile != templatesDir.canonicalFile) { + throw IllegalArgumentException("Invalid template collection name: \"$targetBaseName\"") + } + + // Stage a copy of the incoming archive fully under templatesDir before touching + // destFile, so a failure while writing the new content never destroys the + // existing collection. candidateFile itself is deliberately left alone here (not + // moved/deleted) so that if anything below fails, the caller can retry the whole + // call with the same file - it's only deleted once the swap and the provider + // reload have both fully succeeded. The staging (and backup) filenames carry a + // random suffix so two concurrent installCollection() calls targeting the same + // destFile never race on the same intermediate path. + val stagingFile = File(templatesDir, "${destFile.name}.${UUID.randomUUID()}.tmp") + candidateFile.copyTo(stagingFile, overwrite = true) + + // Back up (rather than delete) any existing destFile, so it can be put back if + // the swap below fails for any reason - the existing collection is only ever + // removed once the new one is confirmed successfully in its place. + val hadExisting = destFile.exists() + val backupFile = File(templatesDir, "${destFile.name}.${UUID.randomUUID()}.bak") + if (hadExisting && !moveFile(destFile, backupFile)) { + stagingFile.delete() + throw IllegalStateException("Failed to back up existing file before replacing: ${destFile.name}") + } + + // Both files are now on the same volume (templatesDir), so this is a cheap, + // same-directory move - renameTo() failing here (as opposed to across the + // temp/templates boundary candidateFile itself would have to cross) would be + // unexpected, but moveFile() falls back to a copy anyway. + if (!moveFile(stagingFile, destFile)) { + if (hadExisting && !moveFile(backupFile, destFile)) { + // Nothing more we can do here - surface it loudly rather than silently + // leaving the user's original collection sitting under the backup's + // random filename, invisible to findExistingCollision(). + log.error( + "Failed to restore backup after a failed swap for \"{}\" - original content may still be at: {}", + destFile.name, + backupFile.name, + ) + } + stagingFile.delete() + throw IllegalStateException("Failed to replace existing file: ${destFile.name}") + } + + if (hadExisting && backupFile.exists() && !backupFile.delete()) { + log.warn("Installed but failed to delete backup file: {}", backupFile.name) + } + + // The file swap above is the operation's real postcondition - it already fully + // succeeded by this point. A reload failure here (e.g. templatesDir briefly + // unreadable) shouldn't turn that into a reported failure: doing so would leave + // destFile installed on disk while the caller believes nothing happened and + // retries, immediately hitting a spurious "already exists". + try { + ITemplateProvider.getInstance(reload = true) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.error( + "Template collection installed but the provider failed to reload: {}", + destFile.name, + e, + ) + } + + if (!candidateFile.delete()) { + log.warn("Installed but failed to delete source temp file: {}", candidateFile.name) + } + Unit + }.onFailure { exception -> + if (exception is CancellationException) throw exception + log.error("Failed to install template collection: {}", candidateFile.name, exception) + } + } + } + + override fun isTemplatesFeatureAvailable(): Boolean = Environment.TEMPLATES_DIR != null +} diff --git a/app/src/main/java/com/itsaky/androidide/repositories/TemplateRepository.kt b/app/src/main/java/com/itsaky/androidide/repositories/TemplateRepository.kt new file mode 100644 index 0000000000..ee497b0106 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/repositories/TemplateRepository.kt @@ -0,0 +1,28 @@ +package com.itsaky.androidide.repositories + +import com.itsaky.androidide.templates.manager.models.CgtFileItem + +/** + * Repository interface for template (`.cgt`) file operations. + * + * Unlike [PluginRepository], this talks directly to the filesystem + * (`Environment.TEMPLATES_DIR` + the Downloads folder) rather than through a plugin-facing + * service - the host app doesn't need the `pluginId`/permission indirection that + * `IdeTemplateService` exists for. + */ +interface TemplateRepository { + /** + * Scans `Environment.TEMPLATES_DIR` (installed) and the Downloads folder (not installed) + * for `.cgt` files and parses each into a [CgtFileItem]. + */ + suspend fun listTemplateFiles(): Result> + + /** Moves [item]'s file from Downloads into the templates directory and reloads templates. */ + suspend fun installTemplate(item: CgtFileItem): Result + + /** Restores a copy of [item]'s file to Downloads, removes it from the templates directory, and reloads templates. */ + suspend fun uninstallTemplate(item: CgtFileItem): Result + + /** Deletes a not-installed [item]'s file from Downloads. */ + suspend fun deleteDownloadFile(item: CgtFileItem): Result +} diff --git a/app/src/main/java/com/itsaky/androidide/repositories/TemplateRepositoryImpl.kt b/app/src/main/java/com/itsaky/androidide/repositories/TemplateRepositoryImpl.kt new file mode 100644 index 0000000000..21662008d1 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/repositories/TemplateRepositoryImpl.kt @@ -0,0 +1,186 @@ +package com.itsaky.androidide.repositories + +import com.itsaky.androidide.templates.ITemplateProvider +import com.itsaky.androidide.templates.manager.models.CgtFileItem +import com.itsaky.androidide.templates.manager.models.TemplateProvenance +import com.itsaky.androidide.templates.manager.parsing.CgtTemplateReader +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.adfa.constants.TEMPLATE_CORE_ARCHIVE +import org.json.JSONException +import org.slf4j.LoggerFactory +import java.io.File +import java.io.IOException + +/** + * Implementation of [TemplateRepository]. + * + * Reimplements the install/uninstall/delete semantics of the reference + * `TemplateManagerPlugin` fragment as direct file operations, since the host app already has + * unrestricted access to [templatesDir]/[downloadDir] and doesn't need `IdeTemplateService`'s + * plugin-facing permission gate. + */ +class TemplateRepositoryImpl( + private val templatesDir: File, + private val downloadDir: File, +) : TemplateRepository { + private companion object { + private val logger = LoggerFactory.getLogger(TemplateRepositoryImpl::class.java) + private const val CGT_EXTENSION = "cgt" + private const val PLUGIN_CGT_PREFIX = "plugin_" + } + + override suspend fun listTemplateFiles(): Result> = + withContext(Dispatchers.IO) { + try { + Result.success(scanTemplates()) + } catch (e: CancellationException) { + throw e + } catch (e: IOException) { + logger.error("Failed to scan template files", e) + Result.failure(e) + } catch (e: SecurityException) { + logger.error("Failed to scan template files", e) + Result.failure(e) + } + } + + /** + * One card per archive, not per file on disk. The same `.cgt` name can exist in both + * directories at once - a copy dropped into Downloads by hand, or the picker-install path, + * which copies rather than moves. Both rows would then render identically apart from the + * status line, and the Downloads twin is a dead end: [installTemplate] refuses to overwrite, + * so its Install can only ever fail. The installed copy wins. + * + * Names are compared case-insensitively, matching the stricter of the two install paths + * (`TemplateCollectionRepository.findExistingCollision`), so any row still listed as + * "not installed" is one the user can actually install. + */ + private fun scanTemplates(): List { + val installed = cgtFilesIn(templatesDir).mapNotNull { file -> parseCgtFile(file, installed = true) } + val installedNames = installed.mapTo(mutableSetOf()) { item -> item.name.lowercase() } + val downloaded = + cgtFilesIn(downloadDir) + .filterNot { file -> file.name.lowercase() in installedNames } + .mapNotNull { file -> parseCgtFile(file, installed = false) } + return installed + downloaded + } + + private fun cgtFilesIn(dir: File): List = + dir + .listFiles { file -> file.isFile && file.extension.equals(CGT_EXTENSION, ignoreCase = true) } + ?.sortedBy { it.name } + ?: emptyList() + + /** Parses a .cgt (which may bundle multiple templates) into a card item, or null if it contains no template.json. */ + private fun parseCgtFile( + file: File, + installed: Boolean, + ): CgtFileItem? { + val templates = + try { + file.inputStream().use(CgtTemplateReader::readTemplates) + } catch (e: CancellationException) { + throw e + } catch (e: IOException) { + logger.warn("Failed to parse {}", file.absolutePath, e) + return null + } catch (e: JSONException) { + logger.warn("Failed to parse {}", file.absolutePath, e) + return null + } catch (e: IllegalArgumentException) { + // ZipInputStream.nextEntry throws this for a malformed (non-UTF-8) entry name - + // downloadDir is the public Downloads folder, so a corrupt/hostile .cgt is + // untrusted input, not a programming error. Skip it like any other bad archive. + logger.warn("Failed to parse {}", file.absolutePath, e) + return null + } + if (templates.isEmpty()) return null + return CgtFileItem( + file = file, + name = file.name, + templates = templates, + installed = installed, + provenance = provenanceOf(file.name), + ) + } + + private fun provenanceOf(fileName: String): TemplateProvenance = + when { + fileName == TEMPLATE_CORE_ARCHIVE -> TemplateProvenance.BUNDLED + fileName.startsWith(PLUGIN_CGT_PREFIX) -> TemplateProvenance.PLUGIN + else -> TemplateProvenance.USER + } + + override suspend fun installTemplate(item: CgtFileItem): Result = + withContext(Dispatchers.IO) { + try { + check(!item.installed) { "'${item.name}' is already installed" } + val dest = File(templatesDir, item.file.name) + check(!dest.exists()) { "A template named '${dest.name}' already exists in $templatesDir" } + item.file.copyTo(dest, overwrite = false) + if (!item.file.delete()) { + dest.delete() + throw IOException("Failed to delete source file after copying: ${item.file.absolutePath}") + } + ITemplateProvider.getInstance(reload = true) + Result.success(Unit) + } catch (e: CancellationException) { + throw e + } catch (e: IOException) { + logger.error("Failed to install template: {}", item.name, e) + Result.failure(e) + } catch (e: IllegalStateException) { + logger.error("Failed to install template: {}", item.name, e) + Result.failure(e) + } + } + + override suspend fun uninstallTemplate(item: CgtFileItem): Result = + withContext(Dispatchers.IO) { + try { + check(item.installed) { "'${item.name}' is not installed" } + check(item.provenance != TemplateProvenance.BUNDLED) { "Cannot uninstall the bundled template" } + + // Restore a copy to Downloads BEFORE removing it from the store: if the restore + // throws, the store copy below is never touched, so the user's only copy survives. + val restored = File(downloadDir, item.file.name) + check(!restored.exists()) { "A download named '${restored.name}' already exists in $downloadDir" } + item.file.copyTo(restored, overwrite = false) + if (!item.file.delete()) { + restored.delete() + throw IOException("Failed to delete source file after copying: ${item.file.absolutePath}") + } + ITemplateProvider.getInstance(reload = true) + Result.success(Unit) + } catch (e: CancellationException) { + throw e + } catch (e: IOException) { + logger.error("Failed to uninstall template: {}", item.name, e) + Result.failure(e) + } catch (e: IllegalStateException) { + logger.error("Failed to uninstall template: {}", item.name, e) + Result.failure(e) + } + } + + override suspend fun deleteDownloadFile(item: CgtFileItem): Result = + withContext(Dispatchers.IO) { + try { + check(!item.installed) { "Cannot delete an installed template; uninstall it first" } + if (!item.file.delete()) { + throw IOException("Failed to delete ${item.file.absolutePath}") + } + Result.success(Unit) + } catch (e: CancellationException) { + throw e + } catch (e: IOException) { + logger.error("Failed to delete download file: {}", item.name, e) + Result.failure(e) + } catch (e: IllegalStateException) { + logger.error("Failed to delete download file: {}", item.name, e) + Result.failure(e) + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/roomData/recentproject/RecentProjectDao.kt b/app/src/main/java/com/itsaky/androidide/roomData/recentproject/RecentProjectDao.kt index e93e01b7e1..dcd6be9ace 100644 --- a/app/src/main/java/com/itsaky/androidide/roomData/recentproject/RecentProjectDao.kt +++ b/app/src/main/java/com/itsaky/androidide/roomData/recentproject/RecentProjectDao.kt @@ -7,35 +7,46 @@ import androidx.room.Query @Dao interface RecentProjectDao { + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insert(project: RecentProject) - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(project: RecentProject) + @Query("DELETE FROM recent_project_table WHERE name = :name") + suspend fun deleteByName(name: String) - @Query("DELETE FROM recent_project_table WHERE name = :name") - suspend fun deleteByName(name: String) + @Query("SELECT * FROM recent_project_table order by last_modified DESC, create_at DESC") + suspend fun dumpAll(): List? - @Query("SELECT * FROM recent_project_table order by last_modified DESC, create_at DESC") - suspend fun dumpAll(): List? + @Query("SELECT * FROM recent_project_table WHERE name = :name LIMIT 1") + suspend fun getProjectByName(name: String): RecentProject? - @Query("SELECT * FROM recent_project_table WHERE name = :name LIMIT 1") - suspend fun getProjectByName(name: String): RecentProject? + @Query("SELECT * FROM recent_project_table WHERE name IN (:names)") + suspend fun getProjectsByNames(names: List): List - @Query("SELECT * FROM recent_project_table WHERE name IN (:names)") - suspend fun getProjectsByNames(names: List): List + @Query("DELETE FROM recent_project_table") + suspend fun deleteAll() - @Query("DELETE FROM recent_project_table") - suspend fun deleteAll() + @Query("DELETE FROM recent_project_table WHERE name IN (:names)") + suspend fun deleteByNames(names: List) - @Query("DELETE FROM recent_project_table WHERE name IN (:names)") - suspend fun deleteByNames(names: List) + @Query("UPDATE recent_project_table SET name = :newName, location = :newLocation WHERE name = :oldName") + suspend fun updateNameAndLocation( + oldName: String, + newName: String, + newLocation: String, + ) - @Query("UPDATE recent_project_table SET name = :newName, location = :newLocation WHERE name = :oldName") - suspend fun updateNameAndLocation(oldName: String, newName: String, newLocation: String) + @Query("UPDATE recent_project_table SET last_modified = :lastModified WHERE name = :projectName") + suspend fun updateLastModified( + projectName: String, + lastModified: String, + ) - @Query("UPDATE recent_project_table SET last_modified = :lastModified WHERE name = :projectName") - suspend fun updateLastModified(projectName: String, lastModified: String) - - @Query("SELECT COUNT(*) FROM recent_project_table") - suspend fun getCount(): Int + @Query("UPDATE recent_project_table SET language = :language WHERE location = :location") + suspend fun updateLanguage( + location: String, + language: String, + ) + @Query("SELECT COUNT(*) FROM recent_project_table") + suspend fun getCount(): Int } diff --git a/app/src/main/java/com/itsaky/androidide/templates/manager/models/CgtFileItem.kt b/app/src/main/java/com/itsaky/androidide/templates/manager/models/CgtFileItem.kt new file mode 100644 index 0000000000..e620764d9c --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/templates/manager/models/CgtFileItem.kt @@ -0,0 +1,70 @@ +package com.itsaky.androidide.templates.manager.models + +import java.io.File + +/** + * One `/template/template.json` entry parsed out of a `.cgt` archive. A single `.cgt` + * file can bundle more than one of these (see [CgtFileItem.templates]) - e.g. a plugin's + * archive offering several related project templates. + */ +data class TemplateMetadata( + val name: String, + val description: String, + val version: String, + /** Tags declared under parameters.optional in template.json, e.g. "language (LANGUAGE)". */ + val optionalTags: List = emptyList(), +) + +/** + * Where a `.cgt` file came from, inferred from its filename convention (there is no stable + * template ID: [com.itsaky.androidide.templates.Template.templateId] is a random UUID + * regenerated on every reload). Matches the convention used by + * `IdeTemplateServiceImpl`/`PluginProjectManager` when they write into `Environment.TEMPLATES_DIR`. + */ +enum class TemplateProvenance { + /** The IDE's bundled `core.cgt`. */ + BUNDLED, + + /** Registered by a plugin (`plugin__*.cgt`). */ + PLUGIN, + + /** Anything else - user-imported via this screen or manually copied in. */ + USER, +} + +/** + * One `.cgt` file discovered on disk, backing a single card in the Templates tab. [templates] + * holds every template the archive bundles (see [TemplateMetadata]); [installed] is true when + * [file] lives in `Environment.TEMPLATES_DIR` (the store Gradle reads templates from) rather + * than the Downloads folder, and [provenance] (see [TemplateProvenance]) says who put it there. + */ +data class CgtFileItem( + val file: File, + val name: String, + val templates: List, + val installed: Boolean, + val provenance: TemplateProvenance, +) + +/** The first template's metadata, used to populate the card's title/description/version. */ +val CgtFileItem.primaryTemplate: TemplateMetadata + get() = templates.firstOrNull() ?: TemplateMetadata(name = "", description = "", version = "") + +/** True when this .cgt file bundles more than one template. */ +val CgtFileItem.hasMultipleTemplates: Boolean + get() = templates.size > 1 + +/** [CgtFileItem.name] without the redundant ".cgt" extension, for display only. */ +val CgtFileItem.displayName: String + get() = if (name.endsWith(".cgt", ignoreCase = true)) name.dropLast(4) else name + +/** + * Formats a version for the card's version chip, matching the host Plugin Manager: + * a "v" prefix, and versions with more than three dot-segments truncated to the first + * three plus an ellipsis. Blank versions render as an empty string. + */ +fun versionLabel(version: String): String { + if (version.isBlank()) return "" + val segments = version.split('.') + return if (segments.size > 3) "v${segments.take(3).joinToString(".")}..." else "v$version" +} diff --git a/app/src/main/java/com/itsaky/androidide/templates/manager/parsing/CgtTemplateReader.kt b/app/src/main/java/com/itsaky/androidide/templates/manager/parsing/CgtTemplateReader.kt new file mode 100644 index 0000000000..ea14521b13 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/templates/manager/parsing/CgtTemplateReader.kt @@ -0,0 +1,86 @@ +package com.itsaky.androidide.templates.manager.parsing + +import com.itsaky.androidide.templates.manager.models.TemplateMetadata +import org.json.JSONObject +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.util.zip.ZipInputStream + +/** + * Pure parser for Code On The Go template (`.cgt`) archives. A `.cgt` is a zip that may + * bundle one or more templates, each described by a `/template/template.json` entry. + * + * Kept free of Android/IDE dependencies so it can be unit-tested directly. + */ +object CgtTemplateReader { + private const val TEMPLATE_JSON_SUFFIX = "/template/template.json" + + // template.json is a small manifest; a legitimate one is a few KB at most. Bounding the + // read protects against a corrupt or hostile archive claiming a huge (or streamed, + // size-unknown) entry under that name and exhausting memory via an unbounded readBytes(). + private const val MAX_TEMPLATE_JSON_BYTES = 1 shl 20 // 1 MiB + private const val COPY_BUFFER_SIZE = 8 * 1024 + + /** + * Reads every `/template/template.json` entry from a `.cgt` zip [input] and returns + * one [TemplateMetadata] per entry (empty if the archive contains none). The stream is + * consumed and closed. + */ + fun readTemplates(input: InputStream): List { + val templates = mutableListOf() + ZipInputStream(input).use { zip -> + while (true) { + val entry = zip.nextEntry ?: break + if (!entry.isDirectory && entry.name.endsWith(TEMPLATE_JSON_SUFFIX)) { + val json = JSONObject(readBounded(zip).toString(Charsets.UTF_8)) + templates.add( + TemplateMetadata( + name = json.optString("name"), + description = json.optString("description"), + version = json.optString("version"), + optionalTags = parseOptionalTags(json), + ), + ) + } + zip.closeEntry() + } + } + return templates + } + + /** Reads the current zip entry, throwing [IOException] instead of exceeding [MAX_TEMPLATE_JSON_BYTES]. */ + private fun readBounded(zip: ZipInputStream): ByteArray { + val out = ByteArrayOutputStream() + val buffer = ByteArray(COPY_BUFFER_SIZE) + var total = 0 + while (true) { + val read = zip.read(buffer) + if (read == -1) break + total += read + if (total > MAX_TEMPLATE_JSON_BYTES) { + throw IOException("template.json entry exceeds $MAX_TEMPLATE_JSON_BYTES bytes") + } + out.write(buffer, 0, read) + } + return out.toByteArray() + } + + /** + * Collects the tags declared under `parameters.optional`, each rendered as + * " ()" when the entry carries an identifier, else just "". + */ + fun parseOptionalTags(json: JSONObject): List { + val optional = + json.optJSONObject("parameters")?.optJSONObject("optional") + ?: return emptyList() + val tags = mutableListOf() + val keys = optional.keys() + while (keys.hasNext()) { + val key = keys.next() + val identifier = optional.optJSONObject(key)?.optString("identifier").orEmpty() + tags.add(if (identifier.isNotBlank()) "$key ($identifier)" else key) + } + return tags + } +} 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..4324eec039 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt @@ -76,6 +76,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.withContext +import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION +import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -88,7 +90,7 @@ 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") +private 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/ui/ProjectInfoBottomSheet.kt b/app/src/main/java/com/itsaky/androidide/ui/ProjectInfoBottomSheet.kt index 2040193810..614a70267d 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/ProjectInfoBottomSheet.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/ProjectInfoBottomSheet.kt @@ -7,166 +7,184 @@ import android.view.ViewGroup import android.widget.Toast import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.itsaky.androidide.databinding.LayoutProjectInfoSheetBinding +import com.itsaky.androidide.models.ProjectFile import com.itsaky.androidide.resources.R import com.itsaky.androidide.roomData.recentproject.RecentProject +import com.itsaky.androidide.templates.Language import com.itsaky.androidide.utils.ProjectDetails +import com.itsaky.androidide.utils.capitalizeString import com.itsaky.androidide.utils.formatDate import com.itsaky.androidide.utils.loadProjectDetails import com.itsaky.androidide.utils.viewLifecycleScope import com.termux.shared.interact.ShareUtils.copyTextToClipboard import kotlinx.coroutines.launch -import com.itsaky.androidide.models.ProjectFile class ProjectInfoBottomSheet : BottomSheetDialogFragment() { - companion object { - fun newInstance(project: ProjectFile, recent: RecentProject?): ProjectInfoBottomSheet { - val args = Bundle() - args.putString("name", project.name) - args.putString("path", project.path) - args.putString("created", project.createdAt) - args.putString("modified", project.lastModified) - - args.putString("template", recent?.templateName) - args.putString("lang", recent?.language) - - val fragment = ProjectInfoBottomSheet() - fragment.arguments = args - return fragment - } - } - - private var _binding: LayoutProjectInfoSheetBinding? = null - private val binding get() = _binding!! - - private val pName by lazy { arguments?.getString("name") ?: "" } - private val pPath by lazy { arguments?.getString("path") ?: "" } - private val pCreated by lazy { arguments?.getString("created") } - private val pModified by lazy { arguments?.getString("modified") } - - private val pTemplate by lazy { arguments?.getString("template") } - private val pLang by lazy { arguments?.getString("lang") } - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { - _binding = LayoutProjectInfoSheetBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - bindGeneral() - - setLoadingState(true) - - viewLifecycleScope.launch { - val details = loadProjectDetails(pPath, requireContext()) - - if (isAdded && _binding != null) { - bindStructure(details) - bindBuildSetup(details) - setLoadingState(false) - } - } - - binding.btnClose.setOnClickListener { dismiss() } - } - - // ----------------------------- - // GENERAL - // ----------------------------- - private fun bindGeneral() { - val unknown = getString(R.string.unknown) - - binding.infoName.setLabelAndValue( - getString(R.string.project_info_name), - pName - ) - - binding.infoLocation.setLabelAndValue( - getString(R.string.project_info_path), - pPath - ) - binding.infoLocation.setOnClickListener { copyToClipboard(pPath) } - - binding.infoTemplate.setLabelAndValue( - getString(R.string.project_info_template), - pTemplate ?: unknown - ) - - binding.infoCreatedAt.setLabelAndValue( - getString(R.string.date_created_label), - formatDate(pCreated ?: unknown) - ) - - binding.infoModifiedAt.setLabelAndValue( - getString(R.string.date_modified_label), - formatDate(pModified ?: unknown) - ) - } - - // ----------------------------- - // STRUCTURE - // ----------------------------- - private fun bindStructure(details: ProjectDetails) { - binding.infoSize.setLabelAndValue( - getString(R.string.project_info_size), details.sizeFormatted - ) - binding.infoFilesCount.setLabelAndValue( - getString(R.string.project_info_files_count), details.numberOfFiles.toString() - ) - } - - // ----------------------------- - // BUILD SETUP - // ----------------------------- - private fun bindBuildSetup(details: ProjectDetails) { - val unknown = getString(R.string.unknown) - - binding.infoLanguage.setLabelAndValue( - getString(R.string.wizard_language), - pLang ?: unknown - ) - binding.infoGradleVersion.setLabelAndValue( - getString(R.string.project_info_gradle_v), - details.gradleVersion - ) - binding.infoKotlinVersion.setLabelAndValue( - getString(R.string.project_info_kotlin_v), - details.kotlinVersion - ) - binding.infoJavaVersion.setLabelAndValue( - getString(R.string.project_info_java_v), - details.javaVersion - ) - } - - private fun setLoadingState(isLoading: Boolean) { - if (isLoading) { - binding.progressHeavyData.visibility = View.VISIBLE - binding.containerHeavyData.visibility = View.GONE - } else { - binding.progressHeavyData.visibility = View.GONE - - binding.containerHeavyData.apply { - alpha = 0f - visibility = View.VISIBLE - animate() - .alpha(1f) - .setDuration(300) - .start() - } - } - } - - private fun copyToClipboard(value: String) { - copyTextToClipboard(context, value) - Toast.makeText(requireContext(), getString(R.string.copied), Toast.LENGTH_SHORT).show() - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} \ No newline at end of file + companion object { + fun newInstance( + project: ProjectFile, + recent: RecentProject?, + ): ProjectInfoBottomSheet { + val args = Bundle() + args.putString("name", project.name) + args.putString("path", project.path) + args.putString("created", project.createdAt) + args.putString("modified", project.lastModified) + + args.putString("template", recent?.templateName) + args.putString("lang", recent?.language) + + val fragment = ProjectInfoBottomSheet() + fragment.arguments = args + return fragment + } + } + + private var _binding: LayoutProjectInfoSheetBinding? = null + val binding get() = _binding!! + + private val pName by lazy { arguments?.getString("name") ?: "" } + private val pPath by lazy { arguments?.getString("path") ?: "" } + private val pCreated by lazy { arguments?.getString("created") } + private val pModified by lazy { arguments?.getString("modified") } + + private val pTemplate by lazy { arguments?.getString("template") } + private val pLang by lazy { arguments?.getString("lang") } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = LayoutProjectInfoSheetBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + bindGeneral() + + setLoadingState(true) + + viewLifecycleScope.launch { + val details = loadProjectDetails(pPath, requireContext()) + + if (isAdded && _binding != null) { + bindStructure(details) + bindBuildSetup(details) + setLoadingState(false) + } + } + + binding.btnClose.setOnClickListener { dismiss() } + } + + // ----------------------------- + // GENERAL + // ----------------------------- + private fun bindGeneral() { + val unknown = getString(R.string.unknown) + + binding.infoName.setLabelAndValue( + getString(R.string.project_info_name), + pName, + ) + + binding.infoLocation.setLabelAndValue( + getString(R.string.project_info_path), + pPath, + ) + binding.infoLocation.setOnClickListener { copyToClipboard(pPath) } + + binding.infoTemplate.setLabelAndValue( + getString(R.string.project_info_template), + pTemplate ?: unknown, + ) + + binding.infoCreatedAt.setLabelAndValue( + getString(R.string.date_created_label), + formatDate(pCreated ?: unknown), + ) + + binding.infoModifiedAt.setLabelAndValue( + getString(R.string.date_modified_label), + formatDate(pModified ?: unknown), + ) + } + + // ----------------------------- + // STRUCTURE + // ----------------------------- + private fun bindStructure(details: ProjectDetails) { + binding.infoSize.setLabelAndValue( + getString(R.string.project_info_size), + details.sizeFormatted, + ) + binding.infoFilesCount.setLabelAndValue( + getString(R.string.project_info_files_count), + details.numberOfFiles.toString(), + ) + } + + // ----------------------------- + // BUILD SETUP + // ----------------------------- + private fun bindBuildSetup(details: ProjectDetails) { + val unknown = Language.Unknown.lang + + val languageToDisplay = + pLang?.takeIf { it.isNotBlank() && !it.equals(unknown, ignoreCase = true) } + ?: details.language.takeIf { + it.isNotBlank() && !it.equals(unknown, ignoreCase = true) + } ?: unknown + + binding.infoLanguage.setLabelAndValue( + getString(R.string.wizard_language), + languageToDisplay.capitalizeString(), + ) + binding.infoGradleVersion.setLabelAndValue( + getString(R.string.project_info_gradle_v), + details.gradleVersion, + ) + binding.infoKotlinVersion.setLabelAndValue( + getString(R.string.project_info_kotlin_v), + details.kotlinVersion, + ) + binding.infoJavaVersion.setLabelAndValue( + getString(R.string.project_info_java_v), + details.javaVersion, + ) + } + + private fun setLoadingState(isLoading: Boolean) { + if (isLoading) { + binding.progressHeavyData.visibility = View.VISIBLE + binding.containerHeavyData.visibility = View.GONE + } else { + binding.progressHeavyData.visibility = View.GONE + + binding.containerHeavyData.apply { + alpha = 0f + visibility = View.VISIBLE + animate() + .alpha(1f) + .setDuration(300) + .start() + } + } + } + + private fun copyToClipboard(value: String) { + copyTextToClipboard(context, value) + Toast.makeText(requireContext(), getString(R.string.copied), Toast.LENGTH_SHORT).show() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/ManagerScreen.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/ManagerScreen.kt new file mode 100644 index 0000000000..bf5c7c58f3 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/ManagerScreen.kt @@ -0,0 +1,324 @@ +package com.itsaky.androidide.ui.compose + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.itsaky.androidide.R +import com.itsaky.androidide.activities.ExternalFileInstallDialogs +import com.itsaky.androidide.idetooltips.TooltipManager +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.ui.compose.plugins.PluginManagerContent +import com.itsaky.androidide.ui.compose.templates.TemplateManagerScreen +import com.itsaky.androidide.ui.models.PluginManagerUiEvent +import com.itsaky.androidide.ui.models.TemplateManagerUiEvent +import com.itsaky.androidide.utils.UrlManager +import com.itsaky.androidide.utils.flashError +import com.itsaky.androidide.utils.getFileName +import com.itsaky.androidide.viewmodels.ExternalFileInstallViewModel +import com.itsaky.androidide.viewmodels.PluginManagerViewModel +import com.itsaky.androidide.viewmodels.TemplateManagerViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION +import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION +import org.slf4j.LoggerFactory + +private val log = LoggerFactory.getLogger("ManagerScreen") + +/** Matches Material's conventional disabled-content alpha; M3 has no ContentAlpha equivalent. */ +private const val DISABLED_ALPHA = 0.38f + +private const val TAB_PLUGINS = 0 +private const val TAB_TEMPLATES = 1 + +/** + * A `pointerInput(detectTapGestures(onLongPress = ...))` modifier placed on + * [FloatingActionButton]/[IconButton] never fires: both append their own `clickable` after the + * caller's modifier, so on the `Main` pointer pass their `clickable` (innermost) consumes the + * down event before it reaches this composable's own gesture detector. Driving the long-press + * off the button's own [MutableInteractionSource] sidesteps the race entirely - it observes the + * same press/release stream the button's `clickable` reports, rather than competing for the raw + * pointer event. + * + * Detection alone isn't suppression: [FloatingActionButton] routes to plain `clickable` + * (`detectTapAndPress`), which has no long-press concept, so the finger lift after a long press + * still fires `onClick`. The returned [LongPressAwareClick] latches a flag the moment the + * long-press timeout elapses - strictly before that lift is reported - so the caller's `onClick` + * can check-and-clear it via [LongPressAwareClick.consumeIfSuppressed] to swallow exactly that + * one click. + */ +@Composable +private fun rememberLongPressInteractionSource(onLongPress: () -> Unit): LongPressAwareClick { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val longPressTimeoutMillis = LocalViewConfiguration.current.longPressTimeoutMillis + val currentOnLongPress by rememberUpdatedState(onLongPress) + val suppressNextClick = remember { mutableStateOf(false) } + LaunchedEffect(isPressed) { + if (isPressed) { + // Reset at press start, not dependent on a click to clear it: a long press that + // doesn't end in a tap-up (slide off before lifting, or a focusable tooltip popup + // stealing the gesture) never reaches consumeIfSuppressed(), which would otherwise + // leave the flag latched and silently eat the next real tap. The previous gesture's + // onTap always fires on its own lift, strictly before this ACTION_DOWN, so a click + // that legitimately needs suppressing can never be un-suppressed by this reset. + suppressNextClick.value = false + delay(longPressTimeoutMillis) + suppressNextClick.value = true + currentOnLongPress() + } + } + return LongPressAwareClick(interactionSource, suppressNextClick) +} + +/** See [rememberLongPressInteractionSource]. */ +private class LongPressAwareClick( + val interactionSource: MutableInteractionSource, + private val suppressNextClick: MutableState, +) { + /** Returns true (and clears the flag) if this click is the tail end of a long press. */ + fun consumeIfSuppressed(): Boolean { + if (!suppressNextClick.value) return false + suppressNextClick.value = false + return true + } +} + +/** + * Root screen for `PluginManagerActivity` (ADFA-4928): a single manager with two tabs, Plugins + * and Templates, defaulting to Plugins. Owns the one shared Scaffold/TopAppBar. + * + * The add FAB is shown on both tabs and accepts either archive type - this screen is the + * Extensions Manager, and "add an extension" means the same thing whichever tab you happen to be + * looking at. The picked file is routed by extension and the matching tab is brought forward, so + * the result is visible where it landed. The discover-plugins action stays Plugins-only: it opens + * a plugin catalog, which has no meaning on the Templates tab. + * + * The picker launcher lives here rather than in [PluginManagerContent] because `HorizontalPager` + * disposes the off-screen page: a launcher owned by the Plugins page would not exist while the + * Templates tab is showing, and the FAB is reachable from both. + * + * Forwards each tab's ViewModel one level down to its own content composable rather than + * hoisting all plugin/template UI state up into this shared screen - matches this repo's + * established Koin `by viewModel()` + pass-as-parameter pattern (no koinViewModel() dependency). + */ +@Suppress("ktlint:compose:vm-forwarding-check") +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun ManagerScreen( + activity: ComponentActivity, + pluginViewModel: PluginManagerViewModel, + templateViewModel: TemplateManagerViewModel, + externalFileInstallViewModel: ExternalFileInstallViewModel, + modifier: Modifier = Modifier, +) { + val pagerState = rememberPagerState(pageCount = { 2 }) + val coroutineScope = rememberCoroutineScope() + val rootView = LocalView.current + val pluginUiState by pluginViewModel.uiState.collectAsStateWithLifecycle() + + fun showTooltip() { + TooltipManager.showIdeCategoryTooltip(activity, rootView, TooltipTag.PLUGIN_MANAGER) + } + + val filePickerLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> + uri ?: return@rememberLauncherForActivityResult + try { + activity.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (e: SecurityException) { + log.warn("Could not take persistable URI permission", e) + } + coroutineScope.launch { + // Resolving a content:// display name is a ContentResolver IPC call. + val name = withContext(Dispatchers.IO) { uri.getFileName(activity) } + when { + name.endsWith(".$TEMPLATE_ARCHIVE_EXTENSION", ignoreCase = true) -> { + pagerState.animateScrollToPage(TAB_TEMPLATES) + externalFileInstallViewModel.onReceived(uri) + } + + name.endsWith(".$PLUGIN_ARCHIVE_EXTENSION", ignoreCase = true) -> { + // Bring the Plugins page forward first: it owns the collector for the + // resulting confirmation effect, and the pager disposes it while hidden. + pagerState.animateScrollToPage(TAB_PLUGINS) + pluginViewModel.onEvent(PluginManagerUiEvent.FileSelected(uri)) + } + + else -> { + activity.flashError(activity.getString(R.string.msg_unsupported_extension_file)) + } + } + } + } + + ExternalFileInstallDialogs( + viewModel = externalFileInstallViewModel, + // A .cgp only reaches this ViewModel via the routing above, which sends plugins down the + // ContentUri path instead - so this is defensive, not a live path. + onForwardPlugin = { filePath -> pluginViewModel.onPendingInstallFile(filePath) }, + onFinish = { templateViewModel.onEvent(TemplateManagerUiEvent.LoadTemplates) }, + ) + + Scaffold( + modifier = modifier, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_manager)) }, + windowInsets = WindowInsets(0, 0, 0, 0), + navigationIcon = { + IconButton(onClick = { activity.onBackPressedDispatcher.onBackPressed() }) { + Icon( + painter = painterResource(R.drawable.ic_back), + contentDescription = stringResource(R.string.cd_navigate_back), + ) + } + }, + actions = { + if (pagerState.currentPage == TAB_PLUGINS) { + // Not an IconButton: it appends its own clickable() after this modifier, which + // would compete with combinedClickable's detector for the same pointer events - + // see rememberLongPressInteractionSource's doc. .size(48.dp) matches + // IconButtonTokens' 48dp minimum touch target; combinedClickable's default + // indication already supplies the ripple IconButton would have. + Box( + modifier = + Modifier + .size(48.dp) + .clip(CircleShape) + .combinedClickable( + role = Role.Button, + onLongClickLabel = stringResource(R.string.cd_show_tooltip), + onClick = { + UrlManager.openUrl(activity.getString(R.string.url_discover_plugins), null, activity) + }, + onLongClick = { showTooltip() }, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.ic_download), + contentDescription = stringResource(R.string.action_discover_plugins), + ) + } + } + }, + ) + }, + floatingActionButton = { + val longPressAwareClick = rememberLongPressInteractionSource { showTooltip() } + FloatingActionButton( + onClick = { + // The long press that just showed the tooltip also ends in a finger lift, which + // FloatingActionButton's plain clickable() has no long-press concept to suppress + // on its own - swallow that one click here. + if (longPressAwareClick.consumeIfSuppressed()) return@FloatingActionButton + if (pluginUiState.isInstalling) return@FloatingActionButton + try { + // SAF filters by MIME type, not extension, and neither .cgp nor .cgt has a + // registered one. Document providers report unrecognized extensions as + // "application/octet-stream", but both are zips, and some providers (and most + // cloud providers' own mappings) report "application/zip" instead - an + // octet-stream-only filter hides those with no way to reach them. "*/*" keeps + // every provider's mapping reachable; SAF still honors this ordering for the + // initial filter. The routing above validates the actual pick, since this is + // only an approximation. + filePickerLauncher.launch(arrayOf("application/octet-stream", "application/zip", "*/*")) + } catch (e: ActivityNotFoundException) { + log.warn("No document provider available for the extension file picker", e) + activity.flashError(activity.getString(R.string.msg_no_file_manager)) + } + }, + modifier = Modifier.alpha(if (pluginUiState.isInstalling) DISABLED_ALPHA else 1f), + interactionSource = longPressAwareClick.interactionSource, + ) { + Icon( + painter = painterResource(R.drawable.ic_add), + contentDescription = stringResource(R.string.cd_add), + ) + } + }, + ) { padding -> + Column(modifier = Modifier.padding(padding).fillMaxSize()) { + TabRow(selectedTabIndex = pagerState.currentPage) { + Tab( + selected = pagerState.currentPage == TAB_PLUGINS, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(TAB_PLUGINS) } }, + text = { Text(stringResource(R.string.tab_plugins)) }, + ) + Tab( + selected = pagerState.currentPage == TAB_TEMPLATES, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(TAB_TEMPLATES) } }, + text = { Text(stringResource(R.string.tab_templates)) }, + ) + } + + HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page -> + when (page) { + TAB_PLUGINS -> { + PluginManagerContent( + activity = activity, + viewModel = pluginViewModel, + modifier = Modifier.fillMaxSize(), + ) + } + + TAB_TEMPLATES -> { + TemplateManagerScreen( + activity = activity, + viewModel = templateViewModel, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/TooltipInterop.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/TooltipInterop.kt new file mode 100644 index 0000000000..a6667bb866 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/TooltipInterop.kt @@ -0,0 +1,33 @@ +package com.itsaky.androidide.ui.compose + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import com.itsaky.androidide.R +import com.itsaky.androidide.idetooltips.TooltipManager + +/** + * Wires the existing long-press help system (`idetooltips`) into a composable. Compose has no + * native tooltip entry point yet (the bridge is tracked as ADFA-4381) - this reuses + * [TooltipManager] via interop instead of a one-off popup, anchored to the Compose hierarchy's + * root [android.view.View] since a `content://`/dialog composable has no Android `View` of its + * own to anchor a popup on. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun Modifier.longPressTooltip( + tag: String, + onLongClickLabel: String = stringResource(R.string.cd_show_help), +): Modifier { + val context = LocalContext.current + val anchorView = LocalView.current + return combinedClickable( + onClick = {}, + onLongClickLabel = onLongClickLabel, + onLongClick = { TooltipManager.showIdeCategoryTooltip(context, anchorView, tag) }, + ) +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/common/FileImage.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/common/FileImage.kt new file mode 100644 index 0000000000..fe44da5a1a --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/common/FileImage.kt @@ -0,0 +1,123 @@ +package com.itsaky.androidide.ui.compose.common + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.slf4j.LoggerFactory +import java.io.File +import java.util.concurrent.atomic.AtomicLong + +private val log = LoggerFactory.getLogger("FileImage") +private const val LOG_THROTTLE_MILLIS = 5_000L +private val lastIconLoadFailureLoggedAt = AtomicLong(0L) + +/** Logs at most once every [LOG_THROTTLE_MILLIS] - a bad icon file can recompose repeatedly. */ +private fun logIconLoadFailureThrottled( + message: String, + cause: Throwable, +) { + val now = System.currentTimeMillis() + val last = lastIconLoadFailureLoggedAt.get() + if (now - last >= LOG_THROTTLE_MILLIS && lastIconLoadFailureLoggedAt.compareAndSet(last, now)) { + log.warn(message, cause) + } +} + +/** + * Renders [file] as an image, decoded off the main thread, falling back to [placeholder] while + * loading, if [file] is null/missing, or if decoding fails. Used for locally-stored icons/thumbnails + * (plugin icons, template thumbnails) where the file rarely changes, so a plain decode is enough + * and doesn't warrant an image-loading library dependency. Decoding is bounded to [maxDimension] + * (via [BitmapFactory.Options.inSampleSize]) so a large source image doesn't allocate a full-size + * bitmap just to be scaled down to an icon. + */ +@Composable +fun FileImage( + file: File?, + placeholder: Painter, + contentDescription: String?, + modifier: Modifier = Modifier, + maxDimension: Dp = 40.dp, +) { + val maxDimensionPx = with(LocalDensity.current) { maxDimension.roundToPx() } + + // File.equals() compares paths, so `file` alone would not re-decode when a plugin is + // reinstalled and rewrites its icon at the same path - the stale bitmap would stick. Keying + // on the mtime too is the Compose equivalent of the Glide ObjectKey(lastModified) signature + // the View-based adapter used (ADFA-4446). + val lastModified = file?.lastModified() ?: 0L + + val bitmap by produceState(initialValue = null, file, lastModified, maxDimensionPx) { + value = + file?.let { candidate -> + withContext(Dispatchers.IO) { + try { + if (!candidate.exists()) return@withContext null + decodeBounded(candidate, maxDimensionPx)?.asImageBitmap() + } catch (e: CancellationException) { + throw e + } catch (e: SecurityException) { + logIconLoadFailureThrottled("Denied access while loading an icon", e) + null + } catch (e: OutOfMemoryError) { + logIconLoadFailureThrottled("Out of memory while loading an icon", e) + null + } + } + } + } + + val current = bitmap + if (current != null) { + Image( + bitmap = current, + contentDescription = contentDescription, + modifier = modifier, + contentScale = ContentScale.Fit, + ) + } else { + Image( + painter = placeholder, + contentDescription = contentDescription, + modifier = modifier, + contentScale = ContentScale.Fit, + ) + } +} + +/** Decodes [file] downsampled so neither dimension exceeds [maxDimensionPx] by more than 2x. */ +private fun decodeBounded( + file: File, + maxDimensionPx: Int, +): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(file.absolutePath, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + + var inSampleSize = 1 + // maxDimensionPx <= 0 (e.g. a sub-pixel Dp at a low density) would otherwise make the + // loop condition (a non-negative quotient >= a non-positive bound) permanently true, + // hanging on an unbounded doubling of inSampleSize. Skip downsampling in that case. + if (maxDimensionPx > 0) { + while (maxOf(bounds.outWidth, bounds.outHeight) / (inSampleSize * 2) >= maxDimensionPx) { + inSampleSize *= 2 + } + } + + val decodeOptions = BitmapFactory.Options().apply { this.inSampleSize = inSampleSize } + return BitmapFactory.decodeFile(file.absolutePath, decodeOptions) +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginListItem.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginListItem.kt new file mode 100644 index 0000000000..68644a865f --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginListItem.kt @@ -0,0 +1,153 @@ +package com.itsaky.androidide.ui.compose.plugins + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.itsaky.androidide.R +import com.itsaky.androidide.plugins.PluginInfo +import com.itsaky.androidide.templates.manager.models.versionLabel +import com.itsaky.androidide.ui.compose.common.FileImage +import com.itsaky.androidide.utils.isSystemInDarkMode +import java.io.File + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun PluginListItem( + plugin: PluginInfo, + onEnable: () -> Unit, + onDisable: () -> Unit, + onUninstall: () -> Unit, + onDetails: () -> Unit, + onLongPressTooltip: () -> Unit, + modifier: Modifier = Modifier, +) { + var menuExpanded by remember { mutableStateOf(false) } + val context = LocalContext.current + + Card( + modifier = + modifier + .fillMaxWidth() + .combinedClickable(onClick = onDetails, onLongClick = onLongPressTooltip), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val iconPath = + if (context.isSystemInDarkMode()) { + plugin.metadata.iconNightPath + } else { + plugin.metadata.iconDayPath + } + FileImage( + file = iconPath?.let(::File), + placeholder = painterResource(R.drawable.ic_extension), + contentDescription = null, + modifier = Modifier.size(40.dp), + ) + + Spacer(Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text(plugin.metadata.name, style = MaterialTheme.typography.titleMedium) + Text( + plugin.metadata.description, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Row { + val versionText = versionLabel(plugin.metadata.version) + if (versionText.isNotBlank()) { + Text(versionText, style = MaterialTheme.typography.labelSmall) + Spacer(Modifier.width(8.dp)) + } + Text( + stringResource(R.string.by_author, plugin.metadata.author), + style = MaterialTheme.typography.labelSmall, + ) + } + + val (statusText, statusColor) = + when { + !plugin.isLoaded -> stringResource(R.string.status_not_loaded) to colorResource(R.color.error) + !plugin.isEnabled -> stringResource(R.string.status_disabled) to colorResource(R.color.warning) + else -> stringResource(R.string.status_enabled) to colorResource(R.color.success) + } + Text(statusText, color = statusColor, style = MaterialTheme.typography.labelMedium) + } + + Box { + IconButton(onClick = { menuExpanded = true }) { + Icon( + painter = painterResource(R.drawable.ic_more_vert), + contentDescription = stringResource(R.string.cd_more_options), + ) + } + DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { + if (plugin.isLoaded) { + if (plugin.isEnabled) { + DropdownMenuItem( + text = { Text(stringResource(R.string.disable_plugin)) }, + onClick = { + menuExpanded = false + onDisable() + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.enable_plugin)) }, + onClick = { + menuExpanded = false + onEnable() + }, + ) + } + } + DropdownMenuItem( + text = { Text(stringResource(R.string.uninstall_plugin)) }, + onClick = { + menuExpanded = false + onUninstall() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.plugin_details)) }, + onClick = { + menuExpanded = false + onDetails() + }, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerContent.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerContent.kt new file mode 100644 index 0000000000..9c5c9793a4 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerContent.kt @@ -0,0 +1,298 @@ +package com.itsaky.androidide.ui.compose.plugins + +import android.content.ActivityNotFoundException +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Intent +import android.net.Uri +import android.os.Parcelable +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle +import com.itsaky.androidide.R +import com.itsaky.androidide.idetooltips.TooltipManager +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.plugins.PluginMetadata +import com.itsaky.androidide.ui.models.PluginInstallSource +import com.itsaky.androidide.ui.models.PluginManagerUiEffect +import com.itsaky.androidide.ui.models.PluginManagerUiEvent +import com.itsaky.androidide.utils.DURATION_INDEFINITE +import com.itsaky.androidide.utils.DialogUtils +import com.itsaky.androidide.utils.errorIcon +import com.itsaky.androidide.utils.flashError +import com.itsaky.androidide.utils.flashSuccess +import com.itsaky.androidide.utils.flashbarBuilder +import com.itsaky.androidide.utils.showOnUiThread +import com.itsaky.androidide.viewmodels.PluginManagerViewModel +import kotlinx.parcelize.Parcelize +import org.slf4j.LoggerFactory + +private val log = LoggerFactory.getLogger("PluginManagerContent") + +/** + * Keyed on the plugin's id rather than holding a [com.itsaky.androidide.plugins.PluginInfo] + * directly: `PluginInfo` isn't Parcelable, so an id is what makes this `rememberSaveable`-able + * across rotation/tab-switch (`HorizontalPager` disposes the off-screen page's state) without + * changing `plugin-api`'s public API surface. Resolved back to the live `PluginInfo` from + * [com.itsaky.androidide.ui.models.PluginManagerUiState.plugins] at the point of use; an id with + * no match (e.g. the plugin was uninstalled elsewhere) is treated as "nothing to show" rather than + * rendered with stale data. [PluginMetadata] (already Parcelable) is kept inline for + * [OverwriteConfirm.incomingMetadata], which describes a plugin not yet installed and so has no id + * to look up. + */ +private sealed interface PluginManagerDialogState : Parcelable { + @Parcelize + data object None : PluginManagerDialogState + + @Parcelize + data class InstallConfirm( + val source: PluginInstallSource, + ) : PluginManagerDialogState + + @Parcelize + data class OverwriteConfirm( + val existingId: String, + val incomingMetadata: PluginMetadata, + val source: PluginInstallSource, + val deleteSourceAfterInstall: Boolean, + ) : PluginManagerDialogState + + @Parcelize + data class UninstallConfirm( + val pluginId: String, + ) : PluginManagerDialogState + + @Parcelize + data class Details( + val pluginId: String, + ) : PluginManagerDialogState +} + +/** + * Plugins tab content (ADR 0009). Preserves every capability of the original + * `PluginManagerActivity`/`activity_plugin_manager.xml` screen: install (via SAF picker; + * the launcher lives here, the FAB that triggers it lives in + * [com.itsaky.androidide.ui.compose.ManagerScreen], which owns the shared Scaffold)/ + * enable/disable/uninstall, overwrite/signature-mismatch conflict handling, restart prompt. + * + * Content-only (no Scaffold/TopAppBar/FAB): composed as one tab's body inside the shared manager + * screen alongside the Templates tab. + * + * The original wired the same long-press tooltip (`TooltipTag.PLUGIN_MANAGER`) to six separate + * views. Since they all show identical content, this collapses to two anchor points here: each + * list item (already handles its own tap-for-details gesture) and the screen's background/empty + * state area - long-pressing anywhere else on the screen shows the same tooltip. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun PluginManagerContent( + activity: ComponentActivity, + viewModel: PluginManagerViewModel, + modifier: Modifier = Modifier, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var dialogState by rememberSaveable { mutableStateOf(PluginManagerDialogState.None) } + val rootView = LocalView.current + val lifecycleOwner = LocalLifecycleOwner.current + + fun showTooltip() { + TooltipManager.showIdeCategoryTooltip(activity, rootView, TooltipTag.PLUGIN_MANAGER) + } + + LaunchedEffect(viewModel, lifecycleOwner) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiEffect.collect { effect -> + when (effect) { + is PluginManagerUiEffect.ShowError -> { + val message = activity.getString(effect.messageResId, *effect.formatArgs.toTypedArray()) + val builder = + activity + .flashbarBuilder(duration = if (effect.formatArgs.isEmpty()) 5000L else DURATION_INDEFINITE) + .errorIcon() + .message(message) + if (effect.formatArgs.isNotEmpty()) { + builder + .positiveActionText(R.string.copy) + .positiveActionTapListener { bar -> + activity + .getSystemService(ClipboardManager::class.java) + ?.setPrimaryClip( + ClipData.newPlainText(activity.getString(R.string.msg_plugin_error_clip_label), message), + ) + bar.dismiss() + } + } + builder.showOnUiThread() + } + + is PluginManagerUiEffect.ShowSuccess -> { + activity.flashSuccess(activity.getString(effect.messageResId)) + } + + is PluginManagerUiEffect.ShowPluginDetails -> { + dialogState = PluginManagerDialogState.Details(effect.plugin.metadata.id) + } + + is PluginManagerUiEffect.ShowInstallConfirmation -> { + dialogState = PluginManagerDialogState.InstallConfirm(effect.source) + } + + is PluginManagerUiEffect.ShowUninstallConfirmation -> { + dialogState = PluginManagerDialogState.UninstallConfirm(effect.plugin.metadata.id) + } + + is PluginManagerUiEffect.ShowRestartPrompt -> { + DialogUtils.showRestartPrompt(activity) + } + + is PluginManagerUiEffect.ShowOverwriteConfirmation -> { + dialogState = + PluginManagerDialogState.OverwriteConfirm( + existingId = effect.existing.metadata.id, + incomingMetadata = effect.incomingMetadata, + source = effect.source, + deleteSourceAfterInstall = effect.deleteSourceAfterInstall, + ) + } + } + } + } + } + + Box( + modifier = + modifier + .fillMaxSize() + .pointerInput(Unit) { detectTapGestures(onLongPress = { showTooltip() }) }, + ) { + if (uiState.showEmptyState) { + PluginManagerEmptyState(modifier = Modifier.fillMaxSize()) + } else { + LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) { + items(uiState.plugins, key = { it.metadata.id }) { plugin -> + PluginListItem( + plugin = plugin, + onEnable = { viewModel.onEvent(PluginManagerUiEvent.EnablePlugin(plugin.metadata.id)) }, + onDisable = { viewModel.onEvent(PluginManagerUiEvent.DisablePlugin(plugin.metadata.id)) }, + onUninstall = { viewModel.onEvent(PluginManagerUiEvent.UninstallPlugin(plugin.metadata.id)) }, + onDetails = { viewModel.onEvent(PluginManagerUiEvent.ShowPluginDetails(plugin)) }, + onLongPressTooltip = { showTooltip() }, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + } + } + } + + when (val dialog = dialogState) { + is PluginManagerDialogState.None -> {} + + is PluginManagerDialogState.InstallConfirm -> { + InstallConfirmationDialog( + onConfirm = { deleteSource -> + viewModel.onEvent(PluginManagerUiEvent.InstallPlugin(dialog.source, deleteSource)) + dialogState = PluginManagerDialogState.None + }, + onDismiss = { + // Declining a forwarded install must dispose of the temp copy + // ExternalFileInstallActivity made for us; a user-picked ContentUri is left + // untouched. CancelPendingInstall encapsulates that distinction. + viewModel.onEvent(PluginManagerUiEvent.CancelPendingInstall(dialog.source)) + dialogState = PluginManagerDialogState.None + }, + ) + } + + is PluginManagerDialogState.OverwriteConfirm -> { + val existing = uiState.plugins.firstOrNull { it.metadata.id == dialog.existingId } + if (existing != null) { + OverwriteConfirmationDialog( + existing = existing, + incomingMetadata = dialog.incomingMetadata, + onConfirm = { + viewModel.onEvent( + PluginManagerUiEvent.ConfirmOverwrite(dialog.source, dialog.deleteSourceAfterInstall), + ) + dialogState = PluginManagerDialogState.None + }, + onDismiss = { dialogState = PluginManagerDialogState.None }, + ) + } + } + + is PluginManagerDialogState.UninstallConfirm -> { + val plugin = uiState.plugins.firstOrNull { it.metadata.id == dialog.pluginId } + if (plugin != null) { + UninstallConfirmationDialog( + plugin = plugin, + onConfirm = { + viewModel.confirmUninstallPlugin(plugin.metadata.id) + dialogState = PluginManagerDialogState.None + }, + onDismiss = { dialogState = PluginManagerDialogState.None }, + ) + } + } + + is PluginManagerDialogState.Details -> { + val plugin = uiState.plugins.firstOrNull { it.metadata.id == dialog.pluginId } + if (plugin != null) { + PluginDetailsDialog( + plugin = plugin, + onDismiss = { dialogState = PluginManagerDialogState.None }, + ) + } + } + } +} + +@Composable +private fun PluginManagerEmptyState(modifier: Modifier = Modifier) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + painter = painterResource(R.drawable.ic_package), + contentDescription = null, + modifier = + Modifier + .size(64.dp) + .padding(bottom = 16.dp), + ) + Text(stringResource(R.string.no_plugins_installed), style = MaterialTheme.typography.headlineSmall) + Text( + stringResource(R.string.no_plugins_installed_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerDialogs.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerDialogs.kt new file mode 100644 index 0000000000..bdd1d39a9f --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerDialogs.kt @@ -0,0 +1,131 @@ +package com.itsaky.androidide.ui.compose.plugins + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.itsaky.androidide.R +import com.itsaky.androidide.plugins.PluginInfo +import com.itsaky.androidide.plugins.PluginMetadata + +@Composable +fun InstallConfirmationDialog( + onConfirm: (deleteSourceAfterInstall: Boolean) -> Unit, + onDismiss: () -> Unit, +) { + var deleteSource by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.title_install_plugin)) }, + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = deleteSource, onCheckedChange = { deleteSource = it }) + Text(stringResource(R.string.checkbox_delete_source_after_install)) + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(deleteSource) }) { Text(stringResource(R.string.btn_install)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + }, + ) +} + +@Composable +fun OverwriteConfirmationDialog( + existing: PluginInfo, + incomingMetadata: PluginMetadata, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.title_plugin_already_installed)) }, + text = { + Text( + stringResource( + R.string.msg_plugin_overwrite_confirm, + existing.metadata.name, + existing.metadata.version, + incomingMetadata.version, + ), + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { Text(stringResource(R.string.replace)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + }, + ) +} + +@Composable +fun UninstallConfirmationDialog( + plugin: PluginInfo, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.title_uninstall_plugin)) }, + text = { Text(stringResource(R.string.msg_uninstall_plugin_confirm, plugin.metadata.name)) }, + confirmButton = { + TextButton(onClick = onConfirm) { Text(stringResource(R.string.uninstall_plugin)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + }, + ) +} + +@Composable +fun PluginDetailsDialog( + plugin: PluginInfo, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(plugin.metadata.name) }, + text = { + SelectionContainer { + Column(modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) { + DetailRow(stringResource(R.string.label_plugin_name), plugin.metadata.name) + DetailRow(stringResource(R.string.label_plugin_id), plugin.metadata.id) + DetailRow(stringResource(R.string.label_plugin_version), plugin.metadata.version) + DetailRow(stringResource(R.string.label_plugin_author), plugin.metadata.author) + DetailRow(stringResource(R.string.label_plugin_description), plugin.metadata.description) + DetailRow(stringResource(R.string.label_plugin_min_ide_version), plugin.metadata.minIdeVersion) + DetailRow(stringResource(R.string.plugin_permissions), plugin.metadata.permissions.joinToString(", ")) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.msg_ok)) } + }, + ) +} + +@Composable +private fun DetailRow( + label: String, + value: String, +) { + Text(stringResource(R.string.label_value, label, value)) +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateListItem.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateListItem.kt new file mode 100644 index 0000000000..ba9346c603 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateListItem.kt @@ -0,0 +1,184 @@ +package com.itsaky.androidide.ui.compose.templates + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.itsaky.androidide.R +import com.itsaky.androidide.templates.manager.models.CgtFileItem +import com.itsaky.androidide.templates.manager.models.TemplateProvenance +import com.itsaky.androidide.templates.manager.models.displayName +import com.itsaky.androidide.templates.manager.models.hasMultipleTemplates +import com.itsaky.androidide.templates.manager.models.primaryTemplate +import com.itsaky.androidide.templates.manager.models.versionLabel + +/** + * Card for a single `.cgt` file. Matches the reference plugin's card: tapping the card only + * opens the multi-template sub-list when the file bundles more than one template; single-template + * files are only actionable through the overflow menu. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun TemplateListItem( + item: CgtFileItem, + onInstall: () -> Unit, + onUninstall: () -> Unit, + onDetails: () -> Unit, + onDelete: () -> Unit, + onViewTemplates: () -> Unit, + onLongPressTooltip: () -> Unit, + modifier: Modifier = Modifier, +) { + var menuExpanded by remember { mutableStateOf(false) } + val primary = item.primaryTemplate + + Card( + modifier = + modifier + .fillMaxWidth() + .let { cardModifier -> + if (item.hasMultipleTemplates) { + cardModifier.combinedClickable(onClick = onViewTemplates, onLongClick = onLongPressTooltip) + } else { + cardModifier.pointerInput(Unit) { + detectTapGestures(onLongPress = { onLongPressTooltip() }) + } + } + }, + ) { + Row(modifier = Modifier.padding(16.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(primary.name.ifBlank { item.displayName }, style = MaterialTheme.typography.titleMedium) + Text( + primary.description, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + val versionText = versionLabel(primary.version) + if (versionText.isNotBlank()) { + Text(versionText, style = MaterialTheme.typography.labelSmall) + } + Text(item.displayName, style = MaterialTheme.typography.labelSmall) + + if (item.hasMultipleTemplates) { + Text( + pluralStringResource( + R.plurals.template_contains_count, + item.templates.size, + item.templates.size, + ), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.clickable(onClick = onViewTemplates), + ) + } + + Row { + val (statusText, statusColor) = + if (item.installed) { + stringResource(R.string.status_template_installed) to colorResource(R.color.success) + } else { + stringResource(R.string.status_template_not_installed) to colorResource(R.color.error) + } + Text(statusText, color = statusColor, style = MaterialTheme.typography.labelMedium) + Text( + stringResource(R.string.label_separator) + stringResource(item.provenance.labelRes()), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Box { + IconButton(onClick = { menuExpanded = true }) { + Icon( + painter = painterResource(R.drawable.ic_more_vert), + contentDescription = stringResource(R.string.cd_more_options), + ) + } + DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { + if (item.installed) { + if (item.provenance != TemplateProvenance.BUNDLED) { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_uninstall_template)) }, + onClick = { + menuExpanded = false + onUninstall() + }, + ) + } + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_install_template)) }, + onClick = { + menuExpanded = false + onInstall() + }, + ) + } + + if (item.hasMultipleTemplates) { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_view_templates)) }, + onClick = { + menuExpanded = false + onViewTemplates() + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.template_details)) }, + onClick = { + menuExpanded = false + onDetails() + }, + ) + } + + if (!item.installed) { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_delete_template)) }, + onClick = { + menuExpanded = false + onDelete() + }, + ) + } + } + } + } + } +} + +private fun TemplateProvenance.labelRes(): Int = + when (this) { + TemplateProvenance.BUNDLED -> R.string.template_provenance_bundled + TemplateProvenance.PLUGIN -> R.string.template_provenance_plugin + TemplateProvenance.USER -> R.string.template_provenance_user + } diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateManagerDialogs.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateManagerDialogs.kt new file mode 100644 index 0000000000..dcdb2f54e3 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateManagerDialogs.kt @@ -0,0 +1,165 @@ +package com.itsaky.androidide.ui.compose.templates + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.itsaky.androidide.R +import com.itsaky.androidide.templates.manager.models.CgtFileItem +import com.itsaky.androidide.templates.manager.models.TemplateMetadata +import com.itsaky.androidide.templates.manager.models.displayName +import com.itsaky.androidide.templates.manager.models.primaryTemplate +import com.itsaky.androidide.templates.manager.models.versionLabel + +@Composable +fun DeleteTemplateConfirmationDialog( + item: CgtFileItem, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.title_delete_template)) }, + text = { Text(stringResource(R.string.msg_delete_template_confirm, item.displayName)) }, + confirmButton = { + TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_delete_template)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + }, + ) +} + +/** File-level details for a single-template .cgt (multi-template files use [TemplateListDialog]). */ +@Composable +fun TemplateFileDetailsDialog( + item: CgtFileItem, + onDismiss: () -> Unit, +) { + val primary = item.primaryTemplate + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(primary.name.ifBlank { item.displayName }) }, + text = { + SelectionContainer { + Column(modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) { + DetailRow(stringResource(R.string.label_template_file), item.displayName) + DetailRow( + stringResource(R.string.label_template_status), + stringResource( + if (item.installed) R.string.status_template_installed else R.string.status_template_not_installed, + ), + ) + DetailRow(stringResource(R.string.label_template_location), item.file.absolutePath) + TemplateMetadataDetails(primary) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.btn_close)) } + }, + ) +} + +/** Details for a single template selected from the [TemplateListDialog] sub-screen. */ +@Composable +fun TemplateDetailsDialog( + template: TemplateMetadata, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(template.name.ifBlank { stringResource(R.string.template_unnamed) }) }, + text = { + SelectionContainer { + Column(modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) { + TemplateMetadataDetails(template) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.btn_close)) } + }, + ) +} + +@Composable +private fun TemplateMetadataDetails(template: TemplateMetadata) { + val versionText = versionLabel(template.version) + if (versionText.isNotBlank()) { + DetailRow(stringResource(R.string.label_template_version), versionText) + } + DetailRow(stringResource(R.string.label_template_description), template.description) + if (template.optionalTags.isNotEmpty()) { + Text(stringResource(R.string.label_template_optional_params), style = MaterialTheme.typography.labelLarge) + template.optionalTags.forEach { tag -> Text(stringResource(R.string.template_optional_tag, tag)) } + } +} + +@Composable +private fun DetailRow( + label: String, + value: String, +) { + Text(stringResource(R.string.label_value, label, value)) +} + +/** Sub-screen: one card per template bundled inside a multi-template .cgt. */ +@Composable +fun TemplateListDialog( + item: CgtFileItem, + onSelectTemplate: (TemplateMetadata) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.title_templates_in, item.displayName)) }, + text = { + LazyColumn { + items(item.templates) { template -> + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clickable { onSelectTemplate(template) } + .padding(12.dp), + ) { + Text( + template.name.ifBlank { stringResource(R.string.template_unnamed) }, + style = MaterialTheme.typography.titleSmall, + ) + val versionText = versionLabel(template.version) + if (versionText.isNotBlank()) { + Text(versionText, style = MaterialTheme.typography.labelSmall) + } + Text(template.description, style = MaterialTheme.typography.bodySmall) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.btn_close)) } + }, + ) +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateManagerScreen.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateManagerScreen.kt new file mode 100644 index 0000000000..4e6da44a51 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateManagerScreen.kt @@ -0,0 +1,267 @@ +package com.itsaky.androidide.ui.compose.templates + +import android.content.ClipData +import android.content.ClipboardManager +import android.os.Parcelable +import androidx.activity.ComponentActivity +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle +import com.itsaky.androidide.R +import com.itsaky.androidide.idetooltips.TooltipManager +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.templates.manager.models.CgtFileItem +import com.itsaky.androidide.ui.models.TemplateManagerUiEffect +import com.itsaky.androidide.ui.models.TemplateManagerUiEvent +import com.itsaky.androidide.utils.DURATION_INDEFINITE +import com.itsaky.androidide.utils.errorIcon +import com.itsaky.androidide.utils.flashSuccess +import com.itsaky.androidide.utils.flashbarBuilder +import com.itsaky.androidide.utils.showOnUiThread +import com.itsaky.androidide.viewmodels.TemplateManagerViewModel +import kotlinx.parcelize.Parcelize + +/** + * Keyed on the backing file's absolute path rather than holding a [CgtFileItem] directly: the + * item carries a plain `java.io.File`, which isn't Parcelable, so a path is what makes this + * `rememberSaveable`-able across rotation/tab-switch (`HorizontalPager` disposes the off-screen + * page's state) without teaching the whole CgtFileItem/TemplateMetadata chain to be Parcelable. + * Resolved back to the live [CgtFileItem] from [TemplateManagerUiState.items][com.itsaky.androidide.ui.models.TemplateManagerUiState] + * at the point of use; a path with no match (e.g. process death mid-scan, or the file was since + * removed) is treated as "nothing to show" rather than rendered with stale data. + */ +private sealed interface TemplateManagerDialogState : Parcelable { + @Parcelize + data object None : TemplateManagerDialogState + + @Parcelize + data class DeleteConfirm( + val path: String, + ) : TemplateManagerDialogState + + @Parcelize + data class FileDetails( + val path: String, + ) : TemplateManagerDialogState + + @Parcelize + data class TemplateList( + val path: String, + ) : TemplateManagerDialogState +} + +/** See [TemplateManagerDialogState]; same reasoning for the nested template-details dialog. */ +@Parcelize +private data class SelectedTemplateKey( + val ownerPath: String, + val index: Int, +) : Parcelable + +/** + * Templates tab content (ADR 0009). Passively scans `Environment.TEMPLATES_DIR` + the Downloads + * folder for `.cgt` files - unlike the Plugins tab, there's no FAB/file-picker install flow here, + * matching the reference `TemplateManagerPlugin`'s design. + * + * Content-only (no Scaffold/TopAppBar): meant to be composed as one tab's body inside the shared + * manager screen alongside the Plugins tab. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun TemplateManagerScreen( + activity: ComponentActivity, + viewModel: TemplateManagerViewModel, + modifier: Modifier = Modifier, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var dialogState by rememberSaveable { mutableStateOf(TemplateManagerDialogState.None) } + var selectedTemplateKey by rememberSaveable { mutableStateOf(null) } + val selectedTemplateDetails = + selectedTemplateKey?.let { key -> + uiState.items + .firstOrNull { it.file.absolutePath == key.ownerPath } + ?.templates + ?.getOrNull(key.index) + } + val rootView = LocalView.current + val lifecycleOwner = LocalLifecycleOwner.current + + fun showTooltip() { + TooltipManager.showIdeCategoryTooltip(activity, rootView, TooltipTag.TEMPLATE_MANAGER) + } + + LaunchedEffect(viewModel, lifecycleOwner) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiEffect.collect { effect -> + when (effect) { + is TemplateManagerUiEffect.ShowError -> { + val message = activity.getString(effect.messageResId, *effect.formatArgs.toTypedArray()) + val builder = + activity + .flashbarBuilder(duration = if (effect.formatArgs.isEmpty()) 5000L else DURATION_INDEFINITE) + .errorIcon() + .message(message) + if (effect.formatArgs.isNotEmpty()) { + builder + .positiveActionText(R.string.copy) + .positiveActionTapListener { bar -> + activity + .getSystemService(ClipboardManager::class.java) + ?.setPrimaryClip( + ClipData.newPlainText(activity.getString(R.string.msg_template_error_clip_label), message), + ) + bar.dismiss() + } + } + builder.showOnUiThread() + } + + is TemplateManagerUiEffect.ShowSuccess -> { + activity.flashSuccess( + activity.getString(effect.messageResId, *effect.formatArgs.toTypedArray()), + ) + } + + is TemplateManagerUiEffect.ShowDeleteConfirmation -> { + dialogState = TemplateManagerDialogState.DeleteConfirm(effect.item.file.absolutePath) + } + + is TemplateManagerUiEffect.ShowTemplateDetails -> { + dialogState = TemplateManagerDialogState.FileDetails(effect.item.file.absolutePath) + } + + is TemplateManagerUiEffect.ShowTemplateList -> { + dialogState = TemplateManagerDialogState.TemplateList(effect.item.file.absolutePath) + } + } + } + } + } + + Box( + modifier = + modifier + .fillMaxSize() + .pointerInput(Unit) { detectTapGestures(onLongPress = { showTooltip() }) }, + ) { + if (uiState.isEmpty) { + TemplateManagerEmptyState(modifier = Modifier.fillMaxSize()) + } else { + LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) { + items(uiState.items, key = { it.file.absolutePath }) { item -> + TemplateListItem( + item = item, + onInstall = { viewModel.onEvent(TemplateManagerUiEvent.InstallTemplate(item)) }, + onUninstall = { viewModel.onEvent(TemplateManagerUiEvent.UninstallTemplate(item)) }, + onDetails = { viewModel.onEvent(TemplateManagerUiEvent.ShowTemplateDetails(item)) }, + onDelete = { viewModel.onEvent(TemplateManagerUiEvent.DeleteDownloadFile(item)) }, + onViewTemplates = { viewModel.onEvent(TemplateManagerUiEvent.ShowTemplateList(item)) }, + onLongPressTooltip = { showTooltip() }, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + } + } + + // Covers both the initial scan and install/uninstall/delete, which reload the whole + // provider afterwards - see TemplateManagerViewModel. Top-aligned so it doesn't hide the + // list underneath while an operation that already has visible content is in flight. + if (uiState.isLoading) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter)) + } + } + + when (val dialog = dialogState) { + is TemplateManagerDialogState.None -> {} + + is TemplateManagerDialogState.DeleteConfirm -> { + val item = uiState.items.firstOrNull { it.file.absolutePath == dialog.path } + if (item != null) { + DeleteTemplateConfirmationDialog( + item = item, + onConfirm = { + viewModel.confirmDeleteDownloadFile(item) + dialogState = TemplateManagerDialogState.None + }, + onDismiss = { dialogState = TemplateManagerDialogState.None }, + ) + } + } + + is TemplateManagerDialogState.FileDetails -> { + val item = uiState.items.firstOrNull { it.file.absolutePath == dialog.path } + if (item != null) { + TemplateFileDetailsDialog( + item = item, + onDismiss = { dialogState = TemplateManagerDialogState.None }, + ) + } + } + + is TemplateManagerDialogState.TemplateList -> { + val item = uiState.items.firstOrNull { it.file.absolutePath == dialog.path } + if (item != null) { + TemplateListDialog( + item = item, + onSelectTemplate = { template -> + selectedTemplateKey = SelectedTemplateKey(item.file.absolutePath, item.templates.indexOf(template)) + }, + onDismiss = { dialogState = TemplateManagerDialogState.None }, + ) + } + } + } + + selectedTemplateDetails?.let { template -> + TemplateDetailsDialog(template = template, onDismiss = { selectedTemplateKey = null }) + } +} + +@Composable +private fun TemplateManagerEmptyState(modifier: Modifier = Modifier) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + painter = painterResource(R.drawable.ic_docs), + contentDescription = null, + modifier = + Modifier + .size(64.dp) + .padding(bottom = 16.dp), + ) + Text(stringResource(R.string.no_templates_found), style = MaterialTheme.typography.headlineSmall) + Text( + stringResource(R.string.no_templates_found_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/theme/ManagerTheme.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/theme/ManagerTheme.kt new file mode 100644 index 0000000000..e5b5ae8c27 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/theme/ManagerTheme.kt @@ -0,0 +1,59 @@ +package com.itsaky.androidide.ui.compose.theme + +import android.content.Context +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import com.google.android.material.color.MaterialColors +import com.google.android.material.R as MatR + +private const val UNRESOLVED_COLOR = Int.MIN_VALUE + +/** + * Wraps manager-screen content (plugin/template manager) in a [MaterialTheme] whose colors are + * read live from the IDE's XML `Theme.AndroidIDE`, so this first Compose screen in `app` stays + * visually consistent with the surrounding View-based UI, including light/dark and the + * BlueWave/SunnyGlow theme variants (all of which override the same Material attrs). + */ +@Composable +fun ManagerTheme(content: @Composable () -> Unit) { + val context = LocalContext.current + val dark = isSystemInDarkTheme() + val colorScheme = remember(context, dark) { context.toComposeColorScheme(dark) } + MaterialTheme(colorScheme = colorScheme, content = content) +} + +private fun Context.toComposeColorScheme(dark: Boolean): ColorScheme { + val base = if (dark) darkColorScheme() else lightColorScheme() + + fun color( + attr: Int, + fallback: Color, + ): Color { + val resolved = MaterialColors.getColor(this, attr, UNRESOLVED_COLOR) + return if (resolved == UNRESOLVED_COLOR) fallback else Color(resolved) + } + + return base.copy( + primary = color(MatR.attr.colorPrimary, base.primary), + onPrimary = color(MatR.attr.colorOnPrimary, base.onPrimary), + primaryContainer = color(MatR.attr.colorPrimaryContainer, base.primaryContainer), + onPrimaryContainer = color(MatR.attr.colorOnPrimaryContainer, base.onPrimaryContainer), + secondary = color(MatR.attr.colorSecondary, base.secondary), + onSecondary = color(MatR.attr.colorOnSecondary, base.onSecondary), + surface = color(MatR.attr.colorSurface, base.surface), + onSurface = color(MatR.attr.colorOnSurface, base.onSurface), + surfaceVariant = color(MatR.attr.colorSurfaceVariant, base.surfaceVariant), + onSurfaceVariant = color(MatR.attr.colorOnSurfaceVariant, base.onSurfaceVariant), + outline = color(MatR.attr.colorOutline, base.outline), + error = color(MatR.attr.colorError, base.error), + onError = color(MatR.attr.colorOnError, base.onError), + background = color(android.R.attr.colorBackground, base.background), + ) +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/models/ExternalFileInstallUiModels.kt b/app/src/main/java/com/itsaky/androidide/ui/models/ExternalFileInstallUiModels.kt new file mode 100644 index 0000000000..b319b686a9 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/models/ExternalFileInstallUiModels.kt @@ -0,0 +1,52 @@ +package com.itsaky.androidide.ui.models + +import androidx.annotation.StringRes +import com.itsaky.androidide.repositories.TemplateCollectionRepository +import java.io.File + +sealed class ExternalFileInstallUiEvent { + data class ConfirmTemplateInstall( + val tempFile: File, + val targetBaseName: String, + val overwrite: Boolean, + ) : ExternalFileInstallUiEvent() + + data class IgnoreTemplateInstall( + val tempFile: File, + ) : ExternalFileInstallUiEvent() +} + +sealed class ExternalFileInstallUiEffect { + data class ForwardToPluginManager( + val filePath: String, + ) : ExternalFileInstallUiEffect() + + data class ShowTemplateInstallConfirmation( + val info: TemplateCollectionRepository.CollectionInfo, + val tempFile: File, + val suggestedBaseName: String, + ) : ExternalFileInstallUiEffect() + + data class ShowTemplateNameConflict( + val existingName: String, + val info: TemplateCollectionRepository.CollectionInfo, + val tempFile: File, + ) : ExternalFileInstallUiEffect() + + data class ShowError( + @StringRes val messageResId: Int, + val formatArgs: List = emptyList(), + ) : ExternalFileInstallUiEffect() + + data class ShowSuccess( + @StringRes val messageResId: Int, + val formatArgs: List = emptyList(), + ) : ExternalFileInstallUiEffect() { + constructor( + @StringRes messageResId: Int, + vararg formatArgs: Any, + ) : this(messageResId, formatArgs.toList()) + } + + object Finish : ExternalFileInstallUiEffect() +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/models/PluginManagerUiState.kt b/app/src/main/java/com/itsaky/androidide/ui/models/PluginManagerUiState.kt index 151d631f4c..e7eae77bc8 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/models/PluginManagerUiState.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/models/PluginManagerUiState.kt @@ -1,54 +1,145 @@ package com.itsaky.androidide.ui.models import android.net.Uri +import android.os.Parcelable import androidx.annotation.StringRes import com.itsaky.androidide.plugins.PluginInfo import com.itsaky.androidide.plugins.PluginMetadata +import kotlinx.parcelize.Parcelize +import java.io.File data class PluginManagerUiState( - val isLoading: Boolean = false, - val plugins: List = emptyList(), - val isPluginManagerAvailable: Boolean = false, - val isInstalling: Boolean = false + val isLoading: Boolean = false, + val plugins: List = emptyList(), + val isPluginManagerAvailable: Boolean = false, + val isInstalling: Boolean = false, ) { - val isEmpty: Boolean - get() = plugins.isEmpty() && !isLoading + val isEmpty: Boolean + get() = plugins.isEmpty() && !isLoading - val showEmptyState: Boolean - get() = isEmpty && isPluginManagerAvailable + val showEmptyState: Boolean + get() = isEmpty && isPluginManagerAvailable +} + +/** + * Where a plugin archive to install comes from - either a `content://` [Uri] the user picked via + * SAF (any provider, including third-party ones), or a plain [File] this process already owns + * (the forwarded-`.cgp` case from [com.itsaky.androidide.activities.ExternalFileInstallActivity], + * which needs no [android.content.ContentResolver] round-trip since it's already a private file). + * + * [Parcelable] so the Compose install-confirmation dialog can hold one in `rememberSaveable` and + * survive rotation. [Uri] is Parcelable outright; [File] is [java.io.Serializable], which + * `@Parcelize` writes via `writeSerializable`. + */ +sealed class PluginInstallSource : Parcelable { + @Parcelize + data class ContentUri( + val uri: Uri, + ) : PluginInstallSource() + + @Parcelize + data class LocalFile( + val file: File, + ) : PluginInstallSource() } sealed class PluginManagerUiEvent { - object LoadPlugins : PluginManagerUiEvent() - data class EnablePlugin(val pluginId: String) : PluginManagerUiEvent() - data class DisablePlugin(val pluginId: String) : PluginManagerUiEvent() - data class UninstallPlugin(val pluginId: String) : PluginManagerUiEvent() - data class InstallPlugin(val uri: Uri, val deleteSourceAfterInstall: Boolean) : PluginManagerUiEvent() - data class ConfirmOverwrite(val uri: Uri, val deleteSourceAfterInstall: Boolean) : PluginManagerUiEvent() - object OpenFilePicker : PluginManagerUiEvent() - data class ShowPluginDetails(val plugin: PluginInfo) : PluginManagerUiEvent() + object LoadPlugins : PluginManagerUiEvent() + + data class EnablePlugin( + val pluginId: String, + ) : PluginManagerUiEvent() + + data class DisablePlugin( + val pluginId: String, + ) : PluginManagerUiEvent() + + data class UninstallPlugin( + val pluginId: String, + ) : PluginManagerUiEvent() + + data class InstallPlugin( + val source: PluginInstallSource, + val deleteSourceAfterInstall: Boolean, + ) : PluginManagerUiEvent() + + data class ConfirmOverwrite( + val source: PluginInstallSource, + val deleteSourceAfterInstall: Boolean, + ) : PluginManagerUiEvent() + + data class CancelPendingInstall( + val source: PluginInstallSource, + ) : PluginManagerUiEvent() + + /** + * The SAF picker returned a `.cgp` document. Always a `content://` [Uri] - the forwarded-file + * entry point goes straight to [PluginManagerUiEffect.ShowInstallConfirmation] with a + * [PluginInstallSource.LocalFile] instead, since it needs no picker round-trip. + */ + data class FileSelected( + val uri: Uri, + ) : PluginManagerUiEvent() + + data class ShowPluginDetails( + val plugin: PluginInfo, + ) : PluginManagerUiEvent() } sealed class PluginManagerUiEffect { - data class ShowError(@StringRes val messageResId: Int, val formatArgs: List = emptyList()) : PluginManagerUiEffect() - data class ShowSuccess(@StringRes val messageResId: Int) : PluginManagerUiEffect() - data class ShowPluginDetails(val plugin: PluginInfo) : PluginManagerUiEffect() - object OpenFilePicker : PluginManagerUiEffect() - data class ShowUninstallConfirmation(val plugin: PluginInfo) : PluginManagerUiEffect() - object ShowRestartPrompt : PluginManagerUiEffect() - data class ShowOverwriteConfirmation( - val existing: PluginInfo, - val incomingMetadata: PluginMetadata, - val uri: Uri, - val deleteSourceAfterInstall: Boolean - ) : PluginManagerUiEffect() + data class ShowError( + @StringRes val messageResId: Int, + val formatArgs: List = emptyList(), + ) : PluginManagerUiEffect() + + data class ShowSuccess( + @StringRes val messageResId: Int, + ) : PluginManagerUiEffect() + + data class ShowPluginDetails( + val plugin: PluginInfo, + ) : PluginManagerUiEffect() + + /** + * Carries a [PluginInstallSource], not a bare [Uri], so both entry points share one dialog: + * the SAF pick (a [PluginInstallSource.ContentUri]) and a `.cgp` forwarded from + * [com.itsaky.androidide.activities.ExternalFileInstallActivity] (a + * [PluginInstallSource.LocalFile]). + */ + data class ShowInstallConfirmation( + val source: PluginInstallSource, + ) : PluginManagerUiEffect() + + data class ShowUninstallConfirmation( + val plugin: PluginInfo, + ) : PluginManagerUiEffect() + + object ShowRestartPrompt : PluginManagerUiEffect() + + data class ShowOverwriteConfirmation( + val existing: PluginInfo, + val incomingMetadata: PluginMetadata, + val source: PluginInstallSource, + val deleteSourceAfterInstall: Boolean, + ) : PluginManagerUiEffect() } sealed class PluginOperation { - object None : PluginOperation() - object Loading : PluginOperation() - object Installing : PluginOperation() - data class Enabling(val pluginId: String) : PluginOperation() - data class Disabling(val pluginId: String) : PluginOperation() - data class Uninstalling(val pluginId: String) : PluginOperation() -} \ No newline at end of file + object None : PluginOperation() + + object Loading : PluginOperation() + + object Installing : PluginOperation() + + data class Enabling( + val pluginId: String, + ) : PluginOperation() + + data class Disabling( + val pluginId: String, + ) : PluginOperation() + + data class Uninstalling( + val pluginId: String, + ) : PluginOperation() +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/models/TemplateManagerUiState.kt b/app/src/main/java/com/itsaky/androidide/ui/models/TemplateManagerUiState.kt new file mode 100644 index 0000000000..b47f16115e --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/models/TemplateManagerUiState.kt @@ -0,0 +1,60 @@ +package com.itsaky.androidide.ui.models + +import androidx.annotation.StringRes +import com.itsaky.androidide.templates.manager.models.CgtFileItem + +data class TemplateManagerUiState( + val isLoading: Boolean = false, + val items: List = emptyList(), +) { + val isEmpty: Boolean + get() = items.isEmpty() && !isLoading +} + +sealed class TemplateManagerUiEvent { + object LoadTemplates : TemplateManagerUiEvent() + + data class InstallTemplate( + val item: CgtFileItem, + ) : TemplateManagerUiEvent() + + data class UninstallTemplate( + val item: CgtFileItem, + ) : TemplateManagerUiEvent() + + data class DeleteDownloadFile( + val item: CgtFileItem, + ) : TemplateManagerUiEvent() + + data class ShowTemplateDetails( + val item: CgtFileItem, + ) : TemplateManagerUiEvent() + + data class ShowTemplateList( + val item: CgtFileItem, + ) : TemplateManagerUiEvent() +} + +sealed class TemplateManagerUiEffect { + data class ShowError( + @StringRes val messageResId: Int, + val formatArgs: List = emptyList(), + ) : TemplateManagerUiEffect() + + data class ShowSuccess( + @StringRes val messageResId: Int, + val formatArgs: List = emptyList(), + ) : TemplateManagerUiEffect() + + data class ShowDeleteConfirmation( + val item: CgtFileItem, + ) : TemplateManagerUiEffect() + + data class ShowTemplateDetails( + val item: CgtFileItem, + ) : TemplateManagerUiEffect() + + data class ShowTemplateList( + val item: CgtFileItem, + ) : TemplateManagerUiEffect() +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/ApkInstaller.kt b/app/src/main/java/com/itsaky/androidide/utils/ApkInstaller.kt index 16a28891a9..3d1ca6a776 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/ApkInstaller.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/ApkInstaller.kt @@ -8,7 +8,6 @@ import android.content.pm.PackageInstaller import android.content.pm.PackageManager import android.os.Process import androidx.core.app.PendingIntentCompat -import androidx.core.content.FileProvider import com.itsaky.androidide.actions.build.DebugAction import com.itsaky.androidide.buildinfo.BuildInfo import com.itsaky.androidide.services.InstallationResultReceiver @@ -23,7 +22,6 @@ import java.io.File * @author Akash Yadav */ object ApkInstaller { - private val log = LoggerFactory.getLogger(ApkInstaller::class.java) private const val DEBUG_FALLBACK_INSTALLER = false @@ -40,9 +38,10 @@ object ApkInstaller { launchInDebugMode: Boolean = false, debugFallbackInstaller: Boolean = DEBUG_FALLBACK_INSTALLER, ): Boolean { - val isValidApk = withContext(Dispatchers.IO) { - apk.exists() && apk.isFile && apk.extension == "apk" - } + val isValidApk = + withContext(Dispatchers.IO) { + apk.exists() && apk.isFile && apk.extension.equals("apk", ignoreCase = true) + } if (!isValidApk) { log.error("File is not an APK: {}", apk) return false @@ -60,7 +59,7 @@ object ApkInstaller { if (DeviceUtils.isMiui() || debugFallbackInstaller) { log.warn( "Cannot use session-based installer on this device." + - " Falling back to intent-based installer." + " Falling back to intent-based installer.", ) installUsingIntent(context, apk, baseIntent) @@ -71,9 +70,12 @@ object ApkInstaller { } @Suppress("DEPRECATION", "RequestInstallPackagesPolicy") - private fun installUsingIntent(context: Context, apk: File, intent: Intent) { - val authority = "${context.packageName}.providers.fileprovider" - val uri = FileProvider.getUriForFile(context, authority, apk) + private fun installUsingIntent( + context: Context, + apk: File, + intent: Intent, + ) { + val uri = context.fileProviderUriFor(apk) intent.setAction(Intent.ACTION_INSTALL_PACKAGE) intent.setDataAndType(uri, "application/vnd.android.package-archive") intent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK @@ -101,15 +103,18 @@ object ApkInstaller { try { session = installer.openSession(sessionId) - val callback = requireNotNull(getCallbackIntent(context, intent, sessionId)) { - "PackageInstaller callback intent is null" - } + val callback = + requireNotNull(getCallbackIntent(context, intent, sessionId)) { + "PackageInstaller callback intent is null" + } addToSession(session, apk) session.commit(callback.intentSender) } catch (t: Throwable) { runCatching { installer.abandonSession(sessionId) } throw t - } finally { session?.close() } + } finally { + session?.close() + } } }.onFailure { error -> log.error("Package installation failed", error) @@ -143,14 +148,18 @@ object ApkInstaller { } } - private fun getCallbackIntent(context: Context, intent: Intent, sessionId: Int): PendingIntent? { - val intent = intent.apply { - action = InstallationResultReceiver.ACTION_INSTALL_STATUS - setClass(context, InstallationResultReceiver::class.java) - setPackage(context.packageName) - addFlags(Intent.FLAG_RECEIVER_FOREGROUND) - } - + private fun getCallbackIntent( + context: Context, + intent: Intent, + sessionId: Int, + ): PendingIntent? { + val intent = + intent.apply { + action = InstallationResultReceiver.ACTION_INSTALL_STATUS + setClass(context, InstallationResultReceiver::class.java) + setPackage(context.packageName) + addFlags(Intent.FLAG_RECEIVER_FOREGROUND) + } return PendingIntentCompat.getBroadcast( context, @@ -177,4 +186,4 @@ object ApkInstaller { session.fsync(outStream) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/BuildInfoUtils.kt b/app/src/main/java/com/itsaky/androidide/utils/BuildInfoUtils.kt index 7b17c93371..ff4a70ab71 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/BuildInfoUtils.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/BuildInfoUtils.kt @@ -31,7 +31,10 @@ import com.termux.shared.termux.TermuxUtils * @author Akash Yadav */ object BuildInfoUtils { - const val BASIC_INFO = BasicBuildInfo.BASIC_INFO + // Not a `const val`: the underlying version string changes between builds and must + // not be inlined into consumers. See ADR 0012. + @JvmField + val BASIC_INFO = BasicBuildInfo.BASIC_INFO private val BUILD_INFO_HEADER by lazy { val map = diff --git a/app/src/main/java/com/itsaky/androidide/utils/DialogExtensions.kt b/app/src/main/java/com/itsaky/androidide/utils/DialogExtensions.kt index 4a6734c185..ede0a296bb 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/DialogExtensions.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/DialogExtensions.kt @@ -5,7 +5,6 @@ import android.app.Activity import android.content.Context import android.graphics.Rect import android.view.MotionEvent -import android.view.View import android.view.ViewGroup import android.view.inputmethod.InputMethodManager import android.widget.AdapterView @@ -16,60 +15,58 @@ import com.itsaky.androidide.idetooltips.TooltipManager @SuppressLint("ClickableViewAccessibility") fun MaterialAlertDialogBuilder.showWithLongPressTooltip( - context: Context, - tooltipTag: String, - vararg customViews: View + context: Context, + tooltipTag: String, ): AlertDialog { - val dialog = this.create() + val dialog = this.create() + dialog.show() - fun longPressAction() { - dialog.dismiss() - val anchor = (context as? Activity)?.window?.decorView ?: return - TooltipManager.showIdeCategoryTooltip( - context = context, - anchorView = anchor, - tag = tooltipTag, - ) - } + fun longPressAction() { + val anchor = (context as? Activity)?.window?.decorView ?: return + TooltipManager.showIdeCategoryTooltip( + context = context, + anchorView = anchor, + tag = tooltipTag, + ) + } - dialog.onLongPress { - longPressAction() - true - } + dialog.onLongPress { + longPressAction() + true + } - dialog.listView?.onItemLongClickListener = - AdapterView.OnItemLongClickListener { _, _, _, _ -> - longPressAction() - true - } + dialog.listView?.onItemLongClickListener = + AdapterView.OnItemLongClickListener { _, _, _, _ -> + longPressAction() + true + } - val customPanel: ViewGroup? = dialog.findViewById(androidx.appcompat.R.id.customPanel) + val customPanel: ViewGroup? = dialog.findViewById(androidx.appcompat.R.id.customPanel) - customPanel?.forEachViewRecursively { view -> - if (view is EditText) { - dialog.setOnShowListener { - view.requestFocus() - val imm = - context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) - } + customPanel?.forEachViewRecursively { view -> + if (view is EditText) { + dialog.setOnShowListener { + view.requestFocus() + val imm = + context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) + } - dialog.window?.decorView?.setOnTouchListener { v, event -> - if (event.action == MotionEvent.ACTION_DOWN) { - val outRect = Rect() - view.getGlobalVisibleRect(outRect) - if (!outRect.contains(event.rawX.toInt(), event.rawY.toInt())) { - view.clearFocus() - val imm = - view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(view.windowToken, 0) - } - } - false - } - } - } + dialog.window?.decorView?.setOnTouchListener { v, event -> + if (event.action == MotionEvent.ACTION_DOWN) { + val outRect = Rect() + view.getGlobalVisibleRect(outRect) + if (!outRect.contains(event.rawX.toInt(), event.rawY.toInt())) { + view.clearFocus() + val imm = + view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(view.windowToken, 0) + } + } + false + } + } + } - dialog.show() - return dialog -} \ No newline at end of file + return dialog +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/InstallTempFiles.kt b/app/src/main/java/com/itsaky/androidide/utils/InstallTempFiles.kt new file mode 100644 index 0000000000..7c5b779269 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/InstallTempFiles.kt @@ -0,0 +1,60 @@ +package com.itsaky.androidide.utils + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.util.UUID +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong + +/** + * Shared `filesDir/temp` staging area for the .cgp/.cgt install flows (ExternalFileInstallViewModel, + * and PluginManagerViewModel's ContentUri branch) - centralizes temp-file naming so both ViewModels + * don't duplicate it, and sweeps orphans left behind by a hand-off that never completed (e.g. + * process death between ExternalFileInstallViewModel sending ForwardToPluginManager and + * PluginManagerActivity reading the pending-install-file extra). + */ +object InstallTempFiles { + private val MAX_AGE_MS = TimeUnit.HOURS.toMillis(1) + + // Stale entries can only ever appear once an hour (MAX_AGE_MS), so there's no point + // re-scanning the directory on every single newTempFile() call - throttle to once per + // interval instead of doing a full listFiles()+lastModified() pass every time. An AtomicLong + // (rather than a plain var) since newTempFile() can be called concurrently from both + // ExternalFileInstallViewModel and PluginManagerViewModel's coroutines. + private val SWEEP_INTERVAL_MS = TimeUnit.MINUTES.toMillis(10) + private val lastSweepAtMs = AtomicLong(0L) + + /** + * Creates a uniquely-named `_.` file under `filesDir/temp`. Suspends + * and dispatches to [Dispatchers.IO] internally - mkdirs() and the periodic directory + * sweep/delete below are real filesystem work, so callers don't need their own withContext to + * keep this off the caller's (possibly Main) dispatcher. + */ + suspend fun newTempFile( + filesDir: File, + prefix: String, + extension: String, + ): File = + withContext(Dispatchers.IO) { + val tempDir = File(filesDir, "temp").apply { mkdirs() } + sweepStaleIfDue(tempDir) + File(tempDir, "${prefix}_${UUID.randomUUID()}.$extension") + } + + private fun sweepStaleIfDue(tempDir: File) { + val now = System.currentTimeMillis() + val last = lastSweepAtMs.get() + if (now - last < SWEEP_INTERVAL_MS) return + // Loses the race to another concurrent caller -> that caller's sweep already covers this + // interval, so skip rather than sweep twice. + if (!lastSweepAtMs.compareAndSet(last, now)) return + + val cutoff = now - MAX_AGE_MS + tempDir.listFiles()?.forEach { file -> + if (file.lastModified() < cutoff) { + file.delete() + } + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt b/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt index 3655a19784..0bcf662ba4 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt @@ -22,7 +22,6 @@ import android.content.Intent import android.os.Build import androidx.annotation.RequiresApi import androidx.core.app.ShareCompat -import androidx.core.content.FileProvider import com.itsaky.androidide.R import com.itsaky.androidide.utils.ImageUtils.ImageType.TYPE_UNKNOWN import org.slf4j.LoggerFactory @@ -88,12 +87,7 @@ object IntentUtils { mimeType: String = MIME_ANY, intentAction: String = Intent.ACTION_SEND, ) { - val uri = - FileProvider.getUriForFile( - context, - "${context.packageName}.providers.fileprovider", - file, - ) + val uri = context.fileProviderUriFor(file) val intent = ShareCompat .IntentBuilder(context) diff --git a/app/src/main/java/com/itsaky/androidide/utils/LastValueGate.kt b/app/src/main/java/com/itsaky/androidide/utils/LastValueGate.kt new file mode 100644 index 0000000000..6c894807a6 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/LastValueGate.kt @@ -0,0 +1,39 @@ +/* + * 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 + +/** + * Tracks the last value handed to [consume], so a caller can tell "already handled" from "new" + * without an Activity `savedInstanceState` check. Meant to live as a field on a `ViewModel`: it + * survives a configuration change (same instance, so a repeat [consume] of the same value is a + * no-op), but resets after process death (a fresh instance is created), so a process-death + * recreation still processes a restored pending value instead of silently dropping it. + * + * Not thread-safe: [lastHandled] is unsynchronized, so call [consume] from a single thread only + * (e.g. always from the main thread, as every current call site does). + */ +class LastValueGate { + private var lastHandled: T? = null + + /** Returns true the first time [value] is passed, or if it differs from the last one seen. */ + fun consume(value: T): Boolean { + if (lastHandled == value) return false + lastHandled = value + return true + } +} diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt index 790276a4de..5c220f86c8 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.future.await import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION import org.slf4j.LoggerFactory import java.io.File import kotlin.coroutines.cancellation.CancellationException @@ -150,7 +151,7 @@ class BuildViewModel : ViewModel() { val isDebug = variant.name.contains("debug", ignoreCase = true) return pluginDir - .listFiles { file -> file.extension.equals("cgp", ignoreCase = true) } + .listFiles { file -> file.extension.equals(PLUGIN_ARCHIVE_EXTENSION, ignoreCase = true) } ?.filter { it.name.contains("-debug") == isDebug } ?.maxByOrNull { it.lastModified() } } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/LogViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/LogViewModel.kt index 6195ffbe2e..f7fbfcd2e9 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/LogViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/LogViewModel.kt @@ -183,6 +183,15 @@ abstract class LogViewModel : ViewModel() { } } + /** + * Force [uiEvents] to restart and replay a fresh [UiEvent.SetText] snapshot of the + * retained history. Used by the view layer to recover when an append could not be + * rendered (e.g. the editor had no dimensions yet). + */ + fun resync() { + generation.update { it + 1 } + } + /** Whether the retained log buffer contains no entries. O(1) check. */ val isBufferEmpty: Boolean get() = buffer.isEmpty 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 46f42ba1ab..4d59706af4 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/MainViewModel.kt @@ -17,6 +17,7 @@ package com.itsaky.androidide.viewmodel +import android.database.SQLException import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData @@ -25,7 +26,9 @@ 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 @@ -40,83 +43,95 @@ import java.util.concurrent.atomic.AtomicInteger * @author Akash Yadav */ class MainViewModel( - private val recentProjectDao: RecentProjectDao + private val recentProjectDao: RecentProjectDao, ) : 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. - // For example, SCREEN_MAIN is the first screen visible to the user, followed by SCREEN_TEMPLATE_LIST, - // and then SCREEN_TEMPLATE_DETAILS. - // - // These values are used as unique identifiers for the screens as well as for determining whether - // the screen change transition should be forward or backward. - const val SCREEN_MAIN = 0 - const val SCREEN_TEMPLATE_LIST = 1 - const val SCREEN_TEMPLATE_DETAILS = 2 - const val TOOLTIPS_WEB_VIEW = 3 - 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) - private val _previousScreen = AtomicInteger(-1) - private val _isTransitionInProgress = MutableLiveData(false) - - private val cloneRepositoryEventChannel = Channel(Channel.BUFFERED) - - internal val template = MutableLiveData>(null) - internal val creatingProject = MutableLiveData(false) - - val currentScreen: LiveData = _currentScreen - - val cloneRepositoryEvent = cloneRepositoryEventChannel.receiveAsFlow() - - val previousScreen: Int - get() = _previousScreen.get() - - var isTransitionInProgress: Boolean - get() = _isTransitionInProgress.value ?: false - set(value) { - _isTransitionInProgress.value = value - } - - fun setScreen(screen: Int) { - _previousScreen.set(_currentScreen.value ?: SCREEN_MAIN) - _currentScreen.value = screen - } - - fun requestCloneRepository(url: String) { - viewModelScope.launch { - cloneRepositoryEventChannel.send(url) - } - setScreen(SCREEN_CLONE_REPO) - } - - fun postTransition(owner: LifecycleOwner, action: Runnable) { - if (isTransitionInProgress) { - _isTransitionInProgress.observe(owner, object : Observer { - override fun onChanged(t: Boolean) { - _isTransitionInProgress.removeObserver(this) - action.run() - } - }) - } else { - action.run() - } - } - - fun saveProjectToRecents(project: RecentProject) { - viewModelScope.launch(Dispatchers.IO) { - try { - recentProjectDao.insert(project) - } catch (e: Exception) { - logger.warn("Failed to save project to recents", e) - } - } - } + 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. + // For example, SCREEN_MAIN is the first screen visible to the user, followed by SCREEN_TEMPLATE_LIST, + // and then SCREEN_TEMPLATE_DETAILS. + // + // These values are used as unique identifiers for the screens as well as for determining whether + // the screen change transition should be forward or backward. + const val SCREEN_MAIN = 0 + const val SCREEN_TEMPLATE_LIST = 1 + const val SCREEN_TEMPLATE_DETAILS = 2 + const val TOOLTIPS_WEB_VIEW = 3 + 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) + private val _previousScreen = AtomicInteger(-1) + private val _isTransitionInProgress = MutableLiveData(false) + + private val cloneRepositoryEventChannel = Channel(Channel.BUFFERED) + + internal val template = MutableLiveData>(null) + internal val creatingProject = MutableLiveData(false) + + val currentScreen: LiveData = _currentScreen + + val cloneRepositoryEvent = cloneRepositoryEventChannel.receiveAsFlow() + + val previousScreen: Int + get() = _previousScreen.get() + + var isTransitionInProgress: Boolean + get() = _isTransitionInProgress.value ?: false + set(value) { + _isTransitionInProgress.value = value + } + + fun setScreen(screen: Int) { + _previousScreen.set(_currentScreen.value ?: SCREEN_MAIN) + _currentScreen.value = screen + } + + fun requestCloneRepository(url: String) { + viewModelScope.launch { + cloneRepositoryEventChannel.send(url) + } + setScreen(SCREEN_CLONE_REPO) + } + + fun postTransition( + owner: LifecycleOwner, + action: Runnable, + ) { + if (isTransitionInProgress) { + _isTransitionInProgress.observe( + owner, + object : Observer { + override fun onChanged(t: Boolean) { + _isTransitionInProgress.removeObserver(this) + action.run() + } + }, + ) + } else { + 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/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt index 4c545f01ca..3fadd60479 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt @@ -7,14 +7,16 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.application import androidx.lifecycle.viewModelScope -import com.itsaky.androidide.resources.R import com.itsaky.androidide.adapters.RecentProjectsAdapter +import com.itsaky.androidide.models.ProjectFile +import com.itsaky.androidide.resources.R import com.itsaky.androidide.roomData.recentproject.RecentProject import com.itsaky.androidide.roomData.recentproject.RecentProjectDao import com.itsaky.androidide.roomData.recentproject.RecentProjectRoomDatabase +import com.itsaky.androidide.templates.Language import com.itsaky.androidide.utils.getCreatedTime import com.itsaky.androidide.utils.getLastModifiedTime -import com.itsaky.androidide.models.ProjectFile +import com.itsaky.androidide.utils.readProjectLanguage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow @@ -29,290 +31,292 @@ import java.io.File import java.io.IOException enum class SortCriteria { - NAME, - DATE_CREATED, - DATE_MODIFIED + NAME, + DATE_CREATED, + DATE_MODIFIED, } data class FilterState( - val query: String = "", - val sort: SortCriteria? = null, - val ascending: Boolean = true + val query: String = "", + val sort: SortCriteria? = null, + val ascending: Boolean = true, ) { - val hasAny: Boolean get() = sort != null || query.isNotEmpty() + val hasAny: Boolean get() = sort != null || query.isNotEmpty() } -class RecentProjectsViewModel(application: Application) : AndroidViewModel(application) { - - companion object { - private val logger = LoggerFactory.getLogger(RecentProjectsViewModel::class.java) - } - - private val _projects = MutableLiveData>() - private var allProjects: List = emptyList() - val projects: LiveData> = _projects - private val _filterEvents = MutableSharedFlow() - val filterEvents = _filterEvents - var didBootstrap = false - private var currentQuery: String = "" - private var currentSort: SortCriteria? = null - private var isAscending: Boolean = true - - private val _filterState = MutableStateFlow(FilterState()) - val filterState: StateFlow = _filterState.asStateFlow() - - val currentSortCriteria: SortCriteria? get() = currentSort - val currentSortAscending: Boolean get() = isAscending - val hasActiveFilters: Boolean - get() = _filterState.value.hasAny - - private val _deletionStatus = MutableSharedFlow(replay = 1) - val deletionStatus = _deletionStatus.asSharedFlow() - - private val _renameStatus = MutableSharedFlow() - val renameStatus = _renameStatus.asSharedFlow() - - // Get the database and DAO instance - private val recentProjectDatabase: RecentProjectRoomDatabase = - RecentProjectRoomDatabase.getDatabase(application, viewModelScope) - private val recentProjectDao: RecentProjectDao = recentProjectDatabase.recentProjectDao() - - fun loadProjects(): Job { - return viewModelScope.launch(Dispatchers.IO) { - val projectsFromDb = recentProjectDao.dumpAll() ?: emptyList() - allProjects = projectsFromDb.map { ProjectFile(it.location, it.createdAt, it.lastModified) } - applyFilters() - } - } - - fun notifyFiltersSaved() { - viewModelScope.launch { - _filterEvents.emit(Unit) - } - } - - private suspend fun applyFilters() { - _filterState.value = FilterState(currentQuery, currentSort, isAscending) - withContext(Dispatchers.Default) { - var result = allProjects - - if (currentQuery.isNotEmpty()) { - result = result.filter { it.name.contains(currentQuery, ignoreCase = true) } - } - - val criteria = currentSort - if (criteria != null) { - result = when (criteria) { - SortCriteria.NAME -> result.sortedBy { it.name.lowercase() } - SortCriteria.DATE_CREATED -> result.sortedBy { it.createdAt } - SortCriteria.DATE_MODIFIED -> result.sortedBy { it.lastModified } - } - if (!isAscending) { - result = result.reversed() - } - } - _projects.postValue(result) - } - } - - suspend fun onSearchQuery(query: String) { - currentQuery = query.trim() - applyFilters() - } - - suspend fun onSortSelected(criteria: SortCriteria?) { - currentSort = criteria - applyFilters() - } - - suspend fun onSortDirectionChanged(ascending: Boolean) { - isAscending = ascending - applyFilters() - } - - suspend fun clearFilters() { - currentSort = null - isAscending = true - currentQuery = "" - applyFilters() - } - - suspend fun clearSort() { - currentSort = null - isAscending = true - applyFilters() - } - - suspend fun getProjectByName(name: String): RecentProject? { - return withContext(Dispatchers.IO) { - recentProjectDao.getProjectByName(name) - } - } - - fun projectNameExists(name: String): Boolean = - allProjects.any { it.name == name } - - fun insertProjectFromFolder(name: String, location: String) = - viewModelScope.launch(Dispatchers.IO) { - // Check if the project already exists - val existingProject = getProjectByName(name) - if (existingProject == null) { - val createdAt = getCreatedTime(location) - val modifiedAt = getLastModifiedTime(location) - val unknown = application.getString(R.string.unknown) - recentProjectDao.insert( - RecentProject( - location = location, - name = name, - createdAt = createdAt.toString(), - lastModified = modifiedAt.toString(), - templateName = unknown, - language = unknown - ) - ) - } - } +class RecentProjectsViewModel( + application: Application, +) : AndroidViewModel(application) { + companion object { + private val logger = LoggerFactory.getLogger(RecentProjectsViewModel::class.java) + } + + private val _projects = MutableLiveData>() + private var allProjects: List = emptyList() + val projects: LiveData> = _projects + private val _filterEvents = MutableSharedFlow() + val filterEvents = _filterEvents + var didBootstrap = false + private var currentQuery: String = "" + private var currentSort: SortCriteria? = null + private var isAscending: Boolean = true + + private val _filterState = MutableStateFlow(FilterState()) + val filterState: StateFlow = _filterState.asStateFlow() + + val currentSortCriteria: SortCriteria? get() = currentSort + val currentSortAscending: Boolean get() = isAscending + val hasActiveFilters: Boolean + get() = _filterState.value.hasAny + + private val _deletionStatus = MutableSharedFlow(replay = 1) + val deletionStatus = _deletionStatus.asSharedFlow() + + private val _renameStatus = MutableSharedFlow() + val renameStatus = _renameStatus.asSharedFlow() + + // Get the database and DAO instance + private val recentProjectDatabase: RecentProjectRoomDatabase = + RecentProjectRoomDatabase.getDatabase(application, viewModelScope) + private val recentProjectDao: RecentProjectDao = recentProjectDatabase.recentProjectDao() + + fun loadProjects(): Job = + viewModelScope.launch(Dispatchers.IO) { + val projectsFromDb = recentProjectDao.dumpAll() ?: emptyList() + allProjects = projectsFromDb.map { ProjectFile(it.location, it.createdAt, it.lastModified) } + applyFilters() + } + + fun notifyFiltersSaved() { + viewModelScope.launch { + _filterEvents.emit(Unit) + } + } + + private suspend fun applyFilters() { + _filterState.value = FilterState(currentQuery, currentSort, isAscending) + withContext(Dispatchers.Default) { + var result = allProjects + + if (currentQuery.isNotEmpty()) { + result = result.filter { it.name.contains(currentQuery, ignoreCase = true) } + } + val criteria = currentSort + if (criteria != null) { + result = + when (criteria) { + SortCriteria.NAME -> result.sortedBy { it.name.lowercase() } + SortCriteria.DATE_CREATED -> result.sortedBy { it.createdAt } + SortCriteria.DATE_MODIFIED -> result.sortedBy { it.lastModified } + } + if (!isAscending) { + result = result.reversed() + } + } + _projects.postValue(result) + } + } + + suspend fun onSearchQuery(query: String) { + currentQuery = query.trim() + applyFilters() + } + + suspend fun onSortSelected(criteria: SortCriteria?) { + currentSort = criteria + applyFilters() + } + + suspend fun onSortDirectionChanged(ascending: Boolean) { + isAscending = ascending + applyFilters() + } + + suspend fun clearFilters() { + currentSort = null + isAscending = true + currentQuery = "" + applyFilters() + } + + suspend fun clearSort() { + currentSort = null + isAscending = true + applyFilters() + } + + suspend fun getProjectByName(name: String): RecentProject? = + withContext(Dispatchers.IO) { + recentProjectDao.getProjectByName(name) + } + + fun projectNameExists(name: String): Boolean = allProjects.any { it.name == name } + + fun insertProjectFromFolder( + name: String, + location: String, + ) = viewModelScope.launch(Dispatchers.IO) { + // Check if the project already exists + val existingProject = getProjectByName(name) + if (existingProject == null) { + val createdAt = getCreatedTime(location) + val modifiedAt = getLastModifiedTime(location) + val unknown = Language.Unknown.lang + val detectedLanguage = readProjectLanguage(File(location)) + val languageToStore = if (detectedLanguage != unknown) detectedLanguage else unknown + recentProjectDao.insert( + RecentProject( + location = location, + name = name, + createdAt = createdAt.toString(), + lastModified = modifiedAt.toString(), + templateName = unknown, + language = languageToStore, + ), + ) + } + } fun deleteProject(project: ProjectFile) = deleteProject(project.name) - fun deleteProject(name: String) = viewModelScope.launch { - try { - val success = withContext(Dispatchers.IO) { - // Delete files from storage first - val projectToDelete = recentProjectDao.getProjectByName(name) - ?: return@withContext false - val isDeleted = File(projectToDelete.location).deleteRecursively() - - // Delete from DB if storage deletion was successful - if (isDeleted) { - recentProjectDao.deleteByName(name) - } - isDeleted - } - - if (success) { - // Update LiveData - val currentList = _projects.value ?: emptyList() - allProjects = allProjects.filter { it.name != name } - _projects.value = currentList.filter { it.name != name } - _deletionStatus.emit(true) - } else { - // Emit failure if files couldn't be deleted - _deletionStatus.emit(false) - } - } catch (e: IOException) { - logger.error("An I/O error occurred during project deletion", e) - _deletionStatus.emit(false) - } catch (e: SQLException) { - logger.error("A database error occurred during project deletion", e) - _deletionStatus.emit(false) - } catch (e: SecurityException) { - logger.error("Security error during project deletion", e) - _deletionStatus.emit(false) - } - vacuumDatabase() - } + fun deleteProject(name: String) = + viewModelScope.launch { + try { + val success = + withContext(Dispatchers.IO) { + // Delete files from storage first + val projectToDelete = + recentProjectDao.getProjectByName(name) + ?: return@withContext false + val isDeleted = File(projectToDelete.location).deleteRecursively() + + // Delete from DB if storage deletion was successful + if (isDeleted) { + recentProjectDao.deleteByName(name) + } + isDeleted + } + + if (success) { + // Update LiveData + val currentList = _projects.value ?: emptyList() + allProjects = allProjects.filter { it.name != name } + _projects.value = currentList.filter { it.name != name } + _deletionStatus.emit(true) + } else { + // Emit failure if files couldn't be deleted + _deletionStatus.emit(false) + } + } catch (e: IOException) { + logger.error("An I/O error occurred during project deletion", e) + _deletionStatus.emit(false) + } catch (e: SQLException) { + logger.error("A database error occurred during project deletion", e) + _deletionStatus.emit(false) + } catch (e: SecurityException) { + logger.error("Security error during project deletion", e) + _deletionStatus.emit(false) + } + vacuumDatabase() + } fun updateProject(renamedFile: RecentProjectsAdapter.RenamedFile) = updateProject( renamedFile.oldName, renamedFile.newName, renamedFile.oldPath, - renamedFile.newPath + renamedFile.newPath, ) - fun updateProject( - oldName: String, - newName: String, - oldLocation: String, - newLocation: String - ) = - viewModelScope.launch(Dispatchers.IO) { - try { - val modifiedAt = System.currentTimeMillis().toString() - recentProjectDao.updateNameAndLocation( - oldName = oldName, - newName = newName, - newLocation = newLocation - ) - recentProjectDao.updateLastModified( - projectName = newName, - lastModified = modifiedAt - ) - loadProjects() - _renameStatus.emit(true) - } catch (e: SQLException) { - logger.error("Failed to update project after rename ($oldName -> $newName)", e) - val rolledBack = File(newLocation).renameTo(File(oldLocation)) - if (rolledBack) { - logger.info("Rolled back filesystem rename: $newLocation -> $oldLocation") - } else { - logger.error("Rollback failed; filesystem and DB are out of sync (disk=$newLocation, db=$oldLocation)") - } - _renameStatus.emit(false) - } - } + fun updateProject( + oldName: String, + newName: String, + oldLocation: String, + newLocation: String, + ) = viewModelScope.launch(Dispatchers.IO) { + try { + val modifiedAt = System.currentTimeMillis().toString() + recentProjectDao.updateNameAndLocation( + oldName = oldName, + newName = newName, + newLocation = newLocation, + ) + recentProjectDao.updateLastModified( + projectName = newName, + lastModified = modifiedAt, + ) + loadProjects() + _renameStatus.emit(true) + } catch (e: SQLException) { + logger.error("Failed to update project after rename ($oldName -> $newName)", e) + val rolledBack = File(newLocation).renameTo(File(oldLocation)) + if (rolledBack) { + logger.info("Rolled back filesystem rename: $newLocation -> $oldLocation") + } else { + logger.error("Rollback failed; filesystem and DB are out of sync (disk=$newLocation, db=$oldLocation)") + } + _renameStatus.emit(false) + } + } fun updateProjectModifiedDate(name: String) = viewModelScope.launch(Dispatchers.IO) { val modifiedAt = System.currentTimeMillis() recentProjectDao.updateLastModified( - projectName = name, - lastModified = modifiedAt.toString() + projectName = name, + lastModified = modifiedAt.toString(), ) loadProjects() } - fun deleteSelectedProjects(selectedNames: List) = - viewModelScope.launch { - if (selectedNames.isEmpty()) { - return@launch - } - - var allDeletionsSucceeded = true - - try { - withContext(Dispatchers.IO) { - // Find the full project details for the selected project names - val projectsToDelete = recentProjectDao.getProjectsByNames(selectedNames) - val successfullyDeletedNames = mutableListOf() - - for (project in projectsToDelete) { - // Delete from storage - val isDeletedFromStorage = File(project.location).deleteRecursively() - - if (isDeletedFromStorage) { - successfullyDeletedNames.add(project.name) - } else { - logger.warn("Failed to delete project files from storage: ${project.location}") - allDeletionsSucceeded = false - } - } - - if (successfullyDeletedNames.isNotEmpty()) { - // Delete from database - recentProjectDao.deleteByNames(successfullyDeletedNames) - } - } - - vacuumDatabase() - loadProjects() - - _deletionStatus.emit(allDeletionsSucceeded) - - } catch (e: Exception) { - logger.error("An exception occurred during project deletion", e) - _deletionStatus.emit(false) - } - } - - - private suspend fun vacuumDatabase() { - withContext(Dispatchers.IO) { - runCatching { - recentProjectDatabase.vacuum() + fun deleteSelectedProjects(selectedNames: List) = + viewModelScope.launch { + if (selectedNames.isEmpty()) { + return@launch + } + + var allDeletionsSucceeded = true + + try { + withContext(Dispatchers.IO) { + // Find the full project details for the selected project names + val projectsToDelete = recentProjectDao.getProjectsByNames(selectedNames) + val successfullyDeletedNames = mutableListOf() + + for (project in projectsToDelete) { + // Delete from storage + val isDeletedFromStorage = File(project.location).deleteRecursively() + + if (isDeletedFromStorage) { + successfullyDeletedNames.add(project.name) + } else { + logger.warn("Failed to delete project files from storage: ${project.location}") + allDeletionsSucceeded = false + } + } + + if (successfullyDeletedNames.isNotEmpty()) { + // Delete from database + recentProjectDao.deleteByNames(successfullyDeletedNames) + } + } + + vacuumDatabase() + loadProjects() + + _deletionStatus.emit(allDeletionsSucceeded) + } catch (e: Exception) { + logger.error("An exception occurred during project deletion", e) + _deletionStatus.emit(false) } - } - } + } + + private suspend fun vacuumDatabase() { + withContext(Dispatchers.IO) { + runCatching { + recentProjectDatabase.vacuum() + } + } + } } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt new file mode 100644 index 0000000000..f2bb0c6dc3 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt @@ -0,0 +1,374 @@ +package com.itsaky.androidide.viewmodels + +import android.content.ContentResolver +import android.net.Uri +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.itsaky.androidide.repositories.PluginRepository +import com.itsaky.androidide.repositories.TemplateCollectionRepository +import com.itsaky.androidide.resources.R +import com.itsaky.androidide.ui.models.ExternalFileInstallUiEffect +import com.itsaky.androidide.ui.models.ExternalFileInstallUiEvent +import com.itsaky.androidide.utils.InstallTempFiles +import com.itsaky.androidide.utils.LastValueGate +import com.itsaky.androidide.utils.UriFileImporter +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION +import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION +import org.slf4j.LoggerFactory +import java.io.File + +/** + * Handles a `.cgp`/`.cgt` file opened from outside the app (e.g. an email attachment), backing + * [com.itsaky.androidide.activities.ExternalFileInstallActivity]. + */ +class ExternalFileInstallViewModel( + private val pluginRepository: PluginRepository, + private val templateCollectionRepository: TemplateCollectionRepository, + private val contentResolver: ContentResolver, + private val filesDir: File, +) : ViewModel() { + private companion object { + private val log = LoggerFactory.getLogger(ExternalFileInstallViewModel::class.java) + private val UNSAFE_FILENAME_CHARS = Regex("[\\\\/:*?\"<>|]") + + // A cold OS-triggered launch of this activity can win the race against IDEApplication's + // async setup (device-unlock -> CredentialProtectedApplicationLoader.load(), which itself + // chains a long, unbounded sequence of Sentry/Firebase/EventBus/WorkManager/Termux/plugin + // init work), so isPluginManagerAvailable()/isTemplatesFeatureAvailable() are polled + // instead of failing on the very first check. ~8s total gives real cold starts a + // realistic margin; there's no true completion signal to await instead (see ADFA-4934 + // code review notes), so this remains a bounded-poll approximation, not a hard guarantee. + private const val SETUP_WAIT_ATTEMPTS = 20 + private const val SETUP_WAIT_INTERVAL_MS = 400L + + // Bounds suggestUniqueBaseName()'s search - a pathological repository (or a huge run of + // pre-existing "foo (2)", "foo (3)", ... collections) must not hang the Rename dialog + // forever waiting for a free name. + private const val MAX_SUGGESTION_ATTEMPTS = 50 + } + + // Buffered (not rendezvous): onReceived() runs via Dispatchers.Main.immediate right after + // Activity.onCreate() starts collecting uiEffect, and a synchronous decision path (e.g. an + // unsupported file type) can otherwise complete before the collector actually attaches, + // silently dropping the effect. + private val _uiEffect = Channel(capacity = Channel.BUFFERED) + val uiEffect = _uiEffect.receiveAsFlow() + + // onReceived() must run at most once per distinct uri per ViewModel instance: this instance + // survives a rotation (so a duplicate call there for the same uri is a no-op, not a + // re-processed intent), but is recreated fresh by Koin after process death (so the fresh + // instance still processes the restored intent instead of the call being skipped entirely). + private val receivedUriGate = LastValueGate() + + private val _isInstalling = MutableStateFlow(false) + val isInstalling: StateFlow = _isInstalling.asStateFlow() + + // Monotonically increasing per onReceived() call, assigned synchronously (before launching + // the coroutine below) so it always reflects real intent-arrival order. Two onReceived() + // calls in quick succession (ExternalFileInstallActivity is singleTask, so a second VIEW + // intent for a *different* file reaches this same instance via onNewIntent) run as + // independent coroutines with no guarantee the first *finishes* before the second - a slow + // first request can otherwise complete its async work (copy/inspect/collision-check) after a + // faster second request already committed, and overwrite the Compose screen's single + // dialogState slot with stale info. isCurrentGeneration() below lets each request notice, at + // its final commit point, that it's been superseded and should abandon silently instead. + private var currentRequestGeneration = 0 + + // Tracks the temp file behind the most recently *committed* .cgt confirm/conflict dialog - + // used to clean it up the moment a newer request supersedes it, rather than silently + // orphaning it for InstallTempFiles' hour-long sweep. Only ever touched by whichever request + // currently holds isCurrentGeneration()'s "true" (see supersedePendingConfirmation()), so + // there's no ordering ambiguity about which file it refers to. + private var pendingConfirmationTempFile: File? = null + + // The generation pendingConfirmationTempFile actually belongs to - NOT necessarily + // currentRequestGeneration, which can already have moved on to a newer, still-in-flight + // request by the time the user taps a button on the dialog still on screen (its onReceived() + // bumped the counter synchronously, but hasn't reached supersedePendingConfirmation() yet). + // confirmTemplateInstall()/onEvent() must key off this, not the live counter, or a stale + // dialog's action gets misattributed to the newer request and can tear the Activity down out + // from under it. + private var pendingConfirmationGeneration: Int = 0 + + private fun isCurrentGeneration(generation: Int) = generation == currentRequestGeneration + + private suspend fun supersedePendingConfirmation( + newPendingFile: File?, + newGeneration: Int, + ) { + pendingConfirmationTempFile?.let { old -> if (old != newPendingFile) deleteQuietly(old) } + pendingConfirmationTempFile = newPendingFile + pendingConfirmationGeneration = newGeneration + } + + /** Call once, from `Activity.onCreate()`/`onNewIntent()`, with the VIEW intent's data [Uri]. */ + fun onReceived(uri: Uri) { + if (!receivedUriGate.consume(uri)) return + + val generation = ++currentRequestGeneration + + viewModelScope.launch { + val displayName = withContext(Dispatchers.IO) { UriFileImporter.getDisplayName(contentResolver, uri) } + val extension = displayName?.substringAfterLast('.', "")?.lowercase() + + if (displayName.isNullOrBlank() || extension.isNullOrBlank()) { + sendErrorAndFinish(generation, R.string.msg_invalid_incoming_file) + return@launch + } + + if (extension != PLUGIN_ARCHIVE_EXTENSION && extension != TEMPLATE_ARCHIVE_EXTENSION) { + sendErrorAndFinish(generation, R.string.msg_unsupported_file_type) + return@launch + } + + val featureAvailable = + if (extension == PLUGIN_ARCHIVE_EXTENSION) { + pluginRepository::isPluginManagerAvailable + } else { + templateCollectionRepository::isTemplatesFeatureAvailable + } + if (!awaitAvailable(featureAvailable)) { + sendErrorAndFinish(generation, R.string.msg_ide_setup_incomplete) + return@launch + } + + val destination = InstallTempFiles.newTempFile(filesDir, "incoming", extension) + + val tempFile = + try { + withContext(Dispatchers.IO) { + UriFileImporter.copyUriToFile(contentResolver, uri, destination) { + IllegalStateException("Cannot open file") + } + destination + } + } catch (e: CancellationException) { + withContext(NonCancellable + Dispatchers.IO) { deleteQuietlyBlocking(destination) } + throw e + } catch (e: Exception) { + log.error("Failed to copy incoming file", e) + withContext(Dispatchers.IO) { deleteQuietlyBlocking(destination) } + sendErrorAndFinish(generation, R.string.msg_invalid_incoming_file) + return@launch + } + + if (!isCurrentGeneration(generation)) { + // A newer VIEW intent has since arrived and is now authoritative - abandon this + // one silently rather than emit an effect that would incorrectly supersede it. + deleteQuietly(tempFile) + return@launch + } + + val baseName = sanitizeBaseName(displayName.substringBeforeLast('.', "templates")) + + if (extension == PLUGIN_ARCHIVE_EXTENSION) { + // Forwarded as a plain path, not a content:// Uri: both activities run in this + // same process and already trust filesDir paths, so PluginManagerViewModel can + // install straight from this file instead of copying it a second time. + supersedePendingConfirmation(null, generation) + _uiEffect.trySend(ExternalFileInstallUiEffect.ForwardToPluginManager(tempFile.absolutePath)) + } else { + dispatchTemplateInstall(tempFile, baseName, generation) + } + } + } + + private suspend fun awaitAvailable(check: () -> Boolean): Boolean { + repeat(SETUP_WAIT_ATTEMPTS) { attempt -> + if (check()) return true + if (attempt < SETUP_WAIT_ATTEMPTS - 1) delay(SETUP_WAIT_INTERVAL_MS) + } + return false + } + + private suspend fun dispatchTemplateInstall( + tempFile: File, + baseName: String, + generation: Int, + ) { + val info = + templateCollectionRepository.inspectCollection(tempFile).getOrElse { exception -> + log.warn("Invalid template collection file: {}", tempFile.name, exception) + deleteQuietly(tempFile) + sendErrorAndFinish(generation, R.string.msg_template_invalid_file) + return + } + + val existing = templateCollectionRepository.findExistingCollision(baseName) + + if (!isCurrentGeneration(generation)) { + deleteQuietly(tempFile) + return + } + + supersedePendingConfirmation(tempFile, generation) + // This dialog's buttons must start enabled regardless of whether some earlier, + // now-abandoned generation's install is still finishing up in the background (see + // confirmTemplateInstall()'s own generation check for the other half of this). + _isInstalling.value = false + if (existing == null) { + _uiEffect.trySend( + ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation(info, tempFile, baseName), + ) + } else { + _uiEffect.trySend( + ExternalFileInstallUiEffect.ShowTemplateNameConflict(existing, info, tempFile), + ) + } + } + + fun onEvent(event: ExternalFileInstallUiEvent) { + when (event) { + is ExternalFileInstallUiEvent.ConfirmTemplateInstall -> { + confirmTemplateInstall(event.tempFile, event.targetBaseName, event.overwrite) + } + + is ExternalFileInstallUiEvent.IgnoreTemplateInstall -> { + // If this doesn't match, the dialog this event was fired from has already been + // superseded (and its tempFile already deleted by supersedePendingConfirmation) - + // nothing left on screen to Finish, and Finish-ing anyway would tear down the + // Activity out from under whatever newer dialog is now showing. + if (pendingConfirmationTempFile == event.tempFile) { + pendingConfirmationTempFile = null + viewModelScope.launch { + deleteQuietly(event.tempFile) + _uiEffect.trySend(ExternalFileInstallUiEffect.Finish) + } + } + } + } + } + + private fun confirmTemplateInstall( + tempFile: File, + targetBaseName: String, + overwrite: Boolean, + ) { + // Guards against a double-tap on Install/Overwrite/Rename firing this twice concurrently - + // the second call's renameTo()/copyTo() would otherwise race the first's on the same + // tempFile and surface a spurious failure toast. + if (_isInstalling.value) return + // If this doesn't match, the dialog this event was fired from has already been superseded + // (its tempFile already deleted by supersedePendingConfirmation) - there's nothing left to + // install, and proceeding anyway would mean using currentRequestGeneration as this + // install's generation, misattributing it to whatever newer request bumped the counter. + if (pendingConfirmationTempFile != tempFile) return + _isInstalling.value = true + // The generation the now-showing dialog was committed under - NOT currentRequestGeneration, + // which may already have moved on to a newer, still-in-flight request (see + // pendingConfirmationGeneration's kdoc). This install must neither tear down the Activity + // out from under that newer request nor touch state that by then belongs to it. + val generation = pendingConfirmationGeneration + // From here on, tempFile's fate is owned by this install attempt, not "a dialog awaiting + // an answer" - a subsequent onReceived() for a different file must not delete it out from + // under an install already in flight. + pendingConfirmationTempFile = null + + viewModelScope.launch { + templateCollectionRepository + .installCollection(tempFile, targetBaseName, overwrite) + .onSuccess { + // The install genuinely happened (the file's on disk in templatesDir) even if + // a newer request has since taken over the screen, so still surface the + // success - but only tear down the Activity (Finish) if nothing newer is now + // relying on it staying alive. targetBaseName is included in the message so + // the toast is unambiguous even when it overlays a newer, unrelated dialog. + _uiEffect.trySend( + ExternalFileInstallUiEffect.ShowSuccess(R.string.msg_template_installed, targetBaseName), + ) + if (isCurrentGeneration(generation)) { + // The Screen suspends on ShowSuccess until the flashbar's entrance + // animation actually finishes (flashSuccessAwaitShown) before processing + // the next buffered effect, so Finish here doesn't need its own delay. + _uiEffect.trySend(ExternalFileInstallUiEffect.Finish) + } + }.onFailure { exception -> + log.error("Failed to install template collection", exception) + if (isCurrentGeneration(generation)) { + // Deliberately don't delete tempFile or Finish here: the dialog the user + // was just on (install-confirm / name-conflict / rename) stays open so + // they can retry - e.g. pick a different name after a collision, or + // Overwrite instead. If a newer request has since superseded this dialog, + // there's nothing left on-screen to retry against, so skip ShowError too. + // Restore pendingConfirmationTempFile/Generation (cleared above on entry): + // a retry tap or Cancel/back on this still-open dialog must match again, or + // confirmTemplateInstall()/IgnoreTemplateInstall's guards would treat every + // button on it as a permanent no-op from here on. + pendingConfirmationTempFile = tempFile + pendingConfirmationGeneration = generation + _uiEffect.trySend( + ExternalFileInstallUiEffect.ShowError( + R.string.msg_template_install_failed, + listOf(exception.message ?: exception.javaClass.simpleName), + ), + ) + } + } + if (isCurrentGeneration(generation)) { + _isInstalling.value = false + } + } + } + + /** + * Suggests a unique base name for the rename dialog by appending "(2)", "(3)", etc. + * + * Each candidate is checked via [TemplateCollectionRepository.findExistingCollision] - a + * fresh directory listing per call - rather than listing `templatesDir` once and checking + * membership in-memory. Left as-is deliberately: [MAX_SUGGESTION_ATTEMPTS] already bounds + * the worst case, a real templates directory is realistically small (a user's own installed + * collections), and avoiding the redundant scans would mean adding a batch-listing method to + * [TemplateCollectionRepository] purely for this one call site's benefit. + */ + suspend fun suggestUniqueBaseName(baseName: String): String { + var candidate = baseName + var suffix = 2 + // Collision must be checked before the attempt-count bound, not after: checking + // `suffix <= MAX` first would let the bound short-circuit the very last candidate's + // collision check, silently returning it unverified once the cap is hit. + while (templateCollectionRepository.findExistingCollision(candidate) != null && suffix <= MAX_SUGGESTION_ATTEMPTS) { + candidate = "$baseName ($suffix)" + suffix++ + } + return candidate + } + + fun sanitizeBaseName(rawName: String): String = rawName.replace(UNSAFE_FILENAME_CHARS, "_").trim().ifBlank { "templates" } + + private suspend fun sendErrorAndFinish( + generation: Int, + @StringRes messageResId: Int, + ) { + if (!isCurrentGeneration(generation)) return + // A stale (already-superseded) request never reaches here (see the isCurrentGeneration + // check above), so whatever's still pending at this point genuinely belongs to an earlier, + // now-being-terminated request and must be cleaned up rather than left dangling. + supersedePendingConfirmation(null, generation) + // See confirmTemplateInstall()'s onSuccess: the Screen suspends on ShowError until the + // flashbar is actually shown before processing Finish, so no delay is needed here either. + _uiEffect.trySend(ExternalFileInstallUiEffect.ShowError(messageResId)) + _uiEffect.trySend(ExternalFileInstallUiEffect.Finish) + } + + private suspend fun deleteQuietly(file: File) { + withContext(Dispatchers.IO) { deleteQuietlyBlocking(file) } + } + + private fun deleteQuietlyBlocking(file: File) { + if (file.exists()) { + file.delete() + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt index 24043b5f46..c4a27463f4 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt @@ -9,13 +9,20 @@ import androidx.lifecycle.viewModelScope import com.itsaky.androidide.plugins.PluginInfo import com.itsaky.androidide.repositories.PluginRepository import com.itsaky.androidide.resources.R +import com.itsaky.androidide.ui.models.PluginInstallSource import com.itsaky.androidide.ui.models.PluginManagerUiEffect import com.itsaky.androidide.ui.models.PluginManagerUiEvent import com.itsaky.androidide.ui.models.PluginManagerUiState import com.itsaky.androidide.ui.models.PluginOperation import com.itsaky.androidide.utils.EditorDecorationBridge +import com.itsaky.androidide.utils.InstallTempFiles +import com.itsaky.androidide.utils.LastValueGate import com.itsaky.androidide.utils.UriFileImporter +import com.itsaky.androidide.utils.getFileName +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -24,6 +31,7 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION import java.io.File /** @@ -31,367 +39,544 @@ import java.io.File * Manages UI state and business logic using MVVM pattern */ class PluginManagerViewModel( - private val pluginRepository: PluginRepository, - private val contentResolver: ContentResolver, - private val filesDir: File + private val pluginRepository: PluginRepository, + private val contentResolver: ContentResolver, + private val filesDir: File, ) : ViewModel() { - - private companion object { - private const val TAG = "PluginManagerViewModel" - } - - // Mutable state for internal updates - private val _uiState = MutableStateFlow( - PluginManagerUiState( - isPluginManagerAvailable = pluginRepository.isPluginManagerAvailable() - ) - ) - - // Public read-only state - val uiState: StateFlow = _uiState.asStateFlow() - - // Channel for one-time UI effects - private val _uiEffect = Channel() - val uiEffect = _uiEffect.receiveAsFlow() - - // Current operation tracking - private val _currentOperation = MutableStateFlow(PluginOperation.None) - val currentOperation: StateFlow = _currentOperation.asStateFlow() - - init { - loadPlugins() - } - - /** - * Handle UI events - */ - fun onEvent(event: PluginManagerUiEvent) { - when (event) { - is PluginManagerUiEvent.LoadPlugins -> loadPlugins() - is PluginManagerUiEvent.EnablePlugin -> enablePlugin(event.pluginId) - is PluginManagerUiEvent.DisablePlugin -> disablePlugin(event.pluginId) - is PluginManagerUiEvent.UninstallPlugin -> showUninstallConfirmation(event.pluginId) - is PluginManagerUiEvent.InstallPlugin -> installPlugin( - event.uri, - event.deleteSourceAfterInstall - ) - is PluginManagerUiEvent.ConfirmOverwrite -> installPlugin( - event.uri, - event.deleteSourceAfterInstall, - checkConflict = false - ) - - is PluginManagerUiEvent.OpenFilePicker -> openFilePicker() - is PluginManagerUiEvent.ShowPluginDetails -> showPluginDetails(event.plugin) - } - } - - /** - * Load all plugins - */ - private fun loadPlugins() { - if (!pluginRepository.isPluginManagerAvailable()) { - _uiState.update { it.copy(isPluginManagerAvailable = false) } - return - } - - viewModelScope.launch { - _currentOperation.value = PluginOperation.Loading - _uiState.update { it.copy(isLoading = true) } - - pluginRepository.getAllPlugins() - .onSuccess { plugins -> - Log.d(TAG, "Loaded ${plugins.size} plugins") - _uiState.update { - it.copy( - isLoading = false, - plugins = plugins, - isPluginManagerAvailable = true - ) - } - } - .onFailure { exception -> - Log.e(TAG, "Failed to load plugins", exception) - _uiState.update { - it.copy(isLoading = false) - } - _uiEffect.trySend( - PluginManagerUiEffect.ShowError( - R.string.msg_plugin_load_failed, - listOf(exception.message ?: "") - ) - ) - } - - // Keep the editor decoration providers in sync with the enabled plugin set. - EditorDecorationBridge.refresh() - - _currentOperation.value = PluginOperation.None - } - } - - /** - * Enable a plugin - */ - private fun enablePlugin(pluginId: String) { - viewModelScope.launch { - _currentOperation.value = PluginOperation.Enabling(pluginId) - - pluginRepository.enablePlugin(pluginId) - .onSuccess { success -> - if (success) { - Log.d(TAG, "Plugin enabled successfully: $pluginId") - _uiEffect.trySend(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_enabled)) - loadPlugins() - } else { - Log.w(TAG, "Failed to enable plugin: $pluginId") - _uiEffect.trySend(PluginManagerUiEffect.ShowError(R.string.msg_plugin_enable_failed)) - } - } - .onFailure { exception -> - Log.e(TAG, "Error enabling plugin: $pluginId", exception) - _uiEffect.trySend( - PluginManagerUiEffect.ShowError( - R.string.msg_plugin_enable_error, - listOf(exception.message ?: "") - ) - ) - } - - _currentOperation.value = PluginOperation.None - } - } - - /** - * Disable a plugin - */ - private fun disablePlugin(pluginId: String) { - viewModelScope.launch { - _currentOperation.value = PluginOperation.Disabling(pluginId) - - pluginRepository.disablePlugin(pluginId) - .onSuccess { success -> - if (success) { - Log.d(TAG, "Plugin disabled successfully: $pluginId") - _uiEffect.trySend(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_disabled)) - loadPlugins() - } else { - Log.w(TAG, "Failed to disable plugin: $pluginId") - _uiEffect.trySend(PluginManagerUiEffect.ShowError(R.string.msg_plugin_disable_failed)) - } - } - .onFailure { exception -> - Log.e(TAG, "Error disabling plugin: $pluginId", exception) - _uiEffect.trySend( - PluginManagerUiEffect.ShowError( - R.string.msg_plugin_disable_error, - listOf(exception.message ?: "") - ) - ) - } - - _currentOperation.value = PluginOperation.None - } - } - - /** - * Show uninstall confirmation dialog - */ - private fun showUninstallConfirmation(pluginId: String) { - val plugin = _uiState.value.plugins.find { it.metadata.id == pluginId } - if (plugin != null) { - viewModelScope.launch { - _uiEffect.trySend(PluginManagerUiEffect.ShowUninstallConfirmation(plugin)) - } - } - } - - /** - * Uninstall a plugin (called after confirmation) - */ - fun confirmUninstallPlugin(pluginId: String) { - viewModelScope.launch { - _currentOperation.value = PluginOperation.Uninstalling(pluginId) - - pluginRepository.uninstallPlugin(pluginId) - .onSuccess { success -> - if (success) { - Log.d(TAG, "Plugin uninstalled successfully: $pluginId") - _uiEffect.trySend(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_uninstalled)) - loadPlugins() - _uiEffect.trySend(PluginManagerUiEffect.ShowRestartPrompt) - } else { - Log.w(TAG, "Failed to uninstall plugin: $pluginId") - _uiEffect.trySend(PluginManagerUiEffect.ShowError(R.string.msg_plugin_uninstall_failed)) - } - } - .onFailure { exception -> - Log.e(TAG, "Error uninstalling plugin: $pluginId", exception) - _uiEffect.trySend( - PluginManagerUiEffect.ShowError( - R.string.msg_plugin_uninstall_error, - listOf(exception.message ?: "") - ) - ) - } - - _currentOperation.value = PluginOperation.None - } - } - - private fun installPlugin(uri: Uri, deleteSourceAfterInstall: Boolean, checkConflict: Boolean = true) { - viewModelScope.launch { - _currentOperation.value = PluginOperation.Installing - _uiState.update { it.copy(isInstalling = true) } - - var tempFile: File? = null - - try { - tempFile = withContext(Dispatchers.IO) { - val fileName = UriFileImporter.getDisplayName(contentResolver, uri) - val extension = if (fileName?.endsWith( - ".cgp", - ignoreCase = true - ) == true - ) ".cgp" else ".apk" - val tempFileName = "temp_plugin_${System.currentTimeMillis()}$extension" - val tempDir = File(filesDir, "temp").apply { mkdirs() } - val tempFile = File(tempDir, tempFileName) - - UriFileImporter.copyUriToFile(contentResolver, uri, tempFile) { - Exception("Cannot open file") - } - tempFile - } - - if (checkConflict && resolveInstallConflict(tempFile, uri, deleteSourceAfterInstall)) { - return@launch - } - - pluginRepository.installPluginFromFile(tempFile) - .onSuccess { - Log.d(TAG, "Plugin installed successfully") - _uiEffect.trySend(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_installed)) - loadPlugins() - _uiEffect.trySend(PluginManagerUiEffect.ShowRestartPrompt) - - if (deleteSourceAfterInstall) { - deleteSourceDocument(uri) - } - } - .onFailure { exception -> - Log.e(TAG, "Failed to install plugin", exception) - _uiEffect.trySend( - PluginManagerUiEffect.ShowError( - R.string.msg_plugin_install_failed, - listOf(exception.message ?: "") - ) - ) - } - } catch (exception: Exception) { - Log.e(TAG, "Error installing plugin from URI", exception) - _uiEffect.trySend( - PluginManagerUiEffect.ShowError( - R.string.msg_plugin_install_failed, - listOf(exception.message ?: "") - ) - ) - } finally { - tempFile?.let { file -> - withContext(Dispatchers.IO) { - if (file.exists()) { - file.delete() - } - } - } - _uiState.update { it.copy(isInstalling = false) } - _currentOperation.value = PluginOperation.None - } - } - } - - private suspend fun resolveInstallConflict( - tempFile: File, - uri: Uri, - deleteSourceAfterInstall: Boolean - ): Boolean { - val incoming = pluginRepository.getPluginMetadataFromFile(tempFile).getOrNull() - if (incoming == null) { - Log.w(TAG, "Failed to read plugin metadata from ${tempFile.name}; aborting install") - _uiEffect.trySend(PluginManagerUiEffect.ShowError(R.string.msg_plugin_invalid_file)) - return true - } - - val existing = _uiState.value.plugins.find { it.metadata.id == incoming.id } - ?: return false - - val signaturesMatch = pluginRepository - .haveMatchingSignatures(tempFile, existing.metadata.id) - .getOrDefault(false) - - val effect = if (!signaturesMatch) { - PluginManagerUiEffect.ShowError( - R.string.msg_plugin_signature_mismatch, - listOf(existing.metadata.name) - ) - } else { - PluginManagerUiEffect.ShowOverwriteConfirmation( - existing = existing, - incomingMetadata = incoming, - uri = uri, - deleteSourceAfterInstall = deleteSourceAfterInstall - ) - } - _uiEffect.trySend(effect) - return true - } - - private suspend fun deleteSourceDocument(uri: Uri) { - withContext(Dispatchers.IO) { - try { - val deleted = DocumentsContract.deleteDocument(contentResolver, uri) - if (!deleted) { - _uiEffect.trySend( - PluginManagerUiEffect.ShowError(R.string.msg_source_delete_failed) - ) - } - } catch (e: Exception) { - Log.w(TAG, "Failed to delete source document", e) - _uiEffect.trySend( - PluginManagerUiEffect.ShowError(R.string.msg_source_delete_failed) - ) - } - } - } - - /** - * Open file picker - */ - private fun openFilePicker() { - viewModelScope.launch { - _uiEffect.trySend(PluginManagerUiEffect.OpenFilePicker) - } - } - - /** - * Show plugin details - */ - private fun showPluginDetails(plugin: PluginInfo) { - viewModelScope.launch { - _uiEffect.trySend(PluginManagerUiEffect.ShowPluginDetails(plugin)) - } - } - - /** - * Check if a specific plugin operation is in progress - */ - fun isPluginOperationInProgress(pluginId: String): Boolean { - return when (val operation = _currentOperation.value) { - is PluginOperation.Enabling -> operation.pluginId == pluginId - is PluginOperation.Disabling -> operation.pluginId == pluginId - is PluginOperation.Uninstalling -> operation.pluginId == pluginId - else -> false - } - } - + private companion object { + private const val TAG = "PluginManagerViewModel" + } + + // Tracks the last forwarded-install file path (from ExternalFileInstallActivity) this + // instance has already shown a dialog for. Survives rotation (same ViewModel instance, via + // the ViewModelStore) so the dialog isn't re-popped on every rotation, but resets on process + // death (a fresh instance is created), so a process-death-recreated PluginManagerActivity + // still shows the dialog instead of silently dropping the forwarded install. + private val pendingInstallGate = LastValueGate() + + // Completed once the first loadPlugins() call (from init{}) has concluded, successfully or + // not. resolveInstallConflict() awaits this before consulting _uiState.value.plugins, so an + // install confirmed immediately after a cold start can't race the async plugin-list load and + // skip the same-ID signature check by seeing an still-empty list. + private val initialLoadCompleted = CompletableDeferred() + + /** + * Entry point for a `.cgp` forwarded from + * [com.itsaky.androidide.activities.ExternalFileInstallActivity], by absolute path. + * + * Emits [PluginManagerUiEffect.ShowInstallConfirmation] with a + * [PluginInstallSource.LocalFile], so the forwarded install reuses the same confirmation + * dialog the SAF pick does rather than a second, parallel one. + * + * [pendingInstallGate] is the idempotency check rather than an Activity `savedInstanceState` + * check, because it is what correctly distinguishes "already shown after a rotation" (same + * ViewModel instance, gate still holds the path) from "never shown because the process died" + * (fresh ViewModel, gate empty, dialog must still appear). The `exists()` probe runs on IO: + * this is called from `onCreate`/`onNewIntent`, and a missing file is legitimate if + * `InstallTempFiles`' stale-file sweep already removed the temp copy. + */ + fun onPendingInstallFile(filePath: String) { + if (!pendingInstallGate.consume(filePath)) return + viewModelScope.launch { + val file = File(filePath) + if (withContext(Dispatchers.IO) { file.exists() }) { + _uiEffect.send( + PluginManagerUiEffect.ShowInstallConfirmation(PluginInstallSource.LocalFile(file)), + ) + } else { + _uiEffect.send(PluginManagerUiEffect.ShowError(R.string.msg_plugin_file_not_found)) + } + } + } + + // Mutable state for internal updates + private val _uiState = + MutableStateFlow( + PluginManagerUiState( + isPluginManagerAvailable = pluginRepository.isPluginManagerAvailable(), + ), + ) + + // Public read-only state + val uiState: StateFlow = _uiState.asStateFlow() + + // Channel for one-time UI effects. Buffered (not rendezvous): a synchronous decision path + // (e.g. handlePendingInstallExtra()'s effect right after onCreate()/onNewIntent()) can + // otherwise complete before the Activity's collector actually attaches, silently dropping the + // effect - see ExternalFileInstallViewModel's identical reasoning for its own uiEffect. + private val _uiEffect = Channel(capacity = Channel.BUFFERED) + val uiEffect = _uiEffect.receiveAsFlow() + + // Current operation tracking + private val _currentOperation = MutableStateFlow(PluginOperation.None) + val currentOperation: StateFlow = _currentOperation.asStateFlow() + + init { + loadPlugins() + } + + /** + * Handle UI events + */ + fun onEvent(event: PluginManagerUiEvent) { + when (event) { + is PluginManagerUiEvent.LoadPlugins -> { + loadPlugins() + } + + is PluginManagerUiEvent.EnablePlugin -> { + enablePlugin(event.pluginId) + } + + is PluginManagerUiEvent.DisablePlugin -> { + disablePlugin(event.pluginId) + } + + is PluginManagerUiEvent.UninstallPlugin -> { + showUninstallConfirmation(event.pluginId) + } + + is PluginManagerUiEvent.InstallPlugin -> { + installPlugin( + event.source, + event.deleteSourceAfterInstall, + ) + } + + is PluginManagerUiEvent.ConfirmOverwrite -> { + installPlugin( + event.source, + event.deleteSourceAfterInstall, + checkConflict = false, + ) + } + + is PluginManagerUiEvent.CancelPendingInstall -> { + // Only a forwarded LocalFile (our own disposable temp copy) is cleaned up here - + // nothing was installed, so a user-picked ContentUri source is never touched on + // decline (deletion there only ever happens after a *successful* install, + // matching the "delete after install" checkbox's label - there's no flag to + // consult here since a decline never installs anything). + viewModelScope.launch { deleteIfLocalFile(event.source) } + } + + is PluginManagerUiEvent.FileSelected -> { + handleFileSelected(event.uri) + } + + is PluginManagerUiEvent.ShowPluginDetails -> { + showPluginDetails(event.plugin) + } + } + } + + /** + * Load all plugins + */ + private fun loadPlugins() { + if (!pluginRepository.isPluginManagerAvailable()) { + _uiState.update { it.copy(isPluginManagerAvailable = false) } + initialLoadCompleted.complete(Unit) + return + } + + viewModelScope.launch { + _currentOperation.value = PluginOperation.Loading + _uiState.update { it.copy(isLoading = true) } + + pluginRepository + .getAllPlugins() + .onSuccess { plugins -> + Log.d(TAG, "Loaded ${plugins.size} plugins") + _uiState.update { + it.copy( + isLoading = false, + plugins = plugins, + isPluginManagerAvailable = true, + ) + } + }.onFailure { exception -> + Log.e(TAG, "Failed to load plugins", exception) + _uiState.update { + it.copy(isLoading = false) + } + _uiEffect.send( + PluginManagerUiEffect.ShowError( + R.string.msg_plugin_load_failed, + listOf(exception.message ?: ""), + ), + ) + } + + // Keep the editor decoration providers in sync with the enabled plugin set. + EditorDecorationBridge.refresh() + + _currentOperation.value = PluginOperation.None + // A no-op if already completed by an earlier loadPlugins() call - only the first + // call's outcome matters for initialLoadCompleted's purpose. + initialLoadCompleted.complete(Unit) + } + } + + /** + * Enable a plugin + */ + private fun enablePlugin(pluginId: String) { + viewModelScope.launch { + _currentOperation.value = PluginOperation.Enabling(pluginId) + + pluginRepository + .enablePlugin(pluginId) + .onSuccess { success -> + if (success) { + Log.d(TAG, "Plugin enabled successfully: $pluginId") + _uiEffect.send(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_enabled)) + loadPlugins() + } else { + Log.w(TAG, "Failed to enable plugin: $pluginId") + _uiEffect.send(PluginManagerUiEffect.ShowError(R.string.msg_plugin_enable_failed)) + } + }.onFailure { exception -> + Log.e(TAG, "Error enabling plugin: $pluginId", exception) + _uiEffect.send( + PluginManagerUiEffect.ShowError( + R.string.msg_plugin_enable_error, + listOf(exception.message ?: ""), + ), + ) + } + + _currentOperation.value = PluginOperation.None + } + } + + /** + * Disable a plugin + */ + private fun disablePlugin(pluginId: String) { + viewModelScope.launch { + _currentOperation.value = PluginOperation.Disabling(pluginId) + + pluginRepository + .disablePlugin(pluginId) + .onSuccess { success -> + if (success) { + Log.d(TAG, "Plugin disabled successfully: $pluginId") + _uiEffect.send(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_disabled)) + loadPlugins() + } else { + Log.w(TAG, "Failed to disable plugin: $pluginId") + _uiEffect.send(PluginManagerUiEffect.ShowError(R.string.msg_plugin_disable_failed)) + } + }.onFailure { exception -> + Log.e(TAG, "Error disabling plugin: $pluginId", exception) + _uiEffect.send( + PluginManagerUiEffect.ShowError( + R.string.msg_plugin_disable_error, + listOf(exception.message ?: ""), + ), + ) + } + + _currentOperation.value = PluginOperation.None + } + } + + /** + * Show uninstall confirmation dialog + */ + private fun showUninstallConfirmation(pluginId: String) { + val plugin = _uiState.value.plugins.find { it.metadata.id == pluginId } + if (plugin != null) { + viewModelScope.launch { + _uiEffect.send(PluginManagerUiEffect.ShowUninstallConfirmation(plugin)) + } + } + } + + /** + * Uninstall a plugin (called after confirmation) + */ + fun confirmUninstallPlugin(pluginId: String) { + viewModelScope.launch { + _currentOperation.value = PluginOperation.Uninstalling(pluginId) + + pluginRepository + .uninstallPlugin(pluginId) + .onSuccess { success -> + if (success) { + Log.d(TAG, "Plugin uninstalled successfully: $pluginId") + _uiEffect.send(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_uninstalled)) + loadPlugins() + _uiEffect.send(PluginManagerUiEffect.ShowRestartPrompt) + } else { + Log.w(TAG, "Failed to uninstall plugin: $pluginId") + _uiEffect.send(PluginManagerUiEffect.ShowError(R.string.msg_plugin_uninstall_failed)) + } + }.onFailure { exception -> + Log.e(TAG, "Error uninstalling plugin: $pluginId", exception) + _uiEffect.send( + PluginManagerUiEffect.ShowError( + R.string.msg_plugin_uninstall_error, + listOf(exception.message ?: ""), + ), + ) + } + + _currentOperation.value = PluginOperation.None + } + } + + private fun installPlugin( + source: PluginInstallSource, + deleteSourceAfterInstall: Boolean, + checkConflict: Boolean = true, + ) { + viewModelScope.launch { + _currentOperation.value = PluginOperation.Installing + _uiState.update { it.copy(isInstalling = true) } + + // ownedTempFile (the ContentUri case's own temp copy) is what the `finally` block + // below cleans up unconditionally. Note pluginRepository.installPluginFromFile() + // itself unconditionally deletes whatever `pluginFile` it's given once that's copied + // into the plugins directory - that's pre-existing behavior this function doesn't + // control (it also affects InstallFileAction.kt's direct callers). What + // deleteSourceAfterInstall/deleteInstallSource governs below is the *original* + // source's lifecycle instead: a user-picked ContentUri is only ever deleted after a + // successful install (see the onSuccess/onFailure split below), while a forwarded + // LocalFile temp copy is always cleaned up regardless of outcome. + var ownedTempFile: File? = null + var pluginFile: File? = null + + try { + if (checkConflict) { + // See initialLoadCompleted's kdoc: guarantees _uiState.value.plugins reflects + // the real installed set before resolveInstallConflict() checks it below. + initialLoadCompleted.await() + } + + pluginFile = + when (source) { + is PluginInstallSource.LocalFile -> { + source.file + } + + is PluginInstallSource.ContentUri -> { + withContext(Dispatchers.IO) { + val fileName = UriFileImporter.getDisplayName(contentResolver, source.uri) + val extension = + if (fileName?.endsWith(".$PLUGIN_ARCHIVE_EXTENSION", ignoreCase = true) == true) { + PLUGIN_ARCHIVE_EXTENSION + } else { + "apk" + } + val tempFile = InstallTempFiles.newTempFile(filesDir, "temp_plugin", extension) + // Assigned immediately (a plain, non-suspending write), before the + // suspending copy below - so a cancellation landing mid-copy still + // leaves ownedTempFile pointing at the file for `finally` to clean + // up. Assigning only after this whole block returns (e.g. via + // `.also{}` on the block's result) would miss that window: a + // cancellation right as the block finishes makes withContext throw + // instead of returning, so the assignment would never run. + ownedTempFile = tempFile + + UriFileImporter.copyUriToFile(contentResolver, source.uri, tempFile) { + Exception("Cannot open file") + } + tempFile + } + } + } + + if (checkConflict && resolveInstallConflict(pluginFile, source, deleteSourceAfterInstall)) { + return@launch + } + + pluginRepository + .installPluginFromFile(pluginFile) + .onSuccess { + Log.d(TAG, "Plugin installed successfully") + _uiEffect.send(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_installed)) + loadPlugins() + _uiEffect.send(PluginManagerUiEffect.ShowRestartPrompt) + + if (deleteSourceAfterInstall) { + deleteInstallSource(source) + } + }.onFailure { exception -> + Log.e(TAG, "Failed to install plugin", exception) + _uiEffect.send( + PluginManagerUiEffect.ShowError( + R.string.msg_plugin_install_failed, + listOf(exception.message ?: ""), + ), + ) + // A failed install deletes nothing but our own disposable temp copy - a + // user-picked ContentUri is preserved so they can retry, matching + // deleteSourceAfterInstall's "delete after install [succeeds]" meaning. + deleteIfLocalFile(source) + } + } catch (e: CancellationException) { + // Matches the "always cleaned up regardless of outcome" comment above: cancellation + // is itself an outcome the forwarded temp file must not survive. + withContext(NonCancellable) { deleteIfLocalFile(source) } + throw e + } catch (exception: Exception) { + Log.e(TAG, "Error installing plugin from URI", exception) + _uiEffect.send( + PluginManagerUiEffect.ShowError( + R.string.msg_plugin_install_failed, + listOf(exception.message ?: ""), + ), + ) + deleteIfLocalFile(source) + } finally { + ownedTempFile?.let { file -> + withContext(NonCancellable + Dispatchers.IO) { + if (file.exists()) { + file.delete() + } + } + } + _uiState.update { it.copy(isInstalling = false) } + _currentOperation.value = PluginOperation.None + } + } + } + + private suspend fun resolveInstallConflict( + pluginFile: File, + source: PluginInstallSource, + deleteSourceAfterInstall: Boolean, + ): Boolean { + val incoming = pluginRepository.getPluginMetadataFromFile(pluginFile).getOrNull() + if (incoming == null) { + Log.w(TAG, "Failed to read plugin metadata from ${pluginFile.name}; aborting install") + _uiEffect.send(PluginManagerUiEffect.ShowError(R.string.msg_plugin_invalid_file)) + deleteIfLocalFile(source) + return true + } + + val existing = + _uiState.value.plugins.find { it.metadata.id == incoming.id } + ?: return false + + val signaturesMatch = + pluginRepository + .haveMatchingSignatures(pluginFile, existing.metadata.id) + .getOrDefault(false) + + if (!signaturesMatch) { + _uiEffect.send( + PluginManagerUiEffect.ShowError( + R.string.msg_plugin_signature_mismatch, + listOf(existing.metadata.name), + ), + ) + deleteIfLocalFile(source) + return true + } + + // Deliberately don't delete the source yet: the user still needs to choose Replace or + // Cancel. ConfirmOverwrite re-runs installPlugin() to consume it on Replace; + // CancelPendingInstall cleans it up if they back out instead. + _uiEffect.send( + PluginManagerUiEffect.ShowOverwriteConfirmation( + existing = existing, + incomingMetadata = incoming, + source = source, + deleteSourceAfterInstall = deleteSourceAfterInstall, + ), + ) + return true + } + + /** A user-picked ContentUri is only ever deleted after a successful install (matching the + * "delete after install" checkbox's label) - a forwarded LocalFile temp copy is disposable + * regardless of outcome, so it's the only source type any non-success path cleans up here. */ + private suspend fun deleteIfLocalFile(source: PluginInstallSource) { + if (source is PluginInstallSource.LocalFile) { + deleteInstallSource(source) + } + } + + private suspend fun deleteInstallSource(source: PluginInstallSource) { + when (source) { + is PluginInstallSource.LocalFile -> { + withContext(Dispatchers.IO) { + if (source.file.exists() && !source.file.delete()) { + Log.w(TAG, "Failed to delete forwarded install file: ${source.file.absolutePath}") + } + } + } + + is PluginInstallSource.ContentUri -> { + deleteSourceDocument(source.uri) + } + } + } + + private suspend fun deleteSourceDocument(uri: Uri) { + withContext(Dispatchers.IO) { + try { + if (!DocumentsContract.deleteDocument(contentResolver, uri)) { + _uiEffect.send( + PluginManagerUiEffect.ShowError(R.string.msg_source_delete_failed), + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Failed to delete source document", e) + _uiEffect.send( + PluginManagerUiEffect.ShowError(R.string.msg_source_delete_failed), + ) + } + } + } + + /** + * Validate the picked plugin file's name off the main thread (querying a `content://` URI's + * display name is a `ContentResolver` IPC call) and route to install confirmation or an error. + * + * A SAF pick is always a `content://` URI, so it becomes a [PluginInstallSource.ContentUri]; + * the forwarded-file path emits [PluginManagerUiEffect.ShowInstallConfirmation] with a + * [PluginInstallSource.LocalFile] directly and never comes through here. + */ + private fun handleFileSelected(uri: Uri) { + viewModelScope.launch { + val isSupported = + withContext(Dispatchers.IO) { + uri.getFileName(contentResolver).endsWith(".$PLUGIN_ARCHIVE_EXTENSION", ignoreCase = true) + } + + if (isSupported) { + _uiEffect.send( + PluginManagerUiEffect.ShowInstallConfirmation(PluginInstallSource.ContentUri(uri)), + ) + } else { + _uiEffect.send(PluginManagerUiEffect.ShowError(R.string.msg_unsupported_plugin_file)) + } + } + } + + /** + * Show plugin details + */ + private fun showPluginDetails(plugin: PluginInfo) { + viewModelScope.launch { + _uiEffect.send(PluginManagerUiEffect.ShowPluginDetails(plugin)) + } + } + + /** + * Check if a specific plugin operation is in progress + */ + fun isPluginOperationInProgress(pluginId: String): Boolean = + when (val operation = _currentOperation.value) { + is PluginOperation.Enabling -> operation.pluginId == pluginId + is PluginOperation.Disabling -> operation.pluginId == pluginId + is PluginOperation.Uninstalling -> operation.pluginId == pluginId + else -> false + } } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/TemplateManagerViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/TemplateManagerViewModel.kt new file mode 100644 index 0000000000..13df7bca84 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/TemplateManagerViewModel.kt @@ -0,0 +1,166 @@ +package com.itsaky.androidide.viewmodels + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.itsaky.androidide.repositories.TemplateRepository +import com.itsaky.androidide.resources.R +import com.itsaky.androidide.templates.manager.models.CgtFileItem +import com.itsaky.androidide.templates.manager.models.displayName +import com.itsaky.androidide.ui.models.TemplateManagerUiEffect +import com.itsaky.androidide.ui.models.TemplateManagerUiEvent +import com.itsaky.androidide.ui.models.TemplateManagerUiState +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * ViewModel for the Templates tab. Same UDF shape as [PluginManagerViewModel]. + */ +class TemplateManagerViewModel( + private val templateRepository: TemplateRepository, +) : ViewModel() { + private companion object { + private const val TAG = "TemplateManagerViewModel" + } + + private val _uiState = MutableStateFlow(TemplateManagerUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _uiEffect = Channel(Channel.BUFFERED) + val uiEffect = _uiEffect.receiveAsFlow() + + init { + loadTemplates() + } + + fun onEvent(event: TemplateManagerUiEvent) { + when (event) { + is TemplateManagerUiEvent.LoadTemplates -> loadTemplates() + is TemplateManagerUiEvent.InstallTemplate -> installTemplate(event.item) + is TemplateManagerUiEvent.UninstallTemplate -> uninstallTemplate(event.item) + is TemplateManagerUiEvent.DeleteDownloadFile -> showDeleteConfirmation(event.item) + is TemplateManagerUiEvent.ShowTemplateDetails -> showTemplateDetails(event.item) + is TemplateManagerUiEvent.ShowTemplateList -> showTemplateList(event.item) + } + } + + private fun loadTemplates() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + + templateRepository + .listTemplateFiles() + .onSuccess { items -> + Log.d(TAG, "Loaded ${items.size} template files") + _uiState.update { it.copy(isLoading = false, items = items) } + }.onFailure { exception -> + Log.e(TAG, "Failed to load template files", exception) + _uiState.update { it.copy(isLoading = false) } + _uiEffect.send( + TemplateManagerUiEffect.ShowError( + R.string.msg_template_load_failed, + listOf(exception.message ?: ""), + ), + ) + } + } + } + + private fun installTemplate(item: CgtFileItem) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + templateRepository + .installTemplate(item) + .onSuccess { + Log.d(TAG, "Template installed successfully: ${item.name}") + _uiEffect.send( + TemplateManagerUiEffect.ShowSuccess( + R.string.msg_template_installed, + listOf(item.displayName), + ), + ) + // loadTemplates() clears isLoading once the post-install rescan completes, so the + // indicator stays up continuously across both phases instead of flickering off + // between them. + loadTemplates() + }.onFailure { exception -> + Log.e(TAG, "Failed to install template: ${item.name}", exception) + _uiState.update { it.copy(isLoading = false) } + _uiEffect.send( + TemplateManagerUiEffect.ShowError( + R.string.msg_template_install_failed, + listOf(exception.message ?: ""), + ), + ) + } + } + } + + private fun uninstallTemplate(item: CgtFileItem) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + templateRepository + .uninstallTemplate(item) + .onSuccess { + Log.d(TAG, "Template uninstalled successfully: ${item.name}") + _uiEffect.send(TemplateManagerUiEffect.ShowSuccess(R.string.msg_template_uninstalled)) + loadTemplates() + }.onFailure { exception -> + Log.e(TAG, "Failed to uninstall template: ${item.name}", exception) + _uiState.update { it.copy(isLoading = false) } + _uiEffect.send( + TemplateManagerUiEffect.ShowError( + R.string.msg_template_uninstall_failed, + listOf(exception.message ?: ""), + ), + ) + } + } + } + + private fun showDeleteConfirmation(item: CgtFileItem) { + viewModelScope.launch { + _uiEffect.send(TemplateManagerUiEffect.ShowDeleteConfirmation(item)) + } + } + + /** Deletes a not-installed template's Downloads file (called after confirmation). */ + fun confirmDeleteDownloadFile(item: CgtFileItem) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + templateRepository + .deleteDownloadFile(item) + .onSuccess { + Log.d(TAG, "Deleted download file: ${item.name}") + _uiEffect.send(TemplateManagerUiEffect.ShowSuccess(R.string.msg_template_deleted)) + loadTemplates() + }.onFailure { exception -> + Log.e(TAG, "Failed to delete download file: ${item.name}", exception) + _uiState.update { it.copy(isLoading = false) } + _uiEffect.send( + TemplateManagerUiEffect.ShowError( + R.string.msg_template_delete_failed, + listOf(exception.message ?: ""), + ), + ) + } + } + } + + private fun showTemplateDetails(item: CgtFileItem) { + viewModelScope.launch { + _uiEffect.send(TemplateManagerUiEffect.ShowTemplateDetails(item)) + } + } + + private fun showTemplateList(item: CgtFileItem) { + viewModelScope.launch { + _uiEffect.send(TemplateManagerUiEffect.ShowTemplateList(item)) + } + } +} diff --git a/app/src/main/res/layout/activity_plugin_manager.xml b/app/src/main/res/layout/activity_plugin_manager.xml index 120e4dd2e1..70204d9e6f 100644 --- a/app/src/main/res/layout/activity_plugin_manager.xml +++ b/app/src/main/res/layout/activity_plugin_manager.xml @@ -1,83 +1,16 @@ - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_height="match_parent" /> - + diff --git a/app/src/main/res/layout/dialog_install_plugin.xml b/app/src/main/res/layout/dialog_install_plugin.xml deleted file mode 100644 index 54824f6dec..0000000000 --- a/app/src/main/res/layout/dialog_install_plugin.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - diff --git a/app/src/main/res/layout/item_plugin.xml b/app/src/main/res/layout/item_plugin.xml deleted file mode 100644 index 6ca63b9e41..0000000000 --- a/app/src/main/res/layout/item_plugin.xml +++ /dev/null @@ -1,106 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/layout_dialog_privacy_consent.xml b/app/src/main/res/layout/layout_dialog_privacy_consent.xml new file mode 100644 index 0000000000..6cdf16e6c4 --- /dev/null +++ b/app/src/main/res/layout/layout_dialog_privacy_consent.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/menu_plugin_manager.xml b/app/src/main/res/menu/menu_plugin_manager.xml deleted file mode 100644 index d68857f07b..0000000000 --- a/app/src/main/res/menu/menu_plugin_manager.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml new file mode 100644 index 0000000000..0a7e4ccd4f --- /dev/null +++ b/app/src/main/res/values-in/strings.xml @@ -0,0 +1,26 @@ + + + + + Gunakan prompt sederhana + Gagal memperbarui sumber daya string yang dihasilkan. + Tidak dapat menemukan file sumber daya string proyek. + Perangkat ini tidak mendukung konfigurasi parser XML yang diperlukan untuk memperbarui sumber daya string yang dihasilkan. + Sumber daya string yang dihasilkan bukan XML yang valid. + Tidak dapat membuka “%1$s” di jendela mengambang. + diff --git a/app/src/test/java/com/itsaky/androidide/analytics/AnalyticsManagerConsentTest.kt b/app/src/test/java/com/itsaky/androidide/analytics/AnalyticsManagerConsentTest.kt new file mode 100644 index 0000000000..07a7ddde43 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/analytics/AnalyticsManagerConsentTest.kt @@ -0,0 +1,70 @@ + +package com.itsaky.androidide.analytics + +import com.google.firebase.analytics.FirebaseAnalytics +import com.google.firebase.analytics.ktx.analytics +import com.google.firebase.ktx.Firebase +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class AnalyticsManagerConsentTest { + private lateinit var firebaseAnalytics: FirebaseAnalytics + + @Before + fun setUp() { + System.setProperty("androidide.test.mode", "true") + + firebaseAnalytics = mockk(relaxed = true) + mockkStatic("com.google.firebase.analytics.ktx.AnalyticsKt") + every { Firebase.analytics } returns firebaseAnalytics + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `track call before initialize keeps collection disabled`() { + AnalyticsManager().trackFeatureUsed("editor") + + verify { firebaseAnalytics.setAnalyticsCollectionEnabled(false) } + verify(exactly = 0) { firebaseAnalytics.setAnalyticsCollectionEnabled(true) } + } + + @Test + fun `metric call before initialize keeps collection disabled`() { + AnalyticsManager().trackProjectOpened("/sdcard/project") + + verify { firebaseAnalytics.setAnalyticsCollectionEnabled(false) } + verify(exactly = 0) { firebaseAnalytics.setAnalyticsCollectionEnabled(true) } + } + + @Test + fun `initialize enables collection`() { + AnalyticsManager().initialize() + + verify(atLeast = 1) { firebaseAnalytics.setAnalyticsCollectionEnabled(true) } + verify(exactly = 0) { firebaseAnalytics.setAnalyticsCollectionEnabled(false) } + } + + @Test + fun `initialize re-enables collection on an instance that already tracked`() { + val manager = AnalyticsManager() + manager.trackFeatureUsed("editor") + verify { firebaseAnalytics.setAnalyticsCollectionEnabled(false) } + + manager.initialize() + + verify { firebaseAnalytics.setAnalyticsCollectionEnabled(true) } + } +} diff --git a/app/src/test/java/com/itsaky/androidide/analytics/AttachedDevicesCollectorTest.kt b/app/src/test/java/com/itsaky/androidide/analytics/AttachedDevicesCollectorTest.kt new file mode 100644 index 0000000000..09b1621827 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/analytics/AttachedDevicesCollectorTest.kt @@ -0,0 +1,122 @@ +package com.itsaky.androidide.analytics + +import android.view.InputDevice +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class AttachedDevicesCollectorTest { + @Test + fun `external mouse is classified as mouse`() { + val classes = + AttachedDevicesCollector.classify( + sources = InputDevice.SOURCE_MOUSE, + keyboardType = InputDevice.KEYBOARD_TYPE_NONE, + isVirtual = false, + isExternal = true, + ) + assertThat(classes).containsExactly(AttachedDeviceClass.MOUSE) + } + + @Test + fun `external alphabetic keyboard is classified as keyboard`() { + val classes = + AttachedDevicesCollector.classify( + sources = InputDevice.SOURCE_KEYBOARD, + keyboardType = InputDevice.KEYBOARD_TYPE_ALPHABETIC, + isVirtual = false, + isExternal = true, + ) + assertThat(classes).containsExactly(AttachedDeviceClass.EXTERNAL_KEYBOARD) + } + + @Test + fun `non alphabetic keyboard is not classified`() { + val classes = + AttachedDevicesCollector.classify( + sources = InputDevice.SOURCE_KEYBOARD, + keyboardType = InputDevice.KEYBOARD_TYPE_NON_ALPHABETIC, + isVirtual = false, + isExternal = true, + ) + assertThat(classes).isEmpty() + } + + @Test + fun `touchpad stylus and gamepad classes are detected`() { + assertThat( + AttachedDevicesCollector.classify(InputDevice.SOURCE_TOUCHPAD, InputDevice.KEYBOARD_TYPE_NONE, false, true), + ).containsExactly(AttachedDeviceClass.TOUCHPAD) + assertThat( + AttachedDevicesCollector.classify(InputDevice.SOURCE_STYLUS, InputDevice.KEYBOARD_TYPE_NONE, false, true), + ).containsExactly(AttachedDeviceClass.STYLUS) + assertThat( + AttachedDevicesCollector.classify(InputDevice.SOURCE_BLUETOOTH_STYLUS, InputDevice.KEYBOARD_TYPE_NONE, false, true), + ).containsExactly(AttachedDeviceClass.STYLUS) + assertThat( + AttachedDevicesCollector.classify(InputDevice.SOURCE_GAMEPAD, InputDevice.KEYBOARD_TYPE_NONE, false, true), + ).containsExactly(AttachedDeviceClass.GAMEPAD) + assertThat( + AttachedDevicesCollector.classify(InputDevice.SOURCE_JOYSTICK, InputDevice.KEYBOARD_TYPE_NONE, false, true), + ).containsExactly(AttachedDeviceClass.GAMEPAD) + } + + @Test + fun `virtual devices are never classified`() { + val classes = + AttachedDevicesCollector.classify( + sources = InputDevice.SOURCE_KEYBOARD, + keyboardType = InputDevice.KEYBOARD_TYPE_ALPHABETIC, + isVirtual = true, + isExternal = true, + ) + assertThat(classes).isEmpty() + } + + @Test + fun `internal devices are never classified on api 29 plus`() { + val classes = + AttachedDevicesCollector.classify( + sources = InputDevice.SOURCE_MOUSE, + keyboardType = InputDevice.KEYBOARD_TYPE_NONE, + isVirtual = false, + isExternal = false, + ) + assertThat(classes).isEmpty() + } + + @Test + fun `api 28 fallback excludes stylus capable touchscreens`() { + val classes = + AttachedDevicesCollector.classify( + sources = InputDevice.SOURCE_STYLUS or InputDevice.SOURCE_TOUCHSCREEN, + keyboardType = InputDevice.KEYBOARD_TYPE_NONE, + isVirtual = false, + isExternal = null, + ) + assertThat(classes).isEmpty() + } + + @Test + fun `api 28 fallback still detects a mouse`() { + val classes = + AttachedDevicesCollector.classify( + sources = InputDevice.SOURCE_MOUSE, + keyboardType = InputDevice.KEYBOARD_TYPE_NONE, + isVirtual = false, + isExternal = null, + ) + assertThat(classes).containsExactly(AttachedDeviceClass.MOUSE) + } + + @Test + fun `combo keyboard with touchpad yields both classes`() { + val classes = + AttachedDevicesCollector.classify( + sources = InputDevice.SOURCE_KEYBOARD or InputDevice.SOURCE_TOUCHPAD, + keyboardType = InputDevice.KEYBOARD_TYPE_ALPHABETIC, + isVirtual = false, + isExternal = true, + ) + assertThat(classes).containsExactly(AttachedDeviceClass.EXTERNAL_KEYBOARD, AttachedDeviceClass.TOUCHPAD) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/analytics/AttachedDevicesMetricTest.kt b/app/src/test/java/com/itsaky/androidide/analytics/AttachedDevicesMetricTest.kt new file mode 100644 index 0000000000..bf03795f9f --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/analytics/AttachedDevicesMetricTest.kt @@ -0,0 +1,35 @@ +package com.itsaky.androidide.analytics + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class AttachedDevicesMetricTest { + @Test + fun `bundle carries every device count under its exact param name`() { + val metric = + AttachedDevicesMetric( + AttachedDevicesSnapshot( + mouseCount = 1, + externalKeyboardCount = 2, + touchpadCount = 3, + stylusCount = 4, + gamepadCount = 5, + externalDisplayCount = 6, + ), + ) + + val bundle = metric.asBundle() + + assertThat(metric.eventName).isEqualTo("attached_devices") + assertThat(bundle.getLong("mouse_count")).isEqualTo(1L) + assertThat(bundle.getLong("external_keyboard_count")).isEqualTo(2L) + assertThat(bundle.getLong("touchpad_count")).isEqualTo(3L) + assertThat(bundle.getLong("stylus_count")).isEqualTo(4L) + assertThat(bundle.getLong("gamepad_count")).isEqualTo(5L) + assertThat(bundle.getLong("external_display_count")).isEqualTo(6L) + assertThat(bundle.keySet()).hasSize(6) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/app/TelemetryConsentMigrationTest.kt b/app/src/test/java/com/itsaky/androidide/app/TelemetryConsentMigrationTest.kt new file mode 100644 index 0000000000..7cc9091573 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/app/TelemetryConsentMigrationTest.kt @@ -0,0 +1,57 @@ + +package com.itsaky.androidide.app + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.preferences.internal.TelemetryConsent +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class TelemetryConsentMigrationTest { + @Before + fun setUp() { + System.setProperty("androidide.test.mode", "true") + } + + @Test + fun `unset consent with legacy acceptance migrates`() { + assertThat( + DeviceProtectedApplicationLoader.shouldMigrateLegacyConsent( + currentConsent = TelemetryConsent.UNSET, + legacyDisclosureShown = true, + ), + ).isTrue() + } + + @Test + fun `unset consent without legacy acceptance does not migrate`() { + assertThat( + DeviceProtectedApplicationLoader.shouldMigrateLegacyConsent( + currentConsent = TelemetryConsent.UNSET, + legacyDisclosureShown = false, + ), + ).isFalse() + } + + @Test + fun `granted consent never re-migrates`() { + assertThat( + DeviceProtectedApplicationLoader.shouldMigrateLegacyConsent( + currentConsent = TelemetryConsent.GRANTED, + legacyDisclosureShown = true, + ), + ).isFalse() + } + + @Test + fun `declined consent is never overridden by legacy acceptance`() { + assertThat( + DeviceProtectedApplicationLoader.shouldMigrateLegacyConsent( + currentConsent = TelemetryConsent.DECLINED, + legacyDisclosureShown = true, + ), + ).isFalse() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/assets/AssetsInstallationHelperTest.kt b/app/src/test/java/com/itsaky/androidide/assets/AssetsInstallationHelperTest.kt index dda5ce2954..9bdd5b52a6 100644 --- a/app/src/test/java/com/itsaky/androidide/assets/AssetsInstallationHelperTest.kt +++ b/app/src/test/java/com/itsaky/androidide/assets/AssetsInstallationHelperTest.kt @@ -41,6 +41,19 @@ class AssetsInstallationHelperTest { @Before fun setup() { + // Load the brotli native for real before anything here mocks Brotli4jLoader. brotli4j caches + // its availability in a static field, so a JVM whose first sight of that class is a mocked + // one keeps a "never loaded" state -- and a later *real* ensureAvailability(), which + // BrotliDictionaryDecodeTest does in @BeforeClass, then throws UnsatisfiedLinkError even + // after unmockkAll(). Only UnsatisfiedLinkError is absorbed -- that is what the loader raises + // when there is no native for this host, which is a legitimate configuration (see + // brotli4jNativeForHost) -- so any other setup failure here still surfaces. + try { + Brotli4jLoader.ensureAvailability() + } catch (e: UnsatisfiedLinkError) { + println("brotli native unavailable on this host, continuing: ${e.message}") + } + mockkObject(helper) every { helper["checkStorageAccessibility"](any(), any()) diff --git a/app/src/test/java/com/itsaky/androidide/handlers/GlitchTipDiagnosticsContextTest.kt b/app/src/test/java/com/itsaky/androidide/handlers/GlitchTipDiagnosticsContextTest.kt index a183f9c14b..03cf0e80a7 100644 --- a/app/src/test/java/com/itsaky/androidide/handlers/GlitchTipDiagnosticsContextTest.kt +++ b/app/src/test/java/com/itsaky/androidide/handlers/GlitchTipDiagnosticsContextTest.kt @@ -18,6 +18,8 @@ package com.itsaky.androidide.handlers import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.analytics.AttachedDevicesCollector +import com.itsaky.androidide.analytics.AttachedDevicesSnapshot import com.itsaky.androidide.app.IDEApplication import com.itsaky.androidide.buildinfo.BuildInfo import io.mockk.every @@ -95,4 +97,42 @@ class GlitchTipDiagnosticsContextTest { // ...the event is still returned, with every other field intact. assertThat(event.getTag("app_version_name")).isEqualTo(BuildInfo.VERSION_NAME_SIMPLE) } + + @Test + fun `attached devices context carries every count under its exact key`() { + mockkObject(AttachedDevicesCollector) + every { AttachedDevicesCollector.collect(any()) } returns + AttachedDevicesSnapshot( + mouseCount = 1, + externalKeyboardCount = 2, + touchpadCount = 3, + stylusCount = 4, + gamepadCount = 5, + externalDisplayCount = 6, + ) + + val event = enrichNewEvent() + + assertThat(event.contexts["attached_devices"]).isEqualTo( + mapOf( + "mouse_count" to 1, + "external_keyboard_count" to 2, + "touchpad_count" to 3, + "stylus_count" to 4, + "gamepad_count" to 5, + "external_display_count" to 6, + ), + ) + } + + @Test + fun `a throwing attached devices collector drops only that section`() { + mockkObject(AttachedDevicesCollector) + every { AttachedDevicesCollector.collect(any()) } throws RuntimeException("input service dead") + + val event = enrichNewEvent() + + assertThat(event.contexts["attached_devices"]).isNull() + assertThat(event.getTag("app_version_name")).isEqualTo(BuildInfo.VERSION_NAME_SIMPLE) + } } diff --git a/app/src/test/java/com/itsaky/androidide/localWebServer/BrotliDictionaryDecodeTest.kt b/app/src/test/java/com/itsaky/androidide/localWebServer/BrotliDictionaryDecodeTest.kt new file mode 100644 index 0000000000..80a1ac152c --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/localWebServer/BrotliDictionaryDecodeTest.kt @@ -0,0 +1,251 @@ +package com.itsaky.androidide.localWebServer + +import com.aayushatharva.brotli4j.Brotli4jLoader +import com.aayushatharva.brotli4j.decoder.BrotliInputStream +import com.aayushatharva.brotli4j.encoder.BrotliOutputStream +import com.aayushatharva.brotli4j.encoder.Encoder +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.BeforeClass +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import java.util.Base64 + +// Deliberately routed through production's toDirectByteBuffer rather than allocating here: +// attachDictionary reads the buffer's capacity, so an over-allocated buffer fails every decode. +// Duplicating the allocation would leave that helper untested and let the two drift apart. +private fun decodeBase64ToDirectBuffer(base64: String): ByteBuffer = toDirectByteBuffer(Base64.getDecoder().decode(base64)) + +// Regression coverage for ADFA-5153: documentation.db's Content rows are Brotli-compressed +// against a shared dictionary trained by OfflineDocumentationTools' zstd/brotli CLI pipeline +// (see populate_db.py's DictionaryCompressor), not by brotli4j itself. These fixtures were +// produced by that exact pipeline, so this test is what protects the cross-tool contract: a +// brotli4j upgrade (or native lib change) that silently broke compatibility with the CLI-produced +// wire format would otherwise only surface as garbled content on-device. +class BrotliDictionaryDecodeTest { + companion object { + // Unlike on-device (where ToolsManager/AssetsInstallationHelper already load it before + // WebServer ever runs), nothing loads brotli4j's native lib in a plain JVM unit test -- + // without this, every test below fails with UnsatisfiedLinkError instead of exercising + // real decode behavior. + @JvmStatic + @BeforeClass + fun loadNativeLibrary() { + Brotli4jLoader.ensureAvailability() + } + } + + // A ~3.3 KB zstd fast-cover dictionary trained on synthetic doc-page-like text, and a small + // payload Brotli-compressed against it via the `brotli` CLI's `-D` flag (OfflineDocumentationTools' + // actual encode path) -- see ADFA-5153. + private val dictionaryBase64 = + "N6Qw7OTyEGgfENCSpAP//////49QsrssRMqWGsnNSkLy/zfL/Ef3/zMAADhYoPCcRptTLgAEQIEAAMAS" + + "pykQlqZI41QGmTEGEAIAAAAAAAAAAAAAAABkXQEAAAAAAAAAAAAAAAAAAAABAAAABAAAAAgAAABhY2Ug" + + "dG9jLWVsZW1lbnQgZG9jcy1zaWRlYmFyIGludGVyZmFjZSB2YWwgZnVuIG9iamxlbWVudCB0b2MtZWxl" + + "bWVudCBrb3RsaW4gb3ZlcnJpZGUgdG9jLWVsZW1lbnQgb3ZlciBrb3RsaW4ga290bGluIHZhciBkb2Nz" + + "LXNpZGViYXIgdmFsIGNvbXBhbmlvbiBjb21wZSBmdW4gcGFnZS5wZWIga290bGluIGZ1biB2YXIgb2Jq" + + "ZWN0IHRlbXBsYXRlIGRvY24gdmFyIHRlbXBsYXRlIGludGVyZmFjZSBjb21wYW5pb24gcGFnZS5wZWIg" + + "dmFyIGlua290bGluIENvbnRlbnQtVHlwZSBkb2NzLXNpZGViYXIgbmF2IGludGVyZmFjZSBjb20gdG9j" + + "LWVsZW1lbnQgY29tcGFuaW9uIG9iamVjdCBpbnRlcmZhY2Uga290bGluIGRvY2RlYmFyIG5hdiB0b2Mt" + + "ZWxlbWVudCBDb250ZW50LVR5cGUgdGVtcGxhdGUgdmFyIGNsbiBzaWRlYmFyIHNpZGViYXIgdG9jLWVs" + + "ZW1lbnQgb2JqZWN0IGNvbXBhbmlvbiBpbnRycmlkZSB0b2MtZWxlbWVudCBmdW4gY2xhc3MgdGVtcGxh" + + "dGUgaW50ZXJmYWNlIGRvYyB0b2MtZWxlbWVudCBmdW4gdG9jLWVsZW1lbnQgdmFsIG9iamVjdCBvYmpl" + + "Y3QgdG9jYmplY3QgbmF2IGZ1biBzaWRlYmFyIG92ZXJyaWRlIG9iamVjdCBmdW4gdmFsIG92ZXJhdGUg" + + "aW50ZXJmYWNlIHZhciB0ZW1wbGF0ZSB0ZW1wbGF0ZSB2YXIgb2JqZWN0IGtvdGUga290bGluIG92ZXJy" + + "aWRlIHBhZ2UucGViIG92ZXJyaWRlIGZ1biBjbGFzcyB2YXIgaW50ZXJmYWNlIGNsYXNzIHRlbXBsYXRl" + + "IHNpZGViYXIgZnVuIHBhZ2UucGViIGRvY3NlIENvbnRlbnQtVHlwZSBpbnRlcmZhY2UgdGVtcGxhdGUg" + + "aW50ZXJmYWNlIHZhciB0ZWVudC1UeXBlIENvbnRlbnQtVHlwZSBvYmplY3QgcGFnZS5wZWIgdGVtcGxh" + + "dGUgb3ZlZW50LVR5cGUgb3ZlcnJpZGUgQ29udGVudC1UeXBlIHBhZ2UucGViIGNsYXNzIHNpZGVyIHRv" + + "Yy1lbGVtZW50IHZhciBzaWRlYmFyIG5hdiBmdW4gY2xhc3Mga290bGluIHBhZyBvdmVycmlkZSBpbnRl" + + "cmZhY2UgbmF2IHZhciBvdmVycmlkZSBjb21wYW5pb24gcGFnY2xhc3MgdmFsIGNsYXNzIENvbnRlbnQt" + + "VHlwZSBkb2NzLXNpZGViYXIgbmF2IGNvbXAgZnVuIHRlbXBsYXRlIHBhZ2UucGViIGNsYXNzIG5hdiBw" + + "YWdlLnBlYiBuYXYgQ29udCBjb21wYW5pb24gb3ZlcnJpZGUgdGVtcGxhdGUga290bGluIHNpZGViYXIg" + + "dmFyIHBhdmFsIG5hdiBjbGFzcyBmdW4gb3ZlcnJpZGUgaW50ZXJmYWNlIGludGVyZmFjZSBrb3RudGVu" + + "dC1UeXBlIENvbnRlbnQtVHlwZSBjbGFzcyBvYmplY3QgcGFnZS5wZWIgQ29udGJhciBzaWRlYmFyIHBh" + + "Z2UucGViIHZhbCBDb250ZW50LVR5cGUgdGVtcGxhdGUgdmFsbCBjb21wYW5pb24gZnVuIGRvY3Mtc2lk" + + "ZWJhciBjbGFzcyB0b2MtZWxlbWVudCBDb25kZWJhciB2YWwgZG9jcy1zaWRlYmFyIHZhciBDb250ZW50" + + "LVR5cGUgY2xhc3MgcGFnZXVuIHNpZGViYXIgQ29udGVudC1UeXBlIHZhbCBvYmplY3QgdGVtcGxhdGUg" + + "bmF2IG92ZmFjZSBDb250ZW50LVR5cGUgcGFnZS5wZWIga290bGluIGZ1biBvdmVycmlkZSB2YXJuaW9u" + + "IENvbnRlbnQtVHlwZSBrb3RsaW4gbmF2IHRvYy1lbGVtZW50IG9iamVjdCBvYmF2IG92ZXJyaWRlIHRv" + + "Yy1lbGVtZW50IHZhbCB2YWwgbmF2IG5hdiBvYmplY3QgcGFnbGluIGZ1biB2YWwgY2xhc3MgaW50ZXJm" + + "YWNlIHRvYy1lbGVtZW50IHNpZGViYXIgY29hdGUgc2lkZWJhciB2YXIgQ29udGVudC1UeXBlIGNvbXBh" + + "bmlvbiB2YXIgZnVuIHNpZCBrb3RsaW4gZnVuIENvbnRlbnQtVHlwZSBpbnRlcmZhY2UgdG9jLWVsZW1l" + + "bnQgZnVuYWdlLnBlYiB0ZW1wbGF0ZSBjb21wYW5pb24gdmFyIG92ZXJyaWRlIGtvdGxpbiBuYXZpbnRl" + + "cmZhY2UgZnVuIGludGVyZmFjZSBvYmplY3QgdGVtcGxhdGUgY2xhc3MgZG9jc2xpbiB0ZW1wbGF0ZSB0" + + "b2MtZWxlbWVudCB0b2MtZWxlbWVudCBuYXYga290bGluIGRvbmlvbiB0ZW1wbGF0ZSBvYmplY3QgY2xh" + + "c3Mgb2JqZWN0IENvbnRlbnQtVHlwZSBmdW5lY3QgY2xhc3MgY2xhc3MgdG9jLWVsZW1lbnQgY2xhc3Mg" + + "bmF2IHRlbXBsYXRlIENvbiBuYXYgdGVtcGxhdGUgZnVuIG5hdiBzaWRlYmFyIG92ZXJyaWRlIHZhbCBm" + + "dW4gdmFsZW50IGNsYXNzIHZhbCB2YXIgb2JqZWN0IGNsYXNzIGZ1biBrb3RsaW4gdmFsIGludGVvbXBh" + + "bmlvbiBjbGFzcyBrb3RsaW4gZnVuIGRvY3Mtc2lkZWJhciBrb3RsaW4gQ29udG4gZG9jcy1zaWRlYmFy" + + "IHRvYy1lbGVtZW50IG9iamVjdCB2YWwgbmF2IG5hdiBzaWRlciBDb250ZW50LVR5cGUgbmF2IHBhZ2Uu" + + "cGViIG5hdiBjbGFzcyBvdmVycmlkZSBzaWRpZGViYXIgb2JqZWN0IHNpZGViYXIgdmFsIG5hdiBpbnRl" + + "cmZhY2Ugb2JqZWN0IGRvYyBpbnRlcmZhY2Ugb3ZlcnJpZGUgcGFnZS5wZWIgb3ZlcnJpZGUgb3ZlcnJp" + + "ZGUgY2xhb2NzLXNpZGViYXIgY2xhc3MgY29tcGFuaW9uIGtvdGxpbiB0b2MtZWxlbWVudCBpbnQucGVi" + + "IHRvYy1lbGVtZW50IGNvbXBhbmlvbiBzaWRlYmFyIGRvY3Mtc2lkZWJhciBuYW1lbnQgcGFnZS5wZWIg" + + "dmFsIGtvdGxpbiBvYmplY3QgdmFyIHZhciBvYmplY3QgdGVtYWwgcGFnZS5wZWIgdmFyIHRvYy1lbGVt" + + "ZW50IHRlbXBsYXRlIHBhZ2UucGViIHNpZGVuYXYgcGFnZS5wZWIgdmFyIGtvdGxpbiBpbnRlcmZhY2Ug" + + "c2lkZWJhciB2YXIgY29tcGUga290bGluIGNsYXNzIHZhbCBzaWRlYmFyIHBhZ2UucGViIGludGVyZmFj" + + "ZSBwYWdlZ2UucGViIGNvbXBhbmlvbiBuYXYgb2JqZWN0IGNsYXNzIENvbnRlbnQtVHlwZSB0b2NiYXIg" + + "b3ZlcnJpZGUgdGVtcGxhdGUgdmFyIHNpZGViYXIga290bGluIGZ1biB2YXIgQ25pb24gdmFsIHBhZ2Uu" + + "cGViIGZ1biB0ZW1wbGF0ZSB0b2MtZWxlbWVudCB2YWwgY29tbnRlcmZhY2UgdmFsIGNsYXNzIGNvbXBh" + + "bmlvbiBzaWRlYmFyIHRlbXBsYXRlIGludGV2YWwgdGVtcGxhdGUgdGVtcGxhdGUgb2JqZWN0IG5hdiBk" + + "b2NzLXNpZGViYXIgc2lkZWUgY29tcGFuaW9uIG9iamVjdCBvdmVycmlkZSBmdW4gZnVuIGNvbXBhbmlv" + + "biB0b2MtVHlwZSBvdmVycmlkZSBuYXYgdmFsIHRvYy1lbGVtZW50IGtvdGxpbiB2YXIgbmF2IHBudC1U" + + "eXBlIHZhciBkb2NzLXNpZGViYXIgQ29udGVudC1UeXBlIHNpZGViYXIgcGFnZWViYXIgdmFsIHBhZ2Uu" + + "cGViIG9iamVjdCBmdW4gcGFnZS5wZWIgcGFnZS5wZWIgZG9jbiBvdmVycmlkZSBkb2NzLXNpZGViYXIg" + + "b2JqZWN0IGludGVyZmFjZSBjbGFzcyBrb3RhciB0ZW1wbGF0ZSB2YXIga290bGluIGNvbXBhbmlvbiBk" + + "b2NzLXNpZGViYXIgZnVuICB0b2MtZWxlbWVudCBkb2NzLXNpZGViYXIgaW50ZXJmYWNlIENvbnRlbnQt" + + "VHlwZSBj" + + private val compressedBase64 = + "H6AEIBypU5+7WdgVm1yEUcQuEA0twSdtb3qRIOfy83EJ6BCu9aGiz72LjySb9TQmV4wATYW9JhfwdjwI" + + "woRvurJjIaNH/hC6U59+QaiVFTX9XajztuGO9hS2C2GJEnZn+6vh0spFMR6RDFwzXTjCHWzxThsHAcW2" + + "9ev+Wau/71qnhgYFy8JNHS3F87DOOc02MhMXA9ZP9Ti9LOWqrKld7hlsgT8bDn888jGY1CPGtwU=" + + private val expectedBase64 = + "dmFsIG92ZXJyaWRlIGZ1biB2YXIgaW50ZXJmYWNlIHNpZGViYXIgaW50ZXJmYWNlIHNpZGViYXIgb2Jq" + + "ZWN0IGNsYXNzIGZ1biBDb250ZW50LVR5cGUgcGFnZS5wZWIgZnVuIHNpZGViYXIgaW50ZXJmYWNlIG92" + + "ZXJyaWRlIHNpZGViYXIgb3ZlcnJpZGUgZG9jcy1zaWRlYmFyIGtvdGxpbiBDb250ZW50LVR5cGUgdG9j" + + "LWVsZW1lbnQgb2JqZWN0IG92ZXJyaWRlIGNvbXBhbmlvbiBrb3RsaW4gZG9jcy1zaWRlYmFyIGtvdGxp" + + "biB2YWwgdG9jLWVsZW1lbnQgbmF2IGNvbXBhbmlvbiB2YXIgQ29udGVudC1UeXBlIG92ZXJyaWRlIGNs" + + "YXNzIGtvdGxpbiBuYXYgcGFnZS5wZWIgc2lkZWJhciBDb250ZW50LVR5cGUgb3ZlcnJpZGUgaW50ZXJm" + + "YWNlIHRvYy1lbGVtZW50IGludGVyZmFjZSBzaWRlYmFyIHNpZGViYXIgaW50ZXJmYWNlIG92ZXJyaWRl" + + "IHNpZGViYXIgc2lkZWJhciBmdW4gZG9jcy1zaWRlYmFyIHZhciB2YWwgY2xhc3MgZnVuIHBhZ2UucGVi" + + "IENvbnRlbnQtVHlwZSB2YWwgc2lkZWJhciB2YXIgaW50ZXJmYWNlIGNsYXNzIHRlbXBsYXRlIGludGVy" + + "ZmFjZSBmdW4gdG9jLWVsZW1lbnQgY2xhc3MgdmFsIHRlbXBsYXRlIHNpZGViYXIgY2xhc3MgbmF2IHNp" + + "ZGViYXIgdmFyIG9iamVjdCB2YXIgZG9jcy1zaWRlYmFyIHZhciBpbnRlcmZhY2UgdmFyIHRvYy1lbGVt" + + "ZW50IHRlbXBsYXRlIG9iamVjdCBjb21wYW5pb24ga290bGluIGNvbXBhbmlvbiBvdmVycmlkZSBpbnRl" + + "cmZhY2UgdmFsIG9iamVjdCB0ZW1wbGF0ZSBkb2NzLXNpZGViYXIgZG9jcy1zaWRlYmFyIGludGVyZmFj" + + "ZSBzaWRlYmFyIGRvY3Mtc2lkZWJhciBrb3RsaW4gdmFsIGZ1biBpbnRlcmZhY2UgdGVtcGxhdGUgaW50" + + "ZXJmYWNlIGludGVyZmFjZSBvdmVycmlkZSBkb2NzLXNpZGViYXIgc2lkZWJhciB2YWwgdmFsIG9iamVj" + + "dCBvYmplY3QgdGVtcGxhdGUgdmFsIGtvdGxpbiBuYXYgdGVtcGxhdGUgdGVtcGxhdGUgZnVuIHRvYy1l" + + "bGVtZW50IG92ZXJyaWRlIHRlbXBsYXRlIGludGVyZmFjZSB2YWwgb3ZlcnJpZGUgdmFyIHBhZ2UucGVi" + + "IHZhciBrb3RsaW4gdGVtcGxhdGUgdmFyIHRlbXBsYXRlIG5hdiBuYXYgdGVtcGxhdGUgQ29udGVudC1U" + + "eXBlIGtvdGxpbiB2YWwgaW50ZXJmYWNlIGRvY3Mtc2lkZWJhciBwYWdlLnBlYiBvYmplY3Qgb2JqZWN0" + + "IGZ1biBrb3RsaW4gc2lkZWJhciB2YXIgdGVtcGxhdGUgZG9jcy1zaWRlYmFy" + + @Test + fun `decodes CLI dictionary-compressed content correctly`() { + val dictionary = decodeBase64ToDirectBuffer(dictionaryBase64) + val compressed = Base64.getDecoder().decode(compressedBase64) + val expected = Base64.getDecoder().decode(expectedBase64) + + val result = + BrotliInputStream(ByteArrayInputStream(compressed)).use { stream -> + stream.attachDictionary(dictionary) + stream.readBytes() + } + + assertArrayEquals(expected, result) + } + + @Test + fun `the same dictionary buffer instance is safe to reuse across multiple decodes`() { + // WebServer holds one long-lived dictionary buffer across many requests -- + // this guards against a brotli4j change that mutates buffer position/limit + // state in a way that would break the second decode. + val dictionary = decodeBase64ToDirectBuffer(dictionaryBase64) + val compressed = Base64.getDecoder().decode(compressedBase64) + val expected = Base64.getDecoder().decode(expectedBase64) + + repeat(3) { + val result = + BrotliInputStream(ByteArrayInputStream(compressed)).use { stream -> + stream.attachDictionary(dictionary) + stream.readBytes() + } + assertArrayEquals(expected, result) + } + } + + @Test + fun `decoding dictionary-compressed content without attaching a dictionary fails`() { + // Unlike a *wrong* dictionary (whose backward distances resolve into real, + // just incorrect, bytes -- silently wrong output, no error), decoding with + // no dictionary at all leaves distances that reach into the dictionary + // region out of bounds for any spec-compliant decoder, which must reject + // the stream as corrupt. Verified empirically: brotli4j throws IOException + // here, not an arbitrary Exception subtype. + val compressed = Base64.getDecoder().decode(compressedBase64) + + assertThrows(IOException::class.java) { + BrotliInputStream(ByteArrayInputStream(compressed)).use { it.readBytes() } + } + } + + @Test + fun `dictionary-free plugin content fails with a dictionary attached but decodes plain`() { + // Regression coverage for the WebServer.decompressBrotli fallback: plugin-contributed + // Tier 3 docs (PluginDocumentationManager/BrotliCompressor) are compressed with the same + // encoder params (quality 11, window 24) but no dictionary, coexisting in the same Content + // table as ADFA-5153-migrated, dictionary-compressed rows. + val dictionary = decodeBase64ToDirectBuffer(dictionaryBase64) + val plaintext = "plugin-contributed Tier 3 content, compressed with no dictionary" + val expected = plaintext.toByteArray(StandardCharsets.UTF_8) + val compressed = + ByteArrayOutputStream() + .apply { + BrotliOutputStream(this, Encoder.Parameters().setQuality(11).setWindow(24)).use { it.write(expected) } + }.toByteArray() + + assertThrows(IOException::class.java) { + BrotliInputStream(ByteArrayInputStream(compressed)).use { stream -> + stream.attachDictionary(dictionary) + stream.readBytes() + } + } + + val plainResult = BrotliInputStream(ByteArrayInputStream(compressed)).use { it.readBytes() } + assertArrayEquals(expected, plainResult) + } + + @Test + fun `content split across chunks decodes the same as one contiguous array`() { + // Rows over 1 MB are stored as several Content rows and were previously concatenated + // before decoding; they are now fed to the decoder as a stream over the chunk list, so + // a compressed stream must decode identically no matter where the chunk boundaries fall. + val dictionary = decodeBase64ToDirectBuffer(dictionaryBase64) + val compressed = Base64.getDecoder().decode(compressedBase64) + val expected = Base64.getDecoder().decode(expectedBase64) + + // Deliberately uneven, and not aligned to anything in the brotli stream. + val chunks = + listOf( + compressed.copyOfRange(0, 7), + compressed.copyOfRange(7, 8), + compressed.copyOfRange(8, compressed.size - 1), + compressed.copyOfRange(compressed.size - 1, compressed.size), + ) + + val result = + BrotliInputStream(chunksAsStream(chunks)).use { stream -> + stream.attachDictionary(dictionary) + stream.readBytes() + } + + assertArrayEquals(expected, result) + } + + @Test + fun `joinChunks concatenates in order and sizes the result exactly`() { + val chunks = listOf(byteArrayOf(1, 2, 3), byteArrayOf(), byteArrayOf(4), byteArrayOf(5, 6)) + + val joined = joinChunks(chunks) + + assertArrayEquals(byteArrayOf(1, 2, 3, 4, 5, 6), joined) + assertEquals(6, joined.size) + } + + @Test + fun `joinChunks hands back a lone chunk without copying it`() { + val only = byteArrayOf(7, 8, 9) + + assertSame(only, joinChunks(listOf(only))) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/localWebServer/WebServerTest.kt b/app/src/test/java/com/itsaky/androidide/localWebServer/WebServerTest.kt new file mode 100644 index 0000000000..e68b2e05e4 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/localWebServer/WebServerTest.kt @@ -0,0 +1,417 @@ +package com.itsaky.androidide.localWebServer + +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.net.TrafficStats +import com.itsaky.androidide.utils.DatabaseVersionResolver +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.TimeUnit + +// Covers the ADFA-5035 fix: start()'s bind and stop()'s close are serialized on a +// shared lock, with a stopRequested flag, so no ordering of the two calls can leave +// serverSocket bound-but-orphaned. The first test needs no real concurrency at all +// (stop() fully happens-before start()); the second uses a bounded, connect-based +// poll as the readiness signal instead of a fixed sleep. +class WebServerTest { + @Before + fun setup() { + mockkStatic(TrafficStats::class) + every { TrafficStats.setThreadStatsTag(any()) } returns Unit + every { TrafficStats.clearThreadStatsTag() } returns Unit + + // start() opens config.databasePath before ever reaching the bind step; + // stub it out since these tests exercise the bind/stop lifecycle, not the + // HTTP-serving behavior that depends on real database content. + mockkStatic(SQLiteDatabase::class) + every { + SQLiteDatabase.openDatabase(any(), isNull(), any()) + } returns mockk(relaxed = true) + } + + @After + fun tearDown() { + unmockkAll() + } + + private fun testConfig(port: Int) = + ServerConfig( + port = port, + databasePath = "/nonexistent/test.db", + fileDirPath = "/tmp", + debugDatabasePath = "/nonexistent/debug.db", + debugEnablePath = "/nonexistent/debug-flag", + experimentsEnablePath = "/nonexistent/exp-flag", + clearCacheEnablePath = "/nonexistent/cs0-flag", + projectDatabasePath = "/nonexistent/recent-projects.db", + ) + + // ADFA-5153/ADFA-5220: the dictionary is gated on the MAJOR version the database declares, so + // every test that expects the dictionary to load has to declare one. A relaxed mock answers the + // existence probe with moveToFirst() = false, i.e. "no version table", which would silently turn + // the dictionary tests below into no-ops rather than failing them. + private fun stubDeclaredMajorVersion( + db: SQLiteDatabase, + major: Int?, + ) { + every { + db.rawQuery(match { it.contains("FROM sqlite_master") && it.contains("DocumentationDatabaseVersion") }, any()) + } returns mockk(relaxed = true) { every { moveToFirst() } returns (major != null) } + if (major != null) { + every { + db.rawQuery(match { it.contains("FROM DocumentationDatabaseVersion") }, any()) + } returns + mockk(relaxed = true) { + every { moveToFirst() } returns true + every { isNull(0) } returns false + every { getInt(0) } returns major + } + } + } + + private fun freePort(): Int = ServerSocket(0).use { it.localPort } + + private fun assertPortIsFree(port: Int) { + ServerSocket().apply { reuseAddress = true }.use { probe -> + probe.bind(InetSocketAddress("localhost", port)) + assertTrue("Expected to rebind port $port", probe.isBound) + } + } + + @Test + fun `stop before start prevents the socket from ever binding`() { + val port = freePort() + val server = WebServer(testConfig(port)) + + server.stop() + // stopRequested is now true, so start() must abort inside its synchronized + // bind block without ever calling ServerSocket.bind(). Run it on a joined, + // bounded-timeout thread rather than calling it inline: if this fix ever + // regresses, start() binds anyway and blocks forever in its accept loop, + // and an inline call would hang this test (and the whole test JVM) instead + // of failing it. + val serverThread = Thread { server.start() }.apply { isDaemon = true } + serverThread.start() + serverThread.join(2_000) + assertFalse("Expected start() to return once stop() had already been requested", serverThread.isAlive) + + // If start() had bound anyway, this second bind on the same port would + // throw BindException ("Address already in use"). + assertPortIsFree(port) + } + + @Test + fun `start then stop closes the socket so the port can be reused`() { + val port = freePort() + val server = WebServer(testConfig(port)) + + val serverThread = Thread { server.start() }.apply { isDaemon = true } + serverThread.start() + try { + awaitPortBound(port) + } finally { + server.stop() + serverThread.join(2_000) + } + + assertPortIsFree(port) + } + + // ADFA-5153: the compression dictionary is loaded lazily -- not merely from starting the + // server -- but only once per database, cached across every subsequent request against that + // same database rather than re-fetched per-request. + @Test + fun `compression dictionary loads lazily on first use, once per database, not once per request`() { + val port = freePort() + + val dictionaryExistsCursor = + mockk(relaxed = true) { + every { moveToFirst() } returns true + } + val dictionaryDataCursor = + mockk(relaxed = true) { + every { moveToFirst() } returns true + every { getBlob(0) } returns "test-dictionary-bytes".toByteArray() + } + val contentCursor = + mockk(relaxed = true) { + every { count } returns 1 + every { moveToFirst() } returns true + every { getBlob(0) } returns "hello".toByteArray() + every { getString(1) } returns "text/plain" + every { getString(2) } returns "none" + every { getInt(3) } returns 0 + } + + val db = mockk(relaxed = true) + every { SQLiteDatabase.openDatabase(any(), isNull(), any()) } returns db + stubDeclaredMajorVersion(db, DatabaseVersionResolver.MAJOR_VERSION_WITH_COMPRESSION_DICTIONARY) + every { + db.rawQuery(match { it.contains("FROM sqlite_master") && it.contains("CompressionDictionary") }, null) + } returns dictionaryExistsCursor + every { + db.rawQuery(match { it.contains("SELECT data FROM CompressionDictionary") }, null) + } returns dictionaryDataCursor + every { + db.rawQuery(match { it.contains("FROM Content") }, any()) + } returns contentCursor + + val server = WebServer(testConfig(port)) + val serverThread = Thread { server.start() }.apply { isDaemon = true } + serverThread.start() + try { + awaitPortBound(port) + + // Nothing fetches the dictionary merely from starting the server -- only a content + // fetch does, so before any request there should be no dictionary query at all yet -- + // neither the sqlite_master existence check nor the data fetch. + verify(exactly = 0) { + db.rawQuery(match { it.contains("FROM sqlite_master") && it.contains("CompressionDictionary") }, null) + } + verify(exactly = 0) { + db.rawQuery(match { it.contains("SELECT data FROM CompressionDictionary") }, null) + } + + repeat(3) { sendRawGetRequestAndAwaitClose(port, "/some/path") } + + // Exactly one dictionary load across all 3 requests against the same, unchanged + // database -- the first request's lazy load, cached for the other two. Both queries + // loadCompressionDictionary issues (the sqlite_master existence check, then the data + // fetch) must be checked, or a regression re-running just the existence check on + // every request would pass unnoticed. + verify(exactly = 1) { + db.rawQuery(match { it.contains("FROM sqlite_master") && it.contains("CompressionDictionary") }, null) + } + verify(exactly = 1) { + db.rawQuery(match { it.contains("SELECT data FROM CompressionDictionary") }, null) + } + } finally { + server.stop() + serverThread.join(2_000) + } + } + + // ADFA-5153: a database swap (the debug-DB override) must invalidate the cached dictionary -- + // the new database can have a different one, or none -- causing exactly one fresh reload on + // the first content fetch against the new database, not a reload on every later request too. + @Test + fun `database swap invalidates the cached dictionary, reloading it once for the new database`() { + val port = freePort() + val debugDbFile = File.createTempFile("webserver-test-debug", ".db") + debugDbFile.delete() // must not exist yet -- the first request should stay on the primary db + + fun contentCursorFor(marker: String) = + mockk(relaxed = true) { + every { count } returns 1 + every { moveToFirst() } returns true + every { getBlob(0) } returns marker.toByteArray() + every { getString(1) } returns "text/plain" + every { getString(2) } returns "none" + every { getInt(3) } returns 0 + } + + fun stubDatabase( + db: SQLiteDatabase, + dictionaryBytes: String, + ) { + stubDeclaredMajorVersion(db, DatabaseVersionResolver.MAJOR_VERSION_WITH_COMPRESSION_DICTIONARY) + every { + db.rawQuery(match { it.contains("FROM sqlite_master") && it.contains("CompressionDictionary") }, null) + } returns mockk(relaxed = true) { every { moveToFirst() } returns true } + every { + db.rawQuery(match { it.contains("SELECT data FROM CompressionDictionary") }, null) + } returns + mockk(relaxed = true) { + every { moveToFirst() } returns true + every { getBlob(0) } returns dictionaryBytes.toByteArray() + } + every { + db.rawQuery(match { it.contains("FROM Content") }, any()) + } returns contentCursorFor(dictionaryBytes) + } + + val primaryDb = mockk(relaxed = true) + val debugDb = mockk(relaxed = true) + stubDatabase(primaryDb, "dict-primary") + stubDatabase(debugDb, "dict-debug") + + val config = testConfig(port).copy(debugDatabasePath = debugDbFile.absolutePath) + every { SQLiteDatabase.openDatabase(config.databasePath, isNull(), any()) } returns primaryDb + every { SQLiteDatabase.openDatabase(config.debugDatabasePath, isNull(), any()) } returns debugDb + + val server = WebServer(config) + val serverThread = Thread { server.start() }.apply { isDaemon = true } + serverThread.start() + try { + awaitPortBound(port) + + sendRawGetRequestAndAwaitClose(port, "/some/path") + verify(exactly = 1) { + primaryDb.rawQuery(match { it.contains("FROM sqlite_master") && it.contains("CompressionDictionary") }, null) + } + verify(exactly = 1) { + primaryDb.rawQuery(match { it.contains("SELECT data FROM CompressionDictionary") }, null) + } + verify(exactly = 0) { + debugDb.rawQuery(match { it.contains("FROM sqlite_master") && it.contains("CompressionDictionary") }, null) + } + verify(exactly = 0) { + debugDb.rawQuery(match { it.contains("SELECT data FROM CompressionDictionary") }, null) + } + + // Now make the debug override newer than the primary database -- the swap check in + // handleClient() picks this up on the very next request. + debugDbFile.createNewFile() + debugDbFile.setLastModified(System.currentTimeMillis() + 60_000) + + repeat(2) { sendRawGetRequestAndAwaitClose(port, "/some/path") } + + // Exactly one reload for the new (debug) database, across both post-swap requests -- + // not zero (it must invalidate), not two (it must still cache after the first reload). + // Both queries loadCompressionDictionary issues must be checked (see the sibling test). + verify(exactly = 1) { + debugDb.rawQuery(match { it.contains("FROM sqlite_master") && it.contains("CompressionDictionary") }, null) + } + verify(exactly = 1) { + debugDb.rawQuery(match { it.contains("SELECT data FROM CompressionDictionary") }, null) + } + // The primary database's dictionary is never touched again after the swap. + verify(exactly = 1) { + primaryDb.rawQuery(match { it.contains("FROM sqlite_master") && it.contains("CompressionDictionary") }, null) + } + verify(exactly = 1) { + primaryDb.rawQuery(match { it.contains("SELECT data FROM CompressionDictionary") }, null) + } + } finally { + server.stop() + serverThread.join(2_000) + debugDbFile.delete() + } + } + + // ADFA-5153/ADFA-5220: below MAJOR 2 the dictionary is neither read nor attached, and the + // CompressionDictionary probe does not even run -- table sniffing is precisely what the version + // gate replaces, since a database can carry the table while its content is still plain brotli. + @Test + fun `a database declaring a version below 2 is never asked for a dictionary`() { + assertDictionaryLoads(declaredMajor = 1, expected = 0) + } + + @Test + fun `a database with no version table is never asked for a dictionary`() { + assertDictionaryLoads(declaredMajor = null, expected = 0) + } + + // A later format is still expected to carry the dictionary, so the gate is a floor, not a match. + @Test + fun `a database declaring a version above 2 still loads the dictionary`() { + assertDictionaryLoads(declaredMajor = 3, expected = 1) + } + + // The CompressionDictionary cursors are stubbed as *available* in every case, including the + // ones expecting zero queries: that is what makes this a test of the gate rather than of a + // missing table -- the queries are not skipped for want of an answer. + private fun assertDictionaryLoads( + declaredMajor: Int?, + expected: Int, + ) { + val port = freePort() + val db = mockk(relaxed = true) + every { SQLiteDatabase.openDatabase(any(), isNull(), any()) } returns db + stubDeclaredMajorVersion(db, declaredMajor) + every { + db.rawQuery(match { it.contains("FROM sqlite_master") && it.contains("CompressionDictionary") }, null) + } returns mockk(relaxed = true) { every { moveToFirst() } returns true } + every { + db.rawQuery(match { it.contains("SELECT data FROM CompressionDictionary") }, null) + } returns + mockk(relaxed = true) { + every { moveToFirst() } returns true + every { getBlob(0) } returns "test-dictionary-bytes".toByteArray() + } + every { + db.rawQuery(match { it.contains("FROM Content") }, any()) + } returns + mockk(relaxed = true) { + every { count } returns 1 + every { moveToFirst() } returns true + every { getBlob(0) } returns "hello".toByteArray() + every { getString(1) } returns "text/plain" + every { getString(2) } returns "none" + every { getInt(3) } returns 0 + } + + val server = WebServer(testConfig(port)) + val serverThread = Thread { server.start() }.apply { isDaemon = true } + serverThread.start() + try { + awaitPortBound(port) + sendRawGetRequestAndAwaitClose(port, "/some/path") + + verify(exactly = expected) { + db.rawQuery(match { it.contains("FROM sqlite_master") && it.contains("CompressionDictionary") }, null) + } + verify(exactly = expected) { + db.rawQuery(match { it.contains("SELECT data FROM CompressionDictionary") }, null) + } + // The version itself is read once per database either way -- the gate is consulted, and + // its answer cached, exactly like the dictionary it guards. + verify(exactly = 1) { + db.rawQuery(match { it.contains("FROM sqlite_master") && it.contains("DocumentationDatabaseVersion") }, any()) + } + } finally { + server.stop() + serverThread.join(2_000) + } + } + + // Blocks until the server closes the connection (every response sends "Connection: close"), + // so by the time this returns the server has fully finished processing this one request -- + // making repeated calls a reliable way to serialize several full request/response cycles. + private fun sendRawGetRequestAndAwaitClose( + port: Int, + path: String, + ) { + Socket().use { socket -> + socket.connect(InetSocketAddress("localhost", port), 2_000) + socket.soTimeout = 2_000 + socket.getOutputStream().apply { + write("GET $path HTTP/1.1\r\n\r\n".toByteArray(Charsets.ISO_8859_1)) + flush() + } + socket.getInputStream().readBytes() + } + } + + // Polls by attempting an actual TCP connect rather than sleeping a fixed + // duration: as soon as WebServer's accept() loop is listening, the connect + // succeeds, which is the readiness signal. (A bind-then-unbind probe was + // tried first and was itself racy against the server's own bind.) The small + // sleep between attempts matters -- a bare spin loop can starve the JVM's + // other threads, including the one running WebServer.start(), of a chance to + // run at all on a constrained number of cores. + private fun awaitPortBound(port: Int) { + val deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadlineNanos) { + try { + Socket().use { it.connect(InetSocketAddress("localhost", port), 200) } + return + } catch (_: Exception) { + Thread.sleep(10) + } + } + error("WebServer did not bind port $port in time") + } +} diff --git a/app/src/test/java/com/itsaky/androidide/logs/LogBufferTest.kt b/app/src/test/java/com/itsaky/androidide/logs/LogBufferTest.kt index 2eec65c367..584f2f621e 100644 --- a/app/src/test/java/com/itsaky/androidide/logs/LogBufferTest.kt +++ b/app/src/test/java/com/itsaky/androidide/logs/LogBufferTest.kt @@ -64,6 +64,19 @@ class LogBufferTest { assertEquals(0L, lastSeq) } + @Test + fun `cleared buffer snapshots to the last issued seq, not 0`() { + val buffer = LogBuffer(trimOnEntryCount = 10, maxEntryCount = 5) + val last = buffer.append(null, "discarded\n") + buffer.clear() + + // Discarded entries must not stitch in after the snapshot: reporting 0 here + // would let a live stream's replay cache re-deliver cleared lines. + val (text, lastSeq) = buffer.snapshotFiltered(LogFilter.NONE) + assertEquals("", text) + assertEquals(last.seq, lastSeq) + } + @Test fun `buffer trims to maxEntryCount once trimOnEntryCount is exceeded`() { val buffer = LogBuffer(trimOnEntryCount = 10, maxEntryCount = 5) diff --git a/app/src/test/java/com/itsaky/androidide/lsp/SearchResultGroupingTest.kt b/app/src/test/java/com/itsaky/androidide/lsp/SearchResultGroupingTest.kt new file mode 100644 index 0000000000..d047e7c876 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/lsp/SearchResultGroupingTest.kt @@ -0,0 +1,154 @@ +package com.itsaky.androidide.lsp + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.models.Location +import com.itsaky.androidide.models.Position +import com.itsaky.androidide.models.Range +import io.github.rosemoe.sora.text.Content +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * The panel used to read each result file in full, once per hit, on the main thread. These pin the + * replacement: one pass per file, only the lines a hit needs, and stale hits dropped rather than + * throwing. + */ +class SearchResultGroupingTest { + @get:Rule + val folder = TemporaryFolder() + + private fun location( + file: File, + startLine: Int, + startColumn: Int, + endLine: Int = startLine, + endColumn: Int = startColumn, + ) = Location( + file.toPath(), + Range(Position(startLine, startColumn, 0), Position(endLine, endColumn, 0)), + ) + + @Test + fun `a single-line hit carries its line and the matched text`() { + val file = File("Example.kt") + val lines = mapOf(1 to "fun caller() { target() }") + + val results = SearchResultGrouping.resultsFor(file, listOf(location(file, 1, 15, 1, 21)), lines) + + assertThat(results).hasSize(1) + assertThat(results[0].line).isEqualTo("fun caller() { target() }") + assertThat(results[0].match).isEqualTo("target") + assertThat(results[0].file).isEqualTo(file) + } + + @Test + fun `a multi-line hit joins the lines it spans`() { + val file = File("Multi.kt") + val lines = mapOf(0 to "first line", 1 to "middle", 2 to "last line") + + val results = SearchResultGrouping.resultsFor(file, listOf(location(file, 0, 6, 2, 4)), lines) + + assertThat(results).hasSize(1) + assertThat(results[0].match).isEqualTo("line\nmiddle\nlast") + // The row's line text is the line the hit starts on. + assertThat(results[0].line).isEqualTo("first line") + } + + @Test + fun `a hit on a line that no longer exists is dropped`() { + val file = File("Stale.kt") + + val results = SearchResultGrouping.resultsFor(file, listOf(location(file, 9, 0, 9, 3)), mapOf(0 to "only line")) + + assertThat(results).isEmpty() + } + + @Test + fun `a column past the end of its line is clamped rather than throwing`() { + val file = File("Clamped.kt") + + val results = SearchResultGrouping.resultsFor(file, listOf(location(file, 0, 2, 0, 99)), mapOf(0 to "short")) + + assertThat(results).hasSize(1) + assertThat(results[0].match).isEqualTo("ort") + } + + @Test + fun `an open file's rows come from its buffer, not its saved bytes`() { + val file = folder.newFile("Buffered.kt") + file.writeText("saved text\n") + + val results = + SearchResultGrouping.resultsFor(file, listOf(location(file, 0, 4, 0, 10)), Content("fun target() {}")) + + assertThat(results).hasSize(1) + assertThat(results[0].line).isEqualTo("fun target() {}") + assertThat(results[0].match).isEqualTo("target") + } + + @Test + fun `a hit past the end of the buffer is dropped`() { + // The Content overload filters out-of-range lines itself, before the shared row builder sees them. + val file = File("StaleBuffer.kt") + + val results = SearchResultGrouping.resultsFor(file, listOf(location(file, 1, 0, 1, 3)), Content("only")) + + assertThat(results).isEmpty() + } + + @Test + fun `only the lines a hit needs are collected`() { + val file = folder.newFile("Wanted.kt") + file.writeText("zero\none\ntwo\nthree\nfour\n") + + assertThat(SearchResultGrouping.readLines(file, setOf(1, 3))) + .isEqualTo(mapOf(1 to "one", 3 to "three")) + } + + @Test + fun `lines past the end of the file are absent rather than failing`() { + val file = folder.newFile("Short.kt") + file.writeText("only\n") + + assertThat(SearchResultGrouping.readLines(file, setOf(0, 7))).isEqualTo(mapOf(0 to "only")) + } + + @Test + fun `an unreadable file yields no lines rather than throwing`() { + val missing = File(folder.root, "Absent.kt") + + assertThat(SearchResultGrouping.readLines(missing, setOf(0))).isEmpty() + } + + @Test + fun `every hit in a file is built from one read`() { + val file = folder.newFile("Several.kt") + file.writeText("fun a() { target() }\nfun b() { target() }\n") + + val results = + SearchResultGrouping.readFromDisk( + mapOf(file to listOf(location(file, 0, 10, 0, 16), location(file, 1, 10, 1, 16))), + ) + + assertThat(results.keys).containsExactly(file) + assertThat(results.getValue(file).map { it.match }).containsExactly("target", "target") + } + + @Test + fun `a file whose every hit is stale is omitted entirely`() { + val file = folder.newFile("AllStale.kt") + file.writeText("one line\n") + + assertThat(SearchResultGrouping.readFromDisk(mapOf(file to listOf(location(file, 40, 0, 40, 2))))).isEmpty() + } + + @Test + fun `linesNeededBy covers every line a hit spans`() { + val file = File("Spans.kt") + + assertThat(SearchResultGrouping.linesNeededBy(listOf(location(file, 2, 0, 4, 1), location(file, 9, 0)))) + .containsExactly(2, 3, 4, 9) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/preferences/StatPreferencesTest.kt b/app/src/test/java/com/itsaky/androidide/preferences/StatPreferencesTest.kt new file mode 100644 index 0000000000..18fa671d60 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/preferences/StatPreferencesTest.kt @@ -0,0 +1,50 @@ + + +package com.itsaky.androidide.preferences + +import android.content.Context +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.app.BaseApplication +import com.itsaky.androidide.preferences.internal.StatPreferences +import com.itsaky.androidide.preferences.internal.TelemetryConsent +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class StatPreferencesTest { + @Before + fun setUp() { + System.setProperty("androidide.test.mode", "true") + } + + @Test + fun `consent defaults to UNSET when nothing is stored`() { + assertThat(StatPreferences.telemetryConsent).isEqualTo(TelemetryConsent.UNSET) + } + + @Test + fun `GRANTED round-trips through device-protected storage`() { + StatPreferences.telemetryConsent = TelemetryConsent.GRANTED + assertThat(StatPreferences.telemetryConsent).isEqualTo(TelemetryConsent.GRANTED) + } + + @Test + fun `DECLINED round-trips through device-protected storage`() { + StatPreferences.telemetryConsent = TelemetryConsent.DECLINED + assertThat(StatPreferences.telemetryConsent).isEqualTo(TelemetryConsent.DECLINED) + } + + @Test + fun `corrupt stored value degrades to UNSET`() { + BaseApplication.baseInstance + .createDeviceProtectedStorageContext() + .getSharedPreferences("ide.stats", Context.MODE_PRIVATE) + .edit() + .putString(StatPreferences.TELEMETRY_CONSENT, "garbage") + .commit() + + assertThat(StatPreferences.telemetryConsent).isEqualTo(TelemetryConsent.UNSET) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt b/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt new file mode 100644 index 0000000000..555511efb1 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt @@ -0,0 +1,302 @@ +package com.itsaky.androidide.repositories + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.plugins.templates.CgtTemplateBuilder +import com.itsaky.androidide.utils.Environment +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@OptIn(ExperimentalCoroutinesApi::class) +class TemplateCollectionRepositoryImplTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var repository: TemplateCollectionRepository + private lateinit var templatesDir: File + private val previousTemplatesDir: File? = Environment.TEMPLATES_DIR + + @Before + fun setup() { + repository = TemplateCollectionRepositoryImpl() + templatesDir = tempFolder.newFolder("templates") + Environment.TEMPLATES_DIR = templatesDir + } + + @After + fun tearDown() { + Environment.TEMPLATES_DIR = previousTemplatesDir + } + + private fun buildCgt( + name: String, + outputDir: File = tempFolder.newFolder(), + ): File = + CgtTemplateBuilder(name) + .description("A test template") + // ZipTemplateReader.read() fully builds a ProjectTemplate (not just metadata) and, + // absent this, falls back to Environment.PROJECTS_DIR - null outside a real app setup. + .defaultSaveLocation(tempFolder.newFolder().absolutePath) + .build(outputDir) + + @Test + fun `isTemplatesFeatureAvailable is true when TEMPLATES_DIR is set`() { + assertThat(repository.isTemplatesFeatureAvailable()).isTrue() + } + + @Test + fun `isTemplatesFeatureAvailable is false when TEMPLATES_DIR is null`() { + Environment.TEMPLATES_DIR = null + assertThat(repository.isTemplatesFeatureAvailable()).isFalse() + } + + @Test + fun `inspectCollection returns template names for a valid archive`() = + runTest { + val cgt = buildCgt("Empty Activity") + + val result = repository.inspectCollection(cgt) + + assertThat(result.isSuccess).isTrue() + assertThat(result.getOrNull()?.templateNames).containsExactly("Empty Activity") + } + + @Test + fun `inspectCollection fails for a corrupted archive`() = + runTest { + val corrupted = File(tempFolder.newFolder(), "broken.cgt") + corrupted.writeText("not a zip file") + + val result = repository.inspectCollection(corrupted) + + assertThat(result.isFailure).isTrue() + } + + @Test + fun `findExistingCollision matches an installed collection case-insensitively`() = + runTest { + File(templatesDir, "MyTemplates.cgt").writeText("placeholder") + + val match = repository.findExistingCollision("mytemplates") + + assertThat(match).isEqualTo("MyTemplates") + } + + @Test + fun `findExistingCollision matches an uppercase CGT extension`() = + runTest { + File(templatesDir, "MyTemplates.CGT").writeText("placeholder") + + val match = repository.findExistingCollision("mytemplates") + + assertThat(match).isEqualTo("MyTemplates") + } + + @Test + fun `findExistingCollision returns null when there is no match`() = + runTest { + val match = repository.findExistingCollision("does-not-exist") + + assertThat(match).isNull() + } + + @Test + fun `installCollection copies the archive into TEMPLATES_DIR and deletes the source`() = + runTest { + val cgt = buildCgt("Empty Activity") + val expectedBytes = cgt.readBytes() + + val result = repository.installCollection(cgt, "my-templates", overwrite = false) + + assertThat(result.isSuccess).isTrue() + val installed = File(templatesDir, "my-templates.cgt") + assertThat(installed.exists()).isTrue() + assertThat(installed.readBytes()).isEqualTo(expectedBytes) + assertThat(cgt.exists()).isFalse() + } + + @Test + fun `installCollection without overwrite fails when the destination already exists`() = + runTest { + File(templatesDir, "my-templates.cgt").writeText("existing") + val cgt = buildCgt("Empty Activity") + + val result = repository.installCollection(cgt, "my-templates", overwrite = false) + + assertThat(result.isFailure).isTrue() + } + + @Test + fun `installCollection without overwrite fails against an existing case-variant destination`() = + runTest { + File(templatesDir, "MyTemplates.CGT").writeText("existing") + val cgt = buildCgt("Empty Activity") + + val result = repository.installCollection(cgt, "mytemplates", overwrite = false) + + assertThat(result.isFailure).isTrue() + } + + @Test + fun `installCollection with overwrite replaces an existing case-variant destination in place`() = + runTest { + val destination = File(templatesDir, "MyTemplates.CGT") + destination.writeText("stale content") + val cgt = buildCgt("Empty Activity") + val expectedBytes = cgt.readBytes() + + val result = repository.installCollection(cgt, "mytemplates", overwrite = true) + + assertThat(result.isSuccess).isTrue() + assertThat(destination.readBytes()).isEqualTo(expectedBytes) + } + + @Test + fun `installCollection with overwrite replaces the existing destination`() = + runTest { + val destination = File(templatesDir, "my-templates.cgt") + destination.writeText("stale content") + val cgt = buildCgt("Empty Activity") + val expectedBytes = cgt.readBytes() + + val result = repository.installCollection(cgt, "my-templates", overwrite = true) + + assertThat(result.isSuccess).isTrue() + assertThat(destination.readBytes()).isEqualTo(expectedBytes) + } + + @Test + fun `installCollection refuses to replace the reserved bundled core archive, even with overwrite`() = + runTest { + val bundledCore = File(templatesDir, "core.cgt") + bundledCore.writeText("bundled default templates") + val cgt = buildCgt("Empty Activity") + + val result = repository.installCollection(cgt, "core", overwrite = true) + + assertThat(result.isFailure).isTrue() + assertThat(bundledCore.readText()).isEqualTo("bundled default templates") + } + + @Test + fun `installCollection refuses a reserved name case-insensitively`() = + runTest { + val cgt = buildCgt("Empty Activity") + + val result = repository.installCollection(cgt, "CORE", overwrite = true) + + assertThat(result.isFailure).isTrue() + } + + @Test + fun `installCollection rejects a targetBaseName containing a path separator`() = + runTest { + val cgt = buildCgt("Empty Activity") + + val result = repository.installCollection(cgt, "../evil", overwrite = false) + + assertThat(result.isFailure).isTrue() + assertThat(File(templatesDir.parentFile, "evil.cgt").exists()).isFalse() + } + + @Test + fun `installCollection rejects a targetBaseName that is a bare traversal segment`() = + runTest { + val cgt = buildCgt("Empty Activity") + + val result = repository.installCollection(cgt, "..", overwrite = false) + + assertThat(result.isFailure).isTrue() + } + + @Test + fun `installCollection rejects a targetBaseName containing a backslash`() = + runTest { + val cgt = buildCgt("Empty Activity") + + val result = repository.installCollection(cgt, "evil\\name", overwrite = false) + + assertThat(result.isFailure).isTrue() + } + + @Test + fun `installCollection rejects a bare dot targetBaseName`() = + runTest { + val cgt = buildCgt("Empty Activity") + + val result = repository.installCollection(cgt, ".", overwrite = false) + + assertThat(result.isFailure).isTrue() + } + + @Test + fun `installCollection rejects a blank targetBaseName`() = + runTest { + val cgt = buildCgt("Empty Activity") + + val result = repository.installCollection(cgt, " ", overwrite = false) + + assertThat(result.isFailure).isTrue() + } + + @Test + fun `installCollection preserves the existing collection if the incoming archive cannot be staged`() = + runTest { + val destination = File(templatesDir, "my-templates.cgt") + destination.writeText("stale but valid content") + // A candidate that no longer exists can't be copied into staging, so the staging + // step fails before destFile is ever touched. + val missingCandidate = File(tempFolder.newFolder(), "gone.cgt") + + val result = repository.installCollection(missingCandidate, "my-templates", overwrite = true) + + assertThat(result.isFailure).isTrue() + assertThat(destination.exists()).isTrue() + assertThat(destination.readText()).isEqualTo("stale but valid content") + } + + @Test + fun `installCollection leaves candidateFile untouched so the caller can retry after a failure`() = + runTest { + // A reserved-name failure happens before any file I-O, so candidateFile must still be + // exactly where the caller left it - this is the contract ExternalFileInstallViewModel + // relies on to keep the retry dialog usable after a failed install. + val cgt = buildCgt("Empty Activity") + + val result = repository.installCollection(cgt, "core", overwrite = true) + + assertThat(result.isFailure).isTrue() + assertThat(cgt.exists()).isTrue() + } + + @Test + fun `installCollection leaves no stray staging or backup files behind on a fresh install`() = + runTest { + val cgt = buildCgt("Empty Activity") + + val result = repository.installCollection(cgt, "my-templates", overwrite = false) + + assertThat(result.isSuccess).isTrue() + assertThat(templatesDir.listFiles()?.map { it.name }).containsExactly("my-templates.cgt") + } + + @Test + fun `installCollection leaves no stray staging or backup files behind on an overwrite`() = + runTest { + File(templatesDir, "my-templates.cgt").writeText("stale content") + val cgt = buildCgt("Empty Activity") + + val result = repository.installCollection(cgt, "my-templates", overwrite = true) + + assertThat(result.isSuccess).isTrue() + assertThat(templatesDir.listFiles()?.map { it.name }).containsExactly("my-templates.cgt") + } +} diff --git a/app/src/test/java/com/itsaky/androidide/repositories/TemplateRepositoryImplTest.kt b/app/src/test/java/com/itsaky/androidide/repositories/TemplateRepositoryImplTest.kt new file mode 100644 index 0000000000..c1c0048630 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/repositories/TemplateRepositoryImplTest.kt @@ -0,0 +1,217 @@ +package com.itsaky.androidide.repositories + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.templates.manager.models.CgtFileItem +import com.itsaky.androidide.templates.manager.models.TemplateMetadata +import com.itsaky.androidide.templates.manager.models.TemplateProvenance +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File +import java.io.IOException +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** + * Pins the two riskiest branches in [TemplateRepositoryImpl.installTemplate]/ + * [TemplateRepositoryImpl.uninstallTemplate]: a name collision must fail without touching either + * copy, and a failed delete after a successful copy must roll back to leave exactly one copy + * behind. Both are reachable without [com.itsaky.androidide.templates.ITemplateProvider], which is + * only touched on the success path (`ITemplateProvider.getInstance(reload = true)`, a + * ServiceLoader-backed singleton not wired up on the unit test classpath) - so a happy-path + * install/uninstall test is intentionally not included here. + * + * Runs under Robolectric rather than plain JUnit4 because the `listTemplateFiles` cases build + * real `.cgt` archives, and parsing one reaches `org.json.JSONObject` - a "not mocked" stub + * under plain `android.jar`. + */ +@RunWith(RobolectricTestRunner::class) +class TemplateRepositoryImplTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var templatesDir: File + private lateinit var downloadDir: File + private lateinit var repository: TemplateRepositoryImpl + + @Before + fun setup() { + templatesDir = tempFolder.newFolder("templates") + downloadDir = tempFolder.newFolder("downloads") + repository = TemplateRepositoryImpl(templatesDir, downloadDir) + } + + @After + fun tearDown() { + // Undo any permission changes a test made, or TemporaryFolder can't clean up after itself. + templatesDir.setWritable(true) + downloadDir.setWritable(true) + } + + private fun item( + file: File, + installed: Boolean, + ) = CgtFileItem( + file = file, + name = file.name, + templates = listOf(TemplateMetadata("T", "d", "1.0")), + installed = installed, + provenance = TemplateProvenance.USER, + ) + + @Test + fun installTemplate_nameCollision_failsWithoutTouchingEitherCopy() = + runTest { + val source = File(downloadDir, "dup.cgt").apply { writeText("source") } + val existingDest = File(templatesDir, "dup.cgt").apply { writeText("already installed") } + + val result = repository.installTemplate(item(source, installed = false)) + + assertThat(result.isFailure).isTrue() + assertThat(source.exists()).isTrue() + assertThat(source.readText()).isEqualTo("source") + assertThat(existingDest.readText()).isEqualTo("already installed") + } + + @Test + fun installTemplate_deleteFails_rollsBackAndLeavesExactlyOneCopy() = + runTest { + val source = File(downloadDir, "install.cgt").apply { writeText("source") } + val dest = File(templatesDir, "install.cgt") + + // File.delete() needs write permission on the *parent directory*, not the file + // itself - this is what makes item.file.delete() fail after copyTo() already + // succeeded (dest is in the unaffected templatesDir). + check(downloadDir.setWritable(false)) { "test setup: could not make downloadDir read-only" } + + val result = repository.installTemplate(item(source, installed = false)) + + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isInstanceOf(IOException::class.java) + assertThat(source.exists()).isTrue() + assertThat(dest.exists()).isFalse() + } + + @Test + fun uninstallTemplate_nameCollision_failsWithoutTouchingEitherCopy() = + runTest { + val source = File(templatesDir, "dup.cgt").apply { writeText("installed") } + val existingDownload = File(downloadDir, "dup.cgt").apply { writeText("already in downloads") } + + val result = repository.uninstallTemplate(item(source, installed = true)) + + assertThat(result.isFailure).isTrue() + assertThat(source.exists()).isTrue() + assertThat(source.readText()).isEqualTo("installed") + assertThat(existingDownload.readText()).isEqualTo("already in downloads") + } + + @Test + fun uninstallTemplate_deleteFails_rollsBackAndLeavesExactlyOneCopy() = + runTest { + val source = File(templatesDir, "uninstall.cgt").apply { writeText("installed") } + val restored = File(downloadDir, "uninstall.cgt") + + check(templatesDir.setWritable(false)) { "test setup: could not make templatesDir read-only" } + + val result = repository.uninstallTemplate(item(source, installed = true)) + + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isInstanceOf(IOException::class.java) + assertThat(source.exists()).isTrue() + assertThat(restored.exists()).isFalse() + } + + @Test + fun deleteDownloadFile_succeeds_whenNotInstalled() = + runTest { + val file = File(downloadDir, "unused.cgt").apply { writeText("x") } + + val result = repository.deleteDownloadFile(item(file, installed = false)) + + assertThat(result.isSuccess).isTrue() + assertThat(file.exists()).isFalse() + } + + @Test + fun deleteDownloadFile_fails_whenInstalled() = + runTest { + val file = File(templatesDir, "installed.cgt").apply { writeText("x") } + + val result = repository.deleteDownloadFile(item(file, installed = true)) + + assertThat(result.isFailure).isTrue() + assertThat(file.exists()).isTrue() + } + + @Test + fun listTemplateFiles_partitionsByDirectory_andSkipsUnparsableArchives() = + runTest { + File(templatesDir, "not-a-zip.cgt").writeText("garbage") + + val result = repository.listTemplateFiles() + + assertThat(result.isSuccess).isTrue() + // The malformed .cgt has no template.json and is silently skipped, not surfaced as + // a failure - see TemplateRepositoryImpl.parseCgtFile. + assertThat(result.getOrThrow()).isEmpty() + } + + /** Writes a minimal but genuinely parseable .cgt carrying one template. */ + private fun writeCgt( + dir: File, + fileName: String, + ): File { + val file = File(dir, fileName) + ZipOutputStream(file.outputStream()).use { zip -> + zip.putNextEntry(ZipEntry("Sample/template/template.json")) + zip.write("""{"name":"Sample","description":"d","version":"1.0"}""".toByteArray(Charsets.UTF_8)) + zip.closeEntry() + } + return file + } + + @Test + fun listTemplateFiles_hidesTheDownloadedTwinOfAnInstalledArchive() = + runTest { + writeCgt(templatesDir, "dup.cgt") + writeCgt(downloadDir, "dup.cgt") + + val items = repository.listTemplateFiles().getOrThrow() + + // One row, not two identical-looking ones - the Downloads twin's Install could only + // ever fail, since installTemplate refuses to overwrite. + assertThat(items).hasSize(1) + assertThat(items.single().installed).isTrue() + } + + @Test + fun listTemplateFiles_matchesTwinNamesCaseInsensitively() = + runTest { + writeCgt(templatesDir, "Dup.cgt") + writeCgt(downloadDir, "dup.cgt") + + val items = repository.listTemplateFiles().getOrThrow() + + assertThat(items).hasSize(1) + assertThat(items.single().installed).isTrue() + } + + @Test + fun listTemplateFiles_keepsDownloadsThatAreNotTwins() = + runTest { + writeCgt(templatesDir, "installed.cgt") + writeCgt(downloadDir, "other.cgt") + + val items = repository.listTemplateFiles().getOrThrow() + + // Shadowing must be keyed on the name, not applied to every download. + assertThat(items.map { it.name }).containsExactly("installed.cgt", "other.cgt") + assertThat(items.filter { it.installed }.map { it.name }).containsExactly("installed.cgt") + } +} diff --git a/app/src/test/java/com/itsaky/androidide/templates/manager/models/CgtFileItemTest.kt b/app/src/test/java/com/itsaky/androidide/templates/manager/models/CgtFileItemTest.kt new file mode 100644 index 0000000000..d95c291705 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/templates/manager/models/CgtFileItemTest.kt @@ -0,0 +1,73 @@ +package com.itsaky.androidide.templates.manager.models + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.io.File + +class CgtFileItemTest { + private fun item( + name: String, + templates: List = listOf(TemplateMetadata("T", "d", "1.0")), + provenance: TemplateProvenance = TemplateProvenance.USER, + ) = CgtFileItem( + file = File("/tmp/$name"), + name = name, + templates = templates, + installed = false, + provenance = provenance, + ) + + @Test + fun displayName_stripsCgtExtension() { + assertThat(item("core.cgt").displayName).isEqualTo("core") + assertThat(item("core.CGT").displayName).isEqualTo("core") // case-insensitive + } + + @Test + fun displayName_leavesOtherNamesUnchanged() { + assertThat(item("core").displayName).isEqualTo("core") + assertThat(item("my.template.cgt").displayName).isEqualTo("my.template.cgt".dropLast(4)) + assertThat(item("readme.txt").displayName).isEqualTo("readme.txt") + } + + @Test + fun primaryTemplate_isFirst_orEmptyFallback() { + val a = TemplateMetadata("A", "da", "1.0") + val b = TemplateMetadata("B", "db", "2.0") + assertThat(item("x.cgt", listOf(a, b)).primaryTemplate).isEqualTo(a) + + val empty = item("x.cgt", emptyList()).primaryTemplate + assertThat(empty.name).isEmpty() + assertThat(empty.version).isEmpty() + } + + @Test + fun hasMultipleTemplates_reflectsCount() { + assertThat(item("x.cgt", listOf(TemplateMetadata("A", "", "1"))).hasMultipleTemplates).isFalse() + assertThat( + item("x.cgt", listOf(TemplateMetadata("A", "", "1"), TemplateMetadata("B", "", "1"))) + .hasMultipleTemplates, + ).isTrue() + assertThat(item("x.cgt", emptyList()).hasMultipleTemplates).isFalse() + } + + @Test + fun versionLabel_prefixesWithV() { + assertThat(versionLabel("1.0")).isEqualTo("v1.0") + assertThat(versionLabel("0.1")).isEqualTo("v0.1") + assertThat(versionLabel("1.2.3")).isEqualTo("v1.2.3") + } + + @Test + fun versionLabel_truncatesMoreThanThreeSegments() { + // Only the first three dot-separated segments are kept (matches the host Plugin Manager). + assertThat(versionLabel("1.0.0-build.20260101")).isEqualTo("v1.0.0-build...") + assertThat(versionLabel("1.2.3.4")).isEqualTo("v1.2.3...") + } + + @Test + fun versionLabel_blankBecomesEmpty() { + assertThat(versionLabel("")).isEmpty() + assertThat(versionLabel(" ")).isEmpty() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/templates/manager/parsing/CgtTemplateReaderTest.kt b/app/src/test/java/com/itsaky/androidide/templates/manager/parsing/CgtTemplateReaderTest.kt new file mode 100644 index 0000000000..a05d675c88 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/templates/manager/parsing/CgtTemplateReaderTest.kt @@ -0,0 +1,134 @@ +package com.itsaky.androidide.templates.manager.parsing + +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +// org.json.JSONObject needs Robolectric's shadow to run real logic instead of +// android.jar's "not mocked" stub. +@RunWith(RobolectricTestRunner::class) +class CgtTemplateReaderTest { + /** Builds an in-memory .cgt (zip) from a map of entry path -> contents. */ + private fun cgt(entries: Map): ByteArrayInputStream { + val bytes = ByteArrayOutputStream() + ZipOutputStream(bytes).use { zip -> + for ((path, content) in entries) { + zip.putNextEntry(ZipEntry(path)) + zip.write(content.toByteArray(Charsets.UTF_8)) + zip.closeEntry() + } + } + return ByteArrayInputStream(bytes.toByteArray()) + } + + @Test + fun readsSingleTemplateMetadata() { + val input = + cgt( + mapOf( + "pkg/template/template.json" to + """{"name":"Basic Activity","description":"Creates a new basic activity","version":"0.1"}""", + ), + ) + val result = CgtTemplateReader.readTemplates(input) + assertThat(result).hasSize(1) + assertThat(result[0].name).isEqualTo("Basic Activity") + assertThat(result[0].description).isEqualTo("Creates a new basic activity") + assertThat(result[0].version).isEqualTo("0.1") + assertThat(result[0].optionalTags).isEmpty() + } + + @Test + fun readsAllTemplatesInMultiTemplateArchive() { + val input = + cgt( + mapOf( + "a/template/template.json" to """{"name":"Empty","description":"e","version":"1.0"}""", + "b/template/template.json" to """{"name":"Login","description":"l","version":"1.1"}""", + "a/build.gradle.kts.peb" to "// not a template.json", + ), + ) + val result = CgtTemplateReader.readTemplates(input) + assertThat(result).hasSize(2) + assertThat(result.map { it.name }.toSet()).isEqualTo(setOf("Empty", "Login")) + } + + @Test + fun parsesOptionalParametersAsTagWithIdentifier() { + val input = + cgt( + mapOf( + "pkg/template/template.json" to + """ + { + "name":"T","description":"d","version":"1.0", + "parameters": { "optional": { + "language": {"identifier":"LANGUAGE"}, + "minsdk": {"identifier":"MIN_SDK"} + } } + } + """.trimIndent(), + ), + ) + val tags = CgtTemplateReader.readTemplates(input).single().optionalTags + // org.json key iteration order isn't guaranteed, so compare as a set. + assertThat(tags.toSet()).isEqualTo(setOf("language (LANGUAGE)", "minsdk (MIN_SDK)")) + } + + @Test + fun handlesUnquotedInnerKeys_asShippedByCore() { + // The bundled core.cgt uses lenient JSON with unquoted inner keys; org.json accepts it. + val input = + cgt( + mapOf( + "BasicActivity/template/template.json" to + """ + { + "name":"Basic Activity","description":"d","version":"0.1", + "parameters": { "optional": { "language": {identifier: "LANGUAGE"} } } + } + """.trimIndent(), + ), + ) + val template = CgtTemplateReader.readTemplates(input).single() + assertThat(template.name).isEqualTo("Basic Activity") + assertThat(template.optionalTags).isEqualTo(listOf("language (LANGUAGE)")) + } + + @Test + fun optionalTagWithoutIdentifierFallsBackToKey() { + val input = + cgt( + mapOf( + "pkg/template/template.json" to + """{"name":"T","description":"d","version":"1.0","parameters":{"optional":{"flag":{}}}}""", + ), + ) + assertThat(CgtTemplateReader.readTemplates(input).single().optionalTags).isEqualTo(listOf("flag")) + } + + @Test + fun returnsEmptyWhenNoTemplateJson() { + val input = cgt(mapOf("pkg/readme.txt" to "hello", "pkg/template/other.json" to "{}")) + assertThat(CgtTemplateReader.readTemplates(input)).isEmpty() + } + + @Test + fun throwsInsteadOfReadingAnOversizedTemplateJson() { + // A legitimate template.json is a few KB; this stands in for a corrupt/hostile + // archive claiming a huge entry under that name, which readTemplates must reject + // rather than buffer in full. + val oversized = "x".repeat(2 shl 20) + val input = cgt(mapOf("pkg/template/template.json" to oversized)) + assertThrows(IOException::class.java) { + CgtTemplateReader.readTemplates(input) + } + } +} diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/LogViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/LogViewModelTest.kt index 0b51948f20..679612bc4e 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodel/LogViewModelTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/LogViewModelTest.kt @@ -165,6 +165,87 @@ class LogViewModelTest { } } + @Test + fun `clear does not replay stale lines into the next generation`() { + val viewModel = TestLogViewModel() + viewModel.submit(null, "stale") + + withCollectedEvents(viewModel) { events -> + assertEquals("stale\n", (events.receive() as LogViewModel.UiEvent.SetText).text) + + viewModel.clear() + + // Wait for the post-clear snapshot; it must be empty. + var event = events.receive() + while (event !is LogViewModel.UiEvent.SetText) { + event = events.receive() + } + assertEquals("", event.text) + + // The live stream's replay cache still holds "stale". If stitching is + // broken, it is re-delivered before anything submitted after the clear. + viewModel.submit(null, "fresh") + + val appended = StringBuilder() + while (!appended.endsWith("fresh\n")) { + val append = events.receive() + assertTrue(append is LogViewModel.UiEvent.Append) + appended.append((append as LogViewModel.UiEvent.Append).text) + } + assertEquals("fresh\n", appended.toString()) + } + } + + @Test + fun `resync replays history as a snapshot without clearing`() { + val viewModel = TestLogViewModel() + viewModel.submit(null, "first") + viewModel.submit(null, "second") + + withCollectedEvents(viewModel) { events -> + assertEquals("first\nsecond\n", (events.receive() as LogViewModel.UiEvent.SetText).text) + + viewModel.resync() + + var event = events.receive() + while (event !is LogViewModel.UiEvent.SetText) { + event = events.receive() + } + assertEquals("first\nsecond\n", event.text) + assertFalse(viewModel.isBufferEmpty) + } + } + + @Test + fun `lines submitted around a resync are neither lost nor duplicated`() { + val viewModel = TestLogViewModel() + viewModel.submit(null, "old") + + withCollectedEvents(viewModel) { events -> + assertEquals("old\n", (events.receive() as LogViewModel.UiEvent.SetText).text) + + viewModel.submit(null, "before") + viewModel.resync() + viewModel.submit(null, "after") + + // Skip in-flight appends from the previous generation; the resync + // snapshot covers everything up to its sequence number... + var event = events.receive() + while (event !is LogViewModel.UiEvent.SetText) { + event = events.receive() + } + val rendered = StringBuilder(event.text) + + // ...and the rest arrives as appends, exactly once. + while (!rendered.endsWith("after\n")) { + val append = events.receive() + assertTrue(append is LogViewModel.UiEvent.Append) + rendered.append((append as LogViewModel.UiEvent.Append).text) + } + assertEquals("old\nbefore\nafter\n", rendered.toString()) + } + } + @Test fun `re-collection replays history as a snapshot without duplicates`() { val viewModel = TestLogViewModel() diff --git a/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt new file mode 100644 index 0000000000..fbf2784b15 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt @@ -0,0 +1,406 @@ +package com.itsaky.androidide.viewmodels + +import android.content.Context +import android.net.Uri +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.repositories.PluginRepository +import com.itsaky.androidide.repositories.TemplateCollectionRepository +import com.itsaky.androidide.ui.models.ExternalFileInstallUiEffect +import com.itsaky.androidide.ui.models.ExternalFileInstallUiEvent +import com.itsaky.androidide.viewmodel.MainDispatcherRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@OptIn(ExperimentalCoroutinesApi::class) +class ExternalFileInstallViewModelTest { + @get:Rule + val instantExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + val tempFolder = TemporaryFolder() + + private val context: Context = ApplicationProvider.getApplicationContext() + private val pluginRepository = mockk(relaxed = true) + private val templateCollectionRepository = mockk(relaxed = true) + + private lateinit var viewModel: ExternalFileInstallViewModel + + @Before + fun setup() { + viewModel = + ExternalFileInstallViewModel( + pluginRepository = pluginRepository, + templateCollectionRepository = templateCollectionRepository, + contentResolver = context.contentResolver, + filesDir = tempFolder.root, + ) + } + + private fun sourceUriFor( + fileName: String, + content: String = "dummy", + ): Uri { + val file = File(tempFolder.newFolder(), fileName) + file.writeText(content) + return Uri.fromFile(file) + } + + @Test + fun `unsupported extension shows error and finishes`() = + runTest { + viewModel.onReceived(sourceUriFor("notes.txt")) + + val first = viewModel.uiEffect.first() + assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ShowError::class.java) + } + + @Test + fun `cgp when plugin manager unavailable shows setup-incomplete error`() = + runTest { + stubPluginManagerAvailable(false) + + viewModel.onReceived(sourceUriFor("my-plugin.cgp")) + + val first = viewModel.uiEffect.first() + assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ShowError::class.java) + } + + @Test + fun `cgt when templates unavailable shows setup-incomplete error`() = + runTest { + stubTemplatesFeatureAvailable(false) + + viewModel.onReceived(sourceUriFor("my-templates.cgt")) + + val first = viewModel.uiEffect.first() + assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ShowError::class.java) + } + + @Test + fun `fresh cgp forwards to plugin manager`() = + runTest { + stubPluginManagerAvailable(true) + + viewModel.onReceived(sourceUriFor("my-plugin.cgp")) + + val first = viewModel.uiEffect.first() + assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ForwardToPluginManager::class.java) + } + + @Test + fun `fresh cgt with no name collision shows install confirmation`() = + runTest { + stubTemplatesFeatureAvailable(true) + val info = TemplateCollectionRepository.CollectionInfo(templateNames = listOf("Empty Activity")) + coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.success(info) + coEvery { templateCollectionRepository.findExistingCollision(any()) } returns null + + viewModel.onReceived(sourceUriFor("my-templates.cgt")) + + val first = viewModel.uiEffect.first() + assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation::class.java) + val effect = first as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation + assertThat(effect.suggestedBaseName).isEqualTo("my-templates") + assertThat(effect.info.templateNames).containsExactly("Empty Activity") + } + + @Test + fun `cgt with existing name collision shows name conflict`() = + runTest { + stubTemplatesFeatureAvailable(true) + val info = TemplateCollectionRepository.CollectionInfo(templateNames = listOf("Empty Activity")) + coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.success(info) + coEvery { templateCollectionRepository.findExistingCollision(any()) } returns "my-templates" + + viewModel.onReceived(sourceUriFor("my-templates.cgt")) + + val first = viewModel.uiEffect.first() + assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ShowTemplateNameConflict::class.java) + assertThat((first as ExternalFileInstallUiEffect.ShowTemplateNameConflict).existingName).isEqualTo("my-templates") + } + + @Test + fun `a second onReceived for a different file cleans up the first file's still-pending temp copy`() = + runTest { + // Simulates a second VIEW intent for a different file arriving via onNewIntent() on + // the singleTask ExternalFileInstallActivity while the first file's confirmation + // dialog is still unanswered. + stubTemplatesFeatureAvailable(true) + val info = TemplateCollectionRepository.CollectionInfo(templateNames = listOf("Empty Activity")) + coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.success(info) + coEvery { templateCollectionRepository.findExistingCollision(any()) } returns null + + viewModel.onReceived(sourceUriFor("first.cgt")) + val firstEffect = viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation + val firstTempFile = firstEffect.tempFile + assertThat(firstTempFile.exists()).isTrue() + + viewModel.onReceived(sourceUriFor("second.cgt")) + val secondEffect = viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation + + assertThat(firstTempFile.exists()).isFalse() + assertThat(secondEffect.tempFile).isNotEqualTo(firstTempFile) + assertThat(secondEffect.tempFile.exists()).isTrue() + } + + @Test + fun `isInstalling for a superseded generation does not block a newer dialog's buttons`() = + runTest { + stubTemplatesFeatureAvailable(true) + val info = TemplateCollectionRepository.CollectionInfo(templateNames = listOf("Empty Activity")) + coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.success(info) + coEvery { templateCollectionRepository.findExistingCollision(any()) } returns null + + val installDeferred = CompletableDeferred>() + coEvery { templateCollectionRepository.installCollection(any(), any(), any()) } coAnswers { installDeferred.await() } + + viewModel.onReceived(sourceUriFor("first.cgt")) + val firstEffect = viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation + viewModel.onEvent( + ExternalFileInstallUiEvent.ConfirmTemplateInstall(firstEffect.tempFile, firstEffect.suggestedBaseName, overwrite = false), + ) + assertThat(viewModel.isInstalling.value).isTrue() + + // A second, unrelated file arrives (e.g. via onNewIntent on the singleTask activity) + // while the first file's install is still in flight. + viewModel.onReceived(sourceUriFor("second.cgt")) + viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation + + // The new dialog must not render with its buttons disabled just because an unrelated, + // already-superseded install is still finishing up in the background. + assertThat(viewModel.isInstalling.value).isFalse() + + installDeferred.complete(Result.success(Unit)) + viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowSuccess + + // The now-completed, superseded install must not re-enable (or otherwise touch) + // isInstalling on behalf of the current, unrelated generation. + assertThat(viewModel.isInstalling.value).isFalse() + } + + @Test + fun `confirming a stale dialog uses its own generation, not a newer request's`() = + runTest { + // Regression test: confirmTemplateInstall() must key off the generation the on-screen + // dialog was actually committed under (pendingConfirmationGeneration), not the live + // currentRequestGeneration counter, which a second onReceived() can already have bumped + // before its own dialog is shown. + stubTemplatesFeatureAvailable(true) + val info = TemplateCollectionRepository.CollectionInfo(templateNames = listOf("Empty Activity")) + coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.success(info) + coEvery { templateCollectionRepository.findExistingCollision("first") } returns null + coEvery { templateCollectionRepository.installCollection(any(), any(), any()) } returns Result.success(Unit) + + val secondGate = CompletableDeferred() + coEvery { templateCollectionRepository.findExistingCollision("second") } coAnswers { + secondGate.await() + null + } + + viewModel.onReceived(sourceUriFor("first.cgt")) + val firstEffect = viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation + val firstTempFile = firstEffect.tempFile + + // A second VIEW intent arrives (e.g. via onNewIntent) while file A's dialog is still + // the one on screen - this bumps currentRequestGeneration synchronously, well before + // file B's own async pipeline (gated on secondGate) can commit its own dialog. + viewModel.onReceived(sourceUriFor("second.cgt")) + + // The user taps Install on the still-visible (but now globally-stale) dialog for A. + viewModel.onEvent( + ExternalFileInstallUiEvent.ConfirmTemplateInstall(firstTempFile, firstEffect.suggestedBaseName, overwrite = false), + ) + + // File A's install genuinely succeeds - but must not Finish the Activity, since file + // B's request (a newer generation) is still in flight and hasn't shown its own dialog. + assertThat(viewModel.uiEffect.first()).isInstanceOf(ExternalFileInstallUiEffect.ShowSuccess::class.java) + + secondGate.complete(Unit) + assertThat(viewModel.uiEffect.first()).isInstanceOf(ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation::class.java) + } + + @Test + fun `ignoring a stale dialog does not finish the activity out from under a newer one`() = + runTest { + stubTemplatesFeatureAvailable(true) + val info = TemplateCollectionRepository.CollectionInfo(templateNames = listOf("Empty Activity")) + coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.success(info) + coEvery { templateCollectionRepository.findExistingCollision(any()) } returns null + coEvery { templateCollectionRepository.installCollection(any(), any(), any()) } returns Result.success(Unit) + + viewModel.onReceived(sourceUriFor("first.cgt")) + val firstEffect = viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation + val firstTempFile = firstEffect.tempFile + + viewModel.onReceived(sourceUriFor("second.cgt")) + val secondEffect = viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation + + // A stale Ignore/Cancel tap for file A's now-replaced dialog must be a no-op - in + // particular it must not Finish the Activity out from under file B's current dialog. + viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(firstTempFile)) + + viewModel.onEvent( + ExternalFileInstallUiEvent.ConfirmTemplateInstall( + secondEffect.tempFile, + secondEffect.suggestedBaseName, + overwrite = false, + ), + ) + assertThat(viewModel.uiEffect.first()).isInstanceOf(ExternalFileInstallUiEffect.ShowSuccess::class.java) + } + + @Test + fun `retrying Install after a failed install actually attempts install again`() = + runTest { + // Regression test: confirmTemplateInstall() clears pendingConfirmationTempFile on + // entry (transferring tempFile's "ownership" to the install attempt) but the dialog is + // deliberately left open on failure so the user can retry - if that field isn't + // restored, the retry tap's pendingConfirmationTempFile != tempFile guard silently + // no-ops forever, permanently stranding the user on an unresponsive dialog. + stubTemplatesFeatureAvailable(true) + val info = TemplateCollectionRepository.CollectionInfo(templateNames = listOf("Empty Activity")) + coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.success(info) + coEvery { templateCollectionRepository.findExistingCollision(any()) } returns null + coEvery { templateCollectionRepository.installCollection(any(), any(), any()) } returnsMany + listOf(Result.failure(IllegalStateException("disk full")), Result.success(Unit)) + + viewModel.onReceived(sourceUriFor("first.cgt")) + val effect = viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation + + viewModel.onEvent( + ExternalFileInstallUiEvent.ConfirmTemplateInstall(effect.tempFile, effect.suggestedBaseName, overwrite = false), + ) + assertThat(viewModel.uiEffect.first()).isInstanceOf(ExternalFileInstallUiEffect.ShowError::class.java) + + // Retry tap on the still-open dialog must actually attempt the install again, not + // silently no-op. + viewModel.onEvent( + ExternalFileInstallUiEvent.ConfirmTemplateInstall(effect.tempFile, effect.suggestedBaseName, overwrite = false), + ) + assertThat(viewModel.uiEffect.first()).isInstanceOf(ExternalFileInstallUiEffect.ShowSuccess::class.java) + coVerify(exactly = 2) { templateCollectionRepository.installCollection(any(), any(), any()) } + } + + @Test + fun `cancelling after a failed install still finishes`() = + runTest { + stubTemplatesFeatureAvailable(true) + val info = TemplateCollectionRepository.CollectionInfo(templateNames = listOf("Empty Activity")) + coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.success(info) + coEvery { templateCollectionRepository.findExistingCollision(any()) } returns null + coEvery { templateCollectionRepository.installCollection(any(), any(), any()) } returns + Result.failure(IllegalStateException("disk full")) + + viewModel.onReceived(sourceUriFor("first.cgt")) + val effect = viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation + + viewModel.onEvent( + ExternalFileInstallUiEvent.ConfirmTemplateInstall(effect.tempFile, effect.suggestedBaseName, overwrite = false), + ) + assertThat(viewModel.uiEffect.first()).isInstanceOf(ExternalFileInstallUiEffect.ShowError::class.java) + + // Cancel/back on the still-open dialog after a failed install must still Finish, not + // silently no-op. + viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(effect.tempFile)) + assertThat(viewModel.uiEffect.first()).isInstanceOf(ExternalFileInstallUiEffect.Finish::class.java) + } + + @Test + fun `invalid cgt shows invalid-file error`() = + runTest { + stubTemplatesFeatureAvailable(true) + coEvery { templateCollectionRepository.inspectCollection(any()) } returns + Result.failure(IllegalArgumentException("no templates")) + + viewModel.onReceived(sourceUriFor("broken.cgt")) + + val first = viewModel.uiEffect.first() + assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ShowError::class.java) + } + + @Test + fun `onReceived is idempotent per ViewModel instance`() = + runTest { + stubPluginManagerAvailable(true) + val uri = sourceUriFor("my-plugin.cgp") + + viewModel.onReceived(uri) + viewModel.onReceived(uri) + + val first = viewModel.uiEffect.first() + assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ForwardToPluginManager::class.java) + verify(exactly = 1) { pluginRepository.isPluginManagerAvailable() } + } + + @Test + fun `plugin manager becoming available mid-retry still forwards`() = + runTest { + every { pluginRepository.isPluginManagerAvailable() } returnsMany listOf(false, false, true) + + viewModel.onReceived(sourceUriFor("my-plugin.cgp")) + + val first = viewModel.uiEffect.first() + assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ForwardToPluginManager::class.java) + } + + @Test + fun `sanitizeBaseName strips filesystem-unsafe characters`() { + assertThat(viewModel.sanitizeBaseName("my:templates/v2")).isEqualTo("my_templates_v2") + assertThat(viewModel.sanitizeBaseName(" ")).isEqualTo("templates") + } + + @Test + fun `suggestUniqueBaseName bumps suffix until free`() = + runTest { + coEvery { templateCollectionRepository.findExistingCollision("foo") } returns "foo" + coEvery { templateCollectionRepository.findExistingCollision("foo (2)") } returns "foo (2)" + coEvery { templateCollectionRepository.findExistingCollision("foo (3)") } returns null + + val suggested = viewModel.suggestUniqueBaseName("foo") + + assertThat(suggested).isEqualTo("foo (3)") + } + + @Test + fun `suggestUniqueBaseName gives up after a bounded number of attempts`() = + runTest { + // A pathological repository that always reports a collision must not hang this + // suspend function forever. + coEvery { templateCollectionRepository.findExistingCollision(any()) } returns "always-taken" + + val suggested = viewModel.suggestUniqueBaseName("foo") + + assertThat(suggested).isEqualTo("foo (50)") + // The give-up candidate itself must actually have been checked for collision - not + // returned unverified because the attempt-count bound short-circuited before it. + coVerify(exactly = 1) { templateCollectionRepository.findExistingCollision("foo (50)") } + } + + private fun stubPluginManagerAvailable(available: Boolean) { + every { pluginRepository.isPluginManagerAvailable() } returns available + } + + private fun stubTemplatesFeatureAvailable(available: Boolean) { + every { templateCollectionRepository.isTemplatesFeatureAvailable() } returns available + } +} diff --git a/app/src/test/java/com/itsaky/androidide/viewmodels/TemplateManagerViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodels/TemplateManagerViewModelTest.kt new file mode 100644 index 0000000000..d84bb62bc5 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/viewmodels/TemplateManagerViewModelTest.kt @@ -0,0 +1,116 @@ +package com.itsaky.androidide.viewmodels + +import android.util.Log +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.repositories.TemplateRepository +import com.itsaky.androidide.templates.manager.models.CgtFileItem +import com.itsaky.androidide.templates.manager.models.TemplateMetadata +import com.itsaky.androidide.templates.manager.models.TemplateProvenance +import com.itsaky.androidide.ui.models.TemplateManagerUiEffect +import com.itsaky.androidide.ui.models.TemplateManagerUiEvent +import com.itsaky.androidide.viewmodel.MainDispatcherRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import java.io.File + +@RunWith(JUnit4::class) +@OptIn(ExperimentalCoroutinesApi::class) +class TemplateManagerViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val repository = mockk() + + private val item = + CgtFileItem( + file = File("/tmp/install.cgt"), + name = "install.cgt", + templates = listOf(TemplateMetadata("T", "d", "1.0")), + installed = false, + provenance = TemplateProvenance.USER, + ) + + @Before + fun stubAndroidLog() { + // TemplateManagerViewModel calls android.util.Log.d/Log.e directly; under plain JVM + // unit tests those throw "not mocked" and the coroutine never reaches its state update. + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + } + + @After + fun cleanup() { + unmockkStatic(Log::class) + } + + @Test + fun init_loadsTemplates_intoUiState() = + runTest { + coEvery { repository.listTemplateFiles() } returns Result.success(listOf(item)) + + val viewModel = TemplateManagerViewModel(repository) + advanceUntilIdle() + + assertThat(viewModel.uiState.value.isLoading).isFalse() + assertThat(viewModel.uiState.value.items).containsExactly(item) + } + + /** + * Also pins why `_uiEffect` is a `Channel(Channel.BUFFERED)` rather than the rendezvous + * default: `onEvent` -> `installTemplate` sends this effect, and only afterward (the + * `advanceUntilIdle()` below) does anything collect `uiEffect` via `.first()` - mirroring + * production, where the screen's `LaunchedEffect` collector attaches on first composition, + * strictly after the ViewModel (and its `init { loadTemplates() }`) is constructed. A + * rendezvous channel would drop this send with nothing collecting yet; `first()` returning + * it here proves it was buffered instead. + */ + @Test + fun installTemplate_onSuccess_reloadsAndSendsShowSuccessEffect() = + runTest { + coEvery { repository.listTemplateFiles() } returns Result.success(emptyList()) + coEvery { repository.installTemplate(item) } returns Result.success(Unit) + + val viewModel = TemplateManagerViewModel(repository) + advanceUntilIdle() + + viewModel.onEvent(TemplateManagerUiEvent.InstallTemplate(item)) + advanceUntilIdle() + + coVerify(exactly = 1) { repository.installTemplate(item) } + // listTemplateFiles is called once by init{} and again by the post-install reload. + coVerify(exactly = 2) { repository.listTemplateFiles() } + assertThat(viewModel.uiEffect.first() is TemplateManagerUiEffect.ShowSuccess).isTrue() + } + + @Test + fun installTemplate_onFailure_sendsShowErrorEffect_withoutReloading() = + runTest { + coEvery { repository.listTemplateFiles() } returns Result.success(emptyList()) + coEvery { repository.installTemplate(item) } returns Result.failure(java.io.IOException("boom")) + + val viewModel = TemplateManagerViewModel(repository) + advanceUntilIdle() + + viewModel.onEvent(TemplateManagerUiEvent.InstallTemplate(item)) + advanceUntilIdle() + + // Only the init{} load - a failed install must not trigger a reload. + coVerify(exactly = 1) { repository.listTemplateFiles() } + assertThat(viewModel.uiEffect.first() is TemplateManagerUiEffect.ShowError).isTrue() + } +} diff --git a/assets/core.cgt b/assets/core.cgt index 7b248673c1..c66ce2460d 100644 Binary files a/assets/core.cgt and b/assets/core.cgt differ diff --git a/build-info/build.gradle.kts b/build-info/build.gradle.kts index 812fc61ced..79dc003c04 100644 --- a/build-info/build.gradle.kts +++ b/build-info/build.gradle.kts @@ -84,4 +84,14 @@ tasks.create("generateBuildInfo") { } tasks.withType { dependsOn("generateBuildInfo") } -tasks.withType { dependsOn("generateBuildInfo") } +tasks.withType { + dependsOn("generateBuildInfo") + + // Jars embed per-entry timestamps by default, so rebuilding identical sources + // still produces different bytes. kapt tracks this jar through its + // `internalNonAbiClasspath` input -- jar contents, not the ABI -- so a + // non-reproducible jar re-runs annotation processing across every kapt module + // for no reason. See docs/adr/0012-volatile-build-metadata-out-of-abis.md. + isPreserveFileTimestamps = false + isReproducibleFileOrder = true +} diff --git a/build-info/src/main/java/com/itsaky/androidide/buildinfo/BuildInfo.java.in b/build-info/src/main/java/com/itsaky/androidide/buildinfo/BuildInfo.java.in index 75ab99615c..475dcd840d 100644 --- a/build-info/src/main/java/com/itsaky/androidide/buildinfo/BuildInfo.java.in +++ b/build-info/src/main/java/com/itsaky/androidide/buildinfo/BuildInfo.java.in @@ -38,16 +38,20 @@ public class BuildInfo { public static final String MVN_GROUP_ID = "@@MVN_GROUP_ID@@"; public static final String VERSION_NAME = "@@VERSION_NAME@@"; - public static final String VERSION_NAME_SIMPLE = "@@VERSION_NAME_SIMPLE@@"; public static final String RELEASE_VERSION = "@@RELEASE_VERSION@@"; - public static final String VERSION_NAME_PUBLISHING = "@@VERSION_NAME_PUBLISHING@@"; - public static final String VERSION_NAME_DOWNLOAD = "@@VERSION_NAME_DOWNLOAD@@"; + + // The three fields below embed the build time to the minute, so they change + // between any two builds. volatileValue() keeps them out of the ConstantValue + // attribute: see the note on that method. + public static final String VERSION_NAME_SIMPLE = volatileValue("@@VERSION_NAME_SIMPLE@@"); + public static final String VERSION_NAME_PUBLISHING = volatileValue("@@VERSION_NAME_PUBLISHING@@"); + public static final String VERSION_NAME_DOWNLOAD = volatileValue("@@VERSION_NAME_DOWNLOAD@@"); // --------- CI info -------------------- public static final boolean CI_BUILD = @@CI_BUILD@@; - public static final String CI_GIT_BRANCH = "@@CI_GIT_BRANCH@@"; - public static final String CI_GIT_COMMIT_HASH = "@@CI_COMMIT_HASH@@"; + public static final String CI_GIT_BRANCH = volatileValue("@@CI_GIT_BRANCH@@"); + public static final String CI_GIT_COMMIT_HASH = volatileValue("@@CI_COMMIT_HASH@@"); // --------- CI info -------------------- @@ -68,4 +72,23 @@ public class BuildInfo { public static final String PROJECT_SITE = "@@PROJECT_SITE@@"; public static final String SNAPSHOTS_REPOSITORY = "@@SNAPSHOTS_REPOSITORY@@"; public static final String PUBLIC_REPOSITORY = "@@PUBLIC_REPOSITORY@@"; + + /** + * Returns its argument unchanged. + * + *

A {@code static final String} initialised by a constant expression is a + * compile-time constant: javac records it in the ConstantValue attribute and inlines + * it into every consumer, which makes its value part of this module's ABI. + * Because :build-info sits at the root of the dependency graph, a value that changes + * between builds would then force the entire project to recompile every time. + * + *

Routing the value through a method call makes the initialiser non-constant, so + * no ConstantValue is emitted and the value leaves the ABI. Do not "simplify" the + * volatile fields back to plain literals. + * + *

See docs/adr/0012-volatile-build-metadata-out-of-abis.md. + */ + private static String volatileValue(String value) { + return value; + } } \ No newline at end of file diff --git a/common-compose/build.gradle.kts b/common-compose/build.gradle.kts new file mode 100644 index 0000000000..50b8c4c042 --- /dev/null +++ b/common-compose/build.gradle.kts @@ -0,0 +1,29 @@ +import com.itsaky.androidide.build.config.BuildConfig + +plugins { + id("com.android.library") + id("kotlin-android") + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "${BuildConfig.PACKAGE_NAME}.common.compose" + + buildFeatures { + compose = true + } +} + +dependencies { + // api, not implementation: consumers write Compose against these types (ColorScheme, Typography), + // so they must be on the consumer's compile classpath. + api(platform(libs.compose.bom)) + api(libs.compose.runtime) + api(libs.compose.material3) + api(libs.compose.ui) + + implementation(libs.compose.foundation) + implementation(libs.google.material) + + testImplementation(projects.testing.unit) +} diff --git a/common-compose/src/main/java/com/itsaky/androidide/common/compose/IdeColorScheme.kt b/common-compose/src/main/java/com/itsaky/androidide/common/compose/IdeColorScheme.kt new file mode 100644 index 0000000000..a777ae3fb4 --- /dev/null +++ b/common-compose/src/main/java/com/itsaky/androidide/common/compose/IdeColorScheme.kt @@ -0,0 +1,86 @@ +package com.itsaky.androidide.common.compose + +import android.content.Context +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color +import com.google.android.material.color.MaterialColors +import com.google.android.material.R as MaterialR + +/** + * Resolves a theme colour attribute, or null when the attribute is not defined. + * + * Exists so [ideColorScheme] can be exercised without an Android [Context]: the mapping from Material + * attributes to Compose colour roles is the part worth testing, and it is pure once resolution is a + * parameter. + */ +typealias ColorAttrResolver = (attr: Int) -> Color? + +/** + * A Compose [ColorScheme] built from the IDE's XML theme, so Compose UI matches the surrounding + * View-based IDE exactly -- including the user's light/dark choice and any theme overlay in effect. + * + * Every role falls back to the stock Material baseline ([lightColorScheme]/[darkColorScheme]) when its + * attribute is undefined, so a partial XML theme degrades to sensible colours rather than to + * transparent or black. + * + * [dark] selects the baseline. It is the caller's business rather than something read from the context + * here, because the attribute values already come from whichever theme is applied; the baseline only + * matters for roles the theme does not define. + */ +fun ideColorScheme( + dark: Boolean, + resolve: ColorAttrResolver, +): ColorScheme { + val base = if (dark) darkColorScheme() else lightColorScheme() + + fun role( + attr: Int, + fallback: Color, + ): Color = resolve(attr) ?: fallback + + return base.copy( + primary = role(MaterialR.attr.colorPrimary, base.primary), + onPrimary = role(MaterialR.attr.colorOnPrimary, base.onPrimary), + primaryContainer = role(MaterialR.attr.colorPrimaryContainer, base.primaryContainer), + onPrimaryContainer = role(MaterialR.attr.colorOnPrimaryContainer, base.onPrimaryContainer), + secondary = role(MaterialR.attr.colorSecondary, base.secondary), + onSecondary = role(MaterialR.attr.colorOnSecondary, base.onSecondary), + secondaryContainer = role(MaterialR.attr.colorSecondaryContainer, base.secondaryContainer), + onSecondaryContainer = role(MaterialR.attr.colorOnSecondaryContainer, base.onSecondaryContainer), + tertiary = role(MaterialR.attr.colorTertiary, base.tertiary), + onTertiary = role(MaterialR.attr.colorOnTertiary, base.onTertiary), + tertiaryContainer = role(MaterialR.attr.colorTertiaryContainer, base.tertiaryContainer), + onTertiaryContainer = role(MaterialR.attr.colorOnTertiaryContainer, base.onTertiaryContainer), + // colorBackground is a platform attribute, not a Material one. + background = role(android.R.attr.colorBackground, base.background), + onBackground = role(MaterialR.attr.colorOnBackground, base.onBackground), + surface = role(MaterialR.attr.colorSurface, base.surface), + onSurface = role(MaterialR.attr.colorOnSurface, base.onSurface), + surfaceVariant = role(MaterialR.attr.colorSurfaceVariant, base.surfaceVariant), + onSurfaceVariant = role(MaterialR.attr.colorOnSurfaceVariant, base.onSurfaceVariant), + outline = role(MaterialR.attr.colorOutline, base.outline), + outlineVariant = role(MaterialR.attr.colorOutlineVariant, base.outlineVariant), + error = role(MaterialR.attr.colorError, base.error), + onError = role(MaterialR.attr.colorOnError, base.onError), + errorContainer = role(MaterialR.attr.colorErrorContainer, base.errorContainer), + onErrorContainer = role(MaterialR.attr.colorOnErrorContainer, base.onErrorContainer), + ) +} + +/** [ideColorScheme] reading the live attribute values off this context's theme. */ +fun Context.ideColorScheme(dark: Boolean): ColorScheme = ideColorScheme(dark, materialColorResolver()) + +/** + * Resolves through [MaterialColors], which handles both direct colour values and colour-resource + * references. A sentinel distinguishes "undefined" from a legitimately resolved colour -- returning 0 + * would be indistinguishable from transparent black. + */ +private fun Context.materialColorResolver(): ColorAttrResolver = + { attr -> + val resolved = MaterialColors.getColor(this, attr, UNRESOLVED) + if (resolved == UNRESOLVED) null else Color(resolved) + } + +private const val UNRESOLVED = Int.MIN_VALUE diff --git a/common-compose/src/main/java/com/itsaky/androidide/common/compose/IdeTheme.kt b/common-compose/src/main/java/com/itsaky/androidide/common/compose/IdeTheme.kt new file mode 100644 index 0000000000..9fecc28919 --- /dev/null +++ b/common-compose/src/main/java/com/itsaky/androidide/common/compose/IdeTheme.kt @@ -0,0 +1,48 @@ +package com.itsaky.androidide.common.compose + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +/** + * Wraps Compose content in a [MaterialTheme] whose colours come from the IDE's XML theme, so a Compose + * surface is indistinguishable from the View-based UI around it. + * + * Use this instead of a bare `MaterialTheme { }`: the bare form falls back to Material's purple + * baseline, which looks nothing like the IDE and ignores the user's theme entirely. + * + * [typography] is a parameter because branding type is a separate concern from colour -- overlay + * windows brand theirs with the IDE's Atkinson Hyperlegible face, while most surfaces want the + * default. + * + * [contentColor] seeds [LocalContentColor], which is **not** something [MaterialTheme] sets. Its + * global default is [androidx.compose.ui.graphics.Color.Black], and normally only a `Surface` replaces + * it (via `contentColorFor`). Content hosted inside a View that already draws the background -- a + * `BottomSheetDialog`, an overlay window, a `ComposeView` in an XML layout -- has no `Surface`, so + * every `Text` would render black regardless of how dark the background is. Defaulting to `onSurface` + * makes that case correct; a `Surface` further down still overrides it, so screens that do use one are + * unaffected. + */ +@Composable +fun IdeTheme( + typography: Typography = MaterialTheme.typography, + contentColor: Color? = null, + content: @Composable () -> Unit, +) { + val context = LocalContext.current + val dark = isSystemInDarkTheme() + // Attribute resolution reads the theme, so it is keyed on both the context and the dark-mode flag. + val colorScheme = remember(context, dark) { context.ideColorScheme(dark) } + MaterialTheme(colorScheme = colorScheme, typography = typography) { + CompositionLocalProvider( + LocalContentColor provides (contentColor ?: colorScheme.onSurface), + content = content, + ) + } +} diff --git a/common-compose/src/test/java/com/itsaky/androidide/common/compose/IdeColorSchemeTest.kt b/common-compose/src/test/java/com/itsaky/androidide/common/compose/IdeColorSchemeTest.kt new file mode 100644 index 0000000000..0c8b63f5d9 --- /dev/null +++ b/common-compose/src/test/java/com/itsaky/androidide/common/compose/IdeColorSchemeTest.kt @@ -0,0 +1,123 @@ +package com.itsaky.androidide.common.compose + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import com.google.android.material.R as MaterialR + +/** + * The attribute-to-role mapping, tested with a fake resolver rather than a real themed + * [android.content.Context]. + * + * The interesting behaviour is entirely in the mapping and the per-role fallback, so making resolution + * a parameter buys full coverage with no Robolectric and no theme fixtures. + */ +class IdeColorSchemeTest { + private val red = Color(0xFFFF0000) + private val green = Color(0xFF00FF00) + + /** + * Every role [ideColorScheme] claims to map, paired with its name for readable failures. + * + * [ColorScheme] has no structural `equals`, so whole-scheme comparison would compare identity and + * pass vacuously. Listing the roles also makes "did the mapping forget one?" a real assertion. + */ + private fun mappedRoles(scheme: ColorScheme): List> = + listOf( + "primary" to scheme.primary, + "onPrimary" to scheme.onPrimary, + "primaryContainer" to scheme.primaryContainer, + "onPrimaryContainer" to scheme.onPrimaryContainer, + "secondary" to scheme.secondary, + "onSecondary" to scheme.onSecondary, + "secondaryContainer" to scheme.secondaryContainer, + "onSecondaryContainer" to scheme.onSecondaryContainer, + "tertiary" to scheme.tertiary, + "onTertiary" to scheme.onTertiary, + "tertiaryContainer" to scheme.tertiaryContainer, + "onTertiaryContainer" to scheme.onTertiaryContainer, + "background" to scheme.background, + "onBackground" to scheme.onBackground, + "surface" to scheme.surface, + "onSurface" to scheme.onSurface, + "surfaceVariant" to scheme.surfaceVariant, + "onSurfaceVariant" to scheme.onSurfaceVariant, + "outline" to scheme.outline, + "outlineVariant" to scheme.outlineVariant, + "error" to scheme.error, + "onError" to scheme.onError, + "errorContainer" to scheme.errorContainer, + "onErrorContainer" to scheme.onErrorContainer, + ) + + @Test + fun `a resolved attribute wins over the baseline`() { + val scheme = ideColorScheme(dark = false) { attr -> red.takeIf { attr == MaterialR.attr.colorPrimary } } + + assertEquals(red, scheme.primary) + } + + @Test + fun `an undefined attribute falls back to the light baseline`() { + val scheme = ideColorScheme(dark = false) { null } + + assertEquals(mappedRoles(lightColorScheme()), mappedRoles(scheme)) + } + + @Test + fun `an undefined attribute falls back to the dark baseline`() { + val scheme = ideColorScheme(dark = true) { null } + + assertEquals(mappedRoles(darkColorScheme()), mappedRoles(scheme)) + } + + @Test + fun `roles fall back individually, so a partial theme still yields sensible colours`() { + // A theme defining only the surface pair, as a minimal overlay might. + val scheme = + ideColorScheme(dark = false) { attr -> + when (attr) { + MaterialR.attr.colorSurface -> red + MaterialR.attr.colorOnSurface -> green + else -> null + } + } + + assertEquals(red, scheme.surface) + assertEquals(green, scheme.onSurface) + // Everything else keeps the baseline rather than going transparent or black. + assertEquals(lightColorScheme().primary, scheme.primary) + assertEquals(lightColorScheme().error, scheme.error) + } + + @Test + fun `background reads the platform attribute, not a Material one`() { + // colorBackground has no Material equivalent; mapping it to one would silently lose the theme's + // window background. + val scheme = ideColorScheme(dark = false) { attr -> red.takeIf { attr == android.R.attr.colorBackground } } + + assertEquals(red, scheme.background) + } + + @Test + fun `every role the mapping claims to cover is actually resolved`() { + // Resolving everything to one colour proves no listed role was left out of the copy() call: an + // unmapped role would still hold its baseline value. + val scheme = ideColorScheme(dark = false) { red } + + val unmapped = mappedRoles(scheme).filter { (_, color) -> color != red } + assertTrue("roles not read from the theme: ${unmapped.map { it.first }}", unmapped.isEmpty()) + } + + @Test + fun `the dark baseline differs from the light one, so the flag is not ignored`() { + val light = ideColorScheme(dark = false) { null } + val dark = ideColorScheme(dark = true) { null } + + assertTrue(mappedRoles(light) != mappedRoles(dark)) + } +} diff --git a/common/src/androidTest/java/com/itsaky/androidide/utils/DatabaseVersionResolverTest.kt b/common/src/androidTest/java/com/itsaky/androidide/utils/DatabaseVersionResolverTest.kt index 1ba7c2eab1..1761c0e4e5 100644 --- a/common/src/androidTest/java/com/itsaky/androidide/utils/DatabaseVersionResolverTest.kt +++ b/common/src/androidTest/java/com/itsaky/androidide/utils/DatabaseVersionResolverTest.kt @@ -4,13 +4,13 @@ import android.database.sqlite.SQLiteDatabase import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class DatabaseVersionResolverTest { - private lateinit var db: SQLiteDatabase @Before @@ -28,17 +28,82 @@ class DatabaseVersionResolverTest { "CREATE TABLE LastChange (" + "documentationSet TEXT, " + "changeTime TEXT, " + - "who TEXT)" + "who TEXT)", ) } - private fun insertRow(documentationSet: String, changeTime: String, who: String?) { + private fun insertRow( + documentationSet: String, + changeTime: String, + who: String?, + ) { db.execSQL( "INSERT INTO LastChange (documentationSet, changeTime, who) VALUES (?, ?, ?)", arrayOf(documentationSet, changeTime, who), ) } + private fun createVersionTable() { + db.execSQL( + "CREATE TABLE DocumentationDatabaseVersion (" + + "major INT NOT NULL, " + + "minor INT NOT NULL, " + + "patch INT NOT NULL, " + + "who TEXT NOT NULL, " + + "comment TEXT NOT NULL, " + + "changeTime TIMESTAMP DEFAULT CURRENT_TIMESTAMP)", + ) + } + + private fun insertVersion( + major: Int, + minor: Int, + patch: Int, + ) { + db.execSQL( + "INSERT INTO DocumentationDatabaseVersion (major, minor, patch, who, comment) VALUES (?, ?, ?, 'test', 'test')", + arrayOf(major, minor, patch), + ) + } + + // ADFA-5220: a database built before the version table existed has to read as unversioned, not + // as an error -- that is how WebServer decides not to look for a compression dictionary. + @Test + fun majorVersionIsNull_whenVersionTableMissing() { + assertNull(DatabaseVersionResolver.resolveMajorVersion(db)) + } + + @Test + fun majorVersionIsNull_whenVersionTableEmpty() { + createVersionTable() + assertNull(DatabaseVersionResolver.resolveMajorVersion(db)) + } + + @Test + fun majorVersionIsRead_whenDeclared() { + createVersionTable() + insertVersion(2, 0, 0) + assertEquals(2, DatabaseVersionResolver.resolveMajorVersion(db)) + } + + // The table is an append-only log, so the row inserted last is the current version... + @Test + fun majorVersionIsTheLastRowInserted() { + createVersionTable() + insertVersion(2, 0, 0) + insertVersion(3, 1, 4) + assertEquals(3, DatabaseVersionResolver.resolveMajorVersion(db)) + } + + // ...including when that row is a downgrade, which MAX(major) would read as still current. + @Test + fun majorVersionFollowsADowngrade() { + createVersionTable() + insertVersion(3, 0, 0) + insertVersion(2, 0, 0) + assertEquals(2, DatabaseVersionResolver.resolveMajorVersion(db)) + } + @Test fun returnsWholedbRow_whenPresent() { createTable() diff --git a/common/src/main/java/com/itsaky/androidide/templates/Language.kt b/common/src/main/java/com/itsaky/androidide/templates/Language.kt new file mode 100644 index 0000000000..81718c8791 --- /dev/null +++ b/common/src/main/java/com/itsaky/androidide/templates/Language.kt @@ -0,0 +1,30 @@ +/* + * 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.templates + +/** + * Language for source files. + */ +enum class Language( + val lang: String, + val ext: String, +) { + Java("Java", "java"), + Kotlin("Kotlin", "kt"), + Unknown("Unknown", ""), +} diff --git a/common/src/main/java/com/itsaky/androidide/utils/BuildInfoUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/BuildInfoUtils.kt index dcb9065c00..ff5a5ccc2e 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/BuildInfoUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/BuildInfoUtils.kt @@ -11,8 +11,12 @@ object BasicBuildInfo { /** * Basic info, includes internal app name and version name. + * + * Not a `const val`: [BuildInfo.VERSION_NAME_SIMPLE] changes between builds, and a + * `const val` would inline it here and put it back in this module's ABI. See ADR 0012. */ - const val BASIC_INFO = "${BuildInfo.INTERNAL_NAME} (${BuildInfo.VERSION_NAME_SIMPLE})" + @JvmField + val BASIC_INFO = "${BuildInfo.INTERNAL_NAME} (${BuildInfo.VERSION_NAME_SIMPLE})" val hasReleaseVersion: Boolean get() = BuildInfo.RELEASE_VERSION.isNotBlank() diff --git a/common/src/main/java/com/itsaky/androidide/utils/DatabaseVersionResolver.kt b/common/src/main/java/com/itsaky/androidide/utils/DatabaseVersionResolver.kt index 711905eadd..225ffd6a39 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/DatabaseVersionResolver.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/DatabaseVersionResolver.kt @@ -4,7 +4,6 @@ import android.database.sqlite.SQLiteDatabase import android.util.Log object DatabaseVersionResolver { - const val VERSION_UNKNOWN = "Version Unknown" private const val TAG = "DatabaseVersionResolver" @@ -16,6 +15,28 @@ object DatabaseVersionResolver { LIMIT 1 """ + // ADFA-5220's DocumentationDatabaseVersion table. A database declaring at least this MAJOR + // version has its brotli `Content` rows compressed against `CompressionDictionary` (ADFA-5153); + // one declaring less -- or carrying no version table at all -- predates that migration, and its + // rows are plain Brotli. + const val MAJOR_VERSION_WITH_COMPRESSION_DICTIONARY = 2 + + private const val QUERY_VERSION_TABLE_EXISTS = """ + SELECT 1 + FROM sqlite_master + WHERE type = 'table' AND name = 'DocumentationDatabaseVersion' + """ + + // The table is an append-only log -- ADFA-5220 records each change as another INSERT -- so the + // current version is the row inserted last, not the highest one ever recorded: rebuilding from + // an older content set is a downgrade and has to read as one. + private const val QUERY_MAJOR_VERSION = """ + SELECT major + FROM DocumentationDatabaseVersion + ORDER BY rowid DESC + LIMIT 1 + """ + private const val QUERY_FALLBACK_LATEST = """ SELECT changeTime, documentationSet, who FROM LastChange @@ -36,11 +57,12 @@ object DatabaseVersionResolver { db.rawQuery(QUERY_FALLBACK_LATEST, arrayOf()).use { c -> if (c.moveToFirst()) { - val result = formatVersion( - changeTime = c.getString(0), - who = c.getString(2), - documentationSet = c.getString(1), - ) + val result = + formatVersion( + changeTime = c.getString(0), + who = c.getString(2), + documentationSet = c.getString(1), + ) Log.e( TAG, "Missing 'wholedb' record in LastChange table; falling back to $result", @@ -57,6 +79,26 @@ object DatabaseVersionResolver { } } + /** + * The MAJOR version [db] declares in `DocumentationDatabaseVersion` (ADFA-5220), or null when + * that table is absent or empty -- which is how every database built before it existed + * identifies itself. + * + * Deliberately does *not* catch exceptions, unlike [resolveDatabaseVersion]: callers cache the + * answer for the lifetime of a database (see `WebServer.loadCompressionDictionary`), so a + * transient `SQLiteException` has to stay distinguishable from a definitive "no version table", + * or one hiccup would pin the database at unversioned until it is swapped. + */ + fun resolveMajorVersion(db: SQLiteDatabase): Int? { + val tableExists = db.rawQuery(QUERY_VERSION_TABLE_EXISTS, arrayOf()).use { it.moveToFirst() } + if (!tableExists) { + return null + } + return db.rawQuery(QUERY_MAJOR_VERSION, arrayOf()).use { cursor -> + if (cursor.moveToFirst() && !cursor.isNull(0)) cursor.getInt(0) else null + } + } + private fun formatVersion( changeTime: String?, who: String?, diff --git a/common/src/main/java/com/itsaky/androidide/utils/FeedbackEmailHandler.kt b/common/src/main/java/com/itsaky/androidide/utils/FeedbackEmailHandler.kt index fc45eced78..19e208e1c8 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/FeedbackEmailHandler.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/FeedbackEmailHandler.kt @@ -8,7 +8,6 @@ import android.net.Uri import android.os.Handler import android.os.Looper import android.view.PixelCopy -import androidx.core.content.FileProvider import androidx.core.graphics.createBitmap import androidx.core.net.toUri import com.itsaky.androidide.common.R @@ -26,16 +25,17 @@ import kotlin.coroutines.suspendCoroutine class FeedbackEmailHandler( val context: Context, ) { - - companion object { - const val AUTHORITY_SUFFIX = "providers.fileprovider" - const val SCREENSHOTS_DIR = "feedback_screenshots" - const val LOGS_DIR = "feedback_logs" - const val MAX_EMAIL_BODY_CHARS = 50_000 + companion object { + const val SCREENSHOTS_DIR = "feedback_screenshots" + const val LOGS_DIR = "feedback_logs" + const val MAX_EMAIL_BODY_CHARS = 50_000 private val log = LoggerFactory.getLogger(FeedbackEmailHandler::class.java) } - private fun sanitizeEmailBody(body: String, hasLogAttachment: Boolean = true): String { + private fun sanitizeEmailBody( + body: String, + hasLogAttachment: Boolean = true, + ): String { if (body.length <= MAX_EMAIL_BODY_CHARS) return body val suffix = if (hasLogAttachment) " See attached file." else "" return buildString { @@ -46,9 +46,7 @@ class FeedbackEmailHandler( } } - suspend fun captureAndPrepareScreenshotUri( - activity: Activity, - ): Uri? { + suspend fun captureAndPrepareScreenshotUri(activity: Activity): Uri? { val rootView = activity.window?.decorView?.rootView ?: return null if (rootView.width <= 0 || rootView.height <= 0 || !rootView.isShown) return null @@ -90,101 +88,99 @@ class FeedbackEmailHandler( val screenshotsDir = File(context.filesDir, SCREENSHOTS_DIR).apply { mkdirs() } val timestamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.getDefault()).format(Date()) - val filename = "Screenshot ${timestamp}.jpg" + val filename = "Screenshot $timestamp.jpg" val screenshotFile = File(screenshotsDir, filename) FileOutputStream(screenshotFile).use { out -> bitmap.compress(Bitmap.CompressFormat.JPEG, 80, out) } - val authority = "${context.packageName}.$AUTHORITY_SUFFIX" - val uri = FileProvider.getUriForFile(context, authority, screenshotFile) - uri + context.fileProviderUriFor(screenshotFile) } catch (e: Exception) { log.error(context.getString(R.string.failed_to_save_bitmap_to_file), e) null } - suspend fun getLogUri( - context: Context, - logContent: String?, - ): Uri? = - withContext(Dispatchers.IO) { - when { - logContent.isNullOrEmpty() -> null - - else -> { - try { - val logsDir = File(context.filesDir, LOGS_DIR).apply { mkdirs() } - val timestamp = - SimpleDateFormat( - "yyyy-MM-dd_HH-mm-ss", - Locale.getDefault() - ).format(Date()) - val filename = "Feedback Log ${timestamp}.txt" - val logFile = File(logsDir, filename) - logFile.writeText(logContent) - val authority = "${context.packageName}.$AUTHORITY_SUFFIX" - val uri = FileProvider.getUriForFile(context, authority, logFile) - uri - } catch (e: Exception) { - log.error(context.getString(R.string.msg_file_creation_failed), e) - null - } - } - } - } - - fun prepareEmailIntent( - screenshotUri: Uri?, - logContentUri: Uri?, - emailRecipient: String, - subject: String, - body: String, - ): Intent { - val attachmentUris = mutableListOf() - screenshotUri?.let { attachmentUris.add(it) } - logContentUri?.let { attachmentUris.add(it) } - - return getIntentBasedOnAttachments( - emailRecipient = emailRecipient, - subject = subject, - body = body, - attachmentUris = attachmentUris, - hasLogAttachment = logContentUri != null - ) - } - - fun getIntentBasedOnAttachments( - emailRecipient: String, - subject: String, - body: String, - attachmentUris: MutableList, - hasLogAttachment: Boolean = false - ): Intent { - val safeBody = sanitizeEmailBody(body, hasLogAttachment) - return when { - // No screenshot or log file (if both files failed to be created) - attachmentUris.isEmpty() -> { - Intent(Intent.ACTION_SENDTO).apply { - data = "mailto:".toUri() - putExtra(Intent.EXTRA_EMAIL, arrayOf(emailRecipient)) - putExtra(Intent.EXTRA_SUBJECT, subject) - putExtra(Intent.EXTRA_TEXT, safeBody) - } - } - // Screenshot and/or log file - else -> { - Intent(Intent.ACTION_SEND_MULTIPLE).apply { - putExtra(Intent.EXTRA_EMAIL, arrayOf(emailRecipient)) - putExtra(Intent.EXTRA_SUBJECT, subject) - putExtra(Intent.EXTRA_TEXT, safeBody) - putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(attachmentUris)) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - type = "message/rfc822" - } - } - } - } + suspend fun getLogUri( + context: Context, + logContent: String?, + ): Uri? = + withContext(Dispatchers.IO) { + when { + logContent.isNullOrEmpty() -> { + null + } + + else -> { + try { + val logsDir = File(context.filesDir, LOGS_DIR).apply { mkdirs() } + val timestamp = + SimpleDateFormat( + "yyyy-MM-dd_HH-mm-ss", + Locale.getDefault(), + ).format(Date()) + val filename = "Feedback Log $timestamp.txt" + val logFile = File(logsDir, filename) + logFile.writeText(logContent) + context.fileProviderUriFor(logFile) + } catch (e: Exception) { + log.error(context.getString(R.string.msg_file_creation_failed), e) + null + } + } + } + } + + fun prepareEmailIntent( + screenshotUri: Uri?, + logContentUri: Uri?, + emailRecipient: String, + subject: String, + body: String, + ): Intent { + val attachmentUris = mutableListOf() + screenshotUri?.let { attachmentUris.add(it) } + logContentUri?.let { attachmentUris.add(it) } + + return getIntentBasedOnAttachments( + emailRecipient = emailRecipient, + subject = subject, + body = body, + attachmentUris = attachmentUris, + hasLogAttachment = logContentUri != null, + ) + } + + fun getIntentBasedOnAttachments( + emailRecipient: String, + subject: String, + body: String, + attachmentUris: MutableList, + hasLogAttachment: Boolean = false, + ): Intent { + val safeBody = sanitizeEmailBody(body, hasLogAttachment) + return when { + // No screenshot or log file (if both files failed to be created) + attachmentUris.isEmpty() -> { + Intent(Intent.ACTION_SENDTO).apply { + data = "mailto:".toUri() + putExtra(Intent.EXTRA_EMAIL, arrayOf(emailRecipient)) + putExtra(Intent.EXTRA_SUBJECT, subject) + putExtra(Intent.EXTRA_TEXT, safeBody) + } + } + // Screenshot and/or log file + else -> { + Intent(Intent.ACTION_SEND_MULTIPLE).apply { + putExtra(Intent.EXTRA_EMAIL, arrayOf(emailRecipient)) + putExtra(Intent.EXTRA_SUBJECT, subject) + putExtra(Intent.EXTRA_TEXT, safeBody) + putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(attachmentUris)) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + type = "message/rfc822" + } + } + } + } } diff --git a/common/src/main/java/com/itsaky/androidide/utils/FeedbackManager.kt b/common/src/main/java/com/itsaky/androidide/utils/FeedbackManager.kt index 28f07dc935..b3b749e596 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/FeedbackManager.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/FeedbackManager.kt @@ -15,7 +15,6 @@ import android.view.View import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.FileProvider import androidx.core.graphics.createBitmap import androidx.core.net.toUri import androidx.core.text.HtmlCompat @@ -41,29 +40,32 @@ object FeedbackManager { private const val EMAIL_SUPPORT = "feedback@appdevforall.org" private val logger = LoggerFactory.getLogger(FeedbackManager::class.java) - /** - * Shows the feedback dialog and handles sending feedback email. - * - * @param activity The context from which feedback is being sent - */ - fun showFeedbackDialog(activity: AppCompatActivity, logContent: String?) { - val builder = DialogUtils.newMaterialDialogBuilder(activity) + /** + * Shows the feedback dialog and handles sending feedback email. + * + * @param activity The context from which feedback is being sent + */ + fun showFeedbackDialog( + activity: AppCompatActivity, + logContent: String?, + ) { + val builder = DialogUtils.newMaterialDialogBuilder(activity) - builder - .setTitle(R.string.title_alert) - .setMessage( - HtmlCompat.fromHtml( - activity.getString(R.string.email_feedback_warning_prompt), - HtmlCompat.FROM_HTML_MODE_COMPACT, - ), - ).setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() } - .setPositiveButton(android.R.string.ok) { dialog, _ -> - dialog.dismiss() - sendFeedbackWithAttachments(activity, logContent) - }.show() - } + builder + .setTitle(R.string.title_alert) + .setMessage( + HtmlCompat.fromHtml( + activity.getString(R.string.email_feedback_warning_prompt), + HtmlCompat.FROM_HTML_MODE_COMPACT, + ), + ).setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() } + .setPositiveButton(android.R.string.ok) { dialog, _ -> + dialog.dismiss() + sendFeedbackWithAttachments(activity, logContent) + }.show() + } - /** + /** * Shows a simple contact dialog as fallback when email intents fail. * Uses the same title, message, and button text as the existing contact dialog. */ @@ -92,19 +94,20 @@ object FeedbackManager { customSubject: String, metadata: String, includeScreenshot: Boolean = true, - shareActivityResultLauncher: ActivityResultLauncher? = null - ) { - val message = buildString { - append(metadata) - append( - context.getString( - R.string.feedback_device_info, - BasicBuildInfo.formatVersion(), - Build.VERSION.RELEASE, - "${Build.MANUFACTURER} ${Build.MODEL}", - ) - ) - } + shareActivityResultLauncher: ActivityResultLauncher? = null, + ) { + val message = + buildString { + append(metadata) + append( + context.getString( + R.string.feedback_device_info, + BasicBuildInfo.formatVersion(), + Build.VERSION.RELEASE, + "${Build.MANUFACTURER} ${Build.MODEL}", + ), + ) + } if (includeScreenshot) { captureScreenshot(context) { screenshotFile -> @@ -113,7 +116,7 @@ object FeedbackManager { customSubject, message, screenshotFile, - shareActivityResultLauncher + shareActivityResultLauncher, ) } } else { @@ -122,7 +125,7 @@ object FeedbackManager { customSubject, message, null, - shareActivityResultLauncher + shareActivityResultLauncher, ) } } @@ -132,51 +135,49 @@ object FeedbackManager { subject: String, message: String, attachmentFile: File?, - shareActivityResultLauncher: ActivityResultLauncher? + shareActivityResultLauncher: ActivityResultLauncher?, ) { runCatching { - val intent = if (attachmentFile != null) { - Intent(Intent.ACTION_SEND).apply { - type = "message/rfc822" - putExtra(Intent.EXTRA_EMAIL, arrayOf(EMAIL_SUPPORT)) - putExtra(Intent.EXTRA_SUBJECT, subject) - putExtra(Intent.EXTRA_TEXT, message) - - val uri = FileProvider.getUriForFile( - context, - "${context.packageName}.providers.fileprovider", - attachmentFile - ) - putExtra(Intent.EXTRA_STREAM, uri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - - if (context !is Activity) { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + val intent = + if (attachmentFile != null) { + Intent(Intent.ACTION_SEND).apply { + type = "message/rfc822" + putExtra(Intent.EXTRA_EMAIL, arrayOf(EMAIL_SUPPORT)) + putExtra(Intent.EXTRA_SUBJECT, subject) + putExtra(Intent.EXTRA_TEXT, message) + + val uri = context.fileProviderUriFor(attachmentFile) + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + + if (context !is Activity) { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } } - } - } else { - Intent(Intent.ACTION_SENDTO).apply { - data = "mailto:".toUri() - putExtra(Intent.EXTRA_EMAIL, arrayOf(EMAIL_SUPPORT)) - putExtra(Intent.EXTRA_SUBJECT, subject) - putExtra(Intent.EXTRA_TEXT, message) - - if (context !is Activity) { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } else { + Intent(Intent.ACTION_SENDTO).apply { + data = "mailto:".toUri() + putExtra(Intent.EXTRA_EMAIL, arrayOf(EMAIL_SUPPORT)) + putExtra(Intent.EXTRA_SUBJECT, subject) + putExtra(Intent.EXTRA_TEXT, message) + + if (context !is Activity) { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } } } - } launchIntentChooser( intent, context.getString(R.string.send_feedback), context, - shareActivityResultLauncher + shareActivityResultLauncher, ) }.recoverCatching { - val fallbackIntent = Intent(Intent.ACTION_SENDTO).apply { - data = "mailto:${EMAIL_SUPPORT}?subject=${Uri.encode(subject)}&body=${Uri.encode(message)}".toUri() - } + val fallbackIntent = + Intent(Intent.ACTION_SENDTO).apply { + data = "mailto:${EMAIL_SUPPORT}?subject=${Uri.encode(subject)}&body=${Uri.encode(message)}".toUri() + } context.startActivity(fallbackIntent) }.onFailure { logger.error("Failed to send feedback with attachment", it) @@ -184,8 +185,10 @@ object FeedbackManager { } } - - fun captureScreenshot(context: Context, callback: (File?) -> Unit) { + fun captureScreenshot( + context: Context, + callback: (File?) -> Unit, + ) { val activity = context as? AppCompatActivity if (activity == null) { logger.warn("Cannot capture screenshot: Context is not an Activity") @@ -194,37 +197,36 @@ object FeedbackManager { } val rootView = activity.window.decorView.rootView - val screenshotFile = createScreenshotFile(context) ?: run { - callback(null) - return - } - captureWithPixelCopy(activity, rootView, screenshotFile, callback) - } - - - private fun createScreenshotFile(context: Context): File? { - return runCatching { - val screenshotDir = File(context.cacheDir, "screenshots").apply { - if (!exists()) mkdirs() + val screenshotFile = + createScreenshotFile(context) ?: run { + callback(null) + return } + captureWithPixelCopy(activity, rootView, screenshotFile, callback) + } + + private fun createScreenshotFile(context: Context): File? = + runCatching { + val screenshotDir = + File(context.cacheDir, "screenshots").apply { + if (!exists()) mkdirs() + } val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) File(screenshotDir, "screenshot_$timestamp.png") }.onFailure { logger.error("Failed to create screenshot file", it) }.getOrNull() - } private fun captureWithPixelCopy( activity: AppCompatActivity, rootView: View, screenshotFile: File, - callback: (File?) -> Unit + callback: (File?) -> Unit, ) { + var bitmap: Bitmap? = null - var bitmap: Bitmap? = null - - try { - bitmap = createBitmap(rootView.width, rootView.height) + try { + bitmap = createBitmap(rootView.width, rootView.height) val locationOfViewInWindow = IntArray(2) rootView.getLocationInWindow(locationOfViewInWindow) @@ -234,51 +236,54 @@ object FeedbackManager { locationOfViewInWindow[0], locationOfViewInWindow[1], locationOfViewInWindow[0] + rootView.width, - locationOfViewInWindow[1] + rootView.height + locationOfViewInWindow[1] + rootView.height, ), bitmap, { result -> if (result == PixelCopy.SUCCESS) { - activity.lifecycleScope.launch { - saveScreenshot(bitmap, screenshotFile, callback) - } - } else { - logger.error("PixelCopy failed with result code: $result") - bitmap.recycle() - callback(null) - } + activity.lifecycleScope.launch { + saveScreenshot(bitmap, screenshotFile, callback) + } + } else { + logger.error("PixelCopy failed with result code: $result") + bitmap.recycle() + callback(null) + } }, - Handler(Looper.getMainLooper()) + Handler(Looper.getMainLooper()), ) } catch (e: Exception) { logger.error("PixelCopy exception, falling back to Canvas", e) - bitmap?.recycle() - callback(null) + bitmap?.recycle() + callback(null) } } - - private suspend fun saveScreenshot(bitmap: Bitmap, file: File, callback: (File?) -> Unit) { - val result = withContext(Dispatchers.IO) { - runCatching { - FileOutputStream(file).use { out -> - bitmap.compress(Bitmap.CompressFormat.PNG, 90, out) - } - file - }.onFailure { - logger.error("Failed to save screenshot", it) - }.getOrNull() - } - bitmap.recycle() - callback(result) - } - + private suspend fun saveScreenshot( + bitmap: Bitmap, + file: File, + callback: (File?) -> Unit, + ) { + val result = + withContext(Dispatchers.IO) { + runCatching { + FileOutputStream(file).use { out -> + bitmap.compress(Bitmap.CompressFormat.PNG, 90, out) + } + file + }.onFailure { + logger.error("Failed to save screenshot", it) + }.getOrNull() + } + bitmap.recycle() + callback(result) + } private fun launchIntentChooser( intent: Intent, chooserTitle: String, context: Context, - shareActivityResultLauncher: ActivityResultLauncher? + shareActivityResultLauncher: ActivityResultLauncher?, ) { val chooser = Intent.createChooser(intent, chooserTitle) shareActivityResultLauncher?.launch(chooser) ?: context.startActivity(chooser) @@ -294,74 +299,76 @@ object FeedbackManager { else -> "Unknown Screen" } - private fun sendFeedbackWithAttachments( - activity: AppCompatActivity, - logContent: String? - ) { - activity.lifecycleScope.launch { - val handler = FeedbackEmailHandler(activity) + private fun sendFeedbackWithAttachments( + activity: AppCompatActivity, + logContent: String?, + ) { + activity.lifecycleScope.launch { + val handler = FeedbackEmailHandler(activity) + + val screenshotUri = handler.captureAndPrepareScreenshotUri(activity) + val logContentUri = handler.getLogUri(activity, logContent) + + val feedbackRecipient = activity.getString(R.string.feedback_email) + val feedbackSubject = + activity.getString(R.string.feedback_subject, getCurrentScreenName(activity)) + val stackTraceSection = + logContent?.trim().takeIf { it?.isNotEmpty() == true } + ?: activity.getString(R.string.feedback_stack_trace_unavailable) + val feedbackBody = + buildString { + append( + activity.getString( + R.string.feedback_device_info, + BasicBuildInfo.formatVersion(), + Build.VERSION.RELEASE, + "${Build.MANUFACTURER} ${Build.MODEL}", + ), + ) + append( + activity.getString( + R.string.feedback_message, + stackTraceSection, + ), + ) + } - val screenshotUri = handler.captureAndPrepareScreenshotUri(activity) - val logContentUri = handler.getLogUri(activity, logContent) + val emailIntent = + handler.prepareEmailIntent( + screenshotUri, + logContentUri, + feedbackRecipient, + feedbackSubject, + feedbackBody, + ) - val feedbackRecipient = activity.getString(R.string.feedback_email) - val feedbackSubject = - activity.getString(R.string.feedback_subject, getCurrentScreenName(activity)) - val stackTraceSection = - logContent?.trim().takeIf { it?.isNotEmpty() == true } - ?: activity.getString(R.string.feedback_stack_trace_unavailable) - val feedbackBody = - buildString { - append( - activity.getString( - R.string.feedback_device_info, - BasicBuildInfo.formatVersion(), - Build.VERSION.RELEASE, - "${Build.MANUFACTURER} ${Build.MODEL}", - ), - ) - append( - activity.getString( - R.string.feedback_message, - stackTraceSection, - ), - ) - } + runCatching { + activity.startActivity(emailIntent) + }.onFailure { e -> + when { + e is ActivityNotFoundException -> { + Toast.makeText(activity, R.string.no_email_apps, Toast.LENGTH_LONG).show() + } - val emailIntent = - handler.prepareEmailIntent( - screenshotUri, - logContentUri, - feedbackRecipient, - feedbackSubject, - feedbackBody, - ) + e is TransactionTooLargeException || + (e is RuntimeException && e.cause is TransactionTooLargeException) -> { + logger.error("Intent transaction failed: Data too large", e) + Toast.makeText(activity, R.string.msg_feedback_log_too_long, Toast.LENGTH_LONG).show() + } - runCatching { - activity.startActivity(emailIntent) - }.onFailure { e -> - when { - e is ActivityNotFoundException -> { - Toast.makeText(activity, R.string.no_email_apps, Toast.LENGTH_LONG).show() - } - e is TransactionTooLargeException || - (e is RuntimeException && e.cause is TransactionTooLargeException) -> { - logger.error("Intent transaction failed: Data too large", e) - Toast.makeText(activity, R.string.msg_feedback_log_too_long, Toast.LENGTH_LONG).show() - } - else -> { - logger.error("Intent transaction failed: Unknown error", e) - EventBus.getDefault().post( - ReportCaughtExceptionEvent( - throwable = e, - message = "Feedback email intent failed", - extras = mapOf("screen" to getCurrentScreenName(activity)) - ) - ) - Toast.makeText(activity, R.string.unknown_error, Toast.LENGTH_LONG).show() - } - } - } - } - } + else -> { + logger.error("Intent transaction failed: Unknown error", e) + EventBus.getDefault().post( + ReportCaughtExceptionEvent( + throwable = e, + message = "Feedback email intent failed", + extras = mapOf("screen" to getCurrentScreenName(activity)), + ), + ) + Toast.makeText(activity, R.string.unknown_error, Toast.LENGTH_LONG).show() + } + } + } + } + } } diff --git a/common/src/main/java/com/itsaky/androidide/utils/FileProviderUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/FileProviderUtils.kt new file mode 100644 index 0000000000..dbce28665b --- /dev/null +++ b/common/src/main/java/com/itsaky/androidide/utils/FileProviderUtils.kt @@ -0,0 +1,24 @@ +package com.itsaky.androidide.utils + +import android.content.Context +import android.net.Uri +import androidx.core.content.FileProvider +import java.io.File + +const val FILE_PROVIDER_AUTHORITY_SUFFIX = "providers.fileprovider" + +/** + * This app's [androidx.core.content.FileProvider] authority for a given [packageName] - shared so + * every caller that mints or checks a `content://` Uri against it agrees on the same string, even + * callers (e.g. a ViewModel) that only hold a package name rather than a full [Context]. + */ +fun fileProviderAuthorityFor(packageName: String): String = "$packageName.$FILE_PROVIDER_AUTHORITY_SUFFIX" + +/** + * This app's [androidx.core.content.FileProvider] authority - shared so every caller that mints + * or checks a `content://` Uri against it agrees on the same string. + */ +fun Context.fileProviderAuthority(): String = fileProviderAuthorityFor(packageName) + +/** Mints a `content://` Uri for [file] via this app's [androidx.core.content.FileProvider]. */ +fun Context.fileProviderUriFor(file: File): Uri = FileProvider.getUriForFile(this, fileProviderAuthority(), file) diff --git a/common/src/main/java/com/itsaky/androidide/utils/FlashbarActivityUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/FlashbarActivityUtils.kt index 532fd8f59e..67bfcec141 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/FlashbarActivityUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/FlashbarActivityUtils.kt @@ -34,13 +34,19 @@ import com.itsaky.androidide.tasks.runOnUiThread import com.itsaky.androidide.utils.FlashType.ERROR import com.itsaky.androidide.utils.FlashType.INFO import com.itsaky.androidide.utils.FlashType.SUCCESS +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull const val DURATION_SHORT = 2000L const val DURATION_LONG = 3500L const val DURATION_INDEFINITE = Flashbar.DURATION_INDEFINITE +/** Safety net for [Flashbar.OnBarShowListener.onShown] never firing - callers awaiting it are + * never blocked indefinitely (e.g. if there's no foreground activity to actually show a bar). */ +private const val FLASH_SHOWN_TIMEOUT_MS = 3000L + val COLOR_SUCCESS = Color.parseColor("#4CAF50") val COLOR_ERROR = Color.parseColor("#f44336") const val COLOR_INFO = Color.DKGRAY @@ -54,35 +60,45 @@ private fun Flashbar.Builder.applyIcon(iconType: IconType): Flashbar.Builder = IconType.INFO -> this.infoIcon() } -private fun Activity.showFlashBar( +/** + * Builds and configures a Flashbar for [msg]/[iconType] (icon, and - for an indefinite error - the + * dismiss button), without showing it yet. Shared by [showFlashBar] and [showFlashBarAwaitShown] + * so their setup can't silently diverge. Returns `null` for a `null` [msg] (nothing to show). + */ +private fun Activity.configureFlashbar( msg: Any?, iconType: IconType, - gravity: Flashbar.Gravity = TOP, - duration: Long = Flashbar.DURATION_SHORT, -) { - val builder = flashbarBuilder(gravity, duration) - .applyIcon(iconType) + gravity: Flashbar.Gravity, + duration: Long, +): Flashbar.Builder? { + if (msg == null) return null + if (msg !is Int && msg !is String) { + throw IllegalArgumentException("Message must be String or Int resource") + } - // Add a close button if the flashbar is an indefinite error - if (duration == DURATION_INDEFINITE && iconType == IconType.ERROR) { - builder.positiveActionText(getString(R.string.dismiss)) - builder.positiveActionTapListener { it.dismiss() } - } + val builder = flashbarBuilder(gravity, duration).applyIcon(iconType) + + // Add a close button if the flashbar is an indefinite error + if (duration == DURATION_INDEFINITE && iconType == IconType.ERROR) { + builder.positiveActionText(getString(R.string.dismiss)) + builder.positiveActionTapListener { it.dismiss() } + } when (msg) { - null -> return - is Int -> - builder - .message(msg) - .showOnUiThread() - - is String -> - builder - .message(msg) - .showOnUiThread() - - else -> throw IllegalArgumentException("Message must be String or Int resource") + is Int -> builder.message(msg) + is String -> builder.message(msg) } + + return builder +} + +private fun Activity.showFlashBar( + msg: Any?, + iconType: IconType, + gravity: Flashbar.Gravity = TOP, + duration: Long = Flashbar.DURATION_SHORT, +) { + configureFlashbar(msg, iconType, gravity, duration)?.showOnUiThread() } @JvmOverloads @@ -128,6 +144,44 @@ fun Activity.flashError(msg: String?) = showFlashBar(msg, IconType.ERROR, durati fun Activity.flashInfo(msg: String?) = showFlashBar(msg, IconType.INFO) +/** + * Like [showFlashBar], but suspends until the bar's entrance animation has actually finished (or + * [FLASH_SHOWN_TIMEOUT_MS] elapses) instead of firing-and-forgetting - for callers (e.g. a + * one-shot screen about to finish()) that need the message to be visible before proceeding, + * rather than guessing a fixed delay that may or may not outlast the real animation. + */ +private suspend fun Activity.showFlashBarAwaitShown( + msg: Any?, + iconType: IconType, + gravity: Flashbar.Gravity = TOP, + duration: Long = Flashbar.DURATION_SHORT, +) { + val builder = configureFlashbar(msg, iconType, gravity, duration) ?: return + + val shown = CompletableDeferred() + builder.barShowListener( + object : Flashbar.OnBarShowListener { + override fun onShowing(bar: Flashbar) = Unit + + override fun onShowProgress( + bar: Flashbar, + progress: Float, + ) = Unit + + override fun onShown(bar: Flashbar) { + shown.complete(Unit) + } + }, + ) + + runOnUiThread { builder.build().show() } + withTimeoutOrNull(FLASH_SHOWN_TIMEOUT_MS) { shown.await() } +} + +suspend fun Activity.flashSuccessAwaitShown(msg: String?) = showFlashBarAwaitShown(msg, IconType.SUCCESS) + +suspend fun Activity.flashErrorAwaitShown(msg: String?) = showFlashBarAwaitShown(msg, IconType.ERROR, duration = DURATION_INDEFINITE) + fun Activity.flashSuccess( @StringRes msg: Int, ) = showFlashBar(msg, IconType.SUCCESS) diff --git a/common/src/main/java/com/itsaky/androidide/utils/FlashbarUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/FlashbarUtils.kt index 6028135fa9..1d951a5495 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/FlashbarUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/FlashbarUtils.kt @@ -48,10 +48,20 @@ fun flashSuccess( withActivity { flashSuccess(msg) } } +/** Suspends until the success bar has actually finished its entrance animation - see [Activity.flashSuccessAwaitShown]. */ +suspend fun flashSuccessAwaitShown(msg: String?) { + withActivitySuspend { flashSuccessAwaitShown(msg) } +} + fun flashError(msg: String?) { withActivity { flashError(msg) } } +/** Suspends until the error bar has actually finished its entrance animation - see [Activity.flashErrorAwaitShown]. */ +suspend fun flashErrorAwaitShown(msg: String?) { + withActivitySuspend { flashErrorAwaitShown(msg) } +} + fun flashError( @StringRes msg: Int, ) { @@ -78,6 +88,15 @@ private inline fun withActivity(action: Activity.() -> T?): T? = null } +private suspend inline fun withActivitySuspend(crossinline action: suspend Activity.() -> Unit) { + val activity = BaseApplication.baseInstance.foregroundActivity + if (activity == null) { + ILogger.ROOT.warn("Cannot show flashbar message. Cannot get top activity.") + return + } + activity.action() +} + /** The type of flashbar message. */ enum class FlashType { ERROR, diff --git a/common/src/main/java/com/itsaky/androidide/utils/GetProjectBuildVersions.kt b/common/src/main/java/com/itsaky/androidide/utils/GetProjectBuildVersions.kt index 653972dccf..898a9f4c6d 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/GetProjectBuildVersions.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/GetProjectBuildVersions.kt @@ -1,82 +1,143 @@ package com.itsaky.androidide.utils +import com.itsaky.androidide.templates.Language import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File -suspend fun readGradleVersion(root: File): String = withContext(Dispatchers.IO) { - val gradleWrapper = File(root, "gradle/wrapper/gradle-wrapper.properties") - if (!gradleWrapper.exists()) return@withContext "Unknown" +suspend fun readGradleVersion(root: File): String = + withContext(Dispatchers.IO) { + val gradleWrapper = File(root, "gradle/wrapper/gradle-wrapper.properties") + if (!gradleWrapper.exists()) return@withContext "Unknown" - val text = gradleWrapper.readText() - val match = Regex("distributionUrl=.*gradle-(.*)-").find(text) - return@withContext match?.groupValues?.get(1) ?: "Unknown" -} - -suspend fun readKotlinVersion(root: File): String = withContext(Dispatchers.IO) { - val kotlinVarRegex = - Regex("""kotlin_version\s*=\s*"([^"]+)"""") - - val kotlinPluginRegex = - Regex("""id\(["']org.jetbrains.kotlin[^"']+["']\)\s*version\s*"([^"]+)"""") - - val kotlinForceRegex = - Regex("""force\(["']org.jetbrains.kotlin:kotlin-stdlib:([^"']+)["']\)""") - - val tomlDirectRegex = - Regex("""kotlin\s*=\s*"([^"]+)"""") - - val tomlRefRegex = - Regex("""version\.ref\s*=\s*"([^"]+)"""") - - val gradleFiles = sequenceOf( - File(root, "app/build.gradle"), - File(root, "app/build.gradle.kts"), - File(root, "build.gradle"), - File(root, "build.gradle.kts"), - ).filter { it.exists() } - - for (file in gradleFiles) { - val text = file.readText() - - kotlinVarRegex.find(text)?.groupValues?.get(1)?.let { return@withContext it } - kotlinPluginRegex.find(text)?.groupValues?.get(1)?.let { return@withContext it } - kotlinForceRegex.find(text)?.groupValues?.get(1)?.let { return@withContext it } + val text = gradleWrapper.readText() + val match = Regex("distributionUrl=.*gradle-(.*)-").find(text) + return@withContext match?.groupValues?.get(1) ?: "Unknown" } - val libsToml = File(root, "gradle/libs.versions.toml") - if (!libsToml.exists()) return@withContext "Unknown" - - val toml = libsToml.readText() - - tomlDirectRegex.find(toml)?.groupValues?.get(1)?.let { return@withContext it } +suspend fun readKotlinVersion(root: File): String = + withContext(Dispatchers.IO) { + val kotlinVarRegex = + Regex("""kotlin_version\s*=\s*"([^"]+)"""") + + val kotlinPluginRegex = + Regex("""id\(["']org.jetbrains.kotlin[^"']+["']\)\s*version\s*"([^"]+)"""") + + val kotlinForceRegex = + Regex("""force\(["']org.jetbrains.kotlin:kotlin-stdlib:([^"']+)["']\)""") + + // ([^":]+) excludes shorthand coordinates like "org.jetbrains.kotlin:kotlin-stdlib:1.9.24" + val tomlDirectKotlinRegex = + Regex("""(?i)\b(?:kotlin|kotlinVersion|kotlin-version|org-jetbrains-kotlin[a-zA-Z0-9_-]*)\s*=\s*"([^":]+)"""") + + // Matches the quoted id/module/group of a kotlin plugin or library entry, then the + // version.ref that follows it on the same line. + val tomlKotlinRefRegex = + Regex("""(?i)"org\.jetbrains\.kotlin[^"]*".*?version\.ref\s*=\s*"([^"]+)"""") + + val gradleFiles = + sequenceOf( + File(root, "app/build.gradle"), + File(root, "app/build.gradle.kts"), + File(root, "build.gradle"), + File(root, "build.gradle.kts"), + ).filter { it.exists() } + + for (file in gradleFiles) { + val text = file.readText() + + kotlinVarRegex + .find(text) + ?.groupValues + ?.get(1) + ?.let { return@withContext it } + kotlinPluginRegex + .find(text) + ?.groupValues + ?.get(1) + ?.let { return@withContext it } + kotlinForceRegex + .find(text) + ?.groupValues + ?.get(1) + ?.let { return@withContext it } + } + + val libsToml = File(root, "gradle/libs.versions.toml") + if (!libsToml.exists()) return@withContext "Unknown" + + val toml = libsToml.readText() + + tomlDirectKotlinRegex + .find(toml) + ?.groupValues + ?.get(1) + ?.let { return@withContext it } + + val refName = + tomlKotlinRefRegex.find(toml)?.groupValues?.get(1) + ?: return@withContext "Unknown" + + Regex("""(?m)^${Regex.escape(refName)}\s*=\s*"([^"]+)"""") + .find(toml) + ?.groupValues + ?.get(1) + ?: "Unknown" + } - val refName = tomlRefRegex.find(toml)?.groupValues?.get(1) - ?: return@withContext "Unknown" +suspend fun readJavaVersion(root: File): String = + withContext(Dispatchers.IO) { + val buildGradle = File(root, "build.gradle") + val buildGradleKts = File(root, "build.gradle.kts") - Regex("""$refName\s*=\s*"([^"]+)"""") - .find(toml) - ?.groupValues - ?.get(1) - ?: "Unknown" -} + val file = + when { + buildGradle.exists() -> buildGradle + buildGradleKts.exists() -> buildGradleKts + else -> return@withContext "Unknown" + } + val text = file.readText() -suspend fun readJavaVersion(root: File): String = withContext(Dispatchers.IO) { - val buildGradle = File(root, "build.gradle") - val buildGradleKts = File(root, "build.gradle.kts") + // Regex: sourceCompatibility = JavaVersion.VERSION_17 | JavaVersion.VERSION_1_8 + val regex = Regex("""sourceCompatibility\s*=\s*JavaVersion\.VERSION_([0-9_]+)""") + val match = regex.find(text) - val file = when { - buildGradle.exists() -> buildGradle - buildGradleKts.exists() -> buildGradleKts - else -> return@withContext "Unknown" + return@withContext match?.groupValues?.get(1) ?: "Unknown" } - val text = file.readText() - - // Regex: sourceCompatibility = JavaVersion.VERSION_17 | JavaVersion.VERSION_1_8 - val regex = Regex("""sourceCompatibility\s*=\s*JavaVersion\.VERSION_([0-9_]+)""") - val match = regex.find(text) - - return@withContext match?.groupValues?.get(1) ?: "Unknown" -} \ No newline at end of file +/** + * Detects the primary programming language of an Android project by scanning its source directory. + * + * Kotlin source files, including Kotlin script files (`.kts`), take precedence over Java source + * files. If no recognized source files are found, or if the expected source directory does not + * exist, `"Unknown"` is returned. + * + * The scan is performed on [Dispatchers.IO] to avoid blocking the calling coroutine. + * + * @param root the root directory of the project. + * @return [Language.Kotlin] if Kotlin source is found, [Language.Java] if only Java + * source is found, or [Language.Unknown] if no recognized source is found. + */ +suspend fun readProjectLanguage(root: File): String = + withContext(Dispatchers.IO) { + val srcDir = + listOf( + File(root, "app/src/main"), + File(root, "src/main"), + ).firstOrNull(File::exists) ?: return@withContext Language.Unknown.lang + + var hasJava = false + + srcDir + .walkTopDown() + .filter(File::isFile) + .forEach { file -> + when (file.extension.lowercase()) { + Language.Kotlin.ext, "kts" -> return@withContext Language.Kotlin.lang + Language.Java.ext -> hasJava = true + } + } + + if (hasJava) Language.Java.lang else Language.Unknown.lang + } diff --git a/common/src/main/java/com/itsaky/androidide/utils/GetProjectDetails.kt b/common/src/main/java/com/itsaky/androidide/utils/GetProjectDetails.kt index 24ff10dc89..e372473f06 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/GetProjectDetails.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/GetProjectDetails.kt @@ -7,37 +7,39 @@ import kotlinx.coroutines.withContext import java.io.File data class ProjectDetails( - val sizeFormatted: String, - val numberOfFiles: Int, - val gradleVersion: String, - val kotlinVersion: String, - val javaVersion: String + val sizeFormatted: String, + val numberOfFiles: Int, + val gradleVersion: String, + val kotlinVersion: String, + val javaVersion: String, + val language: String ) suspend fun loadProjectDetails(projectPath: String, context: Context): ProjectDetails = - withContext(Dispatchers.IO) { - val root = File(projectPath) - val appDir = root.toPath().resolve("app").toFile() - var sizeBytes = 0L - var fileCount = 0 + withContext(Dispatchers.IO) { + val root = File(projectPath) + val appDir = root.toPath().resolve("app").toFile() + var sizeBytes = 0L + var fileCount = 0 - val ignoredDirs = arrayOf("build", ".gradle", ".git", ".idea") + val ignoredDirs = arrayOf("build", ".gradle", ".git", ".idea") - root.walkTopDown() - .onEnter { !ignoredDirs.contains(it.name) } - .forEach { file -> - if (file.isFile) { - fileCount++ - sizeBytes += file.length() - } - } - val sizeFormatted = formatFileSize(context, sizeBytes) + root.walkTopDown() + .onEnter { !ignoredDirs.contains(it.name) } + .forEach { file -> + if (file.isFile) { + fileCount++ + sizeBytes += file.length() + } + } + val sizeFormatted = formatFileSize(context, sizeBytes) - ProjectDetails( - sizeFormatted = sizeFormatted, - numberOfFiles = fileCount, - gradleVersion = readGradleVersion(root), - kotlinVersion = readKotlinVersion(root), - javaVersion = readJavaVersion(appDir) - ) - } \ No newline at end of file + ProjectDetails( + sizeFormatted = sizeFormatted, + numberOfFiles = fileCount, + gradleVersion = readGradleVersion(root), + kotlinVersion = readKotlinVersion(root), + javaVersion = readJavaVersion(appDir), + language = readProjectLanguage(root) + ) + } diff --git a/common/src/main/java/com/itsaky/androidide/utils/UriExtensions.kt b/common/src/main/java/com/itsaky/androidide/utils/UriExtensions.kt index a542147b4b..2109dc8350 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/UriExtensions.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/UriExtensions.kt @@ -1,32 +1,39 @@ package com.itsaky.androidide.utils +import android.content.ContentResolver import android.content.Context import android.net.Uri import android.provider.OpenableColumns -import android.util.Log +import org.slf4j.LoggerFactory -fun Uri.getFileName(context: Context): String { - val unknownFileLabel = "Unknown File" - if (scheme == "content") { - try { - context.contentResolver.query(this, null, null, null, null)?.use { cursor -> - if (cursor.moveToFirst()) { - val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - if (nameIndex >= 0) { - return cursor.getString(nameIndex) ?: unknownFileLabel - } - } - } - } catch (e: SecurityException) { - Log.w("UriExtensions", "SecurityException while reading URI: ${scheme}://${authority}", e) - } catch (e: Exception) { - Log.w("UriExtensions", "Unexpected error while reading URI: ${scheme}://${authority}", e) - } +private val log = LoggerFactory.getLogger("UriExtensions") - return unknownFileLabel - } +fun Uri.getFileName(context: Context): String = getFileName(context.contentResolver) - val fallbackName = path?.substringAfterLast('/') ?: unknownFileLabel - val decodedName = Uri.decode(fallbackName) - return decodedName.ifBlank { unknownFileLabel } -} \ No newline at end of file +fun Uri.getFileName(contentResolver: ContentResolver): String { + val unknownFileLabel = "Unknown File" + if (scheme == "content") { + try { + contentResolver.query(this, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0) { + return cursor.getString(nameIndex) ?: unknownFileLabel + } + } + } + } catch (e: Exception) { + // Broad on purpose: a third-party content provider can throw almost anything + // (SecurityException, unresolvable-URI IllegalArgumentException, + // CursorWindowAllocationException, a RuntimeException wrapping a dead Binder, ...) + // and this is a best-effort display-name lookup, not a critical path. + log.warn("Failed to read display name for URI: {}://{}", scheme, authority, e) + } + + return unknownFileLabel + } + + val fallbackName = path?.substringAfterLast('/') ?: unknownFileLabel + val decodedName = Uri.decode(fallbackName) + return decodedName.ifBlank { unknownFileLabel } +} diff --git a/common/src/main/java/com/itsaky/androidide/utils/ViewExtensions.kt b/common/src/main/java/com/itsaky/androidide/utils/ViewExtensions.kt index 7671bec043..4186c618e1 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/ViewExtensions.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/ViewExtensions.kt @@ -9,6 +9,7 @@ import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration import android.view.ViewGroup +import android.widget.EditText import android.widget.ListView import androidx.appcompat.app.AlertDialog import androidx.core.view.forEach @@ -29,76 +30,100 @@ fun View.forEachViewRecursively(action: (View) -> Unit) { } } +/** + * Attaches a long-press listener to this view and, recursively, to every view in its + * subtree. [ListView]s and views in [exclude] are skipped along with their subtrees. + * + * Text fields are skipped by default: a long press inside an [EditText] is the platform + * text-editing gesture (select/paste), and hijacking it would e.g. dismiss a dialog when + * the user long-presses its input field to paste + * + * @param exclude Views whose subtrees are left untouched. + * @param includeEditTexts Whether to also attach the listener to [EditText]s. Enable only + * when the listener implements its own text actions, like find-in-project's + * SearchFieldToolbar. + * @param listener Invoked with the long-pressed view; returns `true` if it consumed the + * event, `false` to let the view's default long-press behavior run. + */ fun View.applyLongPressRecursively( - exclude: List = emptyList(), - listener: (View) -> Boolean + exclude: List = emptyList(), + includeEditTexts: Boolean = false, + listener: (View) -> Boolean, ) { - if (this is ListView || this in exclude) return + if (this is ListView || (this is EditText && !includeEditTexts) || this in exclude) return - setOnLongClickListener { listener(it) } + setOnLongClickListener { listener(it) } - if (this is ViewGroup) { - forEach { it.applyLongPressRecursively(exclude, listener) } - } + if (this is ViewGroup) { + forEach { it.applyLongPressRecursively(exclude, includeEditTexts, listener) } + } } fun RecyclerView.onLongPress(listener: (MotionEvent) -> Unit) { - val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { - override fun onLongPress(e: MotionEvent) { - listener(e) - } - }) - - addOnItemTouchListener(object : RecyclerView.SimpleOnItemTouchListener() { - override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { - gestureDetector.onTouchEvent(e) - return false - } - }) + val gestureDetector = + GestureDetector( + context, + object : GestureDetector.SimpleOnGestureListener() { + override fun onLongPress(e: MotionEvent) { + listener(e) + } + }, + ) + + addOnItemTouchListener( + object : RecyclerView.SimpleOnItemTouchListener() { + override fun onInterceptTouchEvent( + rv: RecyclerView, + e: MotionEvent, + ): Boolean { + gestureDetector.onTouchEvent(e) + return false + } + }, + ) } - @SuppressLint("ClickableViewAccessibility") fun View.setupGestureHandling( - onLongPress: (View) -> Unit, - onDrag: (View) -> Unit + onLongPress: (View) -> Unit, + onDrag: (View) -> Unit, ) { - val handler = Handler(Looper.getMainLooper()) - var isTooltipStarted = false - var startTime = 0L - - setOnTouchListener { view, event -> - when (event.action) { - MotionEvent.ACTION_DOWN -> { - isTooltipStarted = false - startTime = System.currentTimeMillis() - - // Trigger long press after 800ms - handler.postDelayed({ - if (!isTooltipStarted) { - isTooltipStarted = true - view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) - onLongPress(view) - } - }, LONG_PRESS_TIMEOUT_MS) - } - - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - handler.removeCallbacksAndMessages(null) - - if (!isTooltipStarted) { - val holdDuration = System.currentTimeMillis() - startTime - if (holdDuration >= HOLD_DURATION_MS) { - // Medium hold for drag (600-800ms) - onDrag(view) - } else { - view.performClick() - } - } - } - } - true - } + val handler = Handler(Looper.getMainLooper()) + var isTooltipStarted = false + var startTime = 0L + + setOnTouchListener { view, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> { + isTooltipStarted = false + startTime = System.currentTimeMillis() + + // Trigger long press after 800ms + handler.postDelayed({ + if (!isTooltipStarted) { + isTooltipStarted = true + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + onLongPress(view) + } + }, LONG_PRESS_TIMEOUT_MS) + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + handler.removeCallbacksAndMessages(null) + + if (!isTooltipStarted) { + val holdDuration = System.currentTimeMillis() - startTime + if (holdDuration >= HOLD_DURATION_MS) { + // Medium hold for drag (600-800ms) + onDrag(view) + } else { + view.performClick() + } + } + } + } + true + } } /** @@ -108,16 +133,22 @@ fun View.setupGestureHandling( * is long-pressed. It works by recursively attaching a long-press listener to the * dialog's decor view and all its children. * + * @param includeEditTexts Whether the listener is also attached to text fields. Off by + * default so the platform select/paste gesture keeps working; enable it + * only when the listener implements its own text actions. * @param listener A lambda function that will be invoked when a long-press event occurs. * The lambda receives the [View] that was long-pressed as its argument * and should return `true` if the listener has consumed the event, `false` otherwise. */ -fun AlertDialog.onLongPress(listener: (View) -> Boolean) { +fun AlertDialog.onLongPress( + includeEditTexts: Boolean = false, + listener: (View) -> Boolean, +) { if (this.isShowing) { - this.window?.decorView?.applyLongPressRecursively(emptyList(), listener) + this.window?.decorView?.applyLongPressRecursively(emptyList(), includeEditTexts, listener) } else { this.setOnShowListener { - this.window?.decorView?.applyLongPressRecursively(emptyList(), listener) + this.window?.decorView?.applyLongPressRecursively(emptyList(), includeEditTexts, listener) } } } @@ -208,7 +239,10 @@ fun View.handleLongClicksAndDrag( longPressFired = false return@setOnTouchListener true } - else -> false + + else -> { + false + } } } } diff --git a/common/src/test/java/com/itsaky/androidide/utils/FileProviderUtilsTest.kt b/common/src/test/java/com/itsaky/androidide/utils/FileProviderUtilsTest.kt new file mode 100644 index 0000000000..f9f10926c1 --- /dev/null +++ b/common/src/test/java/com/itsaky/androidide/utils/FileProviderUtilsTest.kt @@ -0,0 +1,31 @@ +package com.itsaky.androidide.utils + +import android.content.Context +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import org.junit.Test + +/** Every `content://` Uri this app mints or checks must agree on the same FileProvider authority string. */ +class FileProviderUtilsTest { + @Test + fun `fileProviderAuthorityFor appends the fixed suffix to the package name`() { + assertThat(fileProviderAuthorityFor("com.itsaky.androidide")) + .isEqualTo("com.itsaky.androidide.providers.fileprovider") + } + + @Test + fun `fileProviderAuthorityFor is stable across different package names`() { + assertThat(fileProviderAuthorityFor("com.example.other")) + .isEqualTo("com.example.other.providers.fileprovider") + } + + @Test + fun `Context fileProviderAuthority delegates to the context's package name`() { + val context = mockk() + every { context.packageName } returns "com.itsaky.androidide" + + assertThat(context.fileProviderAuthority()) + .isEqualTo(fileProviderAuthorityFor("com.itsaky.androidide")) + } +} diff --git a/common/src/test/java/com/itsaky/androidide/utils/GetProjectBuildVersionsTest.kt b/common/src/test/java/com/itsaky/androidide/utils/GetProjectBuildVersionsTest.kt new file mode 100644 index 0000000000..af170c3342 --- /dev/null +++ b/common/src/test/java/com/itsaky/androidide/utils/GetProjectBuildVersionsTest.kt @@ -0,0 +1,176 @@ +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class GetProjectBuildVersionsTest { + @get:Rule + val tempFolder = TemporaryFolder() + + @Test + fun `readProjectLanguage identifies Java project with java files`() = + runBlocking { + val root = tempFolder.newFolder("JavaProject") + val srcDir = File(root, "app/src/main/java/com/example") + srcDir.mkdirs() + File( + srcDir, + "MainActivity.java", + ).writeText("package com.example; public class MainActivity {}") + + val gradleToml = File(root, "gradle") + gradleToml.mkdirs() + File(gradleToml, "libs.versions.toml").writeText( + """ + [versions] + agp = "8.2.0" + appcompat = "1.6.1" + [libraries] + androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } + """.trimIndent(), + ) + + val language = readProjectLanguage(root) + assertThat(language).isEqualTo("Java") + } + + @Test + fun `readProjectLanguage identifies Kotlin project with kt files`() = + runBlocking { + val root = tempFolder.newFolder("KotlinProject") + val srcDir = File(root, "app/src/main/java/com/example") + srcDir.mkdirs() + File(srcDir, "MainActivity.kt").writeText("package com.example\nclass MainActivity") + + val language = readProjectLanguage(root) + assertThat(language).isEqualTo("Kotlin") + } + + @Test + fun `readProjectLanguage identifies Kotlin project with kts files`() = + runBlocking { + val root = tempFolder.newFolder("KotlinScriptProject") + val srcDir = File(root, "app/src/main") + srcDir.mkdirs() + File(srcDir, "build.gradle.kts").writeText( + """ + plugins { + id("com.android.application") + } + """.trimIndent(), + ) + + val language = readProjectLanguage(root) + + assertThat(language).isEqualTo("Kotlin") + } + + @Test + fun `readProjectLanguage returns Unknown when source tree has no supported files`() = + runBlocking { + val root = tempFolder.newFolder("UnsupportedProject") + val srcDir = File(root, "app/src/main/java/com/example") + srcDir.mkdirs() + File(srcDir, "MainActivity.xml").writeText( + """ + + """.trimIndent(), + ) + + val language = readProjectLanguage(root) + + assertThat(language).isEqualTo("Unknown") + } + + @Test + fun `readProjectLanguage returns Unknown for empty source tree`() = + runBlocking { + val root = tempFolder.newFolder("EmptyProject") + File(root, "app/src/main").mkdirs() + + val language = readProjectLanguage(root) + + assertThat(language).isEqualTo("Unknown") + } + + @Test + fun `readKotlinVersion returns Unknown when libs toml has no kotlin version`() = + runBlocking { + val root = tempFolder.newFolder("TomlProject") + val gradleToml = File(root, "gradle") + gradleToml.mkdirs() + File(gradleToml, "libs.versions.toml").writeText( + """ + [versions] + agp = "8.2.0" + appcompat = "1.6.1" + [libraries] + androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } + """.trimIndent(), + ) + + val kotlinVer = readKotlinVersion(root) + assertThat(kotlinVer).isEqualTo("Unknown") + } + + @Test + fun `readKotlinVersion parses kotlin version correctly from libs toml`() = + runBlocking { + val root = tempFolder.newFolder("KotlinTomlProject") + val gradleToml = File(root, "gradle") + gradleToml.mkdirs() + File(gradleToml, "libs.versions.toml").writeText( + """ + [versions] + agp = "8.2.0" + kotlin = "1.9.20" + """.trimIndent(), + ) + + val kotlinVer = readKotlinVersion(root) + assertThat(kotlinVer).isEqualTo("1.9.20") + } + + @Test + fun `readKotlinVersion resolves kotlin version through version ref`() = + runBlocking { + val root = tempFolder.newFolder("KotlinRefProject") + val gradleToml = File(root, "gradle") + gradleToml.mkdirs() + File(gradleToml, "libs.versions.toml").writeText( + """ + [versions] + agp = "8.2.0" + kgp = "1.9.20" + [plugins] + kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kgp" } + """.trimIndent(), + ) + + val kotlinVer = readKotlinVersion(root) + assertThat(kotlinVer).isEqualTo("1.9.20") + } + + @Test + fun `readKotlinVersion ignores shorthand kotlin library coordinates`() = + runBlocking { + val root = tempFolder.newFolder("KotlinShorthandProject") + val gradleToml = File(root, "gradle") + gradleToml.mkdirs() + File(gradleToml, "libs.versions.toml").writeText( + """ + [libraries] + org-jetbrains-kotlin-stdlib = "org.jetbrains.kotlin:kotlin-stdlib:1.9.24" + [versions] + kotlin = "2.0.0" + """.trimIndent(), + ) + + val kotlinVer = readKotlinVersion(root) + assertThat(kotlinVer).isEqualTo("2.0.0") + } +} diff --git a/composite-builds/build-deps-common/constants/src/main/java/org/adfa/constants/constants.kt b/composite-builds/build-deps-common/constants/src/main/java/org/adfa/constants/constants.kt index d7bc7f5694..bbe103cca4 100644 --- a/composite-builds/build-deps-common/constants/src/main/java/org/adfa/constants/constants.kt +++ b/composite-builds/build-deps-common/constants/src/main/java/org/adfa/constants/constants.kt @@ -17,9 +17,9 @@ package org.adfa.constants -const val ANDROID_GRADLE_PLUGIN_VERSION = "8.11.0" -const val GRADLE_DISTRIBUTION_VERSION = "8.14.3" -const val KOTLIN_VERSION = "1.9.22" +const val ANDROID_GRADLE_PLUGIN_VERSION = "9.3.1" +const val GRADLE_DISTRIBUTION_VERSION = "9.6.1" +const val KOTLIN_VERSION = "2.3.21" val TARGET_SDK_VERSION = Sdk.Baklava val COMPILE_SDK_VERSION = Sdk.Baklava @@ -88,3 +88,6 @@ const val GRADLE_API_NAME_JAR_BR = "${GRADLE_API_NAME_JAR}.br" const val TEMPLATE_ARCHIVE_EXTENSION = "cgt" const val TEMPLATE_CORE_ARCHIVE = "core.$TEMPLATE_ARCHIVE_EXTENSION" const val TEMPLATE_CORE_ARCHIVE_BR = "${TEMPLATE_CORE_ARCHIVE}.br" + +// Plugin archive +const val PLUGIN_ARCHIVE_EXTENSION = "cgp" diff --git a/composite-builds/build-logic/common/src/main/java/com/itsaky/androidide/build/config/CI.kt b/composite-builds/build-logic/common/src/main/java/com/itsaky/androidide/build/config/CI.kt index db22c94e6b..2e4949cde0 100644 --- a/composite-builds/build-logic/common/src/main/java/com/itsaky/androidide/build/config/CI.kt +++ b/composite-builds/build-logic/common/src/main/java/com/itsaky/androidide/build/config/CI.kt @@ -29,6 +29,7 @@ import kotlin.getOrDefault object CI { private var commitHash: String? = null private var branchName: String? = null + private var commitEpochSeconds: Long? = null fun commitHash(project: Project): String { if (commitHash == null) { @@ -63,6 +64,42 @@ object CI { return branchName ?: "unknown" } + /** + * Committer timestamp of the commit being built, in epoch seconds. + * + * Version strings derive from this rather than from the wall clock, so rebuilding + * a commit yields the same version instead of one that changes every minute. That + * keeps the generated BuildInfo, and therefore build-info.jar, byte-stable between + * rebuilds. See docs/adr/0012-volatile-build-metadata-out-of-abis.md. + * + * Falls back to the current time if git cannot be read; determinism then no longer + * holds, but the build still succeeds. + * + * This is read during configuration, so it goes through [ProviderFactory.exec] + * rather than a raw ProcessBuilder: the configuration cache cannot track an + * external process started directly from a build script, but it can track this. + */ + fun commitEpochSeconds(project: Project): Long { + if (commitEpochSeconds == null) { + val sha = System.getenv("GITHUB_SHA") ?: "HEAD" + commitEpochSeconds = + runCatching { + project.providers + .exec { spec -> + spec.workingDir(project.rootProject.projectDir) + spec.commandLine("git", "show", "-s", "--format=%ct", sha) + spec.isIgnoreExitValue = true + }.standardOutput.asText + .get() + .trim() + }.getOrNull() + ?.toLongOrNull() + ?: (System.currentTimeMillis() / 1000L) + } + + return commitEpochSeconds ?: (System.currentTimeMillis() / 1000L) + } + /** Whether the current build is a CI build. */ val isCiBuild by lazy { "true" == System.getenv("CI") } diff --git a/composite-builds/build-logic/common/src/main/java/com/itsaky/androidide/build/config/ProjectConfig.kt b/composite-builds/build-logic/common/src/main/java/com/itsaky/androidide/build/config/ProjectConfig.kt index 87ee78efa3..4b6736ff58 100644 --- a/composite-builds/build-logic/common/src/main/java/com/itsaky/androidide/build/config/ProjectConfig.kt +++ b/composite-builds/build-logic/common/src/main/java/com/itsaky/androidide/build/config/ProjectConfig.kt @@ -68,7 +68,20 @@ val Project.simpleVersionName: String } val buildTypeShort = if (buildType == "debug") "d" else "r" - val calendar = java.util.Calendar.getInstance() + // Derived from the commit being built, not the wall clock, so rebuilding a + // commit produces the same version string. With the wall clock, any two builds + // a minute apart produced different values, which changed BuildInfo and so + // build-info.jar on every build. See ADR 0012. + // + // Fixed to UTC deliberately: Calendar.getInstance() uses the JVM default zone, + // which would make the version a function of the builder's timezone as well as + // the commit, so the same commit built in two places would not agree. + val calendar = + java.util.Calendar + .getInstance(java.util.TimeZone.getTimeZone("UTC")) + .apply { + timeInMillis = CI.commitEpochSeconds(project) * 1000L + } val month = calendar.get(java.util.Calendar.MONTH) + 1 val day = calendar.get(java.util.Calendar.DAY_OF_MONTH) val hour = calendar.get(java.util.Calendar.HOUR_OF_DAY) @@ -89,7 +102,12 @@ val Project.simpleVersionName: String val Project.releaseVersion: String get() { - val raw = providers.gradleProperty("next_release_version").orNull.orEmpty().trim() + val raw = + providers + .gradleProperty("next_release_version") + .orNull + .orEmpty() + .trim() if (raw.isNotEmpty() && !Regex("""^\d{2}\.\d{2}$""").matches(raw)) { throw GradleException( "Invalid next_release_version '$raw'; expected YY.ww (two digits, dot, two digits), e.g. 25.47", diff --git a/docs/PLUGIN_API_CHANGELOG.md b/docs/PLUGIN_API_CHANGELOG.md index 9c5677868c..1611f20b16 100644 --- a/docs/PLUGIN_API_CHANGELOG.md +++ b/docs/PLUGIN_API_CHANGELOG.md @@ -26,14 +26,94 @@ Versions are bare `YY.WW` — two-digit ISO year, two-digit ISO week (`26.30` = ## Changelog -Newest first. Every change so far is **additive** — no capability has been -removed or had its signature broken since the plugin system shipped. A future -breaking change belongs here as a `breaking` row. +Newest first. Most changes are **additive**; the ones that are not carry a +`breaking` row saying what breaks and what to do about it. Read the `breaking` +rows at or below your `min_ide_version` before you bump it. -Legend: `added` = new capability, safe to adopt · `tooling` = API-stability +Legend: `added` = new capability, safe to adopt · `breaking` = existing plugins +need a source change, a recompile, or both · `tooling` = API-stability milestone. **[verified]** = read from the checked-in ABI dump. **[reconstructed]** = diffed from `plugin-api/src` history (predates the dump; symbol-accurate). +### 26.33 — 2026-08-12 +- **added — Plugin-contributed agent tools** _(ADFA-2592)_ **[verified]** + Any `.cgp` can add tools to the AI agent, whose tool set was previously fixed at + ai-core compile time. The contract has to live in the host: each plugin is loaded + by its own class loader with the host as parent, so a type packaged in one `.cgp` + is not resolvable from another — and duplicating it into each plugin compiles + cleanly, then fails on device with `ClassCastException`. ai-core implements the + registry and publishes it under `SharedServices`, exactly as it does + `LlmInferenceService`; a provider registers on `activate()` and unregisters on + `deactivate()`. Host runtime behaviour, `PluginManager`, the loader and + `PluginPermission` are unchanged — a provider declares the permissions its own + work needs. + `ToolSourceRegistry` (`registerToolSource`, `unregisterToolSource`, + `getToolSources`, `notifyToolsChanged`, `CONTRACT_VERSION`), + `ToolSourceRegistry.ToolSource` / `.ToolSpec` / `.ToolInvocation` / `.ToolOutcome`. + Values crossing this boundary must be JDK types, and the registry hands each + source a sanitized copy of the argument map rather than its own. + `unregisterToolSource` takes the `ToolSource` instance, not a provider id, so a + reused provider id cannot remove another plugin's source — but the registry is + no trust boundary between plugins: `getToolSources` hands out the registered + instances and registering under a taken id replaces it. `ToolSpec.requiresApproval()` + defaults to **true**, inverted relative to the agent's own tools: those are + contained by its path guard, a contributed tool by nothing. +- **added — Optional LLM backend capabilities** _(ADFA-5095)_ **[verified]** + An LLM backend declares what it supports by the interfaces it implements, so a + backend can ship as its own plugin and implement only what it can do. The + consumer asks with `instanceof` before it calls; a backend that implements none + of these is still a valid `LlmBackend`. + `LlmInferenceService.HistoryCapableBackend` (`generateStreamingWithHistory`), + `ToolCallingBackend` (`generateStreamingWithTools`), + `CancellableBackend` (`cancelStreaming`), + `ConfigurableBackend` (`getSettingsFragmentClassName` — the backend's own + settings `Fragment`, loaded with the backend's classloader). +- **added — Backend-owned prompt and sampling** _(ADFA-5095)_ **[verified]** + A backend supplies the system prompt and temperature its model needs, instead of + the consumer hardcoding them per provider. Both are `default` and return null + for "no preference"; `getDefaultTemperature()` is a boxed `Float`, so null-check + before assigning it to the primitive `LlmConfig.temperature`. + `LlmBackend.getSystemPrompt(SystemPromptRequest)`, + `LlmBackend.getDefaultTemperature()`, `SystemPromptRequest`. +- **breaking — Tool results correlated by call id and tool name** _(ADFA-5095)_ **[verified]** + A tool's output travels back into the next turn as a message of its own, so a + turn's several calls are matched by correlator rather than by position. Both + correlators travel with the result because providers key results differently — + by call id, or by function name — and a backend can only forward what it was + given. + `ChatMessage.toolResult(String, String, String)`, `ChatMessage.toolCallId` / + `toolName`, `ChatMessage.Role.TOOL`. + **What breaks:** `Role` gains a fourth constant, so an exhaustive Kotlin `when` + over it with no `else` stops compiling. A plugin already built against the + three-constant enum has the worse failure: the `when` throws + `NoWhenBranchMatchedException` with a null message, which reads as an + unattributable crash inside the plugin rather than as anything to do with + `Role`. A `TOOL` message reaches a backend that never calls `toolResult` — the + consumer builds it and passes it in the history — so handling it is not + optional for backends. **What to do:** add a `TOOL` branch (routing it as a + user turn is fine for a backend with no native function calling) and republish; + a `.cgp` that is only reinstalled, not rebuilt, stays exposed. +- **added — Preferred backend id** _(ADFA-5095)_ **[verified]** + A backend can ask which backend the user selected, so one that would otherwise + spend seconds and gigabytes preparing itself knows whether it is about to be + used — without reading another plugin's preferences. + `LlmInferenceService.getPreferredBackendId()` (`default`, null when unset). +- **breaking — Nullability annotated across the LLM surface** _(ADFA-5095)_ + Every parameter, return and field on `LlmInferenceService` and the types nested + in it now carries `@NonNull` or `@Nullable`, so the contract is stated rather + than inferred. + **What breaks:** an unannotated Java type reaches Kotlin as a platform type + (`String!`) that dereferences without a check; annotated `@Nullable` it becomes + `String?`, and every existing dereference stops compiling with "only safe (?.) + or non-null asserted (!!.) calls are allowed". This hits **callers**, not just + implementors — `LlmResponse.text` / `.error`, `ToolCallRequest.args` and + `ToolDefinition.parametersSchema` are the ones consumers touch, and + `@NonNull` across `LlmBackend` tightens what an implementor may return. + Bytecode is unchanged, so an installed `.cgp` keeps running; the break is at + compile time in the plugin repo. **What to do:** `?.`, `.orEmpty()` or an + explicit null check at each site — the annotations describe values the API + could already return. + ### 26.31 — 2026-07-29 - **tooling — Plugin API & builder resolvable by Maven coordinate on-device** _(ADFA-4911)_ The plugin API and the builder Gradle plugin are injected into the on-device diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index 7b7d4610fd..74d5adb563 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -155,7 +155,7 @@ use the JSON form (see `PluginManifest.kt`). ## Theme-aware icons The plugin manager renders a different icon based on whether the system -is in light or dark mode (`PluginListAdapter.kt:61`). To opt in, ship +is in light or dark mode (`PluginListItem.kt:69-70`). To opt in, ship two raster icons in your plugin and point at them from the manifest. ### Where the files go @@ -183,7 +183,7 @@ manifest matches the path the loader will find. - **JPEG** **Not supported:** raw SVG, Android vector drawable XML (compiled or -not). Icons are decoded with Glide (`PluginListAdapter.kt:69`), which +not). Icons are decoded with `BitmapFactory` (`FileImage.kt:102`), which handles raster formats only. Convert SVG sources to PNG yourself before bundling. @@ -295,7 +295,7 @@ manifest value (use `assets/icon_day.png`, not `/assets/icon_day.png`). **Wrong icon shows for the current theme** -The selection happens in `PluginListAdapter.kt:61` via +The selection happens in `PluginListItem.kt:69-70` via `isSystemInDarkMode()`. Verify your device is actually in the theme you expect (system Settings → Display). Also verify both files extracted to the device: diff --git a/docs/adr/0001-prefer-room-for-persistence.md b/docs/adr/0001-prefer-room-for-persistence.md index 6a12919bef..0944a429bd 100644 --- a/docs/adr/0001-prefer-room-for-persistence.md +++ b/docs/adr/0001-prefer-room-for-persistence.md @@ -35,9 +35,11 @@ If none of these hold, use Room. "It's a small table" or "I already know SQL" ar | In-app / plugin help | `plugin-manager/.../documentation/PluginDocumentationManager.kt` | Prebuilt help-content DB (condition 1). | | Local web server | `app/.../localWebServer/WebServer.kt` | Reads databases (incl. project data) it doesn't own, read-only (conditions 1 & 3). | +The tooltips and in-app/plugin-help rows, plus the local web server's Tier 3 serving, all read `documentation.db` — see [docs/documentation-database.md](../documentation-database.md) for its schema and consumers. + The **Recent Projects** feature (`app/.../roomData/recentproject/`, `RecentProjectRoomDatabase`, `@Database version = 4`) is **not** an exception — it uses Room and is the reference example of the default. Extend it (and add new persistence) the same way. -> Note: `idetooltips` still declares Room Gradle deps it doesn't use — remove them (its store is raw SQLite by exception 1). And the `androidx.room:*` strings in `editor`'s `GroovyAutoComplete` are autocomplete suggestions for the *user's* code, not CoGo persistence. +> Note: the `androidx.room:*` strings in `editor`'s `GroovyAutoComplete` are autocomplete suggestions for the *user's* code, not CoGo persistence. ## Consequences @@ -51,7 +53,6 @@ The **Recent Projects** feature (`app/.../roomData/recentproject/`, `RecentProje - Contributors must justify raw-SQLite use rather than reach for it by habit. **Follow-ups** -- Remove the unused Room dependencies from `idetooltips`. - Provide shared helper utilities for the raw-SQLite exceptions so they stay consistent and safe (parameterized queries — see SECURITY.md). ## Alternatives considered diff --git a/docs/adr/0010-navigation-resolves-via-analysis-api.md b/docs/adr/0010-navigation-resolves-via-analysis-api.md index c72d3fad1d..78c8bcbafd 100644 --- a/docs/adr/0010-navigation-resolves-via-analysis-api.md +++ b/docs/adr/0010-navigation-resolves-via-analysis-api.md @@ -43,4 +43,6 @@ It cannot. The index stores names, kinds, visibility, and containing-class metad ## Related - [docs/features/kotlin-goto-definition.md](../features/kotlin-goto-definition.md) - the first feature built on this decision +- [docs/features/kotlin-find-usages.md](../features/kotlin-find-usages.md) - the second, which additionally has no reference-search infrastructure to fall back on: the bundled Analysis API ships no `ReferencesSearch`, no `PsiSearchHelper` and no word index +- [ADR 0011](0011-command-analysis-priority.md) - the analysis priority those features run at - [ADR 0001](0001-prefer-room-for-persistence.md) - persistence choices for the indexes this ADR declines to use diff --git a/docs/adr/0011-command-analysis-priority.md b/docs/adr/0011-command-analysis-priority.md new file mode 100644 index 0000000000..5db2e7ed62 --- /dev/null +++ b/docs/adr/0011-command-analysis-priority.md @@ -0,0 +1,65 @@ +# 0011. User-invoked commands get their own analysis priority + +- **Status:** Proposed +- **Date:** 2026-08-03 +- **Deciders:** Code On The Go team + +## Context + +Analysis in the K2 Kotlin LSP is serialised behind one priority lock (`AnalysisScheduler`). Until now it had three tiers: + +| Priority | `supersedesSamePriority` | Preempted work | +|---|---|---| +| `INDEXING` | false | re-queued | +| `DIAGNOSTICS` | false | re-queued | +| `INTERACTIVE` | **true** | **discarded** | + +`INTERACTIVE`'s defining property is *"a newer request of the same priority makes me stale, so discard my work"*. That is exactly right for completion and signature help: they fire on keystrokes, and an in-flight result for text the user has already moved past is worthless. + +It is wrong for a command the user invoked from the code-actions menu. The user tapped a menu item and is watching a progress flashbar; the request is not stale, and discarding it silently produces a wrong answer rather than no answer. Yet three commands sat on `INTERACTIVE`: + +- `GoToDefinitionAction` - discovered the problem and worked around it with a one-shot retry (ADFA-4823). +- `OrganizeImportsAction` - no retry. A completion request discards it and it silently does nothing. +- `ImplementMembersAction` - same. + +Find usages (ADFA-4824) makes this acute. It is user-invoked, runs one analysis session per candidate file, and can take seconds across a workspace. On `INTERACTIVE` a single keystroke anywhere would discard an in-flight file's work, and two concurrent searches would discard each other. + +## Decision + +**Add a fourth priority, `COMMAND`, for user-invoked commands, ordered between `DIAGNOSTICS` and `INTERACTIVE`, with `supersedesSamePriority = false`.** + +```text +INDEXING < DIAGNOSTICS < COMMAND < INTERACTIVE +``` + +- Every user-invoked command runs at `COMMAND`: find usages, go-to-definition, organize imports, implement members. +- `supersedesSamePriority = false`, so **two commands never discard each other**; the second waits for the lock. +- Keystroke-driven features (completion, signature help) stay on `INTERACTIVE` and therefore still win against a command. +- A command preempted by `INTERACTIVE` retries. Long-running commands take their session **per unit of work** - for find usages, per candidate file - so a preemption costs one file, not the whole request. + +## Consequences + +**Positive** + +- The silent-failure bug in organize-imports and implement-members is fixed, not just in the one action that happened to notice it. +- Commands stop competing destructively with each other, which is what makes a multi-file search viable at all. +- Typing responsiveness is untouched. On a phone, completion is part of how text gets entered; starving it is the one regression a user would feel immediately. +- The priority now says what it means. `INTERACTIVE` is "stale on newer input"; `COMMAND` is "explicitly requested, must finish or be cancelled". + +**Negative / costs** + +- Commands still need a retry policy, because `INTERACTIVE` outranks them. The retry is one line at each call site and already proven in `findDefinitionAt`, but it is a rule every future command has to remember. +- Four tiers instead of three is more scheduler surface to reason about. +- Background diagnostics now lose to any command, so a long search delays diagnostics for its duration. Acceptable: diagnostics are re-queued, never discarded. + +## Alternatives considered + +- **`COMMAND` above `INTERACTIVE`** - rejected, though tempting. Nothing could preempt a command, so retries would disappear everywhere and the two buggy actions would be fixed for free. But a multi-second search would then starve the completion popup for its whole duration, and releasing the lock between files would not help - the command wins it straight back. Fixing *that* means teaching the scheduler to yield to waiting requesters between chunks, which is new machinery for a case only find usages hits. +- **Keep commands on `INTERACTIVE` and add a retry to each** - rejected: it leaves `supersedesSamePriority = true` applying to requests that are never stale, so two commands still discard each other, and every command pays for a property none of them want. +- **Flip `INTERACTIVE.supersedesSamePriority` to false** - rejected: completion genuinely needs discard-on-newer. Rapid typing would otherwise queue a chain of results for text the user has already left. + +## Related + +- [ADR 0010](0010-navigation-resolves-via-analysis-api.md) - Kotlin navigation resolves via the Analysis API, not the symbol index +- [docs/features/kotlin-find-usages.md](../features/kotlin-find-usages.md) - the feature that forced the distinction +- `lsp/kotlin/.../compiler/modules/AnalysisScheduler.kt` - the scheduler and priority enum diff --git a/docs/adr/0012-volatile-build-metadata-out-of-abis.md b/docs/adr/0012-volatile-build-metadata-out-of-abis.md new file mode 100644 index 0000000000..8853045cee --- /dev/null +++ b/docs/adr/0012-volatile-build-metadata-out-of-abis.md @@ -0,0 +1,126 @@ +# 0012. Keep volatile build metadata out of module ABIs + +- **Status:** Proposed +- **Date:** 2026-08-13 +- **Deciders:** Code On The Go team + +## Context + +`:build-info` generates `BuildInfo.java` from a template and sits at the root of the +dependency graph. Five of its generated fields change from build to build: + +```java +VERSION_NAME_SIMPLE = "C-d-0810-1555" // wall-clock time, to the minute +VERSION_NAME_PUBLISHING = "C-d-0810-1555-98ea6f6a4-SNAPSHOT" // time + commit hash +VERSION_NAME_DOWNLOAD = "C-d-0810-1555-98ea6f6a4-SNAPSHOT" // time + commit hash +CI_GIT_BRANCH = "ci-bench" +CI_GIT_COMMIT_HASH = "98ea6f6a4" +``` + +All are `public static final String`. Java and Kotlin inline compile-time constants +into every consumer, so a constant's *value* belongs to the declaring module's ABI. +Every build therefore changed `:build-info`'s ABI and forced the whole project to +recompile. + +Three of the five derive from the current time (`simpleVersionName` in +`ProjectConfig.kt` formats `C-{d|r}-MMDD-HHMM`), so this fires on **any two builds a +minute apart, even of an identical commit**. That is strictly worse than the commit +hash, and it is why the problem reproduces off CI. + +Measured locally with a scripted no-change scenario - no source edit whatsoever, only +a different `GITHUB_SHA`: + +``` +30 compileV8DebugKotlin <- every Kotlin module in the project +12 kaptGenerateStubsV8DebugKotlin +11 kaptV8DebugKotlin +``` + +The same signature appears on CI (30 executed `compileV8DebugKotlin`). A build in +which nothing changed recompiles the entire tree. + +The churn also propagates a second time. `common/.../BuildInfoUtils.kt` declares: + +```kotlin +const val BASIC_INFO = "${BuildInfo.INTERNAL_NAME} (${BuildInfo.VERSION_NAME_SIMPLE})" +``` + +A Kotlin `const val` is inlined too, so `:common`'s ABI churns as well and everything +depending on `:common` recompiles from there. + +## Decision + +Generate the volatile fields with **non-constant initialisers**, so `javac` emits no +`ConstantValue` attribute and the values leave the ABI entirely: + +```java +public static final String VERSION_NAME_SIMPLE = volatileValue("@@VERSION_NAME_SIMPLE@@"); +``` + +The rule this encodes: **a value that changes between builds must never be a +compile-time constant.** Where it is declared matters less than whether it is +inlinable. + +`:common`'s `BASIC_INFO` becomes a non-`const` `val`. This is not optional - a Kotlin +`const val` requires a compile-time constant initialiser, so it stops compiling until +corrected. + +Stable fields (package name, repo coordinates, AGP versions, F-Droid flags) keep their +constant form. + +Two related changes follow from the same invariant: + +- `simpleVersionName` derives its timestamp from the commit being built rather than + the wall clock, so the generated source is a function of the commit. Format and + ordering are unchanged, so nothing product-visible moves. The calendar is fixed to + UTC, otherwise the version would be a function of the builder's timezone too. +- `:build-info`'s Jar sets `preserveFileTimestamps = false` and + `reproducibleFileOrder = true`. This is not optional in practice: with the timestamp + fixed, `BuildInfo.java` became byte-identical between rebuilds while the *jar* still + changed, because Gradle embeds per-entry timestamps by default. kapt tracks that jar + through an input property named `internalNonAbiClasspath` - jar bytes rather than the + ABI - so the ABI fix above cannot reach it and only a reproducible jar can. + +## Consequences + +**Positive** +- A commit, or the clock advancing, no longer changes any module's ABI. Recompilation + is confined to modules whose sources actually changed: 1 Kotlin module for a no-op or + a leaf edit, 10 for a three-module edit containing one real ABI change. +- Gradle's build cache and up-to-date checks become effective for the first time. +- The invariant is enforced by the compiler rather than by convention: reintroducing a + `const val` over a volatile value fails the build. + +**Negative / costs** +- `BuildInfo`'s volatile fields can no longer be used where Java or Kotlin requires a + compile-time constant (annotation arguments, `when` branch constants). None of the + current call sites need that. +- The `volatileValue()` indirection is unusual and invites "simplification" back into a + plain constant. The generated file carries a comment saying why. +- Values move from being inlined at each call site to a single static read. The runtime + cost is immaterial; the behaviour is unchanged. +- kapt still re-runs across its 11 modules: it resolves the full compile classpath + rather than the ABI-normalised one. Tracked by ADFA-4598 (kapt to KSP). + +## Alternatives considered + +- **Move the fields into `:app`'s `BuildConfig`.** Considered first and rejected on + evidence: `:common` and `:editor` consume `VERSION_NAME_SIMPLE`, and neither can + depend on `:app`. It would have addressed only the two `CI_GIT_*` fields and left the + dominant, time-based churn untouched. +- **A separate `:build-info-git` leaf module.** Same defect - it isolates the git + fields but not the version fields that library modules genuinely need. +- **Drop the timestamp from `simpleVersionName`.** Attacks the root cause rather than + the propagation, and would help independently. Rejected *for this ADR* because the + version string is product-visible (Firebase release notes, tester-facing builds, + Jira), so it is a product decision rather than a build one. Worth revisiting. +- **Leave it and rely on the remote build cache.** Does not help: the compile tasks + miss the cache precisely because their compile classpath genuinely changed. + +## Related + +- [0005](0005-per-abi-product-flavors.md) - the flavor dimension that multiplies every + build task, and so multiplies the cost of this churn. +- [Build and CI glossary](../process/build-ci-glossary.md) - *ABI change*, *ABI churn*, + *build graph health*. +- ADFA-5126 - the ticket, with the full before/after measurements. diff --git a/docs/adr/0013-refactoring-ui-lives-in-the-owning-lsp-module.md b/docs/adr/0013-refactoring-ui-lives-in-the-owning-lsp-module.md new file mode 100644 index 0000000000..92a2a0b16b --- /dev/null +++ b/docs/adr/0013-refactoring-ui-lives-in-the-owning-lsp-module.md @@ -0,0 +1,52 @@ +# 0012. Refactoring UI lives in the owning LSP module + +- **Status:** Proposed +- **Date:** 2026-08-03 +- **Deciders:** Code On The Go team + +## Context + +The K2 Kotlin LSP is gaining interactive refactorings: extract variable and extract method (ADFA-4826), inline variable (ADFA-4827), semantic rename (ADFA-4825). Unlike every existing Kotlin code action, these cannot be a single fire-and-forget edit — the user has to choose an expression, a name, a target scope, and whether to replace other occurrences. That is a real UI surface, not a `DialogUtils` one-liner. + +[ADR 0009](0009-jetpack-compose-for-new-ui.md) settles *what* that UI is built with (Compose, UDF, `ViewModel` + `StateFlow`). It says nothing about *where* language-specific UI lives, and the module graph makes that a genuine question: + +- `editor` depends on `lsp/kotlin` (`editor/build.gradle.kts`), so the dependency flows **LSP -> editor**. An LSP module cannot reach the editor or `app`. +- `ActionData` carries only a `Context` and the editor; there is no service-lookup mechanism for an LSP module to call *up* into a UI layer. +- `lsp/java` already owns UI code today — `AutoFixImportsAction` builds and shows a `DialogUtils` chooser directly. + +So a refactoring in `lsp/kotlin` either renders its own UI, or a new inversion mechanism has to be invented for it. + +## Decision + +**A language server module owns the UI for its own refactorings.** `lsp/kotlin` enables Compose and hosts the refactoring bottom sheets; the same applies to any future `lsp/*` module that grows an interactive refactoring. + +- Compose is enabled per-module exactly as `flamegraph`, `floating-window` and `profiler` do it: the `kotlin-compose` plugin, `compose = true`, and the Compose BOM with `ui`/`foundation`/`material3`. +- The UI is a `BottomSheetDialogFragment` hosting a `ComposeView`. The hosting `FragmentActivity` is found by walking `ContextWrapper.baseContext` up from `ActionData`'s `Context` — no new `ActionData` key, no change to the `editor` module. +- **The analysis/UI split is enforced by data, not by module boundaries.** The action's background pass produces a plain-data plan (candidate expressions, scope chains, occurrence ranges, suggested name, document version); the sheet performs no analysis and holds no PSI. All refactoring logic lives in pure functions, unit-testable without an editor, an activity, or Compose. +- ADR 0009 otherwise applies unchanged: `ViewModel` + `StateFlow`, sealed `UiEvent`, `collectAsStateWithLifecycle()`. + +## Consequences + +**Positive** +- No new indirection: one module, one PR per refactoring, no interface to register or resolve. +- Consistent with `lsp/java` already owning its dialogs, so there is one rule for LSP-owned UI rather than two. +- The plain-data plan boundary keeps the valuable logic testable regardless of where the UI sits, so the placement decision does not compromise test coverage. + +**Negative / costs** +- A language server module gains a UI surface, which is a layering smell: `lsp/kotlin` is no longer purely a language service. +- Compose and `lifecycle-viewmodel` are added to a module that previously had neither, growing its build surface and bringing ktlint's compose-rules ruleset to bear on it. +- Walking the `ContextWrapper` chain for a `FragmentActivity` is an implicit dependency on how the editor is hosted; a future change to that hosting breaks it at runtime rather than at compile time. +- If three or more `lsp/*` modules end up with Compose UI, extracting a shared UI module becomes worthwhile and this decision will need revisiting. + +## Alternatives considered + +- **Render in `editor`, invert via an interface.** Declare a refactoring-UI interface in `editorApi` or `lsp/models`, implement it in `editor`, have `lsp/kotlin` call up through it. Cleanest layering. Rejected: nothing registers such an implementation today, so it means inventing a service-lookup mechanism for one sheet, and the interface would be guessed from a single client. +- **Render in `app`.** `app` is the integration point and already hosts `BottomSheetDialogFragment`s and `ILanguageClient`. Rejected: same inversion problem, and it puts Kotlin-specific refactoring UI in the module where nothing else language-specific lives. +- **A new `lsp/kotlin-ui` module.** Keeps Compose out of `lsp/kotlin` without inverting. Rejected for now: a new Gradle module in a ~80-module build is disproportionate for one sheet. Reconsider once extract-method and inline-variable have landed and the UI surface is known. + +## Related + +- [ADR 0009](0009-jetpack-compose-for-new-ui.md) — Compose for new UI; this ADR answers *where*, not *what*. +- [ADR 0006](0006-koin-dependency-injection.md) — Koin DI, unchanged. +- [ADR 0010](0010-navigation-resolves-via-analysis-api.md) — the K2 Analysis API as the Kotlin semantic source of truth. +- [ARCHITECTURE.md](../../ARCHITECTURE.md) — module map, layering, UDF. diff --git a/docs/adr/0014-refactorings-decline-rather-than-rewrite.md b/docs/adr/0014-refactorings-decline-rather-than-rewrite.md new file mode 100644 index 0000000000..d4b8898a04 --- /dev/null +++ b/docs/adr/0014-refactorings-decline-rather-than-rewrite.md @@ -0,0 +1,63 @@ +# 0013. Interactive refactorings decline rather than rewrite unselected code + +- **Status:** Proposed +- **Date:** 2026-08-10 +- **Deciders:** Code On The Go team + +## Context + +The K2 Kotlin LSP is growing a family of interactive refactorings: extract variable (ADFA-4826), extract method (ADFA-5080), inline variable (ADFA-4827), semantic rename (ADFA-4825). [ADR 0012](0012-refactoring-ui-lives-in-the-owning-lsp-module.md) settles where their UI lives and that analysis produces plain data. It says nothing about how capable they should be. + +That question turns out to dominate the requirements. Designing extract method surfaced a run of cases where the transformation the user asked for cannot be performed by *moving* their code - it also needs the moved code's interior edited, or a guess about intent: + +- A `var` declared outside the selection and reassigned inside it. Kotlin has no `out` parameters, so the faithful emission is a parameter plus `var x = x` at the top of the body - which compiles, with a name-shadowing warning. +- Two or more values flowing out of the selection. There is no tuple to return that the user would have written themselves. +- A `return` in the middle of the selection. Real IDEs encode the exit in a nullable or sentinel return and re-test it at the call site. +- Members of an enclosing `with`/`apply`/`run` receiver used unqualified. They can only survive as a parameter if every unqualified access inside the body is qualified. +- A type parameter declared on the enclosing function. It needs a filtered copy of the type-parameter list with its bounds. + +Desktop IDEs handle most of these, and their users accept the result because they can read a multi-file diff, undo granularly, and fix up whatever the refactoring got slightly wrong. Code On The Go's users are on a phone: a small screen, no side-by-side diff, imprecise touch selection, and - per ADFA-5081 - a code-action edit history that is not even reliably one undo step yet. Many are also students, for whom generated code carrying a fresh compiler warning is indistinguishable from a broken tool. + +## Decision + +**An interactive refactoring moves the user's code. It does not edit the interior of what it moved, and where it cannot transform faithfully it declines with a specific, actionable reason.** + +Concretely: + +- **Refusal is a designed outcome, not an error.** Each refactoring's plan carries a typed reason (extract method: `ExtractionRefusal`), and each reason has its own user-facing message naming the construct in the way - "the selection assigns to `total`, which is declared outside it", not "cannot extract". +- **Prefer excluding a case by construction over filtering it later.** Extract method accepts only sibling statements in one block; extract variable rejects bare literals and expression fragments up front. Both remove whole classes of hard case before any analysis runs. +- **Prefer a stricter rule to a cleverer one** when strictness costs capability and cleverness costs certainty. Extract method refuses a reassigned outer `var` even when the write is provably dead, because proving it needs liveness analysis. +- **Never emit code that does not compile, and avoid emitting code that warns.** The two modifiers extract method *does* add - `suspend` and `@Composable` - are required precisely because omitting them breaks compilation. +- **A refusal is a backlog item, not a dead end.** Where the refused case is common, file it: ADFA-5082 tracks the reassigned-`var` output. + +This applies to the whole refactoring family, not just extract method. Inline variable and rename inherit it. + +## Consequences + +**Positive** + +- Every applied refactoring produces code the user could have written, so the feature earns trust on a device where verifying the result is expensive. +- Refusal reasons are cheap to specify, cheap to test (one case each) and cheap to QA, where a clever transformation needs its own test matrix and its own failure modes. +- The rules are stateable in a sentence each, which is what makes the feature docs reviewable by someone who has not read the implementation. +- Excluding cases by construction keeps the analysis pass small, which matters when it runs on a phone. + +**Negative / costs** + +- The refactorings are visibly less capable than a desktop IDE's. Two of extract method's refusals - a reassigned outer `var` (the accumulator loop) and an enclosing `with`/`apply` receiver (pervasive in Android code) - will be hit routinely. +- The quality of the *messages* becomes load-bearing. A generic refusal reads as a broken feature, so this decision spends translated strings: roughly seven for extract method alone. +- Users arriving from IntelliJ will read some refusals as regressions rather than as design. +- The line is a judgement, not a formalism. "Editing the interior of the moved code" is clear in the cases above but will need re-application, case by case, in each future refactoring. + +## Alternatives considered + +- **Match desktop IDE capability.** Handle multiple outputs, mid-selection returns, receiver capture and type parameters, as IntelliJ does. Rejected: each requires rewriting the body's interior or inventing a signature the user did not ask for, and the cost of getting it subtly wrong is paid on a device where the user can least easily see it. +- **Transform, but warn.** Apply the refactoring and flash a caveat ("check the result"). Rejected: it puts the verification burden on the person least equipped to do it, and a warning shown once is gone before the user reads the code. +- **Transform behind a setting**, off by default. Rejected: it doubles the behaviour to test and support for a feature whose hard cases are exactly the ones a setting's users would hit first. Revisit only if specific refusals prove to be common complaints - which is what ADFA-5082 exists to measure. +- **One generic refusal message.** Cheapest, and consistent with extract variable's single "nothing to extract". Rejected as a direct consequence of this decision: if declining is the primary answer in hard cases, the decline has to teach. + +## Related + +- [ADR 0012](0012-refactoring-ui-lives-in-the-owning-lsp-module.md) - where refactoring UI lives; this ADR answers *how capable it is* +- [ADR 0010](0010-navigation-resolves-via-analysis-api.md) - the K2 Analysis API as the Kotlin semantic source of truth +- [kotlin-extract-method.md](../features/kotlin-extract-method.md) - R7 to R10 and R14 are this decision applied case by case +- [kotlin-extract-variable.md](../features/kotlin-extract-variable.md) - the shared vocabulary and primitives diff --git a/docs/adr/README.md b/docs/adr/README.md index 9bb6db0c4a..5b7b7fa226 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -24,3 +24,7 @@ Format is lightweight **MADR / Nygard**: Context → Decision → Consequences | [0008](0008-retain-androidide-namespace.md) | Retain the `com.itsaky.androidide` namespace after rebrand | Proposed | | [0009](0009-jetpack-compose-for-new-ui.md) | Build new UI in Jetpack Compose, not XML Views | Proposed | | [0010](0010-navigation-resolves-via-analysis-api.md) | Kotlin navigation resolves via the Analysis API, not the symbol index | Proposed | +| [0011](0011-command-analysis-priority.md) | User-invoked commands get their own analysis priority | Proposed | +| [0012](0012-volatile-build-metadata-out-of-abis.md) | Keep volatile build metadata out of module ABIs | Proposed | +| [0013](0012-refactoring-ui-lives-in-the-owning-lsp-module.md) | Refactoring UI lives in the owning LSP module | Proposed | +| [0014](0013-refactorings-decline-rather-than-rewrite.md) | Interactive refactorings decline rather than rewrite unselected code | Proposed | diff --git a/docs/documentation-database.md b/docs/documentation-database.md new file mode 100644 index 0000000000..3055c955f0 --- /dev/null +++ b/docs/documentation-database.md @@ -0,0 +1,109 @@ +# Documentation Database + +Reference for `documentation.db`, the SQLite database backing all in-app help: Tier 1/2 tooltips, plus the Tier 3 web content they link to, served by `WebServer`. Read this before touching anything under `localWebServer/`, `idetooltips/`, or `plugin-manager/.../documentation/`, or before writing/editing SQL against this database. + +This is a **read-only, prebuilt** database — CoGo never creates or migrates its schema at runtime (see [ADR 0001](adr/0001-prefer-room-for-persistence.md), exception 1). The schema is owned by the separate `OfflineDocumentationTools` project (the `docdb-studio` tool); **never change it from this repo.** + +## Where it lives + +- Installed path: `context.getDatabasePath("documentation.db")` (`Environment.DOC_DB` in `common/.../utils/Environment.java`), i.e. the app's private `databases/` dir. +- Bundled as an asset and extracted on install/update by `BundledAssetsInstaller` / `SplitAssetsInstaller`. +- **Debug override:** if `/sdcard/Download/documentation.db` exists and is newer than the installed copy, `WebServer` and `ToolTipManager` swap to it at request time (timestamp-compared per request, not just at startup) — a fast way to test a new database on-device without reinstalling. **The comparison is on modification time, and `adb push` preserves the *source* file's mtime** -- so pushing a database saved earlier than the one already on the device silently does not swap, and the app keeps serving the old one with no error anywhere. Follow a push with `adb shell touch /sdcard/Download/documentation.db` (this cost real debugging time on ADFA-5153). `WebServer`'s debug logging and experiment flags are also file-flag-gated under `/sdcard/Download/` (`CodeOnTheGo.webserver.debug`, `CodeOnTheGo.exp`, `CodeOnTheGo.webserver.cs0`). +- **Don't trust a local copy's on-disk schema or row content as ground truth without checking freshness first.** Any manually downloaded or debug-override copy is independent of git history — a stale one can have a different schema (e.g. missing `UNIQUE(path)` or `templateId`) or be missing rows that already exist in the current, maintained database. A stale copy caused a real near-miss in ADFA-5088: a SQL script validated against it would have silently overwritten curated production tooltip content for several tags. Diff or re-download before authoring SQL against a local copy's state, not just before shipping it. + +## Schema + +A **star schema**: a large fact table at the center, small dimension tables around it. There are two fact tables — `Content` (Tier 3 web content) and `Tooltips` (Tier 1/2 tooltips) — because they serve different lookup patterns. + +### Tier 3: `Content` (the fact table) + +```sql +CREATE TABLE Content ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL, + languageID INTEGER NOT NULL, + content BLOB NOT NULL, + contentTypeID INTEGER NOT NULL, + templateId INTEGER, + FOREIGN KEY (languageID) REFERENCES Languages(id), + FOREIGN KEY (contentTypeID) REFERENCES ContentTypes(id), + UNIQUE(path) +); +``` + +One row per file the web server can serve (HTML, CSS, JS, image, video, PDF, ...) — 30,000+ rows. Key points: + +- **`path`** is the lookup key (indexed via the `UNIQUE` constraint) and is what `WebServer` matches the HTTP request path against. Paths carry a short source prefix to avoid collisions between doc sets, e.g. `k/index.html` (Kotlin) vs `j/index.html` (Java). +- **`content`** is compressed — Brotli for text-like formats, format-specific compression otherwise (images/video/fonts). `ContentTypes.compression` says which. Every migrated `Content` row with `ContentTypes.compression = 'brotli'` is Brotli-compressed against the single shared dictionary in `CompressionDictionary` (see below), converted in one pass by ADFA-5153 — but plugin-contributed Tier 3 rows (`PluginDocumentationManager`/`BrotliCompressor`, see below) are plain, dictionary-free Brotli, and there is no per-row flag distinguishing the two, because a dictionary-compressed stream and a plain one are not distinguishable at decode time by inspection. They *are* distinguishable by attempting the decode: attaching the *wrong* dictionary decodes without error to different bytes than were compressed (its backward distances resolve into real, just incorrect, bytes) — but attaching *no* dictionary to a stream that needs one reliably throws (`IOException`, "corrupted input"), since distances into the dictionary region are then out of bounds for any spec-compliant decoder. `WebServer` relies on exactly this: it tries the dictionary first and falls back to a plain decode on `IOException`, which correctly handles both dictionary-compressed and plain rows — but never rely on decode success/failure to detect a *wrong* dictionary, since that case is silent. Content over 1 MB is split across multiple rows: the first row's path is the base path, continuation rows are `path-1`, `path-2`, ... (`languageId = 1`), reassembled by `WebServer` before returning. +- **`templateId`**: `0` (or unset) means `content` is legacy HTML with presentation baked in (the pre-CMS Release 0/1 format). A positive value means `content` is JSON *facts only*, rendered through the matching row in `Templates` (a Pebble template) — the ongoing move to a proper CMS that de-duplicates presentation across near-identical pages (e.g. `sin`/`cos` docs). +- The `UNIQUE(path)` constraint rejects any duplicate `path`, regardless of `languageID` — a second language for an existing path isn't supported yet (only `EN-us` currently exists). Getting there needs an upstream schema change to composite uniqueness on `(path, languageID)` (see *Known rough edges* below). + +Dimensions: `Languages(id, value)` (4-letter codes, e.g. `EN-us`); `ContentTypes(id, value, compression)` (MIME type + compression scheme, ~30 rows). + +### Tier 1/2: `Tooltips` (the other fact table) + +```sql +CREATE TABLE Tooltips ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + categoryId INTEGER NOT NULL, + tag TEXT NOT NULL, + summary TEXT NOT NULL, + detail TEXT NOT NULL, + UNIQUE(categoryId, tag), + FOREIGN KEY(categoryId) REFERENCES TooltipCategories(id) +); +``` + +- `summary` is Tier 1 (the initial popup), `detail` is Tier 2 (after "See more"); both may contain HTML. +- Looked up by `(categoryId, tag)`, which the IDE stamps on the UI widget that owns the tooltip. The `UNIQUE` constraint gives this lookup an index, so it's fast. +- `TooltipCategories(id, category)` is a tiny dimension table (four categories at the time of writing). +- `TooltipButtons(tooltipId, buttonNumberId, description, uri)` holds the Tier 3 links shown at the bottom of a Tier 2 tooltip — `tooltipId` -> `Tooltips.id`, `buttonNumberId` -> `TooltipButtonNumbers.id`. `uri` should resolve to a `Content.path` (after stripping `?query`/`#fragment`). +- `TooltipButtonNumbers(id)` exists only to pin a fixed, manually-assigned display order when a tooltip has multiple Tier 3 links. (Flagged in the source design doc as something worth redoing without a whole extra table.) + +### Supporting tables + +- **`DocumentationDatabaseVersion(major, minor, patch, who, comment, changeTime)`** — the database's own semver (ADFA-5220), replacing the heuristics that used to infer the format from which tables happened to exist. Append-only: each change is another `INSERT`, so the **row inserted last** is the current version, not the highest one ever recorded — a rebuild from an older content set is a downgrade and has to read as one (`DatabaseVersionResolver.resolveMajorVersion`, which returns null for a database predating the table). `MAJOR >= 2` is what tells the app its brotli `Content` rows are dictionary-compressed; below that, `WebServer` neither reads nor attaches `CompressionDictionary`. Gating on the declared version rather than on the table's presence matters in both directions: a database can carry the dictionary table while its content is still plain brotli (every row would then pay a failed dictionary decode before its plain one, on every request), and a migrated database that lost the table fails loudly instead of quietly. +- **`CompressionDictionary(id, data)`** — single-row table (`id INTEGER PRIMARY KEY CHECK (id = 1)`) holding the raw Brotli dictionary every ADFA-5153-migrated `compression = 'brotli'` `Content` row is compressed against. Trained once, from a representative sample across the whole `Content` table, by `OfflineDocumentationTools`' `migrate_content_to_dictionary_brotli.py` / `populate_db.py` (never retrained after that — a dictionary-compressed row is only decodable against the exact dictionary it was compressed with, so replacing it would silently orphan every already-migrated row). Shipping the dictionary inside `documentation.db` itself, rather than as a separate bundled asset, keeps it version-locked to the content compressed against it. `WebServer` loads it lazily -- not merely from starting the server or swapping databases, but on the first content fetch that needs it after `database` changes, and only when `DocumentationDatabaseVersion` declares `MAJOR >= 2` (see above) -- and caches it from then on, reloading again only on the next database change (a swap can bring in a database with a different dictionary or none, so it can't stay cached across one). Per row, it tries decoding with the dictionary attached first via brotli4j's `attachDictionary`, falling back to a plain decode on failure — needed both for a database predating this migration (no `CompressionDictionary` table at all) and for plugin-contributed rows within an otherwise-migrated database (see `PluginDocumentationManager` below). +- **`Templates(id, name, content)`** — Pebble template source, keyed by id (and by `name` for well-known templates like `bookshelf`). Referenced by `Content.templateId`. +- **`Bookshelf(contentID, bookCategoryID, title, description)`** / **`BookCategories(id, category, description)`** — the Dynamic Bookshelf: one row per "book" (PDF or similar), linked to its Tier 3 page via `contentID` -> `Content.id`. Two DB triggers keep `Bookshelf` in sync when a PDF row is inserted/deleted from `Content`; `title`/`description` don't come from those triggers and must be set by hand. Non-PDF books need a separate ingestion path (plugin-provided, e.g. via `PluginDocumentationManager`). +- **`LastChange(documentationSet, changeTime, who)`** — audit trail for edits made through `docdb-studio`; not shown to end users. `DatabaseVersionResolver` reads the `documentationSet = 'wholedb'` row to report the DB's build/edit stamp in debug logging, falling back to the most recent row of any set if `'wholedb'` is missing. +- Misc `ide_tooltip_table` and `PUCC` tables are historical/example artifacts — not part of the live lookup paths above. + +## How CoGo talks to this database + +All three sites below open the file with `SQLiteDatabase.openDatabase(..., OPEN_READONLY)` — no writes, ever, from this app (see ADR 0001 for why raw SQLite is justified here instead of Room). + +- **`app/.../localWebServer/WebServer.kt`** — serves Tier 3. On each `GET`, runs: + + ```sql + SELECT C.content, CT.value, CT.compression, C.templateId + FROM Content C, ContentTypes CT + WHERE C.contentTypeID = CT.id + AND C.path = ? + ``` + + then reassembles chunked blobs, always decompresses Brotli content (attaching `CompressionDictionary`'s bytes first, if loaded — see above) since this server never negotiates `Content-Encoding` with the client, and instantiates the template if `templateId > 0`. Also serves a Dynamic Bookshelf JSON payload (joining `Content`/`Bookshelf`/`BookCategories`, rendered through the `bookshelf` template) and debug-only HTML dumps at `/pr/db` (`LastChange`, last 20 rows) and `/pr/pr` (recent projects, from a *different* database). +- **`idetooltips/.../ToolTipManager.kt`** — serves Tier 1/2. Looks up `Tooltips` joined to `TooltipCategories` by `(category, tag)`, then `TooltipButtons` for the Tier 3 links shown at the bottom. +- **`plugin-manager/.../documentation/PluginDocumentationManager.kt`** (with `Tier3AssetWalker.kt`, and the `DocumentationExtension` contract in `plugin-api`) — lets plugins contribute their own help content into the same lookup paths. + +## Editing the database + +Schema changes and data edits happen **outside this repo**, in `OfflineDocumentationTools/docdb-studio` (a Flet GUI over this same `documentation.db`). Conventions enforced there that matter if you're reasoning about data correctness here: + +- The schema is locked — `docdb-studio`'s own `AGENTS.md` says never change it. If a new column/table is genuinely needed, it's a cross-repo change coordinated with that project, not something to route around in CoGo. +- Tooltip uniqueness is `(categoryId, tag)`; `TooltipButtons.uri` values are validated there against `Content.path` (post `?query`/`#fragment` stripping) before being allowed into the database. +- Every edit made through the tool updates `LastChange` for the affected documentation set, which is how `DatabaseVersionResolver`'s debug logging can say what build of the docs is loaded. + +### Writing one-off SQL scripts against this database + +Some tickets (e.g. ADFA-5088) ship a one-off `.sql` script under `docs/docdb/` for a `docdb-studio` maintainer to run against the real database, rather than editing it directly through the tool. Gotchas found writing those scripts: + +- **Keep each `.system` line simple.** The sqlite3 CLI's `.system` dot-command can hit a content-dependent shell-parsing failure when a line chains multiple operators (`;`, `&&`, `||`, parentheses) — it reproduces for some input strings and not others, so it won't necessarily show up in a quick test. Stick to one plain `command | pipe > file` per `.system` line. +- **`.bail on` is required for `BEGIN`/`COMMIT` to actually mean atomic.** Without it, a mid-script SQL error prints to stderr but the script *keeps going* — including reaching the final `COMMIT`, which then persists whatever succeeded before the error (verified empirically, not just documented behavior). `.bail` also can't see `.system` shell failures directly, so a failed or empty Brotli payload (which leaves its target file missing or zero-length) needs its own check: insert its `READFILE()` into a throwaway `CREATE TEMP TABLE` guarded by `NOT NULL CHECK (length(content) > 0)` immediately before the real `Content` insert, turning that failure into a real SQL error `.bail` will catch. See `docs/docdb/ADFA-5088-preference-tooltips.sql` for the working pattern. +- **Don't write Brotli payloads to bare `/tmp/*.br` filenames.** A fixed, guessable name directly under world-writable `/tmp` lets another local user pre-plant a symlink or race the write/read pair between the `.system echo | brotli` write and the `READFILE()` read (CWE-377). Create an owner-only working directory instead — `rm -rf` it, then `mkdir -m 700` it (the mode is set atomically at creation, with no window where it's briefly world-accessible) — write every payload under that directory, and remove it again before `COMMIT`. See the same script for the working pattern. + +## Known rough edges + +- A Tier 3 link that points off-device is a bug in the *content*, not the code — the web server and webview will happily follow it. If you see one while working in this area, it's a data problem to report upstream, not a `WebServer` bug to fix here. +- `Content`'s `UNIQUE(path)` constraint (rather than `UNIQUE(path, languageID)`) means a second language for an existing path can't currently be added without a schema change upstream — multi-language content isn't fully wired yet even though the `Languages` dimension anticipates it. +- `TooltipButtonNumbers` is a whole table whose only job is pinning a manual sort order; a lighter-weight mechanism (e.g. an ordering column directly on `TooltipButtons`) would remove a table. diff --git a/docs/features/kotlin-extract-method.md b/docs/features/kotlin-extract-method.md new file mode 100644 index 0000000000..f6e23ad819 --- /dev/null +++ b/docs/features/kotlin-extract-method.md @@ -0,0 +1,295 @@ +# Kotlin extract method (K2 LSP) + +- **Ticket:** ADFA-5080 (subtask of ADFA-3317; split out of ADFA-4826, which now covers extract variable only) +- **Status:** Implemented +- **Module:** `lsp/kotlin` +- **Vocabulary:** the term is **method**, matching the ticket and the already-fixed tooltip tag `editor.codeactions.kotlin.extractmethod`, even though the refactoring's output is a Kotlin `fun`. + +Move the expression at the cursor, or a selected range of statements, into a new function, and replace it with a call to that function. + +Ships as the top of a three-PR stack: `common-compose` theming, then extract variable (ADFA-4826), then this. It reuses that PR's primitives - offsets, naming, indentation, edit emission - and adds no new module, no new dependency and no new UI mechanism. + +The governing principle is [ADR 0013](../adr/0013-refactorings-decline-rather-than-rewrite.md): this refactoring **moves** code, it never edits the interior of what it moved, and where it cannot do that faithfully it **declines with a specific reason** rather than guessing. Most of the requirements below are that principle applied to one case each. + +## Language + +Shared vocabulary - *selection*, *extraction region*, *expression candidate*, *text span*, *occurrence*, *refactoring plan*, *rewrite span* - is defined once in [kotlin-extract-variable.md](kotlin-extract-variable.md#language). This feature adds: + +**Statement range**: +One or more *sibling* statements inside a single `KtBlockExpression`, snapped outward from the selection to whole statement boundaries. The second kind of extraction region; the first is an expression candidate. +_Avoid_: statement list, block, selection. + +**Enclosing declaration**: +The named function, property accessor or `init` block whose body contains the extraction region. It is both the boundary that decides what becomes a parameter and the sibling anchor the new function is inserted after. +_Avoid_: parent function, host, owner. + +**Captured declaration**: +A declaration the region references whose PSI lies *inside* the enclosing declaration - a local, a function or lambda parameter, `it`, a destructuring entry, a loop variable. Each becomes a **parameter**. Anything else (class members, top-level declarations, imports) resolves unchanged from the new function body and needs no parameter. +_Avoid_: free variable, capture, dependency. + +**Output**: +The single value that flows out of the region and is still needed after it - a local declared inside the region and read after it. Zero outputs means the extracted function returns `Unit`; two or more is declined. +_Avoid_: result, return value (that's the extracted function's `return`, which an output is only one cause of). + +**Exit**: +A `return`, `break`, `continue` or non-local return inside the region whose target lies outside it. Declined, except the tail return (R8). +_Avoid_: jump, control flow, early return. + +**Refusal**: +A typed reason (`ExtractionRefusal`) the region could not be extracted, carried on the plan and rendered as a specific message. A refusal is a designed outcome, not an error. +_Avoid_: failure, error, invalid. + +## Scope + +### In scope + +An expression, or a range of sibling statements, inside any executable body - a function body, an accessor, an `init` block, a constructor, or a lambda - in a Kotlin file. + +### Out of scope + +The positions extract variable already rejects, for the same reasons and via the same `isExtractionPosition` check: annotation arguments, default parameter values, super-constructor delegation arguments, and anything outside an executable body (notably a class-body property initializer). + +## Requirements + +**R1 - Trigger.** An "Extract method" item (`action_extract_method`) in the editor code-actions menu for Kotlin files, id `ide.editor.lsp.kt.extractMethod`, tooltip tag `EDITOR_CODE_ACTIONS_KT_EXTRACT_METHOD = "editor.codeactions.kotlin.extractmethod"` - a new constant in `TooltipTag.kt`. The tag string is fixed: tooltip *content* lives in the out-of-repo tooltips database keyed by tag, so it cannot be renamed here. + +As with extract variable: **no `prepare()` visibility gate** (deciding extractability needs an analysis session, far too costly for the UI thread), and `requiresUIThread = false` so the selection is read on a background thread. + +**R2 - Region.** The selection resolves to exactly one extraction region, of one of two kinds. + +*Expression candidate* - reuses `candidateExpressionsAt` unchanged, including whitespace trimming, the `offset - 1` cursor retry, the innermost-first walk, `MAX_CANDIDATES = 3` and the legal-target rules. A bare cursor always takes this path. + +*Statement range* - a non-empty selection that spans statement boundaries snaps **outward** to whole statements: a touch selection will not land on a boundary. The result must be 1..N statements that are **siblings in one `KtBlockExpression`**. A selection spanning two different blocks, or partially covering a statement that cannot be snapped, is declined (`NotASingleRegion`). + +Restricting to siblings in one block excludes every hard case - a selection covering half an `if` and half its `else`, a range straddling a lambda boundary - by construction rather than by later filtering, exactly as `isLegalExtractionTarget` excludes expression fragments today. + +**R3 - Live offsets and the version guard.** Identical to extract variable: analysis runs against `getCurrentKtFile(path)` fetched *before* entering `project.read`, the plan records the document version (on the `RefactoringPlan` supertype), and each action re-reads the version on confirm with a mismatch refusing the edit. + +**R4 - Target.** One uniform rule, no target picker: **the new function is inserted as a sibling of the enclosing declaration** - immediately after it, except for a local `fun` target, where it goes immediately *before* it. A local function is only visible from its declaration onward, so it has to be declared above the code that calls it; every other target has no such constraint. That one rule produces the conventional answer in every context: + +| The region sits in | The new function becomes | +|---|---| +| a member function, accessor or `init` of a class | a `private fun` member of that class | +| a top-level function or property | a `private` top-level `fun` | +| a lambda inside either of the above | still a sibling of the enclosing *named* declaration; the lambda's captures become parameters | +| an anonymous `fun(...) { }` used as a value | still a sibling of the enclosing *named* declaration, exactly as for a lambda; PSI gives it the same node type as a named function, but it is a value and nothing can be inserted after it. An anonymous **extension** function (`fun String.() { }`) is declined instead: skipping it would drop a receiver the body depends on | +| a local `fun` or local class | a local `fun` in the enclosing block, since the sibling *is* a statement there | +| a companion object body | a member of the companion | + +A region with **no enclosing named declaration at all** - one inside a lambda or an anonymous `fun` that is itself in a class-body property initializer - is declined as `NotASingleRegion`. There is no anchor a sibling could follow, and the message is imprecise about why rather than wrong. + +Unlike extract variable there is no scope chain and no ceiling, because anything not visible at the insertion site becomes a parameter instead of constraining the anchor. + +**R5 - Parameters.** A referenced declaration needs a parameter exactly when it is a captured declaration - its PSI lies inside the enclosing declaration. Members of the enclosing class need nothing, because the new function is a member of that same class. + +- **Order** - first textual appearance in the region, so the signature reads in the order the body uses it. +- **Names** - the original identifier, unchanged. `it` becomes a parameter literally named `it`, which is legal Kotlin, and the call site passes `it`. +- **Types** - the resolved type rendered **fully qualified** (`KaTypeRendererForSource.WITH_QUALIFIED_NAMES`), so `java.util.Date` rather than `Date`. Verbose, but a short name resolves only when the file already imports it, and a local's type usually comes from inference rather than a spelled-out type reference - this refactoring adds no imports. A **platform type** is emitted as its lower bound: the renderer prints `String!`, which does not parse, and the lower bound is both what IntelliJ writes and what the moved body already assumes. A type that cannot be rendered - anonymous, intersection, a resolution failure, or a `!` the lower bound did not remove (a platform type on a type *argument*) - **declines the extraction** (`UnrenderableType`) rather than emitting uncompilable text. A value whose type is a class declared inside the enclosing declaration declines too (`CapturedLocalDeclaration`): the value survives the move, its type name does not. +- **Not editable.** The derived signature is shown read-only (R11). Renaming, reordering or excluding parameters is a desktop-sized dialog; a wrong parameter *name* is fixable afterwards with rename (ADFA-4825), and a wrong parameter *set* is not something the user could correct by hand anyway. + +**R6 - Return type and call-site form.** Determined by the region kind and its output: + +| Case | Extracted body | Call site | +|---|---|---| +| expression candidate | `return ` | `extracted(args)` in the expression's place | +| statement range, no output | the statements; returns `Unit` | `extracted(args)` as a statement | +| statement range, one output `x` | the statements, then `return x` | `val x = extracted(args)` | +| statement range, tail return (R8) | the statements including the `return` | `return extracted(args)` | + +A region that always throws still declares `Unit`; the exception propagates and the call site behaves identically, so `throw` needs no rule of its own. + +**R7 - Outputs.** An output is a local declared inside the region and read after it. Exactly one plain `val`/`var` is supported; **two or more declines** (`MultipleOutputs`, naming them), and a single output the call site cannot receive back declines separately (`OutputNotReturnable`, naming it) - a destructuring entry or a local `fun`, which a `val` cannot stand in for, or a local the following code reassigns, which a `val` cannot be. The two are distinct refusals because "produces more than one value" is simply untrue of the second, and a refusal that misdescribes the situation teaches nothing. + +A `var` declared outside the region and **reassigned inside it declines** (`ReassignsOuterVar`, naming the variable), because Kotlin has no `out` parameters and the faithful emission - a parameter plus `var x = x` at the top of the body - carries a name-shadowing warning into generated code. This is deliberately stricter than dataflow requires: a reassignment whose result is never read afterwards is still refused, because proving that needs real liveness analysis. ADFA-5082 tracks supporting it. + +The refused case is the accumulator loop, which is a genuinely common extraction, so its message must name the variable and read as a limitation rather than a malfunction. + +**R8 - Exits.** Every exit declines (`ExitsRegion`), with one syntactic exception. + +**Tail return:** when the region's *last* statement is a `return`, the region contains no other `return`, `break` or `continue`, and there is no other output, the extracted function takes the enclosing function's return type, keeps the `return`, and the call site becomes `return extracted(args)`. The exception holds only when that tail `return` returns from the **enclosing declaration itself** and carries **no label**: a `return` owned by an anonymous `fun` wrapped around the region would take a return type its own function never returns, and a `return@label` names something outside the region that the new function does not declare. Both fall through to the ordinary exit check and decline. "Extract the rest of this function into a helper" is one of the most common real extractions and the enabling check is purely syntactic - last-child kind plus a recursive absence check - so it costs a predicate and one call-site form, not an analysis. + +One exception to "the enclosing function's return type": a **secondary constructor** is treated as `Unit`. Its symbol's return type is the constructed class, but its `return` carries no value, so taking that type would emit both a bare `return` in a value-returning function and a call site returning the wrong thing. `return extracted(args)` on a `Unit`-valued call is legal inside a constructor. An `init` block needs no rule - `return` is illegal there, so no tail return can arise. + +Declined: a `return` anywhere but the tail position, a `break`/`continue` whose target loop is outside the region, a labelled `return@` whose target is outside it, and a non-local return from an inlined lambda. Each would silently change meaning, since a `return` in the extracted body returns from *it*. + +Not an exit: a `return` belonging to a function **declared inside** the region - a local `fun`, an anonymous `fun`, or an anonymous-object override. It moves with its own declaration and its jump never crosses the region boundary, so counting it would refuse a perfectly good extraction with a message describing something the user did not write. + +**R9 - Receivers.** + +- **Class dispatch receiver** - nothing to do; the new function is a member of the same class. +- **The enclosing declaration's extension receiver** - the new function is generated as an extension on the **same receiver type**, copied syntactically from the enclosing declaration's receiver type reference. The call site needs no change at all: inside `fun Foo.original()`, `this` is a `Foo`, so `extracted(args)` resolves to `private fun Foo.extracted(args)`. +- **An implicit receiver introduced inside the enclosing declaration** - the `with(x) { ... }` / `apply` / `run` / `buildString` case - **declines** (`InnerImplicitReceiver`). Turning that receiver into a parameter would require qualifying every unqualified member access inside the extracted body, which is editing the interior of the moved code. Android code uses these scoping functions heavily, so this refusal will be common and its message must say which construct is in the way. + +**R10 - Modifiers.** Copy nothing from the enclosing declaration; add only what the body needs in order to compile in its new home. + +- **Visibility** - always `private`, whether a class member or top-level. Never `internal`, never `open`, no annotations copied, no KDoc generated. +- **`suspend`** - added when any call in the region resolves to a suspend function, or the region references `coroutineContext`. The call site is necessarily already a suspend context. **Not** added for a suspension the region only performs inside a *nested* suspend-typed lambda - `scope.launch { }`, `runBlocking { }`, any `suspend () -> T` parameter: the region carries that lambda with it, so the new function needs no `suspend`, and adding it breaks a call site that is not itself a suspend context. An ordinary inline lambda (`forEach`, `let`, `run`) is not one of these and still propagates `suspend` outwards. Not detected: invoking a `suspend () -> T` **parameter** directly, where the modifier is carried by the functional type and not by the resolved `Function0.invoke` symbol. +- **`@Composable`** - added when the region uses one: any call resolving to a `@Composable`-annotated function, **or any name reference resolving to a property whose getter is annotated**. The second half is not an edge case - `MaterialTheme.colorScheme` and `LocalDensity.current` are annotated getters, not calls. This is not polish: CoGo users write Compose apps on the device, and an extracted composable without the annotation does not compile. Not detected: invoking a `@Composable`-typed lambda **parameter**, the Compose slot-API shape (`fun Card(content: @Composable () -> Unit)`, extracting `content()`), because the annotation sits on the functional type and the call resolves to an unannotated `Function0.invoke`. A region whose only composable use is such an invocation extracts without the modifier. +- **Function-level type parameters** - a region referencing a type parameter declared on the *enclosing function* **declines** (`UsesTypeParameter`, naming it). Class-level type parameters need no rule; they stay in scope for a member. A filtered copy of the enclosing type-parameter list with its bounds would mean deciding "is `T` referenced" from rendered type text, which is fragile. + +`suspend` and `@Composable` are the two cases where omitting a modifier produces non-compiling code, which is why they are requirements while everything else is left off. + +**R11 - Sheet.** A sibling of the extract-variable sheet, not a generalisation of it: `ExtractMethodSheet` (a `BottomSheetDialogFragment` hosting a `ComposeView`), a stateless `ExtractMethodSheetContent`, `ExtractMethodViewModel` + `ExtractMethodUiState` + a sealed `ExtractMethodUiEvent`. `LabelledSection` and `OptionList` are promoted to a shared internal file in `refactor/ui/`. + +Contents, top to bottom: title -> expression chooser (only for an expression region with more than one candidate) -> name field with its `NameProblem` message -> signature preview -> Cancel/Extract. There is **no scope chooser** (R4) and **no replace-all checkbox** (R13). + +The preview is **one monospace line: the signature exactly as it will be emitted** - modifiers, receiver, parameters, return type. Types render fully qualified (R5), so a real preview reads `private suspend fun loadUser(id: kotlin.String): com.example.User`. It wraps rather than truncating. No body preview: the body is the code the user selected and can see behind the sheet, so it moves verbatim and previewing it says nothing new, while the signature is the one derived artefact and the one place the derivation can surprise them. + +ADR 0012 defers the shared-UI question until the extract-method surface is known; a single generalised sheet would need a state class where half the fields are meaningless to either caller, so that question stays open rather than being settled from one data point. + +**R12 - Name.** Suggestion: for an expression region, the existing shape/type derivation unchanged; for a statement range, the constant `extracted`, since there is no expression to read a name from and inventing a verb from statement shapes is guesswork. Uniquified as today. + +Validation reuses `validateVariableName` and `NameProblem` unchanged - so no new error strings - with taken names being **every callable name visible in the insertion container, including inherited members** (the container's `memberScope`, not just its declared members) for a class target; every top-level declaration name in the file for a top-level target; enclosing-block declarations for a local target. + +Including inherited names is a correctness requirement, not a nicety: a private function accidentally matching a supertype member is an accidental-override compile error. Rejecting *any* name match rather than only a signature match also means the refactoring never creates an overload the user did not ask for. + +**R13 - One call site.** The region is the only site rewritten. No duplicate detection, no replace-all toggle: exact-duplicate matching would almost never fire, and near-duplicate matching needs anti-unification plus a per-site parameter mapping - a feature in its own right. `Occurrences.kt` is expression-granular by construction. + +**R14 - Refusals.** The plan carries a typed `ExtractionRefusal` rather than merely being empty, and `postExec` maps it to a specific message: + +| Reason | Message intent | +|---|---| +| `NotASingleRegion` | select an expression, or whole statements inside one block | +| `CouldNotAnalyse` | the analysis could not run - deliberately neutral, since the selection may have been fine | +| `MultipleOutputs` | the selection produces more than one value | +| `OutputNotReturnable` | the selection produces ``, which cannot be handed back as a return value | +| `ReassignsOuterVar` | the selection assigns to ``, declared outside it | +| `ExitsRegion` | the selection jumps out of itself (`return`/`break`/`continue`) | +| `AnonymousExtensionFunction` | the selection is inside an anonymous extension function | +| `InnerImplicitReceiver` | the selection uses members of an enclosing `with`/`apply` receiver | +| `UsesTypeParameter` | the selection uses type parameter `` | +| `UnrenderableType` | a type in the selection cannot be written out | +| `UsesBackingField` | the selection uses the property's backing field, only reachable inside this accessor | +| `SmartCastParameter` | the selection uses `` under a smart cast that does not hold outside it | +| `CapturedLocalDeclaration` | the selection uses ``, which goes out of scope once the selection moves | + +All but `CouldNotAnalyse` are actionable - they tell the user what to change - and several (`ReassignsOuterVar`, `InnerImplicitReceiver`, `UsesBackingField`) are common enough that a generic message would read as the feature being broken. `CouldNotAnalyse` exists precisely so the others stay truthful: a missing compilation environment, an unreachable `KtFile` or a thrown analysis error must not be reported as `NotASingleRegion`, which blames a selection nothing ever looked at. Given how much of this design is "decline cleanly", the refusal text is a first-class part of the feature. New entries in `resources/.../values/strings.xml`, picked up by the next translation batch. + +Cancellation is not a refusal at all: `buildExtractMethodPlan` re-throws `CancellationException` (which `AnalysisPreemptedException` is), so a cancelled action ends silently rather than flashing at a user who has moved on. + +The refusal lives on `ExtractMethodPlan` only; extract variable keeps its single "nothing to extract" behaviour unchanged. + +**R15 - Edit.** Two regions change - the region becomes a call, and the new function appears next to the enclosing declaration - emitted as **two `TextEdit`s in one `DocumentChange`, sorted by descending start offset**. + +The ordering is mandatory, not stylistic. `IDELanguageClientImpl.applyActionEdits` iterates the edit list in order and `editInEditor` applies each with **line/column** ranges against whatever the text is at that moment (the `index` in `Position` is ignored), so an earlier edit must never shift a later one. Which edit leads follows from R4 rather than being fixed: a member or top-level target is inserted *after* its anchor, so the new function leads; a **local `fun` target is inserted before** its anchor, so the call site leads. + +**Known consequence:** nothing on that path calls `beginBatchEdit`, so this is **two undo entries**, and a single undo leaves a half-refactored, non-compiling file. This knowingly diverges from `RewriteSpan`'s single-replacement rule, which extract variable relies on. **ADFA-5081** fixes it properly by batching the edit loop in `applyActionEdits`, which benefits every multi-edit action; until it lands, the two-step undo is a stated limitation to be covered in QA. + +The new function is emitted **fully indented** at the enclosing declaration's own indentation, separated by one blank line, reusing `detectIndentUnit`, `detectNewline`, `leadingIndentAt` and `positionAt`. Code-action edits bypass the editor's auto-indent and `CMD_FORMAT_CODE` is a no-op for Kotlin. + +One exception to re-indenting every line: the interior and closing delimiter of a **raw (triple-quoted) string literal** are emitted byte-for-byte. Their whitespace is part of the literal's value, and the closing delimiter's column sets `trimIndent`'s margin, so shifting either would edit the interior of the moved code (ADR 0013). The candidate carries those literals' spans so the text layer can skip them without needing PSI. + +**R16 - Responsiveness and failure isolation.** As extract variable: one background pass at `AnalysisPriority.INTERACTIVE` under a cancel checker tied to the action's coroutine produces the whole plan; the sheet does pure string and offset arithmetic and re-enters no analysis on confirm. Anything thrown in the pipeline degrades to a refusal (`CouldNotAnalyse`) plus a log line, never an uncaught throw - the action framework catches only `IllegalArgumentException` and this runs on a scope with no exception handler. + +**`CancellationException` is the one deliberate exception**, and it is re-thrown rather than swallowed - `AnalysisPreemptedException` is one. A cancelled action has no result worth reporting, and `DefaultActionsRegistry.executeAction` launches into a scope whose `invokeOnCompletion` already treats a `CancellationException` as an ordinary cancel, so re-throwing ends the action quietly instead of flashing a message at a user who has moved on. Swallowing it would also break structured concurrency for whatever cancelled the job. The sheet's confirm path is outside the framework's guards entirely, so `ExtractMethodAction.applyChoice` wraps its own body. + +## Non-goals + +- **Duplicate or near-duplicate call sites** (R13). +- **An editable parameter list** - rename, reorder or exclude (R5). +- **Two or more outputs, and a reassigned outer `var`** (R7). The latter is ADFA-5082. +- **Mid-region `return`/`break`/`continue`** (R8). +- **Inner `with`/`apply`/`run` receivers** (R9). +- **Function-level type parameters** (R10). +- **Detecting `@Composable` or `suspend` carried by a functional type** rather than by the called symbol (R10) - invoking a `@Composable () -> Unit` or `suspend () -> T` parameter does not add the modifier. +- **Choosing a different target** - another class, another file, a local `fun` when a member is possible, or a property instead of a function (R4). Moving a declaration elsewhere is a move refactoring. +- **Extraction from a property initializer or annotation argument** - inherited from `isExtractionPosition`. +- **Generated KDoc** for the new function. +- **Post-extract inline rename** of the new name in the editor - ADFA-4825. +- **Atomic undo** of the two edits - ADFA-5081. +- **Formatting the result.** R15 emits indented text instead. +- **Java extract method** - ADFA-5048. + +## Acceptance criteria + +1. "Extract method" appears in the code-actions menu of a Kotlin file and is absent in a non-Kotlin file. +2. A cursor inside an expression offers the innermost-first candidates; extracting one replaces it with a call and adds a `private fun` returning that expression, directly below the enclosing function. +3. Selecting two adjacent statements that use two locals produces a function with those two locals as parameters, in first-use order, and a call passing them. +4. A selection with ragged boundaries snaps outward to whole statements before extracting. +5. A selection spanning two different blocks reports "select an expression, or whole statements inside one block". +6. A range declaring a local that is read afterwards produces `val x = extracted(...)` at the call site. +7. A range declaring two locals that are both read afterwards is declined as producing more than one value. +8. Selecting a loop that accumulates into an outer `var` is declined, and the message names that variable. +9. Selecting the tail of a function ending in `return x` produces `return extracted(...)` and a function with the enclosing return type. +10. Selecting a range containing a `return` in the middle is declined. +11. Selecting a range with a `break` targeting a loop outside it is declined. +12. Extracting from inside `fun Foo.bar()` when the region touches `Foo`'s members produces `private fun Foo.extracted(...)`, and the call site is unchanged. +13. Extracting from inside a `with(x) { ... }` block whose region uses `x`'s members is declined, and the message names the construct. +14. A region calling a suspend function produces a `suspend fun`. +15. A region calling a `@Composable` function, or reading a `@Composable` property such as `MaterialTheme.colorScheme`, produces a `@Composable` function that compiles. +16. A region using a type parameter of the enclosing function is declined, naming the parameter. +17. A name matching an existing member - including an inherited one - is rejected with "That name is already used". +18. The signature preview matches the emitted declaration exactly, including modifiers and receiver. +19. Editing the file while the sheet is open, then confirming, reports the file-changed message and leaves the file untouched. +20. Undo restores the file; it currently takes **two** undo steps (R15), and the intermediate state is non-compiling. +21. A space-indented file receives space-indented output; a CRLF file keeps CRLF. + +## Design + +Same shape as extract variable, and the same data boundary from [ADR 0012](../adr/0012-refactoring-ui-lives-in-the-owning-lsp-module.md): one background pass produces a plain-data plan, the sheet holds no PSI. + +``` +ExtractMethodAction.execAction (background) lsp/kotlin/actions + server.compilationEnvironmentFor(path) ?: refusal + -> buildExtractMethodPlan(...) utils/refactor/ExtractMethodPlanner.kt + ktFile = env.ktSymbolIndex.getCurrentKtFile(path).get() [R3: before project.read] + env.project.read { + resolveRegion(ktFile, start, end) utils/refactor/ExtractionRegion.kt [R2] + expression -> candidateExpressionsAt(...) (reused unchanged) + statements -> snap outward, sibling check + analyzeMaybeDangling(INTERACTIVE, cancelChecker) { [R16] + captured declarations -> parameters utils/refactor/MethodSignature.kt [R5] + outputs / exits / receivers / modifiers [R6-R10] + -> ExtractMethodPlan | ExtractionRefusal [R14] + } + } + <- ExtractMethodPlan (plain data, no PSI) + +ExtractMethodAction.postExec (UI thread) + refusal -> flashInfo(message for reason) [R14] + ExtractMethodSheet.show refactor/ui [R11] + on confirm -> version re-read; mismatch -> refuse [R3] + buildExtractMethodRewrite -> two RewriteSpans utils/refactor/ExtractMethodEdit.kt [R15] + client.performCodeAction(one DocumentChange, two TextEdits, descending) +``` + +New files, all in `lsp/kotlin`: + +- **`utils/refactor/ExtractionRegion.kt`** - the region model and its resolution (R2). Purely syntactic, so unit-testable with no analysis session, exactly as `CandidateExpressions.kt` is. +- **`utils/refactor/MethodSignature.kt`** - captured declarations to parameters, outputs, exits, receivers, modifiers, and the rendered signature string (R5-R10). The only analysis-dependent part. +- **`utils/refactor/ExtractMethodPlan.kt`** - `ExtractMethodPlan` (a `RefactoringPlan` subtype) and `ExtractionRefusal`. +- **`utils/refactor/ExtractMethodPlanner.kt`** - the single background pass (R3, R16). +- **`utils/refactor/ExtractMethodEdit.kt`** - the two rewrites and their ordering (R15). Pure text and offsets. +- **`refactor/ui/ExtractMethod*.kt`** - sheet, content, ViewModel, state, events (R11). +- **`actions/ExtractMethodAction.kt`** - registered in `KotlinCodeActionsMenu`; the only class touching the editor, the document version or the language client. +- **`TooltipTag.EDITOR_CODE_ACTIONS_KT_EXTRACT_METHOD`** - one new constant (R1). + +Reused from extract variable unchanged: `TextSpan`, `collapseForLabel`, `candidateExpressionsAt` / `CandidateSyntax`, `isExtractionPosition`, `enclosingExecutableBody`, `NameProblem` + `validateVariableName`, `suggestVariableName`, `detectIndentUnit`, `detectNewline`, `leadingIndentAt`, `lineStartOffset`, `RewriteSpan` + `toTextEdit`, `positionAt`, `renderName`. + +Deliberately **not** reused: `ScopeOption`, `AnchorForm` and `CandidateExpression`. Each is shaped by the legal scope chain, which this refactoring does not have (R4) - so the two refactorings share primitives, not the aggregate. What they do share is hoisted into the sealed `RefactoringPlan` (`fileText` and `documentVersion`), introduced in the extract-variable PR so this one is purely additive. The version *guard* itself - reading the live version and comparing - stays in each action rather than on the supertype, since it needs the `ActionData` and the action's own "file changed" string; hoisting it is a small cleanup, not a shared primitive today. + +Nothing outside `lsp/kotlin` changes except `TooltipTag.kt` and `values/strings.xml`. No new module, no new dependency. + +## Verification + +Unit tests in `:lsp:kotlin` (`flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest`), mirroring the extract-variable split so a failure localises to one layer: + +- **`ExtractMethodRegionTest`** - no analysis session, PSI only: outward snapping to whole statements, the sibling-in-one-block rule, cross-block rejection, and the expression path (R2). +- **`ExtractMethodPlanEndToEndTest`** - analysis-backed, one case per rule: the parameter set, order and types (R5), the single output and the `Unit` case (R6, R7), the tail return and the nested-declaration `return` that is not an exit (R8), the extension receiver (R9), `suspend`, a `@Composable` call and a `@Composable` property getter (R10), the anonymous-function anchor (R4), the recorded multi-line-string spans (R15), and **one case per refusal reason** (R14). +- **`ExtractMethodEditTest`** - pure text: the two edits and their descending order, the three call-site forms, indentation, raw (triple-quoted) string literals left verbatim, the blank-line separation, and CRLF preservation (R15). +- **`ExtractMethodViewModelTest`** - state derivation: chooser visibility, name validation against inherited names, and the rendered signature preview (R11, R12). + +`lsp/kotlin` has **no `androidTest`** source set, and none is added: `@Composable` detection is tested by declaring `package androidx.compose.runtime; annotation class Composable` in a test source module, and `suspend` is a language modifier, so both need **no new dependency** (`KtLspTestEnvironment` supports `extraLibraryJars`, but not for this). + +The sheet, `prepare()`/`ActionData`, the two-step undo and the new tooltip row are not unit-testable; they are covered by on-device QA from the acceptance criteria above, recorded in ADFA-5080's "Steps to QA" field. + +## Related + +- [ADR 0013](../adr/0013-refactorings-decline-rather-than-rewrite.md) - refactorings decline rather than rewrite unselected code; the principle behind R7-R10 +- [ADR 0012](../adr/0012-refactoring-ui-lives-in-the-owning-lsp-module.md) - refactoring UI lives in the owning LSP module +- [kotlin-extract-variable.md](kotlin-extract-variable.md) - ADFA-4826; owns the shared Language section and every primitive reused here +- ADFA-5081 - code action edits should be a single undo step (fixes R15's consequence) +- ADFA-5082 - support a reassigned outer `var` as the single output (lifts R7's refusal) +- ADFA-5178 - shorten signature type text to match extract variable (revisits R5's fully-qualified rendering) +- ADFA-5048 - Java extract method, the sibling in `lsp/java` +- [ARCHITECTURE.md](../../ARCHITECTURE.md) diff --git a/docs/features/kotlin-extract-variable.md b/docs/features/kotlin-extract-variable.md new file mode 100644 index 0000000000..c27f5ab1e0 --- /dev/null +++ b/docs/features/kotlin-extract-variable.md @@ -0,0 +1,285 @@ +# Kotlin extract variable (K2 LSP) + +- **Ticket:** ADFA-4826 (subtask of ADFA-3317; split out of the closed ADFA-3324 "Refactoring"). Extract method was originally part of this subtask and is now ADFA-5080. +- **Status:** Implemented in `lsp/kotlin/utils/refactor/` and `lsp/kotlin/refactor/ui/`, pending on-device QA. Still to land in this PR: the `ExtractionPlan` -> `ExtractVariablePlan` rename under a sealed `RefactoringPlan` supertype, which arrives with extract method (ADFA-5080). +- **Module:** `lsp/kotlin` + +Bind the expression at the cursor, or the selected one, to a new local `val`, and replace the occurrences of that expression with the new name. + +This is the first *interactive* Kotlin code action: the user chooses an expression, a name, a target scope and whether to replace other occurrences, so it needs a real UI surface rather than a fire-and-forget edit. Where that UI lives is [ADR 0012](../adr/0012-refactoring-ui-lives-in-the-owning-lsp-module.md); what it refuses to do is [ADR 0013](../adr/0013-refactorings-decline-rather-than-rewrite.md). + +## Language + +This section is the glossary for the whole refactoring family - extract variable, extract method (ADFA-5080), inline variable (ADFA-4827), rename (ADFA-4825). Prefer these terms over ad-hoc synonyms in code, tests, docs and review comments. + +**Selection**: +The user's raw offsets from the editor caret, before any processing. A cursor is the degenerate selection where start equals end. Trimmed and snapped before it becomes an extraction region, so it is *not* interchangeable with one. +_Avoid_: range (that's `Range`, the LSP line/column type), region. + +**Extraction region**: +The contiguous text an extraction reads its body from. For extract variable it is always an expression candidate; extract method adds statement ranges. +_Avoid_: target (overloaded with go-to-definition's target and with the insertion site), extent, fragment. + +**Expression candidate**: +A `KtExpression` at the selection that is a legal extraction target. Ordered innermost-first, at most `MAX_CANDIDATES` (3) of them, so the chooser stays scannable on a phone. +_Avoid_: candidate expression when naming code (the type is `CandidateExpression`, but the term is "expression candidate"), match, option. + +**Text span**: +A half-open offset range `[start, end)` into the analysed file's text - the type `TextSpan`. Purely positional; it carries no meaning about what it covers. +_Avoid_: range, offset pair. + +**Legal scope chain**: +The ordered anchors available for the new declaration, innermost first: outward from the candidate's own statement through enclosing blocks, crossing a lambda boundary only when nothing lambda-scoped is referenced, and stopping at the enclosing named function, accessor or `init` body. +_Avoid_: scope list, parent chain. + +**Anchor scope**: +The chain member the user picked. The `val` is declared inside it. + +**Anchor form**: +How the declaration is woven into an anchor scope, since not all Kotlin scopes are blocks: `ExistingBlock`, `WrapInBraces`, or `ConvertExpressionBody`. + +**Anchor point**: +The exact insertion offset - the start of the line holding the first statement *within the anchor +scope* that contains a replaced occurrence. Recorded per rung in the plan (`ExistingBlock`'s +`statementSpans`), because it is the only thing that makes an outer rung differ from an inner one. + +**Occurrence**: +A site inside the anchor scope that is structurally equal to the candidate *and* whose every name reference resolves to the same declaration. Sites made unsound by an intervening write are excluded, so an occurrence set is always safe to replace wholesale. +_Avoid_: duplicate, match, usage. + +**Refactoring plan**: +The complete result of the background analysis pass, carrying the analysed `fileText` and its `documentVersion`. Plain data: no PSI, no symbols, no session. Currently `ExtractionPlan`; extract method (ADFA-5080) adds a sealed `RefactoringPlan` supertype and renames this to `ExtractVariablePlan`, its subtype. +_Avoid_: model, result, context. + +**Rewrite span**: +The single text replacement an extraction performs - a `TextSpan` plus its replacement text (`RewriteSpan`), converted to one `TextEdit` at the boundary. + +## Scope + +### In scope + +An expression inside any executable body: a function body, a property accessor, an `init` block, a constructor, or a lambda. Both a bare cursor and a selection, since a cursor is just the selection where start equals end. + +### Out of scope + +Positions where no `val` can precede the expression, all rejected up front by `isExtractionPosition`: + +- **Annotation arguments** - must be compile-time constants. +- **Default parameter values** - evaluated per call, and a hoisted local would not be in scope. +- **Super-constructor delegation arguments** - nothing can precede them. +- **Anything outside an executable body**, notably a class-body property initializer. Converting one to a getter would turn compute-once into compute-per-access, so it is declined rather than silently changing evaluation semantics. + +## Requirements + +**R1 - Trigger.** An "Extract variable" item (`action_extract_variable`) appears in the editor code-actions menu for Kotlin files, id `ide.editor.lsp.kt.extractVariable`, tooltip tag `EDITOR_CODE_ACTIONS_KT_EXTRACT_VARIABLE = "editor.codeactions.kotlin.extractvariable"`. Tooltip *content* is keyed by tag in the out-of-repo tooltips database, so the tag shows no text until a row exists for it - a hand-off item, not code. + +There is deliberately **no `prepare()` visibility gate**. Deciding whether anything is extractable needs a K2 analysis session, which is far too costly for `prepare()` (UI thread, per menu item). The action stays visible on any Kotlin file and reports "nothing to extract" instead, matching `OrganizeImportsAction` and `ImplementMembersAction`. `requiresUIThread = false`, so the selection is read on a background thread; a torn read while the user is mid-edit can only produce a plan the version guard (R3) then refuses. + +**R2 - Region.** The selection is whitespace-trimmed first, because a touch-screen selection routinely carries a leading or trailing space; a selection holding nothing but whitespace collapses to a cursor at its start, since a drag over the gap between two tokens carries the same intent as a tap in it. For a cursor, the element is looked up at the offset and then at `offset - 1`, so a caret resting just past a token still resolves. + +From the innermost element the parent chain is walked outwards, collecting legal targets and stopping at the enclosing declaration. Illegal nodes along the way are **skipped rather than terminating the walk**, so `if (c) a else b` is still offered from inside one of its branches. At most 3 candidates, innermost first, deduplicated by range. + +An expression is not a legal target when it is: a block, a loop, `return`/`throw`/`break`/`continue`, an operation reference, `super`, a lambda (the `{ ... }` expression and the literal inside it -- outside its call site the parameter types are gone, so `val v = { it.length + 1 }` does not compile), the selector of a qualified expression (`b` in `a.b`), a call's callee (`foo` in `foo(x)`), the left side of an assignment, or a **bare literal**. Excluding bare literals removes the only case where omitting the type annotation could change meaning - an `Int` literal where a `Long` is expected, or a bare `null` inferring `Nothing?`. + +**R3 - Live offsets and the version guard.** Analysis runs against `ktSymbolIndex.getCurrentKtFile(path)`, PSI refreshed to the open document's current version - an offset resolved against stale text points at the wrong element. The `KtFile` is fetched *before* entering `project.read`: the refresh needs `project.write`, and awaiting it under the read lock deadlocks. + +The plan records the document version it was computed against. On confirm, the version is re-read and the edit is **refused** if it has moved on (`msg_extract_variable_file_changed`) - the editor stays reachable while the sheet is open, and applying spans computed against older text would corrupt the file. Refusing is always safe; the user can invoke the action again. + +**R4 - Value filter.** A candidate whose type is `Unit` or `Nothing` is dropped: `val u = println(x)` compiles but is pointless. A candidate whose legal scope chain is empty is dropped too - a candidate with no legal anchor is not a candidate. + +A rung whose anchor geometry the rewrite cannot honour (see R9) is dropped during the plan pass, not on +confirm - so a candidate left with no rung is dropped, and a plan left with no candidate reports +"nothing to extract" instead of opening a sheet whose confirm is bound to fail. + +**R5 - Scope chain.** Anchors are enumerated outward from the candidate's own statement, each one of three anchor forms: + +| Anchor form | When | Emitted as | +|---|---|---| +| `ExistingBlock` | the scope already has a `{ ... }` body | a new statement line | +| `WrapInBraces` | a braceless statement position: `if (c) foo()`, a `when` entry, a braceless loop body | the statement is replaced by a braced block holding the declaration and the original statement | +| `ConvertExpressionBody` | an expression-bodied function or accessor, `fun area(r: Int) = r * r` | `=` and the body become a block body; `return` is added unless the declaration returns `Unit`; the return type is written into the signature when the declaration does not spell one out, because a block body with no declared type returns `Unit` | + +A written-out return type is rendered fully qualified and then shortened to its simple name only where +that name already resolves in the file -- an exact import, a star import of its package, or a +default-imported package such as `kotlin.collections`. Everything else stays qualified: verbose, but it +compiles, and this refactoring adds no imports. When the type cannot be written as source at all +(anonymous, intersection, an unresolved type, or a platform type the renderer cannot reduce) the rung +is declined rather than emitting a block body that does not compile. +`Unit`-ness is decided from the resolved type and, if that cannot be answered, from the rendered text: +a rendered `Unit` retracts both the `return` and the written type, because the rendered text is what +lands in the file and a `Unit` return needs neither. + +Each rung is labelled with the construct that owns it -- `fun name`, `getter`, `setter`, `init block`, +`lambda`, `if block`, `else block`, `for loop`, `while loop`, `do-while loop`, `when branch` -- so the +`Declare in` list reads as a place rather than as a nesting level. A braced control-structure body is +wrapped in a container node, so the owner is the block's grandparent, not its parent. + +The walk stops after the enclosing named function, accessor or `init` body. A class body or file is never an anchor. Lambda boundaries are crossed during the syntactic walk, then **truncated afterwards** by the innermost scope holding a declaration the candidate references - so a candidate using `it` or a lambda parameter can never be hoisted out of that lambda. `it` needs its own case: it has no source PSI, so a value-parameter symbol with no PSI referenced by the name `it` is taken to be the innermost enclosing lambda's implicit parameter. That is a property of the language, not a guess about the text. + +Braceless control-structure bodies are wrapped in a container node, so the `if`/loop is the grandparent; without unwrapping, no braceless body is ever detected and the declaration silently hoists to the enclosing block instead of braces being added. + +**R6 - Occurrences.** Two sites are the same expression when they are structurally identical (whitespace and comments ignored) *and* every name reference in them resolves to the same declaration. The symbol check is the point: text or structure alone would match `config.timeout` inside a nested lambda where `config` is a different `config`. ADFA-3324 states the standard outright - text-based matching breaks things. + +Source declarations are compared by PSI identity, which is exactly the question being asked ("the same `val`?"); symbols without source PSI fall back to symbol equality. A resolution failure reads as "not the same" rather than propagating. + +Matches must themselves be legal targets - in `a.a`, a candidate of `a` matches the selector too, and rewriting it would produce `v.v`. Overlapping matches are dropped so no site is rewritten twice. + +An occurrence set is then restricted to a contiguous run around the candidate that **no write to a referenced mutable interrupts**: + +```kotlin +var limit = 1 +foo(limit + 1) // occurrence +limit = 5 +foo(limit + 1) // same expression, different value +``` + +Unsound sites are excluded rather than warned about, so "Replace all N occurrences" can never produce wrong code and N is always achievable. The walk grows outward from the candidate - never dropping the site the user selected - and stops in each direction at the first write it would cross. Writes counted: plain assignment, the augmented forms, and `++`/`--`, against any `var` the candidate reads. + +Occurrence sets are ascending by offset and always contain the candidate's own span, so `occurrences.size` is the count shown in "Replace all N occurrences". Narrowing to an inner scope can only shrink the set, never grow it. A block rung's set is narrowed once more, dropping leading occurrences whose own anchor statement cannot host the declaration: a replace-all anchors on the first served occurrence, so keeping an unhostable one would refuse the whole rewrite. That lowers the N the user is offered - two identical expressions can become "Replace all 1 occurrence", which hides the checkbox - and it is what keeps N always achievable. + +**R7 - Name.** The suggestion is derived from the expression's shape first (`items.size` -> `size`, `getFoo()` -> `foo`, an interpolated string -> `text`), then its rendered type (`List` -> `list`), then `"value"`; shape beats type because `size`, `count` and `name` are far better names than `int` and `string`. It is then uniquified with a numeric suffix. + +Validation returns a `NameProblem` - `Blank`, `NotAnIdentifier`, `Keyword`, `AlreadyTaken` - rather than throwing, since the input is a text field. Only Kotlin's **hard** keywords are rejected; soft and modifier keywords (`by`, `data`, `it`) are legal names. Backtick-quoted names are rejected: legal Kotlin, but a poor generated local, and accepting them would mean validating the quoted form too. + +Taken names are what a new declaration at the anchor would collide with or shadow: the parameters and local declarations of each enclosing block, lambda, function and accessor, the *declared* members of each enclosing class or object including its companion, and the file's top-level declarations. Members inherited from a supertype are not included - finding them needs resolution, which a syntactic walk cannot do, so a local may still shadow an inherited member unnoticed. A lambda that declares no parameter contributes `it`. Enclosing members and top-level names are included even though a local may legally shadow them, because shadowing one changes what every other reference to that name in the block means. A local in a *sibling* function is not included - it is invisible at the anchor, and treating it as taken refuses a legal name, which is a defect QA found on this ticket. The walk is purely syntactic, so it needs no analysis session and is unit-testable. + +**R8 - Sheet.** One surface holding every choice, with no navigation between steps: expression chooser, name field, scope chooser, replace-all checkbox, Cancel/Extract. The four are interdependent - a different expression changes the scope list and the occurrence count - so they are shown together where that relationship is visible, rather than across sequential dialogs the user would have to back out of to explore. + +Each chooser is hidden when it has nothing to ask: the expression chooser when there is one candidate, +the scope chooser when the chain has one rung, the replace-all checkbox at an occurrence count of one. +An exact selection does *not* hide the expression chooser, even though it says which expression the +user meant: long-press is the natural phone gesture and selects exactly one token, so hiding the list +there leaves no way to widen to an enclosing expression short of cancelling and dragging the selection +handles. The matched expression is the innermost one, which is preselected anyway, so the cost is one +extra row to look at. Changing the expression re-suggests the name, because the old one described the +old expression. + +**R9 - Edit.** Exactly **one** `TextEdit`, built as a `RewriteSpan` covering one contiguous span. `IDELanguageClientImpl.applyActionEdits` applies each edit in its own `runOnUiThread` with no `beginBatchEdit`, and every range is interpreted against the *current* text - so a list of N edits would be applied against positions already shifted by its predecessors and would cost N undo steps with a typing window between each. Occurrences are substituted right-to-left within the span so an earlier substitution cannot shift a later offset. + +The span is anchored on the chosen rung's statement, not on the occurrence: for an outer rung the +declaration goes above the whole enclosing statement, at that statement's indentation. + +A block written on one line -- `items.map { it.length + 1 }`, `fun f(n: Int): Int { return n * 2 }`, +a one-line `if` body -- is expanded instead: the content between the braces moves onto its own line +with the declaration above it and the closing brace below. Anchoring on the statement's line start +there would place the declaration *before* the `{`, outside the scope the value belongs to, which +leaves a lambda's `it` unresolved. The braces themselves and a lambda's `param ->` header are left +where they are. + +Whether a block counts as "one line" takes two conditions, not one. A single check against where the +block's content starts is not enough: a lambda body's block does not own its braces, so its content +span sits at the body's first token even when that token starts its own line, and comparing that alone +against the line start would wrongly expand an ordinary multi-line lambda. Both must hold: something +other than indentation already precedes the statement on its line (the brace, a header, or a prior +semicolon-separated statement), *and* the block's own content contains no newline (so re-emitting it +as a single line loses nothing). A multi-line lambda fails the first and keeps its shape; a multi-line +block with two semicolon-separated statements on one line satisfies the first but fails the second, so +it also keeps its shape, with the declaration hoisted above the whole line instead. + +A block that fails *both* conditions -- something besides indentation precedes the statement on its +line, but the block's own content spans more than one line, as in `items.forEach { log(x)\n\tlog(y) }` +-- is **declined** rather than hoisted. Hoisting would anchor before the block's own opening delimiter, +outside the scope the user picked, which is unsound whenever anything inside that scope (a lambda's +`it`, say) is not visible there. The placement decision - expand, line above, or refuse - is one function +shared by the planner and the rewriter, so the refusal reaches the user as "nothing to extract" before +the sheet opens rather than as a failed confirm. + +The emitted text is **fully indented**: code-action edits bypass the editor's auto-indent (raw `Content.replace`), and `CMD_FORMAT_CODE` is a no-op for Kotlin. The indent unit is inferred from the file's own lines (a tab if any line is tab-indented, else the smallest positive run of leading spaces, defaulting to a tab), mirroring `ImplementMembersAction`; CRLF is used only when the file already contains it, so the edit never mixes line endings. + +**R10 - Responsiveness.** One background analysis pass produces the plan for *all* candidates at once; the sheet then performs pure string and offset arithmetic on it. Nothing re-enters analysis on confirm, which keeps PSI off the UI thread, removes the stale-PSI window, and makes the whole derivation unit-testable without an editor, an activity or Compose. Analysis runs at `AnalysisPriority.INTERACTIVE` under a cancel checker tied to the action's coroutine, so cancelling the action aborts the analysis. + +**R11 - Failure isolation.** Anything thrown in the analysis pipeline degrades to an empty plan and a log line. The action framework catches only `IllegalArgumentException` and this runs on a scope with no exception handler, so an uncaught throw would crash the app; reporting "nothing to extract" is always safe. A missing `FragmentActivity` or fragment manager logs and flashes `msg_cannot_perform_fix` rather than failing silently. + +## Non-goals + +- **Extract to a `val` outside an executable body** - a class property or a top-level `val`. That is a different refactoring with different scope rules. +- **Extract `var`, `lateinit`, or a property with accessors.** Always a `val`. +- **An explicit type annotation** on the generated declaration. Bare literals are excluded (R2) precisely so inference cannot change meaning. +- **Occurrences outside the anchor scope**, or across files. +- **Renaming the declaration in place after the edit** - ADFA-4825. +- **Formatting the result.** `CMD_FORMAT_CODE` is a no-op for Kotlin; R9 emits indented text instead. +- **Extract method** - ADFA-5080, which shares this vocabulary and these primitives. + +## Acceptance criteria + +1. "Extract variable" appears in the code-actions menu of a Kotlin file and is absent in a non-Kotlin file. +2. A cursor inside `a + b * c` offers the innermost-first candidates and extracting the selected one produces `val = ...` on its own line above, correctly indented. +3. A selection that exactly matches an expression skips the expression chooser. +4. A caret immediately after an identifier resolves the same as one inside it. +5. A cursor on a bare literal, on whitespace, in a comment, or in an annotation argument reports "No expression to extract here". +6. An expression appearing three times in the same block reports "Replace all 3 occurrences" and rewrites all three. +7. The same expression with an intervening reassignment of a `var` it reads offers only the contiguous sound run. +8. An expression using `it` inside a lambda offers no anchor outside that lambda. +9. Extracting from `if (c) foo(x + 1)` wraps the branch in braces with the declaration inside. +10. With a candidate inside a braced `if` inside a function, picking `fun name` in `Declare in` puts the declaration above the `if`, and picking `if block` puts it inside the branch. +11. Extracting from `return items.map { it.length + 1 }` puts the declaration inside the lambda and expands the block over three lines; the same holds for a one-line function body. +12. Extracting from `fun area(r: Int): Int = r * r` converts it to a block body with `return`, leaving the declared type alone; extracting from `fun area(r: Int) = r * r` converts it *and* writes `: Int` into the signature. +13. Extracting from a `Unit`-returning expression-bodied function converts it without adding `return` and without writing a type. +14. A name that is blank, not an identifier, a hard keyword, or already used disables Extract and shows the matching message. +15. Editing the file while the sheet is open, then confirming, reports "The file changed. Try extracting again." and leaves the file untouched. +16. One undo restores the file exactly. +17. A file indented with spaces receives space-indented output; a CRLF file keeps CRLF. + +## Design + +Per [ADR 0012](../adr/0012-refactoring-ui-lives-in-the-owning-lsp-module.md), `lsp/kotlin` owns its refactoring UI, and the analysis/UI split is enforced **by data rather than by module boundaries**: the background pass produces a plain-data plan, and the sheet holds no PSI and performs no analysis. + +``` +ExtractVariableAction.execAction (background) lsp/kotlin/actions + server.compilationEnvironmentFor(path) ?: empty plan + cursor -> [selectionStart, selectionEnd) + -> buildExtractionPlan(...) utils/refactor/ExtractVariablePlanner.kt + ktFile = env.ktSymbolIndex.getCurrentKtFile(path).get() [R3: before project.read] + env.project.read { + candidateExpressionsAt(ktFile, start, end) utils/refactor/CandidateExpressions.kt [R2] + analyzeMaybeDangling(INTERACTIVE, cancelChecker) { [R10] + per candidate: type filter [R4] + enclosingScopeFrames + truncateAtCeiling ScopeChain.kt / Occurrences.kt [R5] + findOccurrences + excludeUnsoundOccurrences Occurrences.kt [R6] + suggestVariableName + namesInScopeAt NameSuggestion.kt / Occurrences.kt [R7] + } + } + <- ExtractVariablePlan (plain data, no PSI) + +ExtractVariableAction.postExec (UI thread) + empty -> flashInfo("No expression to extract here") [R11] + findFragmentActivity() -> ExtractVariableSheet.show refactor/ui [R8] + ExtractVariableViewModel: StateFlow, sealed UiEvent + on confirm -> ExtractionChoice + version re-read; mismatch -> refuse [R3] + buildExtractVariableRewrite -> RewriteSpan -> toTextEdit utils/refactor/ExtractVariableEdit.kt [R9] + client.performCodeAction(one DocumentChange, one TextEdit) +``` + +Components: + +- **`utils/refactor/ExtractionPlan.kt`** - `TextSpan`, `AnchorForm`, `ScopeOption`, `CandidateExpression`, the plan, `collapseForLabel`. To be renamed to `ExtractVariablePlan` under a sealed `RefactoringPlan` carrying `fileText`, `documentVersion` and the shared version guard, so ADFA-5080 adds a subtype rather than renaming this one. Both refactorings share these *primitives*, not the aggregate: extract method has no scope chain, so `ScopeOption`/`AnchorForm`/`CandidateExpression` are not shared. +- **`CandidateExpressions.kt`** - purely syntactic, no analysis session, hence unit-testable on its own (R2). +- **`ScopeChain.kt`** - the syntactic chain and the three anchor forms (R5); indentation and newline detection shared with the edit builder. +- **`Occurrences.kt`** - symbol-aware structural equality, the occurrence search, the unsoundness filter, the referenced-declaration ceiling, and `namesInScopeAt` (R5, R6, R7). +- **`NameSuggestion.kt`** - suggestion and validation, no analysis session (R7). +- **`ExtractVariableEdit.kt`** - `RewriteSpan`, the three anchor-form rewrites, `toTextEdit` (R9). Pure text and offsets. +- **`refactor/ui/`** - `ExtractVariableSheet` (a `BottomSheetDialogFragment` hosting a `ComposeView`), stateless `ExtractVariableSheetContent`, `ExtractVariableViewModel` + `ExtractVariableUiState` + sealed `ExtractVariableUiEvent`. `LabelledSection` and `OptionList` become shared with ADFA-5080. The ViewModel uses a plain `ViewModelProvider.Factory` rather than a Koin definition: it is sheet-scoped, injects nothing, and takes the plan as a runtime argument. +- **`ExtractVariableAction`** extending `BaseKotlinCodeAction`, registered in `KotlinCodeActionsMenu`; the only class that touches the editor, the document version or the language client. +- **`common-compose`** - `IdeTheme`/`IdeColorScheme`, shared with `profiler` and `floating-window` so the sheet matches the IDE's theme. + +## Verification + +Unit tests in `:lsp:kotlin` (`flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest`), split so a failure localises to one layer: + +- **`RefactorPrimitivesTest`** - no analysis session: selection trimming, candidate collection and the legal-target rules (R2), indent/newline detection, name suggestion and validation (R7), the unsoundness filter as a pure function (R6). +- **`ExtractVariablePlanEndToEndTest`** - analysis-backed: the value filter (R4), scope chains and the lambda ceiling (R5), occurrence sets including the `it` and same-name-different-symbol cases (R6). +- **`ExtractVariableEditTest`** - pure text: the three anchor forms, right-to-left substitution, indentation and CRLF (R9). +- **`ExtractVariableViewModelTest`** - state derivation: chooser visibility, candidate switching re-suggesting the name, replace-all clamping, `choice()` refusing an invalid name (R8). +- **`KotlinCodeActionTooltipTagTest`** - every action carries a tooltip tag (R1). + +`prepare()`/`ActionData` and the sheet itself are not unit-testable, consistent with the other Kotlin code actions. They are covered by on-device QA from the acceptance criteria, recorded in ADFA-4826's "Steps to QA" field. + +## Related + +- [ADR 0012](../adr/0012-refactoring-ui-lives-in-the-owning-lsp-module.md) - refactoring UI lives in the owning LSP module +- [ADR 0013](../adr/0013-refactorings-decline-rather-than-rewrite.md) - refactorings decline rather than rewrite unselected code +- [ADR 0009](../adr/0009-jetpack-compose-for-new-ui.md) - Compose, UDF, `ViewModel` + `StateFlow` +- [ADR 0010](../adr/0010-navigation-resolves-via-analysis-api.md) - the K2 Analysis API as the Kotlin semantic source of truth +- [kotlin-extract-method.md](kotlin-extract-method.md) - ADFA-5080, the sibling refactoring +- [ARCHITECTURE.md](../../ARCHITECTURE.md) diff --git a/docs/features/kotlin-find-usages.md b/docs/features/kotlin-find-usages.md new file mode 100644 index 0000000000..92cbdc4200 --- /dev/null +++ b/docs/features/kotlin-find-usages.md @@ -0,0 +1,278 @@ +# Kotlin find usages (K2 LSP) + +- **Ticket:** ADFA-4824 (subtask of ADFA-3317; split out of the closed ADFA-3321 "Navigation") +- **Status:** Implemented in `lsp/kotlin/navigation/`, pending on-device QA +- **Module:** `lsp/kotlin` + +From a Kotlin declaration - or from a reference to one - list every place in the workspace that uses it, across three scopes: same file, another file in the same module, another module in the workspace. + +`KotlinLanguageServer.findReferences` already exists as a stub that answers empty; this feature fills it in. Everything downstream of it (`ReferenceResult`, `IDEEditor.onFindReferencesResult`, the search-results panel) already existed for the Java server. + +The sibling feature [go-to-definition](kotlin-goto-definition.md) answers the *opposite* question and shares this feature's caret handling, symbol-to-location conversion, and test fixture. Read its Language section first; the terms below extend it rather than replace it. + +## Language + +**Usage**: +A reference that resolves into the match set. This is the unit the feature reports. +_Avoid_: reference (that is the PSI element, per go-to-definition's glossary), occurrence, hit, match. + +**Target**: +The declaration whose usages are being searched for. Derived from the caret either directly (the caret is on the declaration's own name) or by resolving the reference under the caret. +_Avoid_: symbol, subject, source, declaration (reserve that for the PSI element a reference resolves to). + +**Match set**: +The target plus every declaration a call to the target may legitimately have been written against: its **workspace-source** supers, and - when the target is a classifier - its constructors. A reference is a usage if and only if it resolves into this set. +_Avoid_: hierarchy, family, candidates (go-to-definition uses "candidate" for a resolved declaration). + +**Search scope**: +The set of modules a usage could possibly live in, derived from the target's visibility. Distinct from go-to-definition's **resolution scope** (same-file / inter-file / inter-module), which describes coverage rather than a bound. These two are easy to conflate and are deliberately named apart. +_Avoid_: scope (unqualified), visibility scope, module scope. + +**Candidate file**: +A file that survived the text prefilter and is therefore worth parsing and resolving. Most candidate files contain no usage at all - the prefilter is a cheap over-approximation. +_Avoid_: match, result, hit. + +**Workspace boundary**: +The line between declarations with source PSI in a source module and everything else (the stdlib, the framework, library jars). The match set stops at it, and so does the reportable result set. +_Avoid_: project boundary, library edge. + +## Scope + +### In scope + +Any reference, in any of the three resolution scopes, that resolves into the match set - where both the reference and the target's declaration are workspace sources. + +The **target** may be a Java-source declaration. A caret on a Kotlin reference to a workspace `.java` class or method resolves to it (go-to-definition's AC5 already covers that direction), and its Kotlin usages are found like any other target's. + +**Convention references are valid entry points.** A caret on `a + b`, on `by`, on `[`, on a `for` loop's `in`, or on a destructuring entry resolves through to `plus` / `getValue` / `get` / `iterator` / `componentN`, and the feature then searches for *named* usages of that function. This costs nothing beyond what go-to-definition already does. + +### Out of scope + +- **Implicit call sites as results.** A usage search on `operator fun plus` finds explicit `a.plus(b)` calls, not `a + b`. Discovering implicit sites would mean resolving every operator, index, call, delegate and loop expression in every file in scope, because the text of `a + b` contains no name to prefilter on. Java's find-references reports no implicit usages either. +- **`.java` files as search targets.** Kotlin declarations *are* visible to Java PSI as light classes here (`symbol-light-classes.xml` registers `KotlinAsJavaSupport`, and `JavaElementFinder` is registered), but nothing in the repo exercises Java PSI *resolution*, and the Java server has its own find-references. Kotlin call sites of a Java declaration work; Java call sites of a Kotlin declaration are not searched. +- **Usages reachable only through a subclass.** See R3 and Non-goals. +- **Binary symbols.** As with go-to-definition: no decompiler, and `showLocations` can only open a real file. A search from a reference to `listOf` finds nothing. +- **Test sources.** Not a choice made here - `AndroidModule.getSourceDirectories()` returns `mainSourceSet` only, so `src/test/**` and `src/androidTest/**` are not content roots for *any* Kotlin LSP feature. + +## Requirements + +**R1 - Trigger.** A "Find references" item appears in the Kotlin code-actions menu, mirroring Java's. `FindReferencesAction` extends `BaseKotlinCodeAction`, id `ide.editor.lsp.kt.findReferences`, reuses `R.string.action_find_references`, and delegates to `ILspEditor.findReferences()`. Registered in `KotlinCodeActionsMenu` immediately after `GoToDefinitionAction`, matching Java's ordering. + +It carries its own tooltip tag, `EDITOR_CODE_ACTIONS_KT_FIND_REFS = "editor.codeactions.kotlin.findrefs"`, not Java's `EDITOR_CODE_ACTIONS_FIND_REFS` - the same split go-to-definition made, so Kotlin and Java can carry different tooltip text. The tooltips database is not in this repo, so the tag shows no text until a row exists for it; that row is a hand-off item, not code. + +The item is **always visible** for `.kt`/`.kts` and never conditioned on what the caret is sitting on: deciding "is there a target here" needs PSI and the project lock, and `prepare()` runs on the UI thread. A caret on whitespace therefore flashes "No references found". A `.kts` file shows the item and it does nothing, because a script has no `CompilationEnvironment` - identical to go-to-definition. + +**R2 - Target at caret.** The caret maps to a target declaration by trying, in order: + +1. **The caret is on a declaration's own name** - the leaf is the `nameIdentifier` of a `KtNamedDeclaration`. That declaration is the target. +2. **The caret is on a reference** - delegate to go-to-definition's `referenceAtCaret`, then resolve it to its declaration, which becomes the target. + +Order matters, and it makes the two features answer differently from one identical caret. For `val (x, y) = p` with the caret on `x`, go-to-definition navigates to `component1`; find usages targets the local `x`. That is deliberate: `x` is both a declaration and a convention reference, and each feature wants the reading that is useful to it. + +`referenceAtCaret` cannot be reused for step 1. It is built so that a caret on a declaration's own name resolves nothing - go-to-definition's no-self-jump rule - which is precisely the caret position find usages is normally invoked from. Step 1 is therefore a new, separate check; the token accept-list and the `offset - 1` retry are shared. + +**R3 - Match set.** Assembled once, in the caret's analysis session: + +- The target symbol, normalised through `fakeOverrideOriginal`. A call `derived.foo()` where `Derived` does not redeclare `foo` resolves to a substituted fake override, not to `Base.foo`, so both sides of every comparison are normalised. +- Its supers, via `allOverriddenSymbols`, **stopping at the workspace boundary**. So a call dispatched through a workspace `Base.foo` counts as a usage of `Derived.foo`, wherever `Base` lives - which is why R4's scope unions the supers' modules too. Library supers are excluded: including them would make a usage search on an overridden `toString` match every `.toString()` call in the workspace, and a library super can never yield a reportable result anyway. +- When the target is a classifier, its **constructors**. Otherwise `Foo()` - which resolves to the constructor, not the class (go-to-definition's R4) - would not count as a usage of `class Foo`, and the feature would miss every instantiation. The reverse expansion is not applied: a target that *is* a specific constructor stays that constructor, because asking for usages of one overload is a deliberate act. + +The walk goes **up** only. Usages reachable solely through a subclass (`Base.foo` searched, `derived.foo()` written) are not found - that needs a workspace inheritor search, and `DirectInheritorsProvider.computeIndex()` rebuilds its entire index on every call. + +**Import directives count as usages.** `import a.b.Foo` resolves to `Foo`, so it is one by construction. The panel has no categories to separate them into, and the noise is bounded at one hit per importing file. + +**R4 - Search scope.** Derived from the target's visibility, which is an exact bound rather than a heuristic: + +| Target | Scope | +|---|---| +| local val/var, parameter, local fun, local class, loop variable | containing file | +| `private` top-level declaration | containing file (Kotlin private top-level is file-private) | +| `private` class/object member | containing file | +| `internal` | the target's module | +| `protected`, `public`, default | every match-set member's module + its transitive dependents (`KotlinModuleDependentsProvider.getTransitiveDependents`) | + +The ticket's three resolution scopes fall out of this one code path rather than being three implementations. Cheap cases stay cheap: a search on a local variable never leaves the open file. + +The last row unions **every match-set member's** module, not just the target's, because R3's up-walk and R4's dependents pull in opposite directions. With `Base` in `lib` and `Derived` in `app`, a `base.paint()` call written in `lib` is a usage of `Derived.paint` - but `lib` is a *dependency* of `app`, not a dependent, so the target's own module and dependents would never look at it. Library supers are already out of the match set, so the union cannot escape the workspace. The first three rows need no union: `private` cannot override, and this project model has no friend modules, so `internal` cannot be overridden across one. + +The first three rows need the declaration's path. The file the user is editing is a live `KtFile` built from the editor buffer, whose `virtualFile` is a non-physical `LightVirtualFile`, so the path comes from `backingFilePath` first and the VFS only as a fallback - go-to-definition's derivation. A target that still has no path cannot be confined to one file, but it is still unreferenceable outside its own module, so it falls back to the `internal` row rather than to the last one. + +`internal` needs no widening for test sources. There is no test module to widen to - `collectKtModules` builds one `KtSourceModule` per Gradle module from `mainSourceSet` only, and `directFriendDependencies` is empty everywhere. + +**R5 - Candidate discovery.** Two tiers, because find usages is run *while* editing and unsaved text must not be invisible: + +| File | Prefilter text | PSI | +|---|---|---| +| open in the editor | the live buffer | `ktSymbolIndex.getCurrentKtFile(path).await()`, awaited **outside** `project.read` | +| everything else | disk | `ktSymbolIndex.getKtFile(path)` | + +A module's files include `.java`, which is a non-goal to search, so candidates are filtered on the extension *before* the read - otherwise a Java-heavy workspace spends most of the prefilter reading files whose result is already known. + +The prefilter is `mentionsName`, word-boundary exact on the target's simple name, reading line by line through `FileManager.getReader(path)` - which returns the live document when the file is open and the file itself otherwise, so the two tiers above need no branch of their own. Its errors are one-directional: a file that mentions the name but contains no usage is parsed and discarded (wasted work, correct result), while a file that does not mention the name cannot contain a named usage. An unreadable file drops out of the scan with a log rather than failing the search. + +Open documents are tab-count many, so the live tier is free. Without it, a usage the user just typed would be missed entirely - the prefilter would never select the file, so it would never be parsed. + +Deliberately **not** `StringSearch.containsWord`, the equivalent helper the Java server prefilters with. It reads only the first 1 MB of a file, so a usage below the mark would be silently dropped; it reads through one process-global `ByteBuffer` that the Java server mutates concurrently from its own threads; and it rethrows an unreadable file as a `RuntimeException`, which here would abort the whole search. A name cannot span a line break, so matching per line loses nothing. + +Only `KtSimpleNameExpression`s are examined. That is what makes the name filter cheap - it runs on PSI alone, so it runs *before the analysis session is opened*, and a text-prefilter hit whose only mention is a comment or a string literal never costs an analysis-lock acquisition, a FIR session or a match-set restore. It is also what implements "convention references are not results": `a + b` contains no `plus` token, so it is never a candidate. The cost is that a **KDoc `[link]`** to the target is not reported, even though go-to-definition navigates from one; a documented gap rather than a decision worth its own machinery in v1. + +**R6 - Identity.** A reference is a usage if its resolved symbol is in the match set. Deciding that across files needs care, because `KaSymbol` is session-scoped and the same declaration exists as two PSI instances - the on-disk `KtFile` cached in the index, and the dangling `KtFile` built from the editor buffer for an open file. + +Matching therefore uses `KaSymbolPointer`: `createPointer()` for each match-set member in the caret's session, then `restoreSymbol(session)` **once per candidate session**, then `==` against each resolved candidate symbol inside that session. This is the platform's cross-session identity mechanism, with structural implementations per symbol kind, and it is the direct analogue of the Java server re-deriving its target `Element` inside each compile task. + +Locals pay almost none of it: R4 confines them to one file, so there is a single candidate session and the pointers restore once. + +A pointer that fails to restore drops that session's candidates, with a log. That under-reports rather than reporting something false, which is the safe direction, and it is tested. + +Neither a PSI identity check nor a (file, offset) key works here. Both break exactly when the target's own file has unsaved edits: the live PSI and the on-disk PSI disagree about offsets, so every cross-file usage would be silently missed - and editing-then-searching is the common case. + +**R7 - Results.** Each usage becomes a `Location` whose range covers the reference's **name identifier** (`foo` in `a.b.foo()`, `Foo` in `Foo()`), matching go-to-definition's R6. Deduplicated by file plus range, ordered by file path then start offset. + +`includeDeclaration` is **ignored**, and the target's own declaration is never emitted. Java's provider ignores it too. Honouring it would also create a trap: a declaration with no usages would return exactly one location in the current file, which `onFindReferencesResult` turns into a silent `setSelection` on the declaration the caret is already on - indistinguishable from a broken no-op. Returning empty flashes "No references found", which is true. + +There is **no result cap**. See R10 for why one is not needed. + +**R8 - Result handling.** The server returns `ReferenceResult(locations)`; `IDEEditor.onFindReferencesResult` applies unchanged: + +- empty -> flash `msg_no_references` +- one location in the current file -> `setSelection` +- otherwise -> `languageClient.showLocations`, the grouped search-results panel + +**R9 - Scheduling.** The request runs at the new `AnalysisPriority.COMMAND` ([ADR 0011](../adr/0011-command-analysis-priority.md)), behind the editor's existing cancellable progress flashbar (`msg_finding_references`). + +Granularity is per candidate file, and it is load-bearing: + +- **One analysis session per candidate file.** A preemption by completion costs one file's work, which is retried once - `findDefinitionAt`'s pattern. The target-resolution phase is retried twice over, because losing *it* loses the whole search rather than one file (R12). One session for the whole search would let a single keystroke discard a whole-workspace scan. A file preempted *twice* is dropped like any other failed candidate (R12), not rethrown: keystroke-driven work winning the lock must not turn a search with plenty of hits into "no references". +- **`project.read` per candidate file, never once for the search.** A whole-workspace search holding the read lock start to finish would block every `project.write`, which is what index refresh needs. +- **The live-document await stays outside `project.read`.** The refresh it waits on needs `project.write`; awaiting it under the read lock deadlocks. Go-to-definition's R10 records the same constraint. +- `params.cancelChecker` is honoured per prefiltered file, between candidate files **and** between references within a file. The prefilter checks it per file rather than once for the pass: a whole-workspace scan is seconds of I/O, and cancelling has to stop it rather than let it finish and discard the result. + +The prefilter pass runs first, before any analysis, and takes no *analysis* lock at all (`computeFiles` takes `project.read` per file to resolve one path to a `VirtualFile`, but nothing is held across the pass). No progress count is shown - `launchCancellableAsyncWithProgress` takes a fixed `@StringRes`, and threading a live count through it would change a shared editor API for a cosmetic gain. No timeout and no file budget: the search finishes or the user cancels. + +**R10 - Panel cost.** `IDELanguageClientImpl.showLocations` used to read each result file **in full, once per hit, on the main thread** (`FileIOUtils.readFile2String` inside the per-location loop, plus an `exists()` stat per hit). That is a main-thread I/O violation and O(hits) file reads; Java's find-references had it too and simply rarely produced enough hits to hurt. + +Rewritten to: group locations by file, then one sequential `BufferedReader` pass per file pulling only the lines its ranges touch, retaining nothing before moving on. The disk pass runs **off** the main thread through `TaskExecutor`, which posts its callback back to the UI thread. + +A file with an **open editor** is still resolved **on** the UI thread. Its `Content` is live UI state that a background thread must not touch, and pulling a few lines out of it is substring work with no I/O. That is also what keeps unsaved edits reflected in the panel. + +Reads drop from O(hits) to O(files), peak memory is one line rather than one file (deliberately *not* a per-file content cache - holding every result file's text at once is the wrong trade on a phone), and the main thread does no I/O. This removes the need for a result cap, which would otherwise silently truncate. + +Because the publish is now asynchronous, it is also guarded: `showLocations` claims the panel with a request counter and captures `EditorViewModel.currentSearchGeneration`, and the callback publishes only if both still hold. Otherwise a slow request that started first would land last and overwrite the newer search the user is looking at. Panel visibility is committed *with* the rows for the same reason - a publish that never happens (superseded, or the activity recreated mid-read) must not leave the panel open with the "no results" placeholder hidden over the previous query's rows. + +Two behaviour changes, both improvements: a hit whose line no longer exists is dropped rather than yielding whatever `Content` returned, and a file whose every hit is stale is omitted rather than contributing an empty group. The grouping and line extraction live in `SearchResultGrouping` so they can be unit-tested; the activity call is a thin shell. + +**R11 - Not ready.** No `CompilationEnvironment` for the file (a script, a file outside the content roots), or no analysis session yet, answers empty and logs. There is no "still indexing" signal; that gap is cross-cutting across every LSP feature and is not solved here. + +**R12 - Failure isolation.** A resolution failure on one candidate file drops that file and continues - one unparseable file must not lose the whole result. So does an unreadable one in the prefilter pass, and one preempted past `retryingOnPreemption`'s single retry. A failure in the target-resolution phase returns empty. + +Preemption is not cancellation, and the two must not share a handler. Both unwind as a `CancellationException`, but a preempted request is still *wanted* - the user is watching the flashbar - so reporting empty for it is a wrong answer, while reporting empty for a cancelled one is invisible (the cancelled coroutine never reaches `onFindReferencesResult`). Hence a candidate file preempted past `retryingOnPreemption`'s single retry drops like any other failed candidate, and the target-resolution phase runs `planAt` twice - up to four underlying attempts - before giving up with a warning. Genuine cancellation short-circuits to empty at any depth. Nothing propagates an exception to the editor or leaves the progress flashbar up. + +## Non-goals + +- **Rename / safe-delete**, or anything that edits the usages found. +- **Usages via subclasses** (the down-walk). Blocked on `DirectInheritorsProvider.computeIndex()` being cached; filed separately. +- **Searching `.java` files** for usages of a Kotlin declaration. Filed separately. +- **Usages in test source sets.** Filed separately, as an LSP-wide content-root gap. +- **Implicit call sites as results** (see Scope). +- **KDoc `[link]`s as results.** Only `KtSimpleNameExpression`s are examined (R5). Go-to-definition navigates *from* a KDoc link, so this is an asymmetry, but a bounded one. +- **Library-source usages**, via decompilation or `-sources.jar`. +- **Categorising results** (imports vs calls vs type references) - the panel has no grouping beyond file. +- **A partiality signal.** `ReferenceResult` is shared with the Java and XML servers and has no field for it, and `showLocations` has no header slot; the same caveat already applies silently to test sources. +- **A gesture trigger.** Editor-wide UX change that would apply to Java too. + +## Acceptance criteria + +1. "Find references" appears in a Kotlin file's code-actions menu and is absent in a non-Kotlin file. +2. Same-file: a local function's call sites are listed. +3. Inter-file: usages of a class in a sibling file of the same module are listed. +4. Inter-module: usages in a dependent module are listed. +5. Invoked from a **reference** rather than a declaration, the result is the same set. +6. A `private` top-level declaration reports no usages from another file, even when that file contains a same-named unrelated declaration. +7. An `internal` declaration reports usages within its module only. +8. A local variable's usages are confined to its file. +9. `Foo()` is reported as a usage of `class Foo`. +10. An `import` of the target is reported as a usage. +11. A call dispatched via a workspace `Base.foo` is reported as a usage of `Derived.foo`, including when `Base` lives in a module `Derived`'s depends on. +12. A usage search on an override of `toString` does **not** report unrelated `.toString()` calls. +13. A usage typed into an open, unsaved file is reported. +14. A target with no usages flashes "No references found". +15. The target's own declaration never appears in the results. +16. Cancelling the progress flashbar mid-search leaves the editor responsive and unchanged. +17. Typing during a search does not discard it. +18. A search from a reference to a stdlib or framework symbol flashes "No references found". +19. A caret on whitespace, in a comment, or on a non-navigable keyword produces no search. +20. Invoking before the project finishes loading flashes "No references found" and does not crash or hang. +21. A result set spanning many files opens the panel without a main-thread stall. + +## Design + +Resolution goes through the Analysis API and PSI only; the symbol indexes are never consulted - see [ADR 0010](../adr/0010-navigation-resolves-via-analysis-api.md). That decision is load-bearing here for a second reason: there is no reference-search infrastructure to fall back on. `analysis-api-standalone-embeddable-for-ide` ships no `ReferencesSearch`, no `PsiSearchHelper` and no word index, and `KtFileMetadata` records declarations only. The search is built here. + +```text +FindReferencesAction.execAction lsp/kotlin/actions + -> ILspEditor.findReferences() editor (unchanged: progress flashbar + cancel checker) + -> KotlinLanguageServer.findReferences(params) + guards: settings.referencesEnabled(), DocumentUtils.isKotlinFile + compilationEnvironmentFor(params.file) ?: empty [R11] + -> context(env) { findUsagesAt(params) } navigation/FindUsages.kt + planWithRetry -> planAt(params): (twice, each retrying a preemption once) [R12] + ktFile = env.ktSymbolIndex.getCurrentKtFile(file).await() ?: empty [R5, R11] + env.project.read { + target = targetAtCaret(ktFile, offset) navigation/TargetAtCaret.kt [R2] + analyzeMaybeDangling(ktFile, COMMAND, cancelChecker) { + planFor(target) -> simpleName, matchSet pointers, scope [R3, R4, R6] + } + } + candidateFiles(plan, cancelChecker) [R5] + per candidate file: (retried once if preempted) [R9] + await live PSI if open (outside project.read) + env.project.read { + walk name references (PSI only); no hit -> skip the file [R5] + analyzeMaybeDangling(file, COMMAND, cancelChecker) { + restore pointers once, compare [R6] + } + } -> locations [R7] + <- ReferenceResult(locations) [R8] +``` + +New components: + +- **`navigation/TargetAtCaret.kt`** - `targetAtCaret(file: KtFile, offset: Int): CaretTarget?`, returning either a `Declaration` or a `Reference` so the resolution step does not re-derive which case it is looking at. Pure PSI, no analysis session, so R2's caret rules are testable without one. Shares `ReferenceAtCaret.kt`'s token accept-list, which becomes `internal`. It checks the leaf at the offset **and** the one before it, because `referenceAtCaret`'s single retry is not enough here: a caret just past `fun target` lands on `(`, which is navigable in its own right, so checking only that leaf made a caret one character past a declaration's name find nothing. +- **`navigation/FindUsages.kt`** - `planAt` (target, match set, scope) and the per-file resolve loop, reusing go-to-definition's `symbolsAt` and range helper. `planAt`, `SearchPlan` and `candidateFiles` are `internal` rather than private so the visibility ladder is directly assertable: it is *not* observable from a result set, since symbol matching means a same-named decoy can never be a false positive whatever the scope. +- **`SearchResultGrouping`** (in `app/`) - R10's grouping and line extraction. + +An **ambiguous** reference at the caret (overloads, broken code) searches for its first resolved candidate and logs. The alternative is a chooser the panel cannot host, and refusing to search would be worse. + +Touched existing components: + +- **`KotlinLanguageServer.findReferences`** - the stub's guards stay; it now delegates inside the file's `CompilationEnvironment`, matching how `findDefinition` and `signatureHelp` dispatch. +- **`navigation/ReferenceAtCaret.kt`** - visibility loosened for reuse. Behaviour unchanged, and its existing tests are kept as the proof of that. +- **`AnalysisPriority` / `AnalysisScheduler`** - the new `COMMAND` tier, plus `retryingOnPreemption`, which holds the two invariants every command's retry depends on: a fresh `ScheduledCancelChecker` per attempt (`preempt()` latches), and re-fetching the `KtFile` inside the attempt ([ADR 0011](../adr/0011-command-analysis-priority.md)). +- **`GoToDefinitionAction`, `OrganizeImportsAction`, `ImplementMembersAction`** - migrated to `COMMAND`; the latter two gain the retry they never had, and take the delegate `ICancelChecker` rather than a pre-wrapped one since wrapping is now per attempt. +- **`GoToDefinition.symbolsAt`** - `internal`, so the reference-at-caret resolution is shared rather than duplicated. +- **`services/ModuleDependentsProvider`** - its direct- and refinement-dependents maps are now accumulated across all modules instead of built per module and merged with `Map + Map`, which *replaced* a shared dependency's dependent set. R4's last row reads that map, so a module used by more than one other silently lost every dependent but the last. +- **`IDELanguageClientImpl.showLocations`** - R10's grouped streaming rewrite, plus its staleness guard. +- **`TooltipTag`** - one new constant (R1). + +Unchanged: `ReferenceParams`/`ReferenceResult`, `ILanguageServer`, `IDEEditor`, and every string resource. + +## Verification + +`flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest` and `:app:testV7DebugUnitTest`, split to match the helpers: + +- **`TargetAtCaretTest`** (13) - PSI only, no session. Caret on a function's / class's / property's / parameter's own name; caret on a reference rather than the enclosing declaration; one past a declaration's name; a local declaration inside a lambda; a destructuring entry targeting the local rather than `componentN`; an operator; whitespace / comment / non-navigable keyword. One case asserts the contrast directly: the same caret that `referenceAtCaret` rejects still yields a target. +- **`ReferenceAtCaretTest`** - kept as-is, as the regression proof that loosening visibility changed no behaviour. +- **`FindUsagesTest`** (20) - the `lib` + `app(dependsOn = lib)` fixture from ADFA-4823: the three resolution scopes; each row of R4's visibility ladder, asserted on the plan's scope rather than the result set; R3's super-walk, workspace-boundary cutoff and constructor expansion; imports; a Java-source target; a same-named decoy in another package; ordering; property reads and writes; a stdlib reference; a caret that names nothing; and a pre-cancelled request. +- **`FindUsagesLiveDocumentTest`** (2) - R5's live tier, which needs `enableParserEventSystem`: a usage that exists only in an unsaved buffer is found, and one deleted in the buffer but still on disk is not. +- **`AnalysisSerializationTest`** (+5) - `COMMAND`'s three ordering properties, plus `retryingOnPreemption`'s one-retry-with-a-fresh-checker contract and its refusal to loop. +- **`KotlinCodeActionTooltipTagTest`** - the new tag row. +- **`SearchResultGroupingTest`** (10, in `:app`) - single-line and multi-line hits, a hit on a line that no longer exists, a column past its line's end, only-the-wanted-lines collection, a short file, an unreadable file, and several hits in one file from one read. + +Not unit-testable, so covered by on-device QA via the "Steps to QA" field on ADFA-4824: the menu item and its tooltip tag, the panel with a large result set, cancelling mid-search, and typing during a search without losing it. + +## Related + +- [docs/features/kotlin-goto-definition.md](kotlin-goto-definition.md) - the sibling feature whose helpers and fixture this reuses +- [ADR 0010](../adr/0010-navigation-resolves-via-analysis-api.md) - navigation resolves via the Analysis API, not the symbol index +- [ADR 0011](../adr/0011-command-analysis-priority.md) - user-invoked commands get their own analysis priority +- [ARCHITECTURE.md](../../ARCHITECTURE.md) diff --git a/docs/features/kotlin-goto-definition.md b/docs/features/kotlin-goto-definition.md index 30cf72a7ba..8bd6d0c872 100644 --- a/docs/features/kotlin-goto-definition.md +++ b/docs/features/kotlin-goto-definition.md @@ -111,7 +111,7 @@ Both rules are enforced by construction rather than by filtering afterwards: an ## Non-goals -- **Find usages** - ADFA-4824, the sibling subtask. It will share the reference-at-caret resolution helper. +- **Find usages** - ADFA-4824, the sibling subtask; see [kotlin-find-usages.md](kotlin-find-usages.md). - **Go-to-implementation.** A call through an interface or abstract member resolves to the declaring member only. Walking down to overriding implementations needs an inheritance search over the workspace. - **Go-to-super.** - **Library-source navigation**, via decompilation, generated stubs, or `-sources.jar` extraction. @@ -161,7 +161,7 @@ The dispatch mirrors `signatureHelp` line for line, which is what buys R3 and R1 Touched components: - **`KotlinLanguageServer.findDefinition`** - guards stay (`definitionsEnabled()`, `isKotlinFile`), then delegates inside the file's `CompilationEnvironment`, matching how `signatureHelp` and `analyze` already dispatch. A `.kts` has no environment, so the lookup returns null there and the request answers empty. -- **`navigation/ReferenceAtCaret.kt`** - `referenceAtCaret(file: KtFile, offset: Int): KtElement?`. Pure PSI, no analysis session: the caret-token accept-list, the `offset - 1` retry, and the two-level climb (R2). ADFA-4824 imports this verbatim; it needs the reference element, not the declarations. +- **`navigation/ReferenceAtCaret.kt`** - `referenceAtCaret(file: KtFile, offset: Int): KtElement?`. Pure PSI, no analysis session: the caret-token accept-list, the `offset - 1` retry, and the two-level climb (R2). ADFA-4824 reuses its accept-list and retry, but not the function: this deliberately resolves nothing when the caret is on a declaration's own name, which is exactly where find usages is invoked from. See [kotlin-find-usages.md](kotlin-find-usages.md) R2. - **`navigation/GoToDefinition.kt`** - `findDefinitionAt(params)` under `context(env: CompilationEnvironment)`. The two symbol paths (R4), then symbol -> source PSI -> name-identifier range -> `Location`, with dedup, ordering, cancellation and failure isolation (R5, R6, R10, R11). - **`GoToDefinitionAction` in `lsp/kotlin/actions`** extending `BaseKotlinCodeAction`, id `ide.editor.lsp.kt.gotoDefinition` (the prefix every other Kotlin action uses), `requiresUIThread = true` like Java's, registered in `KotlinCodeActionsMenu` after the comment actions - the same slot Java uses. - **`TooltipTag.EDITOR_CODE_ACTIONS_KT_GOTO_DEF`** - one new constant (R1). diff --git a/docs/plugin-api.md b/docs/plugin-api.md index fb03b91287..43c52cd0d1 100644 --- a/docs/plugin-api.md +++ b/docs/plugin-api.md @@ -12,6 +12,7 @@ The surface a plugin binds to is broader than one module. All of the following a - Core: `IPlugin` (lifecycle), `PluginContext`, `PluginLogger`, `ServiceRegistry`, `ResourceManager`. - Extension interfaces plugins **implement**: `UIExtension`, `EditorExtension`, `EditorTabExtension`, `DocumentationExtension`, `BuildActionExtension`, `SnippetExtension`, `ProjectExtension`, `FileOpenExtension`, `SettingsExtension`. - IDE service interfaces plugins **call** (via `ServiceRegistry.get(X::class.java)`): `IdeProjectService`, `IdeEditorService`, `IdeFileService`, `IdeEnvironmentService`, `IdeArchiveService`, `IdeBuildService`, `IdeUIService`, `IdeEditorTabService`, `IdeTooltipService`, `IdeThemeService`, `IdeFeatureFlagService`, `IdeCommandService`, `IdeTemplateService`, `IdeSnippetService`, `IdeSidebarService`. + - Cross-plugin service interfaces, where **one plugin implements what another calls** (via `SharedServices`): `LlmInferenceService` — implemented by ai-core, called by every AI plugin — together with the types nested in it that a *backend* plugin implements (`LlmBackend`, `HistoryCapableBackend`, `ToolCallingBackend`, `CancellableBackend`, `ConfigurableBackend`) and the value types either side constructs (`ChatMessage`, `LlmConfig`, `LlmResponse`, `SystemPromptRequest`, `ToolDefinition`, `ToolCallRequest`). Also `ToolSourceRegistry` — implemented by ai-core, called by any plugin contributing tools to the agent — with `ToolSource` and `ToolSpec`, which a *contributing* plugin implements, `ToolInvocation`, which ai-core constructs and passes to `ToolSource.invoke`, and `ToolOutcome`, which the source returns. - Data classes plugins **construct** (e.g. `MenuItem`, `TabItem`, `EditorTabItem`, `NavigationItem`, `ToolbarAction`, `FabAction`, `PluginBuildAction`, `SnippetContribution`, `PluginTooltipEntry`, `PluginSettingsEntry`). - Enums / sealed types plugins **reference**: `PluginPermission`, `ShowAsAction`, `ArchiveFormat`, `BuildActionCategory`, `ToolbarActionIds`, `CommandSpec`, `CommandResult`, `ExtractResult`. - **Wire/format contracts outside the module:** @@ -35,9 +36,10 @@ When the API is later frozen, this doc gains a formal compatibility guarantee an These look source-compatible but break already-built `.cgp` plugins: - **Data-class constructor parameters.** Adding a parameter *even with a default value* changes the synthetic constructor and `copy()` signatures — binary-incompatible for any plugin that constructs or copies the class (`MenuItem`, `PluginBuildAction`, `SnippetContribution`, …). If compatibility matters, add a secondary constructor or a builder instead. -- **Interface methods — direction matters.** +- **Interface methods — direction matters.** Ask who implements the interface before you apply a rule; the answer is not "host" just because the name ends in `Service`. - *Extension interfaces* (`UIExtension`, `BuildActionExtension`, …) are implemented **by plugins**: adding a method is breaking for them (even a defaulted one can break depending on compilation). Provide defaults and prefer additive optional hooks. - - *Service interfaces* (`Ide*Service`) are implemented **by the host** and only called by plugins: **adding** a method is safe; changing or removing a signature is breaking. + - *Host service interfaces* (`Ide*Service`) are implemented **by the host** and only called by plugins: **adding** a method is safe; changing or removing a signature is breaking. + - *Plugin-implemented service interfaces* (`LlmInferenceService` and the backend interfaces nested in it) are implemented **by a plugin** even though they are shaped like services. The extension-interface rule applies, not the host-service one: **adding** a method is breaking. A Kotlin implementor's existing method loses its `override` when a Java `default` appears above it, so the break is a compile error in the *other* repo — which the impact check below is what catches. Prefer a new interface extending the old one over a new method on it. - **Enum constants.** Removing or renaming a constant (`PluginPermission`, `ShowAsAction`, `ArchiveFormat`, `ToolbarActionIds`, `BuildActionCategory`) breaks plugins that name it; adding one can still break an exhaustive `when`. - **Types & nullability.** Flipping nullable↔non-null, changing a parameter/return type, or `val`↔`var` on an API property. - **Moving or renaming** any class/package under `com.itsaky.androidide.plugins.*` — breaks imports and `ServiceRegistry.get(...)` lookups. diff --git a/docs/process/build-ci-glossary.md b/docs/process/build-ci-glossary.md new file mode 100644 index 0000000000..bc6de22bd7 --- /dev/null +++ b/docs/process/build-ci-glossary.md @@ -0,0 +1,60 @@ +# Build and CI glossary + +Vocabulary for build and CI work on Code On The Go. Terms are defined here so that a +word means one thing across code, tickets, PRs, and conversation. + +This file is a **glossary only**. It holds no implementation detail and no decision +rationale - decisions live in [docs/adr/](../adr/), structure lives in +[ARCHITECTURE.md](../../ARCHITECTURE.md). + +## Terms + +**Critical path** +The longest chain of work that must finish before CI reports a verdict. Work that runs +concurrently on another runner is not on the critical path even though it costs time. +Distinct from *runner occupancy*. + +**Runner occupancy** +Total runner-minutes a single push consumes, summed across every job it starts. A push +can have a short critical path and high occupancy (two runners busy in parallel). +Occupancy is what makes other people's builds queue; critical path is what makes one +developer wait. Reducing one can increase the other. + +**ABI change** (of a module) +A change to a module's public compile-time surface: signatures, public constants, +anything a dependent module compiles against. Dependents must recompile. Contrast +*non-ABI change* (a method body, a comment) where dependents need not recompile. +Java and Kotlin **inline** compile-time constants such as `static final String`, so +changing a constant's *value* is an ABI change even though the declaration is untouched. + +**ABI churn** +An ABI change that carries no semantic meaning for dependents, forcing recompilation +for nothing. Build metadata stamped into a widely-depended-on module is the canonical +source - see [ADR 0012](../adr/0012-volatile-build-metadata-out-of-abis.md). + +**Build graph health** +How closely the set of re-executed tasks matches the set of genuinely affected tasks. +Measured as the ratio of `executed` to `up-to-date`/`from-cache` tasks in Gradle's +summary line. Independent of hardware, and therefore comparable across machines - +unlike wall clock. + +**Baseline** +A recorded measurement of the pipeline before a change, against which later iterations +are compared. A measurement is only a baseline if it was produced under the same +protocol and scenario as the runs compared to it. + +**Scenario** +A deterministic, scripted source change of defined scope, used as a measurement +workload. Scenarios differ in blast radius - no-op, single leaf module, ABI change in +a core module, multi-module - so one pipeline produces a profile rather than a number. + +**Warm workspace** +A checkout whose `build/` outputs and Gradle caches survive from a previous run. The +steady state of a self-hosted runner, and the state any representative measurement must +reproduce. Contrast a *cold* build, which no runner ever performs in practice. + +## Related + +- [ARCHITECTURE.md](../../ARCHITECTURE.md) - module map, layering, tech stack. +- [docs/adr/](../adr/) - the decisions and their rationale. +- [CLAUDE.md](../../CLAUDE.md) - build and test invocations. diff --git a/docs/process/learnings.md b/docs/process/learnings.md index 852a02c8b3..7c4224a00d 100644 --- a/docs/process/learnings.md +++ b/docs/process/learnings.md @@ -19,6 +19,11 @@ ## Measuring a real before/after delta - To measure an actual size/perf delta for a change (not just estimate it), use `git worktree add `, build there, and diff the artifacts — avoids disturbing the current working tree or stashing. +## SQLite CLI scripting +- The sqlite3 CLI's `.system` dot-command can hit a content-dependent shell-parsing failure when a line chains multiple operators (`;`, `&&`, `||`, parentheses) — reproduces for some strings and not others, so it won't show up in a quick smoke test. Keep each `.system` line to one plain `command | pipe > file`. +- `.bail on` is required for a `BEGIN;...COMMIT;`-wrapped script to actually be atomic: without it, a mid-script SQL error prints to stderr but the script keeps going, including reaching the final `COMMIT`, which persists whatever succeeded before the error. `.bail` also can't see `.system` shell failures directly — if a step's success depends on a shell command's exit status, assert it in SQL (e.g. a temp table with a `CHECK` constraint) rather than relying on `.bail` to catch it. +- Don't write a `.system` command's output to a fixed, guessable filename directly under `/tmp` (CWE-377) — another local user could pre-plant a symlink there or race the write against your later read. Create an owner-only working directory instead (`rm -rf` it, then `mkdir -m 700` it — the mode is set atomically at creation, so there's no window where it's briefly wider), write everything under that, and remove it when done. `mkdir` itself can fail (e.g. another user recreates the path between the `rm -rf` and the `mkdir`) — that's a `.system` failure `.bail` won't catch either, so assert the directory's mode in SQL before trusting it, the same way you'd guard the Brotli step above. A fresh `mktemp -d` per run would be even better, but it doesn't fit this script shape: each `.system` line is its own subshell, so a path it generates can't be carried into later `.system`/`READFILE()` calls without writing it to another fixed, guessable file first. + ## Kotlin LSP test harness - Disposing the `KtLspTestEnvironment` in a unit test (`env.close()`, or `Disposer.dispose(env.project)`) throws `AssertionError: Write access is allowed inside write-action only`. IntelliJ requires model teardown to run inside a write action. This is why `KtLspTestRule`'s teardown has `env.close()` commented out as "fails in test cases". To dispose deterministically in a test, wrap it: `ApplicationManager.getApplication().runWriteAction { env.close() }`. - The index/compilation environment lifecycle is racy: background `IndexWorker` coroutines call `PsiManager.findFile(project)` and will crash with `Project is already disposed` if the project is disposed before the workers are stopped. Always stop & join `KtSymbolIndex.close()` (and cancel related scopes) before `Disposer.dispose(...)`. diff --git a/docs/process/retrospective.md b/docs/process/retrospective.md index 4285d3c04a..fb4eeadd34 100644 --- a/docs/process/retrospective.md +++ b/docs/process/retrospective.md @@ -1,5 +1,50 @@ # Retrospective Log +## 2026-08-13 - ADFA-5088: individual Preferences/Plugin Manager tooltips + docdb SQL scripts + +### Time Breakdown + +| Started | Phase | 👤 Hands-On Time | 🤖 Agent Time | Problems | +|---------|-------|-----------------|---------------|----------| +| Aug 12, 7:38am | Setup & research (branch, ticket, docdb schema + Preferences tag investigation via 2 background agents) | ▏ ~3m | ████ 42m | | +| Aug 13, 5:12am | Implement fixup commits + fold in Plugin Manager screen (investigated via background agent, then implemented) | ▌ ~5m | █████ 45m | ⚠ mid-session scope addition | +| Aug 13, 6:01am | Architecture review + open PR + Jira update | ▏ ~1m | ███ 28m | | +| Aug 13, 6:30am | Code review response — verified findings, discovered stale local DB, rewrote SQL scripts | ▋ ~6m | █ 13m | ⚠ near-miss: caught mid-review only because the user pushed back | +| Aug 13, 6:49am | Fixups, ADFA-5121 follow-up ticket, wrap-up | ▎ ~2m | ▋ 7m | | +| Aug 13, ~7:00am | Second review round: fail-fast SQL fix (`.bail on` + guard table), validated against user-supplied real DB copies (est.) | ▌ ~8m | ████ 35m | ⚠ `.system` shell-parsing rabbit hole before finding the right fix | +| Aug 13, ~8:00am | Retro + 2 follow-up PRs (docdb doc gotchas, CLAUDE.md provenance rule) (est.) | ▊ ~10m | ███ 25m | | + +*(A ~20.9h overnight gap between the first two phases is excluded from the bars/percentages below as idle time, not work. The last two rows are estimated from context, not re-run through the transcript-analysis script.)* + +### Metrics + +| Metric | Duration | +|--------|----------| +| Total active wall-clock | ~4h | +| Hands-on | ~35 min (15%) | +| Automated agent time | ~195 min (85%) | +| Idle (overnight, between sessions) | ~20.9h (excluded above) | +| Retro analysis time | ~3 min (script run) + manual extension for later phases | + +### Key Observations +- **The one real near-miss**: a SQL script was built and validated against `assets/documentation.db` — a 213MB file that's `.gitignore`d and downloaded by a Gradle task, not a committed repo asset. Its schema and content were treated as ground truth (including writing "confirmed via sqlite3" claims into the script's own header) without ever running `git ls-files`/`git check-ignore` on it. The local copy was stale; the real database already had curated production content for 5 of the tags the script was about to write to, which would have been silently overwritten. Caught only because the user independently checked the schema and pushed back. +- **Second review round found a related, second-order bug**: a `BEGIN;...COMMIT;` wrapper without `.bail on` doesn't actually give atomicity — verified empirically that a mid-script SQL error still lets `COMMIT` through with whatever succeeded before it. Fixed with `.bail on` plus a temp-table `CHECK` constraint that turns a silently-empty Brotli payload into a catchable SQL error. +- **A costly (but ultimately abandoned) detour**: significant time went into reverse-engineering a content-dependent shell-parsing failure in the sqlite3 CLI's `.system` dot-command (some strings triggered a dash syntax error, most didn't, with no clean single hypothesis found). The eventual fix sidestepped the problem entirely — kept `.system` lines simple and did the fail-fast check in SQL instead of shell chaining — rather than continuing to chase the CLI quirk's root cause. +- **Good pattern reinforced twice**: both times a bulk SQL rewrite was needed under time pressure, it was done via a small Python script parsing and regenerating the statements programmatically, rather than hand-editing 60+ lines — this avoided introducing new content errors while doing a structural change. +- **Real-world validation loop with the user**: the user independently ran the scripts against copies of the real database (`.save`, current, `.new`) and handed back concrete artifacts (file paths, MD5 comparison) rather than descriptions — this was more useful than any amount of scratch-DB testing alone, and surfaced that an earlier script version had already partially, successfully applied to the "before" copy. + +### Feedback +**What worked:** Not directly stated this session — inferred from the user's engagement pattern (quick short replies, handing over real artifacts to check rather than describing them). +**What didn't:** "Don't make assumptions about large binary files. They may be maintained and updated outside the repository." (direct user feedback, in response to the stale-DB near-miss) + +### Actions Taken + +| Issue | Action Type | Change | +|-------|-------------|--------| +| No standing guidance against treating a large binary asset's on-disk content as ground truth without checking provenance | CLAUDE.md | Added a bullet to "Project-specific constraints": check `git ls-files`/`git check-ignore` and how an asset is provisioned before trusting its schema/content — generalizes beyond docdb to ~6 other gitignored, externally-fetched assets in `app/build.gradle.kts` | +| `documentation.db`-specific provenance and SQL-authoring gotchas (`.system` chaining, `.bail on` + guard-table pattern) not documented anywhere a future SQL-script author would find them | Doc | `docs/documentation-database.md` updated via PR #1666 (ADFA-5123): provenance warning in "Where it lives", new "Writing one-off SQL scripts against this database" subsection | +| Dead `UseSytemShell` preference (class never instantiated, underlying setting never read elsewhere) found while auditing for tooltip coverage | Ticket | Filed ADFA-5121 | + ## 2026-07-24 - LeakCanary icon shrink (ADFA-4843), JAXP/PDF.js investigations (ADFA-1491/ADFA-3304), and full blankj:utilcodex removal (ADFA-4649) ### Time Breakdown diff --git a/docs/superpowers/plans/2026-07-28-inject-plugin-coordinates-localmvnrepository.md b/docs/superpowers/plans/2026-07-28-inject-plugin-coordinates-localmvnrepository.md deleted file mode 100644 index 194c959950..0000000000 --- a/docs/superpowers/plans/2026-07-28-inject-plugin-coordinates-localmvnrepository.md +++ /dev/null @@ -1,601 +0,0 @@ -# Inject plugin-api + builder coordinates into localMvnRepository — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** During CoGo onboarding, materialize plugin-api (fat compile jar), plugin-builder, and the `com.itsaky.androidide.plugins.build` marker into the on-device `localMvnRepository` as real Maven coordinates, so plugins resolve them by coordinate, offline, with no `libs/*.jar`. - -**Architecture:** Build-time, the host CoGo build assembles a small Maven-layout zip (`plugin-maven-repo.zip`): a fat `com.itsaky.androidide:plugin-api:1.0.0` jar (merged classes of plugin-api + common + eventbus-events + idetooltips, dependency-free POM) plus the builder impl + POM + marker emitted by real `maven-publish`. On-device, the two installers extract that zip into `LOCAL_MAVEN_DIR` **inside** the existing `localMvnRepository` branch (after its wipe+extract, via the non-wiping `extractZipToDir`) to avoid a wipe/concurrency race. - -**Tech Stack:** Gradle Kotlin DSL, `maven-publish` + `java-gradle-plugin`, AGP `com.android.library`, Kotlin, brotli4j, java.nio zip. Build wrapped in `flox activate -d flox/local -- ./gradlew`. - -## Global Constraints - -- **Build wrapper:** every Gradle call is `flox activate -d flox/local -- ./gradlew `. -- **Worktree:** work in `~/src/cogo/ADFA-4911` (branch `ADFA-4911-inject-plugin-jars-localmvn`); `app/google-services.json` already copied in. -- **Coordinates:** `com.itsaky.androidide:plugin-api:1.0.0` (jar), `com.itsaky.androidide.plugins:plugin-builder:1.0.0`, marker `com.itsaky.androidide.plugins.build:com.itsaky.androidide.plugins.build.gradle.plugin:1.0.0`. Version `1.0.0` everywhere. -- **Do NOT** add `plugin-maven-repo.zip` to `AssetsInstallationHelper.expectedEntries` — it must not become a concurrent install job (would race the `LOCAL_MAVEN_DIR` wipe). It is applied inside the `localMvnRepository` branch only. -- **Do NOT** touch the `plugin-artifacts.zip → .cg/plugin-api/` flow (still feeds `isPluginProject` until ADFA-4913) or the harvest pipeline. The plugin-api / common / eventbus-events / idetooltips module build files **are** edited — pinned to Kotlin `languageVersion`/`apiVersion` 2.0 so their metadata is readable by the on-device Kotlin 1.9.22 compiler. -- **Fat-jar harvest paths:** plugin-api `intermediates/aar_main_jar/release/syncReleaseLibJars/classes.jar`; the other three (v7/v8 flavored) `intermediates/aar_main_jar/v8Release/syncV8ReleaseLibJars/classes.jar`. -- **Code style:** tabs, LF; run `spotlessApply` before any commit that touches Kotlin/gradle.kts. Branch name already matches `ADFA-#####`. -- **Links:** the Maven POM `xmlns="http://maven.apache.org/POM/4.0.0"` is a standard XML **namespace identifier**, never dereferenced (no network) — it is required for a well-formed POM and is the one allowed http string. - ---- - -### Task 1: Publish plugin-builder to a build-dir Maven repo (impl POM + marker) - -**Files:** -- Modify: `plugin-api/plugin-builder/build.gradle.kts` - -**Interfaces:** -- Produces: a Maven layout under `plugin-api/plugin-builder/build/plugin-maven-repo/` containing - `com/itsaky/androidide/plugins/plugin-builder/1.0.0/plugin-builder-1.0.0.{jar,pom}` and - `com/itsaky/androidide/plugins/build/com.itsaky.androidide.plugins.build.gradle.plugin/1.0.0/*.pom`. -- Produces: publish task `publishAllPublicationsToPluginMavenRepoRepository` (referenced by Task 3). - -- [ ] **Step 1: Add `maven-publish`, a build-dir repo, and disable module metadata** - -Edit `plugin-api/plugin-builder/build.gradle.kts`: - -```kotlin -plugins { - `kotlin-dsl` - `maven-publish` -} - -group = "com.itsaky.androidide.plugins" -version = "1.0.0" - -dependencies { - // compileOnly so the published POM stays dependency-free; the on-device build - // provides AGP (agp-tooling 8.11.0, as shipped in localMvnRepository). - compileOnly("com.android.tools.build:gradle:8.11.0") -} - -gradlePlugin { - plugins { - create("pluginBuilder") { - id = "com.itsaky.androidide.plugins.build" - implementationClass = "com.itsaky.androidide.plugins.build.PluginBuilder" - displayName = "Code on the Go Plugin Builder" - description = "Gradle plugin for building Code on the Go plugins" - } - } -} - -publishing { - repositories { - maven { - name = "pluginMavenRepo" - url = uri(layout.buildDirectory.dir("plugin-maven-repo")) - } - } -} - -// Ship POMs only (parity with the harvested repo); marker/plugin resolution works off POMs. -tasks.withType().configureEach { enabled = false } - -tasks.withType { - compilerOptions { - apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_1) - languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_1) - } -} -``` - -`java-gradle-plugin` (auto-applied by `kotlin-dsl`) auto-creates the `pluginMaven` (impl) and `pluginBuilderPluginMarkerMaven` (marker) publications; `maven-publish` adds the `publishAllPublicationsToPluginMavenRepoRepository` task. - -- [ ] **Step 2: Run the publish task and confirm the exact task name** - -Run: `flox activate -d flox/local -- ./gradlew -p plugin-api/plugin-builder tasks --all | grep -i publish` -Expected: a line `publishAllPublicationsToPluginMavenRepoRepository`. If the name differs, use the actual name in Task 3. - -- [ ] **Step 3: Publish and inspect the output layout** - -Run: -```bash -flox activate -d flox/local -- ./gradlew -p plugin-api/plugin-builder publishAllPublicationsToPluginMavenRepoRepository -find plugin-api/plugin-builder/build/plugin-maven-repo -type f | sort -``` -Expected files (no `.module`): -``` -.../com/itsaky/androidide/plugins/build/com.itsaky.androidide.plugins.build.gradle.plugin/1.0.0/com.itsaky.androidide.plugins.build.gradle.plugin-1.0.0.pom -.../com/itsaky/androidide/plugins/plugin-builder/1.0.0/plugin-builder-1.0.0.jar -.../com/itsaky/androidide/plugins/plugin-builder/1.0.0/plugin-builder-1.0.0.pom -``` - -- [ ] **Step 4: Verify the POMs carry the right dependencies** - -Run: `grep -A3 -i "artifactId" plugin-api/plugin-builder/build/plugin-maven-repo/com/itsaky/androidide/plugins/plugin-builder/1.0.0/plugin-builder-1.0.0.pom` -Expected: the impl POM is **dependency-free** (AGP is `compileOnly`, so excluded from the published POM; the on-device build supplies it). The marker POM depends on `com.itsaky.androidide.plugins:plugin-builder:1.0.0`: -Run: `grep -i "plugin-builder" plugin-api/plugin-builder/build/plugin-maven-repo/com/itsaky/androidide/plugins/build/*/1.0.0/*.pom` -Expected: a `` on `plugin-builder` `1.0.0`. - -- [ ] **Step 5: Commit** - -```bash -cd ~/src/cogo/ADFA-4911 -flox activate -d flox/local -- ./gradlew spotlessApply -git add plugin-api/plugin-builder/build.gradle.kts -git commit -m "ADFA-4911: Publish plugin-builder (impl POM + Gradle plugin marker) to a build-dir maven repo" -``` - ---- - -### Task 2: Assemble the fat plugin-api jar - -**Files:** -- Modify: `app/build.gradle.kts` (add task near the existing `createPluginArtifactsZip`, ~L435) - -**Interfaces:** -- Produces: `app/build/plugin-maven-repo-staging/plugin-api-1.0.0.jar` — a jar containing the merged main classes of `:plugin-api`, `:common`, `:eventbus-events`, `:idetooltips`. - -- [ ] **Step 1: Register the fat-jar task** - -Add to `app/build.gradle.kts` (after `createPluginArtifactsZip`, before `createAssetsZip`): - -```kotlin -// Fat compile-only jar published as com.itsaky.androidide:plugin-api:1.0.0. -// Merges the API surface plugins already compile against (plugin-api + common + -// eventbus-events + idetooltips) into one coordinate. The three add-ons are -// v7/v8-flavored (unlike plugin-api); their classes are ABI-neutral so v8 is used. -tasks.register("assemblePluginApiFatJar") { - dependsOn( - ":plugin-api:assembleRelease", - ":common:assembleV8Release", - ":eventbus-events:assembleV8Release", - ":idetooltips:assembleV8Release", - ) - archiveFileName.set("plugin-api-1.0.0.jar") - destinationDirectory.set(layout.buildDirectory.dir("plugin-maven-repo-staging")) - duplicatesStrategy = DuplicatesStrategy.EXCLUDE - - from(zipTree(project(":plugin-api").layout.buildDirectory - .file("intermediates/aar_main_jar/release/syncReleaseLibJars/classes.jar").get().asFile)) - from(zipTree(project(":common").layout.buildDirectory - .file("intermediates/aar_main_jar/v8Release/syncV8ReleaseLibJars/classes.jar").get().asFile)) - from(zipTree(project(":eventbus-events").layout.buildDirectory - .file("intermediates/aar_main_jar/v8Release/syncV8ReleaseLibJars/classes.jar").get().asFile)) - from(zipTree(project(":idetooltips").layout.buildDirectory - .file("intermediates/aar_main_jar/v8Release/syncV8ReleaseLibJars/classes.jar").get().asFile)) -} -``` - -- [ ] **Step 2: Build the fat jar** - -Run: `flox activate -d flox/local -- ./gradlew :app:assemblePluginApiFatJar` -Expected: BUILD SUCCESSFUL; `app/build/plugin-maven-repo-staging/plugin-api-1.0.0.jar` exists. If a `classes.jar` path is wrong, the build fails on a missing zip input — fix the path (verify with `find /build/intermediates/aar_main_jar -name classes.jar`). - -- [ ] **Step 3: Verify the jar contains a class from each of the 4 modules** - -Run: -```bash -unzip -l app/build/plugin-maven-repo-staging/plugin-api-1.0.0.jar | \ - grep -E "com/itsaky/androidide/(plugins/api|common|eventbus|idetooltips)" | head -``` -Expected: at least one `.class` under each of the four package roots (`plugins/api`, `common`, `eventbus`, `idetooltips`). If any is missing, that module's `classes.jar` path is wrong. - -- [ ] **Step 4: Commit** - -```bash -cd ~/src/cogo/ADFA-4911 -flox activate -d flox/local -- ./gradlew spotlessApply -git add app/build.gradle.kts -git commit -m "ADFA-4911: Assemble fat plugin-api jar (plugin-api + common + eventbus-events + idetooltips)" -``` - ---- - -### Task 3: Write the plugin-api POM and assemble `plugin-maven-repo.zip` - -**Files:** -- Modify: `app/build.gradle.kts` (add `writePluginApiPom` + `createPluginMavenRepoZip` after Task 2's task) - -**Interfaces:** -- Consumes: Task 1's `publishAllPublicationsToPluginMavenRepoRepository`; Task 2's `assemblePluginApiFatJar`. -- Produces: `assets/plugin-maven-repo.zip` — a Maven layout with all three coordinates. - -- [ ] **Step 1: Register the POM writer and the zip assembler** - -Add to `app/build.gradle.kts` (after `assemblePluginApiFatJar`): - -```kotlin -// Dependency-free POM for the fat plugin-api coordinate: it is compile-only/provided, -// so it must NOT drag transitives that would need offline resolution. -tasks.register("writePluginApiPom") { - val pomFile = layout.buildDirectory.file("plugin-maven-repo-staging/plugin-api-1.0.0.pom") - outputs.file(pomFile) - doLast { - pomFile.get().asFile.writeText( - """ - - 4.0.0 - com.itsaky.androidide - plugin-api - 1.0.0 - jar - -""", - ) - } -} - -// Assembles the shippable Maven layout: the fat plugin-api coordinate + the -// builder impl/POM/marker published by the plugin-builder included build. -tasks.register("createPluginMavenRepoZip") { - dependsOn("assemblePluginApiFatJar", "writePluginApiPom") - dependsOn(gradle.includedBuild("plugin-builder") - .task(":publishAllPublicationsToPluginMavenRepoRepository")) - - archiveFileName.set("plugin-maven-repo.zip") - destinationDirectory.set(rootProject.file("assets")) - - into("com/itsaky/androidide/plugin-api/1.0.0") { - from(layout.buildDirectory.file("plugin-maven-repo-staging/plugin-api-1.0.0.jar")) - from(layout.buildDirectory.file("plugin-maven-repo-staging/plugin-api-1.0.0.pom")) - } - // Builder tree is already in Maven layout (com/itsaky/androidide/plugins/...). - from(rootProject.file("plugin-api/plugin-builder/build/plugin-maven-repo")) -} -``` - -- [ ] **Step 2: Build the zip** - -Run: `flox activate -d flox/local -- ./gradlew :app:createPluginMavenRepoZip` -Expected: BUILD SUCCESSFUL; `assets/plugin-maven-repo.zip` exists. - -- [ ] **Step 3: Verify the coordinate layout inside the zip** - -Run: `unzip -l assets/plugin-maven-repo.zip | grep -E "1.0.0/" | sort` -Expected exactly these artifact paths (order aside): -``` -com/itsaky/androidide/plugin-api/1.0.0/plugin-api-1.0.0.jar -com/itsaky/androidide/plugin-api/1.0.0/plugin-api-1.0.0.pom -com/itsaky/androidide/plugins/plugin-builder/1.0.0/plugin-builder-1.0.0.jar -com/itsaky/androidide/plugins/plugin-builder/1.0.0/plugin-builder-1.0.0.pom -com/itsaky/androidide/plugins/build/com.itsaky.androidide.plugins.build.gradle.plugin/1.0.0/com.itsaky.androidide.plugins.build.gradle.plugin-1.0.0.pom -``` - -- [ ] **Step 4: Commit** - -```bash -cd ~/src/cogo/ADFA-4911 -flox activate -d flox/local -- ./gradlew spotlessApply -git add app/build.gradle.kts -git commit -m "ADFA-4911: Assemble plugin-maven-repo.zip (plugin-api coordinate + builder + marker)" -``` - ---- - -### Task 4: Register `plugin-maven-repo.zip` as a shipped asset (bundled `.br` + split zip) - -**Files:** -- Modify: `composite-builds/build-deps-common/constants/src/main/java/org/adfa/constants/constants.kt` -- Modify: `composite-builds/build-logic/plugins/src/main/java/com/itsaky/androidide/plugins/AndroidIDEAssetsPlugin.kt` -- Modify: `app/build.gradle.kts` (`createAssetsZip` file list ~L455-464; `assembleV8Assets`/`assembleV7Assets` deps ~L486-503) - -**Interfaces:** -- Consumes: Task 3's `assets/plugin-maven-repo.zip`. -- Produces: constant `PLUGIN_MAVEN_REPO_ZIP_NAME = "plugin-maven-repo.zip"` and `PLUGIN_MAVEN_REPO_ZIP_BR`; bundled common asset `data/common/plugin-maven-repo.zip.br`; split entry `plugin-maven-repo.zip` inside `assets-.zip`. (Task 5 consumes these.) - -- [ ] **Step 1: Add the asset-name constants** - -In `constants.kt`, after the Local-maven-repo block (`LOCAL_MAVEN_REPO_FOLDER_DEST`, ~L61): - -```kotlin -// Plugin maven-repo overlay (plugin-api + plugin-builder coordinates + marker) -const val PLUGIN_MAVEN_REPO_ZIP_NAME = "plugin-maven-repo.zip" -const val PLUGIN_MAVEN_REPO_ZIP_BR = "${PLUGIN_MAVEN_REPO_ZIP_NAME}.br" -``` - -- [ ] **Step 2: Register the per-build brotli copier for bundled builds** - -In `AndroidIDEAssetsPlugin.kt`, mirror `registerPluginArtifactsCopierTask` (~L80-107) with a new function, and call it from the `onVariants` block (after the plugin-artifacts copier registration, ~L75). The copier brotli-compresses `assets/plugin-maven-repo.zip` into `data/common/plugin-maven-repo.zip.br` when `hasBundledAssets(variant)`: - -```kotlin -private fun registerPluginMavenRepoCopierTask( - project: Project, - variant: Variant, -) { - val zip = project.rootProject.file("assets/plugin-maven-repo.zip") - val taskName = "copy${variant.name.replaceFirstChar { it.uppercase() }}PluginMavenRepo" - if (hasBundledAssets(variant)) { - val task = project.tasks.register(taskName, AddBrotliFileToAssetsTask::class.java) { - it.dependsOn(project.tasks.named("createPluginMavenRepoZip")) - it.inputFile.set(zip) - } - variant.sources.assets?.addGeneratedSourceDirectory(task, AddBrotliFileToAssetsTask::outputDirectory) - } else { - val task = project.tasks.register(taskName, AddFileToAssetsTask::class.java) { - it.dependsOn(project.tasks.named("createPluginMavenRepoZip")) - it.inputFile.set(zip) - } - variant.sources.assets?.addGeneratedSourceDirectory(task, AddFileToAssetsTask::outputDirectory) - } -} -``` - -Match the exact wiring of `registerPluginArtifactsCopierTask` (task property names, `baseAssetPath`/`data/common` default, `onVariants` call site). Call `registerPluginMavenRepoCopierTask(project, variant)` alongside the existing copier calls in `onVariants`. - -- [ ] **Step 3: Add the split entry + assemble deps in `app/build.gradle.kts`** - -In `createAssetsZip(arch)`, add `"plugin-maven-repo.zip"` to the `arrayOf(...)` file list (after `"plugin-artifacts.zip"`, ~L462). No `entryName` remap is needed (the `when` at ~L471 falls through to `else -> fileName`), so the entry name stays `plugin-maven-repo.zip`. - -Add a `dependsOn("createPluginMavenRepoZip")` to both `assembleV8Assets` and `assembleV7Assets` (~L486-503), so the file exists before `createAssetsZip` runs (it throws `FileNotFoundException` on a missing file, ~L466-468). - -- [ ] **Step 4: Verify the split asset packaging includes the new entry** - -Run: `flox activate -d flox/local -- ./gradlew :app:assembleV8Assets` -Then: `unzip -l app/build/outputs/assets/assets-arm64-v8a.zip | grep plugin-maven-repo` -Expected: `plugin-maven-repo.zip` is listed as an entry. - -- [ ] **Step 5: Commit** - -```bash -cd ~/src/cogo/ADFA-4911 -flox activate -d flox/local -- ./gradlew spotlessApply -git add composite-builds/build-deps-common/constants/src/main/java/org/adfa/constants/constants.kt \ - composite-builds/build-logic/plugins/src/main/java/com/itsaky/androidide/plugins/AndroidIDEAssetsPlugin.kt \ - app/build.gradle.kts -git commit -m "ADFA-4911: Ship plugin-maven-repo.zip as a bundled (.br) and split asset" -``` - ---- - -### Task 5: Merge the overlay into LOCAL_MAVEN_DIR on-device (both installers) - -**Files:** -- Test: `app/src/test/java/com/itsaky/androidide/assets/ExtractZipToDirMergeTest.kt` (new) -- Modify: `app/src/main/java/com/itsaky/androidide/assets/BundledAssetsInstaller.kt` (~L56-71) -- Modify: `app/src/main/java/com/itsaky/androidide/assets/SplitAssetsInstaller.kt` (~L62-75) - -**Interfaces:** -- Consumes: `AssetsInstallationHelper.extractZipToDir(srcStream, destDir)` (existing, L241-271 — creates dirs and copies without wiping); constants `PLUGIN_MAVEN_REPO_ZIP_NAME`, `PLUGIN_MAVEN_REPO_ZIP_BR`; `ToolsManager.getCommonAsset` (prefixes `data/common/`). - -- [ ] **Step 1: Write the failing merge test** - -`extractZipToDir` is the merge primitive: it must add overlay entries into a dir that already has files, without deleting the existing ones, and reject path traversal. Create `ExtractZipToDirMergeTest.kt`: - -```kotlin -package com.itsaky.androidide.assets - -import io.mockk.mockkObject -import org.junit.Assert.assertEquals -import org.junit.Assert.assertThrows -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.nio.file.Files -import java.util.zip.ZipEntry -import java.util.zip.ZipOutputStream - -class ExtractZipToDirMergeTest { - @Before - fun setup() { - mockkObject(AssetsInstallationHelper) - } - - private fun zipOf(vararg entries: Pair): ByteArrayInputStream { - val bos = ByteArrayOutputStream() - ZipOutputStream(bos).use { zip -> - for ((name, body) in entries) { - zip.putNextEntry(ZipEntry(name)) - zip.write(body.toByteArray()) - zip.closeEntry() - } - } - return ByteArrayInputStream(bos.toByteArray()) - } - - @Test - fun `overlay merges without wiping existing files`() { - val dest = Files.createTempDirectory("mvn").also { - Files.createDirectories(it.resolve("com/foo/1.0")) - Files.writeString(it.resolve("com/foo/1.0/foo-1.0.jar"), "harvested") - } - - AssetsInstallationHelper.extractZipToDir( - zipOf("com/itsaky/androidide/plugin-api/1.0.0/plugin-api-1.0.0.jar" to "fat"), - dest, - ) - - assertTrue("harvested file must survive the merge", - Files.exists(dest.resolve("com/foo/1.0/foo-1.0.jar"))) - assertEquals("fat", - Files.readString(dest.resolve("com/itsaky/androidide/plugin-api/1.0.0/plugin-api-1.0.0.jar"))) - } - - @Test - fun `rejects path traversal`() { - val dest = Files.createTempDirectory("mvn") - assertThrows(IllegalStateException::class.java) { - AssetsInstallationHelper.extractZipToDir(zipOf("../evil.jar" to "x"), dest) - } - } -} -``` - -- [ ] **Step 2: Run the test to confirm it passes against the existing primitive** - -Run: `flox activate -d flox/local -- ./gradlew :app:testV8DebugUnitTest --tests "com.itsaky.androidide.assets.ExtractZipToDirMergeTest"` -Expected: PASS both cases. (This pins the merge/no-wipe + traversal-guard contract the installers rely on. `extractZipToDir` already enforces the `..`/absolute-path check at L251-253.) - -- [ ] **Step 3: Add the overlay to `BundledAssetsInstaller`** - -Split `LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME` out of the shared archive arm (L56-71) into its own branch that extracts the harvested repo, then merges the plugin overlay in the same job: - -```kotlin -GRADLE_DISTRIBUTION_ARCHIVE_NAME, -ANDROID_SDK_ZIP, --> { - val destDir = destinationDirForArchiveEntry(entryName).toPath() - if (Files.exists(destDir)) { - destDir.deleteRecursively() - } - Files.createDirectories(destDir) - val assetPath = ToolsManager.getCommonAsset("$entryName.br") - assets.open(assetPath).use { assetStream -> - BrotliInputStream(assetStream).use { srcStream -> - AssetsInstallationHelper.extractZipToDir(srcStream, destDir) - } - } -} - -LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME -> { - val destDir = destinationDirForArchiveEntry(entryName).toPath() - if (Files.exists(destDir)) { - destDir.deleteRecursively() - } - Files.createDirectories(destDir) - // 1) harvested repo - assets.open(ToolsManager.getCommonAsset("$entryName.br")).use { assetStream -> - BrotliInputStream(assetStream).use { srcStream -> - AssetsInstallationHelper.extractZipToDir(srcStream, destDir) - } - } - // 2) plugin coordinate overlay -- merged (no wipe) into the same repo - assets.open(ToolsManager.getCommonAsset(PLUGIN_MAVEN_REPO_ZIP_BR)).use { assetStream -> - BrotliInputStream(assetStream).use { srcStream -> - AssetsInstallationHelper.extractZipToDir(srcStream, destDir) - } - } - logger.debug("Merged plugin coordinates into {}", destDir) -} -``` - -Add imports: `import org.adfa.constants.PLUGIN_MAVEN_REPO_ZIP_BR`. - -- [ ] **Step 4: Add the overlay to `SplitAssetsInstaller`** - -Split `LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME` out of the shared arm (L62-75). Extract the harvested repo from the entry stream, then read the `plugin-maven-repo.zip` entry from the already-open `zipFile` and merge: - -```kotlin -GRADLE_DISTRIBUTION_ARCHIVE_NAME, -ANDROID_SDK_ZIP, -GRADLE_API_NAME_JAR_ZIP, --> { - val destDir = destinationDirForArchiveEntry(entry.name).toPath() - if (Files.exists(destDir)) { - destDir.deleteRecursively() - } - Files.createDirectories(destDir) - AssetsInstallationHelper.extractZipToDir(zipInput, destDir) -} - -LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME -> { - val destDir = destinationDirForArchiveEntry(entry.name).toPath() - if (Files.exists(destDir)) { - destDir.deleteRecursively() - } - Files.createDirectories(destDir) - // 1) harvested repo - AssetsInstallationHelper.extractZipToDir(zipInput, destDir) - // 2) plugin coordinate overlay from the split assets zip -- merged (no wipe) - val overlay = zipFile.getEntry(PLUGIN_MAVEN_REPO_ZIP_NAME) - ?: throw FileNotFoundException( - context.getString(R.string.err_asset_entry_not_found, PLUGIN_MAVEN_REPO_ZIP_NAME)) - zipFile.getInputStream(overlay).use { overlayInput -> - AssetsInstallationHelper.extractZipToDir(overlayInput, destDir) - } - logger.debug("Merged plugin coordinates into {}", destDir) -} -``` - -Add imports: `import org.adfa.constants.PLUGIN_MAVEN_REPO_ZIP_NAME`. (`GRADLE_API_NAME_JAR_ZIP` stays in the shared arm; only `LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME` moves out.) - -- [ ] **Step 5: Build both installers' module to confirm compilation** - -Run: `flox activate -d flox/local -- ./gradlew :app:compileV8DebugKotlin` -Expected: BUILD SUCCESSFUL (constants resolve, imports correct). - -- [ ] **Step 6: Commit** - -```bash -cd ~/src/cogo/ADFA-4911 -flox activate -d flox/local -- ./gradlew spotlessApply -git add app/src/test/java/com/itsaky/androidide/assets/ExtractZipToDirMergeTest.kt \ - app/src/main/java/com/itsaky/androidide/assets/BundledAssetsInstaller.kt \ - app/src/main/java/com/itsaky/androidide/assets/SplitAssetsInstaller.kt -git commit -m "ADFA-4911: Merge plugin coordinate overlay into localMvnRepository during onboarding" -``` - ---- - -### Task 6: Document the coordinate + version - -**Files:** -- Modify: the Plugin API changelog added by ADFA-1713 (find with `git log --oneline | grep -i changelog`, or `find . -iname "*plugin*api*changelog*" -o -iname "CHANGELOG*" -path "*plugin*"`), or `plugin-api/README.md` if no changelog exists. - -**Interfaces:** none (docs). - -- [ ] **Step 1: Add the coordinate + build snippet** - -Document that on-device plugins resolve, offline, with no `libs/`: - -```kotlin -plugins { - id("com.itsaky.androidide.plugins.build") version "1.0.0" -} -dependencies { - compileOnly("com.itsaky.androidide:plugin-api:1.0.0") -} -``` - -Note the `plugin-api:1.0.0` coordinate is a fat jar (plugin-api + common + eventbus-events + idetooltips), injected into `localMvnRepository` at onboarding, and its version tracks the shipped jar. - -- [ ] **Step 2: Commit** - -```bash -cd ~/src/cogo/ADFA-4911 -git add -git commit -m "ADFA-4911: Document the plugin-api:1.0.0 coordinate and coordinate-based plugin build" -``` - ---- - -### Task 7: End-to-end on-device verification (acceptance criteria) - -**Files:** none (verification only). Requires an arm device/emulator (`adb devices -l | grep -v offline`; target `emulator-5554`). - -- [ ] **Step 1: Build + install the debug APK and its split assets** - -```bash -flox activate -d flox/local -- ./gradlew :app:assembleV8Debug :app:assembleV8Assets --parallel --max-workers=6 -adb -s emulator-5554 install -r app/build/outputs/apk/v8/debug/app-v8-debug.apk -adb -s emulator-5554 push app/build/outputs/assets/assets-arm64-v8a.zip /sdcard/Download/assets-arm64-v8a.zip -``` -Then launch the app and complete onboarding (asset installation). - -- [ ] **Step 2: Verify the coordinates landed (AC #1)** - -```bash -adb -s emulator-5554 shell "find /data/data/com.itsaky.androidide/files/home/maven/localMvnRepository -path '*plugin*' -name '*.pom' -o -path '*plugin*' -name '*.jar'" -``` -Expected: the plugin-api jar+pom, plugin-builder jar+pom, and the `com.itsaky.androidide.plugins.build` marker pom, at their coordinate paths. - -- [ ] **Step 3: Build a no-`libs/` plugin offline (AC #2)** - -On-device (or via a Termux/gradle harness), create a minimal plugin project with **no** `libs/` dir: -```kotlin -// settings.gradle.kts resolves via COTGSettingsPlugin (localMvnRepository injected) -plugins { id("com.itsaky.androidide.plugins.build") version "1.0.0" } -dependencies { compileOnly("com.itsaky.androidide:plugin-api:1.0.0") } -``` -Run `:assemblePluginDebug` with networking disabled. Expected: BUILD SUCCESSFUL, a `.cgp` produced, no network access. - -- [ ] **Step 4: Record results on the Jira ticket** - -`jira issue comment add ADFA-4911 ""` - ---- - -## Self-Review - -**Spec coverage:** the 3 coordinates (Task 1-3), fat-jar merge of all 4 modules (Task 2), dependency-free plugin-api POM + real builder POM/marker (Tasks 1,3), sibling-asset shipping bundled+split (Task 4), the wipe/concurrency-safe overlay inside the localMvnRepository branch (Task 5), the merge/traversal test (Task 5), docs (Task 6), and all three acceptance criteria (Task 7). No spec requirement is unmapped. - -**Placeholders:** none — every code/test/command step is concrete. The two empirically-risky names (the builder publish-task name; the `classes.jar` intermediate paths) each have an explicit discover/verify step (1.2, 2.2/2.3) that fails loudly on mismatch. - -**Type/name consistency:** constant names `PLUGIN_MAVEN_REPO_ZIP_NAME` / `PLUGIN_MAVEN_REPO_ZIP_BR` are defined in Task 4 and consumed by the split/bundled branches in Task 5; the coordinate paths asserted in 3.3 match those verified on-device in 7.2; `extractZipToDir` signature matches its existing definition. diff --git a/docs/superpowers/plans/2026-08-06-adfa-4510-codeaction-tooltips.md b/docs/superpowers/plans/2026-08-06-adfa-4510-codeaction-tooltips.md new file mode 100644 index 0000000000..2231dca879 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-adfa-4510-codeaction-tooltips.md @@ -0,0 +1,891 @@ +# ADFA-4510 Code Action Tooltips Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make long-press show a tooltip on every tagged item in the editor's Code Actions menu. + +**Architecture:** `ActionItem` carries two members meaning the same thing (`tooltipTag` property, `retrieveTooltipTag()` function); the code-action render path reads the function while all 22 LSP actions override the property. We unify them at the interface, teach `ActionMenu` to look up a child by `itemId` (the registry cannot see submenu children), hand the submenu adapter its parent menu, and delete a fallback that guaranteed a failed lookup. Two mis-copied tags are dropped and one dead dialog constant is wired up. + +**Tech Stack:** Kotlin, Android (`com.android.library` modules with `v7`/`v8` ABI flavors), JUnit 4 + Truth + Robolectric via `projects.testing.unit`, Gradle wrapped in `flox`, Spotless/ktlint with a `ratchetFrom = "origin/stage"` file-level ratchet. + +**Spec:** `docs/superpowers/specs/2026-08-06-adfa-4510-codeaction-tooltips-design.md` + +## Global Constraints + +- **Indentation is TABS, line endings LF.** Enforced by Spotless. Every Kotlin snippet below is already tab-indented — preserve it. +- **The Spotless ratchet is file-level, not line-level.** Touching one line of a space-indented file pulls the *whole file* under the ratchet and reformats it to tabs. Task 1 exists solely to get that churn into its own commit. Do not skip it. +- **Never run bare `./gradlew`.** Always `flox activate -d flox/local -- ./gradlew `. +- **Unit test task for these modules is `testV8DebugUnitTest`**, not `test`. The aggregate `test` task rejects `--tests`. +- **Do not add tooltip tag constants.** `TooltipTag.kt` is untouched by this plan — open PR #1624 edits it and we must not collide. +- **Do not edit anything under `lsp/kotlin/`.** Same conflict reason. +- **New test files carry no license header** — match `KotlinCodeActionTooltipTagTest.kt`, which starts directly with `package`. +- **Branch:** `bugfix/ADFA-4510-missing-tooltips-code-actions`. Commit after every task. + +--- + +### Task 1: Reindent space-indented target files to tabs + +Four files we must edit are space-indented. Reformatting them is mechanical and must not be mixed with logic changes. The ratchet only reformats files that differ from `origin/stage`, so we make a throwaway whitespace change first to make Spotless see them. + +**Files:** +- Modify: `actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt` +- Modify: `actions/src/main/java/com/itsaky/androidide/actions/ActionMenu.kt` +- Modify: `lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/VariableToStatementAction.kt` +- Modify: `lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/FieldToBlockAction.kt` + +**Interfaces:** +- Consumes: nothing +- Produces: nothing. This task is whitespace-only by construction and is verified as such. + +- [ ] **Step 1: Make each file differ from `origin/stage` so the ratchet picks it up** + +```bash +cd "$(git rev-parse --show-toplevel)" +for f in actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt \ + actions/src/main/java/com/itsaky/androidide/actions/ActionMenu.kt \ + lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/VariableToStatementAction.kt \ + lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/FieldToBlockAction.kt; do + printf '\n' >> "$f" +done +git diff --stat +``` + +Expected: 4 files listed, 1 insertion each. + +- [ ] **Step 2: Run Spotless** + +```bash +flox activate -d flox/local -- ./gradlew spotlessApply +``` + +Expected: BUILD SUCCESSFUL. The trailing blank lines are removed and all four files are reindented to tabs. + +- [ ] **Step 3: Prove the change is formatting-only** + +ktlint does more than reindent, so `git diff -w` will NOT be empty. Expect these +behaviour-preserving normalisations, and nothing else: + +- blank line removed after a declaration opens +- parameter lists exploded one-per-line with a trailing comma +- block bodies collapsed to expression bodies (`{ return x }` becomes `= x`) +- enum entries gaining a trailing comma and `;` +- a `a; b` one-liner split onto two lines + +```bash +git diff -w +``` + +Read every hunk. Each must fall into the list above. If you see a changed +identifier, literal, condition, or call argument — anything that could alter +behaviour — STOP and report BLOCKED without committing. + +Then prove it compiles: + +```bash +flox activate -d flox/local -- ./gradlew :actions:compileV8DebugKotlin :lsp:java:compileV8DebugKotlin +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 4: Confirm the files are now tab-indented** + +```bash +grep -c $'^\t' actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt +``` + +Expected: a non-zero count (was 0 before). + +- [ ] **Step 5: Commit** + +```bash +git add actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt \ + actions/src/main/java/com/itsaky/androidide/actions/ActionMenu.kt \ + lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/VariableToStatementAction.kt \ + lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/FieldToBlockAction.kt +git commit -m "style(ADFA-4510): reformat files to tabs ahead of edits + +Spotless ratchets whole files, so reformatting these four up front keeps the +following commits pure logic. ktlint normalisations only -- tabs, trailing +commas, expression bodies. No behaviour change; both modules compile." +``` + +--- + +### Task 2: Unify the tag members and add `ActionMenu.findAction(itemId)` + +The two fixes at the heart of the bug, developed test-first. This is also the `actions` module's first unit test, so it needs test wiring. + +**Files:** +- Modify: `actions/build.gradle.kts` (add `testImplementation`) +- Modify: `actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt` (line ~92 after Task 1) +- Modify: `actions/src/main/java/com/itsaky/androidide/actions/ActionMenu.kt` +- Create: `actions/src/test/java/com/itsaky/androidide/actions/ActionTooltipResolutionTest.kt` + +**Interfaces:** +- Consumes: nothing +- Produces: + - `ActionMenu.findAction(itemId: Int): ActionItem?` — returns the child whose `itemId` matches, else `null`. Used by Task 4. + - `ActionItem.retrieveTooltipTag(isReadOnlyContext: Boolean): String` now defaults to `tooltipTag` instead of `""`. Used by Task 3 and Task 4. + +- [ ] **Step 1: Add the test dependency** + +In `actions/build.gradle.kts`, inside the existing `dependencies { ... }` block, add this line after `implementation(libs.google.material)`: + +```kotlin + testImplementation(projects.testing.unit) +``` + +`testing/unit` brings JUnit 4, Truth, MockK and Robolectric. It depends only on `buildInfo`, `common`, `shared` and `testing/common`, so there is no dependency cycle with `actions`. + +- [ ] **Step 2: Write the failing test** + +Create `actions/src/test/java/com/itsaky/androidide/actions/ActionTooltipResolutionTest.kt`: + +```kotlin +package com.itsaky.androidide.actions + +import android.graphics.drawable.Drawable +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Covers the two halves of code-action tooltip resolution that failed in ADFA-4510: finding a + * submenu child by its menu item id, and reading a tag from whichever member the action overrode. + * + * Code actions are children of CodeActionsMenu and are never registered with the registry, so the + * render path can only reach them through [ActionMenu.findAction]. They override the `tooltipTag` + * property while the render path reads `retrieveTooltipTag()`, so both must resolve to the same + * value. + */ +@RunWith(RobolectricTestRunner::class) +class ActionTooltipResolutionTest { + private open class FakeAction( + override val id: String, + ) : ActionItem { + override var label: String = id + override var visible: Boolean = true + override var enabled: Boolean = true + override var icon: Drawable? = null + override var requiresUIThread: Boolean = false + override var location: ActionItem.Location = ActionItem.Location.EDITOR_CODE_ACTIONS + + override suspend fun execAction(data: ActionData): Any = true + } + + private class PropertyOnlyAction : FakeAction("fake.propertyOnly") { + override var tooltipTag: String = "editor.codeactions.comment" + } + + private class FunctionOnlyAction : FakeAction("fake.functionOnly") { + override fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = + "editor.codeactions.gotodef" + } + + private class UntaggedAction : FakeAction("fake.untagged") + + private class FakeMenu : ActionMenu { + override val children: MutableSet = mutableSetOf() + override val id: String = "fake.menu" + override var label: String = "Fake menu" + override var visible: Boolean = true + override var enabled: Boolean = true + override var icon: Drawable? = null + override var requiresUIThread: Boolean = false + override var location: ActionItem.Location = ActionItem.Location.EDITOR_TEXT_ACTIONS + } + + private fun menuOf(vararg actions: ActionItem) = FakeMenu().apply { actions.forEach(::addAction) } + + @Test + fun `findAction by itemId returns the matching child`() { + val child = PropertyOnlyAction() + val menu = menuOf(UntaggedAction(), child) + + assertThat(menu.findAction(child.itemId)).isSameInstanceAs(child) + } + + @Test + fun `findAction by itemId returns null when no child matches`() { + val menu = menuOf(UntaggedAction()) + + assertThat(menu.findAction("nothing.registered".hashCode())).isNull() + } + + @Test + fun `retrieveTooltipTag reads an action that overrides only the property`() { + assertThat(PropertyOnlyAction().retrieveTooltipTag(false)) + .isEqualTo("editor.codeactions.comment") + } + + @Test + fun `retrieveTooltipTag reads an action that overrides only the function`() { + assertThat(FunctionOnlyAction().retrieveTooltipTag(false)) + .isEqualTo("editor.codeactions.gotodef") + } + + @Test + fun `retrieveTooltipTag is empty when the action overrides neither member`() { + assertThat(UntaggedAction().retrieveTooltipTag(false)).isEmpty() + } +} +``` + +- [ ] **Step 3: Run the test to verify it fails** + +```bash +flox activate -d flox/local -- ./gradlew :actions:testV8DebugUnitTest \ + --tests "com.itsaky.androidide.actions.ActionTooltipResolutionTest" +``` + +Expected: FAIL. Two distinct failures: +- a compile error, `Unresolved reference: findAction` (the `Int` overload does not exist yet) +- once that compiles, `retrieveTooltipTag reads an action that overrides only the property` fails with `expected: editor.codeactions.comment but was: ` (empty) + +- [ ] **Step 4: Add the `itemId` lookup to `ActionMenu`** + +In `actions/src/main/java/com/itsaky/androidide/actions/ActionMenu.kt`, directly below the existing `findAction(id: String)` function, add: + +```kotlin + /** + * Find the child action with the given menu item ID. + * + * Child actions are not registered with the [ActionsRegistry], so the registry cannot resolve + * them; a submenu's renderer must look them up here (ADFA-4510). + * + * @return The action item or `null` if not found. + */ + fun findAction(itemId: Int): ActionItem? { + return children.find { it.itemId == itemId } + } +``` + +- [ ] **Step 5: Unify the tag members in `ActionItem`** + +In `actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt`, change the body of `retrieveTooltipTag`. Replace: + +```kotlin + fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = "" +``` + +with: + +```kotlin + fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = tooltipTag +``` + +Then extend the existing KDoc's `@return` line so the delegation is documented. Replace: + +```kotlin + * @return The appropriate tooltip tag for the given context, or an empty string if + * no tooltip is available. + */ +``` + +with: + +```kotlin + * @return The appropriate tooltip tag for the given context, or an empty string if + * no tooltip is available. Defaults to [tooltipTag], so an action may override either + * member and every consumer sees the same value (ADFA-4510). + */ +``` + +- [ ] **Step 6: Run the test to verify it passes** + +```bash +flox activate -d flox/local -- ./gradlew :actions:testV8DebugUnitTest \ + --tests "com.itsaky.androidide.actions.ActionTooltipResolutionTest" +``` + +Expected: PASS, 5 tests. + +- [ ] **Step 7: Format and commit** + +```bash +flox activate -d flox/local -- ./gradlew spotlessApply +git add actions/build.gradle.kts \ + actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt \ + actions/src/main/java/com/itsaky/androidide/actions/ActionMenu.kt \ + actions/src/test/java/com/itsaky/androidide/actions/ActionTooltipResolutionTest.kt +git commit -m "fix(ADFA-4510): resolve tooltip tags from either ActionItem member + +retrieveTooltipTag() defaulted to \"\" while every LSP code action overrides the +tooltipTag property, so the code-actions renderer always read an empty tag. +Default the function to the property instead. + +Add ActionMenu.findAction(itemId) so a submenu's renderer can reach children, +which are never registered with the ActionsRegistry." +``` + +--- + +### Task 3: Pin Java code action tags and drop two mis-copied ones + +`VariableToStatementAction` (converts a field to a local variable) and `FieldToBlockAction` both carry `EDITOR_CODE_ACTIONS_FIX_IMPORTS` by copy-paste. Neither touches imports. Before Task 2 they were silent; after it they would show import-fixing help on unrelated actions. Dropping the overrides keeps them silent, which is correct. + +The pinning test reads through `retrieveTooltipTag(false)` — the member the render path uses — unlike the Kotlin test which reads the property. All 22 actions are asserted as one map so a newly registered untagged action fails automatically. + +**Files:** +- Create: `lsp/java/src/test/java/com/itsaky/androidide/lsp/java/actions/JavaCodeActionTooltipTagTest.kt` +- Modify: `lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/VariableToStatementAction.kt` +- Modify: `lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/FieldToBlockAction.kt` + +**Interfaces:** +- Consumes: `ActionItem.retrieveTooltipTag(isReadOnlyContext: Boolean)` from Task 2, which must already delegate to `tooltipTag`. +- Produces: nothing consumed by later tasks. + +No new dependency is needed: `lsp/java/build.gradle.kts:69` already has `testImplementation(projects.testing.lsp)`, which re-exports `testing/unit`. + +- [ ] **Step 1: Write the failing test** + +Create `lsp/java/src/test/java/com/itsaky/androidide/lsp/java/actions/JavaCodeActionTooltipTagTest.kt`: + +```kotlin +package com.itsaky.androidide.lsp.java.actions + +import com.itsaky.androidide.idetooltips.TooltipTag +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pins each Java code action to its tooltip tag. Tooltip content is authored per tag and looked up + * by that tag, so a wrong tag fails silently at runtime: the action shows another action's tooltip + * or none at all (ADFA-4510). + * + * Tags are read through retrieveTooltipTag(), the member the code-actions renderer calls. The + * Kotlin equivalent asserts on the tooltipTag property instead, which is why it kept passing while + * ADFA-4510 was live. + * + * Actions pinned to "" have no authored tooltip yet. Tagging one later must be a deliberate edit + * here, not a silent drift. + */ +class JavaCodeActionTooltipTagTest { + private val actualTags + get() = JavaCodeActionsMenu.actions.associate { it.id to it.retrieveTooltipTag(false) } + + @Test + fun `every java code action maps to its own tooltip tag`() { + val expected = + mapOf( + "ide.editor.lsp.java.commentLine" to TooltipTag.EDITOR_CODE_ACTIONS_COMMENT, + "ide.editor.lsp.java.uncommentLine" to TooltipTag.EDITOR_CODE_ACTIONS_UNCOMMENT, + "ide.editor.lsp.java.gotoDefinition" to TooltipTag.EDITOR_CODE_ACTIONS_GOTO_DEF, + "ide.editor.lsp.java.findReferences" to TooltipTag.EDITOR_CODE_ACTIONS_FIND_REFS, + "ide.editor.lsp.java.diagnostics.addImport" to TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS, + "ide.editor.lsp.java.diagnostics.autoFixImports" to TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS, + "ide.editor.lsp.java.diagnostics.implementAbstractMethods" to + TooltipTag.EDITOR_CODE_ACTIONS_OVERRIDE_SUPER, + "ide.editor.lsp.java.generator.settersAndGetters" to + TooltipTag.EDITOR_CODE_ACTIONS_SETTER_GETTER, + "ide.editor.lsp.java.generator.overrideSuperclassMethods" to + TooltipTag.EDITOR_CODE_ACTIONS_OVERRIDE_SUPER, + "ide.editor.lsp.java.generator.missingConstructor" to + TooltipTag.EDITOR_CODE_ACTIONS_GEN_CONSTRUCTOR, + "ide.editor.lsp.java.generator.constructor" to + TooltipTag.EDITOR_CODE_ACTIONS_GEN_CONSTRUCTOR, + "ide.editor.lsp.java.generator.toString" to TooltipTag.EDITOR_CODE_ACTIONS_GEN_TO_STRING, + "ide.editor.lsp.java.removeUnusedImports" to + TooltipTag.EDITOR_CODE_ACTIONS_UNUSED_IMPORTS, + "lsp_java_organizeImports" to TooltipTag.EDITOR_CODE_ACTIONS_ORGANIZE_IMPORTS, + // No authored tooltip yet. + "ide.editor.lsp.java.diagnostics.variableToStatement" to "", + "ide.editor.lsp.java.diagnostics.fieldToBlock" to "", + "ide.editor.lsp.java.diagnostics.removeClass" to "", + "ide.editor.lsp.java.diagnostics.removeMethod" to "", + "ide.editor.lsp.java.diagnostics.removeUnusedThrows" to "", + "ide.editor.lsp.java.diagnostics.createMissingMethod" to "", + "ide.editor.lsp.java.diagnostics.suppressUncheckedWarning" to "", + "ide.editor.lsp.java.diagnostics.addThrows" to "", + ) + assertEquals(expected, actualTags) + } + + /** Guards a Java action drifting onto a Kotlin tag or some unrelated namespace. */ + @Test + fun `no java code action borrows a non java code action tag`() { + actualTags.forEach { (id, tag) -> + if (tag.isEmpty()) return@forEach + assertTrue( + "$id uses tag '$tag' outside the java code actions namespace", + tag.startsWith("editor.codeactions.") && !tag.startsWith("editor.codeactions.kotlin."), + ) + } + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:java:testV8DebugUnitTest \ + --tests "com.itsaky.androidide.lsp.java.actions.JavaCodeActionTooltipTagTest" +``` + +Expected: FAIL on `every java code action maps to its own tooltip tag`. The map differs at two keys — `variableToStatement` and `fieldToBlock` return `editor.codeactions.fiximports` where `""` is expected. + +- [ ] **Step 3: Drop the mis-copied tag from `VariableToStatementAction`** + +In `lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/VariableToStatementAction.kt`, delete this line: + +```kotlin + override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS +``` + +Also remove the now-unused `import com.itsaky.androidide.idetooltips.TooltipTag` if no other reference to `TooltipTag` remains in the file (check with `grep -n TooltipTag` on that file). + +- [ ] **Step 4: Drop the mis-copied tag from `FieldToBlockAction`** + +In `lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/FieldToBlockAction.kt`, delete this line: + +```kotlin + override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS +``` + +Remove the now-unused `TooltipTag` import on the same condition as Step 3. + +- [ ] **Step 5: Run the test to verify it passes** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:java:testV8DebugUnitTest \ + --tests "com.itsaky.androidide.lsp.java.actions.JavaCodeActionTooltipTagTest" +``` + +Expected: PASS, 2 tests. + +- [ ] **Step 6: Confirm the Kotlin pinning test still passes** + +Task 2 changed a shared interface default, so re-run the neighbouring suite. Do not edit it. + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV8DebugUnitTest \ + --tests "com.itsaky.androidide.lsp.kotlin.KotlinCodeActionTooltipTagTest" +``` + +Expected: PASS, 2 tests. + +- [ ] **Step 7: Format and commit** + +```bash +flox activate -d flox/local -- ./gradlew spotlessApply +git add lsp/java/src/test/java/com/itsaky/androidide/lsp/java/actions/JavaCodeActionTooltipTagTest.kt \ + lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/VariableToStatementAction.kt \ + lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/FieldToBlockAction.kt +git commit -m "fix(ADFA-4510): pin java code action tooltip tags + +VariableToStatementAction and FieldToBlockAction carried the fiximports tag by +copy-paste; neither touches imports. They were silent before this branch and +would have started showing wrong help. Drop both overrides. + +Add JavaCodeActionTooltipTagTest, reading through retrieveTooltipTag() so it +exercises the member the renderer actually calls." +``` + +--- + +### Task 4: Resolve tag and category at the code actions bind site + +The render-path fix. `ActionsListAdapter` gains an optional parent menu so submenu children resolve, the `contentDescription` fallback is deleted, and the hardcoded `ide` category is replaced by the action's own category so plugin-contributed code actions look up their `plugin_` rows. + +**Files:** +- Modify: `editor/src/main/java/com/itsaky/androidide/editor/ui/EditorActionsMenu.kt` + +**Interfaces:** +- Consumes: `ActionMenu.findAction(itemId: Int): ActionItem?` and the `retrieveTooltipTag` delegation, both from Task 2. +- Produces: nothing consumed by later tasks. + +This file is already tab-indented, so no reformat churn. It has no logger yet; we add one to the existing companion object following the module idiom (`IDEEditor.kt:231`). + +- [ ] **Step 1: Add the imports** + +In `editor/src/main/java/com/itsaky/androidide/editor/ui/EditorActionsMenu.kt`, add to the import block, each in its existing alphabetical position: + +```kotlin +import com.itsaky.androidide.actions.ActionMenu +import com.itsaky.androidide.idetooltips.TooltipCategory +``` + +`org.slf4j.LoggerFactory` goes with the other non-`com.itsaky` imports at the bottom of the block: + +```kotlin +import org.slf4j.LoggerFactory +``` + +- [ ] **Step 2: Add a logger to the companion object** + +Replace the existing companion object (around line 81): + +```kotlin + companion object { + const val DELAY: Long = 200 + } +``` + +with: + +```kotlin + companion object { + const val DELAY: Long = 200 + + private val log = LoggerFactory.getLogger(EditorActionsMenu::class.java) + } +``` + +- [ ] **Step 3: Give `ActionsListAdapter` an optional parent menu** + +Replace the adapter's constructor (around line 403): + +```kotlin + private class ActionsListAdapter( + val menu: Menu?, + val forceShowTitle: Boolean = false, + val editor: IDEEditor, + val location: ActionItem.Location, + ) : RecyclerView.Adapter() { +``` + +with: + +```kotlin + private class ActionsListAdapter( + val menu: Menu?, + val forceShowTitle: Boolean = false, + val editor: IDEEditor, + val location: ActionItem.Location, + // Children of a submenu are not registered with the ActionsRegistry, so they can only be + // resolved through their parent menu (ADFA-4510). Null for the top-level actions row. + val actionMenu: ActionMenu? = null, + ) : RecyclerView.Adapter() { +``` + +- [ ] **Step 4: Resolve the action, tag and category in `onBindViewHolder`** + +Replace these three lines (around line 432): + +```kotlin + val action = getInstance().findAction(location, item.itemId) + val tooltipTag = action?.retrieveTooltipTag(false) ?: "" + val tag = tooltipTag.ifEmpty { item.contentDescription?.toString() ?: "" } +``` + +with: + +```kotlin + val action = + actionMenu?.findAction(item.itemId) + ?: getInstance().findAction(location, item.itemId) + val tag = action?.retrieveTooltipTag(false) ?: "" + val category = action?.retrieveTooltipCategory() ?: TooltipCategory.CATEGORY_IDE +``` + +The dropped fallback read `item.contentDescription`, which `DefaultActionsRegistry.kt:217` sets to the action's human-readable label. It could never match a tag, so it only turned "no tooltip" into a silent database miss. + +- [ ] **Step 5: Show the tooltip in the action's own category, and log an untagged action** + +Replace the long-click listener (around line 458): + +```kotlin + button.setOnLongClickListener { + if (tag.isNotEmpty()) { + TooltipManager.showIdeCategoryTooltip( + context = editor.context, + anchorView = editor, + tag = tag, + ) + } + true + } +``` + +with: + +```kotlin + button.setOnLongClickListener { + if (tag.isEmpty()) { + log.warn("No tooltip tag for action '{}'", item.title) + } else { + TooltipManager.showTooltip( + context = editor.context, + anchorView = editor, + category = category, + tag = tag, + ) + } + true + } +``` + +- [ ] **Step 6: Pass the parent menu when building the submenu adapter** + +Replace these lines in `onMenuItemSelected` (around line 490): + +```kotlin + this.list.layoutManager = LinearLayoutManager(editor.context) + this.list.adapter = + ActionsListAdapter(item.subMenu, true, editor, location = onGetActionLocation()) +``` + +with: + +```kotlin + this.list.layoutManager = LinearLayoutManager(editor.context) + val parentMenu = getInstance().findAction(onGetActionLocation(), item.itemId) as? ActionMenu + this.list.adapter = + ActionsListAdapter( + item.subMenu, + true, + editor, + location = onGetActionLocation(), + actionMenu = parentMenu, + ) +``` + +`CodeActionsMenu` *is* registered at `DefaultActionsRegistry.kt:61`, so this lookup succeeds — it is only its children that the registry cannot see. + +- [ ] **Step 7: Compile the module** + +```bash +flox activate -d flox/local -- ./gradlew :editor:compileV8DebugKotlin +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 8: Re-run both pinning suites and the resolver suite** + +```bash +flox activate -d flox/local -- ./gradlew :actions:testV8DebugUnitTest \ + :lsp:java:testV8DebugUnitTest :lsp:kotlin:testV8DebugUnitTest +``` + +Expected: BUILD SUCCESSFUL, no failures. + +- [ ] **Step 9: Format and commit** + +```bash +flox activate -d flox/local -- ./gradlew spotlessApply +git add editor/src/main/java/com/itsaky/androidide/editor/ui/EditorActionsMenu.kt +git commit -m "fix(ADFA-4510): resolve code action tooltips at the bind site + +Pass the parent ActionMenu to the submenu adapter so code actions resolve; the +registry only holds top-level actions. + +Drop the contentDescription fallback. It read the action's label, which can +never match a tag, so it converted a missing tooltip into a silent DB miss. +Log a warning instead. + +Use the action's own tooltip category rather than hardcoding 'ide', so +plugin-contributed code actions hit their plugin_ rows." +``` + +--- + +### Task 5: Point the override-superclass dialog at its own tooltip tag + +`EDITOR_CODE_ACTIONS_OVERRIDE_SUPER_DIALOG` is declared but referenced nowhere. The dialog passes the menu-item tag instead, so long-pressing it shows the wrong tooltip. The three sibling dialogs in `FieldBasedAction.kt` already do this correctly. + +**Files:** +- Modify: `lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/OverrideSuperclassMethodsAction.kt` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: nothing consumed by later tasks. + +This file is already tab-indented. The change is behavioural only inside a dialog callback, which no unit test can reach without an Android dialog; it is verified manually in Task 6. + +- [ ] **Step 1: Confirm the constant is currently unreferenced** + +```bash +grep -rn 'EDITOR_CODE_ACTIONS_OVERRIDE_SUPER_DIALOG' --include=*.kt . +``` + +Expected: exactly one hit, the declaration in `idetooltips/.../TooltipTag.kt`. + +- [ ] **Step 2: Point both dialog long-press handlers at the dialog tag** + +In `OverrideSuperclassMethodsAction.kt` (around lines 211-224), replace: + +```kotlin + val listView = dialog.listView + listView.setOnItemLongClickListener { _, view, position, _ -> + showTooltip(context, view, tooltipTag) + true + } + + dialog.setOnShowListener { + val root = dialog.window?.decorView ?: return@setOnShowListener + + root.applyLongPressRecursively { + showTooltip(context, root, tooltipTag) + true + } + } +``` + +with: + +```kotlin + val listView = dialog.listView + listView.setOnItemLongClickListener { _, view, position, _ -> + showTooltip(context, view, TooltipTag.EDITOR_CODE_ACTIONS_OVERRIDE_SUPER_DIALOG) + true + } + + dialog.setOnShowListener { + val root = dialog.window?.decorView ?: return@setOnShowListener + + root.applyLongPressRecursively { + showTooltip(context, root, TooltipTag.EDITOR_CODE_ACTIONS_OVERRIDE_SUPER_DIALOG) + true + } + } +``` + +`TooltipTag` is already imported in this file (it is used for the `tooltipTag` override); confirm with `grep -n 'import com.itsaky.androidide.idetooltips.TooltipTag' ` and add the import if absent. + +- [ ] **Step 3: Verify the constant is now referenced** + +```bash +grep -rn 'EDITOR_CODE_ACTIONS_OVERRIDE_SUPER_DIALOG' --include=*.kt . +``` + +Expected: three hits — the declaration plus the two call sites. + +- [ ] **Step 4: Compile the module** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:java:compileV8DebugKotlin +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 5: Format and commit** + +```bash +flox activate -d flox/local -- ./gradlew spotlessApply +git add lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/OverrideSuperclassMethodsAction.kt +git commit -m "fix(ADFA-4510): use the dialog tooltip tag in the override dialog + +The method-selection dialog passed the menu item's tag, so it showed the menu +tooltip instead of its own. EDITOR_CODE_ACTIONS_OVERRIDE_SUPER_DIALOG was +declared but referenced nowhere." +``` + +--- + +### Task 6: Build and verify on the emulator + +Static analysis and unit tests cannot prove a popup renders. This task confirms the fix end to end. + +**Files:** none modified. + +**Interfaces:** +- Consumes: all previous tasks. +- Produces: the evidence needed to close the ticket. + +- [ ] **Step 1: Copy in the gitignored Firebase config if absent** + +Fresh worktrees lack `app/google-services.json`, and `:app:processV8DebugGoogleServices` fails without it. It should already be present from worktree setup; confirm. + +```bash +repo_root="$(git rev-parse --show-toplevel)" + +# Already there? Nothing to do. +if [ ! -f "$repo_root/app/google-services.json" ]; then + # Name the donor checkout explicitly -- never guess a sibling path, or you can + # copy Firebase config from an unrelated project into this build. + : "${GOOGLE_SERVICES_SRC:?set GOOGLE_SERVICES_SRC to an existing app/google-services.json}" + [ -f "$GOOGLE_SERVICES_SRC" ] || { + echo "not a file: $GOOGLE_SERVICES_SRC" >&2 + exit 1 + } + cp "$GOOGLE_SERVICES_SRC" "$repo_root/app/google-services.json" +fi + +ls -la "$repo_root/app/google-services.json" +``` + +- [ ] **Step 2: Run the full unit test sweep for the touched modules** + +```bash +flox activate -d flox/local -- ./gradlew :actions:testV8DebugUnitTest \ + :lsp:java:testV8DebugUnitTest :lsp:kotlin:testV8DebugUnitTest :editor:testV8DebugUnitTest +``` + +Expected: BUILD SUCCESSFUL. Record the test counts. + +- [ ] **Step 3: Verify formatting is clean** + +```bash +flox activate -d flox/local -- ./gradlew spotlessCheck +``` + +Expected: BUILD SUCCESSFUL. If it fails, run `spotlessApply` and amend the relevant commit. + +- [ ] **Step 4: Build the debug APK** + +```bash +flox activate -d flox/local -- ./gradlew :app:assembleV8Debug --parallel --max-workers=6 +``` + +Expected: BUILD SUCCESSFUL. This takes several minutes. + +- [ ] **Step 4b: Build and side-load the assets payload** + +`:app:assembleV8Debug` does NOT bundle the large assets. A debug install reads them from a +side-loaded zip, and without it the app comes up with no project templates, no Termux bootstrap, no +Android SDK, and no `documentation.db` — so no project can be opened and no tooltip can ever +resolve. `SplitAssetsInstaller` reads `Environment.SPLIT_ASSETS_ZIP` +(`common/.../Environment.java:143`), which is `/sdcard/Download/assets-.zip`. + +```bash +flox activate -d flox/local -- ./gradlew :app:assembleV8Assets +adb -s emulator-5554 push app/build/outputs/assets/assets-arm64-v8a.zip \ + /sdcard/Download/assets-arm64-v8a.zip +``` + +The payload is ~1.1GB and the on-device install runs at next launch. Confirm afterwards: +`adb -s emulator-5554 shell run-as com.itsaky.androidide ls files/home/.cg/templates` must be +non-empty, and `databases/documentation.db` must exist. + +- [ ] **Step 5: Confirm the emulator is up and install** + +```bash +adb devices -l | grep -v offline +``` + +Expected: `emulator-5554` listed. The app is arm-only (`v7`/`v8`), so this must be an arm or arm-translation device. Then install the APK produced in Step 4: + +```bash +adb -s emulator-5554 install -r app/build/outputs/apk/v8/debug/*.apk +``` + +- [ ] **Step 6: Verify tooltips render** + +Open a Java file in the IDE, select some text to raise the editor actions row, tap the Code actions item, then long-press menu entries. Note that the emulator's bottom gesture-exclusion zone swallows coordinate taps — drive the UI with `ACTION_CLICK` via accessibility (`mcp__android__tap_element`) rather than raw coordinates. + +Check: +- Long-pressing a tagged entry (for example **Comment line**) shows a tooltip popup. +- Long-pressing an untagged entry (for example **Remove class**) shows nothing and logs `No tooltip tag for action` — confirm with `adb -s emulator-5554 logcat -d | grep "No tooltip tag"`. +- Open the **Override superclass methods** dialog and long-press it; the text should describe selecting methods to override, not the menu item's description. + +Take a screenshot of a rendered tooltip as evidence for the ticket. + +- [ ] **Step 7: Post progress to Jira** + +```bash +jira issue comment add ADFA-4510 "Fixed in bugfix/ADFA-4510-missing-tooltips-code-actions. Root cause was the render path, not missing tags: code actions are children of CodeActionsMenu and were never resolvable through the registry, and the bind site read retrieveTooltipTag() while every action overrides the tooltipTag property. All 11 menu tags and all 4 dialog tags now resolve. Added unit tests in the actions and lsp/java modules." +``` + +Also confirm the ticket's assignee and status are correct while you are there. + +--- + +## Self-Review + +**Spec coverage.** Every design section maps to a task: unify members (Task 2, Step 5), `ActionMenu.findAction(itemId)` (Task 2, Step 4), submenu adapter parent (Task 4, Steps 3 and 6), drop the `contentDescription` fallback (Task 4, Step 4), tag and category resolution (Task 4, Steps 4-5), the two tag corrections (Task 3), the dialog tag (Task 5), both test files (Tasks 2 and 3), manual verification (Task 6). The spec's "out of scope" items are correctly absent. + +**Type consistency.** `findAction(itemId: Int): ActionItem?` is defined in Task 2 Step 4 and consumed in Task 4 Step 4 with the same name and signature. `retrieveTooltipTag(isReadOnlyContext: Boolean): String` keeps its existing signature throughout. `actionMenu` is the constructor parameter name in Task 4 Steps 3, 4 and 6. `TooltipManager.showTooltip(context, anchorView, category, tag)` matches the signature at `ToolTipManager.kt:187`. + +**Known gap.** Task 4's changes have no automated coverage; `ActionsListAdapter` is a private nested class requiring an `IDEEditor`. Its two ingredients are unit-tested in Task 2, and the wiring is verified manually in Task 6. Chosen deliberately over a brittle Robolectric test that would need heavy sora-editor mocking. diff --git a/docs/superpowers/specs/2026-08-06-adfa-4510-codeaction-tooltips-design.md b/docs/superpowers/specs/2026-08-06-adfa-4510-codeaction-tooltips-design.md new file mode 100644 index 0000000000..561df79123 --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-adfa-4510-codeaction-tooltips-design.md @@ -0,0 +1,200 @@ +# ADFA-4510: Missing tooltips on code actions + +**Ticket:** [ADFA-4510](https://appdevforall.atlassian.net/browse/ADFA-4510) (Bug, Important 4/10, `R2-bugs`) +**Branch:** `bugfix/ADFA-4510-missing-tooltips-code-actions` + +## Problem + +Long-pressing an item in the editor's Code Actions menu shows nothing. The ticket attributes this to +unimplemented tooltip tags. That diagnosis is wrong: 14 of the 15 tags Elissa listed are already +wired to their actions, and all 15 exist in `documentation.db`. The tooltips fail in the render path. + +Note on the database: our local copy may be stale, so DB contents are not treated as authoritative +here. This spec changes only code. Any tag that still shows nothing after this work is a content +hand-off item, not a code defect. + +### Root cause + +Every code action renders through one bind site, `editor/.../EditorActionsMenu.kt:426`: + +```kotlin +val action = getInstance().findAction(location, item.itemId) +val tooltipTag = action?.retrieveTooltipTag(false) ?: "" +val tag = tooltipTag.ifEmpty { item.contentDescription?.toString() ?: "" } +``` + +Three defects stack here. + +**1. `action` is always null for code actions.** Code actions are never registered with the registry; +they are added as children of `CodeActionsMenu` (`lsp/api/.../LSPEditorActions.java:47`). +`DefaultActionsRegistry.findAction` (`:117-125`) scans only the flat per-location map and never +recurses into `ActionMenu.children`. The submenu adapter also receives `onGetActionLocation()` = +`EDITOR_TEXT_ACTIONS` (`:493`), the parent's location. Since `itemId = id.hashCode()` +(`ActionItem.kt:113`), no match is possible. + +**2. A successful lookup would still return `""`.** `ActionItem` carries two members meaning the same +thing: the property `tooltipTag` (`:73-78`) and the function `retrieveTooltipTag()` (`:92`), both +defaulting to `""`. The bind site calls the function. Across `lsp/` there are **0** overrides of the +function and **22** of the property. + +**3. The fallback guarantees a miss.** `item.contentDescription` is set to `action.label` +(`DefaultActionsRegistry.kt:217`) and by nothing else, so the code queries the tooltip DB for a tag +named e.g. `"Comment line"`. `ToolTipManager.kt:211` logs and shows nothing — silence on long-press, +which `REVIEW.md:164` forbids. + +`editor.toolbar.codeactions` works because `CodeActionsMenu` is registered *and* overrides the +function (`CodeActionsMenu.kt:41`) — the opposite of its own children on both counts. + +### Current state of the 15 tags + +| Status | Count | Why | +| --- | --- | --- | +| Show nothing | 11 | Menu items, blocked by defects 1 and 2 | +| Work | 3 | `genconstructor.dialog`, `gentostring.dialog`, `settergetter.dialog` — `FieldBasedAction.kt:250-263` calls `TooltipManager` directly, bypassing the bind site | +| Dead constant | 1 | `overridesuper.dialog` is referenced nowhere; `OverrideSuperclassMethodsAction.kt:212-224` passes the menu-item tag, so the dialog shows the wrong tooltip | + +## Design + +### 1. Unify the two tag members + +`actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt:92` + +```kotlin +fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = tooltipTag +``` + +Fixes defect 2 for every consumer at once. The change is one-directional and cannot regress: + +| Action overrides | Before | After | +| --- | --- | --- | +| neither member | `""` | `""` | +| the function | function value | function value | +| the property | `""` | property value | + +### 2. Let an `ActionMenu` find a child by `itemId` + +`actions/src/main/java/com/itsaky/androidide/actions/ActionMenu.kt`, mirroring the existing +`findAction(id: String)`: + +```kotlin +fun findAction(itemId: Int): ActionItem? = children.find { it.itemId == itemId } +``` + +Flat, one level. Nested action menus do not occur in this codebase; recursion would be speculative. + +### 3. Give the submenu adapter its parent menu + +`editor/src/main/java/com/itsaky/androidide/editor/ui/EditorActionsMenu.kt` + +`ActionsListAdapter` gains `val actionMenu: ActionMenu? = null`. At `:493` the submenu adapter is +constructed with the resolved parent — `findAction(location, item.itemId)` already returns +`CodeActionsMenu` correctly, so no registry change is needed: + +```kotlin +val parent = getInstance().findAction(location, item.itemId) as? ActionMenu +this.list.adapter = + ActionsListAdapter(item.subMenu, true, editor, location = onGetActionLocation(), actionMenu = parent) +``` + +The top-level adapter at `:309` passes `null` and behaves exactly as today. + +### 4. Resolve tag and category at the bind site + +Replaces `:432-434` and `:458-467`. Drops the `contentDescription` fallback and stops hardcoding the +`ide` category, so plugin-contributed code actions resolve against their own `plugin_` category: + +```kotlin +val action = actionMenu?.findAction(item.itemId) + ?: getInstance().findAction(location, item.itemId) +val tag = action?.retrieveTooltipTag(false) ?: "" +val category = action?.retrieveTooltipCategory() ?: TooltipCategory.CATEGORY_IDE +... +button.setOnLongClickListener { + if (tag.isEmpty()) { + log.warn("No tooltip tag for action '{}'", item.title) + } else { + TooltipManager.showTooltip(editor.context, editor, category, tag) + } + true +} +``` + +A logger is added to the existing companion object (`:81`) following the module idiom, +`LoggerFactory.getLogger(...)` as in `IDEEditor.kt:231`. The warn makes an untagged action visible in +logcat instead of silently absent. + +### 5. Tag corrections + +- **Drop** `tooltipTag` from `VariableToStatementAction.kt:42` and `FieldToBlockAction.kt:41`. Both + carry `EDITOR_CODE_ACTIONS_FIX_IMPORTS` by copy-paste; neither touches imports. Unifying the + members would turn them from silent into actively wrong. They stay silent, correctly. +- **Fix** `OverrideSuperclassMethodsAction.kt:212-224` to pass + `EDITOR_CODE_ACTIONS_OVERRIDE_SUPER_DIALOG` for the dialog long-press instead of the menu-item tag. + Retires the dead constant and completes the fourth dialog tag. + +## Testing + +Both existing tooltip tests pass despite this bug: they assert on the property while the render path +reads the function. New coverage targets the seam that actually failed. + +### `actions/src/test/.../ActionTooltipResolutionTest.kt` + +First tests in the `actions` module; adds `testImplementation(projects.testing.unit)`. No circular +dependency — `testing/unit` depends on `buildInfo`, `common`, `shared`, `testing/common` only. +Plain JVM, no Robolectric. Covers both halves of the resolution chain with hand-rolled fake +`ActionItem` / `ActionMenu` implementations. + +`ActionMenu.findAction(itemId)`: + +- returns the matching child +- returns null for an unknown itemId + +`ActionItem.retrieveTooltipTag(false)`: + +- returns the property value when only `tooltipTag` is overridden (the ADFA-4510 regression) +- returns the function value when only `retrieveTooltipTag` is overridden +- returns `""` when neither is overridden + +### `lsp/java/src/test/.../JavaCodeActionTooltipTagTest.kt` + +Mirrors `KotlinCodeActionTooltipTagTest`, into an existing test source set. No new dependencies: +`lsp/java/build.gradle.kts:69` already has `testImplementation(projects.testing.lsp)`, which +re-exports `testing/unit` (JUnit, Truth, MockK). + +- Whole-map `assertEquals` over all 22 actions in `JavaCodeActionsMenu`, read through + `retrieveTooltipTag(false)` — the member the render path uses. A whole-map comparison means a newly + registered untagged action fails automatically. +- Every non-empty tag `startsWith("editor.codeactions.")`. +- The 8 untagged actions pin explicitly to `""`, so tagging one later is a deliberate test edit + rather than silent drift. + +### Manual verification + +Build `:app:assembleV8Debug`, install on `emulator-5554`, open a Java file, and long-press each code +action to confirm a popup renders. + +## Outcome + +14 of 22 Java code actions resolve a tag. All 11 menu tags and all 4 dialog tags from the ticket are +reachable from code. + +The 8 actions with no tag — `RemoveClassAction`, `RemoveMethodAction`, `RemoveUnusedThrowsAction`, +`CreateMissingMethodAction`, `SuppressUncheckedWarningAction`, `AddThrowsAction`, plus the two +corrected above — stay silent. They are outside the ticket's scope and need authored content before +tagging is meaningful. + +## Out of scope + +Filed or noted, not addressed here: + +- All 8 Kotlin code-action tags (`editor.codeactions.kotlin.*`) appear to have no DB rows. Content + hand-off, not code. +- `idetooltips/README.md` documents a Room database and an API that no longer exist (ADFA-4382). +- Tooltip tag/DB reconciliation in CI. The DB lives outside the repo and our copy may be stale, so a + meaningful check is not possible from this worktree. + +## Conflict risk + +Open PR #1624 (ADFA-4824) edits `TooltipTag.kt`, `KotlinCodeActionsMenu.kt`, and +`KotlinCodeActionTooltipTagTest.kt`. This work adds no constants to `TooltipTag.kt` and touches no +Kotlin LSP file, so the surfaces do not overlap. diff --git a/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorActionsMenu.kt b/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorActionsMenu.kt index 715de8484e..10cf6101c6 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorActionsMenu.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorActionsMenu.kt @@ -36,6 +36,7 @@ import androidx.transition.ChangeBounds import androidx.transition.TransitionManager import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.actions.ActionItem +import com.itsaky.androidide.actions.ActionMenu import com.itsaky.androidide.actions.ActionsRegistry import com.itsaky.androidide.actions.ActionsRegistry.Companion.getInstance import com.itsaky.androidide.actions.EditorActionItem @@ -44,6 +45,7 @@ import com.itsaky.androidide.actions.TextTarget import com.itsaky.androidide.editor.adapters.IdeEditorAdapter import com.itsaky.androidide.editor.databinding.LayoutPopupMenuItemBinding import com.itsaky.androidide.editor.ui.EditorActionsMenu.ActionsListAdapter.VH +import com.itsaky.androidide.idetooltips.TooltipCategory import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.lsp.api.ILanguageClient import com.itsaky.androidide.lsp.api.ILanguageServerRegistry @@ -63,6 +65,7 @@ import io.github.rosemoe.sora.event.SubscriptionReceipt import io.github.rosemoe.sora.text.Cursor import io.github.rosemoe.sora.widget.CodeEditor import io.github.rosemoe.sora.widget.EditorTouchEventHandler +import org.slf4j.LoggerFactory import java.io.File import kotlin.math.max import kotlin.math.min @@ -80,6 +83,8 @@ open class EditorActionsMenu( MenuBuilder.Callback { companion object { const val DELAY: Long = 200 + + private val log = LoggerFactory.getLogger(EditorActionsMenu::class.java) } private val touchHandler: EditorTouchEventHandler = editor.eventHandler @@ -406,6 +411,9 @@ open class EditorActionsMenu( val forceShowTitle: Boolean = false, val editor: IDEEditor, val location: ActionItem.Location, + // Children of a submenu are not registered with the ActionsRegistry, so they can only be + // resolved through their parent menu (ADFA-4510). Null for the top-level actions row. + val actionMenu: ActionMenu? = null, ) : RecyclerView.Adapter() { override fun getItemCount(): Int = menu?.size() ?: 0 @@ -429,9 +437,11 @@ open class EditorActionsMenu( ) { val item = getItem(position) ?: return - val action = getInstance().findAction(location, item.itemId) - val tooltipTag = action?.retrieveTooltipTag(false) ?: "" - val tag = tooltipTag.ifEmpty { item.contentDescription?.toString() ?: "" } + val action = + actionMenu?.findAction(item.itemId) + ?: getInstance().findAction(location, item.itemId) + val tag = action?.retrieveTooltipTag(false) ?: "" + val category = action?.retrieveTooltipCategory() ?: TooltipCategory.CATEGORY_IDE val button = holder.binding.root button.text = if (forceShowTitle) item.title else "" @@ -456,13 +466,17 @@ open class EditorActionsMenu( } button.setOnLongClickListener { - if (tag.isNotEmpty()) { - TooltipManager.showIdeCategoryTooltip( - context = editor.context, - anchorView = editor, - tag = tag, - ) + if (tag.isEmpty()) { + log.warn("No tooltip tag for action '{}'", item.title) } + // An empty tag still goes through: a DB miss renders the documentation + // fallback (ADFA-4754), which beats a dead long-press. + TooltipManager.showTooltip( + context = editor.context, + anchorView = editor, + category = category, + tag = tag, + ) true } } @@ -489,8 +503,15 @@ open class EditorActionsMenu( this.editor.post { TransitionManager.beginDelayedTransition(this.list, ChangeBounds()) this.list.layoutManager = LinearLayoutManager(editor.context) + val parentMenu = getInstance().findAction(onGetActionLocation(), item.itemId) as? ActionMenu this.list.adapter = - ActionsListAdapter(item.subMenu, true, editor, location = onGetActionLocation()) + ActionsListAdapter( + item.subMenu, + true, + editor, + location = onGetActionLocation(), + actionMenu = parentMenu, + ) this.list.post { measureActionsList() diff --git a/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorSearchLayout.kt b/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorSearchLayout.kt index 24d754fbd9..1c39f28eb7 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorSearchLayout.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorSearchLayout.kt @@ -242,6 +242,8 @@ class EditorSearchLayout( findInFileBinding.root.visibility = VISIBLE onSearchModeChanged?.invoke(true) + refreshSearch() + findInFileBinding.searchInput.requestFocus() findInFileBinding.searchInput.post { ViewCompat.getWindowInsetsController(findInFileBinding.searchInput)?.show(WindowInsetsCompat.Type.ime()) @@ -292,6 +294,9 @@ class EditorSearchLayout( searcher.onClose() onSearchModeChanged?.invoke(false) } + if (!searcher.hasQuery()) { + refreshSearch() + } if (!searcher.hasQuery()) { return } diff --git a/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt b/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt index 55b7562d7d..73a0c87cdb 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt @@ -95,6 +95,7 @@ import io.github.rosemoe.sora.widget.IDEEditorSearcher import io.github.rosemoe.sora.widget.component.EditorAutoCompletion import io.github.rosemoe.sora.widget.component.EditorBuiltinComponent import io.github.rosemoe.sora.widget.component.EditorTextActionWindow +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -339,9 +340,16 @@ open class IDEEditor * in [runCatching]. This prevents the app from crashing if the editor's internal layout * calculation fails during the insertion. */ - fun appendBatch(text: String) { - if (isReadyToAppend) { - runCatching { append(text) } + fun appendBatch(text: String): Boolean { + if (!isReadyToAppend) return false + return try { + append(text) + true + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.warn("Failed to append batch to editor", e) + false } } diff --git a/floating-window/build.gradle.kts b/floating-window/build.gradle.kts index cfbbbe8a0b..bb638bc857 100644 --- a/floating-window/build.gradle.kts +++ b/floating-window/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { implementation(libs.common.kotlin.coroutines.android) implementation(libs.google.material) + implementation(projects.commonCompose) implementation(projects.editorApi) implementation(projects.common) implementation(projects.resources) diff --git a/floating-window/src/main/java/com/itsaky/androidide/floating/ui/FloatingTheme.kt b/floating-window/src/main/java/com/itsaky/androidide/floating/ui/FloatingTheme.kt index c3401c9da3..6061716a0b 100644 --- a/floating-window/src/main/java/com/itsaky/androidide/floating/ui/FloatingTheme.kt +++ b/floating-window/src/main/java/com/itsaky/androidide/floating/ui/FloatingTheme.kt @@ -2,28 +2,17 @@ package com.itsaky.androidide.floating.ui -import android.content.Context -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.ColorScheme -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Typography -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight -import com.google.android.material.color.MaterialColors -import com.google.android.material.R as MatR +import com.itsaky.androidide.common.compose.IdeTheme import com.itsaky.androidide.resources.R as ResR -private const val UNRESOLVED_COLOR = Int.MIN_VALUE - private val AtkinsonHyperlegible: FontFamily = FontFamily( Font(ResR.font.atkinson_hyperlegible_regular, FontWeight.Normal), @@ -33,22 +22,22 @@ private val AtkinsonHyperlegible: FontFamily = ) /** - * Wraps floating-window content in a [MaterialTheme] whose colors are read live from the IDE's XML - * `Theme.AndroidIDE` (via the supplied window context) and whose type uses the IDE's Atkinson - * Hyperlegible face. This keeps overlay windows visually identical to the docked editor, including - * light/dark. + * Wraps floating-window content in the shared [IdeTheme] -- colors read live from the IDE's XML + * `Theme.AndroidIDE` via the window context -- with type overridden to the IDE's Atkinson Hyperlegible + * face. This keeps overlay windows visually identical to the docked editor, including light/dark. + * + * Only the typography is local to this module; the color mapping is shared so every Compose surface + * resolves theme attributes the same way. */ @Composable fun FloatingTheme(content: @Composable () -> Unit) { - val context = LocalContext.current - val dark = isSystemInDarkTheme() - val colorScheme = remember(context, dark) { context.toComposeColorScheme(dark) } val typography = remember { brandedTypography() } - MaterialTheme(colorScheme = colorScheme, typography = typography, content = content) + IdeTheme(typography = typography, content = content) } private fun brandedTypography(): Typography { val base = Typography() + fun TextStyle.branded(): TextStyle = copy(fontFamily = AtkinsonHyperlegible) return base.copy( titleMedium = base.titleMedium.branded(), @@ -59,29 +48,3 @@ private fun brandedTypography(): Typography { labelSmall = base.labelSmall.branded(), ) } - -private fun Context.toComposeColorScheme(dark: Boolean): ColorScheme { - val base = if (dark) darkColorScheme() else lightColorScheme() - - fun color(attr: Int, fallback: Color): Color { - val resolved = MaterialColors.getColor(this, attr, UNRESOLVED_COLOR) - return if (resolved == UNRESOLVED_COLOR) fallback else Color(resolved) - } - - return base.copy( - primary = color(MatR.attr.colorPrimary, base.primary), - onPrimary = color(MatR.attr.colorOnPrimary, base.onPrimary), - primaryContainer = color(MatR.attr.colorPrimaryContainer, base.primaryContainer), - onPrimaryContainer = color(MatR.attr.colorOnPrimaryContainer, base.onPrimaryContainer), - secondary = color(MatR.attr.colorSecondary, base.secondary), - onSecondary = color(MatR.attr.colorOnSecondary, base.onSecondary), - surface = color(MatR.attr.colorSurface, base.surface), - onSurface = color(MatR.attr.colorOnSurface, base.onSurface), - surfaceVariant = color(MatR.attr.colorSurfaceVariant, base.surfaceVariant), - onSurfaceVariant = color(MatR.attr.colorOnSurfaceVariant, base.onSurfaceVariant), - outline = color(MatR.attr.colorOutline, base.outline), - error = color(MatR.attr.colorError, base.error), - onError = color(MatR.attr.colorOnError, base.onError), - background = color(android.R.attr.colorBackground, base.background), - ) -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9c4e15649b..a0d5ac5e88 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] activityKtx = "1.8.2" agp = "8.8.2" -agp-tooling = "8.11.0" +agp-tooling = "9.3.1" androidx-sqlite = "2.6.2" appcompatVersion = "1.7.1" colorpickerview = "2.3.0" @@ -96,6 +96,8 @@ androidx-fragment = { module = "androidx.fragment:fragment", version.ref = "frag androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" } androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycleViewmodelKtx" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleViewmodelKtx" } +# Provides collectAsStateWithLifecycle(), the state-collection API mandated by ADR 0009. +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleViewmodelKtx" } androidx-palette-ktx = { module = "androidx.palette:palette-ktx", version.ref = "paletteKtx" } androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtxVersion" } androidx-recyclerview-v132 = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } @@ -137,6 +139,7 @@ compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" compose-foundation = { module = "androidx.compose.foundation:foundation" } compose-material3 = { module = "androidx.compose.material3:material3" } compose-activity = { module = "androidx.activity:activity-compose", version = "1.8.2" } +compose-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleViewmodelKtx" } # Firebase firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" } @@ -269,6 +272,7 @@ git-jgit = { module = "org.eclipse.jgit:org.eclipse.jgit", version = "6.8.0.2023 tests-junit = { module = "junit:junit", version = "4.13.2" } tests-junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" } tests-junit-platformLauncher = { module = "org.junit.platform:junit-platform-launcher" } +tests-junit-vintageEngine = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit-jupiter" } core-tests-anroidx-arch = { module = "androidx.arch.core:core-testing", version.ref = "anroidx-test-core" } tests-google-truth = { module = "com.google.truth:truth", version = "1.4.1" } tests-robolectric = { module = "org.robolectric:robolectric", version = "4.11.1" } @@ -291,6 +295,7 @@ tests-junit-kts = { module = "androidx.test.ext:junit-ktx", version = "1.2.1" } tests-kotlinx-coroutines = {module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesCore"} # Tooling +tooling-agp = { module = "com.android.tools.build:gradle", version.ref = "agp-tooling" } tooling-builderModel = { module = "com.android.tools.build:builder-model", version.ref = "agp-tooling" } tooling-gradleApi = { module = "com.itsaky.androidide.gradle:gradle-tooling-api", version.ref = "gradle-tooling" } tooling-slf4j = { module = "org.slf4j:slf4j-api", version = "2.0.12" } diff --git a/idetooltips/build.gradle.kts b/idetooltips/build.gradle.kts index 4716486943..42999a14f9 100644 --- a/idetooltips/build.gradle.kts +++ b/idetooltips/build.gradle.kts @@ -3,7 +3,6 @@ import com.itsaky.androidide.build.config.BuildConfig plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.android.library) - id("kotlin-kapt") } android { @@ -20,9 +19,6 @@ kotlin { } dependencies { - kapt(libs.room.compiler) - - implementation(libs.room.ktx) implementation(libs.google.gson) implementation(libs.google.guava) implementation(libs.androidx.constraintlayout) diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt index 5254603145..92d5221b68 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt @@ -209,7 +209,17 @@ object TooltipManager { } ) } else { - Log.e(TAG, "Tooltip item $tooltipItem is null") + Log.d(TAG, "No tooltip for category='$category', tag='$tag'; showing documentation fallback") + showTooltipPopup( + context = context, + anchorView = anchorView, + level = 0, + tooltipItem = IDETooltipItem(-1, -1, category, tag, "", "", arrayListOf(), ""), + requestFocus = requestFocus, + onHelpLinkClicked = { context, url, _ -> + HelpActivity.launch(context, url, context.getString(ResR.string.back_to_cogo)) + } + ) } } } @@ -308,12 +318,18 @@ object TooltipManager { else ResR.color.tooltip_link_color_light, ).toCssHex() + val detailContent = tooltipItem.detail.takeUnless { it.isMissingTooltipContent() } ?: "" val tooltipHtmlContent = when (level) { 0 -> { - tooltipItem.summary + // A blank or "n/a" summary is a dead end; route the user to the + // documentation instead (ADFA-4754). + tooltipItem.summary.takeUnless { it.isMissingTooltipContent() } + ?: context.getString( + ResR.string.tooltip_missing_fallback_html, + context.getString(ResR.string.docs_url), + ) } 1 -> { - val detailContent = tooltipItem.detail.ifBlank { "" } if (tooltipItem.buttons.isNotEmpty()) { val buttonsSeparator = context.getString(R.string.tooltip_buttons_separator) val linksHtml = tooltipItem.buttons.joinToString(buttonsSeparator) { (label, url) -> @@ -367,7 +383,7 @@ object TooltipManager { onSeeMoreClicked(popupWindow, nextLevel, tooltipItem) } val shouldShowSeeMore = when { - level == 0 && (tooltipItem.detail.isNotBlank() || tooltipItem.buttons.isNotEmpty()) -> true + level == 0 && (detailContent.isNotBlank() || tooltipItem.buttons.isNotEmpty()) -> true else -> false } seeMore.visibility = if (shouldShowSeeMore) View.VISIBLE else View.GONE @@ -550,6 +566,9 @@ object TooltipManager { """.trimIndent() } + private fun String.isMissingTooltipContent(): Boolean = + isBlank() || trim().equals("n/a", ignoreCase = true) + private fun View.isInOverlayWindow(): Boolean { val params = layoutParams return params is WindowManager.LayoutParams && diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt index d8e71fd5dd..1a46ea1775 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -50,6 +50,8 @@ object TooltipTag { const val PREFS_EDITOR_XML = "prefs.editor.xml" const val PREFS_DEVELOPER = "prefs.developer" const val PLUGIN_MANAGER = "plugin.manager" + const val EXTERNAL_FILE_INSTALL = "external.file.install" + const val TEMPLATE_MANAGER = "template.manager" const val TEMPLATE_TABBED_ACTIVITY = "template.tabbed.activity" const val TEMPLATE_LEGACY_PROJECT = "template.legacy.project" const val TEMPLATE_EMPTY_ACTIVITY = "template.empty.activity" @@ -70,6 +72,7 @@ object TooltipTag { const val EDITOR_CODE_ACTIONS_GOTO_DEF = "editor.codeactions.gotodef" const val EDITOR_CODE_ACTIONS_FIND_REFS = "editor.codeactions.findrefs" const val EDITOR_CODE_ACTIONS_FIX_IMPORTS = "editor.codeactions.fiximports" + const val EDITOR_CODE_ACTIONS_FIX_IMPORTS_DIALOG = "editor.codeactions.fiximports.dialog" const val EDITOR_CODE_ACTIONS_SETTER_GETTER = "editor.codeactions.settergetter" const val EDITOR_CODE_ACTIONS_SETTER_GETTER_DIALOG = "editor.codeactions.settergetter.dialog" const val EDITOR_CODE_ACTIONS_OVERRIDE_SUPER = "editor.codeactions.overridesuper" @@ -81,17 +84,25 @@ object TooltipTag { const val EDITOR_CODE_ACTIONS_GEN_TO_STRING_DIALOG = "editor.codeactions.gentostring.dialog" const val EDITOR_CODE_ACTIONS_UNUSED_IMPORTS = "editor.codeactions.unusedimports" const val EDITOR_CODE_ACTIONS_ORGANIZE_IMPORTS = "editor.codeactions.organizeimports" + const val EDITOR_CODE_ACTIONS_TRY_CATCH = "editor.codeactions.trycatch" // Kotlin code actions. Tags are per-language even where the action exists in both languages, // so the tooltip can describe the Kotlin behaviour (see ADFA-4730). const val EDITOR_CODE_ACTIONS_KT_COMMENT = "editor.codeactions.kotlin.comment" const val EDITOR_CODE_ACTIONS_KT_UNCOMMENT = "editor.codeactions.kotlin.uncomment" const val EDITOR_CODE_ACTIONS_KT_IMPORT_CLASS = "editor.codeactions.kotlin.importclass" + const val EDITOR_CODE_ACTIONS_KT_IMPORT_CLASS_DIALOG = + "editor.codeactions.kotlin.importclass.dialog" const val EDITOR_CODE_ACTIONS_KT_ORGANIZE_IMPORTS = "editor.codeactions.kotlin.organizeimports" const val EDITOR_CODE_ACTIONS_KT_IMPLEMENT_MEMBERS = "editor.codeactions.kotlin.implementmembers" const val EDITOR_CODE_ACTIONS_KT_NULL_SAFETY_FIX = "editor.codeactions.kotlin.nullsafetyfix" + const val EDITOR_CODE_ACTIONS_KT_NULL_SAFETY_FIX_DIALOG = + "editor.codeactions.kotlin.nullsafetyfix.dialog" const val EDITOR_CODE_ACTIONS_KT_SURROUND_TRY_CATCH = "editor.codeactions.kotlin.trycatch" const val EDITOR_CODE_ACTIONS_KT_GOTO_DEF = "editor.codeactions.kotlin.gotodef" + const val EDITOR_CODE_ACTIONS_KT_FIND_REFS = "editor.codeactions.kotlin.findrefs" + const val EDITOR_CODE_ACTIONS_KT_EXTRACT_VARIABLE = "editor.codeactions.kotlin.extractvariable" + const val EDITOR_CODE_ACTIONS_KT_EXTRACT_METHOD = "editor.codeactions.kotlin.extractmethod" const val EXIT_TO_MAIN = "exit.to.main" diff --git a/infra/well-known-worker/README.md b/infra/well-known-worker/README.md new file mode 100644 index 0000000000..ac5a01d6bf --- /dev/null +++ b/infra/well-known-worker/README.md @@ -0,0 +1,65 @@ +# well-known Worker + +Serves `https://appdevforall.org/.well-known/assetlinks.json` (and the `www` +host) out of the private `well-known` R2 bucket, for Android App Link +verification (ADFA-5067). + +## Why a Worker and not an Origin Rule + +R2 picks a bucket from the `Host` header, so pointing a path at it with an +Origin Rule needs a host header override plus a DNS record override. Both are +Enterprise-only; the Free plan exposes just the destination-port override. + +A Worker replaces the origin fetch instead of retargeting it. `env.WELL_KNOWN` +is an in-network binding rather than a URL, so no DNS, TLS or `Host` header is +involved and the bucket needs no public hostname at all. **Leave the bucket's +public access disabled** - it is reachable only through this Worker. + +A Redirect Rule to an R2 custom domain is not an alternative: Android's App Link +verifier does not follow redirects. + +## Shape + +- Routes match **exact paths**, not `/.well-known/*`. A wildcard would also + capture `/.well-known/acme-challenge/`, which the origin needs for certificate + renewal. +- A request whose object is missing falls through to the site origin, so the + Worker can never black-hole a path it does not own. +- `Content-Type` is replayed from the object's stored metadata. The uploader + sets `application/json`; re-uploading by hand without `--content-type` yields + `binary/octet-stream` and fails verification. +- No `Cache-Control`. `signing-fingerprint.yml` re-fetches the URL within seconds + of writing it and compares bytes, so a cached copy would fail that check on a + legitimate update. + +## Deploying + +CI only, via `.github/workflows/deploy-well-known-worker.yml` - it runs on any +push touching this directory, and on manual dispatch. + +Prerequisites: + +1. The `well-known` R2 bucket exists in the account. `wrangler deploy` does not + create it. +2. A `CLOUDFLARE_WORKERS_DEPLOY_TOKEN` repository secret, non-expiring, with: + - **Account -> Workers Scripts -> Edit** + - **Account -> Workers R2 Storage -> Read** - wrangler resolves the bucket + named in the binding via `GET /accounts//r2/buckets/well-known`, and + fails with `Authentication error [code: 10000]` without it. A scope added + to an existing token takes a few minutes to take effect, and the same + error persists until it does - wait before re-running + - **Zone -> Workers Routes -> Edit** on `appdevforall.org` + + The existing `CLOUDFLARE_KEY_ID` / `CLOUDFLARE_SECRET_ACCESS_KEY` pair is an + R2 S3-compatible credential and cannot deploy a Worker. + +## Verifying + +The object is published by the **Print release signing certificate fingerprint** +workflow with `deploy` enabled, which also verifies the served result. By hand: + +```bash +curl -sSI https://www.appdevforall.org/.well-known/assetlinks.json # 200, application/json, no 3xx +adb shell pm verify-app-links --re-verify com.itsaky.androidide +adb shell pm get-app-links com.itsaky.androidide +``` diff --git a/infra/well-known-worker/src/index.js b/infra/well-known-worker/src/index.js new file mode 100644 index 0000000000..110fff4824 --- /dev/null +++ b/infra/well-known-worker/src/index.js @@ -0,0 +1,41 @@ +/** + * Serves the private "well-known" R2 bucket at the request path. + * + * Cloudflare Origin Rules cannot retarget an origin on the Free plan - host + * header, SNI and DNS record overrides are all Enterprise-only - so the bucket + * is reached through an R2 binding instead. env.WELL_KNOWN is an in-network + * handle, not a URL, so the bucket needs no public hostname and its public + * access stays switched off. + */ +export default { + async fetch(request, env) { + // Anything this Worker does not own passes through to the site origin. + if (request.method !== "GET" && request.method !== "HEAD") { + return fetch(request); + } + + // R2 keys carry no leading slash: "/.well-known/assetlinks.json" is stored + // as ".well-known/assetlinks.json". The routes match exact paths, so this + // is the whole path-to-key mapping. + const key = new URL(request.url).pathname.slice(1); + const object = + request.method === "HEAD" + ? await env.WELL_KNOWN.head(key) + : await env.WELL_KNOWN.get(key); + + if (object === null) { + return fetch(request); + } + + const headers = new Headers(); + // Replays the Content-Type recorded at upload time. Digital Asset Links + // requires application/json, which the deploy step sets via --content-type. + object.writeHttpMetadata(headers); + headers.set("etag", object.httpEtag); + + // No Cache-Control on purpose: signing-fingerprint.yml re-fetches this URL + // within seconds of writing the object and compares bytes, so a cached copy + // would fail that check on a legitimate update. + return new Response(request.method === "HEAD" ? null : object.body, { headers }); + }, +}; diff --git a/infra/well-known-worker/wrangler.toml b/infra/well-known-worker/wrangler.toml new file mode 100644 index 0000000000..4a55f0f57c --- /dev/null +++ b/infra/well-known-worker/wrangler.toml @@ -0,0 +1,22 @@ +# Serves /.well-known/assetlinks.json for appdevforall.org out of the private +# "well-known" R2 bucket. Deployed by .github/workflows/deploy-well-known-worker.yml. + +name = "well-known" +main = "src/index.js" +compatibility_date = "2026-08-18" + +# Nothing should reach this Worker except through the routes below; a workers.dev +# URL would expose the bucket on a second, unverified hostname. +workers_dev = false + +# Exact paths, no trailing wildcard. A "/.well-known/*" route would also capture +# /.well-known/acme-challenge/, which the origin needs for certificate renewal. +routes = [ + { pattern = "appdevforall.org/.well-known/assetlinks.json", zone_name = "appdevforall.org" }, + { pattern = "www.appdevforall.org/.well-known/assetlinks.json", zone_name = "appdevforall.org" }, +] + +# binding must match env.WELL_KNOWN in src/index.js. +[[r2_buckets]] +binding = "WELL_KNOWN" +bucket_name = "well-known" diff --git a/logsender/src/main/res/values-in/strings.xml b/logsender/src/main/res/values-in/strings.xml new file mode 100644 index 0000000000..a19f3b781e --- /dev/null +++ b/logsender/src/main/res/values-in/strings.xml @@ -0,0 +1,24 @@ + + + + Gagal terhubung ke Code On The Go + Keluar + Layanan LogSender + Terhubung ke Code On The Go + Layanan LogSender + \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/SurroundWithTryCatch.kt b/lsp/api/src/main/java/com/itsaky/androidide/lsp/actions/SurroundWithTryCatch.kt similarity index 64% rename from lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/SurroundWithTryCatch.kt rename to lsp/api/src/main/java/com/itsaky/androidide/lsp/actions/SurroundWithTryCatch.kt index f41a5dac0a..7bc52473cf 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/SurroundWithTryCatch.kt +++ b/lsp/api/src/main/java/com/itsaky/androidide/lsp/actions/SurroundWithTryCatch.kt @@ -1,4 +1,4 @@ -package com.itsaky.androidide.lsp.kotlin.utils +package com.itsaky.androidide.lsp.actions import com.itsaky.androidide.lsp.models.TextEdit import com.itsaky.androidide.models.Position @@ -7,12 +7,12 @@ import com.itsaky.androidide.models.Range /** * Resolves an editor selection (cursor left/right line+column) to the whole-line * span the surround action wraps. Whole-line based by design: mid-line columns - * still select the entire line -- statement-boundary snapping needs PSI, which is - * out of scope, so a wrapped `val x = ...` on a multi-statement line stays scoped - * inside the try. A selection whose end handle sits at column 0 of the line after - * the last selected line (the common "drag to select whole lines" gesture) would - * otherwise wrap that trailing, visually-unselected line, so it is trimmed. - * Returns (startLine, endLine), 0-based inclusive. + * still select the entire line -- statement-boundary snapping needs a syntax + * tree, which is out of scope, so a wrapped declaration on a multi-statement + * line stays scoped inside the try. A selection whose end handle sits at column + * 0 of the line after the last selected line (the common "drag to select whole + * lines" gesture) would otherwise wrap that trailing, visually-unselected line, + * so it is trimmed. Returns (startLine, endLine), 0-based inclusive. */ fun resolveSurroundLines( leftLine: Int, @@ -27,19 +27,24 @@ fun resolveSurroundLines( /** * Wraps lines [startLine]..[endLine] (0-based, inclusive) of [text] in a - * try/catch block. Whole-line based: columns are ignored and full lines are - * replaced. Indentation is computed here so the result is correct even without a - * follow-up formatter. Returns null when the span is blank (a whitespace-only - * selection is an intended silent no-op) or out of range. + * try/catch block. The catch syntax is language-specific and provided by the + * caller: [catchClause] is the clause without braces (e.g. `catch (Exception e)`) + * and [catchBody] the single handler statement. Whole-line based: columns are + * ignored and full lines are replaced. Indentation is computed here so the + * result is correct even without a follow-up formatter. Returns null when the + * span is blank (a whitespace-only selection is an intended silent no-op) or + * out of range. */ fun computeSurroundWithTryCatchEdit( text: String, startLine: Int, endLine: Int, + catchClause: String, + catchBody: String, ): TextEdit? { val nl = if (text.contains("\r\n")) "\r\n" else "\n" val lines = text.split(nl) - if (startLine < 0 || startLine > endLine || endLine >= lines.size) { + if (startLine !in 0..endLine || endLine >= lines.size) { return null } @@ -58,8 +63,12 @@ fun computeSurroundWithTryCatchEdit( buildString { append(baseIndent).append("try {").append(nl) append(body).append(nl) - append(baseIndent).append("} catch (e: Exception) {").append(nl) - append(baseIndent).append(indentUnit).append("e.printStackTrace()").append(nl) + append(baseIndent) + .append("} ") + .append(catchClause) + .append(" {") + .append(nl) + append(baseIndent).append(indentUnit).append(catchBody).append(nl) append(baseIndent).append("}") } diff --git a/lsp/api/src/main/java/com/itsaky/androidide/lsp/actions/SurroundWithTryCatchAction.kt b/lsp/api/src/main/java/com/itsaky/androidide/lsp/actions/SurroundWithTryCatchAction.kt new file mode 100644 index 0000000000..d5fc107e98 --- /dev/null +++ b/lsp/api/src/main/java/com/itsaky/androidide/lsp/actions/SurroundWithTryCatchAction.kt @@ -0,0 +1,131 @@ +package com.itsaky.androidide.lsp.actions + +import android.content.Context +import android.graphics.drawable.Drawable +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.ActionItem +import com.itsaky.androidide.actions.EditorActionItem +import com.itsaky.androidide.actions.hasRequiredData +import com.itsaky.androidide.actions.markInvisible +import com.itsaky.androidide.actions.requireContext +import com.itsaky.androidide.actions.requireEditor +import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.lsp.api.ILanguageServerRegistry +import com.itsaky.androidide.lsp.models.CodeActionItem +import com.itsaky.androidide.lsp.models.CodeActionKind +import com.itsaky.androidide.lsp.models.Command +import com.itsaky.androidide.lsp.models.DocumentChange +import com.itsaky.androidide.lsp.models.TextEdit +import com.itsaky.androidide.resources.R +import org.slf4j.LoggerFactory +import java.io.File + +class SurroundWithTryCatchAction( + lang: String, + private val targetFileExtensions: List, + private val serverId: String, + private val catchClause: String, + private val catchBody: String, + tag: String, +) : EditorActionItem { + companion object { + /** The id is per-language, since one instance is registered per language. */ + fun idFor(lang: String) = "ide.editor.lsp.$lang.surroundWithTryCatch" + + private val logger = LoggerFactory.getLogger(SurroundWithTryCatchAction::class.java) + } + + constructor( + lang: String, + extension: String, + serverId: String, + catchClause: String, + catchBody: String, + tag: String, + ) : this(lang, listOf(extension), serverId, catchClause, catchBody, tag) + + override val id: String = idFor(lang) + override var label: String = "" + + override var visible = true + override var enabled = true + override var icon: Drawable? = null + override var location: ActionItem.Location = ActionItem.Location.EDITOR_CODE_ACTIONS + + // Reads the editor selection, so it must run on the UI thread (as CommentLineAction does). + override var requiresUIThread: Boolean = true + + // Required, not defaulted: one instance is registered per language, and a default would let a + // new language silently inherit another language's tooltip. + override var tooltipTag: String = tag + + override fun prepare(data: ActionData) { + super.prepare(data) + + if (!data.hasRequiredData(Context::class.java, File::class.java)) { + markInvisible() + return + } + + val context = data.requireContext() + label = context.getString(R.string.action_surround_with_try_catch) + + val file = data.requireFile() + if (file.extension !in targetFileExtensions) { + markInvisible() + return + } + } + + override suspend fun execAction(data: ActionData): List { + val editor = data.requireEditor() + val cursor = editor.cursor + val (startLine, endLine) = + resolveSurroundLines( + cursor.leftLine, + cursor.leftColumn, + cursor.rightLine, + cursor.rightColumn, + ) + val edit = + computeSurroundWithTryCatchEdit( + editor.text.toString(), + startLine, + endLine, + catchClause, + catchBody, + ) ?: return emptyList() + return listOf(edit) + } + + override fun postExec( + data: ActionData, + result: Any, + ) { + super.postExec(data, result) + + if (result !is List<*> || result.isEmpty()) { + return + } + + @Suppress("UNCHECKED_CAST") + val edits = result as List + + val client = + ILanguageServerRegistry.default.getServer(serverId)?.client + ?: run { + logger.warn("No language client set. Cannot complete action.") + return + } + + val file = data.requireFile() + client.performCodeAction( + CodeActionItem( + title = label, + changes = listOf(DocumentChange(file = file.toPath(), edits = edits)), + kind = CodeActionKind.QuickFix, + command = Command.CMD_FORMAT_CODE, + ), + ) + } +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/SurroundWithTryCatchTest.kt b/lsp/api/src/test/java/com/itsaky/androidide/lsp/actions/SurroundWithTryCatchTest.kt similarity index 63% rename from lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/SurroundWithTryCatchTest.kt rename to lsp/api/src/test/java/com/itsaky/androidide/lsp/actions/SurroundWithTryCatchTest.kt index 52ecfa4fcd..ef714b9fa8 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/SurroundWithTryCatchTest.kt +++ b/lsp/api/src/test/java/com/itsaky/androidide/lsp/actions/SurroundWithTryCatchTest.kt @@ -1,4 +1,4 @@ -package com.itsaky.androidide.lsp.kotlin.utils +package com.itsaky.androidide.lsp.actions import com.google.common.truth.Truth.assertThat import com.itsaky.androidide.models.Position @@ -9,9 +9,28 @@ import org.junit.runners.JUnit4 @RunWith(JUnit4::class) class SurroundWithTryCatchTest { + private companion object { + const val KT_CATCH_CLAUSE = "catch (e: Exception)" + const val KT_CATCH_BODY = "e.printStackTrace()" + const val JAVA_CATCH_CLAUSE = "catch (Exception e)" + const val JAVA_CATCH_BODY = "e.printStackTrace();" + } + + private fun kotlinEdit( + text: String, + startLine: Int, + endLine: Int, + ) = computeSurroundWithTryCatchEdit(text, startLine, endLine, KT_CATCH_CLAUSE, KT_CATCH_BODY) + + private fun javaEdit( + text: String, + startLine: Int, + endLine: Int, + ) = computeSurroundWithTryCatchEdit(text, startLine, endLine, JAVA_CATCH_CLAUSE, JAVA_CATCH_BODY) + @Test fun `single unindented line is wrapped`() { - val edit = computeSurroundWithTryCatchEdit("foo()", 0, 0) + val edit = kotlinEdit("foo()", 0, 0) assertThat(edit).isNotNull() assertThat(edit!!.newText).isEqualTo( "try {\n\tfoo()\n} catch (e: Exception) {\n\te.printStackTrace()\n}", @@ -26,7 +45,7 @@ class SurroundWithTryCatchTest { @Test fun `indented multi-line block preserves and deepens indentation`() { val text = "fun f() {\n\tval a = read()\n\tprocess(a)\n}" - val edit = computeSurroundWithTryCatchEdit(text, 1, 2) + val edit = kotlinEdit(text, 1, 2) assertThat(edit).isNotNull() assertThat(edit!!.newText).isEqualTo( "\ttry {\n\t\tval a = read()\n\t\tprocess(a)\n\t} catch (e: Exception) {\n\t\te.printStackTrace()\n\t}", @@ -40,7 +59,7 @@ class SurroundWithTryCatchTest { @Test fun `blank lines inside the span are not indented`() { - val edit = computeSurroundWithTryCatchEdit("a()\n\nb()", 0, 2) + val edit = kotlinEdit("a()\n\nb()", 0, 2) assertThat(edit!!.newText).isEqualTo( "try {\n\ta()\n\n\tb()\n} catch (e: Exception) {\n\te.printStackTrace()\n}", ) @@ -49,7 +68,7 @@ class SurroundWithTryCatchTest { @Test fun `space-indented file produces a spaces-only body`() { val text = "fun f() {\n val a = read()\n process(a)\n}" - val edit = computeSurroundWithTryCatchEdit(text, 1, 2) + val edit = kotlinEdit(text, 1, 2) assertThat(edit).isNotNull() assertThat(edit!!.newText).isEqualTo( " try {\n val a = read()\n process(a)\n" + @@ -61,16 +80,41 @@ class SurroundWithTryCatchTest { ) } + @Test + fun `java catch clause and semicolon body are emitted`() { + val edit = javaEdit("foo();", 0, 0) + assertThat(edit).isNotNull() + assertThat(edit!!.newText).isEqualTo( + "try {\n\tfoo();\n} catch (Exception e) {\n\te.printStackTrace();\n}", + ) + assertThat(edit.range).isEqualTo( + Range(Position(0, 0, 0), Position(0, 6, 6)), + ) + } + + @Test + fun `java indented multi-line block preserves and deepens indentation`() { + val text = "void f() {\n\tint a = read();\n\tprocess(a);\n}" + val edit = javaEdit(text, 1, 2) + assertThat(edit).isNotNull() + assertThat(edit!!.newText).isEqualTo( + "\ttry {\n\t\tint a = read();\n\t\tprocess(a);\n\t} catch (Exception e) {\n\t\te.printStackTrace();\n\t}", + ) + assertThat(edit.range).isEqualTo( + Range(Position(1, 0, 11), Position(2, 12, 39)), + ) + } + @Test fun `whitespace-only span returns null`() { - assertThat(computeSurroundWithTryCatchEdit("\n \n", 0, 1)).isNull() + assertThat(kotlinEdit("\n \n", 0, 1)).isNull() } @Test fun `out-of-range span returns null`() { - assertThat(computeSurroundWithTryCatchEdit("foo()", 0, 5)).isNull() - assertThat(computeSurroundWithTryCatchEdit("foo()", -1, 0)).isNull() - assertThat(computeSurroundWithTryCatchEdit("foo()", 2, 1)).isNull() + assertThat(kotlinEdit("foo()", 0, 5)).isNull() + assertThat(kotlinEdit("foo()", -1, 0)).isNull() + assertThat(kotlinEdit("foo()", 2, 1)).isNull() } @Test @@ -96,7 +140,7 @@ class SurroundWithTryCatchTest { @Test fun `CRLF file preserves carriage returns and replace indices`() { - val edit = computeSurroundWithTryCatchEdit("a()\r\nb()", 0, 1) + val edit = kotlinEdit("a()\r\nb()", 0, 1) assertThat(edit).isNotNull() assertThat(edit!!.newText).isEqualTo( "try {\r\n\ta()\r\n\tb()\r\n} catch (e: Exception) {\r\n\te.printStackTrace()\r\n}", @@ -109,7 +153,7 @@ class SurroundWithTryCatchTest { @Test fun `stray whitespace-only line does not switch a tab file to spaces`() { val text = "fun f() {\n \n\tval a = read()\n\tprocess(a)\n}" - val edit = computeSurroundWithTryCatchEdit(text, 2, 3) + val edit = kotlinEdit(text, 2, 3) assertThat(edit).isNotNull() assertThat(edit!!.newText).isEqualTo( "\ttry {\n\t\tval a = read()\n\t\tprocess(a)\n\t} catch (e: Exception) {\n\t\te.printStackTrace()\n\t}", diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/JavaCodeActionsMenu.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/JavaCodeActionsMenu.kt index cfd4fd35e5..d6bc8421e5 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/JavaCodeActionsMenu.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/JavaCodeActionsMenu.kt @@ -21,7 +21,9 @@ import com.itsaky.androidide.actions.ActionItem import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.lsp.actions.CommentLineAction import com.itsaky.androidide.lsp.actions.IActionsMenuProvider +import com.itsaky.androidide.lsp.actions.SurroundWithTryCatchAction import com.itsaky.androidide.lsp.actions.UncommentLineAction +import com.itsaky.androidide.lsp.java.JavaLanguageServer import com.itsaky.androidide.lsp.java.actions.common.FindReferencesAction import com.itsaky.androidide.lsp.java.actions.common.GoToDefinitionAction import com.itsaky.androidide.lsp.java.actions.common.OrganizeImportsAction @@ -51,6 +53,8 @@ object JavaCodeActionsMenu : IActionsMenuProvider { private const val LANG = "java" private const val EXT = "java" private const val LINE_COMMENT_TOKEN = "//" + private const val CATCH_CLAUSE = "catch (Exception e)" + private const val CATCH_BODY = "e.printStackTrace();" override val actions: List = listOf( @@ -81,5 +85,13 @@ object JavaCodeActionsMenu : IActionsMenuProvider { GenerateToStringMethodAction(), RemoveUnusedImportsAction(), OrganizeImportsAction(), + SurroundWithTryCatchAction( + LANG, + EXT, + JavaLanguageServer.SERVER_ID, + CATCH_CLAUSE, + CATCH_BODY, + TooltipTag.EDITOR_CODE_ACTIONS_TRY_CATCH, + ), ) } diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/AddImportAction.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/AddImportAction.kt index de002a09bd..00e1e31549 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/AddImportAction.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/AddImportAction.kt @@ -1,183 +1,233 @@ -/* - * 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.lsp.java.actions.diagnostics - -import com.google.common.collect.Iterables.toArray -import com.itsaky.androidide.actions.ActionData -import com.itsaky.androidide.actions.hasRequiredData -import com.itsaky.androidide.actions.markInvisible -import com.itsaky.androidide.actions.newDialogBuilder -import com.itsaky.androidide.actions.requireFile -import com.itsaky.androidide.actions.requirePath -import com.itsaky.androidide.idetooltips.TooltipTag -import com.itsaky.androidide.javac.services.util.JavaDiagnosticUtils -import com.itsaky.androidide.lsp.java.JavaCompilerProvider -import com.itsaky.androidide.lsp.java.actions.BaseJavaCodeAction -import com.itsaky.androidide.lsp.java.models.DiagnosticCode -import com.itsaky.androidide.lsp.java.rewrite.AddImport -import com.itsaky.androidide.lsp.java.rewrite.Rewrite -import com.itsaky.androidide.lsp.models.CodeActionItem -import com.itsaky.androidide.lsp.models.DiagnosticItem -import com.itsaky.androidide.projects.IProjectManager -import com.itsaky.androidide.resources.R -import jdkx.tools.Diagnostic -import jdkx.tools.JavaFileObject -import org.slf4j.LoggerFactory - -/** @author Akash Yadav */ -class AddImportAction : BaseJavaCodeAction() { - - override val id: String = "ide.editor.lsp.java.diagnostics.addImport" - override var label: String = "" - private val diagnosticCode = DiagnosticCode.NOT_IMPORTED.id - - override val titleTextRes: Int = R.string.action_import_classes - override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS - - companion object { - - private val log = LoggerFactory.getLogger(AddImportAction::class.java) - } - - override fun prepare(data: ActionData) { - super.prepare(data) - - if (!visible || !data.hasRequiredData(DiagnosticItem::class.java)) { - markInvisible() - return - } - - val diagnostic = data.get(DiagnosticItem::class.java)!! - if (diagnosticCode != diagnostic.code || diagnostic.extra !is Diagnostic<*>) { - markInvisible() - return - } - - val file = data.requireFile() - val module = - IProjectManager.getInstance().findModuleForFile(file, false) - ?: run { - markInvisible() - return - } - - val compiler = JavaCompilerProvider.get(module) - - @Suppress("UNCHECKED_CAST") - val jcDiagnostic = - JavaDiagnosticUtils.asJCDiagnostic(diagnostic.extra as Diagnostic) - if (jcDiagnostic == null) { - markInvisible() - return - } - - val found = - jcDiagnostic.args[1]?.toString()?.let { compiler.findQualifiedNames(it, true).isNotEmpty() } - ?: false - - visible = found - enabled = found - } - - override suspend fun execAction(data: ActionData): Any { - @Suppress("UNCHECKED_CAST") - val diagnostic = - JavaDiagnosticUtils.asUnwrapper( - data.get(DiagnosticItem::class.java)!!.extra as Diagnostic - )!! - val file = data.requireFile() - val module = - IProjectManager.getInstance().findModuleForFile(file, false) - ?: run { - markInvisible() - return Any() - } - - val compiler = JavaCompilerProvider.get(module) - - val titles = mutableListOf() - val rewrites = mutableListOf() - val simpleName = diagnostic.d.args[1] - for (name in compiler.publicTopLevelTypes()) { - var klass = name - if (klass.contains('/')) { - klass = klass.replace('/', '.') - } - - if (!klass.endsWith(".$simpleName")) { - continue - } - - titles.add(klass) - rewrites.add(AddImport(data.requirePath(), klass)) - } - - if (rewrites.isEmpty()) { - return false - } - - return Pair(titles, rewrites) - } - - @Suppress("UNCHECKED_CAST") - override fun postExec(data: ActionData, result: Any) { - - if (result !is Pair<*, *>) { - return - } - - val file = data.requireFile() - val module = - IProjectManager.getInstance().findModuleForFile(file, false) - ?: run { - markInvisible() - return - } - - val compiler = JavaCompilerProvider.get(module) - val client = data.getLanguageClient() ?: return - val actions = mutableListOf() - val titles = result.first as List - val rewrites = result.second as List - - for (index in rewrites.indices) { - val name = titles[index] - val rewrite = rewrites[index] - rewrite.asCodeActions(compiler, name)?.let { actions.add(it) } - } - - when (actions.size) { - 0 -> { - log.warn("No rewrites found. Cannot perform action") - } - - 1 -> { - client.performCodeAction(actions[0]) - } - - else -> { - val builder = newDialogBuilder(data) - builder.setTitle(label) - builder.setItems(toArray(titles, String::class.java)) { d, w -> - d.dismiss() - client.performCodeAction(actions[w]) - } - builder.show() - } - } - } -} +/* + * 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.lsp.java.actions.diagnostics + +import android.content.Context +import android.view.View +import android.widget.ListView +import com.google.common.collect.Iterables.toArray +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.hasRequiredData +import com.itsaky.androidide.actions.markInvisible +import com.itsaky.androidide.actions.newDialogBuilder +import com.itsaky.androidide.actions.requireContext +import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.actions.requirePath +import com.itsaky.androidide.idetooltips.TooltipManager +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.javac.services.util.JavaDiagnosticUtils +import com.itsaky.androidide.lsp.api.ILanguageClient +import com.itsaky.androidide.lsp.java.JavaCompilerProvider +import com.itsaky.androidide.lsp.java.actions.BaseJavaCodeAction +import com.itsaky.androidide.lsp.java.models.DiagnosticCode +import com.itsaky.androidide.lsp.java.rewrite.AddImport +import com.itsaky.androidide.lsp.java.rewrite.Rewrite +import com.itsaky.androidide.lsp.models.CodeActionItem +import com.itsaky.androidide.lsp.models.DiagnosticItem +import com.itsaky.androidide.projects.IProjectManager +import com.itsaky.androidide.resources.R +import com.itsaky.androidide.utils.applyLongPressRecursively +import jdkx.tools.Diagnostic +import jdkx.tools.JavaFileObject +import org.slf4j.LoggerFactory + +/** @author Akash Yadav */ +class AddImportAction : BaseJavaCodeAction() { + override val id: String = "ide.editor.lsp.java.diagnostics.addImport" + override var label: String = "" + private val diagnosticCode = DiagnosticCode.NOT_IMPORTED.id + + override val titleTextRes: Int = R.string.action_import_classes + override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS + + companion object { + private val log = LoggerFactory.getLogger(AddImportAction::class.java) + } + + override fun prepare(data: ActionData) { + super.prepare(data) + + if (!visible || !data.hasRequiredData(DiagnosticItem::class.java)) { + markInvisible() + return + } + + val diagnostic = data.get(DiagnosticItem::class.java)!! + if (diagnosticCode != diagnostic.code || diagnostic.extra !is Diagnostic<*>) { + markInvisible() + return + } + + val file = data.requireFile() + val module = + IProjectManager.getInstance().findModuleForFile(file, false) + ?: run { + markInvisible() + return + } + + val compiler = JavaCompilerProvider.get(module) + + @Suppress("UNCHECKED_CAST") + val jcDiagnostic = + JavaDiagnosticUtils.asJCDiagnostic(diagnostic.extra as Diagnostic) + if (jcDiagnostic == null) { + markInvisible() + return + } + + val found = + jcDiagnostic.args[1]?.toString()?.let { compiler.findQualifiedNames(it, true).isNotEmpty() } + ?: false + + visible = found + enabled = found + } + + override suspend fun execAction(data: ActionData): Any { + @Suppress("UNCHECKED_CAST") + val diagnostic = + JavaDiagnosticUtils.asUnwrapper( + data.get(DiagnosticItem::class.java)!!.extra as Diagnostic, + )!! + val file = data.requireFile() + val module = + IProjectManager.getInstance().findModuleForFile(file, false) + ?: run { + markInvisible() + return Any() + } + + val compiler = JavaCompilerProvider.get(module) + + val titles = mutableListOf() + val rewrites = mutableListOf() + val simpleName = diagnostic.d.args[1] + for (name in compiler.publicTopLevelTypes()) { + var klass = name + if (klass.contains('/')) { + klass = klass.replace('/', '.') + } + + if (!klass.endsWith(".$simpleName")) { + continue + } + + titles.add(klass) + rewrites.add(AddImport(data.requirePath(), klass)) + } + + if (rewrites.isEmpty()) { + return false + } + + return Pair(titles, rewrites) + } + + @Suppress("UNCHECKED_CAST") + override fun postExec( + data: ActionData, + result: Any, + ) { + if (result !is Pair<*, *>) { + return + } + + val file = data.requireFile() + val module = + IProjectManager.getInstance().findModuleForFile(file, false) + ?: run { + markInvisible() + return + } + + val compiler = JavaCompilerProvider.get(module) + val client = data.getLanguageClient() ?: return + val actions = mutableListOf() + val titles = result.first as List + val rewrites = result.second as List + + for (index in rewrites.indices) { + val name = titles[index] + val rewrite = rewrites[index] + rewrite.asCodeActions(compiler, name)?.let { actions.add(it) } + } + + when (actions.size) { + 0 -> { + log.warn("No rewrites found. Cannot perform action") + } + + 1 -> { + client.performCodeAction(actions[0]) + } + + else -> { + showImportChooser(data, titles, actions, client) + } + } + } + + /** + * Shows the import chooser and makes every part of it long-pressable for help. + * + * [applyLongPressRecursively] skips [ListView] subtrees, so the item list needs its own + * listener -- the dialog chrome and the rows are wired separately (ADFA-4510). + */ + private fun showImportChooser( + data: ActionData, + titles: List, + actions: List, + client: ILanguageClient, + ) { + val context = data.requireContext() + val builder = newDialogBuilder(data) + builder.setTitle(label) + builder.setItems(toArray(titles, String::class.java)) { d, w -> + d.dismiss() + client.performCodeAction(actions[w]) + } + + val dialog = builder.create() + + dialog.listView?.setOnItemLongClickListener { _, view, _, _ -> + showDialogTooltip(context, view) + true + } + + dialog.setOnShowListener { + val root = dialog.window?.decorView ?: return@setOnShowListener + root.applyLongPressRecursively { + showDialogTooltip(context, root) + true + } + } + + dialog.show() + } + + private fun showDialogTooltip( + context: Context, + anchor: View, + ) { + TooltipManager.showIdeCategoryTooltip( + context, + anchor, + TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS_DIALOG, + ) + } +} diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/AutoFixImportsAction.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/AutoFixImportsAction.kt index b00001c582..e89567953e 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/AutoFixImportsAction.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/AutoFixImportsAction.kt @@ -17,9 +17,13 @@ package com.itsaky.androidide.lsp.java.actions.diagnostics +import android.content.Context +import android.view.View +import android.widget.ListView import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.actions.requireContext import com.itsaky.androidide.actions.requirePath +import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.lsp.java.R import com.itsaky.androidide.lsp.java.actions.BaseJavaCodeAction @@ -32,6 +36,7 @@ import com.itsaky.androidide.lsp.models.DocumentChange import com.itsaky.androidide.lsp.models.TextEdit import com.itsaky.androidide.models.Range import com.itsaky.androidide.utils.DialogUtils +import com.itsaky.androidide.utils.applyLongPressRecursively import com.itsaky.androidide.utils.flashInfo import org.slf4j.LoggerFactory import java.nio.file.Path @@ -42,165 +47,221 @@ import java.nio.file.Path * @author Akash Yadav */ class AutoFixImportsAction : BaseJavaCodeAction() { + override val titleTextRes: Int = R.string.title_fix_imports + override val id: String = "ide.editor.lsp.java.diagnostics.autoFixImports" + override var label: String = "" + override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS - override val titleTextRes: Int = R.string.title_fix_imports - override val id: String = "ide.editor.lsp.java.diagnostics.autoFixImports" - override var label: String = "" - override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS - - companion object { - - private val log = LoggerFactory.getLogger(AutoFixImportsAction::class.java) - } - - override suspend fun execAction(data: ActionData): Result { - val path = data.requirePath() - val compiler = data.requireCompiler() - return compiler.compile(path).get { task -> - val classes = mutableMapOf>() - - // find all unresolved simple names - unresolvedNames(path, task).forEach { simpleName -> - - // if we have already looked for this simple name - // we do not need to look it up again - if (classes[simpleName] != null) return@forEach - - // find classes with those names - compiler.findQualifiedNames(simpleName).let { names -> - - // if we find classes with that specific simple name, map them to the simple name - if (names.isNotEmpty()) { - classes[simpleName] = names - } - } - } - - // return the result - Result(getFileImports(task, path), classes) - } - } - - override fun postExec(data: ActionData, result: Any) { - if (result !is Result) { - log.error("Invalid result returned from execAction: {}", result) - return - } - - if (result.classes.isEmpty()) { - flashInfo(R.string.msg_no_unresolved_classes) - return - } - - // if there are multiple classes with same simple name - // ask the user to choose the appropriate class - if (result.classes.any { it.value.size > 1 }) { - finalizeClassNames(data, result) - } else { - performEdits(data, result) - } - } - - private fun finalizeClassNames(data: ActionData, result: Result) { - var e: Map.Entry>? = null - for (entry in result.classes) { - if (entry.value.size > 1) { - e = entry - break - } - } - - if (e == null) { - performEdits(data, result) - return - } - - val context = data.requireContext() - DialogUtils.newMaterialDialogBuilder(context) - .setCancelable(true) - .setItems(e.value.toTypedArray()) { dialog, which -> - dialog.dismiss() - result.classes[e.key] = listOf(e.value[which]) - - // once the user decides which class to import for this simple name, - // call this method again to see if there any other simple names with multiple options - finalizeClassNames(data, result) - } - .setTitle(context.getString(R.string.title_class_chooser, e.key)) - .show() - } - - private fun performEdits(data: ActionData, result: Result) { - val path = data.requirePath() - val compiler = data.requireCompiler() - val client = - data.getLanguageClient() - ?: run { - log.warn("No language client found. Cannot perform edits.") - return - } - - val classes = result.classes.mapNotNull { it.value.firstOrNull() } - - if (classes.isEmpty()) { - flashInfo(R.string.msg_no_unresolved_classes) - return - } - - val insertText = StringBuilder() - if (result.fileImports.isEmpty() && classes.isNotEmpty()) { - // if there are no file imports, the new imports will be added just after the package - // declaration. To avoid this, add a new line before the imports - insertText.append("\n") - } - - for (klass in classes) { - insertText.append("import ${klass};\n") - } - - val position = compiler.compile(path).get { positionForImports(classes[0], it) } - - val change = DocumentChange() - change.file = path - change.edits = listOf(TextEdit(Range.pointRange(position), insertText.toString())) - - val action = CodeActionItem() - action.title = data.requireContext().getString(R.string.title_fix_imports) - action.kind = CodeActionKind.QuickFix - action.changes = listOf(change) - client.performCodeAction(action) - } - - /** - * Walks through the diagnostics of the compilation task, looks for [DiagnosticCode.NOT_IMPORTED] - * errors and returns a list of simple names of all not imported classes. - */ - private fun unresolvedNames(file: Path, task: CompileTask): List { - val names = mutableListOf() - var docContents: CharSequence? = null - val diagnostics = - task.diagnostics.filter { - it.source.toUri() == file.toUri() && it.code == DiagnosticCode.NOT_IMPORTED.id - } - for (diagnostic in diagnostics) { - val content = - try { - docContents ?: diagnostic.source.getCharContent(true).also { docContents = it } - } catch (e: Exception) { - log.error("Failed to get contents of file {}", file, e) - continue - } - - val name = - content.subSequence(diagnostic.startPosition.toInt(), diagnostic.endPosition.toInt()) - names.add(name.toString()) - } - return names - } - - private fun getFileImports(task: CompileTask, file: Path): Set { - return task.root(file).imports.map { it.qualifiedIdentifier }.map { it.toString() }.toSet() - } - - inner class Result(val fileImports: Set, val classes: MutableMap>) + companion object { + private val log = LoggerFactory.getLogger(AutoFixImportsAction::class.java) + } + + override suspend fun execAction(data: ActionData): Result { + val path = data.requirePath() + val compiler = data.requireCompiler() + return compiler.compile(path).get { task -> + val classes = mutableMapOf>() + + // find all unresolved simple names + unresolvedNames(path, task).forEach { simpleName -> + + // if we have already looked for this simple name + // we do not need to look it up again + if (classes[simpleName] != null) return@forEach + + // find classes with those names + compiler.findQualifiedNames(simpleName).let { names -> + + // if we find classes with that specific simple name, map them to the simple name + if (names.isNotEmpty()) { + classes[simpleName] = names + } + } + } + + // return the result + Result(getFileImports(task, path), classes) + } + } + + override fun postExec( + data: ActionData, + result: Any, + ) { + if (result !is Result) { + log.error("Invalid result returned from execAction: {}", result) + return + } + + if (result.classes.isEmpty()) { + flashInfo(R.string.msg_no_unresolved_classes) + return + } + + // if there are multiple classes with same simple name + // ask the user to choose the appropriate class + if (result.classes.any { it.value.size > 1 }) { + finalizeClassNames(data, result) + } else { + performEdits(data, result) + } + } + + private fun finalizeClassNames( + data: ActionData, + result: Result, + ) { + var e: Map.Entry>? = null + for (entry in result.classes) { + if (entry.value.size > 1) { + e = entry + break + } + } + + if (e == null) { + performEdits(data, result) + return + } + + val context = data.requireContext() + val entry = e + val dialog = + DialogUtils + .newMaterialDialogBuilder(context) + .setCancelable(true) + .setItems(entry.value.toTypedArray()) { dialog, which -> + dialog.dismiss() + result.classes[entry.key] = listOf(entry.value[which]) + + // once the user decides which class to import for this simple name, + // call this method again to see if there any other simple names with multiple options + finalizeClassNames(data, result) + }.setTitle(context.getString(R.string.title_class_chooser, entry.key)) + .create() + + dialog.listView?.setOnItemLongClickListener { _, view, _, _ -> + showDialogTooltip(context, view) + true + } + + dialog.setOnShowListener { + val root = dialog.window?.decorView ?: return@setOnShowListener + root.applyLongPressRecursively { + showDialogTooltip(context, root) + true + } + } + + dialog.show() + } + + /** + * [applyLongPressRecursively] skips [ListView] subtrees, so the item list needs its own listener + * -- the dialog chrome and the rows are wired separately (ADFA-4510). + * + * Shares [TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS_DIALOG] with AddImportAction's chooser: same + * question asked of the user, same answer, and the two actions already share an action tag. + */ + private fun showDialogTooltip( + context: Context, + anchor: View, + ) { + TooltipManager.showIdeCategoryTooltip( + context, + anchor, + TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS_DIALOG, + ) + } + + private fun performEdits( + data: ActionData, + result: Result, + ) { + val path = data.requirePath() + val compiler = data.requireCompiler() + val client = + data.getLanguageClient() + ?: run { + log.warn("No language client found. Cannot perform edits.") + return + } + + val classes = result.classes.mapNotNull { it.value.firstOrNull() } + + if (classes.isEmpty()) { + flashInfo(R.string.msg_no_unresolved_classes) + return + } + + val insertText = StringBuilder() + if (result.fileImports.isEmpty() && classes.isNotEmpty()) { + // if there are no file imports, the new imports will be added just after the package + // declaration. To avoid this, add a new line before the imports + insertText.append("\n") + } + + for (klass in classes) { + insertText.append("import $klass;\n") + } + + val position = compiler.compile(path).get { positionForImports(classes[0], it) } + + val change = DocumentChange() + change.file = path + change.edits = listOf(TextEdit(Range.pointRange(position), insertText.toString())) + + val action = CodeActionItem() + action.title = data.requireContext().getString(R.string.title_fix_imports) + action.kind = CodeActionKind.QuickFix + action.changes = listOf(change) + client.performCodeAction(action) + } + + /** + * Walks through the diagnostics of the compilation task, looks for [DiagnosticCode.NOT_IMPORTED] + * errors and returns a list of simple names of all not imported classes. + */ + private fun unresolvedNames( + file: Path, + task: CompileTask, + ): List { + val names = mutableListOf() + var docContents: CharSequence? = null + val diagnostics = + task.diagnostics.filter { + it.source.toUri() == file.toUri() && it.code == DiagnosticCode.NOT_IMPORTED.id + } + for (diagnostic in diagnostics) { + val content = + try { + docContents ?: diagnostic.source.getCharContent(true).also { docContents = it } + } catch (e: Exception) { + log.error("Failed to get contents of file {}", file, e) + continue + } + + val name = + content.subSequence(diagnostic.startPosition.toInt(), diagnostic.endPosition.toInt()) + names.add(name.toString()) + } + return names + } + + private fun getFileImports( + task: CompileTask, + file: Path, + ): Set = + task + .root(file) + .imports + .map { + it.qualifiedIdentifier + }.map { it.toString() } + .toSet() + + inner class Result( + val fileImports: Set, + val classes: MutableMap>, + ) } diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/FieldToBlockAction.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/FieldToBlockAction.kt index 62bf0b0f33..b9588f52d5 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/FieldToBlockAction.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/FieldToBlockAction.kt @@ -1,89 +1,87 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ -package com.itsaky.androidide.lsp.java.actions.diagnostics - -import com.itsaky.androidide.actions.ActionData -import com.itsaky.androidide.actions.hasRequiredData -import com.itsaky.androidide.actions.markInvisible -import com.itsaky.androidide.actions.requireFile -import com.itsaky.androidide.actions.requirePath -import com.itsaky.androidide.idetooltips.TooltipTag -import com.itsaky.androidide.lsp.java.JavaCompilerProvider -import com.itsaky.androidide.lsp.java.actions.BaseJavaCodeAction -import com.itsaky.androidide.lsp.java.models.DiagnosticCode -import com.itsaky.androidide.lsp.java.rewrite.ConvertFieldToBlock -import com.itsaky.androidide.lsp.java.utils.CodeActionUtils.findPosition -import com.itsaky.androidide.lsp.models.DiagnosticItem -import com.itsaky.androidide.projects.IProjectManager -import com.itsaky.androidide.resources.R -import org.slf4j.LoggerFactory - -/** @author Akash Yadav */ -class FieldToBlockAction : BaseJavaCodeAction() { - - override val id: String = "ide.editor.lsp.java.diagnostics.fieldToBlock" - override var label: String = "" - private val diagnosticCode = DiagnosticCode.UNUSED_FIELD.id - override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS - - override val titleTextRes: Int = R.string.action_convert_to_block - - companion object { - - private val log = LoggerFactory.getLogger(FieldToBlockAction::class.java) - } - - override fun prepare(data: ActionData) { - super.prepare(data) - - if (!visible) { - return - } - - if (!data.hasRequiredData(DiagnosticItem::class.java)) { - markInvisible() - return - } - - val diagnostic = data.get(DiagnosticItem::class.java)!! - if (diagnosticCode != diagnostic.code) { - markInvisible() - return - } - } - - override suspend fun execAction(data: ActionData): Any { - val compiler = - JavaCompilerProvider.get( - IProjectManager.getInstance().findModuleForFile(data.requireFile(), false) ?: return Any()) - val diagnostic = data[DiagnosticItem::class.java]!! - val file = data.requirePath() - - return compiler.compile(file).get { - ConvertFieldToBlock(file, findPosition(it, diagnostic.range.start)) - } - } - - override fun postExec(data: ActionData, result: Any) { - if (result !is ConvertFieldToBlock) { - log.warn("Unable to convert field to block") - return - } - - performCodeAction(data, result) - } -} +/* + * 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.lsp.java.actions.diagnostics + +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.hasRequiredData +import com.itsaky.androidide.actions.markInvisible +import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.actions.requirePath +import com.itsaky.androidide.lsp.java.JavaCompilerProvider +import com.itsaky.androidide.lsp.java.actions.BaseJavaCodeAction +import com.itsaky.androidide.lsp.java.models.DiagnosticCode +import com.itsaky.androidide.lsp.java.rewrite.ConvertFieldToBlock +import com.itsaky.androidide.lsp.java.utils.CodeActionUtils.findPosition +import com.itsaky.androidide.lsp.models.DiagnosticItem +import com.itsaky.androidide.projects.IProjectManager +import com.itsaky.androidide.resources.R +import org.slf4j.LoggerFactory + +/** @author Akash Yadav */ +class FieldToBlockAction : BaseJavaCodeAction() { + override val id: String = "ide.editor.lsp.java.diagnostics.fieldToBlock" + override var label: String = "" + private val diagnosticCode = DiagnosticCode.UNUSED_FIELD.id + + override val titleTextRes: Int = R.string.action_convert_to_block + + companion object { + private val log = LoggerFactory.getLogger(FieldToBlockAction::class.java) + } + + override fun prepare(data: ActionData) { + super.prepare(data) + + if (!visible) { + return + } + + if (!data.hasRequiredData(DiagnosticItem::class.java)) { + markInvisible() + return + } + + val diagnostic = data.get(DiagnosticItem::class.java)!! + if (diagnosticCode != diagnostic.code) { + markInvisible() + return + } + } + + override suspend fun execAction(data: ActionData): Any { + val compiler = + JavaCompilerProvider.get(IProjectManager.getInstance().findModuleForFile(data.requireFile(), false) ?: return Any()) + val diagnostic = data[DiagnosticItem::class.java]!! + val file = data.requirePath() + + return compiler.compile(file).get { + ConvertFieldToBlock(file, findPosition(it, diagnostic.range.start)) + } + } + + override fun postExec( + data: ActionData, + result: Any, + ) { + if (result !is ConvertFieldToBlock) { + log.warn("Unable to convert field to block") + return + } + + performCodeAction(data, result) + } +} diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/VariableToStatementAction.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/VariableToStatementAction.kt index f15b38cac6..d8288c81da 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/VariableToStatementAction.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/VariableToStatementAction.kt @@ -1,91 +1,89 @@ -/* - * 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.lsp.java.actions.diagnostics - -import com.itsaky.androidide.actions.ActionData -import com.itsaky.androidide.actions.hasRequiredData -import com.itsaky.androidide.actions.markInvisible -import com.itsaky.androidide.actions.requireFile -import com.itsaky.androidide.actions.requirePath -import com.itsaky.androidide.idetooltips.TooltipTag -import com.itsaky.androidide.lsp.java.JavaCompilerProvider -import com.itsaky.androidide.lsp.java.actions.BaseJavaCodeAction -import com.itsaky.androidide.lsp.java.models.DiagnosticCode -import com.itsaky.androidide.lsp.java.rewrite.ConvertVariableToStatement -import com.itsaky.androidide.lsp.java.utils.CodeActionUtils.findPosition -import com.itsaky.androidide.projects.IProjectManager -import com.itsaky.androidide.resources.R -import org.slf4j.LoggerFactory - -/** @author Akash Yadav */ -class VariableToStatementAction : BaseJavaCodeAction() { - - override val id: String = "ide.editor.lsp.java.diagnostics.variableToStatement" - override var label: String = "" - private val diagnosticCode = DiagnosticCode.UNUSED_LOCAL.id - - override val titleTextRes: Int = R.string.action_convert_to_statement - override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS - - companion object { - - private val log = LoggerFactory.getLogger(VariableToStatementAction::class.java) - } - - override fun prepare(data: ActionData) { - super.prepare(data) - - if (!visible) { - return - } - - if (!data.hasRequiredData(com.itsaky.androidide.lsp.models.DiagnosticItem::class.java)) { - markInvisible() - return - } - - val diagnostic = data.get(com.itsaky.androidide.lsp.models.DiagnosticItem::class.java)!! - if (diagnosticCode != diagnostic.code) { - markInvisible() - return - } - - visible = true - enabled = true - } - - override suspend fun execAction(data: ActionData): Any { - val diagnostic = data[com.itsaky.androidide.lsp.models.DiagnosticItem::class.java]!! - val compiler = - JavaCompilerProvider.get( - IProjectManager.getInstance().findModuleForFile(data.requireFile(), false) ?: return Any()) - val path = data.requirePath() - - return compiler.compile(path).get { - ConvertVariableToStatement(path, findPosition(it, diagnostic.range.start)) - } - } - - override fun postExec(data: ActionData, result: Any) { - if (result !is ConvertVariableToStatement) { - log.warn("Unable to convert variable to statement") - return - } - - performCodeAction(data, result) - } -} +/* + * 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.lsp.java.actions.diagnostics + +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.hasRequiredData +import com.itsaky.androidide.actions.markInvisible +import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.actions.requirePath +import com.itsaky.androidide.lsp.java.JavaCompilerProvider +import com.itsaky.androidide.lsp.java.actions.BaseJavaCodeAction +import com.itsaky.androidide.lsp.java.models.DiagnosticCode +import com.itsaky.androidide.lsp.java.rewrite.ConvertVariableToStatement +import com.itsaky.androidide.lsp.java.utils.CodeActionUtils.findPosition +import com.itsaky.androidide.projects.IProjectManager +import com.itsaky.androidide.resources.R +import org.slf4j.LoggerFactory + +/** @author Akash Yadav */ +class VariableToStatementAction : BaseJavaCodeAction() { + override val id: String = "ide.editor.lsp.java.diagnostics.variableToStatement" + override var label: String = "" + private val diagnosticCode = DiagnosticCode.UNUSED_LOCAL.id + + override val titleTextRes: Int = R.string.action_convert_to_statement + + companion object { + private val log = LoggerFactory.getLogger(VariableToStatementAction::class.java) + } + + override fun prepare(data: ActionData) { + super.prepare(data) + + if (!visible) { + return + } + + if (!data.hasRequiredData(com.itsaky.androidide.lsp.models.DiagnosticItem::class.java)) { + markInvisible() + return + } + + val diagnostic = data.get(com.itsaky.androidide.lsp.models.DiagnosticItem::class.java)!! + if (diagnosticCode != diagnostic.code) { + markInvisible() + return + } + + visible = true + enabled = true + } + + override suspend fun execAction(data: ActionData): Any { + val diagnostic = data[com.itsaky.androidide.lsp.models.DiagnosticItem::class.java]!! + val compiler = + JavaCompilerProvider.get(IProjectManager.getInstance().findModuleForFile(data.requireFile(), false) ?: return Any()) + val path = data.requirePath() + + return compiler.compile(path).get { + ConvertVariableToStatement(path, findPosition(it, diagnostic.range.start)) + } + } + + override fun postExec( + data: ActionData, + result: Any, + ) { + if (result !is ConvertVariableToStatement) { + log.warn("Unable to convert variable to statement") + return + } + + performCodeAction(data, result) + } +} diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/OverrideSuperclassMethodsAction.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/OverrideSuperclassMethodsAction.kt index f1f71dfe6e..3229a89f09 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/OverrideSuperclassMethodsAction.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/OverrideSuperclassMethodsAction.kt @@ -210,7 +210,7 @@ class OverrideSuperclassMethodsAction : BaseJavaCodeAction() { val listView = dialog.listView listView.setOnItemLongClickListener { _, view, position, _ -> - showTooltip(context, view, tooltipTag) + showTooltip(context, view, TooltipTag.EDITOR_CODE_ACTIONS_OVERRIDE_SUPER_DIALOG) true } @@ -218,7 +218,7 @@ class OverrideSuperclassMethodsAction : BaseJavaCodeAction() { val root = dialog.window?.decorView ?: return@setOnShowListener root.applyLongPressRecursively { - showTooltip(context, root, tooltipTag) + showTooltip(context, root, TooltipTag.EDITOR_CODE_ACTIONS_OVERRIDE_SUPER_DIALOG) true } } diff --git a/lsp/java/src/test/java/com/itsaky/androidide/lsp/java/actions/JavaCodeActionTooltipTagTest.kt b/lsp/java/src/test/java/com/itsaky/androidide/lsp/java/actions/JavaCodeActionTooltipTagTest.kt new file mode 100644 index 0000000000..f8d2cb363f --- /dev/null +++ b/lsp/java/src/test/java/com/itsaky/androidide/lsp/java/actions/JavaCodeActionTooltipTagTest.kt @@ -0,0 +1,92 @@ +/* + * 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.lsp.java.actions + +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import com.itsaky.androidide.idetooltips.TooltipTag +import org.junit.Test + +/** + * Pins each Java code action to its tooltip tag. Tooltip content is authored per tag and looked up + * by that tag, so a wrong tag fails silently at runtime: the action shows another action's tooltip + * or none at all (ADFA-4510). + * + * Tags are read through retrieveTooltipTag(), the member the code-actions renderer calls. The + * Kotlin equivalent asserts on the tooltipTag property instead, which is why it kept passing while + * ADFA-4510 was live. + * + * Actions pinned to "" carry no tag at all. A pinned tag means the tag is wired, not that + * documentation.db holds content for it -- see surroundWithTryCatch below. Either way, a change + * here must be a deliberate edit, not silent drift. + */ +class JavaCodeActionTooltipTagTest { + private val actualTags + get() = JavaCodeActionsMenu.actions.associate { it.id to it.retrieveTooltipTag(false) } + + @Test + fun `every java code action maps to its own tooltip tag`() { + val expected = + mapOf( + "ide.editor.lsp.java.commentLine" to TooltipTag.EDITOR_CODE_ACTIONS_COMMENT, + "ide.editor.lsp.java.uncommentLine" to TooltipTag.EDITOR_CODE_ACTIONS_UNCOMMENT, + "ide.editor.lsp.java.gotoDefinition" to TooltipTag.EDITOR_CODE_ACTIONS_GOTO_DEF, + "ide.editor.lsp.java.findReferences" to TooltipTag.EDITOR_CODE_ACTIONS_FIND_REFS, + "ide.editor.lsp.java.diagnostics.addImport" to TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS, + "ide.editor.lsp.java.diagnostics.autoFixImports" to TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS, + "ide.editor.lsp.java.diagnostics.implementAbstractMethods" to + TooltipTag.EDITOR_CODE_ACTIONS_OVERRIDE_SUPER, + "ide.editor.lsp.java.generator.settersAndGetters" to + TooltipTag.EDITOR_CODE_ACTIONS_SETTER_GETTER, + "ide.editor.lsp.java.generator.overrideSuperclassMethods" to + TooltipTag.EDITOR_CODE_ACTIONS_OVERRIDE_SUPER, + "ide.editor.lsp.java.generator.missingConstructor" to + TooltipTag.EDITOR_CODE_ACTIONS_GEN_CONSTRUCTOR, + "ide.editor.lsp.java.generator.constructor" to + TooltipTag.EDITOR_CODE_ACTIONS_GEN_CONSTRUCTOR, + "ide.editor.lsp.java.generator.toString" to TooltipTag.EDITOR_CODE_ACTIONS_GEN_TO_STRING, + "ide.editor.lsp.java.removeUnusedImports" to + TooltipTag.EDITOR_CODE_ACTIONS_UNUSED_IMPORTS, + "lsp_java_organizeImports" to TooltipTag.EDITOR_CODE_ACTIONS_ORGANIZE_IMPORTS, + // Tag is reserved ahead of content: documentation.db has no + // editor.codeactions.trycatch row, so long-press renders the documentation + // fallback. The Kotlin twin editor.codeactions.kotlin.trycatch is authored. + "ide.editor.lsp.java.surroundWithTryCatch" to TooltipTag.EDITOR_CODE_ACTIONS_TRY_CATCH, + // No tag pinned. + "ide.editor.lsp.java.diagnostics.variableToStatement" to "", + "ide.editor.lsp.java.diagnostics.fieldToBlock" to "", + "ide.editor.lsp.java.diagnostics.removeClass" to "", + "ide.editor.lsp.java.diagnostics.removeMethod" to "", + "ide.editor.lsp.java.diagnostics.removeUnusedThrows" to "", + "ide.editor.lsp.java.diagnostics.createMissingMethod" to "", + "ide.editor.lsp.java.diagnostics.suppressUncheckedWarning" to "", + "ide.editor.lsp.java.diagnostics.addThrows" to "", + ) + assertThat(actualTags).containsExactlyEntriesIn(expected) + } + + /** Guards a Java action drifting onto a Kotlin tag or some unrelated namespace. */ + @Test + fun `no java code action borrows a non java code action tag`() { + actualTags.forEach { (id, tag) -> + if (tag.isEmpty()) return@forEach + assertWithMessage("$id uses tag '$tag' outside the java code actions namespace") + .that(tag.startsWith("editor.codeactions.") && !tag.startsWith("editor.codeactions.kotlin.")) + .isTrue() + } + } +} diff --git a/lsp/kotlin/build.gradle.kts b/lsp/kotlin/build.gradle.kts index 9b16f87796..d25dd4a40a 100644 --- a/lsp/kotlin/build.gradle.kts +++ b/lsp/kotlin/build.gradle.kts @@ -21,11 +21,18 @@ plugins { id("com.android.library") id("kotlin-android") id("kotlin-kapt") + alias(libs.plugins.kotlin.compose) } android { namespace = "${BuildConfig.PACKAGE_NAME}.lsp.kotlin" + // The refactoring bottom sheets are Compose (ADR 0009); they live here rather than in a UI + // module because `editor` depends on this module, not the reverse (ADR 0012). + buildFeatures { + compose = true + } + kotlin.compilerOptions { freeCompilerArgs.addAll("-Xcontext-parameters") } @@ -51,6 +58,22 @@ dependencies { implementation(projects.subprojects.projects) implementation(projects.subprojects.projectModels) + implementation(projects.commonCompose) + + implementation(platform(libs.compose.bom)) + implementation(libs.compose.runtime) + implementation(libs.compose.ui) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + implementation(libs.compose.ui.tooling.preview) + debugImplementation(libs.compose.ui.tooling) + + implementation(libs.androidx.fragment.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.google.material) + implementation(libs.common.jsonrpc) implementation(libs.common.kotlin) implementation(libs.common.kotlin.coroutines.core) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt index 7530a6fe9b..fbfd5028c3 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt @@ -4,18 +4,23 @@ import com.itsaky.androidide.actions.ActionItem import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.lsp.actions.CommentLineAction import com.itsaky.androidide.lsp.actions.IActionsMenuProvider +import com.itsaky.androidide.lsp.actions.SurroundWithTryCatchAction import com.itsaky.androidide.lsp.actions.UncommentLineAction import com.itsaky.androidide.lsp.kotlin.actions.AddImportAction +import com.itsaky.androidide.lsp.kotlin.actions.ExtractMethodAction +import com.itsaky.androidide.lsp.kotlin.actions.ExtractVariableAction +import com.itsaky.androidide.lsp.kotlin.actions.FindReferencesAction import com.itsaky.androidide.lsp.kotlin.actions.GoToDefinitionAction import com.itsaky.androidide.lsp.kotlin.actions.ImplementMembersAction import com.itsaky.androidide.lsp.kotlin.actions.NullSafetyAction import com.itsaky.androidide.lsp.kotlin.actions.OrganizeImportsAction -import com.itsaky.androidide.lsp.kotlin.actions.SurroundWithTryCatchAction object KotlinCodeActionsMenu : IActionsMenuProvider { internal const val KT_LANG = "kt" private val KT_EXTS = listOf("kt", "kts") private const val KT_LINE_COMMENT_TOKEN = "//" + private const val KT_CATCH_CLAUSE = "catch (e: Exception)" + private const val KT_CATCH_BODY = "e.printStackTrace()" override val actions: List = listOf( @@ -32,10 +37,20 @@ object KotlinCodeActionsMenu : IActionsMenuProvider { TooltipTag.EDITOR_CODE_ACTIONS_KT_UNCOMMENT, ), GoToDefinitionAction(), + FindReferencesAction(), AddImportAction(), OrganizeImportsAction(), - SurroundWithTryCatchAction(), + SurroundWithTryCatchAction( + KT_LANG, + KT_EXTS, + KotlinLanguageServer.SERVER_ID, + KT_CATCH_CLAUSE, + KT_CATCH_BODY, + TooltipTag.EDITOR_CODE_ACTIONS_KT_SURROUND_TRY_CATCH, + ), NullSafetyAction(), ImplementMembersAction(), + ExtractVariableAction(), + ExtractMethodAction(), ) } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt index ae9f0d903c..958928e292 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt @@ -38,6 +38,7 @@ import com.itsaky.androidide.lsp.kotlin.compiler.index.KT_SOURCE_FILE_META_INDEX import com.itsaky.androidide.lsp.kotlin.completion.codeComplete import com.itsaky.androidide.lsp.kotlin.diagnostic.collectDiagnosticsFor import com.itsaky.androidide.lsp.kotlin.navigation.findDefinitionAt +import com.itsaky.androidide.lsp.kotlin.navigation.findUsagesAt import com.itsaky.androidide.lsp.kotlin.signaturehelp.doSignatureHelp import com.itsaky.androidide.lsp.models.CompletionParams import com.itsaky.androidide.lsp.models.CompletionResult @@ -233,7 +234,11 @@ class KotlinLanguageServer : ILanguageServer { return ReferenceResult.empty() } - return ReferenceResult.empty() + logger.debug("findReferences(position={}, file={})", params.position, params.file) + return compiler + ?.compilationEnvironmentFor(params.file) + ?.let { context(it) { findUsagesAt(params) } } + ?: ReferenceResult.empty() } override suspend fun findDefinition(params: DefinitionParams): DefinitionResult { diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/AddImportAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/AddImportAction.kt index ca820ac7ac..33ed711df8 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/AddImportAction.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/AddImportAction.kt @@ -1,11 +1,16 @@ package com.itsaky.androidide.lsp.kotlin.actions +import android.content.Context +import android.view.View +import android.widget.ListView import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.actions.has import com.itsaky.androidide.actions.markInvisible import com.itsaky.androidide.actions.newDialogBuilder import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.lsp.api.ILanguageClient import com.itsaky.androidide.lsp.kotlin.compiler.AbstractCompilationEnvironment import com.itsaky.androidide.lsp.kotlin.compiler.index.findSymbolBySimpleName import com.itsaky.androidide.lsp.kotlin.diagnostic.DiagnosticAction @@ -17,6 +22,7 @@ import com.itsaky.androidide.lsp.models.DiagnosticItem import com.itsaky.androidide.lsp.models.DocumentChange import com.itsaky.androidide.lsp.models.TextEdit import com.itsaky.androidide.resources.R +import com.itsaky.androidide.utils.applyLongPressRecursively import com.itsaky.androidide.utils.flashError import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -138,16 +144,58 @@ class AddImportAction : BaseKotlinCodeAction() { } else -> { - newDialogBuilder(data) - .setTitle(label) - .setItems(actions.map { it.title }.toTypedArray()) { dialog, which -> - dialog.dismiss() - actions.getOrNull(which)?.also { client.performCodeAction(it) } - ?: run { - logger.error("Index $which is out of bounds for actions of size ${actions.size}") - } - }.show() + showImportChooser(data, actions, client) } } } + + /** + * Shows the import chooser and makes every part of it long-pressable for help. + * + * [applyLongPressRecursively] skips [ListView] subtrees, so the item list needs its own + * listener -- the dialog chrome and the rows are wired separately (ADFA-4510). + */ + private fun showImportChooser( + data: ActionData, + actions: List, + client: ILanguageClient, + ) { + val context = data[Context::class.java] ?: return + val dialog = + newDialogBuilder(data) + .setTitle(label) + .setItems(actions.map { it.title }.toTypedArray()) { dialog, which -> + dialog.dismiss() + actions.getOrNull(which)?.also { client.performCodeAction(it) } + ?: run { + logger.error("Index $which is out of bounds for actions of size ${actions.size}") + } + }.create() + + dialog.listView?.setOnItemLongClickListener { _, view, _, _ -> + showDialogTooltip(context, view) + true + } + + dialog.setOnShowListener { + val root = dialog.window?.decorView ?: return@setOnShowListener + root.applyLongPressRecursively { + showDialogTooltip(context, root) + true + } + } + + dialog.show() + } + + private fun showDialogTooltip( + context: Context, + anchor: View, + ) { + TooltipManager.showIdeCategoryTooltip( + context, + anchor, + TooltipTag.EDITOR_CODE_ACTIONS_KT_IMPORT_CLASS_DIALOG, + ) + } } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ExtractMethodAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ExtractMethodAction.kt new file mode 100644 index 0000000000..64e9984774 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ExtractMethodAction.kt @@ -0,0 +1,239 @@ +package com.itsaky.androidide.lsp.kotlin.actions + +import android.content.Context +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.get +import com.itsaky.androidide.actions.requireContext +import com.itsaky.androidide.actions.requireEditor +import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.lsp.kotlin.KotlinLanguageServer +import com.itsaky.androidide.lsp.kotlin.compiler.modules.ScheduledCancelChecker +import com.itsaky.androidide.lsp.kotlin.refactor.ui.ExtractMethodChoice +import com.itsaky.androidide.lsp.kotlin.refactor.ui.ExtractMethodSheet +import com.itsaky.androidide.lsp.kotlin.refactor.ui.findFragmentActivity +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlan +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractionRefusal +import com.itsaky.androidide.lsp.kotlin.utils.refactor.buildExtractMethodPlan +import com.itsaky.androidide.lsp.kotlin.utils.refactor.buildExtractMethodRewrites +import com.itsaky.androidide.lsp.kotlin.utils.refactor.toTextEdit +import com.itsaky.androidide.lsp.models.CodeActionItem +import com.itsaky.androidide.lsp.models.CodeActionKind +import com.itsaky.androidide.lsp.models.Command +import com.itsaky.androidide.lsp.models.DocumentChange +import com.itsaky.androidide.projects.FileManager +import com.itsaky.androidide.resources.R +import com.itsaky.androidide.tasks.createJobCancelChecker +import com.itsaky.androidide.utils.flashError +import com.itsaky.androidide.utils.flashInfo +import java.nio.file.Path + +/** + * Moves the expression at the cursor, or a selected range of statements, into a new `private fun`. + * + * [execAction] runs one background analysis pass and returns a plain-data [ExtractMethodPlan]; + * [postExec] shows the sheet and turns the user's choice into two text edits with pure offset + * arithmetic. Where the region cannot be moved faithfully the plan carries a typed refusal, which + * postExec renders as a specific message rather than a generic failure (ADR 0013). + */ +class ExtractMethodAction : BaseKotlinCodeAction() { + companion object { + const val ID = "ide.editor.lsp.kt.extractMethod" + } + + override var titleTextRes: Int = R.string.action_extract_method + override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_KT_EXTRACT_METHOD + + override val id: String = ID + override var label: String = "" + + // Analysis must not run on the UI thread, so the selection is read at the top of execAction on a + // background thread. A torn read while the user is mid-edit can only produce a plan the + // document-version guard then refuses to apply. + override var requiresUIThread: Boolean = false + + // Intentionally no prepare() visibility gate: deciding whether anything is extractable needs a K2 + // analysis session, far too costly for prepare(). The action stays visible on any Kotlin file and + // reports a refusal instead. + + override suspend fun execAction(data: ActionData): ExtractMethodPlan { + val server = + data.get() + ?: return ExtractMethodPlan.refused(ExtractionRefusal.CouldNotAnalyse) + val nioPath = data.requireFile().toPath() + val env = + server.compilationEnvironmentFor(nioPath) + ?: return ExtractMethodPlan.refused(ExtractionRefusal.CouldNotAnalyse) + + val cursor = data.requireEditor().cursor + return buildExtractMethodPlan( + env = env, + nioPath = nioPath, + selectionStart = minOf(cursor.left, cursor.right), + selectionEnd = maxOf(cursor.left, cursor.right), + documentVersion = documentVersionOf(nioPath), + // Ties the analysis to this action's coroutine: cancelling the action aborts the analysis. + cancelChecker = ScheduledCancelChecker(createJobCancelChecker()), + ) + } + + override fun postExec( + data: ActionData, + result: Any, + ) { + super.postExec(data, result) + if (result !is ExtractMethodPlan) return + + val context = data.requireContext() + if (result.isEmpty) { + flashInfo(refusalMessage(context, result.refusal ?: ExtractionRefusal.CouldNotAnalyse)) + return + } + + val activity = + context.findFragmentActivity() + ?: run { + // A wiring problem rather than a user path: the editor is always hosted by one. + logger.warn("No FragmentActivity for the editor context. Cannot show the extract sheet.") + flashError(R.string.msg_cannot_perform_fix) + return + } + + val shown = ExtractMethodSheet.show(activity, result) { choice -> applyChoice(data, result, choice) } + if (!shown) { + logger.warn("Fragment manager unavailable. Cannot show the extract sheet.") + } + } + + /** + * Turns the user's choice into the two edits and hands them to the language client. + * + * The document version is re-read here rather than trusted from the plan: the editor stays + * reachable while the sheet is open, and applying spans computed against older text would corrupt + * the file. Refusing is always safe; the user can invoke the action again. + * + * Runs from the sheet's click handler, outside `execAction` and so outside every guard the action + * framework provides -- nothing here may throw (R16), hence the [runCatching]. + */ + private fun applyChoice( + data: ActionData, + plan: ExtractMethodPlan, + choice: ExtractMethodChoice, + ) { + runCatching { performChoice(data, plan, choice) }.onFailure { error -> + logger.error("Failed to apply the extract-method choice '{}'", choice.name, error) + flashError(R.string.msg_cannot_perform_fix) + } + } + + private fun performChoice( + data: ActionData, + plan: ExtractMethodPlan, + choice: ExtractMethodChoice, + ) { + val file = data.requireFile() + val nioPath = file.toPath() + if (documentVersionOf(nioPath) != plan.documentVersion) { + flashInfo(R.string.msg_extract_method_file_changed) + return + } + + val rewrites = + buildExtractMethodRewrites(plan.fileText, choice.candidate, choice.name) ?: run { + logger.warn("Could not build an extract-method rewrite for '{}'", choice.candidate.label) + flashError(R.string.msg_cannot_perform_fix) + return + } + + val client = + data.languageClient ?: run { + logger.warn("No language client set. Cannot extract method.") + return + } + + client.performCodeAction( + CodeActionItem( + title = label, + changes = + listOf( + DocumentChange( + file = nioPath, + // Already in descending document order: applyActionEdits applies these in list + // order with line/column ranges, so the call site must not shift the insertion point. + edits = rewrites.map { it.toTextEdit(plan.fileText) }, + ), + ), + kind = CodeActionKind.QuickFix, + // The rewrites are emitted fully indented; CMD_FORMAT_CODE is a no-op for Kotlin anyway. + command = Command("", ""), + ), + ) + } + + /** + * Each refusal names the construct in the way; a generic message reads as a broken feature. + * + * Exhaustive with no `else`: a future variant added without a message here is a compile error + * rather than a silent gap. + */ + private fun refusalMessage( + context: Context, + refusal: ExtractionRefusal, + ): String = + when (refusal) { + ExtractionRefusal.NotASingleRegion -> { + context.getString(R.string.msg_extract_method_not_single_region) + } + + ExtractionRefusal.CouldNotAnalyse -> { + context.getString(R.string.msg_extract_method_could_not_analyse) + } + + is ExtractionRefusal.MultipleOutputs -> { + context.getString(R.string.msg_extract_method_multiple_outputs, refusal.names.joinToString(", ")) + } + + is ExtractionRefusal.OutputNotReturnable -> { + context.getString(R.string.msg_extract_method_output_not_returnable, refusal.name) + } + + is ExtractionRefusal.ReassignsOuterVar -> { + context.getString(R.string.msg_extract_method_reassigns_outer_var, refusal.name) + } + + ExtractionRefusal.ExitsRegion -> { + context.getString(R.string.msg_extract_method_exits_region) + } + + ExtractionRefusal.AnonymousExtensionFunction -> { + context.getString(R.string.msg_extract_method_anonymous_extension_function) + } + + is ExtractionRefusal.InnerImplicitReceiver -> { + context.getString(R.string.msg_extract_method_inner_implicit_receiver, refusal.construct) + } + + is ExtractionRefusal.UsesTypeParameter -> { + context.getString(R.string.msg_extract_method_uses_type_parameter, refusal.name) + } + + ExtractionRefusal.UnrenderableType -> { + context.getString(R.string.msg_extract_method_unrenderable_type) + } + + ExtractionRefusal.UsesBackingField -> { + context.getString(R.string.msg_extract_method_uses_backing_field) + } + + is ExtractionRefusal.SmartCastParameter -> { + context.getString(R.string.msg_extract_method_smart_cast_parameter, refusal.name) + } + + is ExtractionRefusal.CapturedLocalDeclaration -> { + context.getString(R.string.msg_extract_method_captured_local_declaration, refusal.name) + } + } + + /** -1 when the document is not open, which never matches a real version and so fails the guard. */ + private fun documentVersionOf(path: Path): Int = FileManager.getActiveDocument(path)?.version ?: -1 +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ExtractVariableAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ExtractVariableAction.kt new file mode 100644 index 0000000000..086c8060f7 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ExtractVariableAction.kt @@ -0,0 +1,155 @@ +package com.itsaky.androidide.lsp.kotlin.actions + +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.get +import com.itsaky.androidide.actions.requireContext +import com.itsaky.androidide.actions.requireEditor +import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.lsp.kotlin.KotlinLanguageServer +import com.itsaky.androidide.lsp.kotlin.compiler.modules.ScheduledCancelChecker +import com.itsaky.androidide.lsp.kotlin.refactor.ui.ExtractVariableSheet +import com.itsaky.androidide.lsp.kotlin.refactor.ui.ExtractionChoice +import com.itsaky.androidide.lsp.kotlin.refactor.ui.findFragmentActivity +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractionPlan +import com.itsaky.androidide.lsp.kotlin.utils.refactor.buildExtractVariableRewrite +import com.itsaky.androidide.lsp.kotlin.utils.refactor.buildExtractionPlan +import com.itsaky.androidide.lsp.kotlin.utils.refactor.toTextEdit +import com.itsaky.androidide.lsp.models.CodeActionItem +import com.itsaky.androidide.lsp.models.CodeActionKind +import com.itsaky.androidide.lsp.models.Command +import com.itsaky.androidide.lsp.models.DocumentChange +import com.itsaky.androidide.projects.FileManager +import com.itsaky.androidide.resources.R +import com.itsaky.androidide.tasks.createJobCancelChecker +import com.itsaky.androidide.utils.flashError +import com.itsaky.androidide.utils.flashInfo +import java.nio.file.Path + +/** + * Extracts the expression at the cursor, or the selected one, into a local `val`. + * + * The work is split so nothing heavy touches the UI thread: [execAction] runs one background analysis + * pass and returns a plain-data [ExtractionPlan] covering every candidate, then [postExec] shows the + * sheet and turns the user's choice into a single text edit with pure offset arithmetic. + */ +class ExtractVariableAction : BaseKotlinCodeAction() { + companion object { + const val ID = "ide.editor.lsp.kt.extractVariable" + } + + override var titleTextRes: Int = R.string.action_extract_variable + override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_KT_EXTRACT_VARIABLE + + override val id: String = ID + override var label: String = "" + + // Analysis must not run on the UI thread. The selection is therefore read at the top of + // execAction on a background thread, as ImplementMembersAction does; a torn read while the user + // is mid-edit can only produce a plan the document-version guard then refuses to apply. + override var requiresUIThread: Boolean = false + + // Intentionally no prepare() visibility gate: deciding whether anything is extractable needs a K2 + // analysis session, far too costly for prepare() (UI thread). The action stays visible on any + // Kotlin file and reports "nothing to extract" instead. Matches OrganizeImportsAction and + // ImplementMembersAction. + + override suspend fun execAction(data: ActionData): ExtractionPlan { + val server = data.get() ?: return ExtractionPlan.empty() + val nioPath = data.requireFile().toPath() + val env = server.compilationEnvironmentFor(nioPath) ?: return ExtractionPlan.empty() + + val cursor = data.requireEditor().cursor + val selectionStart = minOf(cursor.left, cursor.right) + val selectionEnd = maxOf(cursor.left, cursor.right) + + return buildExtractionPlan( + env = env, + nioPath = nioPath, + selectionStart = selectionStart, + selectionEnd = selectionEnd, + documentVersion = documentVersionOf(nioPath), + // Ties the analysis to this action's coroutine: cancelling the action aborts the analysis. + cancelChecker = ScheduledCancelChecker(createJobCancelChecker()), + ) + } + + override fun postExec( + data: ActionData, + result: Any, + ) { + super.postExec(data, result) + if (result !is ExtractionPlan) return + + if (result.isEmpty) { + flashInfo(R.string.msg_extract_variable_nothing_to_extract) + return + } + + val activity = + data.requireContext().findFragmentActivity() + ?: run { + // A wiring problem rather than a user path: the editor is always hosted by one. + logger.warn("No FragmentActivity for the editor context. Cannot show the extract sheet.") + flashError(R.string.msg_cannot_perform_fix) + return + } + + val shown = ExtractVariableSheet.show(activity, result) { choice -> applyChoice(data, result, choice) } + if (!shown) { + logger.warn("Fragment manager unavailable. Cannot show the extract sheet.") + } + } + + /** + * Turns the user's choice into one edit and hands it to the language client. + * + * The document version is re-read here rather than trusted from the plan: the editor stays + * reachable while the sheet is open, and applying spans computed against older text would corrupt + * the file. Refusing is always safe; the user can invoke the action again. + */ + private fun applyChoice( + data: ActionData, + plan: ExtractionPlan, + choice: ExtractionChoice, + ) { + val file = data.requireFile() + val nioPath = file.toPath() + if (documentVersionOf(nioPath) != plan.documentVersion) { + flashInfo(R.string.msg_extract_variable_file_changed) + return + } + + val rewrite = + buildExtractVariableRewrite( + fileText = plan.fileText, + candidateSpan = choice.candidate.span, + scope = choice.scope, + name = choice.name, + replaceAll = choice.replaceAll, + ) ?: run { + logger.warn("Could not build an extract-variable rewrite for '{}'", choice.candidate.label) + flashError(R.string.msg_cannot_perform_fix) + return + } + + val client = + data.languageClient ?: run { + logger.warn("No language client set. Cannot extract variable.") + return + } + + client.performCodeAction( + CodeActionItem( + title = label, + changes = listOf(DocumentChange(file = nioPath, edits = listOf(rewrite.toTextEdit(plan.fileText)))), + kind = CodeActionKind.QuickFix, + // The rewrite is emitted fully indented; CMD_FORMAT_CODE is a no-op for Kotlin anyway. + command = Command("", ""), + ), + ) + } + + /** -1 when the document is not open, which never matches a real version and so fails the guard. */ + private fun documentVersionOf(path: Path): Int = FileManager.getActiveDocument(path)?.version ?: -1 +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/FindReferencesAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/FindReferencesAction.kt new file mode 100644 index 0000000000..ab40a307da --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/FindReferencesAction.kt @@ -0,0 +1,50 @@ +package com.itsaky.androidide.lsp.kotlin.actions + +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.hasRequiredData +import com.itsaky.androidide.actions.markInvisible +import com.itsaky.androidide.editor.api.ILspEditor +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.resources.R +import io.github.rosemoe.sora.widget.CodeEditor + +/** + * Lists every usage of the declaration at the caret, or of whatever the reference at the caret names. + * + * Mirrors the Java action: the real work is the editor's own cancellable request, so this only has to + * start it. + */ +class FindReferencesAction : BaseKotlinCodeAction() { + override var titleTextRes: Int = R.string.action_find_references + override val id: String = ID + override var label: String = "" + override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_KT_FIND_REFS + + // execAction only starts the editor's own background request, so it must not be moved off the UI + // thread. Nothing here or in prepare() touches the project lock, the index, or an analysis session - + // but super.prepare() -> BaseKotlinCodeAction.prepare -> isKotlinFile() does stat the file + // (Files.exists + Files.isDirectory) on the UI thread. Pre-existing, shared by every Kotlin/Java + // code action, and out of scope here. + override var requiresUIThread: Boolean = true + + override fun prepare(data: ActionData) { + super.prepare(data) + + // Deliberately not conditioned on what the caret sits on: answering that needs PSI and the + // project read lock, and prepare() runs on the UI thread. A caret that names nothing therefore + // shows the item and flashes "no references", exactly as go-to-definition does. + if (!visible || !data.hasRequiredData(CodeEditor::class.java)) { + markInvisible() + return + } + } + + override suspend fun execAction(data: ActionData): Any { + val editor = data[CodeEditor::class.java] ?: return false + return (editor as? ILspEditor)?.findReferences() ?: false + } + + companion object { + const val ID = "ide.editor.lsp.kt.findReferences" + } +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ImplementMembersAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ImplementMembersAction.kt index 69a5673035..6c103772bf 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ImplementMembersAction.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ImplementMembersAction.kt @@ -8,8 +8,9 @@ import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.lsp.kotlin.KotlinLanguageServer import com.itsaky.androidide.lsp.kotlin.compiler.AbstractCompilationEnvironment import com.itsaky.androidide.lsp.kotlin.compiler.modules.AnalysisPriority -import com.itsaky.androidide.lsp.kotlin.compiler.modules.ScheduledCancelChecker import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling +import com.itsaky.androidide.lsp.kotlin.compiler.modules.isAnalysisCancellation +import com.itsaky.androidide.lsp.kotlin.compiler.modules.retryingOnPreemption import com.itsaky.androidide.lsp.kotlin.compiler.read import com.itsaky.androidide.lsp.kotlin.utils.membersToImplement import com.itsaky.androidide.lsp.kotlin.utils.renderOverrideStub @@ -20,6 +21,7 @@ import com.itsaky.androidide.lsp.models.Command import com.itsaky.androidide.lsp.models.DocumentChange import com.itsaky.androidide.lsp.models.TextEdit import com.itsaky.androidide.models.Range +import com.itsaky.androidide.progress.ICancelChecker import com.itsaky.androidide.resources.R import com.itsaky.androidide.tasks.createJobCancelChecker import org.jetbrains.kotlin.analysis.api.symbols.KaClassKind @@ -52,7 +54,7 @@ class ImplementMembersAction : BaseKotlinCodeAction() { val offset = data.requireEditor().cursor.left val env = server.compilationEnvironmentFor(nioPath) ?: return emptyList() // Ties the analysis to this action's coroutine: cancelling the action aborts the queued analysis. - return computeImplementMembersEdit(env, nioPath, offset, ScheduledCancelChecker(createJobCancelChecker())) + return computeImplementMembersEdit(env, nioPath, offset, createJobCancelChecker()) } /** @@ -70,27 +72,39 @@ class ImplementMembersAction : BaseKotlinCodeAction() { env: AbstractCompilationEnvironment, nioPath: Path, offset: Int, - cancelChecker: ScheduledCancelChecker, + cancelChecker: ICancelChecker, ): List = runCatching { - val ktFile = env.ktSymbolIndex.getCurrentKtFile(nioPath).get() ?: return emptyList() - env.project.read { - val classOrObject = findEnclosingClassOrObject(ktFile, offset) ?: return@read emptyList() - analyzeMaybeDangling(ktFile, AnalysisPriority.INTERACTIVE, cancelChecker) { - val classSymbol = classOrObject.symbol as? KaClassSymbol ?: return@analyzeMaybeDangling emptyList() - if (!isImplementable(classSymbol)) return@analyzeMaybeDangling emptyList() - - val classIndent = classIndentOf(ktFile, classOrObject) - val unit = detectIndentUnit(ktFile.text) - val memberIndent = memberIndentOf(ktFile, classOrObject, classIndent, unit) - val stubs = membersToImplement(classSymbol).mapNotNull { renderOverrideStub(it, memberIndent, unit) } - if (stubs.isEmpty()) return@analyzeMaybeDangling emptyList() - - buildInsertionEdit(ktFile, classOrObject, stubs, classIndent) + // A user-invoked command: AnalysisPriority.COMMAND, retried once if keystroke-driven work + // preempts it (ADR 0011). Without the retry a preemption fell into the getOrElse below and the + // action silently inserted nothing. The file is re-fetched per attempt because the preemptor + // also refreshed the live PSI. + retryingOnPreemption(cancelChecker, "Implement members for $nioPath") { checker -> + val ktFile = env.ktSymbolIndex.getCurrentKtFile(nioPath).get() ?: return@retryingOnPreemption emptyList() + env.project.read { + val classOrObject = findEnclosingClassOrObject(ktFile, offset) ?: return@read emptyList() + analyzeMaybeDangling(ktFile, AnalysisPriority.COMMAND, checker) { + val classSymbol = classOrObject.symbol as? KaClassSymbol ?: return@analyzeMaybeDangling emptyList() + if (!isImplementable(classSymbol)) return@analyzeMaybeDangling emptyList() + + val classIndent = classIndentOf(ktFile, classOrObject) + val unit = detectIndentUnit(ktFile.text) + val memberIndent = memberIndentOf(ktFile, classOrObject, classIndent, unit) + val stubs = membersToImplement(classSymbol).mapNotNull { renderOverrideStub(it, memberIndent, unit) } + if (stubs.isEmpty()) return@analyzeMaybeDangling emptyList() + + buildInsertionEdit(ktFile, classOrObject, stubs, classIndent) + } } } }.getOrElse { e -> - logger.warn("Failed to compute implement-members edit", e) + if (e.isAnalysisCancellation()) { + // Cancelled, or preempted past the retry above: not a failure, and warn-logging it would + // bury the ones that are. + logger.debug("Implement-members edit for {} was cancelled", nioPath, e) + } else { + logger.warn("Failed to compute implement-members edit", e) + } emptyList() } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/NullSafetyAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/NullSafetyAction.kt index 7327aa9d2d..85f4702a2c 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/NullSafetyAction.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/NullSafetyAction.kt @@ -1,11 +1,16 @@ package com.itsaky.androidide.lsp.kotlin.actions +import android.content.Context +import android.view.View +import android.widget.ListView import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.actions.markInvisible import com.itsaky.androidide.actions.newDialogBuilder import com.itsaky.androidide.actions.requireContext import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.lsp.api.ILanguageClient import com.itsaky.androidide.lsp.kotlin.compiler.read import com.itsaky.androidide.lsp.kotlin.diagnostic.DiagnosticAction import com.itsaky.androidide.lsp.kotlin.utils.NullSafetyKind @@ -17,6 +22,7 @@ import com.itsaky.androidide.lsp.models.CodeActionKind import com.itsaky.androidide.lsp.models.Command import com.itsaky.androidide.lsp.models.DocumentChange import com.itsaky.androidide.resources.R +import com.itsaky.androidide.utils.applyLongPressRecursively import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -122,16 +128,58 @@ class NullSafetyAction : BaseKotlinCodeAction() { } else -> { - newDialogBuilder(data) - .setTitle(label) - .setItems(actions.map { it.title }.toTypedArray()) { dialog, which -> - dialog.dismiss() - actions.getOrNull(which)?.also { client.performCodeAction(it) } - ?: logger.error("Index $which is out of bounds for actions of size ${actions.size}") - }.show() + showFixChooser(data, context, actions, client) } } } + + /** + * Shows the fix chooser and makes every part of it long-pressable for help. + * + * [applyLongPressRecursively] skips [ListView] subtrees, so the item list needs its own + * listener -- the dialog chrome and the rows are wired separately (ADFA-4510). + */ + private fun showFixChooser( + data: ActionData, + context: Context, + actions: List, + client: ILanguageClient, + ) { + val dialog = + newDialogBuilder(data) + .setTitle(label) + .setItems(actions.map { it.title }.toTypedArray()) { dialog, which -> + dialog.dismiss() + actions.getOrNull(which)?.also { client.performCodeAction(it) } + ?: logger.error("Index $which is out of bounds for actions of size ${actions.size}") + }.create() + + dialog.listView?.setOnItemLongClickListener { _, view, _, _ -> + showDialogTooltip(context, view) + true + } + + dialog.setOnShowListener { + val root = dialog.window?.decorView ?: return@setOnShowListener + root.applyLongPressRecursively { + showDialogTooltip(context, root) + true + } + } + + dialog.show() + } + + private fun showDialogTooltip( + context: Context, + anchor: View, + ) { + TooltipManager.showIdeCategoryTooltip( + context, + anchor, + TooltipTag.EDITOR_CODE_ACTIONS_KT_NULL_SAFETY_FIX_DIALOG, + ) + } } private val NullSafetyKind.titleRes: Int diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt index 1294b3259d..4f2012bef2 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt @@ -7,8 +7,9 @@ import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.lsp.kotlin.KotlinLanguageServer import com.itsaky.androidide.lsp.kotlin.compiler.AbstractCompilationEnvironment import com.itsaky.androidide.lsp.kotlin.compiler.modules.AnalysisPriority -import com.itsaky.androidide.lsp.kotlin.compiler.modules.ScheduledCancelChecker import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling +import com.itsaky.androidide.lsp.kotlin.compiler.modules.isAnalysisCancellation +import com.itsaky.androidide.lsp.kotlin.compiler.modules.retryingOnPreemption import com.itsaky.androidide.lsp.kotlin.compiler.read import com.itsaky.androidide.lsp.kotlin.utils.collectImportUsage import com.itsaky.androidide.lsp.kotlin.utils.organizedImportBlock @@ -19,6 +20,7 @@ import com.itsaky.androidide.lsp.models.Command import com.itsaky.androidide.lsp.models.DocumentChange import com.itsaky.androidide.lsp.models.TextEdit import com.itsaky.androidide.models.Range +import com.itsaky.androidide.progress.ICancelChecker import com.itsaky.androidide.resources.R import com.itsaky.androidide.tasks.createJobCancelChecker import org.slf4j.LoggerFactory @@ -41,7 +43,7 @@ class OrganizeImportsAction : BaseKotlinCodeAction() { val nioPath = data.requireFile().toPath() val env = server.compilationEnvironmentFor(nioPath) ?: return emptyList() // Ties the analysis to this action's coroutine: cancelling the action aborts the queued analysis. - return computeOrganizeEdit(env, nioPath, ScheduledCancelChecker(createJobCancelChecker())) + return computeOrganizeEdit(env, nioPath, createJobCancelChecker()) } /** @@ -57,20 +59,32 @@ class OrganizeImportsAction : BaseKotlinCodeAction() { internal fun computeOrganizeEdit( env: AbstractCompilationEnvironment, nioPath: Path, - cancelChecker: ScheduledCancelChecker, + cancelChecker: ICancelChecker, ): List = runCatching { - val ktFile = env.ktSymbolIndex.getCurrentKtFile(nioPath).get() ?: return emptyList() - if (ktFile.importDirectives.isEmpty()) return emptyList() - env.project.read { - val usage = analyzeMaybeDangling(ktFile, AnalysisPriority.INTERACTIVE, cancelChecker) { collectImportUsage(ktFile) } - val newText = organizedImportBlock(ktFile, usage) ?: return@read emptyList() - val range = ktFile.importList?.textRange?.toRange(ktFile) ?: return@read emptyList() - if (range == Range.NONE) return@read emptyList() - listOf(TextEdit(range, newText)) + // A user-invoked command: AnalysisPriority.COMMAND, retried once if keystroke-driven work + // preempts it (ADR 0011). Without the retry a preemption fell into the getOrElse below and + // organize-imports silently did nothing. The file is re-fetched per attempt because the + // preemptor also refreshed the live PSI. + retryingOnPreemption(cancelChecker, "Organize imports for $nioPath") { checker -> + val ktFile = env.ktSymbolIndex.getCurrentKtFile(nioPath).get() ?: return@retryingOnPreemption emptyList() + if (ktFile.importDirectives.isEmpty()) return@retryingOnPreemption emptyList() + env.project.read { + val usage = analyzeMaybeDangling(ktFile, AnalysisPriority.COMMAND, checker) { collectImportUsage(ktFile) } + val newText = organizedImportBlock(ktFile, usage) ?: return@read emptyList() + val range = ktFile.importList?.textRange?.toRange(ktFile) ?: return@read emptyList() + if (range == Range.NONE) return@read emptyList() + listOf(TextEdit(range, newText)) + } } }.getOrElse { e -> - logger.warn("Failed to organize imports", e) + if (e.isAnalysisCancellation()) { + // Cancelled, or preempted past the retry above: not a failure, and warn-logging it would + // bury the ones that are. + logger.debug("Organize imports for {} was cancelled", nioPath, e) + } else { + logger.warn("Failed to organize imports", e) + } emptyList() } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/SurroundWithTryCatchAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/SurroundWithTryCatchAction.kt deleted file mode 100644 index b73eb23b6b..0000000000 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/SurroundWithTryCatchAction.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.itsaky.androidide.lsp.kotlin.actions - -import com.itsaky.androidide.actions.ActionData -import com.itsaky.androidide.actions.requireEditor -import com.itsaky.androidide.actions.requireFile -import com.itsaky.androidide.idetooltips.TooltipTag -import com.itsaky.androidide.lsp.kotlin.utils.computeSurroundWithTryCatchEdit -import com.itsaky.androidide.lsp.kotlin.utils.resolveSurroundLines -import com.itsaky.androidide.lsp.models.CodeActionItem -import com.itsaky.androidide.lsp.models.CodeActionKind -import com.itsaky.androidide.lsp.models.Command -import com.itsaky.androidide.lsp.models.DocumentChange -import com.itsaky.androidide.lsp.models.TextEdit -import com.itsaky.androidide.resources.R - -class SurroundWithTryCatchAction : BaseKotlinCodeAction() { - companion object { - const val ID = "ide.editor.lsp.kt.surroundWithTryCatch" - } - - override var titleTextRes: Int = R.string.action_surround_with_try_catch - override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_KT_SURROUND_TRY_CATCH - - override val id: String = ID - override var label: String = "" - - // Reads the editor selection, so it must run on the UI thread (as CommentLineAction does). - override var requiresUIThread: Boolean = true - - override suspend fun execAction(data: ActionData): List { - val editor = data.requireEditor() - val cursor = editor.cursor - val (startLine, endLine) = - resolveSurroundLines( - cursor.leftLine, - cursor.leftColumn, - cursor.rightLine, - cursor.rightColumn, - ) - val edit = - computeSurroundWithTryCatchEdit( - editor.text.toString(), - startLine, - endLine, - ) ?: return emptyList() - return listOf(edit) - } - - override fun postExec( - data: ActionData, - result: Any, - ) { - super.postExec(data, result) - - if (result !is List<*> || result.isEmpty()) { - return - } - - @Suppress("UNCHECKED_CAST") - val edits = result as List - - val client = - data.languageClient - ?: run { - logger.warn("No language client set. Cannot complete action.") - return - } - - val file = data.requireFile() - client.performCodeAction( - CodeActionItem( - title = label, - changes = listOf(DocumentChange(file = file.toPath(), edits = edits)), - kind = CodeActionKind.QuickFix, - command = Command.CMD_FORMAT_CODE, - ), - ) - } -} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/AnalysisScheduler.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/AnalysisScheduler.kt index a4f3afef95..c4874af24d 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/AnalysisScheduler.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/AnalysisScheduler.kt @@ -1,6 +1,8 @@ package com.itsaky.androidide.lsp.kotlin.compiler.modules import com.itsaky.androidide.progress.ICancelChecker +import org.slf4j.Logger +import org.slf4j.LoggerFactory import java.util.concurrent.CancellationException import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.TimeUnit @@ -12,20 +14,37 @@ import kotlin.concurrent.withLock * lower-priority analysis that is currently running, and is served before any lower-priority request * that is merely waiting. * - * Order: [INDEXING] < [DIAGNOSTICS] < [INTERACTIVE] — interactive requests (completion, signature - * help) beat background diagnostics, which beats bulk indexing. + * Order: [INDEXING] < [DIAGNOSTICS] < [COMMAND] < [INTERACTIVE] — keystroke-driven requests + * (completion, signature help) beat user-invoked commands, which beat background diagnostics, which + * beat bulk indexing. * * [supersedesSamePriority] additionally lets a *newer* request preempt an in-flight one of the * **same** priority. On for [INTERACTIVE] only: rapid typing makes the in-flight request stale, so * the newer one cancels it and the superseded work is *discarded* (nothing reschedules it). Off for - * [DIAGNOSTICS]/[INDEXING], whose preempted work is re-queued — there same-priority preemption would - * livelock, two contenders endlessly re-queuing and re-preempting each other. + * the rest, whose preempted work is re-queued — there same-priority preemption would livelock, two + * contenders endlessly re-queuing and re-preempting each other. */ internal enum class AnalysisPriority( val supersedesSamePriority: Boolean, ) { INDEXING(supersedesSamePriority = false), DIAGNOSTICS(supersedesSamePriority = false), + + /** + * A command the user invoked from the code-actions menu: find usages, go-to-definition, organize + * imports, implement members. Distinct from [INTERACTIVE] because such a request is never *stale* — + * the user tapped a menu item and is watching a progress flashbar, so discarding the work produces + * a wrong answer rather than no answer. Hence [supersedesSamePriority] is off: two commands must + * not discard each other. + * + * Ordered below [INTERACTIVE] so a long command never starves the completion popup, which on a + * phone is part of how text gets entered. The cost is that a command *can* be preempted, so its + * call site must retry — and a long-running one should take the lock per unit of work (find usages + * takes it per candidate file) so a preemption costs one unit rather than the whole request. + * + * See ADR 0011 (docs/adr/0011-command-analysis-priority.md). + */ + COMMAND(supersedesSamePriority = false), INTERACTIVE(supersedesSamePriority = true), } @@ -96,6 +115,37 @@ internal class ScheduledCancelChecker( } } +/** + * Runs [attempt] and, if it was preempted, runs it exactly once more. + * + * The retry policy every [AnalysisPriority.COMMAND] call site needs. A command is preempted by + * keystroke-driven work ([AnalysisPriority.INTERACTIVE]), which - unlike a genuine cancellation - + * leaves the user's own request alive, so reporting the empty/failed result would be a lie: "no + * references" for a symbol that has plenty, or a silently skipped organize-imports. + * + * Two details this centralises: + * - **A fresh [ScheduledCancelChecker] per attempt.** [ScheduledCancelChecker.preempt] latches, so + * reusing the checker would make the retry abort at its first checkpoint. + * - **The whole pipeline is retried, not just the `analyze` block.** Whatever preempted the first + * attempt also refreshed the live PSI, unregistering the `KtFile` that attempt held; re-analyzing + * that stale file fails. So [attempt] must re-fetch the file too. + * + * A second preemption propagates - this is one retry, not a loop. + */ +internal inline fun retryingOnPreemption( + delegate: ICancelChecker, + label: String, + attempt: (ScheduledCancelChecker) -> R, +): R = + try { + attempt(ScheduledCancelChecker(delegate)) + } catch (e: AnalysisPreemptedException) { + schedulerLogger.debug("{} preempted; retrying once", label) + attempt(ScheduledCancelChecker(delegate)) + } + +internal val schedulerLogger: Logger = LoggerFactory.getLogger("AnalysisScheduler") + /** * A process-global, priority-aware, preemptive lock that serializes all Kotlin Analysis API access. * diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ModuleDependentsProvider.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ModuleDependentsProvider.kt index 5b057064c8..d334483f9c 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ModuleDependentsProvider.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ModuleDependentsProvider.kt @@ -9,59 +9,58 @@ import org.jetbrains.kotlin.cli.jvm.index.JavaRoot import org.jetbrains.kotlin.com.intellij.mock.MockProject import org.jetbrains.kotlin.com.intellij.util.containers.ContainerUtil.createConcurrentSoftMap -internal class ModuleDependentsProvider : KtLspService, KotlinModuleDependentsProviderBase() { - +internal class ModuleDependentsProvider : + KotlinModuleDependentsProviderBase(), + KtLspService { private lateinit var modules: List override fun setupWith( project: MockProject, index: KtSymbolIndex, modules: List, - libraryRoots: List + libraryRoots: List, ) { this.modules = modules } private val directDependentsByKtModule by lazy { - modules.asSequence() - .map { module -> - buildDependentsMap(module, module.allDirectDependencies()) - } - .reduce { acc, value -> acc + value } + buildDependentsMap(modules) { it.allDirectDependencies() } } private val transitiveDependentsByKtModule = createConcurrentSoftMap>() private val refinementDependentsByKtModule by lazy { - modules - .asSequence() - .map { buildDependentsMap(it, it.transitiveDependsOnDependencies.asSequence()) } - .reduce { acc, map -> acc + map } + buildDependentsMap(modules) { it.transitiveDependsOnDependencies.asSequence() } } - override fun getDirectDependents(module: KaModule): Set { - return directDependentsByKtModule[module].orEmpty() - } + override fun getDirectDependents(module: KaModule): Set = directDependentsByKtModule[module].orEmpty() - override fun getRefinementDependents(module: KaModule): Set { - return refinementDependentsByKtModule[module].orEmpty() - } + override fun getRefinementDependents(module: KaModule): Set = refinementDependentsByKtModule[module].orEmpty() - override fun getTransitiveDependents(module: KaModule): Set { - return transitiveDependentsByKtModule.computeIfAbsent(module) { key -> + override fun getTransitiveDependents(module: KaModule): Set = + transitiveDependentsByKtModule.computeIfAbsent(module) { key -> computeTransitiveDependents( - key + key, ) } - } } +/** + * Inverts every module's dependency edges into one dependency -> dependents map. + * + * Accumulated across all of [modules] rather than built per module and merged: `Map + Map` *replaces* a + * shared dependency's dependent set, so a module used by more than one other kept only the last of them + * and find usages then missed every call site in the rest. + */ private fun buildDependentsMap( - module: KaModule, - dependencies: Sequence, -): Map> = buildMap { - dependencies.forEach { dependency -> - if (dependency == module) return@forEach - val dependents = computeIfAbsent(dependency) { mutableSetOf() } - dependents.add(module) + modules: List, + dependenciesOf: (KtModule) -> Sequence, +): Map> = + buildMap> { + modules.forEach { module -> + dependenciesOf(module).forEach { dependency -> + if (dependency != module) { + getOrPut(dependency) { mutableSetOf() }.add(module) + } + } + } } -} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/navigation/FindUsages.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/navigation/FindUsages.kt new file mode 100644 index 0000000000..afadb2c4ad --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/navigation/FindUsages.kt @@ -0,0 +1,604 @@ +package com.itsaky.androidide.lsp.kotlin.navigation + +import com.itsaky.androidide.lsp.kotlin.compiler.AbstractCompilationEnvironment +import com.itsaky.androidide.lsp.kotlin.compiler.modules.AnalysisPreemptedException +import com.itsaky.androidide.lsp.kotlin.compiler.modules.AnalysisPriority +import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtModule +import com.itsaky.androidide.lsp.kotlin.compiler.modules.ScheduledCancelChecker +import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling +import com.itsaky.androidide.lsp.kotlin.compiler.modules.asFlatSequence +import com.itsaky.androidide.lsp.kotlin.compiler.modules.backingFilePath +import com.itsaky.androidide.lsp.kotlin.compiler.modules.isAnalysisCancellation +import com.itsaky.androidide.lsp.kotlin.compiler.modules.isSourceModule +import com.itsaky.androidide.lsp.kotlin.compiler.modules.retryingOnPreemption +import com.itsaky.androidide.lsp.kotlin.compiler.read +import com.itsaky.androidide.lsp.kotlin.compiler.services.ProjectStructureProvider +import com.itsaky.androidide.lsp.kotlin.utils.rangeOf +import com.itsaky.androidide.lsp.models.ReferenceParams +import com.itsaky.androidide.lsp.models.ReferenceResult +import com.itsaky.androidide.models.Location +import com.itsaky.androidide.models.Range +import com.itsaky.androidide.progress.ICancelChecker +import com.itsaky.androidide.projects.FileManager +import kotlinx.coroutines.future.await +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.platform.projectStructure.KotlinModuleDependentsProvider +import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaClassSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaDeclarationSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaSymbolLocation +import org.jetbrains.kotlin.analysis.api.symbols.KaSymbolVisibility +import org.jetbrains.kotlin.analysis.api.symbols.markers.KaNamedSymbol +import org.jetbrains.kotlin.analysis.api.symbols.pointers.KaSymbolPointer +import org.jetbrains.kotlin.analysis.api.symbols.sourcePsiSafe +import org.jetbrains.kotlin.analysis.low.level.api.fir.util.originalKtFile +import org.jetbrains.kotlin.com.intellij.psi.PsiElement +import org.jetbrains.kotlin.com.intellij.psi.PsiRecursiveElementWalkingVisitor +import org.jetbrains.kotlin.idea.references.mainReference +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtSimpleNameExpression +import org.slf4j.LoggerFactory +import java.io.IOException +import java.nio.file.Path + +private val logger = LoggerFactory.getLogger("FindUsages") + +/** How many times [planWithRetry] runs [planAt], each of which retries a preemption once itself. */ +private const val PLAN_ATTEMPTS = 2 + +/** + * Where a usage could possibly be written, derived from the target's visibility (R4). + * + * Kotlin's visibility rules are an exact bound, not a heuristic: a `private` declaration cannot be + * referenced from another file, and a `public` one cannot be referenced from a module that does not + * depend on its own. Narrowing here is what keeps the common cases cheap - a search on a local + * variable never leaves the open file - and it is also what makes the ticket's three resolution + * scopes fall out of one code path. + */ +internal sealed interface UsageSearchScope { + data class SingleFile( + val path: Path, + ) : UsageSearchScope + + data class Modules( + val modules: List, + ) : UsageSearchScope +} + +/** + * Everything the per-file search loop needs, computed once in the caret's analysis session. + * + * [matchSet] holds pointers rather than symbols because a [KaSymbol] cannot cross a session boundary, + * and each candidate file may be analyzed in a different one (R6). + */ +internal class SearchPlan( + val simpleName: String, + val matchSet: List>, + val scope: UsageSearchScope, +) + +/** + * Computes the usage result for [params]. + * + * Structured so that no lock spans the whole search (R9): the target is resolved under one short + * `project.read`, candidate selection holds nothing across the pass (`computeFiles` takes `project.read` + * per file, for one path lookup), and each candidate then takes its own read lock and analysis session. + * A whole-workspace search holding either for its full duration would block index refresh (which needs + * `project.write`) and would lose all its work to a single keystroke. + */ +context(env: AbstractCompilationEnvironment) +internal suspend fun findUsagesAt(params: ReferenceParams): ReferenceResult { + logger.debug("findUsagesAt requested for file={} position={}", params.file, params.position) + + if (params.cancelChecker.isCancelled()) { + logger.debug("References request for {} was cancelled before processing", params.file) + return ReferenceResult.empty() + } + + return try { + val plan = planWithRetry(params) ?: return ReferenceResult.empty() + val candidates = candidateFiles(plan, params.cancelChecker) + logger.debug("Usage search for '{}': {} candidate file(s)", plan.simpleName, candidates.size) + + val locations = + candidates + .flatMap { candidate -> + params.cancelChecker.abortIfCancelled() + usagesIn(candidate, plan, params.cancelChecker) + }.distinctBy { it.file to it.range } + .sortedWith(compareBy({ it.file.toString() }, { it.range.start.index })) + + logger.debug("Usage result for {}: {} location(s)", params.file, locations.size) + ReferenceResult(locations) + } catch (e: Throwable) { + if (e.isAnalysisCancellation()) { + logger.debug("Usage search for {} cancelled", params.file) + return ReferenceResult.empty() + } + logger.warn("Usage search failed for {}", params.file, e) + ReferenceResult.empty() + } +} + +/** + * [planAt], retried on a preemption that outlived its own single retry. + * + * Without this a *second* preemption escapes as an [AnalysisPreemptedException], which is a + * [java.util.concurrent.CancellationException], so [findUsagesAt]'s cancellation branch turns it into + * an empty result and the editor flashes "No references found" for a symbol with plenty - the wrong + * answer ADR 0011 exists to prevent. [usagesIn] draws the same distinction per candidate file. + * + * Retrying is cheap here: the plan phase is one file and one short session. Genuine cancellation is + * not caught - the delegate throws a plain [java.util.concurrent.CancellationException], not this + * subtype. + */ +context(env: AbstractCompilationEnvironment) +private suspend fun planWithRetry(params: ReferenceParams): SearchPlan? { + repeat(PLAN_ATTEMPTS) { + try { + return planAt(params) + } catch (e: AnalysisPreemptedException) { + logger.debug("Usage search plan for {} was preempted twice; retrying the plan", params.file) + } + } + + logger.warn("Usage search for {} abandoned: target resolution kept being preempted", params.file) + return null +} + +/** + * The search plan for [params]' caret, or null when it names nothing searchable. + * + * Its own short-lived read lock and analysis session, released before any candidate file is touched. + */ +context(env: AbstractCompilationEnvironment) +internal suspend fun planAt(params: ReferenceParams): SearchPlan? { + val offset = params.position.requireIndex() + + return retryingOnPreemption(params.cancelChecker, "Usage search target for ${params.file}") { cancelChecker -> + // Awaited per attempt and outside project.read, exactly as in findDefinitionAt: the refresh this + // waits on needs project.write, and a preemption invalidates the KtFile it returned. + val ktFile = env.ktSymbolIndex.getCurrentKtFile(params.file).await() + if (ktFile == null) { + logger.warn("File {} cannot be loaded for usage search", params.file) + null + } else { + cancelChecker.abortIfCancelled() + env.project.read { + val target = targetAtCaret(ktFile, offset) ?: return@read null + analyzeMaybeDangling(ktFile, AnalysisPriority.COMMAND, cancelChecker) { + planFor(target) + } + } + } + } +} + +/** The search plan for [target], or null when it names nothing searchable. */ +context(env: AbstractCompilationEnvironment) +private fun KaSession.planFor(target: CaretTarget): SearchPlan? { + val symbol = targetSymbol(target) ?: return null + val declaration = symbol.sourcePsiSafe() + if (declaration == null) { + // Not a workspace source: the stdlib, the framework, a library jar. Its usages are unreachable + // for the same reason go-to-definition cannot navigate to it. + logger.debug("Usage search target is not a workspace source; nothing to search") + return null + } + + val simpleName = prefilterName(symbol) ?: return null + + val matchSet = matchSet(symbol) + + return SearchPlan( + simpleName = simpleName, + matchSet = matchSet.map { it.createPointer() }, + scope = scopeOf(symbol, declaration, pathOf(declaration), matchSet), + ) +} + +/** + * The on-disk path of [declaration]'s file, or null when it has none. + * + * [backingFilePath] is tried before the VFS, exactly as in go-to-definition: the file the user is + * editing is a live [KtFile] built from the editor buffer, whose `virtualFile` is a non-physical + * `LightVirtualFile`. Reading the VFS alone would leave the common case pathless, and a pathless local + * or `private` target loses its single-file scope (R4) and widens to the whole module graph. + */ +private fun pathOf(declaration: PsiElement): Path? { + val psiFile = declaration.containingFile ?: return null + val ktFile = psiFile as? KtFile + + return (ktFile?.backingFilePath ?: ktFile?.originalKtFile?.backingFilePath) + ?: psiFile.virtualFile + ?.takeIf { it.fileSystem.protocol == "file" } + ?.let { runCatching { it.toNioPath() }.getOrNull() } +} + +/** + * The declaration [target] names. + * + * A [CaretTarget.Declaration] already *is* the declaration, so it answers through its own symbol; a + * [CaretTarget.Reference] answers through the same two resolution paths go-to-definition uses. + */ +private fun KaSession.targetSymbol(target: CaretTarget): KaDeclarationSymbol? = + when (target) { + is CaretTarget.Declaration -> { + runCatching { target.declaration.symbol }.getOrNull() + } + + is CaretTarget.Reference -> { + symbolsAt(target.element) + .also { + if (it.size > 1) { + // An ambiguous reference (overloads, broken code). Searching for the first candidate + // beats refusing to search; the alternative is a chooser UI the panel cannot host. + logger.debug("Reference at caret resolved to {} symbols; searching the first", it.size) + } + }.firstOrNull() as? KaDeclarationSymbol + } + }?.let { symbol -> + // A call through a subtype that does not redeclare the member resolves to a substituted fake + // override rather than to the declaration the user wrote. Normalise both sides of every + // comparison, starting here. + (symbol as? KaCallableSymbol)?.fakeOverrideOriginal ?: symbol + } + +/** + * The declarations a reference may resolve to and still count as a usage of [symbol] (R3). + * + * Two edges are added to the target itself: + * - **Workspace-source supers.** A call dispatched through `Base.foo` may reach `Derived.foo`, so it + * counts as a usage of it. The walk stops at the workspace boundary: `Any.toString` in the match set + * would make a usage search on an overridden `toString` report every `.toString()` call in the + * workspace, and a library super can never contribute a reportable result anyway. + * - **A classifier's constructors.** `Foo()` resolves to a constructor, not to the class, so without + * this a search on `class Foo` would miss every instantiation. Not applied in reverse: a target that + * *is* one constructor stays that constructor, because asking for usages of one overload is a + * deliberate act. + */ +private fun KaSession.matchSet(symbol: KaDeclarationSymbol): List = + buildList { + add(symbol) + + if (symbol is KaCallableSymbol) { + addAll( + symbol.allOverriddenSymbols + .map { it.fakeOverrideOriginal } + .filter { it.sourcePsiSafe() != null }, + ) + } + + if (symbol is KaClassSymbol) { + addAll(symbol.declaredMemberScope.constructors) + } + } + +/** + * The simple name to prefilter candidate files on, or null when there is none to search by. + * + * A constructor is written as its class's name, never as its own, so prefiltering on the symbol's own + * name would match nothing. + */ +private fun KaSession.prefilterName(symbol: KaDeclarationSymbol): String? { + val named = + if (symbol is KaConstructorSymbol) { + symbol.containingDeclaration as? KaNamedSymbol + } else { + symbol as? KaNamedSymbol + } + + return named?.name?.asString()?.takeUnless { it.isEmpty() } +} + +/** + * [symbol]'s search scope, per R4's visibility ladder. + * + * [matchSet] widens the module case: see the dependents comment below. + */ +context(env: AbstractCompilationEnvironment) +private fun KaSession.scopeOf( + symbol: KaDeclarationSymbol, + declaration: PsiElement, + declarationPath: Path?, + matchSet: List, +): UsageSearchScope { + val fileOnly = declarationPath?.let(UsageSearchScope::SingleFile) + + // A local is confined to its declaring block, and a private declaration to its file: Kotlin's + // private top-level is file-private, and a private member cannot escape the class body it is + // written in. Both are the cheap, exact cases. + val fileConfined = + symbol.location == KaSymbolLocation.LOCAL || symbol.visibility == KaSymbolVisibility.PRIVATE + if (fileOnly != null && fileConfined) { + return fileOnly + } + + val module = moduleOf(declaration) ?: return fileOnly ?: UsageSearchScope.Modules(sourceModules()) + + // internal is module-wide, and there is no associated test module to widen to: this project model + // builds one module per Gradle module from the main source set only. A file-confined target with no + // derivable path lands here too - it cannot be narrowed to one file, but it is still unreferenceable + // outside its own module, so it must not fall through to the dependents below. + if (fileConfined || symbol.visibility == KaSymbolVisibility.INTERNAL) { + return UsageSearchScope.Modules(listOf(module)) + } + + // Anything more visible can be referenced from any module that depends on this one. Dependents, + // not all modules: a module that cannot see the declaration cannot reference it. + // + // Every match-set member contributes its own module and dependents, not just the target's. A call + // written against a workspace `Base.foo` declared in a *dependency* module is a usage of the + // override (R3), and that module is not a dependent of the override's own - so scoping to the + // target's dependents alone would never look at it. + val provider = KotlinModuleDependentsProvider.getInstance(env.project) + val roots = LinkedHashSet() + roots.add(module) + for (member in matchSet) { + val memberDeclaration = member.sourcePsiSafe() ?: continue + moduleOf(memberDeclaration)?.let(roots::add) + } + + val searched = LinkedHashSet() + for (root in roots) { + searched.add(root) + provider.getTransitiveDependents(root).filterIsInstanceTo(searched) + } + + return UsageSearchScope.Modules(searched.toList()) +} + +context(env: AbstractCompilationEnvironment) +private fun moduleOf(declaration: PsiElement): KtModule? = + runCatching { + ProjectStructureProvider.getInstance(env.project).getModule(declaration, useSiteModule = null) as? KtModule + }.getOrNull() + +context(env: AbstractCompilationEnvironment) +private fun sourceModules(): List = + env.modules + .asFlatSequence() + .filter { it.isSourceModule } + .toList() + +/** + * The files worth parsing and resolving for [plan]. + * + * The prefilter is a one-directional over-approximation: a file that mentions the name but contains no + * usage is parsed and discarded, while a file that does not mention it cannot contain a named usage. + * [mentionsName] reads an open file's live editor buffer rather than its saved bytes, so a usage typed + * but not yet saved is still found - which matters here, because find usages is run *while* editing. + */ +context(env: AbstractCompilationEnvironment) +internal fun candidateFiles( + plan: SearchPlan, + cancelChecker: ICancelChecker, +): List = + when (val scope = plan.scope) { + // The declaration's own file always contains its name, so there is nothing to filter. + is UsageSearchScope.SingleFile -> { + listOf(scope.path) + } + + is UsageSearchScope.Modules -> { + scope.modules + .asSequence() + .filter { it.isSourceModule } + .flatMap { it.computeFiles(extended = true) } + // A source module's files are .kt *and* .java, and `ktFileFor` rejects a non-Kotlin path + // anyway (searching .java is a non-goal). Dropping them here, on the extension alone, + // stops a Java-heavy workspace spending most of the prefilter's I/O - the part the user + // waits on - reading files whose result is already known to be nothing. The extensions + // mirror `DocumentUtils.isKotlinFile`, which is what decides it downstream. + .filter { it.extension == "kt" || it.extension == "kts" } + .mapNotNull { runCatching { it.toNioPath() }.getOrNull() } + .distinct() + .filter { + // Checked per file: a whole-workspace scan is seconds of I/O, and cancelling must stop it + // rather than let it run to completion and then discard the result. + cancelChecker.abortIfCancelled() + mentionsName(it, plan.simpleName) + }.toList() + } + } + +/** + * Whether the file at [path] writes [name] as a whole word. + * + * Read line by line through [FileManager] rather than through `StringSearch.containsWord`: that helper + * scans only a file's first megabyte, so a usage below the mark is silently dropped, it does so through + * one process-global `ByteBuffer` the Java LSP mutates concurrently from its own threads, and it rethrows + * an unreadable file as a `RuntimeException` - which here would abort the whole search rather than skip + * one file. [FileManager] keeps the property that matters: an open file is matched against its live + * editor buffer. A name cannot span a line break, so matching per line is exact. + */ +private fun mentionsName( + path: Path, + name: String, +): Boolean = + try { + FileManager.getReader(path).use { reader -> + reader.lineSequence().any { it.containsWord(name) } + } + } catch (e: IOException) { + // One unreadable file must not lose the whole result. + logger.debug("Usage search could not prefilter candidate {}", path, e) + false + } + +/** Whether this line contains [name] bounded by non-identifier characters on both sides. */ +private fun String.containsWord(name: String): Boolean { + var at = indexOf(name) + while (at >= 0) { + val before = at - 1 + val after = at + name.length + if ((before < 0 || !this[before].isIdentifierChar()) && + (after >= length || !this[after].isIdentifierChar()) + ) { + return true + } + at = indexOf(name, at + 1) + } + return false +} + +private fun Char.isIdentifierChar(): Boolean = isLetterOrDigit() || this == '_' || this == '$' + +/** + * Every usage of [plan]'s target in the file at [path]. + * + * One analysis session per file, so a preemption costs this file rather than the whole search, and the + * live-PSI await stays outside `project.read` (R9). + */ +context(env: AbstractCompilationEnvironment) +private suspend fun usagesIn( + path: Path, + plan: SearchPlan, + delegate: ICancelChecker, +): List = + try { + retryingOnPreemption(delegate, "Usage search in $path") { cancelChecker -> + val ktFile = ktFileFor(path) + if (ktFile == null) { + logger.debug("Skipping candidate {}: no PSI", path) + emptyList() + } else { + env.project.read { + // The name filter is pure PSI, so it runs before the analysis session opens. A text + // prefilter hit whose only mention is a comment or a string literal must not cost an + // analysis-lock acquisition, a FIR session and a match-set restore to rule out - and on a + // short, common name most candidates are exactly that. + val named = namedReferences(ktFile, plan.simpleName, cancelChecker) + if (named.isEmpty()) { + emptyList() + } else { + analyzeMaybeDangling(ktFile, AnalysisPriority.COMMAND, cancelChecker) { + matchingReferences(named, plan, ktFile, path, cancelChecker) + } + } + } + } + } + } catch (e: AnalysisPreemptedException) { + // A preemption that outlived retryingOnPreemption's single retry is keystroke-driven work winning + // the lock, not the user cancelling. Rethrowing it would discard every location collected so far + // and report "no references" for a symbol with plenty, so it costs this file like any other + // failure. Genuine cancellation still propagates below (R12). + logger.debug("Usage search gave up on candidate {}: preempted twice", path) + emptyList() + } catch (e: Throwable) { + if (e.isAnalysisCancellation()) throw e + // One unresolvable file must not lose the whole result. + logger.debug("Usage search skipped candidate {}", path, e) + emptyList() + } + +/** + * PSI for a candidate file: refreshed to the live editor buffer when the file is open, the indexed + * on-disk instance otherwise. + * + * The open case must be awaited here, outside `project.read`, because the refresh it waits on needs + * `project.write`. `getKtFile` cannot do it - it runs under `project.read` inside Analysis API + * services, so it only ever peeks the live cache. + */ +context(env: AbstractCompilationEnvironment) +private suspend fun ktFileFor(path: Path): KtFile? = + if (FileManager.isActive(path)) { + env.ktSymbolIndex.getCurrentKtFile(path).await() + } else { + env.ktSymbolIndex.getKtFile(path) + } + +/** + * The simple-name references in [ktFile] written as [simpleName]. + * + * PSI alone, so it can rule a candidate file out before any analysis session is opened. It is also what + * implements "convention references are not discovered": `a + b` contains no `plus` token, so it is never + * a candidate. + * + * Filters during the walk rather than collecting every [KtSimpleNameExpression] and filtering after: on + * the case the text prefilter is worst at - a short, common name in a large file - the intermediate list + * is the bulk of the allocation, and the walk is long enough to need a cancellation checkpoint of its own. + */ +private fun namedReferences( + ktFile: KtFile, + simpleName: String, + cancelChecker: ICancelChecker, +): List { + val found = mutableListOf() + + ktFile.accept( + object : PsiRecursiveElementWalkingVisitor() { + override fun visitElement(element: PsiElement) { + cancelChecker.abortIfCancelled() + if (element is KtSimpleNameExpression && element.getReferencedName() == simpleName) { + found.add(element) + } + super.visitElement(element) + } + }, + ) + + return found +} + +/** + * The [references] that resolve into [plan]'s match set. + * + * Match-set pointers are restored **once** for this session; [KaSymbol] equality within a single + * session compares the underlying FIR symbol, so it is the right comparison once both sides come from + * the same session (R6). + */ +private fun KaSession.matchingReferences( + references: List, + plan: SearchPlan, + ktFile: KtFile, + path: Path, + cancelChecker: ICancelChecker, +): List { + val targets = plan.matchSet.mapNotNull { it.restoreSymbol() } + if (targets.isEmpty()) { + // Under-reporting beats reporting something false, so a pointer that will not restore drops this + // file rather than falling back to a looser comparison. + logger.debug("No match-set symbol restored in {}; skipping", path) + return emptyList() + } + + return references.mapNotNull { reference -> + cancelChecker.abortIfCancelled() + if (resolvesInto(reference, targets)) locationOf(reference, ktFile, path) else null + } +} + +/** Whether [reference] resolves to one of [targets]. */ +private fun KaSession.resolvesInto( + reference: KtSimpleNameExpression, + targets: List, +): Boolean = + runCatching { + reference.mainReference + .resolveToSymbols() + .asSequence() + .map { (it as? KaCallableSymbol)?.fakeOverrideOriginal ?: it } + .any { resolved -> targets.any { it == resolved } } + }.getOrElse { + if (it.isAnalysisCancellation()) throw it + logger.debug("Could not resolve '{}'", reference.text, it) + false + } + +/** [reference]'s name range as an editor [Location], or null when the file has no document. */ +private fun locationOf( + reference: KtSimpleNameExpression, + ktFile: KtFile, + path: Path, +): Location? { + val range = rangeOf(reference.getReferencedNameElement(), ktFile) + if (range == Range.NONE) { + logger.debug("No document for {}; dropping usage", path) + return null + } + return Location(path, range) +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/navigation/GoToDefinition.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/navigation/GoToDefinition.kt index 9380913215..da360c5536 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/navigation/GoToDefinition.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/navigation/GoToDefinition.kt @@ -3,10 +3,10 @@ package com.itsaky.androidide.lsp.kotlin.navigation import com.itsaky.androidide.lsp.kotlin.compiler.AbstractCompilationEnvironment import com.itsaky.androidide.lsp.kotlin.compiler.modules.AnalysisPreemptedException import com.itsaky.androidide.lsp.kotlin.compiler.modules.AnalysisPriority -import com.itsaky.androidide.lsp.kotlin.compiler.modules.ScheduledCancelChecker import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling import com.itsaky.androidide.lsp.kotlin.compiler.modules.backingFilePath import com.itsaky.androidide.lsp.kotlin.compiler.modules.isAnalysisCancellation +import com.itsaky.androidide.lsp.kotlin.compiler.modules.retryingOnPreemption import com.itsaky.androidide.lsp.kotlin.compiler.read import com.itsaky.androidide.lsp.kotlin.utils.rangeOf import com.itsaky.androidide.lsp.kotlin.utils.toRange @@ -78,8 +78,11 @@ private fun KaSession.resolvedLocations( * * Resolution over broken code throws, and a throw must read as "not found" rather than crash the * request, so both paths are guarded. + * + * Shared with find usages, which resolves the reference under the caret the same way before searching + * for what it names. */ -private fun KaSession.symbolsAt(element: KtElement): List = +internal fun KaSession.symbolsAt(element: KtElement): List = runCatching { element.mainReference ?.resolveToSymbols() @@ -197,47 +200,35 @@ internal suspend fun findDefinitionAt(params: DefinitionParams): DefinitionResul return try { val offset = params.position.requireIndex() - // Navigation is user-initiated: run at INTERACTIVE priority so it preempts background - // diagnostics/indexing and is discarded when a newer interactive request wins. - // params.cancelChecker is request-scoped (CancellableRequestParams), so wrap it directly. - // - // INTERACTIVE.supersedesSamePriority is true, so a concurrent completion/signature-help - // request can preempt this lookup even though the user's own request is still alive - unlike - // a genuine cancellation, that coroutine survives, so surfacing an empty result would be a lie - // ("Definition not found" for a reference that resolves fine). One retry, with a fresh - // checker, covers it without turning this into a retry loop. - suspend fun attempt(): List { - // Awaited per attempt, not once: whatever preempted the first attempt also refreshed the - // live PSI, unregistering the KtFile that attempt held, and analyzing it again would fail. - // - // Safe to await a (possibly blocking) refresh here: this runs outside any project.read/write - // block, so it can't deadlock against the refresh's project.write. Refreshed to the open - // document's current version, so the caret offset and the PSI it indexes into come from the - // same text - a stale snapshot points at the wrong element. (params.position is fixed by the - // request, so a retry after the user typed can still be one edit behind; that resolves to - // the wrong element or to nothing, never to a crash.) - val ktFile = env.ktSymbolIndex.getCurrentKtFile(params.file).await() - if (ktFile == null) { - logger.warn("File {} cannot be loaded for definition lookup", params.file) - return emptyList() - } - - val cancelChecker = ScheduledCancelChecker(params.cancelChecker) - cancelChecker.abortIfCancelled() - return env.project.read { - val element = referenceAtCaret(ktFile, offset) ?: return@read emptyList() - analyzeMaybeDangling(ktFile, AnalysisPriority.INTERACTIVE, cancelChecker) { - definitionLocations(element, cancelChecker) - } - } - } - + // Navigation is a user-invoked command: AnalysisPriority.COMMAND preempts background + // diagnostics/indexing but yields to keystroke-driven completion, and is never discarded by + // another command. It can still be preempted by INTERACTIVE, so it retries once (see + // retryingOnPreemption, and ADR 0011). params.cancelChecker is request-scoped + // (CancellableRequestParams), so it is the delegate the per-attempt checker wraps. val locations = - try { - attempt() - } catch (e: AnalysisPreemptedException) { - logger.debug("Definition lookup for {} preempted; retrying once", params.file) - attempt() + retryingOnPreemption(params.cancelChecker, "Definition lookup for ${params.file}") { cancelChecker -> + // Awaited per attempt, not once: whatever preempted the first attempt also refreshed the + // live PSI, unregistering the KtFile that attempt held, and analyzing it again would fail. + // + // Safe to await a (possibly blocking) refresh here: this runs outside any project.read/write + // block, so it can't deadlock against the refresh's project.write. Refreshed to the open + // document's current version, so the caret offset and the PSI it indexes into come from the + // same text - a stale snapshot points at the wrong element. (params.position is fixed by the + // request, so a retry after the user typed can still be one edit behind; that resolves to + // the wrong element or to nothing, never to a crash.) + val ktFile = env.ktSymbolIndex.getCurrentKtFile(params.file).await() + if (ktFile == null) { + logger.warn("File {} cannot be loaded for definition lookup", params.file) + emptyList() + } else { + cancelChecker.abortIfCancelled() + env.project.read { + val element = referenceAtCaret(ktFile, offset) ?: return@read emptyList() + analyzeMaybeDangling(ktFile, AnalysisPriority.COMMAND, cancelChecker) { + definitionLocations(element, cancelChecker) + } + } + } } logger.debug("Definition result for {}: {} location(s)", params.file, locations.size) DefinitionResult(locations) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/navigation/ReferenceAtCaret.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/navigation/ReferenceAtCaret.kt index b954a8d72f..755352688a 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/navigation/ReferenceAtCaret.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/navigation/ReferenceAtCaret.kt @@ -75,7 +75,12 @@ internal fun referenceAtCaret( return null } -private fun navigableLeafAt( +/** + * The leaf token at [offset] if a caret there could name something, else null. Shared with + * [targetAtCaret], which applies the same accept-list before asking whether the leaf is a + * declaration's own name. + */ +internal fun navigableLeafAt( file: KtFile, offset: Int, ): PsiElement? { diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/navigation/TargetAtCaret.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/navigation/TargetAtCaret.kt new file mode 100644 index 0000000000..3d7e2e2179 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/navigation/TargetAtCaret.kt @@ -0,0 +1,86 @@ +package com.itsaky.androidide.lsp.kotlin.navigation + +import org.jetbrains.kotlin.com.intellij.psi.PsiElement +import org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.psi.KtElement +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtNamedDeclaration +import org.slf4j.LoggerFactory + +private val logger = LoggerFactory.getLogger("TargetAtCaret") + +/** + * What a caret names, for a feature that starts *from* a declaration rather than navigating to one. + * + * Find usages can be invoked from either end - on the declaration itself, or on any reference to it - + * and the two need different resolution, so the distinction is made once here rather than re-derived + * by a type test later. + */ +internal sealed interface CaretTarget { + /** The caret sits on [declaration]'s own name identifier. Its symbol is the search target. */ + data class Declaration( + val declaration: KtNamedDeclaration, + ) : CaretTarget + + /** The caret sits on a reference. Resolving [element] yields the search target. */ + data class Reference( + val element: KtElement, + ) : CaretTarget +} + +/** + * What the caret at [offset] in [file] names, or null when it names nothing. + * + * Declaration-first: a caret on a declaration's own name targets *that declaration*, and only a caret + * that names nothing declarable is interpreted as a reference. The order is observable for a + * destructuring entry, which is both at once - `x` in `val (x, y) = p` targets the local `x` here, + * while go-to-definition navigates from the same caret to `component1`. + * + * Callers must hold the project read lock. Pure PSI: no analysis session is needed or used. + */ +internal fun targetAtCaret( + file: KtFile, + offset: Int, +): CaretTarget? { + declarationAtCaret(file, offset)?.let { return CaretTarget.Declaration(it) } + + // Not a declaration's name, so fall back to go-to-definition's reference lookup, which repeats the + // leaf lookup above. One extra findElementAt is worth leaving that helper's contract untouched: + // it must keep returning null for a declaration's own name, which is the caret we just handled. + return referenceAtCaret(file, offset)?.let(CaretTarget::Reference)?.also { + logger.debug("Caret at {} in {} names a reference", offset, file.name) + } +} + +/** + * The declaration whose own name the caret at [offset] sits on, or null. + * + * Both candidate leaves are tried, not just the first navigable one. `referenceAtCaret` can stop at + * the first, because it retries only when the primary leaf names nothing at all; here the primary + * leaf can be navigable in its own right and still not be a name - a caret just past `fun target` + * lands on `(`, which is navigable for the invoke convention. Checking only that leaf would make a + * caret one character past a declaration's name find nothing. + */ +private fun declarationAtCaret( + file: KtFile, + offset: Int, +): KtNamedDeclaration? = + ( + declarationNamedBy(navigableLeafAt(file, offset)) + ?: declarationNamedBy(navigableLeafAt(file, (offset - 1).coerceAtLeast(0))) + )?.also { + logger.debug("Caret at {} in {} names declaration '{}'", offset, file.name, it.name) + } + +/** + * The declaration [leaf] is the name identifier of, or null. + * + * The identity check is what makes this precise: every caret has some enclosing declaration - a call + * site's nearest one is the function containing it - so proximity alone would target the container + * for every reference in the file. + */ +private fun declarationNamedBy(leaf: PsiElement?): KtNamedDeclaration? { + leaf ?: return null + val declaration = PsiTreeUtil.getParentOfType(leaf, KtNamedDeclaration::class.java) ?: return null + return declaration.takeIf { it.nameIdentifier === leaf } +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodSheet.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodSheet.kt new file mode 100644 index 0000000000..ef72fe6a78 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodSheet.kt @@ -0,0 +1,96 @@ +package com.itsaky.androidide.lsp.kotlin.refactor.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.itsaky.androidide.common.compose.IdeTheme +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlan + +/** + * Hosts [ExtractMethodSheetContent]. + * + * The plan is handed in directly rather than through fragment arguments: it carries the file's text + * and offset spans, which is neither `Parcelable` nor meaningful to restore -- after process death + * the document may be entirely different. So [plan] is null on a recreated instance and the sheet + * dismisses itself, the same outcome the action's document-version guard would reach anyway. + */ +class ExtractMethodSheet : BottomSheetDialogFragment() { + private var plan: ExtractMethodPlan? = null + private var onChoice: ((ExtractMethodChoice) -> Unit)? = null + + private val viewModel: ExtractMethodViewModel by viewModels { + ExtractMethodViewModel.factory(requireNotNull(plan) { "sheet shown without a plan" }) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { + if (plan == null) { + dismissAllowingStateLoss() + return null + } + + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + IdeTheme { + val state by viewModel.uiState.collectAsStateWithLifecycle() + ExtractMethodSheetContent( + state = state, + onEvent = ::handleEvent, + ) + } + } + } + } + + private fun handleEvent(event: ExtractMethodUiEvent) { + when (event) { + ExtractMethodUiEvent.Confirmed -> { + viewModel.choice()?.let { choice -> onChoice?.invoke(choice) } + dismiss() + } + + ExtractMethodUiEvent.Dismissed -> { + dismiss() + } + + else -> { + viewModel.onEvent(event) + } + } + } + + companion object { + private const val TAG = "extract_method_sheet" + + /** + * Shows the sheet on [activity], calling [onChoice] once if the user confirms. Returns false + * when it could not be shown, so the caller can report a failure rather than doing nothing. + */ + fun show( + activity: FragmentActivity, + plan: ExtractMethodPlan, + onChoice: (ExtractMethodChoice) -> Unit, + ): Boolean { + val manager = activity.supportFragmentManager + if (manager.isStateSaved || manager.isDestroyed) return false + ExtractMethodSheet() + .apply { + this.plan = plan + this.onChoice = onChoice + }.show(manager, TAG) + return true + } + } +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodSheetContent.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodSheetContent.kt new file mode 100644 index 0000000000..cf0e3cdf92 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodSheetContent.kt @@ -0,0 +1,94 @@ +package com.itsaky.androidide.lsp.kotlin.refactor.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.itsaky.androidide.resources.R + +/** + * The extract-method sheet: the expression chooser (when there is a choice), the name, and the + * signature exactly as it will be emitted. + * + * A sibling of the extract-variable sheet rather than a generalisation of it: a single shared sheet + * would need a state class where half the fields are meaningless to either caller (ADR 0012). + * + * Stateless: all state arrives in [state] and every interaction leaves as an [ExtractMethodUiEvent]. + */ +@Composable +fun ExtractMethodSheetContent( + state: ExtractMethodUiState, + onEvent: (ExtractMethodUiEvent) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.title_extract_method), + style = MaterialTheme.typography.titleLarge, + ) + + if (state.showCandidatePicker) { + LabelledSection(stringResource(R.string.label_extract_variable_expression)) { + OptionList( + options = state.candidateLabels, + selected = state.selectedCandidate, + monospace = true, + onSelect = { onEvent(ExtractMethodUiEvent.CandidateSelected(it)) }, + ) + } + } + + OutlinedTextField( + value = state.name, + onValueChange = { onEvent(ExtractMethodUiEvent.NameChanged(it)) }, + label = { Text(stringResource(R.string.label_extract_variable_name)) }, + isError = state.nameProblem != null, + singleLine = true, + supportingText = state.nameProblem?.let { problem -> { Text(stringResource(problem.messageRes())) } }, + modifier = Modifier.fillMaxWidth(), + ) + + LabelledSection(stringResource(R.string.label_extract_method_signature)) { + Text( + text = state.signaturePreview, + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + modifier = Modifier.fillMaxWidth(), + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = { onEvent(ExtractMethodUiEvent.Dismissed) }) { + Text(stringResource(android.R.string.cancel)) + } + Button( + onClick = { onEvent(ExtractMethodUiEvent.Confirmed) }, + enabled = state.canConfirm, + modifier = Modifier.padding(start = 8.dp), + ) { + Text(stringResource(R.string.action_extract)) + } + } + } +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodUiState.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodUiState.kt new file mode 100644 index 0000000000..82bf60186f --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodUiState.kt @@ -0,0 +1,50 @@ +package com.itsaky.androidide.lsp.kotlin.refactor.ui + +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodCandidate +import com.itsaky.androidide.lsp.kotlin.utils.refactor.NameProblem + +/** + * Everything the extract-method sheet renders. + * + * There is no scope chooser (the new function is always a sibling of the enclosing declaration) and + * no replace-all checkbox (the region is the only site rewritten), so the sheet is a chooser, a name + * field and a preview. + * + * [signaturePreview] is the signature exactly as it will be emitted -- the one derived artefact, and + * the one place the derivation can surprise the user. The body is the code they selected and can see + * behind the sheet, so previewing it says nothing new. + */ +data class ExtractMethodUiState( + val candidateLabels: List, + val selectedCandidate: Int, + val showCandidatePicker: Boolean, + val name: String, + val nameProblem: NameProblem?, + val signaturePreview: String, +) { + val canConfirm: Boolean get() = nameProblem == null +} + +/** What the sheet reports back up; the ViewModel never touches the document itself. */ +sealed interface ExtractMethodUiEvent { + data class CandidateSelected( + val index: Int, + ) : ExtractMethodUiEvent + + data class NameChanged( + val name: String, + ) : ExtractMethodUiEvent + + data object Confirmed : ExtractMethodUiEvent + + data object Dismissed : ExtractMethodUiEvent +} + +/** + * The user's finished decision, handed to the action to turn into edits. Free of offsets and text so + * the sheet stays a pure chooser. + */ +data class ExtractMethodChoice( + val candidate: ExtractMethodCandidate, + val name: String, +) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodViewModel.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodViewModel.kt new file mode 100644 index 0000000000..b1e0ecfc68 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodViewModel.kt @@ -0,0 +1,80 @@ +package com.itsaky.androidide.lsp.kotlin.refactor.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlan +import com.itsaky.androidide.lsp.kotlin.utils.refactor.signatureText +import com.itsaky.androidide.lsp.kotlin.utils.refactor.validateVariableName +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Derives the sheet's state from an [ExtractMethodPlan] and nothing else -- no analysis, no PSI, no + * I/O -- which is what lets it hold all the sheet's logic and still be a plain unit test. + * + * A plain [ViewModelProvider.Factory] rather than a Koin definition, for the same reason as + * `ExtractVariableViewModel`: sheet-scoped, injects nothing, takes the plan as a runtime argument. + */ +class ExtractMethodViewModel( + private val plan: ExtractMethodPlan, +) : ViewModel() { + private val _uiState = MutableStateFlow(stateFor(candidateIndex = 0, name = null)) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onEvent(event: ExtractMethodUiEvent) { + val current = _uiState.value + when (event) { + is ExtractMethodUiEvent.CandidateSelected -> { + if (event.index == current.selectedCandidate) return + // A different expression means a different signature and suggested name, so the name is + // re-suggested rather than carried over -- the old one described the old expression. + _uiState.value = stateFor(event.index, name = null) + } + + is ExtractMethodUiEvent.NameChanged -> { + _uiState.value = stateFor(current.selectedCandidate, name = event.name) + } + + ExtractMethodUiEvent.Confirmed, ExtractMethodUiEvent.Dismissed -> { + Unit + } + } + } + + /** The user's decision, or null when the name is unusable. */ + fun choice(): ExtractMethodChoice? { + val state = _uiState.value + if (!state.canConfirm) return null + return ExtractMethodChoice(candidate(state.selectedCandidate), state.name) + } + + private fun candidate(index: Int) = plan.candidates[index.coerceIn(plan.candidates.indices)] + + private fun stateFor( + candidateIndex: Int, + name: String?, + ): ExtractMethodUiState { + val bounded = candidateIndex.coerceIn(plan.candidates.indices) + val candidate = candidate(bounded) + val resolvedName = name ?: candidate.suggestedName + + return ExtractMethodUiState( + candidateLabels = plan.candidates.map { it.label }, + selectedCandidate = bounded, + showCandidatePicker = plan.candidates.size > 1, + name = resolvedName, + nameProblem = validateVariableName(resolvedName, candidate.takenNames), + // The same call the edit builder makes, so the preview cannot drift from the declaration. + signaturePreview = candidate.signatureText(resolvedName), + ) + } + + companion object { + fun factory(plan: ExtractMethodPlan): ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = ExtractMethodViewModel(plan) as T + } + } +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableSheet.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableSheet.kt new file mode 100644 index 0000000000..17ffdf7dba --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableSheet.kt @@ -0,0 +1,117 @@ +package com.itsaky.androidide.lsp.kotlin.refactor.ui + +import android.content.Context +import android.content.ContextWrapper +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.itsaky.androidide.common.compose.IdeTheme +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractionPlan + +/** + * Hosts [ExtractVariableSheetContent]. + * + * The plan is handed in directly rather than through fragment arguments: it carries the file's text and + * offset spans, which is neither `Parcelable` nor meaningful to restore -- after process death the + * document may be entirely different. So [plan] is null on a recreated instance and the sheet dismisses + * itself, which is the same outcome the action's document-version guard would reach anyway. + */ +class ExtractVariableSheet : BottomSheetDialogFragment() { + private var plan: ExtractionPlan? = null + private var onChoice: ((ExtractionChoice) -> Unit)? = null + + private val viewModel: ExtractVariableViewModel by viewModels { + ExtractVariableViewModel.factory(requireNotNull(plan) { "sheet shown without a plan" }) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { + if (plan == null) { + dismissAllowingStateLoss() + return null + } + + return ComposeView(requireContext()).apply { + // The sheet's window is torn down with the fragment's view, so dispose with it. + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + IdeTheme { + val state by viewModel.uiState.collectAsStateWithLifecycle() + ExtractVariableSheetContent( + state = state, + onEvent = ::handleEvent, + ) + } + } + } + } + + private fun handleEvent(event: ExtractVariableUiEvent) { + when (event) { + ExtractVariableUiEvent.Confirmed -> { + viewModel.choice()?.let { choice -> onChoice?.invoke(choice) } + dismiss() + } + + ExtractVariableUiEvent.Dismissed -> { + dismiss() + } + + else -> { + viewModel.onEvent(event) + } + } + } + + companion object { + private const val TAG = "extract_variable_sheet" + + /** + * Shows the sheet on [activity], calling [onChoice] once if the user confirms. + * + * Returns false when the sheet could not be shown, so the caller can report a failure rather + * than silently doing nothing. + */ + fun show( + activity: FragmentActivity, + plan: ExtractionPlan, + onChoice: (ExtractionChoice) -> Unit, + ): Boolean { + val manager = activity.supportFragmentManager + if (manager.isStateSaved || manager.isDestroyed) return false + ExtractVariableSheet() + .apply { + this.plan = plan + this.onChoice = onChoice + }.show(manager, TAG) + return true + } + } +} + +/** + * Finds the [FragmentActivity] hosting this context by unwrapping the [ContextWrapper] chain. + * + * A view inflated into an activity reports that activity as its context, but a theme overlay wraps it, + * so a direct cast is not reliable. `ActionData` carries only the editor's `Context`, and adding a + * `FragmentActivity` key would only move the same unwrapping one module upstream, into `editor`. + */ +fun Context.findFragmentActivity(): FragmentActivity? { + var context: Context? = this + while (context != null) { + if (context is FragmentActivity) return context + context = (context as? ContextWrapper)?.baseContext + } + return null +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableSheetContent.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableSheetContent.kt new file mode 100644 index 0000000000..5d193ac223 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableSheetContent.kt @@ -0,0 +1,131 @@ +package com.itsaky.androidide.lsp.kotlin.refactor.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.itsaky.androidide.resources.R + +/** + * The extract-variable sheet: one surface holding every choice, with no navigation between steps. + * + * Expression, name, scope and replace-all are interdependent -- picking a different expression changes + * the scope list and the occurrence count -- so they are shown together, where that relationship is + * visible, rather than across sequential dialogs the user would have to back out of to explore. + * + * Stateless: all state arrives in [state] and every interaction leaves as an [ExtractVariableUiEvent]. + */ +@Composable +fun ExtractVariableSheetContent( + state: ExtractVariableUiState, + onEvent: (ExtractVariableUiEvent) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.title_extract_variable), + style = MaterialTheme.typography.titleLarge, + ) + + if (state.showCandidatePicker) { + LabelledSection(stringResource(R.string.label_extract_variable_expression)) { + OptionList( + options = state.candidateLabels, + selected = state.selectedCandidate, + monospace = true, + onSelect = { onEvent(ExtractVariableUiEvent.CandidateSelected(it)) }, + ) + } + } + + OutlinedTextField( + value = state.name, + onValueChange = { onEvent(ExtractVariableUiEvent.NameChanged(it)) }, + label = { Text(stringResource(R.string.label_extract_variable_name)) }, + isError = state.nameProblem != null, + singleLine = true, + supportingText = state.nameProblem?.let { problem -> { Text(stringResource(problem.messageRes())) } }, + modifier = Modifier.fillMaxWidth(), + ) + + if (state.showScopePicker) { + LabelledSection(stringResource(R.string.label_extract_variable_scope)) { + OptionList( + options = state.scopeLabels, + selected = state.selectedScope, + monospace = false, + onSelect = { onEvent(ExtractVariableUiEvent.ScopeSelected(it)) }, + ) + } + } + + if (state.showReplaceAll) { + val replaceAllLabel = + pluralStringResource( + R.plurals.label_extract_variable_replace_all, + state.occurrenceCount, + state.occurrenceCount, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .toggleable( + value = state.replaceAll, + role = Role.Checkbox, + onValueChange = { onEvent(ExtractVariableUiEvent.ReplaceAllChanged(it)) }, + ), + ) { + Checkbox( + checked = state.replaceAll, + // Null so the row, not the box, is the single accessibility target. + onCheckedChange = null, + ) + Text( + text = replaceAllLabel, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = { onEvent(ExtractVariableUiEvent.Dismissed) }) { + Text(stringResource(android.R.string.cancel)) + } + Button( + onClick = { onEvent(ExtractVariableUiEvent.Confirmed) }, + enabled = state.canConfirm, + modifier = Modifier.padding(start = 8.dp), + ) { + Text(stringResource(R.string.action_extract)) + } + } + } +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableUiState.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableUiState.kt new file mode 100644 index 0000000000..7a54322405 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableUiState.kt @@ -0,0 +1,71 @@ +package com.itsaky.androidide.lsp.kotlin.refactor.ui + +import com.itsaky.androidide.lsp.kotlin.utils.refactor.CandidateExpression +import com.itsaky.androidide.lsp.kotlin.utils.refactor.NameProblem +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ScopeOption + +/** + * Everything the extract-variable sheet renders, derived entirely from the + * [com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractionPlan]. + * + * [showCandidatePicker] is false only when the plan holds a single candidate. It stays visible for an + * exact selection: long-press is the natural gesture and selects exactly one token, so hiding the list + * there leaves no way to widen to an enclosing expression short of cancelling and re-selecting. + * + * [occurrenceCount] counts every site the selected scope would rewrite, **including** the one the user + * selected, so "Replace all 3 occurrences" means three sites in total. [showReplaceAll] is false at a + * count of one, where the toggle would have nothing to do. + */ +data class ExtractVariableUiState( + val candidateLabels: List, + val selectedCandidate: Int, + val showCandidatePicker: Boolean, + val name: String, + val nameProblem: NameProblem?, + val scopeLabels: List, + val selectedScope: Int, + val occurrenceCount: Int, + val replaceAll: Boolean, +) { + val showReplaceAll: Boolean get() = occurrenceCount > 1 + + val showScopePicker: Boolean get() = scopeLabels.size > 1 + + val canConfirm: Boolean get() = nameProblem == null +} + +/** What the sheet reports back up; the ViewModel never touches the document itself. */ +sealed interface ExtractVariableUiEvent { + data class CandidateSelected( + val index: Int, + ) : ExtractVariableUiEvent + + data class NameChanged( + val name: String, + ) : ExtractVariableUiEvent + + data class ScopeSelected( + val index: Int, + ) : ExtractVariableUiEvent + + data class ReplaceAllChanged( + val replaceAll: Boolean, + ) : ExtractVariableUiEvent + + data object Confirmed : ExtractVariableUiEvent + + data object Dismissed : ExtractVariableUiEvent +} + +/** + * The user's finished decision, handed to the action to turn into an edit. + * + * Kept free of offsets and text so the sheet stays a pure chooser: resolving this into a rewrite, and + * checking the document has not moved on, both belong to the action. + */ +data class ExtractionChoice( + val candidate: CandidateExpression, + val scope: ScopeOption, + val name: String, + val replaceAll: Boolean, +) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModel.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModel.kt new file mode 100644 index 0000000000..d646c21d5c --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModel.kt @@ -0,0 +1,118 @@ +package com.itsaky.androidide.lsp.kotlin.refactor.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractionPlan +import com.itsaky.androidide.lsp.kotlin.utils.refactor.validateVariableName +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Derives the sheet's state from an [ExtractionPlan] and nothing else. + * + * The plan already contains every candidate's scope chain and occurrence set, so switching expression + * or scope is pure recomputation -- no analysis, no PSI, no I/O. That is what lets this class hold all + * the sheet's logic while remaining a plain unit test. + * + * A plain [ViewModelProvider.Factory] rather than a Koin definition (ADR 0006/0009 resolve ViewModels + * through Koin): this one is sheet-scoped, injects nothing, and takes the plan as a runtime argument, + * so a Koin definition would add indirection without providing anything. + */ +class ExtractVariableViewModel( + private val plan: ExtractionPlan, +) : ViewModel() { + private val _uiState = MutableStateFlow(initialState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private fun initialState(): ExtractVariableUiState = stateFor(candidateIndex = 0, scopeIndex = 0, replaceAll = false, name = null) + + fun onEvent(event: ExtractVariableUiEvent) { + val current = _uiState.value + when (event) { + is ExtractVariableUiEvent.CandidateSelected -> { + if (event.index == current.selectedCandidate) return + // A different expression means a different suggested name, scope chain and count, so the + // name is re-suggested rather than carried over -- the old one described the old expression. + _uiState.value = stateFor(event.index, scopeIndex = 0, replaceAll = false, name = null) + } + + is ExtractVariableUiEvent.ScopeSelected -> { + if (event.index == current.selectedScope) return + _uiState.value = + stateFor(current.selectedCandidate, event.index, current.replaceAll, current.name) + } + + is ExtractVariableUiEvent.NameChanged -> { + _uiState.value = + current.copy( + name = event.name, + nameProblem = validateVariableName(event.name, candidate(current.selectedCandidate).takenNames), + ) + } + + is ExtractVariableUiEvent.ReplaceAllChanged -> { + _uiState.value = current.copy(replaceAll = event.replaceAll) + } + + ExtractVariableUiEvent.Confirmed, ExtractVariableUiEvent.Dismissed -> { + Unit + } + } + } + + /** The user's decision, or null when the name is unusable. */ + fun choice(): ExtractionChoice? { + val state = _uiState.value + if (!state.canConfirm) return null + val candidate = candidate(state.selectedCandidate) + val scope = candidate.scopes.getOrNull(state.selectedScope) ?: return null + return ExtractionChoice( + candidate = candidate, + scope = scope, + name = state.name, + // A single occurrence makes the toggle meaningless, and the sheet hides it; make sure a + // stale `true` from a previous candidate cannot leak into the choice. + replaceAll = state.replaceAll && state.occurrenceCount > 1, + ) + } + + private fun candidate(index: Int) = plan.candidates[index.coerceIn(plan.candidates.indices)] + + /** + * Recomputes the whole state for a (candidate, scope) pair. [name] carries the user's typed name + * across a scope change; pass null to take the candidate's suggestion. + */ + private fun stateFor( + candidateIndex: Int, + scopeIndex: Int, + replaceAll: Boolean, + name: String?, + ): ExtractVariableUiState { + val candidate = candidate(candidateIndex) + val boundedScope = scopeIndex.coerceIn(candidate.scopes.indices) + val scope = candidate.scopes[boundedScope] + val resolvedName = name ?: candidate.suggestedName + val occurrenceCount = scope.occurrences.size + + return ExtractVariableUiState( + candidateLabels = plan.candidates.map { it.label }, + selectedCandidate = candidateIndex.coerceIn(plan.candidates.indices), + showCandidatePicker = plan.candidates.size > 1, + name = resolvedName, + nameProblem = validateVariableName(resolvedName, candidate.takenNames), + scopeLabels = candidate.scopes.map { it.label }, + selectedScope = boundedScope, + occurrenceCount = occurrenceCount, + replaceAll = replaceAll && occurrenceCount > 1, + ) + } + + companion object { + fun factory(plan: ExtractionPlan): ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = ExtractVariableViewModel(plan) as T + } + } +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/SheetComponents.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/SheetComponents.kt new file mode 100644 index 0000000000..6a746e4634 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/SheetComponents.kt @@ -0,0 +1,85 @@ +package com.itsaky.androidide.lsp.kotlin.refactor.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.itsaky.androidide.lsp.kotlin.utils.refactor.NameProblem +import com.itsaky.androidide.resources.R + +/** Shared by the extract-variable and extract-method sheets; neither owns them. */ +@Composable +internal fun LabelledSection( + label: String, + content: @Composable () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(text = label, style = MaterialTheme.typography.labelLarge) + content() + } +} + +/** A radio group. Expression text is monospaced so a candidate reads as the code it is. */ +@Composable +internal fun OptionList( + options: List, + selected: Int, + monospace: Boolean, + onSelect: (Int) -> Unit, +) { + Column( + modifier = Modifier.selectableGroup(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + options.forEachIndexed { index, option -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .selectable( + selected = index == selected, + role = Role.RadioButton, + onClick = { onSelect(index) }, + ), + ) { + RadioButton( + selected = index == selected, + onClick = null, + ) + + Text( + text = option, + style = + if (monospace) { + MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace) + } else { + MaterialTheme.typography.bodyMedium + }, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } +} + +/** The message shown under a name field for each way a name can be unusable. */ +internal fun NameProblem.messageRes(): Int = + when (this) { + NameProblem.Blank -> R.string.msg_extract_variable_name_blank + NameProblem.NotAnIdentifier -> R.string.msg_extract_variable_name_invalid + NameProblem.Keyword -> R.string.msg_extract_variable_name_keyword + NameProblem.AlreadyTaken -> R.string.msg_extract_variable_name_taken + } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/CandidateExpressions.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/CandidateExpressions.kt new file mode 100644 index 0000000000..8498e1d1a4 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/CandidateExpressions.kt @@ -0,0 +1,219 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import org.jetbrains.kotlin.com.intellij.psi.PsiElement +import org.jetbrains.kotlin.com.intellij.psi.PsiWhiteSpace +import org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.lexer.KtTokens +import org.jetbrains.kotlin.psi.KtAnnotationEntry +import org.jetbrains.kotlin.psi.KtAnonymousInitializer +import org.jetbrains.kotlin.psi.KtBinaryExpression +import org.jetbrains.kotlin.psi.KtBlockExpression +import org.jetbrains.kotlin.psi.KtBreakExpression +import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtConstantExpression +import org.jetbrains.kotlin.psi.KtContinueExpression +import org.jetbrains.kotlin.psi.KtDeclaration +import org.jetbrains.kotlin.psi.KtDeclarationWithBody +import org.jetbrains.kotlin.psi.KtExpression +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtFunctionLiteral +import org.jetbrains.kotlin.psi.KtLambdaExpression +import org.jetbrains.kotlin.psi.KtLiteralStringTemplateEntry +import org.jetbrains.kotlin.psi.KtLoopExpression +import org.jetbrains.kotlin.psi.KtOperationReferenceExpression +import org.jetbrains.kotlin.psi.KtParameter +import org.jetbrains.kotlin.psi.KtQualifiedExpression +import org.jetbrains.kotlin.psi.KtReturnExpression +import org.jetbrains.kotlin.psi.KtStringTemplateEntry +import org.jetbrains.kotlin.psi.KtStringTemplateExpression +import org.jetbrains.kotlin.psi.KtSuperExpression +import org.jetbrains.kotlin.psi.KtSuperTypeListEntry +import org.jetbrains.kotlin.psi.KtThrowExpression + +/** How many candidate expressions are ever offered. Keeps the chooser scannable on a phone. */ +const val MAX_CANDIDATES = 3 + +/** + * The purely syntactic result of resolving a cursor or selection to extraction targets. + * + * [expressions] is innermost-first and at most [MAX_CANDIDATES] long. + */ +data class CandidateSyntax( + val expressions: List, +) { + companion object { + val NONE = CandidateSyntax(emptyList()) + } +} + +/** + * Resolves `[selectionStart, selectionEnd)` in [file] to candidate expressions. A cursor is the + * degenerate case where the two offsets are equal, so callers need only one code path. + * + * The selection is whitespace-trimmed first, because a touch-screen selection routinely carries a + * leading or trailing space. From the resulting innermost element the parent chain is walked + * outwards, keeping legal targets ([isLegalExtractionTarget]) and stopping at the enclosing + * declaration. Blocks and other illegal nodes along the way are skipped rather than terminating the + * walk, so `if (c) a else b` is still offered from inside one of its branches. + * + * Returns [CandidateSyntax.NONE] when the position cannot host an extraction at all -- see + * [isExtractionPosition]. + */ +fun candidateExpressionsAt( + file: KtFile, + selectionStart: Int, + selectionEnd: Int, +): CandidateSyntax { + val text = file.text + val (start, end) = trimToCode(text, selectionStart, selectionEnd) ?: return CandidateSyntax.NONE + + val anchor = innermostElementFor(file, start, end) ?: return CandidateSyntax.NONE + if (!isExtractionPosition(anchor)) return CandidateSyntax.NONE + + val collected = mutableListOf() + val seen = mutableSetOf>() + var element: PsiElement? = anchor + while (element != null && element !is KtFile) { + if (element is KtDeclaration && element !is KtFunctionLiteral) break + if (element is KtExpression && element.isLegalExtractionTarget()) { + val range = element.textRange.startOffset to element.textRange.endOffset + if (seen.add(range)) { + collected += element + if (collected.size == MAX_CANDIDATES) break + } + } + element = element.parent + } + + if (collected.isEmpty()) return CandidateSyntax.NONE + return CandidateSyntax(collected) +} + +/** + * Trims whitespace off both ends of `[start, end)`. + * + * A selection holding nothing but whitespace collapses to a cursor at [start] rather than yielding + * nothing: a drag over the gap between two tokens carries the same intent as a tap in it, and the + * cursor path already resolves a position resting just past a token. Returns null only when the range + * is not a valid range into [text]. A cursor (start == end) is returned unchanged. + */ +internal fun trimToCode( + text: String, + start: Int, + end: Int, +): Pair? { + if (start < 0 || end > text.length || start > end) return null + if (start == end) return start to end + var s = start + var e = end + while (s < e && text[s].isWhitespace()) s++ + while (e > s && text[e - 1].isWhitespace()) e-- + return if (s == e) start to start else s to e +} + +/** + * The innermost element covering `[start, end)`. For a cursor, [KtFile.findElementAt] is tried at + * the offset and then just before it, so a caret sitting immediately after a token still resolves. + */ +private fun innermostElementFor( + file: KtFile, + start: Int, + end: Int, +): PsiElement? { + if (start == end) { + val at = file.findElementAt(start)?.takeUnless { it is PsiWhiteSpace } + val before = file.findElementAt((start - 1).coerceAtLeast(0))?.takeUnless { it is PsiWhiteSpace } + return at ?: before + } + val first = file.findElementAt(start) ?: return null + val last = file.findElementAt(end - 1) ?: return null + return PsiTreeUtil.findCommonParent(first, last) +} + +/** + * Whether [element] sits somewhere an extraction can legally be anchored. + * + * Rejects the positions where no `val` can precede the expression: + * - **annotation arguments** -- must be compile-time constants; + * - **default parameter values** -- evaluated per call, and a hoisted local would not be in scope; + * - **super-constructor delegation arguments** -- nothing can precede them; + * - **anything outside an executable body** -- notably a class-body property initializer, which has + * no block to insert into. Converting one to a getter would change compute-once into + * compute-per-access, so it is declined instead. + */ +internal fun isExtractionPosition(element: PsiElement): Boolean { + if (PsiTreeUtil.getParentOfType(element, KtAnnotationEntry::class.java, false) != null) return false + if (PsiTreeUtil.getParentOfType(element, KtSuperTypeListEntry::class.java, false) != null) return false + + val parameter = PsiTreeUtil.getParentOfType(element, KtParameter::class.java, false) + if (parameter != null && parameter.defaultValue?.isAncestorOf(element) == true) return false + + return enclosingExecutableBody(element) != null +} + +/** + * The nearest enclosing thing with a body that can hold statements: a lambda, a named or anonymous + * function, a property accessor, an `init` block, or a constructor. Null when [element] is not + * inside any of them. + */ +internal fun enclosingExecutableBody(element: PsiElement): PsiElement? { + var current: PsiElement? = element + while (current != null && current !is KtFile) { + if (current is KtFunctionLiteral) return current + if (current is KtDeclarationWithBody && current.bodyExpression?.isAncestorOf(element) == true) return current + if (current is KtAnonymousInitializer && current.body?.isAncestorOf(element) == true) return current + current = current.parent + } + return null +} + +private fun PsiElement.isAncestorOf(other: PsiElement): Boolean = PsiTreeUtil.isAncestor(this, other, false) + +/** + * Whether this expression is a thing whose value can be bound to a `val`. + * + * Excluded, and why: + * - blocks, loops, `return`/`throw`/`break`/`continue` -- no useful value to bind; + * - lambdas, literal and wrapper alike -- outside their call site the parameter types are gone; + * - operator tokens and call callees (`foo` in `foo(x)`) -- fragments, not expressions; + * - the selector of a qualified expression (`b` in `a.b`) -- only meaningful with its receiver; + * - the left side of an assignment -- a write target, not a value; + * - `super` -- not a value; + * - **bare literals** (`1`, `"text"`) -- extracting them is pointless, and excluding them removes + * the only case where omitting a type annotation could change meaning (an `Int` literal where a + * `Long` is expected, or a bare `null` inferring `Nothing?`). + */ +internal fun KtExpression.isLegalExtractionTarget(): Boolean { + if (this is KtBlockExpression) return false + if (this is KtLoopExpression) return false + if (this is KtReturnExpression || this is KtThrowExpression) return false + if (this is KtBreakExpression || this is KtContinueExpression) return false + if (this is KtOperationReferenceExpression) return false + if (this is KtSuperExpression) return false + if (this is KtFunctionLiteral) return false + // The wrapper around the literal. A hoisted lambda loses the parameter types its call site was + // supplying, so `{ it.length + 1 }` becomes uncompilable the moment it leaves the call. + if (this is KtLambdaExpression) return false + if (isBareLiteral()) return false + + val parent = parent + if (parent is KtQualifiedExpression && parent.selectorExpression === this) return false + if (parent is KtCallExpression && parent.calleeExpression === this) return false + if (parent is KtBinaryExpression && + parent.operationToken == KtTokens.EQ && + parent.left === this + ) { + return false + } + return true +} + +/** A numeric/boolean/char/null literal, or a string with no interpolation. */ +private fun KtExpression.isBareLiteral(): Boolean = + when (this) { + is KtConstantExpression -> true + is KtStringTemplateExpression -> entries.all { it.isLiteralEntry() } + else -> false + } + +private fun KtStringTemplateEntry.isLiteralEntry(): Boolean = this is KtLiteralStringTemplateEntry diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEdit.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEdit.kt new file mode 100644 index 0000000000..02dc61fe6c --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEdit.kt @@ -0,0 +1,116 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +/** + * The two replacements an extraction performs: the new function, and the call that replaces the + * region. + * + * **Descending document order is mandatory, not stylistic.** `IDELanguageClientImpl.applyActionEdits` + * iterates the list and applies each edit with line/column ranges against whatever the text is at + * that moment, so an earlier edit must never shift a later one. The result is therefore sorted by + * descending start offset rather than assuming which comes first: a member or top-level target is + * inserted *after* its anchor and leads the list, but a **local function must be declared before it + * is called**, so that insertion precedes the region and the call site leads instead. + * + * Nothing on that path calls `beginBatchEdit`, so this costs the user **two** undo steps and the + * intermediate state does not compile. ADFA-5081 fixes that by batching the edit loop; until it + * lands the two-step undo is a stated limitation. + * + * The region is the only site rewritten (R13). Exact-duplicate matching would almost never fire, and + * near-duplicate matching needs anti-unification plus a per-site parameter mapping. + * + * Returns null when the offsets cannot be honoured, which the caller reports rather than applying. + */ +fun buildExtractMethodRewrites( + fileText: String, + candidate: ExtractMethodCandidate, + name: String, +): List? { + val span = candidate.span + if (span.end > fileText.length) return null + if (candidate.insertOffset > fileText.length) return null + // Either side of the region is fine; inside it is incoherent -- the two edits would overlap. + if (candidate.insertOffset > span.start && candidate.insertOffset < span.end) return null + + val newline = detectNewline(fileText) + val indent = candidate.insertIndent + val bodyIndent = indent + detectIndentUnit(fileText) + val regionText = fileText.substring(span.start, span.end) + val baseIndent = leadingIndentAt(fileText, span.start) + + val lines = indentedBodyLines(regionText, span.start, baseIndent, bodyIndent, newline, candidate.rawStringSpans) + val bodyLines = + when (val body = candidate.body) { + is ExtractedBody.ExpressionBody -> { + // The first line is never inside a literal's interior -- the region starts at the code + // itself -- so it always carries bodyIndent and `return ` goes straight after it. + if (body.needsReturn) { + listOf(bodyIndent + "return " + lines.first().substring(bodyIndent.length)) + lines.drop(1) + } else { + lines + } + } + + is ExtractedBody.StatementBody -> { + lines + listOfNotNull(body.trailingReturn?.let { bodyIndent + it }) + } + } + + val declaration = + buildString { + append(indent).append(candidate.signatureText(name)).append(" {").append(newline) + bodyLines.forEach { append(it).append(newline) } + append(indent).append('}') + } + + // A blank line separates the new function from its neighbour either way. Inserting before the + // anchor starts at the anchor's own line, whose indentation is already in the file ahead of the + // insertion point -- so that first indent is dropped here and put back in front of the anchor. + val insertionText = + if (candidate.insertOffset <= span.start) { + declaration.removePrefix(indent) + newline + newline + indent + } else { + newline + newline + declaration + } + + val call = "$name(${candidate.parameters.joinToString(", ") { it.name }})" + val callText = + when (val form = candidate.callSite) { + CallSiteForm.Call -> call + is CallSiteForm.AssignOutput -> "val ${form.name} = $call" + CallSiteForm.Return -> "return $call" + } + + return listOf( + RewriteSpan(TextSpan(candidate.insertOffset, candidate.insertOffset), insertionText), + RewriteSpan(span, callText), + ).sortedByDescending { it.span.start } +} + +/** + * The region's lines at the new function's body indentation: the original base indentation removed and + * [bodyIndent] put in its place. Lines nested deeper than the base keep the extra depth; the first line + * only gains the indent, since the span starts at the code itself. + * + * A line inside one of [protectedSpans] is emitted byte-for-byte. Those are multi-line string literals, + * whose interior whitespace is part of their value, and whose closing delimiter sets `trimIndent`'s + * margin -- moving either edits the interior of the moved code (ADR 0013). + */ +private fun indentedBodyLines( + regionText: String, + regionStart: Int, + baseIndent: String, + bodyIndent: String, + newline: String, + protectedSpans: List, +): List { + var offset = regionStart + return regionText.split(newline).mapIndexed { index, line -> + val lineStart = offset + offset += line.length + newline.length + when { + index == 0 -> bodyIndent + line + protectedSpans.any { lineStart > it.start && lineStart < it.end } -> line + else -> bodyIndent + line.removePrefix(baseIndent) + } + } +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt new file mode 100644 index 0000000000..730215ff52 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt @@ -0,0 +1,198 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +/** One derived parameter of the new function. Names are the originals, unchanged (R5). */ +data class MethodParameter( + val name: String, + val typeText: String, +) + +/** What goes inside the new function's braces. */ +sealed interface ExtractedBody { + /** + * The region's expression text. [needsReturn] is false only for a `Unit`-valued expression, where + * the function returns `Unit` and a bare statement reads better than `return println(x)`. + */ + data class ExpressionBody( + val needsReturn: Boolean, + ) : ExtractedBody + + /** + * The statements verbatim. [trailingReturn] is the `return ` line appended for the + * single-output case, and null otherwise -- including the tail-return case, where the region + * already ends in a `return`. + */ + data class StatementBody( + val trailingReturn: String?, + ) : ExtractedBody +} + +/** How the region's own text is replaced (R6). */ +sealed interface CallSiteForm { + /** `extracted(args)` -- an expression in place, or a statement. */ + data object Call : CallSiteForm + + /** `val x = extracted(args)` for the single output [name]. */ + data class AssignOutput( + val name: String, + ) : CallSiteForm + + /** `return extracted(args)` for the tail-return case (R8). */ + data object Return : CallSiteForm +} + +/** + * One extractable region, fully derived: everything the sheet renders and the edit builder emits, + * with no PSI left in it. + * + * [span] is what the call site replaces. [insertOffset] is the end of the enclosing declaration -- + * the new function goes immediately after it (R4) -- and [insertIndent] is that declaration's own + * indentation, since nothing re-indents a code-action edit after it is applied. + * + * [returnTypeText] is null for a `Unit` function, where the `: Unit` is left off. + * + * [rawStringSpans] are the raw (triple-quoted) string literals inside the region, in file offsets. + * Their interior is whitespace-sensitive, so re-indentation must leave those lines byte-for-byte + * (ADR 0013). + */ +data class ExtractMethodCandidate( + val label: String, + val span: TextSpan, + val suggestedName: String, + val takenNames: Set, + val annotations: List, + val modifiers: List, + val receiverTypeText: String?, + val parameters: List, + val returnTypeText: String?, + val body: ExtractedBody, + val callSite: CallSiteForm, + val insertOffset: Int, + val insertIndent: String, + val rawStringSpans: List, +) + +/** + * Why a region could not be extracted. A refusal is a designed outcome, not an error (ADR 0013): + * each reason gets its own message naming the construct in the way, because a generic one reads as + * the feature being broken. + */ +sealed interface ExtractionRefusal { + /** The selection is neither one expression nor whole statements inside one block (R2). */ + data object NotASingleRegion : ExtractionRefusal + + /** + * The analysis could not run at all -- no compilation environment, no `KtFile`, or something threw. + * Deliberately neutral: the selection may have been perfectly good, so it must not be blamed the way + * [NotASingleRegion] blames it. + */ + data object CouldNotAnalyse : ExtractionRefusal + + /** + * The region declares two or more values the code after it still needs, and one return cannot carry + * them (R7). [names] is what is in the way, so the message can name them. + */ + data class MultipleOutputs( + val names: List, + ) : ExtractionRefusal + + /** + * The region declares exactly one thing the code after it still needs, but the call site cannot + * receive it back (R7): a destructuring entry or a local `fun`, which a `val` cannot stand in for, + * or a local the following code reassigns, which a `val` cannot be. + */ + data class OutputNotReturnable( + val name: String, + ) : ExtractionRefusal + + /** A `var` declared outside the region is assigned inside it. ADFA-5082 lifts this (R7). */ + data class ReassignsOuterVar( + val name: String, + ) : ExtractionRefusal + + /** A `return`, `break` or `continue` whose target is outside the region (R8). */ + data object ExitsRegion : ExtractionRefusal + + /** + * The region sits inside an anonymous extension function (R4). The new function is a sibling of the + * enclosing *named* declaration, so it would be generated on that declaration's receiver -- or on no + * receiver at all -- rather than on the one the region's body actually reads. + */ + data object AnonymousExtensionFunction : ExtractionRefusal + + /** Members of a `with`/`apply`/`run` receiver introduced inside the enclosing declaration (R9). */ + data class InnerImplicitReceiver( + val construct: String, + ) : ExtractionRefusal + + /** A type parameter declared on the enclosing function (R10). */ + data class UsesTypeParameter( + val name: String, + ) : ExtractionRefusal + + /** A parameter or return type that cannot be written out as source (R5). */ + data object UnrenderableType : ExtractionRefusal + + /** + * A property accessor's `field` (R4). The backing field is reachable only from inside the + * accessor, so the reference would move verbatim into the new function and stop resolving. + */ + data object UsesBackingField : ExtractionRefusal + + /** + * A captured value the region uses through a smart cast (R5). Its declared type does not compile + * in the new body and its narrowed type does not compile at the call site, so neither emission is + * faithful (ADR 0013). + */ + data class SmartCastParameter( + val name: String, + ) : ExtractionRefusal + + /** + * A local `fun`, class or object the region uses but does not contain (R5). It goes out of scope + * once the region moves, and only values can be handed over as parameters. + */ + data class CapturedLocalDeclaration( + val name: String, + ) : ExtractionRefusal +} + +/** + * The complete result of the background pass. + * + * Unlike extract variable's plan this carries a [refusal] rather than merely being empty, because + * "why not" is most of what this refactoring has to say (ADR 0013). [candidates] and [refusal] are + * mutually exclusive in practice: a non-empty candidate list means at least one region survived. + */ +data class ExtractMethodPlan( + override val fileText: String, + override val documentVersion: Int, + val candidates: List, + val refusal: ExtractionRefusal?, +) : RefactoringPlan { + val isEmpty: Boolean get() = candidates.isEmpty() + + companion object { + fun refused( + refusal: ExtractionRefusal, + fileText: String = "", + documentVersion: Int = -1, + ) = ExtractMethodPlan(fileText, documentVersion, emptyList(), refusal = refusal) + } +} + +/** + * The signature exactly as [buildExtractMethodRewrites] emits it. The sheet's preview calls this, so + * there is one derivation and the preview cannot drift from the declaration (R11). + */ +fun ExtractMethodCandidate.signatureText(name: String): String = + buildString { + annotations.forEach { append(it).append(' ') } + modifiers.forEach { append(it).append(' ') } + append("fun ") + receiverTypeText?.let { append(it).append('.') } + append(name) + append('(') + append(parameters.joinToString(", ") { "${it.name}: ${it.typeText}" }) + append(')') + returnTypeText?.let { append(": ").append(it) } + } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanner.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanner.kt new file mode 100644 index 0000000000..53a61777ed --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanner.kt @@ -0,0 +1,82 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import com.itsaky.androidide.lsp.kotlin.compiler.AbstractCompilationEnvironment +import com.itsaky.androidide.lsp.kotlin.compiler.modules.AnalysisPriority +import com.itsaky.androidide.lsp.kotlin.compiler.modules.ScheduledCancelChecker +import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling +import com.itsaky.androidide.lsp.kotlin.compiler.read +import org.slf4j.LoggerFactory +import java.nio.file.Path +import kotlin.coroutines.cancellation.CancellationException + +private val logger = LoggerFactory.getLogger("ExtractMethodPlanner") + +/** + * Computes the whole [ExtractMethodPlan] in one background analysis pass. + * + * The current `KtFile` is fetched *before* entering [read] -- blocking on `getCurrentKtFile(...).get()` + * inside `project.read` deadlocks. + * + * Anything thrown in this pipeline degrades to a refusal plus a log line: the action framework + * catches only `IllegalArgumentException` and this runs on a scope with no exception handler, so an + * uncaught throw would crash the app (R16). Cancellation is the exception -- it is re-thrown, since a + * cancelled action has no result to report and the coroutine machinery already handles it. + * + * Everything that is not "your selection is not one region" refuses with [ExtractionRefusal.CouldNotAnalyse]: + * blaming a selection that may have been fine is worse than saying nothing useful. + */ +internal fun buildExtractMethodPlan( + env: AbstractCompilationEnvironment, + nioPath: Path, + selectionStart: Int, + selectionEnd: Int, + documentVersion: Int, + cancelChecker: ScheduledCancelChecker, +): ExtractMethodPlan = + runCatching { + val ktFile = + env.ktSymbolIndex.getCurrentKtFile(nioPath).get() + ?: return ExtractMethodPlan.refused(ExtractionRefusal.CouldNotAnalyse) + + env.project.read { + val fileText = ktFile.text + val region = + resolveExtractionRegion(ktFile, selectionStart, selectionEnd) + ?: return@read ExtractMethodPlan.refused(ExtractionRefusal.NotASingleRegion, fileText, documentVersion) + + analyzeMaybeDangling(ktFile, AnalysisPriority.INTERACTIVE, cancelChecker) { + val results = + when (region) { + is ExtractionRegion.Expressions -> { + region.candidates.map { buildCandidate(listOf(it), isExpression = true, fileText = fileText) } + } + + is ExtractionRegion.Statements -> { + listOf(buildCandidate(region.statements, isExpression = false, fileText = fileText)) + } + } + + val candidates = results.filterIsInstance().map { it.candidate } + if (candidates.isEmpty()) { + // The innermost region is the one the user pointed at, so its reason is the one to show. + // A region with no reason at all cannot happen; if it does, saying nothing useful beats + // blaming the selection. + val refusal = + results.filterIsInstance().firstOrNull()?.refusal + ?: ExtractionRefusal.CouldNotAnalyse + return@analyzeMaybeDangling ExtractMethodPlan.refused(refusal, fileText, documentVersion) + } + + ExtractMethodPlan( + fileText = fileText, + documentVersion = documentVersion, + candidates = candidates, + refusal = null, + ) + } + } + }.getOrElse { error -> + if (error is CancellationException) throw error + logger.warn("Failed to build extract-method plan for {}", nioPath, error) + ExtractMethodPlan.refused(ExtractionRefusal.CouldNotAnalyse) + } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt new file mode 100644 index 0000000000..205997b921 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt @@ -0,0 +1,335 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import com.itsaky.androidide.lsp.models.TextEdit +import com.itsaky.androidide.models.Position +import com.itsaky.androidide.models.Range + +/** + * The one text replacement an extraction performs: replace `[span]` with [newText]. + * + * **Deliberately a single replacement, not a list of edits.** `IDELanguageClientImpl.applyActionEdits` + * applies each `TextEdit` in its own `runOnUiThread` with no `beginBatchEdit`, and every range is + * computed against the *original* text -- so a list of N edits would be applied against positions + * already shifted by its predecessors, and would cost the user N undo steps with a typing window + * between each. Rewriting one contiguous span sidesteps all of it. + */ +data class RewriteSpan( + val span: TextSpan, + val newText: String, +) + +/** + * Builds the extraction rewrite, or null when the inputs cannot produce one. + * + * [name] is the final variable name -- the caller has already validated it. [replaceAll] selects + * between every occurrence in [scope] and only [candidateSpan]. + * + * Occurrences are substituted right-to-left within the rewritten span so earlier substitutions + * cannot shift later offsets, and the whole span is emitted as one replacement. + */ +fun buildExtractVariableRewrite( + fileText: String, + candidateSpan: TextSpan, + scope: ScopeOption, + name: String, + replaceAll: Boolean, +): RewriteSpan? { + val targets = + (if (replaceAll) scope.occurrences else listOf(candidateSpan)) + .sortedBy { it.start } + .takeIf { it.isNotEmpty() } ?: return null + // Only targets are bounds-checked against fileText; contentSpan/statementSpans are trusted + // unchecked. That is safe only because fileText is the plan's own text, not the live document -- + // if a caller ever passed live text here instead, those spans would need the same check. + if (targets.any { it.end > fileText.length }) return null + + val expression = fileText.substring(candidateSpan.start, candidateSpan.end) + val declaration = "val $name = $expression" + + return when (val form = scope.anchorForm) { + is AnchorForm.ExistingBlock -> existingBlockRewrite(fileText, form, targets, declaration, name) + is AnchorForm.WrapInBraces -> wrapInBracesRewrite(fileText, form, targets, declaration, name) + is AnchorForm.ConvertExpressionBody -> convertExpressionBodyRewrite(fileText, form, targets, declaration, name) + } +} + +/** + * What a block rung can do with the anchor statement holding a given target. + * + * Shared by the planner and the rewriter so a rung is never *offered* that the rewrite would then + * refuse: the sheet would open, the user would fill it in, and the confirm would fail with the generic + * quick-fix error instead of the action reporting up front that there is nothing to extract. + */ +internal sealed interface BlockPlacement { + /** The declaration becomes a new line above [anchor], at [anchor]'s indentation. */ + data class LineAbove( + val anchor: TextSpan, + ) : BlockPlacement + + /** The block is written on one line and is expanded, with the declaration inside its braces. */ + data object ExpandOneLine : BlockPlacement + + /** Neither is sound here, so the rung is declined. */ + data object Refused : BlockPlacement +} + +/** + * Decides the placement for the anchor statement of [form] that contains [firstTarget]. + * + * [Refused] covers two shapes. Nothing in the block contains the target, which means the plan and the + * text disagree. Or something other than indentation precedes the anchor statement on its line while + * the block's own content spans several lines, as in `items.forEach { log(x)\n\tlog(y) }` -- anchoring + * at that line start would put the declaration before the block's own opening delimiter, outside the + * scope the user picked, where a lambda's `it` does not exist. + * + * A lambda body's content starts right at its first token with no owned whitespace, so `lineStart` + * sits before `contentSpan.start` on plain indentation alone; that gap must not read as "outside the + * block", which is why the second check tests the gap for real code rather than for mere distance. + * + * [form]'s spans are substringed against [fileText] unchecked, so callers must pass the very text those + * spans were computed against -- the plan's own text, never the live document. + */ +internal fun blockPlacementFor( + fileText: String, + form: AnchorForm.ExistingBlock, + firstTarget: TextSpan, +): BlockPlacement { + val anchor = + form.statementSpans.firstOrNull { it.start <= firstTarget.start && firstTarget.end <= it.end } + ?: return BlockPlacement.Refused + val lineStart = lineStartOffset(fileText, anchor.start) + + /* + * Two conditions together are what actually mean "written on one line": something other than + * indentation already precedes the statement on its line (the brace, a header, or a prior + * semicolon-separated statement), and the block's content itself contains no newline, so + * re-emitting it as a single line loses nothing. + */ + val linePrefix = fileText.substring(lineStart, anchor.start) + val contentIsOneLine = !fileText.substring(form.contentSpan.start, form.contentSpan.end).contains('\n') + if (linePrefix.isNotBlank() && contentIsOneLine) return BlockPlacement.ExpandOneLine + + if (form.contentSpan.start > lineStart && fileText.substring(lineStart, form.contentSpan.start).isNotBlank()) { + return BlockPlacement.Refused + } + return BlockPlacement.LineAbove(anchor) +} + +/** + * Narrows [occurrences] to the ones a replace-all can actually be anchored on. + * + * A replace-all anchors on the *first* served occurrence, so a leading occurrence whose own statement + * shares the block's opening-brace line would refuse the whole rewrite even though the site the user + * selected is perfectly placeable. Dropping such leading sites keeps "Replace all N occurrences" + * achievable, which is the same guarantee `excludeUnsoundOccurrences` makes about soundness. + * + * [candidateSpan] is never dropped: the site the user selected is always served. Only leading sites + * matter, because a later occurrence never becomes the anchor. + */ +internal fun servableOccurrences( + fileText: String, + form: AnchorForm, + occurrences: List, + candidateSpan: TextSpan, +): List { + if (form !is AnchorForm.ExistingBlock) return occurrences + return occurrences.dropWhile { it != candidateSpan && blockPlacementFor(fileText, form, it) is BlockPlacement.Refused } +} + +/** + * Inserts the declaration as its own line before the anchor statement, and rewrites everything from + * there through the last occurrence. + * + * The anchor is the statement *of this scope* that holds the first served occurrence, so picking an + * outer rung hoists the declaration above the enclosing statement rather than leaving it where the + * inner rung would have put it. The rewritten span starts at that statement's line start so the + * declaration lands on a line of its own at the right indentation, and ends at the last occurrence so + * untouched trailing code is left alone. + * + * Null when [blockPlacementFor] refuses the anchor; the caller reports that rather than guessing. + */ +private fun existingBlockRewrite( + fileText: String, + form: AnchorForm.ExistingBlock, + targets: List, + declaration: String, + name: String, +): RewriteSpan? { + val last = targets.last() + val anchor = + when (val placement = blockPlacementFor(fileText, form, targets.first())) { + is BlockPlacement.Refused -> return null + is BlockPlacement.ExpandOneLine -> return oneLineBlockRewrite(fileText, form, targets, declaration, name) + is BlockPlacement.LineAbove -> placement.anchor + } + + val lineStart = lineStartOffset(fileText, anchor.start) + val indent = leadingIndentAt(fileText, anchor.start) + val newline = detectNewline(fileText) + + val span = TextSpan(lineStart, last.end) + val body = replaceOccurrences(fileText, span, targets, name) + return RewriteSpan(span = span, newText = indent + declaration + newline + body) +} + +/** + * Puts the declaration inside a block written on one line, moving the block's content and its closing + * brace onto their own lines. + * + * Only the content between the braces is rewritten: the braces, and a lambda's `param ->` header, + * stay exactly where they are, so the expansion cannot disturb the call around it. + */ +private fun oneLineBlockRewrite( + fileText: String, + form: AnchorForm.ExistingBlock, + targets: List, + declaration: String, + name: String, +): RewriteSpan { + val content = form.contentSpan + val newline = detectNewline(fileText) + val indent = leadingIndentAt(fileText, content.start) + val innerIndent = indent + detectIndentUnit(fileText) + + // A block that does not own its braces (a lambda body) stops short of them, leaving a single + // space between the content span and the brace on each side. Widen the replaced span over that + // gap so it does not survive the rewrite as a stray "{ " or " }". + val span = TextSpan(startOfWhitespaceBefore(fileText, content.start), endOfWhitespaceAfter(fileText, content.end)) + val body = replaceOccurrences(fileText, content, targets, name).trim() + + val newText = + buildString { + append(newline) + append(innerIndent).append(declaration).append(newline) + append(innerIndent).append(body).append(newline) + append(indent) + } + return RewriteSpan(span = span, newText = newText) +} + +/** Wraps a braceless statement in a block containing the declaration and the original statement. */ +private fun wrapInBracesRewrite( + fileText: String, + form: AnchorForm.WrapInBraces, + targets: List, + declaration: String, + name: String, +): RewriteSpan { + // Occurrences in a braceless scope are confined to the statement itself (the frame's search + // range *is* this span), so no cross-span targets are possible; replaceOccurrences filters anyway. + val span = TextSpan(form.bodyStart, form.bodyEnd) + val newline = detectNewline(fileText) + val body = replaceOccurrences(fileText, span, targets, name) + + val newText = + buildString { + append('{').append(newline) + append(form.innerIndent).append(declaration).append(newline) + append(form.innerIndent).append(body).append(newline) + append(form.indent).append('}') + } + return RewriteSpan(span, newText) +} + +/** Converts `= expr` into a block body holding the declaration and a `return` of the rewritten body. */ +private fun convertExpressionBodyRewrite( + fileText: String, + form: AnchorForm.ConvertExpressionBody, + targets: List, + declaration: String, + name: String, +): RewriteSpan { + val bodySpan = TextSpan(form.bodyStart, form.bodyEnd) + val newline = detectNewline(fileText) + val body = replaceOccurrences(fileText, bodySpan, targets, name) + val returned = if (form.needsReturn) "return $body" else body + + // Writing a type means rewriting from the end of the signature, not from the `=`: starting at the + // `=` would leave the space in front of it and emit `fun area(r: Int) : Int {`. + val spanStart = + if (form.returnTypeText == null) form.assignStart else startOfWhitespaceBefore(fileText, form.assignStart) + val header = form.returnTypeText?.let { ": $it " } ?: "" + + val newText = + buildString { + append(header).append('{').append(newline) + append(form.innerIndent).append(declaration).append(newline) + append(form.innerIndent).append(returned).append(newline) + append(form.indent).append('}') + } + return RewriteSpan(TextSpan(spanStart, form.bodyEnd), newText) +} + +/** The offset where the run of whitespace ending at [offset] begins. */ +private fun startOfWhitespaceBefore( + text: String, + offset: Int, +): Int { + var index = offset.coerceIn(0, text.length) + while (index > 0 && text[index - 1].isWhitespace()) index-- + return index +} + +/** The offset where the run of whitespace starting at [offset] ends. */ +private fun endOfWhitespaceAfter( + text: String, + offset: Int, +): Int { + var index = offset.coerceIn(0, text.length) + while (index < text.length && text[index].isWhitespace()) index++ + return index +} + +/** + * Returns `[span]`'s text with every occurrence inside it replaced by [name]. Substitutes + * right-to-left so an earlier replacement cannot invalidate a later offset. + */ +private fun replaceOccurrences( + fileText: String, + span: TextSpan, + targets: List, + name: String, +): String { + val builder = StringBuilder(fileText.substring(span.start, span.end)) + targets + .filter { it.start >= span.start && it.end <= span.end } + .sortedByDescending { it.start } + .forEach { builder.replace(it.start - span.start, it.end - span.start, name) } + return builder.toString() +} + +/** CRLF only when the file already uses it, so the edit does not mix line endings. */ +internal fun detectNewline(text: String): String = if (text.contains("\r\n")) "\r\n" else "\n" + +/** + * Converts a [RewriteSpan] into the `TextEdit` the language client consumes. [Position] carries + * line, column *and* index; all three are filled so neither the client's line/column path nor any + * index-based consumer sees a stale value. + */ +fun RewriteSpan.toTextEdit(fileText: String): TextEdit = + TextEdit( + range = + Range( + positionAt(fileText, span.start), + positionAt(fileText, span.end), + ), + newText = newText, + ) + +internal fun positionAt( + text: String, + offset: Int, +): Position { + val clamped = offset.coerceIn(0, text.length) + var line = 0 + var lineStart = 0 + var i = 0 + while (i < clamped) { + if (text[i] == '\n') { + line++ + lineStart = i + 1 + } + i++ + } + return Position(line, clamped - lineStart, clamped) +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt new file mode 100644 index 0000000000..1270709429 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt @@ -0,0 +1,219 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import com.itsaky.androidide.lsp.kotlin.compiler.AbstractCompilationEnvironment +import com.itsaky.androidide.lsp.kotlin.compiler.modules.AnalysisPriority +import com.itsaky.androidide.lsp.kotlin.compiler.modules.ScheduledCancelChecker +import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling +import com.itsaky.androidide.lsp.kotlin.compiler.read +import com.itsaky.androidide.lsp.kotlin.utils.renderName +import org.jetbrains.kotlin.analysis.api.KaExperimentalApi +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol +import org.jetbrains.kotlin.analysis.api.types.KaType +import org.jetbrains.kotlin.com.intellij.psi.PsiElement +import org.jetbrains.kotlin.psi.KtCallableDeclaration +import org.jetbrains.kotlin.psi.KtDeclarationWithBody +import org.jetbrains.kotlin.psi.KtExpression +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtPropertyAccessor +import org.slf4j.LoggerFactory +import java.nio.file.Path + +private val logger = LoggerFactory.getLogger("ExtractVariablePlanner") + +/** + * Computes the whole [ExtractionPlan] in one background analysis pass. + * + * The current [KtFile] is fetched *before* entering [read] -- blocking on + * `getCurrentKtFile(...).get()` inside `project.read` deadlocks. + * + * Returns an empty plan both when there is genuinely nothing to extract and whenever anything in + * this pipeline throws: the action framework only catches [IllegalArgumentException] and this runs on + * a scope with no exception handler, so an uncaught throw would crash the app. Degrading to an empty + * plan is always safe -- the action reports "nothing to extract" instead of rewriting anything. + */ +internal fun buildExtractionPlan( + env: AbstractCompilationEnvironment, + nioPath: Path, + selectionStart: Int, + selectionEnd: Int, + documentVersion: Int, + cancelChecker: ScheduledCancelChecker, +): ExtractionPlan = + runCatching { + val ktFile = env.ktSymbolIndex.getCurrentKtFile(nioPath).get() ?: return ExtractionPlan.empty() + env.project.read { + val syntax = candidateExpressionsAt(ktFile, selectionStart, selectionEnd) + if (syntax.expressions.isEmpty()) return@read ExtractionPlan.empty(ktFile.text, documentVersion) + + /* PsiFileImpl.getText() allocates a fresh String each call, so the plan pass reads it once and + * threads it down to every candidate and rung. */ + val fileText = ktFile.text + analyzeMaybeDangling(ktFile, AnalysisPriority.INTERACTIVE, cancelChecker) { + ExtractionPlan( + fileText = fileText, + documentVersion = documentVersion, + candidates = syntax.expressions.mapNotNull { candidateFor(it, fileText) }, + ) + } + } + }.getOrElse { error -> + logger.warn("Failed to build extract-variable plan for {}", nioPath, error) + ExtractionPlan.empty() + } + +/** + * Turns one syntactic candidate into a [CandidateExpression], or null when it should not be offered. + * + * Dropped when the expression produces no useful value (`Unit`, `Nothing` -- `val u = println(x)` + * compiles but is pointless) or when nothing remains of its legal scope chain. + */ +@OptIn(KaExperimentalApi::class) +private fun KaSession.candidateFor( + expression: KtExpression, + fileText: String, +): CandidateExpression? { + val type = runCatching { expression.expressionType }.getOrNull() + if (type == null || isValuelessType(type)) return null + + val frames = truncateAtCeiling(enclosingScopeFrames(expression), referencedDeclarationCeiling(expression)) + if (frames.isEmpty()) return null + + val span = TextSpan(expression.textRange.startOffset, expression.textRange.endOffset) + val file = expression.containingKtFile + val scopes = frames.mapNotNull { scopeOptionFor(expression, span, it, file, fileText) } + if (scopes.isEmpty()) return null + val takenNames = namesInScopeAt(expression) + + return CandidateExpression( + label = collapseForLabel(expression.text), + span = span, + suggestedName = suggestVariableName(expression, runCatching { renderName(type) }.getOrNull(), takenNames), + takenNames = takenNames, + scopes = scopes, + ) +} + +/** + * Builds one scope option: settles the anchor form, then resolves the occurrence set it can serve. + * + * Returns null when the rung cannot be honoured at all, either because the block's geometry refuses + * the declaration or because an expression-body conversion cannot be reconciled. Both declines run + * before the occurrence search, so a refused rung costs nothing. + * + * [fileText] must be the text the plan's spans were computed against, since [blockPlacementFor] and + * [servableOccurrences] index into it unchecked. + */ +private fun KaSession.scopeOptionFor( + expression: KtExpression, + span: TextSpan, + frame: ScopeFrame, + file: KtFile, + fileText: String, +): ScopeOption? { + val anchorForm = + when (val form = frame.anchorForm) { + is AnchorForm.ExistingBlock -> { + /* + * The rewrite refuses this geometry, so refusing it here too is what turns a sheet whose + * confirm must fail into an up-front "nothing to extract". The candidate's own span is + * tested here; servableOccurrences is what makes the first served target placeable when + * replace-all is on. + */ + if (blockPlacementFor(fileText, form, span) is BlockPlacement.Refused) return null + form + } + + is AnchorForm.ConvertExpressionBody -> { + convertExpressionBodyForm(form, frame.scopeElement, file) ?: return null + } + + is AnchorForm.WrapInBraces -> { + form + } + } + + val matches = findOccurrences(expression, frame.scopeElement, frame.searchRange) + val writes = writeOffsetsFor(expression, frame.scopeElement) + val sound = excludeUnsoundOccurrences(matches, span, writes) + val occurrences = servableOccurrences(fileText, anchorForm, sound, span) + + return ScopeOption(label = frame.label, anchorForm = anchorForm, occurrences = occurrences) +} + +/** + * Fills in the `return` and written-type details of an expression-body rung, or null to decline it. + * + * A block body with no declared type returns `Unit`, so a `return` that needs a type neither declared + * nor renderable would emit a body that does not compile. Declining is always safe -- the + * decline-rather-than-rewrite principle that ADR 0013 records, landing alongside extract method + * (ADFA-5080). + */ +private fun KaSession.convertExpressionBodyForm( + form: AnchorForm.ConvertExpressionBody, + bodyExpression: PsiElement, + file: KtFile, +): AnchorForm.ConvertExpressionBody? { + val declaration = bodyExpression.parent as? KtDeclarationWithBody + val mustWriteType = declaration != null && !declaration.declaresReturnType() + val rendered = if (mustWriteType) returnTypeTextOf(declaration, file) else null + val (needsReturn, returnTypeText) = + normalizeExpressionBodyReturn(expressionBodyNeedsReturn(bodyExpression), rendered) + if (needsReturn && mustWriteType && returnTypeText == null) return null + return form.copy(needsReturn = needsReturn, returnTypeText = returnTypeText) +} + +/** Whether the declaration spells its return type out, in which case nothing needs writing. */ +private fun KtDeclarationWithBody.declaresReturnType(): Boolean = + when (this) { + // KtPropertyAccessor.returnTypeReference is deprecated in favour of the identical typeReference. + is KtPropertyAccessor -> typeReference != null + + is KtCallableDeclaration -> typeReference != null + + else -> false + } + +/** The declaration's resolved return type, or null when it cannot be resolved. */ +private fun KaSession.returnTypeOf(declaration: KtDeclarationWithBody): KaType? = + runCatching { (declaration.symbol as? KaCallableSymbol)?.returnType }.getOrNull() + +/** The declaration's return type as source text, shortened where the file can resolve it. */ +private fun KaSession.returnTypeTextOf( + declaration: KtDeclarationWithBody, + file: KtFile, +): String? { + val type = returnTypeOf(declaration) ?: return null + val rendered = renderedTypeTextOrNull(type) ?: return null + return shortenTypeText(rendered, importedNamesOf(file), starImportedPackagesOf(file)) +} + +/** + * Whether converting an expression body to a block body needs a `return`. + * + * False only for a `Unit`-returning function, where `return expr` on a non-`Unit` expression would + * not compile and is unnecessary anyway. `Nothing` is deliberately not folded in here even though + * [isValuelessType] treats it like `Unit` for the R4 candidate filter -- a `Nothing`-returning + * function needs its `return` and its written-out type kept, or a caller using it in a `Nothing` + * position (`x ?: boom()`) stops compiling. Defaults to true, which is right for everything else + * including property accessors. + */ +private fun KaSession.expressionBodyNeedsReturn(bodyExpression: PsiElement): Boolean { + val declaration = bodyExpression.parent as? KtDeclarationWithBody ?: return true + val returnType = returnTypeOf(declaration) ?: return true + return !isUnitReturnType(returnType) +} + +/** + * Whether [type] is `Unit`, with the rendered text as the fallback answer. + * + * A throw from `isUnitType` must not read as "not `Unit`": that writes the very `Unit` it failed to + * recognise into the signature and wraps a `Unit` call in a pointless `return`. + */ +private fun KaSession.isUnitReturnType(type: KaType): Boolean = + runCatching { type.isUnitType }.getOrNull() + ?: renderedTypeTextOrNull(type)?.let(::isUnitTypeText) + ?: false + +/** `Unit` and `Nothing` carry no value worth binding to a `val`. */ +private fun KaSession.isValuelessType(type: KaType): Boolean = runCatching { type.isUnitType || type.isNothingType }.getOrDefault(false) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt new file mode 100644 index 0000000000..6a8e6761e0 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt @@ -0,0 +1,182 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +/** A half-open offset range `[start, end)` into the analysed file's text. */ +data class TextSpan( + val start: Int, + val end: Int, +) { + init { + require(start <= end) { "start=$start > end=$end" } + } + + val length: Int get() = end - start + + fun overlaps(other: TextSpan): Boolean = start < other.end && other.start < end +} + +/** + * How the new declaration is woven into an anchor scope. Kotlin scopes are not all blocks, so + * three shapes are needed; [ExistingBlock] is by far the common one. + */ +sealed interface AnchorForm { + /** + * The scope already has a `{ ... }` body (function body, `if` block, lambda body, ...), so the + * declaration is a new statement line inside it. + * + * [statementSpans] are the block's direct child statements, ascending. The anchor point is the + * first of them containing the first served occurrence -- which is what makes an outer rung differ + * from an inner one. Anchoring on the occurrence's own line instead would make every rung of a + * chain produce the same edit. + * + * [contentSpan] is the region *inside* the braces. It tells a block written on one line + * (`items.map { it.length + 1 }`) from a multi-line one, where inserting at the statement's line + * start would put the declaration outside the braces. + */ + data class ExistingBlock( + val contentSpan: TextSpan, + val statementSpans: List, + ) : AnchorForm + + /** + * A braceless statement position -- `if (c) foo()`, a `when` entry, a braceless loop body. + * `[bodyStart, bodyEnd)` (the statement) is replaced by a braced block holding the declaration + * and the original statement. No `return` is involved. + */ + data class WrapInBraces( + val bodyStart: Int, + val bodyEnd: Int, + val indent: String, + val innerIndent: String, + ) : AnchorForm + + /** + * An expression-bodied function or property accessor -- `fun area(r: Int) = r * r`. The `=` and + * the body are replaced by a block body. [needsReturn] is false only when the declaration + * returns `Unit`, where `return` is both unnecessary and wrong for a non-`Unit` expression. + * + * [returnTypeText] is the type to write into the signature, or null when there is nothing to write + * -- the declaration already spells its type out, or the block body infers `Unit` anyway. A block + * body with no declared type returns `Unit`, so `return ` without this would not compile. + */ + data class ConvertExpressionBody( + val assignStart: Int, + val bodyStart: Int, + val bodyEnd: Int, + val indent: String, + val innerIndent: String, + val needsReturn: Boolean, + val returnTypeText: String? = null, + ) : AnchorForm +} + +/** + * One member of a candidate's legal scope chain: a place the declaration may go, together with the + * occurrences that are sound to replace there. + * + * [occurrences] is ascending by offset and always contains the candidate's own span, so + * `occurrences.size` is the count shown as "Replace all N occurrences". Narrowing to an inner scope + * can only shrink this set, never grow it. + * + * A block rung's set is narrowed once more, dropping leading occurrences whose own anchor statement + * cannot host the declaration -- a replace-all anchors on the first served one, so keeping an + * unhostable occurrence would refuse the whole rewrite. That lowers the count the user is shown, which + * is the point: N stays achievable. + */ +data class ScopeOption( + val label: String, + val anchorForm: AnchorForm, + val occurrences: List, +) + +/** + * A legal extraction target and everything the UI needs to act on it. + * + * [label] is the expression's source text with runs of whitespace collapsed, so a multi-line + * expression stays readable in a one-line list item. + * + * [takenNames] is what a new declaration here would collide with or shadow -- enclosing parameters and + * locals, enclosing class members, top-level names -- and is used both to uniquify [suggestedName] and + * to reject a typed name. A local in an unrelated function is not in it. + * + * [scopes] is the legal scope chain, innermost first, and is never empty -- a candidate with no + * legal anchor is not a candidate. + */ +data class CandidateExpression( + val label: String, + val span: TextSpan, + val suggestedName: String, + val takenNames: Set, + val scopes: List, +) + +/** + * The complete result of the background analysis pass, and the central type of the extract/inline + * refactorings. + * + * ## Vocabulary + * + * Used verbatim throughout this package, its tests and its review comments -- prefer these over + * ad-hoc synonyms. + * + * - **Candidate expression** -- a [org.jetbrains.kotlin.psi.KtExpression] at the cursor or selection + * that is a legal extraction target. At most [MAX_CANDIDATES], ordered innermost-first. + * - **Legal scope chain** -- the ordered anchors available for the new declaration: outward from the + * candidate's own statement through enclosing blocks, crossing a lambda boundary only when nothing + * lambda-scoped is referenced, and stopping at the enclosing method body. + * - **Anchor scope** -- the chain member the user picked. The `val` is declared inside it. + * - **Anchor point** -- the exact insertion offset: the start of the line holding the first statement + * *within the anchor scope* that contains a replaced occurrence, or inside the braces when that + * statement shares its line with a block written on one line. + * - **Occurrence** -- a site inside the anchor scope that is structurally equal to the candidate *and* + * whose every name reference resolves to the same symbol. Sites made unsound by an intervening + * reassignment are excluded, so an occurrence set is always safe to replace wholesale. + * - **Extraction plan** -- this type. + * + * ## Why plain data + * + * The user's choices (which expression, what name, which scope, replace-all or not) arrive *after* + * analysis, from a sheet. Rather than re-entering analysis on confirm, one background pass produces + * this plan for *all* candidates at once and the UI does pure string/offset arithmetic on it. That + * keeps PSI off the UI thread, removes the stale-PSI window, and makes the whole derivation + * unit-testable without an editor, an activity or Compose. + * + * [fileText] is the text the offsets here refer to, carried so the UI can build the replacement text + * without PSI; [documentVersion] is what makes that safe -- if the live document has moved on by the + * time the user confirms, the plan is discarded rather than applied against shifted offsets. + */ +data class ExtractionPlan( + override val fileText: String, + override val documentVersion: Int, + val candidates: List, +) : RefactoringPlan { + val isEmpty: Boolean get() = candidates.isEmpty() + + companion object { + fun empty( + fileText: String = "", + documentVersion: Int = -1, + ) = ExtractionPlan(fileText, documentVersion, emptyList()) + } +} + +/** + * Collapses whitespace runs so a multi-line expression reads as one line in a list item. + * + * The space before a `.` or `?.` is then removed: a wrapped call chain is the most common multi-line + * expression in Kotlin, and a plain collapse turns `items\n\t.filter { ... }` into + * `items .filter { ... }`, which reads as a typo in a list the user is choosing from. + */ +internal fun collapseForLabel( + text: String, + maxLength: Int = 80, +): String { + val collapsed = + text + .replace(WHITESPACE_RUN, " ") + .replace(SPACE_BEFORE_DOT, "$1") + .trim() + return if (collapsed.length <= maxLength) collapsed else collapsed.take(maxLength - 3) + "..." +} + +private val WHITESPACE_RUN = Regex("\\s+") +private val SPACE_BEFORE_DOT = Regex(" (\\??\\.)") diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionRegion.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionRegion.kt new file mode 100644 index 0000000000..f6b2f5d198 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionRegion.kt @@ -0,0 +1,125 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import org.jetbrains.kotlin.com.intellij.psi.PsiElement +import org.jetbrains.kotlin.psi.KtBlockExpression +import org.jetbrains.kotlin.psi.KtExpression +import org.jetbrains.kotlin.psi.KtFile + +/** + * What a selection resolved to. Exactly two kinds, which is the whole reason the hard cases never + * arise: a selection covering half an `if` and half its `else`, or straddling a lambda boundary, + * is neither, and is declined by construction rather than filtered out later. + */ +sealed interface ExtractionRegion { + /** The region's covering span in the file's text. */ + val span: TextSpan + + /** One or more nested expressions at the cursor, innermost first. The user picks between them in the sheet. */ + data class Expressions( + val candidates: List, + ) : ExtractionRegion { + override val span: TextSpan + get() = candidates.first().textRange.let { TextSpan(it.startOffset, it.endOffset) } + } + + /** One or more sibling statements in a single [block]. */ + data class Statements( + val statements: List, + val block: KtBlockExpression, + ) : ExtractionRegion { + override val span: TextSpan + get() = + TextSpan( + statements.first().textRange.startOffset, + statements.last().textRange.endOffset, + ) + } +} + +/** + * Resolves `[selectionStart, selectionEnd)` to the one region the refactoring will act on, or null + * when it is neither kind. + * + * A bare cursor is always the expression path. A non-empty selection snaps **outward** to whole + * statements -- a touch selection will not land on a boundary. When the snapped range is a single + * statement and the selection sits strictly inside it, the expression path is preferred instead: + * that is what the user's selection actually points at, not the enclosing statement. But if nothing + * there is a legal expression target, the snapped statement is used anyway -- a near-miss drag + * (e.g. selecting `sum = a + b` and missing the leading `val`) should still extract something, + * rather than being refused for landing a few characters short. + */ +fun resolveExtractionRegion( + file: KtFile, + selectionStart: Int, + selectionEnd: Int, +): ExtractionRegion? { + val (start, end) = trimToCode(file.text, selectionStart, selectionEnd) ?: return null + if (start == end) return expressionRegion(file, selectionStart, selectionEnd) + + val range = snapToStatements(file, start, end) ?: return expressionRegion(file, selectionStart, selectionEnd) + + val only = range.statements.singleOrNull() + if (only != null && (start > only.textRange.startOffset || end < only.textRange.endOffset)) { + expressionRegion(file, selectionStart, selectionEnd)?.let { return it } + } + + return ExtractionRegion.Statements(range.statements, range.block) +} + +private fun expressionRegion( + file: KtFile, + selectionStart: Int, + selectionEnd: Int, +): ExtractionRegion.Expressions? { + val syntax = candidateExpressionsAt(file, selectionStart, selectionEnd) + if (syntax.expressions.isEmpty()) return null + return ExtractionRegion.Expressions(syntax.expressions) +} + +/** A run of sibling statements together with the [KtBlockExpression] that holds them. */ +private class StatementRange( + val statements: List, + val block: KtBlockExpression, +) + +/** + * The whole statements `[start, end)` touches, when they are siblings in one [KtBlockExpression]. + * + * Null when the two ends land in different blocks, which is what rejects a selection spanning an + * `if` body and the code after it without needing to reason about the constructs involved. + */ +private fun snapToStatements( + file: KtFile, + start: Int, + end: Int, +): StatementRange? { + // end > start is guaranteed by the start == end early-return in resolveExtractionRegion. + val first = statementContaining(file, start) ?: return null + val last = statementContaining(file, end - 1) ?: return null + + val block = first.parent as? KtBlockExpression ?: return null + if (last.parent !== block) return null + if (!isExtractionPosition(first)) return null + + val statements = block.statements + val from = statements.indexOfFirst { it === first } + val to = statements.indexOfFirst { it === last } + if (from < 0 || to < from) return null + return StatementRange(statements.subList(from, to + 1).toList(), block) +} + +/** + * The statement containing [offset]: the nearest ancestor that is a direct expression child of a + * block. Null for a position that is not inside one, such as a comment or a class body. + */ +private fun statementContaining( + file: KtFile, + offset: Int, +): KtExpression? { + var current: PsiElement? = file.findElementAt(offset) ?: return null + while (current != null && current !is KtFile) { + if (current is KtExpression && current.parent is KtBlockExpression) return current + current = current.parent + } + return null +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt new file mode 100644 index 0000000000..2a05b28bee --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt @@ -0,0 +1,994 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.resolution.KaCallableMemberCall +import org.jetbrains.kotlin.analysis.api.resolution.KaCompoundArrayAccessCall +import org.jetbrains.kotlin.analysis.api.resolution.KaCompoundVariableAccessCall +import org.jetbrains.kotlin.analysis.api.resolution.KaImplicitReceiverValue +import org.jetbrains.kotlin.analysis.api.resolution.KaReceiverValue +import org.jetbrains.kotlin.analysis.api.resolution.successfulCallOrNull +import org.jetbrains.kotlin.analysis.api.resolution.successfulFunctionCallOrNull +import org.jetbrains.kotlin.analysis.api.resolution.symbol +import org.jetbrains.kotlin.analysis.api.symbols.KaBackingFieldSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaClassSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaNamedFunctionSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaPropertySymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaReceiverParameterSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaValueParameterSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaVariableSymbol +import org.jetbrains.kotlin.analysis.api.symbols.markers.KaAnnotatedSymbol +import org.jetbrains.kotlin.analysis.api.symbols.markers.KaNamedSymbol +import org.jetbrains.kotlin.analysis.api.types.KaClassType +import org.jetbrains.kotlin.analysis.api.types.KaFlexibleType +import org.jetbrains.kotlin.analysis.api.types.KaFunctionType +import org.jetbrains.kotlin.analysis.api.types.KaType +import org.jetbrains.kotlin.builtins.StandardNames +import org.jetbrains.kotlin.com.intellij.psi.PsiElement +import org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.idea.references.mainReference +import org.jetbrains.kotlin.psi.KtAnonymousInitializer +import org.jetbrains.kotlin.psi.KtBlockExpression +import org.jetbrains.kotlin.psi.KtBreakExpression +import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtClassOrObject +import org.jetbrains.kotlin.psi.KtContinueExpression +import org.jetbrains.kotlin.psi.KtDeclaration +import org.jetbrains.kotlin.psi.KtDeclarationWithBody +import org.jetbrains.kotlin.psi.KtExpression +import org.jetbrains.kotlin.psi.KtExpressionWithLabel +import org.jetbrains.kotlin.psi.KtFunctionLiteral +import org.jetbrains.kotlin.psi.KtLabeledExpression +import org.jetbrains.kotlin.psi.KtLambdaArgument +import org.jetbrains.kotlin.psi.KtLambdaExpression +import org.jetbrains.kotlin.psi.KtLoopExpression +import org.jetbrains.kotlin.psi.KtNameReferenceExpression +import org.jetbrains.kotlin.psi.KtNamedDeclaration +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtParameter +import org.jetbrains.kotlin.psi.KtProperty +import org.jetbrains.kotlin.psi.KtPropertyAccessor +import org.jetbrains.kotlin.psi.KtQualifiedExpression +import org.jetbrains.kotlin.psi.KtReturnExpression +import org.jetbrains.kotlin.psi.KtSecondaryConstructor +import org.jetbrains.kotlin.psi.KtSimpleNameExpression +import org.jetbrains.kotlin.psi.KtStringTemplateExpression +import org.jetbrains.kotlin.psi.KtThisExpression +import org.jetbrains.kotlin.psi.KtTypeReference +import org.jetbrains.kotlin.psi.KtValueArgument +import org.jetbrains.kotlin.psi.KtValueArgumentList + +/** The name of the statement-range suggestion; there is no expression to read a name from (R12). */ +private const val STATEMENT_RANGE_NAME = "extracted" + +private const val COMPOSABLE_FQ_NAME = "androidx.compose.runtime.Composable" + +/** What a receiver-binding lambda is called in the refusal when it is not a call argument. */ +private const val UNNAMED_SCOPING_CONSTRUCT = "lambda" + +private const val BACKING_FIELD_NAME = "field" + +private const val COROUTINE_CONTEXT_NAME = "coroutineContext" + +/** As [renderedTypeTextOrNull] prints it. A `Unit` return type is left off the signature entirely. */ +private const val UNIT_TYPE_TEXT = "kotlin.Unit" + +/** Either a derived candidate or the reason there is not one. */ +internal sealed interface SignatureResult { + data class Success( + val candidate: ExtractMethodCandidate, + ) : SignatureResult + + data class Refused( + val refusal: ExtractionRefusal, + ) : SignatureResult +} + +/** + * Derives one candidate from [elements] -- a single expression, or the statement range. + * + * Ordered so the cheapest refusals come first and nothing expensive runs for a region that is going + * to be declined anyway. MUST be called inside an analysis session. + */ +internal fun KaSession.buildCandidate( + elements: List, + isExpression: Boolean, + fileText: String, +): SignatureResult { + val first = elements.first() + val last = elements.last() + val span = TextSpan(first.textRange.startOffset, last.textRange.endOffset) + val enclosing = enclosingDeclaration(first) ?: return refuse(ExtractionRefusal.NotASingleRegion) + + /* + * enclosingDeclaration skips a nameless KtNamedFunction, so an anonymous extension function + * (`fun String.() { ... }` used as a value) between the region and enclosing is invisible to it, + * and receiverTypeTextOf(enclosing) then reads the outer declaration's receiver instead of the + * anonymous function's own -- the region can depend on a receiver the emitted function never gets. + * Declined unconditionally rather than only when the region actually uses the receiver: anonymous + * extension functions are rare, and this is far cheaper than the resolution innerImplicitReceiver + * would need to tell real receiver use apart from an unrelated capture. + */ + if (anonymousExtensionFunctionBetween(first, enclosing)) { + return refuse(ExtractionRefusal.AnonymousExtensionFunction) + } + + val typeParameterNames = typeParameterNamesOf(enclosing) + typeParameterIn(typeParameterNames, elements)?.let { return refuse(ExtractionRefusal.UsesTypeParameter(it)) } + if (usesBackingField(enclosing, elements)) return refuse(ExtractionRefusal.UsesBackingField) + innerImplicitReceiver(enclosing, elements, span)?.let { return refuse(ExtractionRefusal.InnerImplicitReceiver(it)) } + reassignedOuterVar(enclosing, elements, span)?.let { return refuse(ExtractionRefusal.ReassignsOuterVar(it)) } + + val tailReturn = !isExpression && isTailReturn(elements, span, enclosing) + if (!tailReturn && hasExit(elements, span)) return refuse(ExtractionRefusal.ExitsRegion) + + val outputs = if (isExpression) RegionOutputs.NONE else outputsOf(enclosing, elements, span) + // Only a single plain `val`/`var` can come back as the return value. Everything else the region + // declares and the following code still needs is refused rather than silently dropped (R7), split + // by which situation it is: two values genuinely cannot fit in one return, while a lone + // destructuring entry, local `fun` or reassigned local is one value the call site cannot receive. + if (outputs.declarations.size > 1) { + return refuse(ExtractionRefusal.MultipleOutputs(outputs.declarations.mapNotNull { it.name })) + } + val declared = outputs.declarations.singleOrNull() + if (declared != null && (declared !is KtProperty || outputs.writtenAfter.isNotEmpty())) { + return refuse(ExtractionRefusal.OutputNotReturnable(declared.name.orEmpty())) + } + val output = declared as? KtProperty + // The tail-return exception holds only when nothing else flows out (R8). + if (tailReturn && output != null) return refuse(ExtractionRefusal.ExitsRegion) + + val parameters = + when (val captured = capturedParameters(enclosing, elements, span)) { + is CaptureResult.Captured -> captured.parameters + is CaptureResult.Refused -> return refuse(captured.refusal) + } + + val returnTypeText = + when { + isExpression -> { + renderedTypeOrNull(first) ?: return refuse(ExtractionRefusal.UnrenderableType) + } + + tailReturn -> { + // A secondary constructor's symbol returns the constructed class, but its `return` + // carries no value -- so the extracted tail is `Unit`, and `return extracted(...)` on a + // `Unit` call is legal inside a constructor. (`init` needs no rule: `return` is illegal + // there, so no tail return can reach here.) + when (enclosing) { + is KtSecondaryConstructor -> UNIT_TYPE_TEXT + else -> enclosingReturnType(enclosing) ?: return refuse(ExtractionRefusal.UnrenderableType) + } + } + + output != null -> { + renderedDeclarationType(output) ?: return refuse(ExtractionRefusal.UnrenderableType) + } + + else -> { + null + } + }.takeUnless { it == UNIT_TYPE_TEXT } + + val receiverTypeText = receiverTypeTextOf(enclosing) + + // The syntactic check above misses an inferred type argument, which names no type anywhere in the + // region. The rendered signature is the last place to catch it before it is emitted (R10), and it + // has to cover every slot the signature prints -- the receiver included. + renderedTypeParameterIn( + typeParameterNames, + parameters.map { it.typeText } + listOfNotNull(returnTypeText, receiverTypeText), + )?.let { return refuse(ExtractionRefusal.UsesTypeParameter(it)) } + + val body = + when { + isExpression -> ExtractedBody.ExpressionBody(needsReturn = returnTypeText != null) + output != null -> ExtractedBody.StatementBody(trailingReturn = "return ${output.name.orEmpty()}") + else -> ExtractedBody.StatementBody(trailingReturn = null) + } + + val callSite = + when { + tailReturn -> CallSiteForm.Return + output != null -> CallSiteForm.AssignOutput(output.name.orEmpty()) + else -> CallSiteForm.Call + } + + // A getter is not a place a function can follow -- inserting there lands between the accessors of + // a `var` and does not parse -- so the new member goes after the whole property (R4). The accessor + // itself stays the capture boundary everywhere else. + val anchor = (enclosing as? KtPropertyAccessor)?.property ?: enclosing + val isLocalTarget = anchor.parent is KtBlockExpression + val takenNames = takenNamesFor(enclosing, anchor, isLocalTarget) + val modifiers = + buildList { + // A local function joins a block, and a visibility modifier on one does not compile. + if (!isLocalTarget) add("private") + if (usesSuspend(elements)) add("suspend") + } + + return SignatureResult.Success( + ExtractMethodCandidate( + label = collapseForLabel(fileText.substring(span.start, span.end)), + span = span, + suggestedName = + if (isExpression) { + suggestVariableName(first, renderedTypeOrNull(first), takenNames) + } else { + uniqueName(STATEMENT_RANGE_NAME, takenNames) + }, + takenNames = takenNames, + annotations = if (usesComposable(elements)) listOf("@Composable") else emptyList(), + modifiers = modifiers, + receiverTypeText = receiverTypeText, + parameters = parameters, + returnTypeText = returnTypeText, + body = body, + callSite = callSite, + // A local function is only visible from its declaration onward, so it has to go *before* the + // anchor that calls it. Sound in general: everything the anchor's body can reach is already + // declared above the anchor. Every other target keeps the new member after its anchor (R4). + insertOffset = if (isLocalTarget) anchor.textRange.startOffset else anchor.textRange.endOffset, + insertIndent = leadingIndentAt(fileText, anchor.textRange.startOffset), + rawStringSpans = rawStringSpansIn(elements), + ), + ) +} + +private fun refuse(refusal: ExtractionRefusal): SignatureResult = SignatureResult.Refused(refusal) + +/** + * The named function, accessor, `init` block or constructor whose body holds [element]. Lambdas and + * anonymous functions are skipped: the new function is a sibling of the enclosing *named* declaration + * (R4), and their captures become parameters. + */ +private fun enclosingDeclaration(element: PsiElement): KtDeclaration? { + var current: PsiElement? = element.parent + while (current != null) { + when (current) { + is KtNamedFunction -> { + /* + * PSI gives an anonymous `fun(...) { }` the same node type as a named function, with a null + * name. It is a value, not a declaration a sibling can follow: anchoring on it inserts the + * new function into an argument list or a property initializer, and the file stops parsing. + */ + if (current.name != null) return current + } + + is KtPropertyAccessor, is KtAnonymousInitializer, is KtSecondaryConstructor -> { + return current + } + + is KtClassOrObject -> { + return null + } + } + current = current.parent + } + return null +} + +/** + * Whether an anonymous extension function -- a nameless `KtNamedFunction` with a receiver -- sits + * between [element] and [enclosing]. Every such ancestor contains [element], so it is necessarily + * outside the region; no separate in-region check is needed. + */ +private fun anonymousExtensionFunctionBetween( + element: PsiElement, + enclosing: KtDeclaration, +): Boolean { + var current: PsiElement? = element.parent + while (current != null && current != enclosing) { + if (current is KtNamedFunction && current.name == null && current.receiverTypeReference != null) { + return true + } + current = current.parent + } + return false +} + +/** Whether [element] is inside the region's span. */ +private fun inRegion( + element: PsiElement, + span: TextSpan, +): Boolean = element.textRange.startOffset >= span.start && element.textRange.endOffset <= span.end + +private fun simpleNamesIn(elements: List): List = + elements.flatMap { PsiTreeUtil.collectElementsOfType(it, KtSimpleNameExpression::class.java) } + +private fun descendantsOf( + elements: List, + type: Class, +): List = elements.flatMap { PsiTreeUtil.collectElementsOfType(it, type) } + +/** + * The raw (triple-quoted) string literals inside [elements], in file offsets. A single-line literal + * needs no protection: `\n` inside it is an escape, not a line break the re-indentation can reach. + */ +private fun rawStringSpansIn(elements: List): List = + descendantsOf(elements, KtStringTemplateExpression::class.java) + .filter { it.text.startsWith("\"\"\"") } + .map { TextSpan(it.textRange.startOffset, it.textRange.endOffset) } + +/** + * The name of a class declared inside [enclosing] that [type] is written in terms of, or null. + * + * A value of such a type survives the move, but its type name does not resolve at the insertion + * point, so no parameter can be written for it. Type arguments are searched too: `List` is + * just as unwritable as `Holder`. + */ +private fun KaSession.localTypeNameIn( + type: KaType?, + enclosing: KtDeclaration, +): String? { + val classType = ((type as? KaFlexibleType)?.lowerBound ?: type) as? KaClassType ?: return null + val psi = runCatching { classType.symbol.psi }.getOrNull() + if (psi != null && PsiTreeUtil.isAncestor(enclosing, psi, true)) { + return (classType.symbol as? KaNamedSymbol)?.name?.asString() + } + return classType.typeArguments.firstNotNullOfOrNull { localTypeNameIn(it.type, enclosing) } +} + +/** + * A captured declaration is one the region references whose PSI lies inside the enclosing + * declaration but outside the region itself. Anything else -- a class member, a top-level + * declaration, an import -- resolves unchanged from the new function's body (R5). + * + * Declines rather than emitting text that will not compile: a type that cannot be written out as + * source, or a value the region only uses through a smart cast. + */ +private fun KaSession.capturedParameters( + enclosing: KtDeclaration, + elements: List, + span: TextSpan, +): CaptureResult { + val parameters = mutableListOf() + val seen = mutableSetOf() + + for (reference in simpleNamesIn(elements).sortedBy { it.textRange.startOffset }) { + // Deliberately no "skip a qualified selector" guard here. A selector can still resolve to a + // declaration inside the enclosing declaration -- a local extension `fun` called as `h.twice()` + // -- which goes out of scope once the region moves, and skipping it emits a body that no longer + // resolves. The ancestor test below already lets every selector resolving to a non-local member + // through, which is what a guard would have bought. + val resolved = + runCatching { reference.mainReference?.resolveToSymbols()?.firstOrNull() }.getOrNull() ?: continue + val name = reference.getReferencedName() + + // A local class or object is not a callable, so it used to fail the cast below and be silently + // dropped -- emitting a body that names a type the new function cannot see. It is refused here + // for the same reason a local `fun` is: only values can be handed over as parameters (R5). + if (resolved is KaClassSymbol) { + val classPsi = runCatching { resolved.psi }.getOrNull() + if (classPsi != null && PsiTreeUtil.isAncestor(enclosing, classPsi, true) && !inRegion(classPsi, span)) { + return CaptureResult.Refused(ExtractionRefusal.CapturedLocalDeclaration(name)) + } + } + + val symbol = resolved as? KaCallableSymbol ?: continue + val declarationPsi = runCatching { symbol.psi }.getOrNull() + + val key: Any = + when { + declarationPsi != null -> { + if (!PsiTreeUtil.isAncestor(enclosing, declarationPsi, true)) continue + if (inRegion(declarationPsi, span)) continue + declarationPsi + } + + // `it` has no source PSI, so it would otherwise read as "not captured" and be dropped. + // Its binding lambda stands in for the missing declaration: captured only when that + // lambda is outside the region, and keyed on the lambda so that an `it` bound inside the + // region cannot evict a genuinely captured outer one. + symbol is KaValueParameterSymbol && + name == StandardNames.IMPLICIT_LAMBDA_PARAMETER_NAME.asString() -> { + val lambda = + PsiTreeUtil.getParentOfType(reference, KtFunctionLiteral::class.java, true) ?: continue + if (inRegion(lambda, span)) continue + lambda + } + + else -> { + continue + } + } + if (!seen.add(key)) continue + + // Only a value can be passed. A local `fun`, class or object declared outside the region goes + // out of scope once the region moves, and handing it over as a parameter of its own return type + // is not the same program (R5). + if (symbol !is KaVariableSymbol) { + return CaptureResult.Refused(ExtractionRefusal.CapturedLocalDeclaration(name)) + } + + // The value survives the move but its type may not: a local class declared inside the enclosing + // declaration is out of scope at the insertion point, so the parameter could not be written. + localTypeNameIn(runCatching { symbol.returnType }.getOrNull(), enclosing)?.let { + return CaptureResult.Refused(ExtractionRefusal.CapturedLocalDeclaration(it)) + } + + val typeText = + renderedSymbolType(symbol) ?: return CaptureResult.Refused(ExtractionRefusal.UnrenderableType) + // The signature must print the declared type, but the region may be leaning on a smart cast to + // something narrower: the declared type breaks the moved body, the narrowed one breaks the call + // site. + when (val used = usedTypeOf(reference)) { + // An intersection (`A & B`) cannot be printed at all, but the declared type just rendered + // fine, so the two differ and this is a smart cast however it would have been spelled. + UsedType.Unrenderable -> { + return CaptureResult.Refused(ExtractionRefusal.SmartCastParameter(name)) + } + + is UsedType.Rendered -> { + if (used.text != typeText) { + return CaptureResult.Refused(ExtractionRefusal.SmartCastParameter(name)) + } + } + + UsedType.Absent -> { + Unit + } + } + parameters += MethodParameter(name = name, typeText = typeText) + } + return CaptureResult.Captured(parameters) +} + +/** + * The type of a reference as the region uses it. + * + * [Unrenderable] is kept apart from [Absent] on purpose: folding them together is what let a smart + * cast to an intersection type pass as "no information" and emit the declared type. + */ +private sealed interface UsedType { + data object Absent : UsedType + + data object Unrenderable : UsedType + + data class Rendered( + val text: String, + ) : UsedType +} + +private fun KaSession.usedTypeOf(expression: KtExpression): UsedType { + val type = runCatching { expression.expressionType }.getOrNull() ?: return UsedType.Absent + return runCatching { typeTextOrNull(type) }.fold( + onSuccess = { rendered -> rendered?.let { UsedType.Rendered(it) } ?: UsedType.Unrenderable }, + onFailure = { UsedType.Absent }, + ) +} + +/** Either the derived parameter list or the reason there cannot be one. */ +private sealed interface CaptureResult { + data class Captured( + val parameters: List, + ) : CaptureResult + + data class Refused( + val refusal: ExtractionRefusal, + ) : CaptureResult +} + +private fun KaSession.renderedSymbolType(symbol: KaCallableSymbol): String? = + runCatching { symbol.returnType }.getOrNull()?.let { renderedTypeTextOrNull(it) } + +private fun KaSession.renderedTypeOrNull(expression: KtExpression): String? = + runCatching { expression.expressionType }.getOrNull()?.let { renderedTypeTextOrNull(it) } + +private fun KaSession.renderedDeclarationType(property: KtProperty): String? = + runCatching { (property.symbol as? KaCallableSymbol)?.returnType }.getOrNull()?.let { renderedTypeTextOrNull(it) } + +private fun KaSession.enclosingReturnType(enclosing: KtDeclaration): String? = + runCatching { (enclosing.symbol as? KaCallableSymbol)?.returnType }.getOrNull()?.let { renderedTypeTextOrNull(it) } + +/** + * What the region declares that the code after it still uses (R7). + * + * Every named declaration counts, not just [KtProperty]: a destructuring entry, a local `fun` and a + * local class are all things the following code can reference, and none of them can be returned. + * They are collected so [buildCandidate] can refuse them -- omitting them is what produced a call + * site referring to names that no longer exist. + * + * [writtenAfter] is the subset the following code assigns to. The call site emits a `val`, so even a + * single such output cannot be honoured. + */ +private class RegionOutputs( + val declarations: List, + val writtenAfter: List, +) { + companion object { + val NONE = RegionOutputs(emptyList(), emptyList()) + } +} + +/** + * "Used after the region" is a textual-offset test inside the enclosing declaration, which is sound + * because a local is only in scope after its own declaration in the same block. + */ +private fun KaSession.outputsOf( + enclosing: KtDeclaration, + elements: List, + span: TextSpan, +): RegionOutputs { + // Lambdas and parameters are named declarations too, and neither can be referenced after the + // region. Dropping them keeps the short-circuit below meaningful for any region holding a lambda, + // and keeps a lambda's "" out of a refusal message. + val declared = + descendantsOf(elements, KtNamedDeclaration::class.java) + .filterNot { it is KtFunctionLiteral || it is KtParameter } + if (declared.isEmpty()) return RegionOutputs.NONE + + val laterReferences = + PsiTreeUtil + .collectElementsOfType(enclosing, KtSimpleNameExpression::class.java) + .filter { it.textRange.startOffset >= span.end } + val read = laterReferences.filterNot { it.isWriteTarget() }.mapNotNullTo(mutableSetOf()) { resolvedPsi(it) } + val written = laterReferences.filter { it.isWriteTarget() }.mapNotNullTo(mutableSetOf()) { resolvedPsi(it) } + + return RegionOutputs( + declarations = declared.filter { it in read || it in written }, + writtenAfter = declared.filter { it in written }, + ) +} + +private fun KaSession.resolvedPsi(reference: KtSimpleNameExpression): PsiElement? = + runCatching { + reference.mainReference + ?.resolveToSymbols() + ?.firstOrNull() + ?.psi + }.getOrNull() + +/** + * A `var` declared inside the enclosing declaration but outside the region, assigned inside it. + * Kotlin has no `out` parameters, so the faithful emission would shadow a name (R7, ADR 0013). + */ +private fun KaSession.reassignedOuterVar( + enclosing: KtDeclaration, + elements: List, + span: TextSpan, +): String? { + for (reference in simpleNamesIn(elements)) { + if (!reference.isWriteTarget()) continue + val symbol = + runCatching { + (reference.mainReference?.resolveToSymbols()?.firstOrNull() as? KaVariableSymbol)?.takeIf { !it.isVal } + }.getOrNull() ?: continue + val declarationPsi = runCatching { symbol.psi }.getOrNull() ?: continue + if (!PsiTreeUtil.isAncestor(enclosing, declarationPsi, true)) continue + if (inRegion(declarationPsi, span)) continue + return reference.getReferencedName() + } + return null +} + +/** + * The declaration an unlabelled [returnExpression] returns from. + * + * A `KtFunctionLiteral` is skipped rather than accepted: a lambda is transparent to an unlabelled + * `return`, which targets the enclosing function declaration, so a non-local return out of a lambda in + * the region really does leave it. An anonymous `fun` is not transparent and is not a literal, so the + * same walk stops on it correctly. + */ +private fun returnOwner(returnExpression: KtReturnExpression): KtDeclarationWithBody? { + var owner = PsiTreeUtil.getParentOfType(returnExpression, KtDeclarationWithBody::class.java, true) + while (owner is KtFunctionLiteral) { + owner = PsiTreeUtil.getParentOfType(owner, KtDeclarationWithBody::class.java, true) + } + return owner +} + +/** + * Whether [returnExpression] returns from a function declared *inside* the region, so its jump never + * crosses the region boundary and it is not an exit (R8). + */ +private fun returnTargetInRegion( + returnExpression: KtReturnExpression, + span: TextSpan, +): Boolean { + val owner = returnOwner(returnExpression) ?: return false + return inRegion(owner, span) +} + +/** + * The tail-return exception (R8): the region's last statement is a `return` from [enclosing] itself, + * and it is the region's only `return`, `break` or `continue`. Purely syntactic, which is why it is + * worth having. + */ +private fun isTailReturn( + elements: List, + span: TextSpan, + enclosing: KtDeclaration, +): Boolean { + val tail = elements.last() as? KtReturnExpression ?: return false + /* + * A labelled tail return can never be legitimate here: if the label named a lambda inside the + * region, that lambda would have to contain the `return`, contradicting the `return` being a + * top-level element of the region. So the label always names something outside, and the `return` + * would move verbatim into a function where that label does not exist. + */ + if (tail.getLabelName() != null) return false + // The caller reads the return type off `enclosing`, so a tail `return` owned by anything else -- an + // anonymous `fun` wrapped around the region -- would take a type its own function never returns. + if (returnOwner(tail) !== enclosing) return false + val returns = + descendantsOf(elements, KtReturnExpression::class.java) + .filterNot { returnTargetInRegion(it, span) } + if (returns.size != 1 || returns.single() !== elements.last()) return false + return !hasLoopExit(elements, span) +} + +/** Any `return`, `break` or `continue` whose target lies outside the region (R8). */ +private fun hasExit( + elements: List, + span: TextSpan, +): Boolean { + for (returnExpression in descendantsOf(elements, KtReturnExpression::class.java)) { + if (returnTargetInRegion(returnExpression, span)) continue + // An unlabelled `return` always targets the enclosing named declaration, which is outside the + // region by construction. A labelled one targets the lambda carrying that label, which is not + // necessarily the nearest one -- `return@outer` from a nested lambda still leaves the region. + val label = returnExpression.getLabelName() ?: return true + val target = labelledLambdaFor(returnExpression, label) ?: return true + if (!inRegion(target, span)) return true + } + return hasLoopExit(elements, span) +} + +/** The lambda `return@[label]` targets: the innermost enclosing one carrying that label. */ +private fun labelledLambdaFor( + returnExpression: KtReturnExpression, + label: String, +): KtFunctionLiteral? { + var lambda = PsiTreeUtil.getParentOfType(returnExpression, KtFunctionLiteral::class.java, true) + while (lambda != null) { + if (lambdaLabel(lambda) == label) return lambda + lambda = PsiTreeUtil.getParentOfType(lambda, KtFunctionLiteral::class.java, true) + } + return null +} + +/** + * The label a `return@` can name this lambda by: its explicit `label@` if it has one, otherwise the + * name of the function it is an argument to. + */ +private fun lambdaLabel(lambda: KtFunctionLiteral): String? { + val lambdaExpression = lambda.parent as? KtLambdaExpression ?: return null + (lambdaExpression.parent as? KtLabeledExpression)?.getLabelName()?.let { return it } + return callOwning(lambdaExpression)?.calleeName() +} + +/** The call [lambdaExpression] is an argument of, trailing or parenthesised. */ +private fun callOwning(lambdaExpression: KtLambdaExpression): KtCallExpression? = + when (val argument = lambdaExpression.parent) { + is KtLambdaArgument -> argument.parent as? KtCallExpression + is KtValueArgument -> (argument.parent as? KtValueArgumentList)?.parent as? KtCallExpression + else -> null + } + +private fun KtCallExpression.calleeName(): String? = (calleeExpression as? KtNameReferenceExpression)?.getReferencedName() + +private fun hasLoopExit( + elements: List, + span: TextSpan, +): Boolean { + val jumps: List = + descendantsOf(elements, KtBreakExpression::class.java) + + descendantsOf(elements, KtContinueExpression::class.java) + return jumps.any { jump -> + val loop = targetLoopFor(jump) + loop == null || !inRegion(loop, span) + } +} + +/** + * The loop a `break`/`continue` leaves: the innermost enclosing one, or the one its label names. + * + * Reading the label matters for the same reason it does for a labelled `return` -- `break@outer` from + * a nested loop inside the region leaves the region, however local the nearest loop looks. + */ +private fun targetLoopFor(jump: KtExpressionWithLabel): KtLoopExpression? { + var loop = PsiTreeUtil.getParentOfType(jump, KtLoopExpression::class.java, true) + val label = jump.getLabelName() ?: return loop + while (loop != null) { + if ((loop.parent as? KtLabeledExpression)?.getLabelName() == label) return loop + loop = PsiTreeUtil.getParentOfType(loop, KtLoopExpression::class.java, true) + } + return null +} + +/** An accessor's type parameters live on its property, the same place its receiver does. */ +private fun typeParameterNamesOf(enclosing: KtDeclaration): List = + when (enclosing) { + is KtNamedFunction -> enclosing.typeParameters.mapNotNull { it.name } + is KtPropertyAccessor -> enclosing.property.typeParameters.mapNotNull { it.name } + else -> emptyList() + } + +/** + * The name of the enclosing function's type parameter the region *writes out*, or null. A filtered + * copy of the type-parameter list with its bounds is the alternative, and deciding "is `T` + * referenced" from rendered type text is exactly the fragility that rules it out (R10). + * + * This catches only a type the region names. A type argument the region gets by inference names + * nothing at all, and is caught by [renderedTypeParameterIn] once the signature exists. + */ +private fun typeParameterIn( + names: List, + elements: List, +): String? { + if (names.isEmpty()) return null + + val typeTexts = + descendantsOf(elements, KtTypeReference::class.java).map { it.text } + + simpleNamesIn(elements).map { it.getReferencedName() } + return names.firstOrNull { name -> typeTexts.any { it == name || it.containsWord(name) } } +} + +/** + * The type parameter that leaked into the derived signature, or null. + * + * `fun demo(a: T, b: T) { pick(a, b) }` names `T` nowhere in the region, but the parameters + * render as `T` -- and the new function has no type-parameter list to bind it. Checking the rendered + * strings is the only place that shows up before the text is emitted. + */ +private fun renderedTypeParameterIn( + names: List, + renderedTypes: List, +): String? { + if (names.isEmpty()) return null + return names.firstOrNull { name -> renderedTypes.any { it == name || it.containsWord(name) } } +} + +/** Whole-word containment, so `T` does not match `Type`. */ +private fun String.containsWord(word: String): Boolean = + Regex("(^|[^A-Za-z0-9_])" + Regex.escape(word) + "($|[^A-Za-z0-9_])").containsMatchIn(this) + +/** + * Whether the region reads or writes a property accessor's backing field (R4). + * + * `field` is in scope only inside the accessor, so it would move verbatim into the new function and + * stop resolving. Gated on the enclosing declaration being an accessor, which costs nothing + * everywhere else, and confirmed against the resolved symbol so a local that happens to be called + * `field` is not mistaken for it. + */ +private fun KaSession.usesBackingField( + enclosing: KtDeclaration, + elements: List, +): Boolean { + if (enclosing !is KtPropertyAccessor) return false + return simpleNamesIn(elements).any { reference -> + reference.getReferencedName() == BACKING_FIELD_NAME && + runCatching { reference.mainReference?.resolveToSymbols()?.firstOrNull() }.getOrNull() is KaBackingFieldSymbol + } +} + +/** + * The scoping construct whose implicit receiver the region uses unqualified, or null (R9). + * + * Turning that receiver into a parameter would mean qualifying every unqualified member access + * inside the extracted body -- editing the interior of the moved code, which this refactoring does + * not do. Android code leans on `with`/`apply` heavily, so the message names the construct. + * + * The question is asked of the resolved call rather than of a list of known scoping-function names: + * a name list both over-refuses (an inherited member or an outer-class member reached with no + * qualifier is not the receiver's) and under-refuses (it cannot know about `coroutineScope`, + * `buildAnnotatedString`, or any Compose scope). A receiver that is implicit and belongs to a lambda + * between the region and the enclosing declaration is exactly what does not survive the move. + */ +private fun KaSession.innerImplicitReceiver( + enclosing: KtDeclaration, + elements: List, + span: TextSpan, +): String? { + for (reference in simpleNamesIn(elements)) { + // A qualified selector already has its receiver written out next to it. Deliberately syntactic + // and deliberately shallow: a *call* selector (`h.doubled()`) must NOT be skipped, because its + // dispatch receiver can still be an implicit one -- a member extension invoked on a `with` + // receiver is the pervasive Compose shape (`with(density) { size.toPx() }`). + val parent = reference.parent + if (parent is KtQualifiedExpression && parent.selectorExpression === reference) continue + + val lambda = implicitReceiverLambdaFor(reference) ?: continue + if (isBoundOutsideRegion(enclosing, lambda, span)) return constructNameFor(lambda) + } + + // A bare `this` names the receiver without going through a call, so no resolved call reports it. + // Left undetected it does not fail to compile -- it silently becomes the enclosing class instance, + // which is worse. + for (thisExpression in descendantsOf(elements, KtThisExpression::class.java)) { + val symbol = + runCatching { + thisExpression.instanceReference.mainReference + ?.resolveToSymbols() + ?.firstOrNull() + }.getOrNull() + val lambda = lambdaOwning(symbol) ?: continue + if (isBoundOutsideRegion(enclosing, lambda, span)) return constructNameFor(lambda) + } + return null +} + +/** Whether [lambda] binds its receiver between the region and [enclosing], so the move loses it. */ +private fun isBoundOutsideRegion( + enclosing: KtDeclaration, + lambda: KtFunctionLiteral, + span: TextSpan, +): Boolean = !inRegion(lambda, span) && PsiTreeUtil.isAncestor(enclosing, lambda, true) + +private fun constructNameFor(lambda: KtFunctionLiteral): String = + (lambda.parent as? KtLambdaExpression)?.let { callOwning(it)?.calleeName() } ?: UNNAMED_SCOPING_CONSTRUCT + +/** + * The lambda supplying [reference]'s implicit receiver, or null when it has none or the receiver + * comes from somewhere that survives the move (a class, the enclosing function's own receiver). + */ +private fun KaSession.implicitReceiverLambdaFor(reference: KtSimpleNameExpression): KtFunctionLiteral? = + runCatching { + // A callee name does not resolve to a call on its own; its call expression does. + val callSource = + (reference.parent as? KtCallExpression)?.takeIf { it.calleeExpression === reference } ?: reference + val call = callSource.resolveToCall() + // Defensive only. A compound assignment (`n += 1` inside `apply { }`) redirects to the whole + // compound access, but the resolver flags that redirect and still hands back a plain variable + // access, so the branch above already catches it in this version. + val applied = + call?.successfulCallOrNull>()?.partiallyAppliedSymbol + ?: call?.successfulCallOrNull()?.variableCall?.partiallyAppliedSymbol + ?: call?.successfulCallOrNull()?.getterCall?.partiallyAppliedSymbol + receiverLambda(applied?.dispatchReceiver) ?: receiverLambda(applied?.extensionReceiver) + }.getOrNull() + +private fun receiverLambda(receiver: KaReceiverValue?): KtFunctionLiteral? = lambdaOwning((receiver as? KaImplicitReceiverValue)?.symbol) + +/** The lambda [symbol] belongs to, when it is a lambda's receiver rather than a class's. */ +private fun lambdaOwning(symbol: KaSymbol?): KtFunctionLiteral? { + if (symbol == null) return null + // A lambda's receiver reports itself either as the anonymous function or as that function's + // receiver parameter, and only the former carries the PSI. + val psi = + runCatching { symbol.psi }.getOrNull() + ?: runCatching { (symbol as? KaReceiverParameterSymbol)?.owningCallableSymbol?.psi }.getOrNull() + ?: return null + return psi as? KtFunctionLiteral ?: (psi as? KtLambdaExpression)?.functionLiteral +} + +/** + * The receiver the new function must repeat, or null (R4). + * + * An accessor's receiver is declared on its property (`val Foo.x get() = ...`), not on the accessor, + * so reading only the accessor drops it and the moved body's unqualified members stop resolving. + */ +private fun receiverTypeTextOf(enclosing: KtDeclaration): String? = + when (enclosing) { + is KtNamedFunction -> enclosing.receiverTypeReference?.text + is KtPropertyAccessor -> enclosing.property.receiverTypeReference?.text + else -> null + } + +/** + * `suspend` is added when the region calls one, or touches `coroutineContext` (R10). + * + * A suspension the region only performs inside a *nested* suspend-typed lambda does not count: the + * region carries that lambda with it, so the new function needs no `suspend`, and adding it breaks a + * call site that is not itself a suspend context. `scope.launch { }` and `runBlocking { }` are that + * shape, and "extract this whole launch block" is an everyday request. + */ +private fun KaSession.usesSuspend(elements: List): Boolean = + elements.any { root -> + PsiTreeUtil + .collectElementsOfType(root, KtSimpleNameExpression::class.java) + .any { it.getReferencedName() == COROUTINE_CONTEXT_NAME && !inNestedSuspendLambda(it, root) } || + PsiTreeUtil + .collectElementsOfType(root, KtCallExpression::class.java) + .any { isSuspendCall(it) && !inNestedSuspendLambda(it, root) } + } + +private fun KaSession.isSuspendCall(call: KtCallExpression): Boolean = + runCatching { + (call.resolveToCall()?.successfulFunctionCallOrNull()?.symbol as? KaNamedFunctionSymbol)?.isSuspend + }.getOrNull() == true + +/** + * Whether [element] sits inside a suspend-typed lambda that is itself inside [root]. + * + * An ordinary inline lambda -- `forEach`, `let`, `run` -- is not one, so a suspension inside it still + * propagates `suspend` outwards, which is correct: those bodies run in the caller's context. + */ +private fun KaSession.inNestedSuspendLambda( + element: PsiElement, + root: PsiElement, +): Boolean { + // Strict ancestors of [element] that are strict descendants of [root]. A lambda *containing* the + // region is not one of these: the region moves out of it, so the suspension is the new function's. + var current: PsiElement? = element.takeIf { it !== root }?.parent + while (current != null && current !== root) { + if (current is KtFunctionLiteral && isSuspendLambda(current)) return true + current = current.parent + } + return false +} + +/** + * Read off the lambda expression's own functional type rather than its symbol: the anonymous-function + * symbol in this Analysis API build carries no `suspend`, while the type inferred from the parameter + * it is passed to does. + */ +private fun KaSession.isSuspendLambda(lambda: KtFunctionLiteral): Boolean = + runCatching { + ((lambda.parent as? KtLambdaExpression)?.expressionType as? KaFunctionType)?.isSuspend + }.getOrNull() == true + +/** + * `@Composable` is added when the region uses one. Not polish: CoGo users write Compose apps on the + * device, and an extracted composable without the annotation does not compile (R10). + * + * Property *getters* count, not only calls. `MaterialTheme.colorScheme` and `LocalDensity.current` are + * annotated getters reached through a name reference, and they are as common in Compose code as any + * composable call. + */ +private fun KaSession.usesComposable(elements: List): Boolean = + descendantsOf(elements, KtCallExpression::class.java).any { call -> + runCatching { + call + .resolveToCall() + ?.successfulFunctionCallOrNull() + ?.symbol + ?.hasComposableAnnotation() + }.getOrNull() == true + } || + simpleNamesIn(elements).any { reference -> + runCatching { + reference.mainReference + .resolveToSymbols() + .filterIsInstance() + .any { it.getter?.hasComposableAnnotation() == true } + }.getOrNull() == true + } + +/** Whether [this] carries `@Composable`. */ +private fun KaAnnotatedSymbol.hasComposableAnnotation(): Boolean = annotations.any { it.classId?.asFqNameString() == COMPOSABLE_FQ_NAME } + +/** + * Names the new function must avoid (R12). + * + * [isLocalTarget] is tested first, and must be: a local `fun` inside a class member competes with the + * enclosing block's declarations, not with the class's members, and validating against the class + * instead lets the new local collide with a sibling local -- a redeclaration error. + * + * For a class target this is the whole member scope, **including inherited members**: a private + * function accidentally matching a supertype member is an accidental-override compile error. + * Rejecting any name match rather than only a signature match also means the refactoring never + * creates an overload the user did not ask for. + */ +private fun KaSession.takenNamesFor( + enclosing: KtDeclaration, + anchor: KtDeclaration, + isLocalTarget: Boolean, +): Set { + if (isLocalTarget) { + return PsiTreeUtil + .collectElementsOfType(anchor.parent, KtDeclaration::class.java) + .mapNotNull { it.name } + .toSet() + } + + val containingClass = PsiTreeUtil.getParentOfType(enclosing, KtClassOrObject::class.java, true) + if (containingClass != null) { + val fromScope = + runCatching { + (containingClass.symbol as? KaClassSymbol) + ?.memberScope + ?.callables + ?.mapNotNull { (it as? KaNamedSymbol)?.name?.asString() } + ?.toSet() + }.getOrNull().orEmpty() + val declared = containingClass.declarations.mapNotNull { it.name } + return fromScope + declared + } + + return enclosing.containingKtFile.declarations + .mapNotNull { it.name } + .toSet() +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/NameSuggestion.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/NameSuggestion.kt new file mode 100644 index 0000000000..c6cc362228 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/NameSuggestion.kt @@ -0,0 +1,155 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import org.jetbrains.kotlin.psi.KtArrayAccessExpression +import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtExpression +import org.jetbrains.kotlin.psi.KtNameReferenceExpression +import org.jetbrains.kotlin.psi.KtParenthesizedExpression +import org.jetbrains.kotlin.psi.KtQualifiedExpression +import org.jetbrains.kotlin.psi.KtStringTemplateExpression + +/** Used when neither the expression's shape nor its type suggests anything better. */ +const val FALLBACK_NAME = "value" + +/** + * Kotlin's hard keywords -- the ones that are never valid identifiers. Soft and modifier keywords + * (`by`, `data`, `it`, ...) are legal names and are deliberately absent. + */ +private val HARD_KEYWORDS = + setOf( + "as", + "break", + "class", + "continue", + "do", + "else", + "false", + "for", + "fun", + "if", + "in", + "interface", + "is", + "null", + "object", + "package", + "return", + "super", + "this", + "throw", + "true", + "try", + "typealias", + "typeof", + "val", + "var", + "when", + "while", + ) + +/** Why a proposed name cannot be used. Null-free alternative to throwing for user input. */ +enum class NameProblem { + Blank, + NotAnIdentifier, + Keyword, + AlreadyTaken, +} + +/** + * Validates a user-supplied name against Kotlin's identifier rules and the names already visible at + * the anchor point. Returns null when the name is usable. + * + * Backtick-quoted names are rejected rather than supported: they are legal Kotlin but a poor + * suggestion for a generated local, and accepting them would mean validating the quoted form too. + */ +fun validateVariableName( + name: String, + takenNames: Set, +): NameProblem? { + if (name.isBlank()) return NameProblem.Blank + if (!isIdentifier(name)) return NameProblem.NotAnIdentifier + if (name in HARD_KEYWORDS) return NameProblem.Keyword + if (name in takenNames) return NameProblem.AlreadyTaken + return null +} + +private fun isIdentifier(name: String): Boolean { + if (name.isEmpty()) return false + if (!(name[0].isLetter() || name[0] == '_')) return false + return name.all { it.isLetterOrDigit() || it == '_' } +} + +/** + * Suggests a name for the value [expression] produces. + * + * Tried in order: + * 1. **The expression's shape** -- `items.size` -> `size`, `a.b.c()` -> `c`, `getFoo()` -> `foo`, + * `foo(x)` -> `foo`, an interpolated string -> `text`, `xs[i]` -> `xs` element naming. + * 2. **The resolved type**, lowercased -- `List` -> `list`, `Duration` -> `duration`. Pass null + * when the type is unavailable. + * 3. [FALLBACK_NAME]. + * + * The result is then made unique against [takenNames] by appending `1`, `2`, ... Shape beats type + * because `size`, `count` and `name` are far better names than `int` and `string`, and type-derived + * names collide constantly. + */ +fun suggestVariableName( + expression: KtExpression, + typeName: String?, + takenNames: Set, +): String { + val base = + nameFromShape(expression) + ?: typeName?.let(::nameFromType) + ?: FALLBACK_NAME + val sanitised = base.takeIf { isIdentifier(it) && it !in HARD_KEYWORDS } ?: FALLBACK_NAME + return uniqueName(sanitised, takenNames) +} + +private fun nameFromShape(expression: KtExpression): String? = + when (expression) { + is KtParenthesizedExpression -> expression.expression?.let(::nameFromShape) + is KtQualifiedExpression -> expression.selectorExpression?.let(::nameFromShape) + is KtCallExpression -> (expression.calleeExpression as? KtNameReferenceExpression)?.getReferencedName()?.let(::stripAccessorPrefix) + is KtNameReferenceExpression -> expression.getReferencedName().let(::stripAccessorPrefix) + is KtStringTemplateExpression -> "text" + is KtArrayAccessExpression -> expression.arrayExpression?.let(::nameFromShape) + else -> null + }?.takeIf { it.isNotBlank() } + +/** `getFoo` -> `foo`, `isReady` -> `ready`. Leaves anything else alone. */ +private fun stripAccessorPrefix(name: String): String { + for (prefix in ACCESSOR_PREFIXES) { + if (name.length > prefix.length && + name.startsWith(prefix) && + name[prefix.length].isUpperCase() + ) { + return name.substring(prefix.length).decapitaliseFirst() + } + } + return name +} + +private val ACCESSOR_PREFIXES = listOf("get", "is", "has") + +/** `List` -> `list`, `kotlin.time.Duration` -> `duration`, `Array` -> `array`. */ +private fun nameFromType(typeName: String): String? = + typeName + .substringBefore('<') + .substringAfterLast('.') + .trimEnd('?', '!') + .takeIf { it.isNotBlank() } + ?.decapitaliseFirst() + +private fun String.decapitaliseFirst(): String = if (isEmpty()) this else this[0].lowercaseChar() + substring(1) + +/** `size` -> `size1` -> `size2` until nothing in [takenNames] matches. */ +internal fun uniqueName( + base: String, + takenNames: Set, +): String { + if (base !in takenNames) return base + var suffix = 1 + while ("$base$suffix" in takenNames) suffix++ + return "$base$suffix" +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/Occurrences.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/Occurrences.kt new file mode 100644 index 0000000000..9936b3cfec --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/Occurrences.kt @@ -0,0 +1,367 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaValueParameterSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaVariableSymbol +import org.jetbrains.kotlin.builtins.StandardNames +import org.jetbrains.kotlin.com.intellij.psi.PsiComment +import org.jetbrains.kotlin.com.intellij.psi.PsiElement +import org.jetbrains.kotlin.com.intellij.psi.PsiWhiteSpace +import org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.idea.references.mainReference +import org.jetbrains.kotlin.lexer.KtTokens +import org.jetbrains.kotlin.psi.KtBinaryExpression +import org.jetbrains.kotlin.psi.KtBlockExpression +import org.jetbrains.kotlin.psi.KtCallableDeclaration +import org.jetbrains.kotlin.psi.KtCatchClause +import org.jetbrains.kotlin.psi.KtClass +import org.jetbrains.kotlin.psi.KtClassOrObject +import org.jetbrains.kotlin.psi.KtDeclaration +import org.jetbrains.kotlin.psi.KtDestructuringDeclaration +import org.jetbrains.kotlin.psi.KtExpression +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtForExpression +import org.jetbrains.kotlin.psi.KtFunctionLiteral +import org.jetbrains.kotlin.psi.KtParameter +import org.jetbrains.kotlin.psi.KtPropertyAccessor +import org.jetbrains.kotlin.psi.KtSimpleNameExpression +import org.jetbrains.kotlin.psi.KtUnaryExpression +import org.jetbrains.kotlin.psi.KtWhenExpression +import org.jetbrains.kotlin.psi.psiUtil.parents +import org.jetbrains.kotlin.psi.psiUtil.parentsWithSelf + +/** + * Whether [a] and [b] are the same expression for extraction purposes: structurally identical *and* + * every name reference in them resolving to the same declaration. + * + * The symbol check is the whole point. Text or structure alone would happily match `config.timeout` + * inside a nested lambda where `config` is a different `config`, or an `it` that means something + * else -- replacing those would silently change behaviour. The parent ticket (ADFA-3324) states the + * standard outright: text-based matching breaks things. + */ +internal fun KaSession.isSameExpression( + a: PsiElement, + b: PsiElement, +): Boolean { + if (a === b) return true + if (a.node?.elementType != b.node?.elementType) return false + + if (a is KtSimpleNameExpression && b is KtSimpleNameExpression) { + if (a.getReferencedName() != b.getReferencedName()) return false + if (!resolvesToSameDeclaration(a, b)) return false + } + + val childrenA = meaningfulChildren(a) + val childrenB = meaningfulChildren(b) + if (childrenA.size != childrenB.size) return false + if (childrenA.isEmpty()) return a.text == b.text + return childrenA.indices.all { isSameExpression(childrenA[it], childrenB[it]) } +} + +/** Whitespace and comments are formatting, not structure, so they never affect equality. */ +private fun meaningfulChildren(element: PsiElement): List = + element.children.filter { it !is PsiWhiteSpace && it !is PsiComment } + +/** + * Whether two same-named references point at the same declaration. + * + * Source declarations are compared by PSI identity, which is exactly the question being asked ("the + * same `val`?"). Symbols without source PSI -- library members, compiler-generated declarations -- + * fall back to symbol equality. Resolution over broken code throws, and a throw here must read as + * "not the same" rather than crash the action. + */ +private fun KaSession.resolvesToSameDeclaration( + a: KtSimpleNameExpression, + b: KtSimpleNameExpression, +): Boolean = + runCatching { + val symbolA = a.mainReference?.resolveToSymbols()?.firstOrNull() ?: return false + val symbolB = b.mainReference?.resolveToSymbols()?.firstOrNull() ?: return false + val psiA = symbolA.declarationPsi() + val psiB = symbolB.declarationPsi() + if (psiA != null || psiB != null) psiA === psiB else symbolA == symbolB + }.getOrDefault(false) + +private fun KaSymbol.declarationPsi(): PsiElement? = runCatching { psi }.getOrNull() + +/** + * Every site in [searchRoot] within [searchRange] that is the same expression as [candidate] and is + * itself a legal place to put the variable reference. + * + * The legality filter matters: in `a.a`, a candidate of `a` matches the selector too, but rewriting + * a selector would produce `v.v`. Overlapping matches are dropped so no site is rewritten twice. + * Ascending by offset, and always contains [candidate] itself. + */ +internal fun KaSession.findOccurrences( + candidate: KtExpression, + searchRoot: PsiElement, + searchRange: TextSpan, +): List { + val elementType = candidate.node?.elementType + val matches = + PsiTreeUtil + .collectElements(searchRoot) { element -> + element.node?.elementType == elementType && + element is KtExpression && + element.textRange.startOffset >= searchRange.start && + element.textRange.endOffset <= searchRange.end + }.filterIsInstance() + .filter { it === candidate || (it.isLegalExtractionTarget() && isSameExpression(candidate, it)) } + .map { TextSpan(it.textRange.startOffset, it.textRange.endOffset) } + .sortedBy { it.start } + + val accepted = mutableListOf() + for (match in matches) { + if (accepted.none { it.overlaps(match) }) accepted += match + } + return accepted +} + +/** + * The innermost scope that must contain the declaration, or null when the candidate references + * nothing declared inside the enclosing scopes. + * + * This is what stops a hoist from escaping a lambda it depends on: if the candidate uses `it` or a + * lambda parameter, that lambda's body comes back as the ceiling and every outer rung of the scope + * chain is dropped by [truncateAtCeiling]. + */ +internal fun KaSession.referencedDeclarationCeiling(candidate: KtExpression): PsiElement? { + var deepest: PsiElement? = null + var deepestDepth = -1 + for (reference in candidate.collectDescendantsOfType()) { + val symbol = runCatching { reference.mainReference?.resolveToSymbols()?.firstOrNull() }.getOrNull() ?: continue + val body = constrainingBodyFor(reference, symbol) ?: continue + val depth = depthOf(body) + if (depth > deepestDepth) { + deepest = body + deepestDepth = depth + } + } + return deepest +} + +/** + * The scope [reference] pins the declaration inside, or null when it constrains nothing. + * + * A declaration outside the candidate's own scopes -- a class member, a top-level property, anything + * from a library -- constrains nothing; only locals and parameters do. + * + * The implicit-lambda-parameter branch below is **defensive, and unreachable in this Kotlin + * version**: `it` resolves to a value-parameter symbol whose PSI is the enclosing + * [KtFunctionLiteral] (`KtFakeSourceElementKind.ItLambdaParameter` is an allowed fake element kind), + * so the ordinary psi-based lookup already constrains it to that lambda. It is kept because a + * value-parameter symbol with no PSI referenced by the name `it` *is* by definition the implicit + * parameter of the innermost enclosing lambda -- a property of the language, not a guess about the + * text -- and without it a future version that stops supplying the PSI would silently hoist + * `it.length` clean out of its lambda into code that does not compile. + */ +private fun constrainingBodyFor( + reference: KtSimpleNameExpression, + symbol: KaSymbol, +): PsiElement? { + val declaration = runCatching { symbol.psi }.getOrNull() + if (declaration == null) { + if (symbol is KaValueParameterSymbol && reference.getReferencedName() == StandardNames.IMPLICIT_LAMBDA_PARAMETER_NAME.asString()) { + return PsiTreeUtil.getParentOfType(reference, KtFunctionLiteral::class.java, true)?.bodyExpression + } + return null + } + if (!PsiTreeUtil.isAncestor(reference.containingFile, declaration, false)) return null + return enclosingExecutableBody(declaration) +} + +private fun depthOf(element: PsiElement): Int = element.parents.count() + +private inline fun PsiElement.collectDescendantsOfType(): List = + PsiTreeUtil.collectElementsOfType(this, T::class.java).toList() + +/** + * Restricts [occurrences] to a contiguous run around [candidateSpan] that no write to a referenced + * mutable interrupts. + * + * A `var` the candidate reads can be reassigned between two occurrences, and then the two sites do + * not hold the same value even though they are the same expression: + * + * ``` + * var limit = 1 + * foo(limit + 1) // occurrence + * limit = 5 + * foo(limit + 1) // same expression, different value + * ``` + * + * Rather than warn, unsound sites are simply excluded, so "Replace all N occurrences" can never + * produce wrong code and N is always achievable. The walk grows outwards from the candidate -- never + * dropping the site the user actually selected -- and stops in each direction at the first write it + * would have to cross. + */ +internal fun excludeUnsoundOccurrences( + occurrences: List, + candidateSpan: TextSpan, + writeOffsets: List, +): List { + if (occurrences.isEmpty()) return occurrences + val ordered = occurrences.sortedBy { it.start } + val candidateIndex = ordered.indexOfFirst { it.start == candidateSpan.start && it.end == candidateSpan.end } + if (candidateIndex < 0) return listOf(candidateSpan) + + val writes = writeOffsets.sorted() + + fun writeBetween( + from: Int, + to: Int, + ): Boolean = writes.any { it in from until to } + + val accepted = mutableListOf(ordered[candidateIndex]) + for (i in candidateIndex - 1 downTo 0) { + if (writeBetween(ordered[i].end, ordered[candidateIndex].start)) break + accepted.add(0, ordered[i]) + } + for (i in candidateIndex + 1 until ordered.size) { + if (writeBetween(ordered[candidateIndex].end, ordered[i].start)) break + accepted += ordered[i] + } + return accepted +} + +/** + * Offsets of writes, within [searchRoot], to any mutable the candidate reads. Feeds + * [excludeUnsoundOccurrences]. + * + * Counts plain assignment, the augmented forms (`+=` and friends) and `++`/`--`. A `val` cannot be + * written, so only [KaVariableSymbol]s that report themselves mutable are tracked. + */ +internal fun KaSession.writeOffsetsFor( + candidate: KtExpression, + searchRoot: PsiElement, +): List { + val mutableDeclarations = + candidate + .collectDescendantsOfType() + .mapNotNull { reference -> + runCatching { + (reference.mainReference?.resolveToSymbols()?.firstOrNull() as? KaVariableSymbol) + ?.takeIf { !it.isVal } + ?.psi + }.getOrNull() + }.toSet() + if (mutableDeclarations.isEmpty()) return emptyList() + + return searchRoot + .collectDescendantsOfType() + .filter { it.isWriteTarget() } + .filter { reference -> + runCatching { + reference.mainReference + ?.resolveToSymbols() + ?.firstOrNull() + ?.psi + }.getOrNull() in mutableDeclarations + }.map { it.textRange.startOffset } +} + +/** Whether this reference is being written to rather than read. */ +internal fun KtSimpleNameExpression.isWriteTarget(): Boolean { + val parent = parent + if (parent is KtBinaryExpression && parent.left === this && parent.operationToken in ASSIGNMENT_TOKENS) return true + if (parent is KtUnaryExpression && parent.operationToken in INCREMENT_TOKENS) return true + return false +} + +private val ASSIGNMENT_TOKENS = + setOf(KtTokens.EQ, KtTokens.PLUSEQ, KtTokens.MINUSEQ, KtTokens.MULTEQ, KtTokens.DIVEQ, KtTokens.PERCEQ) + +private val INCREMENT_TOKENS = setOf(KtTokens.PLUSPLUS, KtTokens.MINUSMINUS) + +/** + * Names a new declaration at [candidate] would collide with or shadow. + * + * Walks outward from the candidate collecting only what is visible there: the parameters and local + * declarations of each enclosing block, lambda, function and accessor, the *declared* members of each + * enclosing class or object including its companion, and the file's top-level declarations. A local in + * a *sibling* function is deliberately absent -- it is invisible here, and treating it as taken refuses + * a legal name. + * + * Members inherited from a supertype are *not* in the set: finding them needs resolution, which a + * syntactic walk cannot do. A local may therefore still shadow an inherited member unnoticed. + * + * Enclosing members and top-level names stay in the set even though a local may legally shadow them: + * shadowing one changes what every *other* reference to that name in the block means. + * + * Purely syntactic, so it needs no analysis session and is unit-testable on its own. + */ +internal fun namesInScopeAt(candidate: KtExpression): Set { + val names = mutableSetOf() + candidate.containingKtFile.declarations.forEach { it.addNameTo(names) } + + for (ancestor in candidate.parentsWithSelf) { + when (ancestor) { + is KtFile -> { + break + } + + is KtClassOrObject -> { + ancestor.declarations.forEach { it.addNameTo(names) } + /* A companion's members are visible unqualified inside the class, but `declarations` holds + * only the companion itself, so its members need collecting separately. */ + (ancestor as? KtClass)?.companionObjects?.forEach { companion -> + companion.declarations.forEach { it.addNameTo(names) } + } + /* A plain constructor parameter is not a member: it is out of scope in a member function + * body, and treating it as taken there refuses a legal name. */ + ancestor.primaryConstructorParameters.filter { it.hasValOrVar() }.forEach { it.addNameTo(names) } + } + + is KtBlockExpression -> { + ancestor.statements.forEach { (it as? KtDeclaration)?.addNameTo(names) } + } + + is KtFunctionLiteral -> { + val parameters = ancestor.valueParameters + // A lambda with no declared parameter still binds `it`, which a local would shadow. + if (parameters.isEmpty()) names += StandardNames.IMPLICIT_LAMBDA_PARAMETER_NAME.asString() + parameters.forEach { it.addNameTo(names) } + } + + is KtPropertyAccessor -> { + ancestor.valueParameters.forEach { it.addNameTo(names) } + } + + is KtCallableDeclaration -> { + ancestor.valueParameters.forEach { it.addNameTo(names) } + } + + is KtForExpression -> { + ancestor.loopParameter?.addNameTo(names) + } + + is KtCatchClause -> { + ancestor.catchParameter?.addNameTo(names) + } + + is KtWhenExpression -> { + ancestor.subjectVariable?.addNameTo(names) + } + + else -> { + Unit + } + } + } + return names +} + +/** Adds this declaration's name, or each entry name when it destructures. */ +private fun KtDeclaration.addNameTo(names: MutableSet) { + val destructuring = + when (this) { + is KtDestructuringDeclaration -> this + is KtParameter -> destructuringDeclaration + else -> null + } + if (destructuring != null) { + destructuring.entries.forEach { entry -> entry.name?.let(names::add) } + return + } + name?.let(names::add) +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactoringPlan.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactoringPlan.kt new file mode 100644 index 0000000000..b58d6137a7 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactoringPlan.kt @@ -0,0 +1,13 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +/** + * What every interactive refactoring's background pass returns. + * + * The two fields are what makes applying a plan safe long after it was computed: [fileText] is the + * text its offsets refer to, and [documentVersion] is re-read on confirm so a plan computed against + * text the user has since edited is discarded rather than applied against shifted offsets. + */ +sealed interface RefactoringPlan { + val fileText: String + val documentVersion: Int +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt new file mode 100644 index 0000000000..d89c570e5a --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt @@ -0,0 +1,293 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import org.jetbrains.kotlin.com.intellij.psi.PsiElement +import org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.psi.KtAnonymousInitializer +import org.jetbrains.kotlin.psi.KtBlockExpression +import org.jetbrains.kotlin.psi.KtContainerNodeForControlStructureBody +import org.jetbrains.kotlin.psi.KtDeclarationWithBody +import org.jetbrains.kotlin.psi.KtDoWhileExpression +import org.jetbrains.kotlin.psi.KtExpression +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtForExpression +import org.jetbrains.kotlin.psi.KtFunctionLiteral +import org.jetbrains.kotlin.psi.KtIfExpression +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtPropertyAccessor +import org.jetbrains.kotlin.psi.KtWhenEntry +import org.jetbrains.kotlin.psi.KtWhileExpression + +/** + * One rung of the legal scope chain, before occurrences are known. + * + * [scopeElement] is the PSI node that *is* the scope, used to decide whether a referenced + * declaration lives inside it (see [truncateAtCeiling]). [searchRange] bounds the occurrence search + * for this rung. + */ +data class ScopeFrame( + val label: String, + val scopeElement: PsiElement, + val searchRange: TextSpan, + val anchorForm: AnchorForm, +) + +/** + * Enumerates the scopes [candidate] could be hoisted into, innermost first. + * + * Walks outward from the candidate's own statement. Each rung is one of the three [AnchorForm] + * shapes: a real block, a braceless statement position that needs braces, or an expression body that + * needs converting. The walk stops after the enclosing **named function, accessor or `init` block** + * body -- the ceiling agreed for this refactoring. A class body or file is never an anchor, so a + * property initializer outside any executable body yields nothing (already rejected earlier by + * [isExtractionPosition]). + * + * Lambda boundaries are *crossed* here: whether crossing is actually legal depends on what the + * candidate references, which needs resolution, so it is applied afterwards by [truncateAtCeiling]. + */ +fun enclosingScopeFrames(candidate: KtExpression): List { + val text = candidate.containingFile.text + val frames = mutableListOf() + var inner: PsiElement = candidate + + while (true) { + val parent = inner.parent ?: break + if (parent is KtFile) break + + val frame = frameFor(inner, text) + if (frame == null) { + // Most nodes are not themselves anchorable -- a value argument, an argument list, a lambda + // literal. Keep climbing rather than stopping, otherwise the chain would end at the first + // such node and, in particular, a candidate inside a lambda could never be hoisted out of + // it even when that is legal. + inner = parent + continue + } + + frames += frame + // A named function / accessor / init body is the ceiling: record it, then stop. + if (isCeilingBody(frame.scopeElement)) break + inner = frame.scopeElement.parent ?: break + } + return frames +} + +/** + * Drops the rungs that lie outside [ceiling] -- the innermost scope holding a declaration the + * candidate references. Passing null keeps the whole chain (nothing scoped inside was referenced). + * + * This is what enforces "crossing a lambda boundary is allowed only when nothing lambda-scoped is + * referenced": if the candidate uses `it` or a lambda parameter, the lambda body *is* the ceiling + * and every outer rung disappears. + */ +fun truncateAtCeiling( + frames: List, + ceiling: PsiElement?, +): List { + if (ceiling == null) return frames + val kept = frames.takeWhile { PsiTreeUtil.isAncestor(ceiling, it.scopeElement, false) || it.scopeElement === ceiling } + return kept.ifEmpty { frames.take(1) } +} + +/** + * Builds the rung whose scope directly contains [inner], or null when [inner] is not in a position + * this refactoring anchors in. + */ +private fun frameFor( + inner: PsiElement, + text: String, +): ScopeFrame? { + val parent = inner.parent ?: return null + + // A braceless control-structure body is wrapped in a container node, so the `if`/loop is the + // grandparent, not the parent. Without unwrapping, no braceless body is ever detected and the + // declaration silently hoists to the enclosing block instead of braces being added. + val controlOwner = (parent as? KtContainerNodeForControlStructureBody)?.parent + + if (parent is KtBlockExpression) { + return ScopeFrame( + label = blockLabel(parent), + scopeElement = parent, + searchRange = parent.textRange.let { TextSpan(it.startOffset, it.endOffset) }, + anchorForm = + AnchorForm.ExistingBlock( + contentSpan = contentSpanOf(parent), + statementSpans = + parent.statements.map { TextSpan(it.textRange.startOffset, it.textRange.endOffset) }, + ), + ) + } + + val bracelessOwner = controlOwner ?: parent + val bracelessLabel = bracelessOwnerLabel(inner, bracelessOwner) + if (bracelessLabel != null) { + val indent = leadingIndentAt(text, bracelessOwner.textRange.startOffset) + val span = TextSpan(inner.textRange.startOffset, inner.textRange.endOffset) + return ScopeFrame( + label = bracelessLabel, + scopeElement = inner, + searchRange = span, + anchorForm = + AnchorForm.WrapInBraces( + bodyStart = span.start, + bodyEnd = span.end, + indent = indent, + innerIndent = indent + detectIndentUnit(text), + ), + ) + } + + if (parent is KtDeclarationWithBody && parent.bodyExpression === inner && !parent.hasBlockBody()) { + val assign = parent.equalsToken ?: return null + val indent = leadingIndentAt(text, parent.textRange.startOffset) + val span = TextSpan(inner.textRange.startOffset, inner.textRange.endOffset) + return ScopeFrame( + label = declarationLabel(parent), + scopeElement = inner, + searchRange = span, + anchorForm = + AnchorForm.ConvertExpressionBody( + assignStart = assign.textRange.startOffset, + bodyStart = span.start, + bodyEnd = span.end, + indent = indent, + innerIndent = indent + detectIndentUnit(text), + // Filled in by the caller, which has the resolved return type. + needsReturn = true, + ), + ) + } + + return null +} + +/** True for the body of a named function, accessor or `init` block -- where the chain stops. */ +private fun isCeilingBody(scopeElement: PsiElement): Boolean { + val owner = scopeElement.parent ?: return false + return when (owner) { + is KtNamedFunction, is KtPropertyAccessor, is KtAnonymousInitializer -> true + else -> false + } +} + +/** + * The name shown for a block rung. + * + * A braceless *or* braced control-structure body is wrapped in a container node, so the `if`/loop is + * the block's grandparent; without unwrapping, every braced branch reads as a generic "block". + * `getThen()`/`getElse()` return the unwrapped body expression, never the container, so branch + * identity is decided by checking if the container's parent matches what `then`/`else` point at + * (by comparing `owner.then?.parent === container`). + */ +private fun blockLabel(block: KtBlockExpression): String { + val parent = block.parent + val container = parent as? KtContainerNodeForControlStructureBody + return when (val owner = container?.parent ?: parent) { + is KtNamedFunction -> "fun ${owner.name ?: ""}" + is KtPropertyAccessor -> if (owner.isGetter) "getter" else "setter" + is KtAnonymousInitializer -> "init block" + is KtFunctionLiteral -> "lambda" + is KtIfExpression -> if (owner.then?.parent === container) "if block" else "else block" + is KtForExpression -> "for loop" + is KtWhileExpression -> "while loop" + is KtDoWhileExpression -> "do-while loop" + is KtWhenEntry -> "when branch" + else -> "block" + } +} + +private fun declarationLabel(declaration: KtDeclarationWithBody): String = + when (declaration) { + is KtNamedFunction -> "fun ${declaration.name ?: ""}" + is KtPropertyAccessor -> if (declaration.isGetter) "getter" else "setter" + else -> "body" + } + +/** A label when [inner] is a braceless body, else null. */ +private fun bracelessOwnerLabel( + inner: PsiElement, + parent: PsiElement, +): String? = + when (parent) { + is KtIfExpression -> { + if (parent.then === inner) { + "if branch" + } else if (parent.`else` === inner) { + "else branch" + } else { + null + } + } + + is KtForExpression -> { + if (parent.body === inner) "for body" else null + } + + is KtWhileExpression -> { + if (parent.body === inner) "while body" else null + } + + is KtDoWhileExpression -> { + if (parent.body === inner) "do-while body" else null + } + + is KtWhenEntry -> { + if (parent.expression === inner) "when branch" else null + } + + else -> { + null + } + } + +/** + * The region inside a block's braces. + * + * A function, `if` or loop body owns its braces, so they are trimmed off. A lambda body block does not + * -- the braces and any `param ->` header belong to the enclosing function literal -- so its own range + * already *is* the content, which is what keeps the header on the brace line when the block is + * expanded. Ownership is decided structurally, by the block's parent, rather than by sniffing the + * block's own text for a leading `{` and trailing `}`: a lambda body whose sole statement is itself a + * lambda literal (`{ x -> { x + 1 } }`) has text that looks brace-owned, and sniffing it would trim off + * that inner lambda's own braces and return its interior instead of the outer body's full content. + */ +internal fun contentSpanOf(block: KtBlockExpression): TextSpan { + val range = block.textRange + return if (block.parent is KtFunctionLiteral) { + TextSpan(range.startOffset, range.endOffset) + } else { + TextSpan(range.startOffset + 1, range.endOffset - 1) + } +} + +/** Offset of the start of the line containing [offset]. */ +internal fun lineStartOffset( + text: String, + offset: Int, +): Int = text.lastIndexOf('\n', (offset - 1).coerceAtLeast(0)).let { if (it < 0) 0 else it + 1 } + +/** The run of spaces/tabs at the start of [offset]'s line. */ +internal fun leadingIndentAt( + text: String, + offset: Int, +): String { + val lineStart = lineStartOffset(text, offset) + return text.substring(lineStart, offset.coerceAtLeast(lineStart)).takeWhile { it == ' ' || it == '\t' } +} + +/** + * One indentation level for [text], inferred from its own lines: a tab if any line is tab-indented, + * otherwise the smallest positive run of leading spaces, defaulting to a tab (the project + * convention). Code-action edits bypass the editor's auto-indent, so emitted text must already match + * the file's style. Mirrors the detection in `ImplementMembersAction`. + */ +internal fun detectIndentUnit(text: String): String { + var minSpaces = Int.MAX_VALUE + for (line in text.splitToSequence('\n')) { + if (line.isEmpty()) continue + if (line[0] == '\t') return "\t" + if (line[0] != ' ') continue + val spaces = line.takeWhile { it == ' ' }.length + if (spaces in 1 until minSpaces) minSpaces = spaces + } + return if (minSpaces == Int.MAX_VALUE) "\t" else " ".repeat(minSpaces) +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt new file mode 100644 index 0000000000..799186d4ab --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt @@ -0,0 +1,143 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import com.itsaky.androidide.lsp.kotlin.utils.renderName +import org.jetbrains.kotlin.analysis.api.KaExperimentalApi +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.renderer.types.impl.KaTypeRendererForSource +import org.jetbrains.kotlin.analysis.api.types.KaFlexibleType +import org.jetbrains.kotlin.analysis.api.types.KaType +import org.jetbrains.kotlin.psi.KtFile + +/** + * Types are rendered **fully qualified** and only then shortened against what the file can resolve. + * + * A short name resolves only when the file imports it or it comes from a default-imported package, and + * a refactoring that adds imports would be a much larger change -- so qualified is the safe starting + * point and [shortenTypeText] gives back readability where it provably costs nothing. + */ +@OptIn(KaExperimentalApi::class) +private val QUALIFIED_TYPE_RENDERER = KaTypeRendererForSource.WITH_QUALIFIED_NAMES + +/** Packages whose simple names resolve with no import at all on the JVM/Android target. */ +private val DEFAULT_IMPORTED_PACKAGES = + setOf( + "kotlin", + "kotlin.annotation", + "kotlin.collections", + "kotlin.comparisons", + "kotlin.io", + "kotlin.jvm", + "kotlin.ranges", + "kotlin.sequences", + "kotlin.text", + "java.lang", + ) + +/** A dotted run of identifiers -- one qualified name inside rendered type text. */ +private val QUALIFIED_NAME = Regex("""[\p{L}_][\p{L}\p{Nd}_]*(?:\.[\p{L}_][\p{L}\p{Nd}_]*)+""") + +/** + * A type that cannot be written out as source -- anonymous, intersection, a resolution error, or a + * platform type the renderer could not reduce (`List`, where the `!` is on a type argument). + * `!` is not Kotlin syntax anywhere, so its presence alone settles it. + * + * The `"anonymous"` and `"ERROR"` substring checks are not unambiguous -- a real type named + * `com.example.AnonymousUser` or `p.ERRORS` would also match. Both fail safe: a false positive only + * declines the rung instead of emitting a block body that does not compile, so the heuristic is left + * as-is rather than made precise. + */ +internal fun isUnrenderableTypeText(text: String): Boolean = + text.isBlank() || + text.contains("anonymous") || + text.contains("ERROR") || + text.contains(" & ") || + text.contains('!') + +/** + * One type as source text, fully qualified, or null when it cannot be written out. + * + * A platform type is unwrapped to its lower bound first: the renderer prints `String!`, which does not + * parse. Only the outermost bound is unwrapped, so a `!` on a type argument still reaches + * [isUnrenderableTypeText]. + * + * Lets a failure from the renderer itself propagate, so a caller that must tell "the renderer threw" + * from "the type is unrenderable" can. [renderedTypeTextOrNull] is the catching form most callers want. + */ +@OptIn(KaExperimentalApi::class) +internal fun KaSession.typeTextOrNull(type: KaType): String? = + renderName((type as? KaFlexibleType)?.lowerBound ?: type, QUALIFIED_TYPE_RENDERER) + .takeUnless(::isUnrenderableTypeText) + +internal fun KaSession.renderedTypeTextOrNull(type: KaType): String? = runCatching { typeTextOrNull(type) }.getOrNull() + +/** + * Replaces each qualified name in [rendered] with its simple name when that name already resolves in + * the file -- because the file imports it exactly, star-imports its package, or it comes from a + * default-imported package. Everything else stays qualified: verbose, but it always compiles. + * + * Purely textual, so it needs no analysis session and is unit-testable on its own. A nested class + * (`com.example.Outer.Inner`) is only shortened by an import of the nested name itself; an import of + * the outer class leaves it alone rather than emitting an unresolvable `Inner`. + * + * A star import is trusted only when nothing else in the file imports the same simple name from a + * different package -- that explicit import would resolve first, so writing the short name here would + * silently name the wrong type. + */ +internal fun shortenTypeText( + rendered: String, + importedNames: Set, + starImportedPackages: Set, +): String = + QUALIFIED_NAME.replace(rendered) { match -> + val qualified = match.value + val container = qualified.substringBeforeLast('.') + val simpleName = qualified.substringAfterLast('.') + val resolvable = + qualified in importedNames || + container in DEFAULT_IMPORTED_PACKAGES || + (container in starImportedPackages && importedNames.none { it.endsWith(".$simpleName") }) + if (resolvable) simpleName else qualified + } + +/** The fully qualified names [file] imports by name. Syntactic: no analysis session needed. */ +internal fun importedNamesOf(file: KtFile): Set = + file.importDirectives + .filterNot { it.isAllUnder } + .mapNotNullTo(mutableSetOf()) { it.importedFqName?.asString() } + +/** The packages [file] star-imports (`import com.example.*`). */ +internal fun starImportedPackagesOf(file: KtFile): Set = + file.importDirectives + .filter { it.isAllUnder } + .mapNotNullTo(mutableSetOf()) { it.importedFqName?.asString() } + +/** + * Whether [text] is the `Unit` type written as source, qualified or not. + * + * Exact match only: `kotlin.Unit?` is a different type, and a user type named `MyUnit` is not this one. + */ +internal fun isUnitTypeText(text: String): Boolean = text == "Unit" || text == "kotlin.Unit" + +/** + * Reconciles the `return`/written-type pair for an expression-body conversion. + * + * A `Unit` return needs neither, so a rendered `Unit` means the resolved-type check disagreed with the + * text that is about to be written into the signature -- and the text is what lands in the file. It + * therefore wins, retracting both. Without this, a failure to answer "is this `Unit`?" produces + * `fun show(text: String): Unit { ... return report(length) }`: compilable, but not what was asked for. + * + * The two components of the returned pair are never inconsistent: no `return` implies a `Unit` return, + * which needs no written type either, so a retracted `return` retracts the type with it. The rewrite + * reads the two independently, and the other pairing would emit `fun f(): Int { val v = ...; expr }`. + */ +internal fun normalizeExpressionBodyReturn( + needsReturn: Boolean, + returnTypeText: String?, +): Pair = + if (!needsReturn) { + false to null + } else if (returnTypeText != null && isUnitTypeText(returnTypeText)) { + false to null + } else { + needsReturn to returnTypeText + } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionTooltipTagTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionTooltipTagTest.kt index 117ce7ef9e..866ee26d4d 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionTooltipTagTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionTooltipTagTest.kt @@ -2,14 +2,17 @@ package com.itsaky.androidide.lsp.kotlin import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.lsp.actions.CommentLineAction +import com.itsaky.androidide.lsp.actions.SurroundWithTryCatchAction import com.itsaky.androidide.lsp.actions.UncommentLineAction import com.itsaky.androidide.lsp.kotlin.KotlinCodeActionsMenu.KT_LANG import com.itsaky.androidide.lsp.kotlin.actions.AddImportAction +import com.itsaky.androidide.lsp.kotlin.actions.ExtractMethodAction +import com.itsaky.androidide.lsp.kotlin.actions.ExtractVariableAction +import com.itsaky.androidide.lsp.kotlin.actions.FindReferencesAction import com.itsaky.androidide.lsp.kotlin.actions.GoToDefinitionAction import com.itsaky.androidide.lsp.kotlin.actions.ImplementMembersAction import com.itsaky.androidide.lsp.kotlin.actions.NullSafetyAction import com.itsaky.androidide.lsp.kotlin.actions.OrganizeImportsAction -import com.itsaky.androidide.lsp.kotlin.actions.SurroundWithTryCatchAction import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -25,7 +28,7 @@ import org.junit.Test */ class KotlinCodeActionTooltipTagTest { private val actualTags - get() = KotlinCodeActionsMenu.actions.associate { it.id to it.tooltipTag } + get() = KotlinCodeActionsMenu.actions.associate { it.id to it.retrieveTooltipTag(false) } @Test fun `every kotlin code action maps to its own tooltip tag`() { @@ -34,11 +37,15 @@ class KotlinCodeActionTooltipTagTest { CommentLineAction.idFor(KT_LANG) to TooltipTag.EDITOR_CODE_ACTIONS_KT_COMMENT, UncommentLineAction.idFor(KT_LANG) to TooltipTag.EDITOR_CODE_ACTIONS_KT_UNCOMMENT, GoToDefinitionAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_GOTO_DEF, + FindReferencesAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_FIND_REFS, AddImportAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_IMPORT_CLASS, OrganizeImportsAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_ORGANIZE_IMPORTS, NullSafetyAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_NULL_SAFETY_FIX, ImplementMembersAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_IMPLEMENT_MEMBERS, - SurroundWithTryCatchAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_SURROUND_TRY_CATCH, + SurroundWithTryCatchAction.idFor(KT_LANG) to + TooltipTag.EDITOR_CODE_ACTIONS_KT_SURROUND_TRY_CATCH, + ExtractVariableAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_EXTRACT_VARIABLE, + ExtractMethodAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_EXTRACT_METHOD, ) assertEquals(expected, actualTags) } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/AnalysisSerializationTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/AnalysisSerializationTest.kt index 72d55ce4d2..319917428d 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/AnalysisSerializationTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/AnalysisSerializationTest.kt @@ -418,6 +418,177 @@ class AnalysisSerializationTest : KtLspTest() { assertThat(newerRan.get()).isTrue() } + /** + * ADR 0011's central property. Two user-invoked commands must not discard each other - before + * [AnalysisPriority.COMMAND] existed they both ran at [AnalysisPriority.INTERACTIVE], where the + * newer one superseded the older and the older silently produced nothing. + * + * The holder polls its own checker while waiting. Preemption is cooperative, so a holder that only + * blocks would keep the lock even when wrongly flagged, the second command could not enter before + * the release either way, and the entry assertions alone would pass with + * [AnalysisPriority.supersedesSamePriority] set on [AnalysisPriority.COMMAND]. + */ + @Test(timeout = 10_000) + fun `a command does not supersede an in-flight command`() { + val holderChecker = ScheduledCancelChecker(ICancelChecker.NOOP) + val holding = CountDownLatch(1) + val release = CountDownLatch(1) + val firstPreempted = AtomicBoolean(false) + val secondEntered = AtomicBoolean(false) + + val first = + Thread { + try { + withAnalysisLock(AnalysisPriority.COMMAND, holderChecker) { + holding.countDown() + while (!release.await(10, TimeUnit.MILLISECONDS)) { + holderChecker.abortIfCancelled() + } + } + } catch (e: AnalysisPreemptedException) { + firstPreempted.set(true) + } + } + first.start() + assertThat(holding.await(5, TimeUnit.SECONDS)).isTrue() + + val second = + Thread { + withAnalysisLock(AnalysisPriority.COMMAND, ScheduledCancelChecker(ICancelChecker.NOOP)) { + secondEntered.set(true) + } + } + second.start() + + // Give the second command time to (incorrectly) barge in. + Thread.sleep(300) + val enteredWhileHeld = secondEntered.get() + + release.countDown() + first.join(5_000) + second.join(5_000) + + assertThat(firstPreempted.get()).isFalse() + assertThat(enteredWhileHeld).isFalse() + assertThat(secondEntered.get()).isTrue() + } + + @Test(timeout = 10_000) + fun `a command preempts an in-flight diagnostics`() { + val holderChecker = ScheduledCancelChecker(ICancelChecker.NOOP) + val holding = CountDownLatch(1) + val preempted = AtomicBoolean(false) + val commandRan = AtomicBoolean(false) + + val diagnostics = + Thread { + try { + withAnalysisLock(AnalysisPriority.DIAGNOSTICS, holderChecker) { + holding.countDown() + repeat(2_000) { + holderChecker.abortIfCancelled() + Thread.sleep(5) + } + } + } catch (e: AnalysisPreemptedException) { + preempted.set(true) + } + } + diagnostics.start() + assertThat(holding.await(5, TimeUnit.SECONDS)).isTrue() + + val command = + Thread { + withAnalysisLock(AnalysisPriority.COMMAND, ScheduledCancelChecker(ICancelChecker.NOOP)) { + commandRan.set(true) + } + } + command.start() + command.join(5_000) + diagnostics.join(5_000) + + assertThat(preempted.get()).isTrue() + assertThat(commandRan.get()).isTrue() + } + + /** + * The cost ADR 0011 accepts in exchange for typing responsiveness: a command *is* preemptable, so + * every command call site retries (see [retryingOnPreemption]). + */ + @Test(timeout = 10_000) + fun `keystroke-driven work preempts an in-flight command`() { + val holderChecker = ScheduledCancelChecker(ICancelChecker.NOOP) + val holding = CountDownLatch(1) + val preempted = AtomicBoolean(false) + val completionRan = AtomicBoolean(false) + + val command = + Thread { + try { + withAnalysisLock(AnalysisPriority.COMMAND, holderChecker) { + holding.countDown() + repeat(2_000) { + holderChecker.abortIfCancelled() + Thread.sleep(5) + } + } + } catch (e: AnalysisPreemptedException) { + preempted.set(true) + } + } + command.start() + assertThat(holding.await(5, TimeUnit.SECONDS)).isTrue() + + val completion = + Thread { + withAnalysisLock(AnalysisPriority.INTERACTIVE, ScheduledCancelChecker(ICancelChecker.NOOP)) { + completionRan.set(true) + } + } + completion.start() + completion.join(5_000) + command.join(5_000) + + assertThat(preempted.get()).isTrue() + assertThat(completionRan.get()).isTrue() + } + + @Test(timeout = 10_000) + fun `retryingOnPreemption runs a preempted attempt exactly once more with a fresh checker`() { + val attempts = AtomicInteger(0) + + val result = + retryingOnPreemption(ICancelChecker.NOOP, "test") { checker -> + // A latched checker would abort the retry immediately, so each attempt must get its own. + assertThat(checker.isCancelled()).isFalse() + if (attempts.incrementAndGet() == 1) { + checker.preempt() + checker.abortIfCancelled() + } + "done" + } + + assertThat(attempts.get()).isEqualTo(2) + assertThat(result).isEqualTo("done") + } + + @Test(timeout = 10_000) + fun `retryingOnPreemption propagates a second preemption rather than looping`() { + val attempts = AtomicInteger(0) + + val thrown = + runCatching { + retryingOnPreemption(ICancelChecker.NOOP, "test") { checker -> + attempts.incrementAndGet() + checker.preempt() + checker.abortIfCancelled() + } + }.exceptionOrNull() + + assertThat(attempts.get()).isEqualTo(2) + assertThat(thrown).isInstanceOf(AnalysisPreemptedException::class.java) + } + @Test(timeout = 10_000) fun `same priority diagnostics does not preempt an in-flight diagnostics`() { val holding = CountDownLatch(1) diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/navigation/FindUsagesLiveDocumentTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/navigation/FindUsagesLiveDocumentTest.kt new file mode 100644 index 0000000000..6e3c18539c --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/navigation/FindUsagesLiveDocumentTest.kt @@ -0,0 +1,93 @@ +package com.itsaky.androidide.lsp.kotlin.navigation + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.eventbus.events.editor.DocumentCloseEvent +import com.itsaky.androidide.eventbus.events.editor.DocumentOpenEvent +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import com.itsaky.androidide.lsp.models.ReferenceParams +import com.itsaky.androidide.models.Position +import com.itsaky.androidide.progress.ICancelChecker +import com.itsaky.androidide.projects.FileManager +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Test +import java.nio.file.Path + +/** + * R5's live-buffer tier: a usage that exists only in an unsaved editor buffer must still be found. + * + * Separate from [FindUsagesTest] because it needs `enableParserEventSystem`, so that the `KtFile` built + * from the buffer is physical the way production's is (see `KtLspTestEnvironment`). + * + * This is the case find usages is most often run in - you search *while* editing - and the one a + * disk-only prefilter silently gets wrong: the file would never be selected as a candidate, so it would + * never be parsed and the usage would simply not appear. + */ +class FindUsagesLiveDocumentTest : KtLspTest() { + override val enableParserEventSystem = true + + private val openedPaths = mutableListOf() + + @After + fun closeDocs() { + openedPaths.forEach { FileManager.onDocumentClose(DocumentCloseEvent(it)) } + openedPaths.clear() + } + + private fun openDocument( + path: Path, + content: String, + ) { + FileManager.onDocumentOpen(DocumentOpenEvent(path, content, 1)) + openedPaths.add(path) + } + + @Test + fun `a usage typed into an unsaved buffer is found`() { + val declarationText = "fun target() {}" + val declaration = createSourceFile("Declaration.kt", declarationText) + val declarationPath = Path.of(declaration.virtualFile.path) + + // On disk this file contains no usage at all, so a prefilter reading saved bytes would skip it. + val usage = createSourceFile("Usage.kt", "fun caller() { }") + val usagePath = Path.of(usage.virtualFile.path) + val editedText = "fun caller() { target() }" + openDocument(usagePath, editedText) + + val params = + ReferenceParams( + declarationPath, + Position(0, 0, declarationText.indexOf("target")), + true, + ICancelChecker.NOOP, + ) + val locations = runBlocking { context(env) { findUsagesAt(params) } }.locations + + assertThat(locations).hasSize(1) + assertThat(locations[0].file).isEqualTo(usagePath) + assertThat(locations[0].range.start.index).isEqualTo(editedText.indexOf("target()")) + } + + @Test + fun `a usage deleted in an unsaved buffer is not reported`() { + val declarationText = "fun target() {}" + val declaration = createSourceFile("GoneDeclaration.kt", declarationText) + val declarationPath = Path.of(declaration.virtualFile.path) + + // The saved bytes still mention the name, so this file is still a candidate; it is resolution, + // not the prefilter, that must reject it. + val usage = createSourceFile("GoneUsage.kt", "fun caller() { target() }") + val usagePath = Path.of(usage.virtualFile.path) + openDocument(usagePath, "fun caller() { }") + + val params = + ReferenceParams( + declarationPath, + Position(0, 0, declarationText.indexOf("target")), + true, + ICancelChecker.NOOP, + ) + + assertThat(runBlocking { context(env) { findUsagesAt(params) } }.locations).isEmpty() + } +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/navigation/FindUsagesTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/navigation/FindUsagesTest.kt new file mode 100644 index 0000000000..ae894b71f1 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/navigation/FindUsagesTest.kt @@ -0,0 +1,326 @@ +package com.itsaky.androidide.lsp.kotlin.navigation + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import com.itsaky.androidide.lsp.kotlin.fixtures.TestSourceModuleSpec +import com.itsaky.androidide.lsp.models.ReferenceParams +import com.itsaky.androidide.models.Position +import com.itsaky.androidide.progress.ICancelChecker +import kotlinx.coroutines.runBlocking +import org.junit.Test +import java.nio.file.Path + +/** + * The search itself: match set, visibility-derived scope, candidate selection and matching. + * + * Driven through `findUsagesAt`/`planAt` rather than the individual helpers, so each case exercises + * the real request path. + */ +class FindUsagesTest : KtLspTest() { + override val moduleSpecs = + listOf( + TestSourceModuleSpec("lib"), + TestSourceModuleSpec("app", dependsOn = listOf("lib")), + ) + + private class Source( + val path: Path, + val text: String, + ) + + private fun source( + module: String, + name: String, + text: String, + ): Source = Source(Path.of(createSourceFile(module, name, text).virtualFile.path), text) + + private fun paramsAt( + source: Source, + marker: String, + delta: Int = 0, + cancelChecker: ICancelChecker = ICancelChecker.NOOP, + ): ReferenceParams { + val offset = + source.text.indexOf(marker).also { check(it >= 0) { "marker '$marker' not in source" } } + delta + return ReferenceParams(source.path, Position(0, 0, offset), true, cancelChecker) + } + + /** Usages for a caret at `marker + delta` in [source], as `fileName:startOffset` pairs. */ + private fun usagesAt( + source: Source, + marker: String, + delta: Int = 0, + cancelChecker: ICancelChecker = ICancelChecker.NOOP, + ): List = + runBlocking { + context(env) { findUsagesAt(paramsAt(source, marker, delta, cancelChecker)) } + .locations + .map { "${it.file.fileName}:${it.range.start.index}" } + } + + private fun scopeAt( + source: Source, + marker: String, + delta: Int = 0, + ): UsageSearchScope? = + runBlocking { + context(env) { planAt(paramsAt(source, marker, delta))?.scope } + } + + private fun expected( + source: Source, + vararg markers: String, + ): List = + markers.map { marker -> + val index = source.text.indexOf(marker).also { check(it >= 0) { "marker '$marker' not in source" } } + "${source.path.fileName}:$index" + } + + @Test + fun `a same-file call is a usage`() { + val file = source("app", "SameFile.kt", "fun target() {}\nfun caller() { target() }") + + assertThat(usagesAt(file, "fun target", delta = 5)).isEqualTo(expected(file, "target() }")) + } + + @Test + fun `every call in the file is reported, ordered by offset`() { + val text = "fun target() {}\nfun a() { target() }\nfun b() { target() }" + val file = source("app", "Many.kt", text) + + val usages = usagesAt(file, "fun target", delta = 5) + + assertThat(usages).hasSize(2) + assertThat(usages).isEqualTo( + listOf( + "Many.kt:${text.indexOf("target() }")}", + "Many.kt:${text.lastIndexOf("target() }")}", + ), + ) + } + + @Test + fun `the declaration itself is never reported`() { + // includeDeclaration is ignored (R7): a target with no usages must come back empty so the editor + // flashes "no references" rather than silently selecting the declaration the caret is already on. + val file = source("app", "Unused.kt", "fun unused() {}") + + assertThat(usagesAt(file, "fun unused", delta = 5)).isEmpty() + } + + @Test + fun `an inter-file call in the same module is a usage`() { + val declaration = source("app", "Decl.kt", "fun shared() {}") + val usage = source("app", "Use.kt", "fun caller() { shared() }") + + assertThat(usagesAt(declaration, "fun shared", delta = 5)).isEqualTo(expected(usage, "shared()")) + } + + @Test + fun `an inter-module call is a usage`() { + val declaration = source("lib", "LibApi.kt", "fun libFun() {}") + val usage = source("app", "AppUse.kt", "fun caller() { libFun() }") + + assertThat(usagesAt(declaration, "fun libFun", delta = 5)).isEqualTo(expected(usage, "libFun()")) + } + + @Test + fun `searching from a reference finds the same usages as from the declaration`() { + val declaration = source("app", "FromRefDecl.kt", "fun shared() {}") + val usage = source("app", "FromRefUse.kt", "fun caller() { shared() }") + + val fromDeclaration = usagesAt(declaration, "fun shared", delta = 5) + val fromReference = usagesAt(usage, "shared()", delta = 1) + + assertThat(fromReference).isEqualTo(fromDeclaration) + assertThat(fromReference).isNotEmpty() + } + + @Test + fun `a constructor call is a usage of the class`() { + val declaration = source("app", "Widget.kt", "class Widget") + val usage = source("app", "WidgetUse.kt", "fun caller() { Widget() }") + + assertThat(usagesAt(declaration, "class Widget", delta = 7)).isEqualTo(expected(usage, "Widget()")) + } + + @Test + fun `an import is a usage`() { + val declaration = source("lib", "Imported.kt", "package lib\n\nclass Imported") + val usage = source("app", "ImportUse.kt", "package app\n\nimport lib.Imported\n\nfun caller(p: Imported) {}") + + assertThat(usagesAt(declaration, "class Imported", delta = 7)) + .isEqualTo(expected(usage, "Imported\n", "Imported) {}")) + } + + @Test + fun `a same-named declaration elsewhere is not a usage`() { + // Matching is by symbol, not by name: the decoy shares the name and nothing else. Separate + // packages are load-bearing - two top-level `fun ambiguous()` in one package is a redeclaration, + // and the decoy's call then legitimately binds to whichever the resolver picks first. + val declaration = source("app", "Real.kt", "package real\n\nfun ambiguous() {}") + source("app", "Decoy.kt", "package decoy\n\nfun ambiguous() {}\nfun decoyCaller() { ambiguous() }") + + assertThat(usagesAt(declaration, "fun ambiguous", delta = 5)).isEmpty() + } + + @Test + fun `a call dispatched through a workspace supertype is a usage of the override`() { + val declaration = + source( + "app", + "Hierarchy.kt", + """ + interface Base { + fun render() + } + + class Impl : Base { + override fun render() {} + } + """.trimIndent(), + ) + val usage = source("app", "HierarchyUse.kt", "fun caller(b: Base) { b.render() }") + + // The call statically resolves to Base.render, but may dispatch to Impl.render at runtime. + assertThat(usagesAt(declaration, "override fun render", delta = 14)) + .isEqualTo(expected(usage, "render() }")) + } + + @Test + fun `a call dispatched through a supertype in a dependency module is a usage of the override`() { + source("lib", "DepBase.kt", "package lib\n\nopen class DepBase {\n\topen fun paint() {}\n}") + val call = source("lib", "DepBaseUse.kt", "package lib\n\nfun caller(b: DepBase) { b.paint() }") + val override = + source( + "app", + "DepDerived.kt", + "package app\n\nimport lib.DepBase\n\nclass DepDerived : DepBase() {\n\toverride fun paint() {}\n}", + ) + + // The call is written in lib, a *dependency* of app rather than a dependent of it, so scoping to + // the override's own module and its dependents would never look at it. + assertThat(usagesAt(override, "override fun paint", delta = 14)) + .isEqualTo(expected(call, "paint() }")) + } + + @Test + fun `an override is scoped to its supertype's module as well as its own`() { + source("lib", "ScopeBase.kt", "package lib\n\nopen class ScopeBase {\n\topen fun tick() {}\n}") + val override = + source( + "app", + "ScopeDerived.kt", + "package app\n\nimport lib.ScopeBase\n\nclass ScopeDerived : ScopeBase() {\n\toverride fun tick() {}\n}", + ) + + val scope = scopeAt(override, "override fun tick", delta = 14) + + assertThat(scope).isInstanceOf(UsageSearchScope.Modules::class.java) + // app, the override's own module, plus lib, its supertype's. lib's dependents re-add app. + assertThat((scope as UsageSearchScope.Modules).modules.map { it.id }).containsExactly("app", "lib") + } + + @Test + fun `an override of a library member does not match unrelated calls to it`() { + // The up-walk stops at the workspace boundary: with Any.toString in the match set this would + // report every .toString() call in the workspace. + val declaration = + source( + "app", + "Renderer.kt", + "class Renderer {\n\toverride fun toString(): String = \"r\"\n}", + ) + source("app", "OtherToString.kt", "fun caller(value: Int) = value.toString()") + + assertThat(usagesAt(declaration, "override fun toString", delta = 14)).isEmpty() + } + + @Test + fun `a local declaration is scoped to its own file`() { + val file = source("app", "LocalScope.kt", "fun caller() {\n\tval count = 1\n\tprintln(count)\n}") + + assertThat(scopeAt(file, "val count", delta = 4)) + .isEqualTo(UsageSearchScope.SingleFile(file.path)) + assertThat(usagesAt(file, "val count", delta = 4)).isEqualTo(expected(file, "count)")) + } + + @Test + fun `a private top-level declaration is scoped to its own file`() { + val file = source("app", "PrivateScope.kt", "private fun hidden() {}\nfun caller() { hidden() }") + + assertThat(scopeAt(file, "fun hidden", delta = 5)) + .isEqualTo(UsageSearchScope.SingleFile(file.path)) + } + + @Test + fun `an internal declaration is scoped to its own module`() { + val file = source("lib", "InternalScope.kt", "internal fun shared() {}") + + val scope = scopeAt(file, "fun shared", delta = 5) + + assertThat(scope).isInstanceOf(UsageSearchScope.Modules::class.java) + assertThat((scope as UsageSearchScope.Modules).modules.map { it.id }).hasSize(1) + } + + @Test + fun `a public declaration is scoped to its module and dependents`() { + val file = source("lib", "PublicScope.kt", "fun exported() {}") + + val scope = scopeAt(file, "fun exported", delta = 5) + + assertThat(scope).isInstanceOf(UsageSearchScope.Modules::class.java) + // lib plus app, which depends on it. + assertThat((scope as UsageSearchScope.Modules).modules.map { it.id }).hasSize(2) + } + + /** + * Direction 1 of the cross-language split: a Java-source *target* is in scope, because resolving a + * Kotlin reference to it already works. Searching `.java` files for usages is not: a source module's + * files include them, so `candidateFiles` drops them on the extension before reading anything, and + * `getKtFile` would reject one anyway. + */ + @Test + fun `a workspace Java declaration is a valid target`() { + env.createFile("lib", "lib/JavaGreeter.java", "package lib;\npublic class JavaGreeter {}") + val usage = + source( + "app", + "app/JavaUse.kt", + "package app\n\nimport lib.JavaGreeter\n\nfun make(): JavaGreeter? = null", + ) + + // The caret is on the Kotlin reference; the target it resolves to is the Java class. + assertThat(usagesAt(usage, ": JavaGreeter", delta = 2)) + .isEqualTo(expected(usage, "JavaGreeter\n", "JavaGreeter? = null")) + } + + @Test + fun `a reference to a stdlib symbol yields no usages`() { + val file = source("app", "Stdlib.kt", "fun caller() { listOf(1) }") + + assertThat(usagesAt(file, "listOf", delta = 1)).isEmpty() + } + + @Test + fun `a caret that names nothing yields no usages`() { + val file = source("app", "Nothing.kt", "fun caller() { }") + + assertThat(usagesAt(file, "{ }", delta = 2)).isEmpty() + } + + @Test + fun `a cancelled request yields no usages rather than throwing`() { + val file = source("app", "Cancelled.kt", "fun target() {}\nfun caller() { target() }") + + assertThat(usagesAt(file, "fun target", delta = 5, cancelChecker = ICancelChecker.CANCELLED)).isEmpty() + } + + @Test + fun `a property read and write are both usages`() { + val text = "var counter = 0\nfun caller() {\n\tcounter = 1\n\tprintln(counter)\n}" + val file = source("app", "Property.kt", text) + + assertThat(usagesAt(file, "var counter", delta = 5)).hasSize(2) + } +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/navigation/TargetAtCaretTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/navigation/TargetAtCaretTest.kt new file mode 100644 index 0000000000..94673eb9d5 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/navigation/TargetAtCaretTest.kt @@ -0,0 +1,167 @@ +package com.itsaky.androidide.lsp.kotlin.navigation + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.lsp.kotlin.compiler.read +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import org.jetbrains.kotlin.psi.KtClass +import org.jetbrains.kotlin.psi.KtDestructuringDeclarationEntry +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtOperationReferenceExpression +import org.jetbrains.kotlin.psi.KtParameter +import org.jetbrains.kotlin.psi.KtProperty +import org.junit.Test + +/** + * R2's caret rules for find usages. Pure PSI, no analysis session. + * + * The interesting cases are the ones where this must answer *differently* from + * [ReferenceAtCaretTest]: a caret on a declaration's own name is nothing to navigate to, but it is + * the normal place to search for usages from. + */ +class TargetAtCaretTest : KtLspTest() { + /** The target for a caret at `text.indexOf(marker) + delta` in a file containing [text]. */ + private fun targetAt( + name: String, + text: String, + marker: String, + delta: Int = 0, + ): CaretTarget? { + val file = createSourceFile(name, text) + val offset = + text.indexOf(marker).also { check(it >= 0) { "marker '$marker' not in source" } } + delta + return env.project.read { targetAtCaret(file, offset) } + } + + private fun assertDeclaration( + target: CaretTarget?, + name: String, + ): CaretTarget.Declaration { + assertThat(target).isInstanceOf(CaretTarget.Declaration::class.java) + val declaration = (target as CaretTarget.Declaration) + assertThat(declaration.declaration.name).isEqualTo(name) + return declaration + } + + @Test + fun `caret on a function's own name targets that function`() { + val target = targetAt("A.kt", "fun target() {}", "target", delta = 1) + assertDeclaration(target, "target") + assertThat((target as CaretTarget.Declaration).declaration).isInstanceOf(KtNamedFunction::class.java) + } + + @Test + fun `caret on a class's own name targets that class`() { + val target = targetAt("B.kt", "class Widget", "Widget", delta = 2) + assertDeclaration(target, "Widget") + assertThat((target as CaretTarget.Declaration).declaration).isInstanceOf(KtClass::class.java) + } + + @Test + fun `caret on a property's own name targets that property`() { + val target = targetAt("C.kt", "fun caller() {\n\tval count = 1\n}", "count", delta = 1) + assertDeclaration(target, "count") + assertThat((target as CaretTarget.Declaration).declaration).isInstanceOf(KtProperty::class.java) + } + + @Test + fun `caret on a parameter's own name targets that parameter`() { + val target = targetAt("D.kt", "fun caller(value: Int) = value", "value", delta = 1) + assertDeclaration(target, "value") + assertThat((target as CaretTarget.Declaration).declaration).isInstanceOf(KtParameter::class.java) + } + + /** + * The contrast that makes this file necessary: `referenceAtCaret` returns null here, because a + * declaration's own name is not something go-to-definition can navigate to. + */ + @Test + fun `a caret that go-to-definition rejects still yields a target`() { + val text = "fun target() {}" + val file = createSourceFile("E.kt", text) + val offset = text.indexOf("target") + 1 + + env.project.read { + assertThat(referenceAtCaret(file, offset)).isNull() + assertThat(targetAtCaret(file, offset)).isInstanceOf(CaretTarget.Declaration::class.java) + } + } + + @Test + fun `caret on a call targets the reference, not the enclosing declaration`() { + // The nearest enclosing KtNamedDeclaration is `caller`, so this only works because the + // declaration check requires the caret's leaf to *be* that declaration's name identifier. + val target = targetAt("F.kt", "fun target() {}\nfun caller() { target() }", "{ target()", delta = 3) + assertThat(target).isInstanceOf(CaretTarget.Reference::class.java) + } + + @Test + fun `caret one past a declaration's name targets that declaration`() { + // The character after `target` is '(', which is navigable in its own right (the invoke + // convention), so this asserts the declaration check runs on the primary leaf before any + // reference interpretation of it. + val target = targetAt("G.kt", "fun target() {}", "target", delta = 6) + assertDeclaration(target, "target") + } + + @Test + fun `caret on a local declaration inside a lambda targets that declaration`() { + // ReferenceAtCaretTest asserts this same caret navigates nowhere. Searching for usages of a + // local function is legitimate, so it must not inherit that null. + val target = + targetAt( + "H.kt", + "fun run(block: () -> Unit) {}\nfun caller() { run { fun inner() {} } }", + "inner", + delta = 1, + ) + assertDeclaration(target, "inner") + } + + /** + * Q15c / R2: a destructuring entry is simultaneously a declaration and a convention reference to + * `componentN`. Go-to-definition reads it as the reference; find usages reads it as the + * declaration, so a search from here finds usages of `x` rather than of `component1`. + */ + @Test + fun `caret on a destructuring entry targets the entry as a declaration`() { + val target = + targetAt( + "I.kt", + "data class P(val x: Int, val y: Int)\nfun caller(p: P) { val (x, y) = p }", + "(x, y)", + delta = 1, + ) + assertDeclaration(target, "x") + assertThat((target as CaretTarget.Declaration).declaration) + .isInstanceOf(KtDestructuringDeclarationEntry::class.java) + } + + @Test + fun `caret on an operator targets the operation reference`() { + val target = + targetAt( + "J.kt", + "class P { operator fun plus(other: P): P = this }\nfun caller(a: P, b: P) { a + b }", + "a + b", + delta = 2, + ) + assertThat(target).isInstanceOf(CaretTarget.Reference::class.java) + assertThat((target as CaretTarget.Reference).element) + .isInstanceOf(KtOperationReferenceExpression::class.java) + } + + @Test + fun `caret on whitespace yields no target`() { + assertThat(targetAt("K.kt", "fun caller() { }", " ", delta = 1)).isNull() + } + + @Test + fun `caret in a comment yields no target`() { + assertThat(targetAt("L.kt", "// target here\nfun target() {}", "target here", delta = 1)).isNull() + } + + @Test + fun `caret on a non-navigable keyword yields no target`() { + assertThat(targetAt("M.kt", "fun target() {}", "fun", delta = 1)).isNull() + } +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodViewModelTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodViewModelTest.kt new file mode 100644 index 0000000000..e6e69869da --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodViewModelTest.kt @@ -0,0 +1,144 @@ +package com.itsaky.androidide.lsp.kotlin.refactor.ui + +import com.itsaky.androidide.lsp.kotlin.utils.refactor.CallSiteForm +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodCandidate +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlan +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractedBody +import com.itsaky.androidide.lsp.kotlin.utils.refactor.MethodParameter +import com.itsaky.androidide.lsp.kotlin.utils.refactor.NameProblem +import com.itsaky.androidide.lsp.kotlin.utils.refactor.TextSpan +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The sheet's derivation logic, tested without Compose, a fragment or an activity. */ +class ExtractMethodViewModelTest { + private fun candidate( + label: String, + suggestedName: String, + parameters: List = listOf(MethodParameter("a", "Int")), + returnTypeText: String? = "Int", + modifiers: List = listOf("private"), + takenNames: Set = emptySet(), + ) = ExtractMethodCandidate( + label = label, + span = TextSpan(0, 5), + suggestedName = suggestedName, + takenNames = takenNames, + annotations = emptyList(), + modifiers = modifiers, + receiverTypeText = null, + parameters = parameters, + returnTypeText = returnTypeText, + body = ExtractedBody.ExpressionBody(needsReturn = true), + callSite = CallSiteForm.Call, + insertOffset = 100, + insertIndent = "\t", + rawStringSpans = emptyList(), + ) + + private fun plan(candidates: List) = + ExtractMethodPlan( + fileText = "unused", + documentVersion = 1, + candidates = candidates, + refusal = null, + ) + + @Test + fun `the initial state takes the first candidate's suggestion`() { + val model = ExtractMethodViewModel(plan(listOf(candidate("a + b", "total")))) + + assertEquals("total", model.uiState.value.name) + assertEquals(0, model.uiState.value.selectedCandidate) + assertNull(model.uiState.value.nameProblem) + } + + @Test + fun `the chooser is hidden for one candidate and shown for more`() { + val single = ExtractMethodViewModel(plan(listOf(candidate("a + b", "total")))) + assertFalse(single.uiState.value.showCandidatePicker) + + val many = listOf(candidate("a + b", "total"), candidate("a + b + c", "total1")) + assertTrue(ExtractMethodViewModel(plan(many)).uiState.value.showCandidatePicker) + } + + @Test + fun `the preview is the signature as it will be emitted`() { + val model = + ExtractMethodViewModel( + plan( + listOf( + candidate( + "load() + 1", + "total", + parameters = listOf(MethodParameter("id", "String")), + returnTypeText = "User", + modifiers = listOf("private", "suspend"), + ), + ), + ), + ) + + assertEquals("private suspend fun total(id: String): User", model.uiState.value.signaturePreview) + + model.onEvent(ExtractMethodUiEvent.NameChanged("loadUser")) + + assertEquals("private suspend fun loadUser(id: String): User", model.uiState.value.signaturePreview) + } + + @Test + fun `a name matching an inherited member is rejected`() { + val model = + ExtractMethodViewModel(plan(listOf(candidate("a + b", "total", takenNames = setOf("helper"))))) + + model.onEvent(ExtractMethodUiEvent.NameChanged("helper")) + + assertEquals(NameProblem.AlreadyTaken, model.uiState.value.nameProblem) + assertFalse(model.uiState.value.canConfirm) + assertNull(model.choice()) + } + + @Test + fun `switching candidate re-suggests the name`() { + val model = + ExtractMethodViewModel( + plan(listOf(candidate("a + b", "total"), candidate("a + b + c", "sum"))), + ) + model.onEvent(ExtractMethodUiEvent.NameChanged("mine")) + + model.onEvent(ExtractMethodUiEvent.CandidateSelected(1)) + + assertEquals("sum", model.uiState.value.name) + assertEquals(1, model.uiState.value.selectedCandidate) + } + + @Test + fun `the choice carries the selected candidate and the typed name`() { + val model = + ExtractMethodViewModel( + plan(listOf(candidate("a + b", "total"), candidate("a + b + c", "sum"))), + ) + model.onEvent(ExtractMethodUiEvent.CandidateSelected(1)) + model.onEvent(ExtractMethodUiEvent.NameChanged("combined")) + + val choice = model.choice() + + assertNotNull(choice) + assertEquals("a + b + c", choice!!.candidate.label) + assertEquals("combined", choice.name) + } + + @Test + fun `a blank name blocks confirmation`() { + val model = ExtractMethodViewModel(plan(listOf(candidate("a + b", "total")))) + + model.onEvent(ExtractMethodUiEvent.NameChanged("")) + + assertEquals(NameProblem.Blank, model.uiState.value.nameProblem) + assertNull(model.choice()) + } +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModelTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModelTest.kt new file mode 100644 index 0000000000..2712342ca7 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModelTest.kt @@ -0,0 +1,185 @@ +package com.itsaky.androidide.lsp.kotlin.refactor.ui + +import com.itsaky.androidide.lsp.kotlin.utils.refactor.AnchorForm +import com.itsaky.androidide.lsp.kotlin.utils.refactor.CandidateExpression +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractionPlan +import com.itsaky.androidide.lsp.kotlin.utils.refactor.NameProblem +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ScopeOption +import com.itsaky.androidide.lsp.kotlin.utils.refactor.TextSpan +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The sheet's derivation logic, tested without Compose, a fragment or an activity. + * + * Every choice the sheet offers is recomputed from the plan, so all of this is exercisable as plain + * state transitions -- which is the point of keeping the plan plain data. + */ +class ExtractVariableViewModelTest { + private fun scope( + label: String, + occurrences: Int, + ) = ScopeOption( + label = label, + anchorForm = AnchorForm.ExistingBlock(contentSpan = TextSpan(0, 100), statementSpans = emptyList()), + occurrences = (0 until occurrences).map { TextSpan(it * 10, it * 10 + 5) }, + ) + + private fun candidate( + label: String, + suggestedName: String, + scopes: List, + takenNames: Set = emptySet(), + ) = CandidateExpression( + label = label, + span = TextSpan(0, 5), + suggestedName = suggestedName, + takenNames = takenNames, + scopes = scopes, + ) + + private fun plan(candidates: List) = + ExtractionPlan( + fileText = "unused", + documentVersion = 1, + candidates = candidates, + ) + + private val threeCandidatePlan = + plan( + listOf( + candidate("items.size", "size", listOf(scope("lambda", 1), scope("fun demo", 3))), + candidate("items.size * 2", "size1", listOf(scope("fun demo", 2))), + candidate("wrap(items.size * 2)", "wrap", listOf(scope("fun demo", 1))), + ), + ) + + @Test + fun `starts on the innermost candidate, innermost scope, replace-all off`() { + val state = ExtractVariableViewModel(threeCandidatePlan).uiState.value + + assertEquals(0, state.selectedCandidate) + assertEquals(0, state.selectedScope) + assertEquals("size", state.name) + assertFalse(state.replaceAll) + assertTrue(state.canConfirm) + } + + @Test + fun `shows the candidate picker only when there is a real choice`() { + assertTrue(ExtractVariableViewModel(threeCandidatePlan).uiState.value.showCandidatePicker) + + val single = plan(listOf(candidate("items.size", "size", listOf(scope("fun demo", 1))))) + assertFalse(ExtractVariableViewModel(single).uiState.value.showCandidatePicker) + } + + @Test + fun `changing the expression re-derives name, scopes and count`() { + val viewModel = ExtractVariableViewModel(threeCandidatePlan) + + viewModel.onEvent(ExtractVariableUiEvent.CandidateSelected(1)) + val state = viewModel.uiState.value + + assertEquals("size1", state.name) + assertEquals(listOf("fun demo"), state.scopeLabels) + assertEquals(2, state.occurrenceCount) + assertEquals(0, state.selectedScope) + } + + @Test + fun `changing the scope changes the occurrence count`() { + val viewModel = ExtractVariableViewModel(threeCandidatePlan) + assertEquals(1, viewModel.uiState.value.occurrenceCount) + + viewModel.onEvent(ExtractVariableUiEvent.ScopeSelected(1)) + + assertEquals(1, viewModel.uiState.value.selectedScope) + assertEquals(3, viewModel.uiState.value.occurrenceCount) + } + + @Test + fun `a scope change keeps the name the user typed`() { + val viewModel = ExtractVariableViewModel(threeCandidatePlan) + viewModel.onEvent(ExtractVariableUiEvent.NameChanged("mySize")) + + viewModel.onEvent(ExtractVariableUiEvent.ScopeSelected(1)) + + assertEquals("mySize", viewModel.uiState.value.name) + } + + @Test + fun `the replace-all toggle is hidden at a single occurrence`() { + val viewModel = ExtractVariableViewModel(threeCandidatePlan) + assertFalse(viewModel.uiState.value.showReplaceAll) + + viewModel.onEvent(ExtractVariableUiEvent.ScopeSelected(1)) + + assertTrue(viewModel.uiState.value.showReplaceAll) + } + + @Test + fun `an invalid name blocks confirming`() { + val viewModel = ExtractVariableViewModel(threeCandidatePlan) + + viewModel.onEvent(ExtractVariableUiEvent.NameChanged("val")) + + assertEquals(NameProblem.Keyword, viewModel.uiState.value.nameProblem) + assertFalse(viewModel.uiState.value.canConfirm) + assertNull(viewModel.choice()) + } + + @Test + fun `a name colliding with a visible declaration is rejected`() { + val colliding = + plan(listOf(candidate("items.size", "size1", listOf(scope("fun demo", 1)), takenNames = setOf("size")))) + val viewModel = ExtractVariableViewModel(colliding) + + viewModel.onEvent(ExtractVariableUiEvent.NameChanged("size")) + + assertEquals(NameProblem.AlreadyTaken, viewModel.uiState.value.nameProblem) + } + + @Test + fun `the choice carries the selected expression, scope, name and toggle`() { + val viewModel = ExtractVariableViewModel(threeCandidatePlan) + viewModel.onEvent(ExtractVariableUiEvent.ScopeSelected(1)) + viewModel.onEvent(ExtractVariableUiEvent.ReplaceAllChanged(true)) + viewModel.onEvent(ExtractVariableUiEvent.NameChanged("total")) + + val choice = viewModel.choice() + assertNotNull(choice) + assertEquals("items.size", choice!!.candidate.label) + assertEquals("fun demo", choice.scope.label) + assertEquals("total", choice.name) + assertTrue(choice.replaceAll) + } + + @Test + fun `replace-all cannot leak from a wider scope into a single-occurrence one`() { + val viewModel = ExtractVariableViewModel(threeCandidatePlan) + viewModel.onEvent(ExtractVariableUiEvent.ScopeSelected(1)) + viewModel.onEvent(ExtractVariableUiEvent.ReplaceAllChanged(true)) + assertTrue(viewModel.uiState.value.replaceAll) + + // Back to the lambda scope, which has one occurrence and no visible toggle. + viewModel.onEvent(ExtractVariableUiEvent.ScopeSelected(0)) + + assertFalse(viewModel.uiState.value.replaceAll) + assertFalse(viewModel.choice()!!.replaceAll) + } + + @Test + fun `switching expression resets replace-all`() { + val viewModel = ExtractVariableViewModel(threeCandidatePlan) + viewModel.onEvent(ExtractVariableUiEvent.ScopeSelected(1)) + viewModel.onEvent(ExtractVariableUiEvent.ReplaceAllChanged(true)) + + viewModel.onEvent(ExtractVariableUiEvent.CandidateSelected(1)) + + assertFalse(viewModel.uiState.value.replaceAll) + } +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImplementMembersEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImplementMembersEndToEndTest.kt index f3d017163f..fe0ebae8f0 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImplementMembersEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImplementMembersEndToEndTest.kt @@ -3,6 +3,7 @@ package com.itsaky.androidide.lsp.kotlin.utils import com.itsaky.androidide.lsp.kotlin.actions.ImplementMembersAction import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest import com.itsaky.androidide.lsp.models.TextEdit +import com.itsaky.androidide.progress.ICancelChecker import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -15,7 +16,7 @@ class ImplementMembersEndToEndTest : KtLspTest() { ): List { createSourceFile("Main.kt", content) val mainPath = env.sourceRoots.first().resolve("Main.kt") - return ImplementMembersAction().computeImplementMembersEdit(env, mainPath, caret, noopCancelChecker()) + return ImplementMembersAction().computeImplementMembersEdit(env, mainPath, caret, ICancelChecker.NOOP) } /** Applies a single edit's newText over its [TextEdit.range] index span, returning the resulting text. */ diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt index 535a4ae7b9..5fd9ea2ac2 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt @@ -4,6 +4,7 @@ import com.itsaky.androidide.lsp.kotlin.actions.OrganizeImportsAction import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest import com.itsaky.androidide.models.Position import com.itsaky.androidide.models.Range +import com.itsaky.androidide.progress.ICancelChecker import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -31,7 +32,7 @@ class OrganizeImportsEndToEndTest : KtLspTest() { val mainPath = env.sourceRoots.first().resolve("Main.kt") // Drive the action's real plumbing: fetch-before-read ordering + full guard chain. - val edits = OrganizeImportsAction().computeOrganizeEdit(env, mainPath, noopCancelChecker()) + val edits = OrganizeImportsAction().computeOrganizeEdit(env, mainPath, ICancelChecker.NOOP) assertEquals(1, edits.size) assertEquals("import lib.Used", edits.single().newText) @@ -63,7 +64,7 @@ class OrganizeImportsEndToEndTest : KtLspTest() { """.trimIndent(), ) val mainPath = env.sourceRoots.first().resolve("Main.kt") - val edits = OrganizeImportsAction().computeOrganizeEdit(env, mainPath, noopCancelChecker()) + val edits = OrganizeImportsAction().computeOrganizeEdit(env, mainPath, ICancelChecker.NOOP) // Already organized -> no edit. A dropped import would produce a rewrite that removes it. assertTrue("constructor-only import must survive", edits.isEmpty()) } @@ -86,7 +87,7 @@ class OrganizeImportsEndToEndTest : KtLspTest() { """.trimIndent(), ) val mainPath = env.sourceRoots.first().resolve("Main.kt") - val edits = OrganizeImportsAction().computeOrganizeEdit(env, mainPath, noopCancelChecker()) + val edits = OrganizeImportsAction().computeOrganizeEdit(env, mainPath, ICancelChecker.NOOP) assertTrue("annotation-only import must survive", edits.isEmpty()) } @@ -109,7 +110,7 @@ class OrganizeImportsEndToEndTest : KtLspTest() { """.trimIndent(), ) val mainPath = env.sourceRoots.first().resolve("Main.kt") - val edits = OrganizeImportsAction().computeOrganizeEdit(env, mainPath, noopCancelChecker()) + val edits = OrganizeImportsAction().computeOrganizeEdit(env, mainPath, ICancelChecker.NOOP) assertTrue("typealias-only import used as constructor must survive", edits.isEmpty()) } } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEditTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEditTest.kt new file mode 100644 index 0000000000..713c2cf2e6 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEditTest.kt @@ -0,0 +1,498 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The emitted text, with every candidate built by hand -- no PSI, no analysis. Assertions are on the + * resulting file text, the only kind that catches an indentation or off-by-one error. + */ +class ExtractMethodEditTest { + private val file = + "package p\n" + + "class C {\n" + + "\tfun demo(a: Int, b: Int): Int {\n" + + "\t\tval sum = a + b\n" + + "\t\treturn sum\n" + + "\t}\n" + + "}\n" + + private val enclosingStart = file.indexOf("fun demo") + private val enclosingEnd = file.indexOf("\t}\n}") + 2 + + private fun candidate( + span: TextSpan, + body: ExtractedBody, + callSite: CallSiteForm, + parameters: List = emptyList(), + returnTypeText: String? = null, + modifiers: List = listOf("private"), + annotations: List = emptyList(), + receiverTypeText: String? = null, + ) = ExtractMethodCandidate( + label = "region", + span = span, + suggestedName = "extracted", + takenNames = emptySet(), + annotations = annotations, + modifiers = modifiers, + receiverTypeText = receiverTypeText, + parameters = parameters, + returnTypeText = returnTypeText, + body = body, + callSite = callSite, + insertOffset = enclosingEnd, + insertIndent = "\t", + rawStringSpans = emptyList(), + ) + + /** Applies the rewrites in the order they are returned, exactly as the language client does. */ + private fun apply( + text: String, + rewrites: List, + ): String = + rewrites.fold(text) { current, rewrite -> + current.substring(0, rewrite.span.start) + rewrite.newText + current.substring(rewrite.span.end) + } + + @Test + fun `the function insertion comes before the call site`() { + val span = TextSpan(file.indexOf("a + b"), file.indexOf("a + b") + "a + b".length) + val rewrites = + buildExtractMethodRewrites( + file, + candidate( + span, + ExtractedBody.ExpressionBody(needsReturn = true), + CallSiteForm.Call, + parameters = listOf(MethodParameter("a", "Int"), MethodParameter("b", "Int")), + returnTypeText = "Int", + ), + "total", + ) + + assertNotNull(rewrites) + assertEquals(2, rewrites!!.size) + assertTrue( + "the insertion must be at a higher offset than the call site", + rewrites[0].span.start > rewrites[1].span.start, + ) + } + + @Test + fun `an insertion before the region puts the call site first`() { + // A local-function target: the new function is declared ahead of the one that calls it, so the + // descending-order invariant now puts the call site at the head of the list. + val span = TextSpan(file.indexOf("a + b"), file.indexOf("a + b") + "a + b".length) + val rewrites = + buildExtractMethodRewrites( + file, + candidate( + span, + ExtractedBody.ExpressionBody(needsReturn = true), + CallSiteForm.Call, + parameters = listOf(MethodParameter("a", "Int"), MethodParameter("b", "Int")), + returnTypeText = "Int", + ).copy(insertOffset = enclosingStart, modifiers = emptyList()), + "total", + ) + + assertNotNull(rewrites) + assertEquals(2, rewrites!!.size) + assertTrue( + "the call site must come first when the insertion precedes the region", + rewrites[0].span.start > rewrites[1].span.start, + ) + assertEquals(span, rewrites[0].span) + assertEquals(enclosingStart, rewrites[1].span.start) + } + + @Test + fun `an insertion before the region declares the function ahead of its anchor`() { + val span = TextSpan(file.indexOf("a + b"), file.indexOf("a + b") + "a + b".length) + val rewrites = + buildExtractMethodRewrites( + file, + candidate( + span, + ExtractedBody.ExpressionBody(needsReturn = true), + CallSiteForm.Call, + parameters = listOf(MethodParameter("a", "Int"), MethodParameter("b", "Int")), + returnTypeText = "Int", + ).copy(insertOffset = enclosingStart, modifiers = emptyList()), + "total", + ) + + assertEquals( + "package p\n" + + "class C {\n" + + "\tfun total(a: Int, b: Int): Int {\n" + + "\t\treturn a + b\n" + + "\t}\n" + + "\n" + + "\tfun demo(a: Int, b: Int): Int {\n" + + "\t\tval sum = total(a, b)\n" + + "\t\treturn sum\n" + + "\t}\n" + + "}\n", + apply(file, rewrites!!), + ) + } + + @Test + fun `an insertion inside the region is rejected`() { + val span = TextSpan(file.indexOf("a + b"), file.indexOf("a + b") + "a + b".length) + val rewrites = + buildExtractMethodRewrites( + file, + candidate( + span, + ExtractedBody.ExpressionBody(needsReturn = true), + CallSiteForm.Call, + ).copy(insertOffset = span.start + 1), + "total", + ) + + assertNull(rewrites) + } + + @Test + fun `an expression region becomes a call and a returning function`() { + val span = TextSpan(file.indexOf("a + b"), file.indexOf("a + b") + "a + b".length) + val rewrites = + buildExtractMethodRewrites( + file, + candidate( + span, + ExtractedBody.ExpressionBody(needsReturn = true), + CallSiteForm.Call, + parameters = listOf(MethodParameter("a", "Int"), MethodParameter("b", "Int")), + returnTypeText = "Int", + ), + "total", + )!! + + assertEquals( + "package p\n" + + "class C {\n" + + "\tfun demo(a: Int, b: Int): Int {\n" + + "\t\tval sum = total(a, b)\n" + + "\t\treturn sum\n" + + "\t}\n" + + "\n" + + "\tprivate fun total(a: Int, b: Int): Int {\n" + + "\t\treturn a + b\n" + + "\t}\n" + + "}\n", + apply(file, rewrites), + ) + } + + @Test + fun `a statement range with one output assigns at the call site`() { + val span = TextSpan(file.indexOf("val sum"), file.indexOf("val sum") + "val sum = a + b".length) + val rewrites = + buildExtractMethodRewrites( + file, + candidate( + span, + ExtractedBody.StatementBody(trailingReturn = "return sum"), + CallSiteForm.AssignOutput("sum"), + parameters = listOf(MethodParameter("a", "Int"), MethodParameter("b", "Int")), + returnTypeText = "Int", + ), + "total", + )!! + + assertEquals( + "package p\n" + + "class C {\n" + + "\tfun demo(a: Int, b: Int): Int {\n" + + "\t\tval sum = total(a, b)\n" + + "\t\treturn sum\n" + + "\t}\n" + + "\n" + + "\tprivate fun total(a: Int, b: Int): Int {\n" + + "\t\tval sum = a + b\n" + + "\t\treturn sum\n" + + "\t}\n" + + "}\n", + apply(file, rewrites), + ) + } + + @Test + fun `a tail return region returns the call`() { + val span = TextSpan(file.indexOf("return sum"), file.indexOf("return sum") + "return sum".length) + val rewrites = + buildExtractMethodRewrites( + file, + candidate( + span, + ExtractedBody.StatementBody(trailingReturn = null), + CallSiteForm.Return, + parameters = listOf(MethodParameter("sum", "Int")), + returnTypeText = "Int", + ), + "finish", + )!! + + assertEquals( + "package p\n" + + "class C {\n" + + "\tfun demo(a: Int, b: Int): Int {\n" + + "\t\tval sum = a + b\n" + + "\t\treturn finish(sum)\n" + + "\t}\n" + + "\n" + + "\tprivate fun finish(sum: Int): Int {\n" + + "\t\treturn sum\n" + + "\t}\n" + + "}\n", + apply(file, rewrites), + ) + } + + @Test + fun `a multi-line statement range is reindented under the new function`() { + val text = + "package p\n" + + "fun demo(a: Int) {\n" + + "\tif (a > 0) {\n" + + "\t\tprintln(a)\n" + + "\t}\n" + + "}\n" + val start = text.indexOf("if (a > 0)") + val rewrites = + buildExtractMethodRewrites( + text, + ExtractMethodCandidate( + label = "region", + span = TextSpan(start, text.indexOf("\t}\n}") + 2), + suggestedName = "extracted", + takenNames = emptySet(), + annotations = emptyList(), + modifiers = listOf("private"), + receiverTypeText = null, + parameters = listOf(MethodParameter("a", "Int")), + returnTypeText = null, + body = ExtractedBody.StatementBody(trailingReturn = null), + callSite = CallSiteForm.Call, + insertOffset = text.length - 1, + insertIndent = "", + rawStringSpans = emptyList(), + ), + "report", + )!! + + assertEquals( + "package p\n" + + "fun demo(a: Int) {\n" + + "\treport(a)\n" + + "}\n" + + "\n" + + "private fun report(a: Int) {\n" + + "\tif (a > 0) {\n" + + "\t\tprintln(a)\n" + + "\t}\n" + + "}\n", + apply(text, rewrites), + ) + } + + @Test + fun `a multi-line CRLF region is reindented and keeps CRLF throughout`() { + /* + * Mirrors "a multi-line statement range is reindented under the new function" with \r\n in + * place of every \n, so indentedBodyLines's split(newline) path -- the CRLF-sensitive code -- + * actually runs, not just the declaration builder's own append(newline) calls. + */ + val text = + "package p\r\n" + + "fun demo(a: Int) {\r\n" + + "\tif (a > 0) {\r\n" + + "\t\tprintln(a)\r\n" + + "\t}\r\n" + + "}\r\n" + val start = text.indexOf("if (a > 0)") + val rewrites = + buildExtractMethodRewrites( + text, + ExtractMethodCandidate( + label = "region", + span = TextSpan(start, text.indexOf("\t}\r\n}") + 2), + suggestedName = "extracted", + takenNames = emptySet(), + annotations = emptyList(), + modifiers = listOf("private"), + receiverTypeText = null, + parameters = listOf(MethodParameter("a", "Int")), + returnTypeText = null, + body = ExtractedBody.StatementBody(trailingReturn = null), + callSite = CallSiteForm.Call, + insertOffset = text.length - 2, + insertIndent = "", + rawStringSpans = emptyList(), + ), + "report", + )!! + + assertEquals( + "package p\r\n" + + "fun demo(a: Int) {\r\n" + + "\treport(a)\r\n" + + "}\r\n" + + "\r\n" + + "private fun report(a: Int) {\r\n" + + "\tif (a > 0) {\r\n" + + "\t\tprintln(a)\r\n" + + "\t}\r\n" + + "}\r\n", + apply(text, rewrites), + ) + } + + @Test + fun `a Unit-valued expression omits the return type and the return keyword`() { + val span = TextSpan(file.indexOf("a + b"), file.indexOf("a + b") + "a + b".length) + val rewrites = + buildExtractMethodRewrites( + file, + candidate( + span, + ExtractedBody.ExpressionBody(needsReturn = false), + CallSiteForm.Call, + returnTypeText = null, + ), + "log", + )!! + + assertEquals( + "package p\n" + + "class C {\n" + + "\tfun demo(a: Int, b: Int): Int {\n" + + "\t\tval sum = log()\n" + + "\t\treturn sum\n" + + "\t}\n" + + "\n" + + "\tprivate fun log() {\n" + + "\t\ta + b\n" + + "\t}\n" + + "}\n", + apply(file, rewrites), + ) + } + + @Test + fun `the signature preview matches what is emitted`() { + val span = TextSpan(file.indexOf("a + b"), file.indexOf("a + b") + "a + b".length) + val subject = + candidate( + span, + ExtractedBody.ExpressionBody(needsReturn = true), + CallSiteForm.Call, + parameters = listOf(MethodParameter("a", "Int")), + returnTypeText = "Int", + modifiers = listOf("private", "suspend"), + annotations = listOf("@Composable"), + receiverTypeText = "Foo", + ) + + assertEquals("@Composable private suspend fun Foo.total(a: Int): Int", subject.signatureText("total")) + assertTrue( + buildExtractMethodRewrites(file, subject, "total")!![0] + .newText + .contains("@Composable private suspend fun Foo.total(a: Int): Int {"), + ) + } + + @Test + fun `a span past the end of the text produces nothing`() { + val subject = + candidate( + TextSpan(file.length - 1, file.length + 10), + ExtractedBody.StatementBody(trailingReturn = null), + CallSiteForm.Call, + ) + + assertNull(buildExtractMethodRewrites(file, subject, "total")) + } + + @Test + fun `a raw string keeps its interior lines when the body indent differs from the base`() { + val quotes = "\"\"\"" + val nested = + "package p\n" + + "class C {\n" + + "\tfun demo() {\n" + + "\t\tif (true) {\n" + + "\t\t\tsend($quotes\n" + + "line one\n" + + "\t\t\t\tline two\n" + + "$quotes)\n" + + "\t\t}\n" + + "\t}\n" + + "}\n" + val span = TextSpan(nested.indexOf("send("), nested.indexOf("$quotes)") + "$quotes)".length) + val rewrites = + buildExtractMethodRewrites( + nested, + candidate( + span, + ExtractedBody.StatementBody(trailingReturn = null), + CallSiteForm.Call, + ).copy( + insertOffset = nested.indexOf("\t}\n}") + 2, + insertIndent = "\t", + rawStringSpans = listOf(TextSpan(nested.indexOf(quotes), nested.indexOf("$quotes)") + quotes.length)), + ), + "emit", + ) + + val text = apply(nested, rewrites!!) + + assertTrue("the first line takes the body indent", text.contains("\n\t\tsend($quotes\n")) + assertTrue("an unindented literal line stays unindented", text.contains("\nline one\n")) + assertTrue("an indented literal line keeps its own indent", text.contains("\n\t\t\t\tline two\n")) + assertTrue("the closing delimiter line is untouched", text.contains("\n$quotes)\n")) + } + + @Test + fun `a raw string is left alone when the body and base indents match`() { + // The base indent is not a prefix of an unindented literal line, so stripping it is a no-op while + // the body indent is still prefixed. Equal indents are not a safe case. + val quotes = "\"\"\"" + val flat = + "package p\n" + + "class C {\n" + + "\tfun demo() {\n" + + "\t\tsend($quotes\n" + + "line one\n" + + "$quotes)\n" + + "\t}\n" + + "}\n" + val span = TextSpan(flat.indexOf("send("), flat.indexOf("$quotes)") + "$quotes)".length) + val rewrites = + buildExtractMethodRewrites( + flat, + candidate( + span, + ExtractedBody.StatementBody(trailingReturn = null), + CallSiteForm.Call, + ).copy( + insertOffset = flat.indexOf("\t}\n}") + 2, + insertIndent = "\t", + rawStringSpans = listOf(TextSpan(flat.indexOf(quotes), flat.indexOf("$quotes)") + quotes.length)), + ), + "emit", + ) + + val text = apply(flat, rewrites!!) + + assertTrue("an unindented literal line stays unindented", text.contains("\nline one\n")) + assertTrue("the closing delimiter line is untouched", text.contains("\n$quotes)\n")) + } +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt new file mode 100644 index 0000000000..b0354531de --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt @@ -0,0 +1,1480 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import com.itsaky.androidide.lsp.kotlin.compiler.modules.ScheduledCancelChecker +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import com.itsaky.androidide.progress.ICancelChecker +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.CancellationException + +/** + * The parts of the plan that need real resolution: the parameter set, the return type and call-site + * form, the modifiers, and one case per refusal reason. + * + * Where a rewrite is produced the assertion is on the resulting file text, which is the only + * assertion that catches an indentation or off-by-one error. + */ +class ExtractMethodPlanEndToEndTest : KtLspTest() { + private fun plan( + content: String, + start: Int, + end: Int = start, + ): ExtractMethodPlan { + createSourceFile("Main.kt", content) + val path = env.sourceRoots.first().resolve("Main.kt") + return buildExtractMethodPlan(env, path, start, end, documentVersion = 1, cancelChecker = noopCancelChecker()) + } + + private fun apply( + text: String, + rewrites: List, + ): String = + rewrites.fold(text) { current, rewrite -> + current.substring(0, rewrite.span.start) + rewrite.newText + current.substring(rewrite.span.end) + } + + private fun selection( + content: String, + from: String, + to: String, + ): Pair = content.indexOf(from) to (content.indexOf(to) + to.length) + + @Test + fun `an expression region parameterises the locals it uses, in first-use order`() { + val content = + """ + package p + fun demo(a: Int, b: Int): Int { + return b * a + a + } + """.trimIndent() + + val result = plan(content, content.indexOf("b * a") + 1) + val candidate = result.candidates.first { it.label == "b * a" } + + // Types are emitted fully qualified so they resolve without an import the file may not have. + assertEquals(listOf("b" to "kotlin.Int", "a" to "kotlin.Int"), candidate.parameters.map { it.name to it.typeText }) + assertEquals("kotlin.Int", candidate.returnTypeText) + assertEquals(listOf("private"), candidate.modifiers) + } + + @Test + fun `a statement range with no output returns Unit and calls as a statement`() { + val content = + """ + package p + fun log(n: Int) {} + fun demo(a: Int) { + log(a) + log(a + 1) + } + """.trimIndent() + val (start, end) = selection(content, "log(a)", "log(a + 1)") + + val result = plan(content, start, end) + val candidate = result.candidates.single() + + assertNull(candidate.returnTypeText) + assertEquals(CallSiteForm.Call, candidate.callSite) + assertEquals(listOf("a"), candidate.parameters.map { it.name }) + assertEquals("extracted", candidate.suggestedName) + } + + @Test + fun `a single output becomes the return value and a val at the call site`() { + val content = + """ + package p + fun demo(a: Int): Int { + val doubled = a * 2 + return doubled + 1 + } + """.trimIndent() + val (start, end) = selection(content, "val doubled", "val doubled = a * 2") + + val result = plan(content, start, end) + val candidate = result.candidates.single() + + assertEquals(CallSiteForm.AssignOutput("doubled"), candidate.callSite) + assertEquals("kotlin.Int", candidate.returnTypeText) + } + + @Test + fun `two outputs are declined`() { + val content = + """ + package p + fun demo(a: Int): Int { + val x = a * 2 + val y = a * 3 + return x + y + } + """.trimIndent() + val (start, end) = selection(content, "val x", "val y = a * 3") + + val refusal = plan(content, start, end).refusal + + assertTrue(refusal is ExtractionRefusal.MultipleOutputs) + assertEquals(listOf("x", "y"), (refusal as ExtractionRefusal.MultipleOutputs).names) + } + + @Test + fun `a reassigned outer var is declined and names the variable`() { + val content = + """ + package p + fun demo(items: List): Int { + var total = 0 + for (item in items) { + total += item + } + return total + } + """.trimIndent() + val (start, end) = selection(content, "for (item in items)", "\t}") + + val refusal = plan(content, start, end).refusal + + assertEquals(ExtractionRefusal.ReassignsOuterVar("total"), refusal) + } + + @Test + fun `a tail return keeps the return and returns the call`() { + val content = + """ + package p + fun demo(a: Int): Int { + val doubled = a * 2 + return doubled + 1 + } + """.trimIndent() + val (start, end) = selection(content, "return doubled", "return doubled + 1") + + val result = plan(content, start, end) + val candidate = result.candidates.single() + + assertEquals(CallSiteForm.Return, candidate.callSite) + assertEquals("kotlin.Int", candidate.returnTypeText) + assertEquals( + """ + package p + fun demo(a: Int): Int { + val doubled = a * 2 + return finish(doubled) + } + + private fun finish(doubled: kotlin.Int): kotlin.Int { + return doubled + 1 + } + """.trimIndent(), + apply(content, buildExtractMethodRewrites(result.fileText, candidate, "finish")!!), + ) + } + + @Test + fun `a return in the middle of the range is declined`() { + val content = + """ + package p + fun demo(a: Int): Int { + if (a > 0) return a + val b = a * 2 + return b + } + """.trimIndent() + val (start, end) = selection(content, "if (a > 0) return a", "val b = a * 2") + + assertEquals(ExtractionRefusal.ExitsRegion, plan(content, start, end).refusal) + } + + @Test + fun `a break targeting an outer loop is declined`() { + val content = + """ + package p + fun demo(items: List) { + for (item in items) { + if (item < 0) break + println(item) + } + } + """.trimIndent() + val (start, end) = selection(content, "if (item < 0) break", "println(item)") + + assertEquals(ExtractionRefusal.ExitsRegion, plan(content, start, end).refusal) + } + + @Test + fun `an extension receiver is copied onto the new function`() { + val content = + """ + package p + class Foo(val n: Int) + fun Foo.bar(): Int { + return n * 2 + } + """.trimIndent() + + val result = plan(content, content.indexOf("n * 2") + 1) + val candidate = result.candidates.first { it.label == "n * 2" } + + assertEquals("Foo", candidate.receiverTypeText) + // `this` is a Foo at the call site, so nothing is passed and nothing is captured. + assertEquals(emptyList(), candidate.parameters) + } + + @Test + fun `an inner with receiver is declined and names the construct`() { + val content = + """ + package p + class Foo { val n: Int = 1 } + fun demo(f: Foo): Int { + with(f) { + return n * 2 + } + } + """.trimIndent() + + val refusal = plan(content, content.indexOf("n * 2") + 1).refusal + + assertEquals(ExtractionRefusal.InnerImplicitReceiver("with"), refusal) + } + + @Test + fun `a suspend call adds the suspend modifier`() { + val content = + """ + package p + suspend fun load(): Int = 1 + suspend fun demo(): Int { + return load() + 1 + } + """.trimIndent() + + val result = plan(content, content.indexOf("load() + 1") + 1) + val candidate = result.candidates.first { it.label == "load() + 1" } + + assertEquals(listOf("private", "suspend"), candidate.modifiers) + } + + @Test + fun `a Composable call adds the Composable annotation`() { + createSourceFile( + "Composable.kt", + """ + package androidx.compose.runtime + annotation class Composable + """.trimIndent(), + ) + val content = + """ + package p + import androidx.compose.runtime.Composable + @Composable fun Label(text: String) {} + @Composable fun Demo(name: String) { + Label(name) + } + """.trimIndent() + val (start, end) = selection(content, "Label(name)", "Label(name)") + + val candidate = plan(content, start, end).candidates.single() + + assertEquals(listOf("@Composable"), candidate.annotations) + } + + @Test + fun `a function-level type parameter is declined and names it`() { + val content = + """ + package p + fun demo(value: T): String { + val held: T = value + return held.toString() + } + """.trimIndent() + val (start, end) = selection(content, "val held", "val held: T = value") + + assertEquals(ExtractionRefusal.UsesTypeParameter("T"), plan(content, start, end).refusal) + } + + @Test + fun `taken names include inherited members`() { + val content = + """ + package p + open class Base { fun helper(): Int = 1 } + class Child : Base() { + fun demo(a: Int): Int { + return a * 2 + } + } + """.trimIndent() + + val candidate = plan(content, content.indexOf("a * 2") + 1).candidates.first { it.label == "a * 2" } + + // A private member matching an inherited name is an accidental-override compile error. + assertTrue("helper" in candidate.takenNames) + assertTrue("demo" in candidate.takenNames) + } + + @Test + fun `a selection spanning two blocks is declined as not a single region`() { + val content = + """ + package p + fun log(n: Int) {} + fun demo(c: Boolean, a: Int) { + if (c) { + log(a) + } + log(a + 1) + } + """.trimIndent() + val (start, end) = selection(content, "log(a)", "log(a + 1)") + + assertEquals(ExtractionRefusal.NotASingleRegion, plan(content, start, end).refusal) + } + + @Test + fun `an expression extraction rewrites the call site and adds a member function`() { + val content = + """ + package p + class C { + fun demo(a: Int, b: Int): Int { + return a + b + } + } + """.trimIndent() + + val result = plan(content, content.indexOf("a + b") + 1) + val candidate = result.candidates.first { it.label == "a + b" } + + assertEquals( + """ + package p + class C { + fun demo(a: Int, b: Int): Int { + return total(a, b) + } + + private fun total(a: kotlin.Int, b: kotlin.Int): kotlin.Int { + return a + b + } + } + """.trimIndent(), + apply(content, buildExtractMethodRewrites(result.fileText, candidate, "total")!!), + ) + } + + @Test + fun `an it bound by a lambda inside the region is not turned into a parameter`() { + val content = + """ + package p + fun log(n: Int) {} + fun demo(names: List, extra: Int) { + names.forEach { log(it + extra) } + } + """.trimIndent() + val (start, end) = selection(content, "names.forEach", "names.forEach { log(it + extra) }") + + val candidate = plan(content, start, end).candidates.single() + + // `it` belongs to a lambda the region carries with it, so it is not captured from outside. + assertEquals(listOf("names", "extra"), candidate.parameters.map { it.name }) + } + + @Test + fun `a destructuring declaration read after the region is declined`() { + val content = + """ + package p + data class Point(val a: Int, val b: Int) + fun demo(p: Point): Int { + val (x, y) = p + return x + y + } + """.trimIndent() + val (start, end) = selection(content, "val (x, y)", "val (x, y) = p") + + val refusal = plan(content, start, end).refusal + + assertTrue(refusal is ExtractionRefusal.MultipleOutputs) + assertEquals(listOf("x", "y"), (refusal as ExtractionRefusal.MultipleOutputs).names) + } + + @Test + fun `an output reassigned after the region is declined`() { + val content = + """ + package p + fun compute(): Int = 1 + fun demo(flag: Boolean): Int { + var result = compute() + if (flag) result = 0 + return result + } + """.trimIndent() + val (start, end) = selection(content, "var result", "var result = compute()") + + // A `val` at the call site cannot carry an output the following code assigns to -- which is one + // value the call site cannot receive, not "more than one value". + assertEquals(ExtractionRefusal.OutputNotReturnable("result"), plan(content, start, end).refusal) + } + + @Test + fun `an inferred type parameter is declined even though the region names no type`() { + val content = + """ + package p + fun pick(a: T, b: T): T = a + fun demo(a: T, b: T): T { + return pick(a, b) + } + """.trimIndent() + + assertEquals( + ExtractionRefusal.UsesTypeParameter("T"), + plan(content, content.indexOf("pick(a, b)") + 1).refusal, + ) + } + + @Test + fun `a labelled return targeting an outer lambda is declined`() { + val content = + """ + package p + fun demo(items: List) { + items.forEach outer@{ item -> + listOf(item).forEach { + if (it < 0) return@outer + println(it) + } + } + } + """.trimIndent() + val (start, end) = selection(content, "listOf(item).forEach {", "\t\t}") + + // The nearest lambda is in the region, but `outer@` is not. + assertEquals(ExtractionRefusal.ExitsRegion, plan(content, start, end).refusal) + } + + @Test + fun `an inherited member used inside a with block is not mistaken for the receiver`() { + val content = + """ + package p + open class Base { fun helper(): Int = 1 } + class Child : Base() { + fun demo(n: Int): Int = + with(n) { + helper() + 1 + } + } + """.trimIndent() + + val result = plan(content, content.indexOf("helper() + 1") + 1) + + // `helper()` comes from the supertype, not from `with`'s receiver. + assertNull(result.refusal) + assertEquals("kotlin.Int", result.candidates.first { it.label == "helper() + 1" }.returnTypeText) + } + + @Test + fun `a scope receiver outside the stdlib scoping names is still declined`() { + val content = + """ + package p + class Scope { fun item(n: Int) {} } + fun column(body: Scope.() -> Unit) {} + fun demo() { + column { + item(1) + } + } + """.trimIndent() + val (start, end) = selection(content, "item(1)", "item(1)") + + assertEquals(ExtractionRefusal.InnerImplicitReceiver("column"), plan(content, start, end).refusal) + } + + @Test + fun `extracting from a getter inserts the new function after the whole property`() { + val content = + """ + package p + class C { + var backing: Int = 0 + var total: Int + get() { + return backing + 1 + } + set(value) { + backing = value + } + } + """.trimIndent() + + val result = plan(content, content.indexOf("backing + 1") + 1) + val candidate = result.candidates.first { it.label == "backing + 1" } + + assertEquals( + """ + package p + class C { + var backing: Int = 0 + var total: Int + get() { + return next() + } + set(value) { + backing = value + } + + private fun next(): kotlin.Int { + return backing + 1 + } + } + """.trimIndent(), + apply(content, buildExtractMethodRewrites(result.fileText, candidate, "next")!!), + ) + } + + @Test + fun `a region using the backing field is declined`() { + val content = + """ + package p + class C { + var n: Int = 0 + get() { + return field + 1 + } + } + """.trimIndent() + + assertEquals( + ExtractionRefusal.UsesBackingField, + plan(content, content.indexOf("field + 1") + 1).refusal, + ) + } + + @Test + fun `a compound assignment through a receiver lambda is declined`() { + val content = + """ + package p + class Counter { var n = 0 } + fun demo(c: Counter) { + c.apply { + n += 1 + } + } + """.trimIndent() + val (start, end) = selection(content, "n += 1", "n += 1") + + // The assignment resolves to a compound access, not a member call, and used to slip through. + assertEquals(ExtractionRefusal.InnerImplicitReceiver("apply"), plan(content, start, end).refusal) + } + + @Test + fun `an increment through a receiver lambda is declined`() { + val content = + """ + package p + class Counter { var n = 0 } + fun demo(c: Counter) { + c.apply { + n++ + } + } + """.trimIndent() + val (start, end) = selection(content, "n++", "n++") + + assertEquals(ExtractionRefusal.InnerImplicitReceiver("apply"), plan(content, start, end).refusal) + } + + @Test + fun `a bare this inside a receiver lambda is declined`() { + val content = + """ + package p + class Foo(val n: Int) + fun log(f: Foo) {} + fun demo(f: Foo) { + f.apply { + log(this) + } + } + """.trimIndent() + val (start, end) = selection(content, "log(this)", "log(this)") + + assertEquals(ExtractionRefusal.InnerImplicitReceiver("apply"), plan(content, start, end).refusal) + } + + @Test + fun `a this inside a lambda that does not rebind it is not declined`() { + val content = + """ + package p + class Foo { + fun log(f: Foo) {} + fun demo(items: List) { + items.forEach { + log(this) + } + } + } + """.trimIndent() + val (start, end) = selection(content, "log(this)", "log(this)") + + // `forEach` binds `it`, not `this`, so `this` still means the Foo instance after the move. + assertNull(plan(content, start, end).refusal) + } + + @Test + fun `a type parameter reaching only the receiver is declined`() { + val content = + """ + package p + fun log(s: String) {} + fun List.summarize() { + log("size=" + size) + } + """.trimIndent() + val (start, end) = selection(content, "log(\"size=\" + size)", "log(\"size=\" + size)") + + // Nothing in the region names `T`; only the copied receiver does. + assertEquals(ExtractionRefusal.UsesTypeParameter("T"), plan(content, start, end).refusal) + } + + @Test + fun `a labelled break targeting an outer loop is declined`() { + val content = + """ + package p + fun demo(rows: List>) { + outer@ for (row in rows) { + for (cell in row) { + if (cell < 0) break@outer + println(cell) + } + } + } + """.trimIndent() + val (start, end) = selection(content, "for (cell in row)", "\t\t}") + + assertEquals(ExtractionRefusal.ExitsRegion, plan(content, start, end).refusal) + } + + @Test + fun `a labelled continue targeting an outer loop is declined`() { + val content = + """ + package p + fun demo(rows: List>) { + outer@ for (row in rows) { + for (cell in row) { + if (cell < 0) continue@outer + println(cell) + } + } + } + """.trimIndent() + val (start, end) = selection(content, "for (cell in row)", "\t\t}") + + assertEquals(ExtractionRefusal.ExitsRegion, plan(content, start, end).refusal) + } + + @Test + fun `a local function target gets no visibility modifier`() { + val content = + """ + package p + fun demo(a: Int): Int { + fun inner(b: Int): Int { + return b * 2 + } + return inner(a) + } + """.trimIndent() + + val result = plan(content, content.indexOf("b * 2") + 1) + val candidate = result.candidates.first { it.label == "b * 2" } + + // `private fun` inside a block does not compile, and a local function is only visible from its + // declaration onward -- so it must land *before* the function that calls it. + assertEquals(emptyList(), candidate.modifiers) + assertEquals( + """ + package p + fun demo(a: Int): Int { + fun doubled(b: kotlin.Int): kotlin.Int { + return b * 2 + } + + fun inner(b: Int): Int { + return doubled(b) + } + return inner(a) + } + """.trimIndent(), + apply(content, buildExtractMethodRewrites(result.fileText, candidate, "doubled")!!), + ) + } + + @Test + fun `a local target inserts the new function before the call site`() { + val content = + """ + package p + fun demo(a: Int): Int { + fun inner(b: Int): Int { + return b * 2 + } + return inner(a) + } + """.trimIndent() + + val result = plan(content, content.indexOf("b * 2") + 1) + val candidate = result.candidates.first { it.label == "b * 2" } + + assertTrue(candidate.insertOffset < candidate.span.start) + } + + @Test + fun `a type parameter on an extension property is declined`() { + val content = + """ + package p + val List.doubled: Int + get() { + return size * 2 + } + """.trimIndent() + val (start, end) = selection(content, "return size * 2", "return size * 2") + + // The accessor's type parameters live on its property, the same place its receiver does. + assertEquals(ExtractionRefusal.UsesTypeParameter("T"), plan(content, start, end).refusal) + } + + @Test + fun `a smart cast to an intersection type is declined`() { + val content = + """ + package p + interface A { fun a(): Int } + interface B { fun b(): Int } + fun demo(x: Any): Int { + if (x is A && x is B) { + return x.a() + x.b() + } + return 0 + } + """.trimIndent() + val (start, end) = selection(content, "return x.a() + x.b()", "return x.a() + x.b()") + + // The narrowed type cannot be written out at all, which is not the same as not knowing it. + assertEquals(ExtractionRefusal.SmartCastParameter("x"), plan(content, start, end).refusal) + } + + @Test + fun `a captured local function is declined`() { + val content = + """ + package p + fun demo(a: Int): Int { + fun helper(): Int = 1 + return helper() + a + } + """.trimIndent() + val (start, end) = selection(content, "return helper() + a", "return helper() + a") + + assertEquals( + ExtractionRefusal.CapturedLocalDeclaration("helper"), + plan(content, start, end).refusal, + ) + } + + @Test + fun `a captured local class is declined`() { + val content = + """ + package p + fun demo(a: Int): Int { + class Holder(val n: Int) + return Holder(a).n + } + """.trimIndent() + val (start, end) = selection(content, "return Holder(a).n", "return Holder(a).n") + + assertEquals( + ExtractionRefusal.CapturedLocalDeclaration("Holder"), + plan(content, start, end).refusal, + ) + } + + @Test + fun `an extension property accessor keeps its receiver`() { + val content = + """ + package p + class Foo(val n: Int) + fun Foo.bar(): Int = n * 2 + val Foo.doubled: Int + get() { + return bar() + 1 + } + """.trimIndent() + + val result = plan(content, content.indexOf("bar() + 1") + 1) + val candidate = result.candidates.first { it.label == "bar() + 1" } + + assertEquals("Foo", candidate.receiverTypeText) + } + + @Test + fun `a smart-cast parameter is declined`() { + val content = + """ + package p + fun demo(value: Any): Int { + if (value is String) { + return value.length + 1 + } + return 0 + } + """.trimIndent() + + // `value: Any` breaks the moved body; `value: String` breaks the call site. + assertEquals( + ExtractionRefusal.SmartCastParameter("value"), + plan(content, content.indexOf("value.length + 1") + 1).refusal, + ) + } + + @Test + fun `a file the analysis cannot reach is declined as not analysable, not as a bad selection`() { + createSourceFile("Main.kt", "package p\n") + val missing = env.sourceRoots.first().resolve("Absent.kt") + + // "Select an expression, or whole statements inside one block" would blame a selection that + // never got looked at. + assertEquals( + ExtractionRefusal.CouldNotAnalyse, + buildExtractMethodPlan(env, missing, 0, 0, documentVersion = 1, cancelChecker = noopCancelChecker()).refusal, + ) + } + + @Test + fun `cancellation propagates instead of being reported as a refusal`() { + val content = + """ + package p + fun demo(a: Int): Int { + return a * 2 + } + """.trimIndent() + createSourceFile("Main.kt", content) + val path = env.sourceRoots.first().resolve("Main.kt") + val cancelled = ScheduledCancelChecker(ICancelChecker.CANCELLED) + + // A cancelled action has no result to report; swallowing this would flash a message at a user + // who already moved on. + assertThrows(CancellationException::class.java) { + buildExtractMethodPlan( + env, + path, + content.indexOf("a * 2"), + content.indexOf("a * 2") + 5, + documentVersion = 1, + cancelChecker = cancelled, + ) + } + } + + @Test + fun `a type the file does not import is emitted fully qualified`() { + val content = + """ + package p + fun demo() { + val d = java.util.Date() + println(d.time) + } + """.trimIndent() + val (start, end) = selection(content, "println(d.time)", "println(d.time)") + + val result = plan(content, start, end) + val candidate = result.candidates.single() + + // `Date` came from inference, so the file names it nowhere and a short name would not resolve. + assertEquals(listOf("d" to "java.util.Date"), candidate.parameters.map { it.name to it.typeText }) + assertEquals( + """ + package p + fun demo() { + val d = java.util.Date() + extracted(d) + } + + private fun extracted(d: java.util.Date) { + println(d.time) + } + """.trimIndent(), + apply(content, buildExtractMethodRewrites(result.fileText, candidate, "extracted")!!), + ) + } + + @Test + fun `a platform type is emitted as its lower bound rather than as String bang`() { + val content = + """ + package p + fun demo() { + val v = System.getProperty("k") + println(v.length) + } + """.trimIndent() + val (start, end) = selection(content, "println(v.length)", "println(v.length)") + + val candidate = plan(content, start, end).candidates.single() + + // `String!` is not Kotlin syntax; the lower bound is what the moved body already assumes. + assertEquals(listOf("v" to "kotlin.String"), candidate.parameters.map { it.name to it.typeText }) + } + + @Test + fun `a suspend call inside a nested suspend lambda does not add the suspend modifier`() { + val content = + """ + package p + suspend fun work() {} + fun launchIt(block: suspend () -> Unit) {} + fun demo() { + launchIt { work() } + println("x") + } + """.trimIndent() + val (start, end) = selection(content, "launchIt { work() }", "launchIt { work() }") + + val candidate = plan(content, start, end).candidates.single() + + // `demo` is not a suspend context, so a `suspend fun` here would not compile at the call site. + assertEquals(listOf("private"), candidate.modifiers) + } + + @Test + fun `a suspend call inside an ordinary inline lambda still adds the suspend modifier`() { + val content = + """ + package p + suspend fun work(n: Int) {} + suspend fun demo(items: List) { + items.forEach { work(it) } + println("x") + } + """.trimIndent() + val (start, end) = selection(content, "items.forEach", "items.forEach { work(it) }") + + val candidate = plan(content, start, end).candidates.single() + + // `forEach`'s lambda runs in the caller's context, so the suspension is the new function's. + assertEquals(listOf("private", "suspend"), candidate.modifiers) + } + + @Test + fun `statements inside a suspend lambda still add the suspend modifier`() { + val content = + """ + package p + suspend fun work() {} + fun launchIt(block: suspend () -> Unit) {} + fun demo() { + launchIt { + work() + } + } + """.trimIndent() + val start = content.indexOf("work()", content.indexOf("launchIt {")) + + val candidate = plan(content, start, start + "work()".length).candidates.single() + + // The region is *inside* the suspend lambda, so its own call site is a suspend context. + assertEquals(listOf("private", "suspend"), candidate.modifiers) + } + + @Test + fun `a local extension member reached through a qualified call is declined`() { + val content = + """ + package p + class Holder(val n: Int) + fun demo(h: Holder): Int { + fun Holder.twice(): Int = n * 2 + return h.twice() + 1 + } + """.trimIndent() + val (start, end) = selection(content, "return h.twice() + 1", "return h.twice() + 1") + + // A qualified selector is NOT skipped: `twice` is local, so it goes out of scope with the move. + assertEquals( + ExtractionRefusal.CapturedLocalDeclaration("twice"), + plan(content, start, end).refusal, + ) + } + + @Test + fun `a member extension invoked on a with receiver is declined`() { + val content = + """ + package p + class Holder(val n: Int) + class Scope { fun Holder.doubled(): Int = n * 2 } + fun demo(h: Holder): Int = with(Scope()) { h.doubled() + 1 } + """.trimIndent() + val (start, end) = selection(content, "h.doubled() + 1", "h.doubled() + 1") + + // `h.doubled()` reads as fully qualified but its *dispatch* receiver is `with`'s. This is the + // pervasive Compose shape (`with(density) { size.toPx() }`), so the selector guard in + // `innerImplicitReceiver` must stay shallow enough not to skip a call selector. + assertEquals(ExtractionRefusal.InnerImplicitReceiver("with"), plan(content, start, end).refusal) + } + + @Test + fun `a value typed by a local class is declined rather than emitted`() { + val content = + """ + package p + fun demo(): Int { + class Holder(val n: Int) + val h = Holder(1) + return h.n + 1 + } + """.trimIndent() + val (start, end) = selection(content, "return h.n + 1", "return h.n + 1") + + // `Holder` is out of scope at the insertion point, so no parameter for `h` can be written. + assertEquals( + ExtractionRefusal.CapturedLocalDeclaration("Holder"), + plan(content, start, end).refusal, + ) + } + + @Test + fun `a local object used as a qualifier is declined rather than dropped`() { + val content = + """ + package p + fun demo(): Int { + object Cfg { val n = 1 } + return Cfg.n + 1 + } + """.trimIndent() + val (start, end) = selection(content, "return Cfg.n + 1", "return Cfg.n + 1") + + // A class symbol is not callable, so it used to fail the capture cast and vanish silently. + assertEquals( + ExtractionRefusal.CapturedLocalDeclaration("Cfg"), + plan(content, start, end).refusal, + ) + } + + @Test + fun `a tail return in a secondary constructor extracts a Unit function`() { + val content = + """ + package p + class Foo { + constructor(x: Int) { + println(x) + return + } + } + """.trimIndent() + val (start, end) = selection(content, "println(x)", "return") + + val result = plan(content, start, end) + val candidate = result.candidates.single() + + // A constructor's symbol returns the constructed class, but its `return` carries no value. + assertNull(candidate.returnTypeText) + assertEquals( + """ + package p + class Foo { + constructor(x: Int) { + return tail(x) + } + + private fun tail(x: kotlin.Int) { + println(x) + return + } + } + """.trimIndent(), + apply(content, buildExtractMethodRewrites(result.fileText, candidate, "tail")!!), + ) + } + + @Test + fun `a single destructuring entry read after the region is not reported as more than one value`() { + val content = + """ + package p + data class Point(val a: Int, val b: Int) + fun demo(p: Point): Int { + val (x, y) = p + return x + 1 + } + """.trimIndent() + val (start, end) = selection(content, "val (x, y)", "val (x, y) = p") + + // One value, in a form the call site cannot receive -- not "more than one value". + assertEquals(ExtractionRefusal.OutputNotReturnable("x"), plan(content, start, end).refusal) + } + + @Test + fun `a local fun target validates its name against the enclosing block, not the class`() { + val content = + """ + package p + class C { + fun demo(a: Int): Int { + fun inner(b: Int): Int { + return b * 2 + } + return inner(a) + } + } + """.trimIndent() + + val candidate = plan(content, content.indexOf("b * 2") + 1).candidates.first { it.label == "b * 2" } + + // A sibling local named `inner` is what the new local `fun` would redeclare; `demo` is not. + assertTrue("inner" in candidate.takenNames) + assertTrue("demo" !in candidate.takenNames) + } + + @Test + fun `a parameter whose type cannot be written out is declined`() { + val content = + """ + package p + fun demo(): Int { + val helper = object { + fun value(): Int = 1 + } + return helper.value() + } + """.trimIndent() + + assertEquals( + ExtractionRefusal.UnrenderableType, + plan(content, content.indexOf("helper.value()") + 1).refusal, + ) + } + + @Test + fun `a region inside an anonymous function argument anchors on the enclosing member`() { + val content = + """ + package p + class C { + fun demo() { + register(fun(v: Int) { + work(v) + }) + } + fun register(h: (Int) -> Unit) {} + fun work(n: Int) {} + } + """.trimIndent() + + val candidate = plan(content, content.indexOf("work(v)") + 1).candidates.first { it.label == "work(v)" } + + // The anonymous function is a value, not a declaration a sibling can follow: the new member must + // anchor on the enclosing `fun demo`, not one character early inside `register(...)`. + val demoEnd = content.indexOf("\t}\n\tfun register") + 2 + assertEquals(demoEnd, candidate.insertOffset) + assertEquals("\t", candidate.insertIndent) + assertEquals(listOf("private"), candidate.modifiers) + assertEquals(listOf("v" to "kotlin.Int"), candidate.parameters.map { it.name to it.typeText }) + } + + @Test + fun `a region inside an anonymous function initializer anchors on the enclosing function`() { + val content = + """ + package p + fun demo() { + val f = fun(): Int { + return compute() + } + f() + } + fun compute(): Int = 1 + """.trimIndent() + + val candidate = plan(content, content.indexOf("compute()") + 1).candidates.first { it.label == "compute()" } + + assertEquals("", candidate.insertIndent) + val demoEnd = content.indexOf("}\nfun compute") + 1 + assertEquals(demoEnd, candidate.insertOffset) + assertEquals(listOf("private"), candidate.modifiers) + } + + @Test + fun `a region inside an anonymous extension function is declined`() { + val content = + """ + package p + fun demo(): Int { + val f = fun String.(): Int { + return length + 1 + } + return f("ab") + } + """.trimIndent() + + val refusal = plan(content, content.indexOf("length + 1") + 1).refusal + + assertEquals(ExtractionRefusal.AnonymousExtensionFunction, refusal) + } + + @Test + fun `reading a Composable property getter adds the Composable annotation`() { + createSourceFile( + "Composable.kt", + """ + package androidx.compose.runtime + annotation class Composable + """.trimIndent(), + ) + val content = + """ + package p + import androidx.compose.runtime.Composable + object Palette { + val accent: Int + @Composable get() = 1 + } + fun use(n: Int) {} + @Composable fun Demo() { + use(Palette.accent) + } + """.trimIndent() + + val candidate = plan(content, content.indexOf("Palette.accent") + 1).candidates.first { it.label == "Palette.accent" } + + assertEquals(listOf("@Composable"), candidate.annotations) + } + + @Test + fun `reading a plain property getter adds no annotation`() { + createSourceFile( + "Composable.kt", + """ + package androidx.compose.runtime + annotation class Composable + """.trimIndent(), + ) + val content = + """ + package p + import androidx.compose.runtime.Composable + object Palette { + val accent: Int + get() = 1 + } + fun use(n: Int) {} + @Composable fun Demo() { + use(Palette.accent) + } + """.trimIndent() + + val candidate = plan(content, content.indexOf("Palette.accent") + 1).candidates.first { it.label == "Palette.accent" } + + assertEquals(emptyList(), candidate.annotations) + } + + @Test + fun `a return inside a local function declared in the region is not an exit`() { + val content = + """ + package p + fun demo(a: Int): Int { + fun helper(): Int { + return a * 2 + } + val x = helper() + return x + } + """.trimIndent() + val (start, end) = selection(content, "fun helper", "val x = helper()") + + val result = plan(content, start, end) + + assertNull(result.refusal) + val candidate = result.candidates.single() + assertEquals(CallSiteForm.AssignOutput("x"), candidate.callSite) + assertEquals(listOf("a"), candidate.parameters.map { it.name }) + } + + @Test + fun `a return inside an anonymous object override in the region is not an exit`() { + val content = + """ + package p + interface Runner { fun run() } + fun work() {} + fun use(r: Runner) {} + fun demo(flag: Boolean) { + val r = object : Runner { + override fun run() { + if (flag) return + work() + } + } + use(r) + } + """.trimIndent() + val (start, end) = selection(content, "val r = object", "use(r)") + + val result = plan(content, start, end) + + assertNull(result.refusal) + val candidate = result.candidates.single() + assertEquals(listOf("flag"), candidate.parameters.map { it.name }) + assertEquals(CallSiteForm.Call, candidate.callSite) + } + + @Test + fun `a return inside an anonymous function declared in the region is not an exit`() { + val content = + """ + package p + fun compute(): Int = 1 + fun accept(g: () -> Int) {} + fun demo() { + val f = fun(): Int { + return compute() + } + accept(f) + } + """.trimIndent() + val (start, end) = selection(content, "val f = fun", "accept(f)") + + val result = plan(content, start, end) + + assertNull(result.refusal) + val candidate = result.candidates.single() + assertEquals(CallSiteForm.Call, candidate.callSite) + assertNull(candidate.returnTypeText) + } + + @Test + fun `a tail return is recognised when a nested function in the region also returns`() { + val content = + """ + package p + fun demo(a: Int): Int { + fun helper(): Int { + return a * 2 + } + return helper() + } + """.trimIndent() + val (start, end) = selection(content, "fun helper", "return helper()") + + val candidate = plan(content, start, end).candidates.single() + + assertEquals(CallSiteForm.Return, candidate.callSite) + assertEquals("kotlin.Int", candidate.returnTypeText) + } + + @Test + fun `a non-local return from a lambda in the region is still an exit`() { + // A lambda is transparent to an unlabelled `return`, so this one really does leave the region. + val content = + """ + package p + fun demo(items: List): Int { + items.forEach { item -> + if (item > 0) return item + } + return 0 + } + """.trimIndent() + val (start, end) = selection(content, "items.forEach", "\t}") + + assertEquals(ExtractionRefusal.ExitsRegion, plan(content, start, end).refusal) + } + + @Test + fun `a tail return belonging to an anonymous function wrapped around the region is an exit`() { + val content = + """ + package p + fun compute(): Int = 1 + fun demo() { + val f = fun(): Int { + val a = compute() + return a + } + println(f()) + } + """.trimIndent() + val (start, end) = selection(content, "val a = compute()", "return a") + + assertEquals(ExtractionRefusal.ExitsRegion, plan(content, start, end).refusal) + } + + @Test + fun `a labelled tail return is an exit`() { + val content = + """ + package p + fun f(n: Int): Int = n + fun demo(items: List) { + items.forEach { + val a = f(it) + return@forEach + } + } + """.trimIndent() + val (start, end) = selection(content, "val a = f(it)", "return@forEach") + + assertEquals(ExtractionRefusal.ExitsRegion, plan(content, start, end).refusal) + } + + @Test + fun `a multi-line string in the region is recorded and emitted verbatim`() { + val quotes = "\"\"\"" + val content = + "package p\n" + + "fun send(s: String) {}\n" + + "fun demo() {\n" + + "\tif (true) {\n" + + "\t\tsend($quotes\n" + + "line one\n" + + "$quotes)\n" + + "\t}\n" + + "}\n" + val (start, end) = selection(content, "send($quotes", "$quotes)") + + val candidate = plan(content, start, end).candidates.single() + + assertEquals(1, candidate.rawStringSpans.size) + assertEquals(content.indexOf(quotes), candidate.rawStringSpans.single().start) + assertEquals(content.indexOf("$quotes)") + quotes.length, candidate.rawStringSpans.single().end) + + val text = apply(content, buildExtractMethodRewrites(content, candidate, "emit")!!) + assertTrue("the literal must not gain an indent level", text.contains("\nline one\n")) + } +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodRegionTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodRegionTest.kt new file mode 100644 index 0000000000..35f572be56 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodRegionTest.kt @@ -0,0 +1,148 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import org.jetbrains.kotlin.psi.KtFile +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Region resolution is purely syntactic, so it is tested with no analysis session at all -- the same + * split `CandidateExpressions.kt` already has. + */ +class ExtractMethodRegionTest : KtLspTest() { + private fun file(content: String): KtFile = createSourceFile("Main.kt", content) + + private fun region( + content: String, + start: Int, + end: Int = start, + ): ExtractionRegion? = resolveExtractionRegion(file(content), start, end) + + private val twoStatements = + """ + package p + fun log(n: Int) {} + fun demo(a: Int, b: Int) { + val sum = a + b + log(sum) + } + """.trimIndent() + + @Test + fun `a bare cursor resolves to expression candidates`() { + // On the `+`, not `+ 1`: that lands between `a` and the space, which resolves to the `a` + // identifier itself (also a legal candidate) rather than the binary expression. + val region = region(twoStatements, twoStatements.indexOf("a + b") + 2) + + assertTrue(region is ExtractionRegion.Expressions) + assertEquals("a + b", (region as ExtractionRegion.Expressions).candidates.first().text) + } + + @Test + fun `a selection over two whole statements resolves to a statement range`() { + val start = twoStatements.indexOf("val sum") + val end = twoStatements.indexOf("log(sum)") + "log(sum)".length + + val region = region(twoStatements, start, end) + + assertTrue(region is ExtractionRegion.Statements) + assertEquals( + listOf("val sum = a + b", "log(sum)"), + (region as ExtractionRegion.Statements).statements.map { it.text }, + ) + } + + @Test + fun `ragged boundaries snap outward to whole statements`() { + // Starts mid-`sum` and stops mid-`log(sum)`, as a touch drag routinely does. + val start = twoStatements.indexOf("sum = a + b") + val end = twoStatements.indexOf("log(sum)") + 3 + + val region = region(twoStatements, start, end) + + assertTrue(region is ExtractionRegion.Statements) + assertEquals( + listOf("val sum = a + b", "log(sum)"), + (region as ExtractionRegion.Statements).statements.map { it.text }, + ) + } + + @Test + fun `a selection inside a single statement stays an expression selection`() { + val start = twoStatements.indexOf("a + b") + + val region = region(twoStatements, start, start + "a + b".length) + + assertTrue(region is ExtractionRegion.Expressions) + assertEquals("a + b", (region as ExtractionRegion.Expressions).candidates.first().text) + } + + @Test + fun `a partial selection with no expression candidate still snaps to the statement`() { + // Skips the leading `val`, as a touch drag that starts a little late routinely does. Both + // ends land inside the same KtProperty, which is a declaration, not a legal expression + // target, so the expression path has nothing to offer and the snapped statement wins. + val start = twoStatements.indexOf("sum") + val end = twoStatements.indexOf("a + b") + "a + b".length + + val region = region(twoStatements, start, end) + + assertTrue(region is ExtractionRegion.Statements) + assertEquals( + listOf("val sum = a + b"), + (region as ExtractionRegion.Statements).statements.map { it.text }, + ) + } + + @Test + fun `a selection spanning two different blocks resolves to nothing`() { + val content = + """ + package p + fun log(n: Int) {} + fun demo(c: Boolean, a: Int) { + if (c) { + log(a) + } + log(a + 1) + } + """.trimIndent() + val start = content.indexOf("log(a)") + val end = content.indexOf("log(a + 1)") + "log(a + 1)".length + + assertNull(region(content, start, end)) + } + + @Test + fun `the statement range span covers first to last statement`() { + val start = twoStatements.indexOf("val sum") + val end = twoStatements.indexOf("log(sum)") + "log(sum)".length + + val region = region(twoStatements, start, end) as ExtractionRegion.Statements + + assertEquals(TextSpan(start, end), region.span) + } + + @Test + fun `a whitespace-only selection resolves to nothing`() { + val start = twoStatements.indexOf("val sum") - 1 + + assertNull(region(twoStatements, start, start + 1)) + } + + @Test + fun `a property initializer outside an executable body resolves to nothing`() { + val content = + """ + package p + fun compute(): Int = 1 + class C { + val x = compute() + compute() + } + """.trimIndent() + + assertNull(region(content, content.indexOf("compute() + compute()") + 1)) + } +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt new file mode 100644 index 0000000000..3936da29d2 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt @@ -0,0 +1,626 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Rewrite construction, with no PSI and no analysis session involved. + * + * Every assertion is on the **resulting file text** rather than on offsets. Indentation is the thing + * most likely to be wrong here -- code-action edits bypass the editor's auto-indent, so the emitted + * text has to be final -- and a range assertion cannot see an indentation bug at all. + */ +class ExtractVariableEditTest { + private fun apply( + text: String, + rewrite: RewriteSpan, + ): String = text.substring(0, rewrite.span.start) + rewrite.newText + text.substring(rewrite.span.end) + + private fun spanOf( + text: String, + snippet: String, + fromIndex: Int = 0, + ): TextSpan { + val start = text.indexOf(snippet, fromIndex) + require(start >= 0) { "'$snippet' not found" } + return TextSpan(start, start + snippet.length) + } + + private fun allSpansOf( + text: String, + snippet: String, + ): List { + val spans = mutableListOf() + var from = 0 + while (true) { + val start = text.indexOf(snippet, from) + if (start < 0) break + spans += TextSpan(start, start + snippet.length) + from = start + snippet.length + } + return spans + } + + /** + * The block rung of a single-block fixture: content is everything between the first `{` and the + * last `}`, and [statements] are the block's direct child statements in source order. + * + * Only correct for a fixture with exactly one brace pair -- a nested one (e.g. a class wrapping a + * function) needs its `AnchorForm.ExistingBlock` built by hand instead. + */ + private fun existingBlock( + text: String, + vararg statements: String, + ) = AnchorForm.ExistingBlock( + contentSpan = TextSpan(text.indexOf('{') + 1, text.lastIndexOf('}')), + statementSpans = statements.map { spanOf(text, it) }, + ) + + private fun rewrite( + text: String, + candidate: TextSpan, + anchorForm: AnchorForm, + occurrences: List, + name: String, + replaceAll: Boolean, + ) = buildExtractVariableRewrite( + fileText = text, + candidateSpan = candidate, + scope = ScopeOption("scope", anchorForm, occurrences), + name = name, + replaceAll = replaceAll, + ) + + @Test + fun `inserts the declaration above the statement and replaces the selected occurrence`() { + val text = "fun f(items: List) {\n\tprintln(items.size * 2)\n}" + val candidate = spanOf(text, "items.size * 2") + + val result = + rewrite( + text, + candidate, + existingBlock(text, "println(items.size * 2)"), + listOf(candidate), + "size", + replaceAll = false, + )!! + + assertEquals( + "fun f(items: List) {\n" + + "\tval size = items.size * 2\n" + + "\tprintln(size)\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `replace-all rewrites every occurrence and anchors above the first`() { + val text = + "fun f(items: List) {\n" + + "\tprintln(items.size * 2)\n" + + "\tlog(items.size * 2)\n" + + "\tuse(items.size * 2)\n" + + "}" + val occurrences = allSpansOf(text, "items.size * 2") + // The user selected the middle one; the declaration must still hoist above the first. + val candidate = occurrences[1] + + val result = + rewrite( + text, + candidate, + existingBlock(text, "println(items.size * 2)", "log(items.size * 2)", "use(items.size * 2)"), + occurrences, + "size", + replaceAll = true, + )!! + + assertEquals( + "fun f(items: List) {\n" + + "\tval size = items.size * 2\n" + + "\tprintln(size)\n" + + "\tlog(size)\n" + + "\tuse(size)\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `replace-all off leaves the other occurrences alone`() { + val text = + "fun f(items: List) {\n" + + "\tprintln(items.size * 2)\n" + + "\tlog(items.size * 2)\n" + + "}" + val occurrences = allSpansOf(text, "items.size * 2") + + val result = + rewrite( + text, + occurrences[0], + existingBlock(text, "println(items.size * 2)", "log(items.size * 2)"), + occurrences, + "size", + replaceAll = false, + )!! + + assertEquals( + "fun f(items: List) {\n" + + "\tval size = items.size * 2\n" + + "\tprintln(size)\n" + + "\tlog(items.size * 2)\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `matches the file's space indentation rather than assuming tabs`() { + val text = "fun f(items: List) {\n println(items.size * 2)\n}" + val candidate = spanOf(text, "items.size * 2") + + val result = + rewrite( + text, + candidate, + existingBlock(text, "println(items.size * 2)"), + listOf(candidate), + "size", + replaceAll = false, + )!! + + assertEquals( + "fun f(items: List) {\n" + + " val size = items.size * 2\n" + + " println(size)\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `keeps CRLF line endings when the file uses them`() { + val text = "fun f(items: List) {\r\n\tprintln(items.size * 2)\r\n}" + val candidate = spanOf(text, "items.size * 2") + + val result = + rewrite( + text, + candidate, + existingBlock(text, "println(items.size * 2)"), + listOf(candidate), + "size", + replaceAll = false, + )!! + + assertEquals( + "fun f(items: List) {\r\n" + + "\tval size = items.size * 2\r\n" + + "\tprintln(size)\r\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `deeper indentation is preserved`() { + val text = "class C {\n\tfun f(items: List) {\n\t\tprintln(items.size * 2)\n\t}\n}" + val candidate = spanOf(text, "items.size * 2") + // Two brace pairs are nested here, so `existingBlock`'s "first { .. last }" heuristic would + // grab the class's braces instead of `fun f`'s -- built by hand for the inner pair instead. + val form = + AnchorForm.ExistingBlock( + contentSpan = spanOf(text, "\n\t\tprintln(items.size * 2)\n\t"), + statementSpans = listOf(spanOf(text, "println(items.size * 2)")), + ) + + val result = + rewrite( + text, + candidate, + form, + listOf(candidate), + "size", + replaceAll = false, + )!! + + assertEquals( + "class C {\n" + + "\tfun f(items: List) {\n" + + "\t\tval size = items.size * 2\n" + + "\t\tprintln(size)\n" + + "\t}\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `wraps a braceless if branch in braces`() { + val text = "fun f(c: Boolean, a: A) {\n\tif (c) log(a.b)\n}" + val candidate = spanOf(text, "a.b") + val body = spanOf(text, "log(a.b)") + val form = + AnchorForm.WrapInBraces( + bodyStart = body.start, + bodyEnd = body.end, + indent = "\t", + innerIndent = "\t\t", + ) + + val result = rewrite(text, candidate, form, listOf(candidate), "b", replaceAll = false)!! + + assertEquals( + "fun f(c: Boolean, a: A) {\n" + + "\tif (c) {\n" + + "\t\tval b = a.b\n" + + "\t\tlog(b)\n" + + "\t}\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `converts an expression body to a block body with return`() { + val text = "fun area(r: Int) = r * r + r * r" + val occurrences = allSpansOf(text, "r * r") + val form = + AnchorForm.ConvertExpressionBody( + assignStart = text.indexOf('='), + bodyStart = occurrences.first().start, + bodyEnd = text.length, + indent = "", + innerIndent = "\t", + needsReturn = true, + ) + + val result = rewrite(text, occurrences.first(), form, occurrences, "square", replaceAll = true)!! + + assertEquals( + "fun area(r: Int) {\n" + + "\tval square = r * r\n" + + "\treturn square + square\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `omits return when the expression body function returns Unit`() { + val text = "fun show(a: A) = log(a.b)" + val candidate = spanOf(text, "a.b") + val form = + AnchorForm.ConvertExpressionBody( + assignStart = text.indexOf('='), + bodyStart = text.indexOf("log(a.b)"), + bodyEnd = text.length, + indent = "", + innerIndent = "\t", + needsReturn = false, + ) + + val result = rewrite(text, candidate, form, listOf(candidate), "b", replaceAll = false)!! + + assertEquals( + "fun show(a: A) {\n" + + "\tval b = a.b\n" + + "\tlog(b)\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `writes the return type into the signature when the declaration has none`() { + val text = "fun area(r: Int) = r * r" + val candidate = spanOf(text, "r * r") + val form = + AnchorForm.ConvertExpressionBody( + assignStart = text.indexOf('='), + bodyStart = candidate.start, + bodyEnd = text.length, + indent = "", + innerIndent = "\t", + needsReturn = true, + returnTypeText = "Int", + ) + + val result = rewrite(text, candidate, form, listOf(candidate), "squared", replaceAll = false)!! + + assertEquals( + "fun area(r: Int): Int {\n" + + "\tval squared = r * r\n" + + "\treturn squared\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `null when there is nothing to replace`() { + val text = "fun f() {}" + assertNull( + buildExtractVariableRewrite( + fileText = text, + candidateSpan = TextSpan(0, 3), + scope = ScopeOption("scope", AnchorForm.ExistingBlock(TextSpan(9, 9), emptyList()), emptyList()), + name = "value", + replaceAll = true, + ), + ) + } + + @Test + fun `null when an occurrence lies outside the file`() { + val text = "fun f() {}" + assertNull( + buildExtractVariableRewrite( + fileText = text, + candidateSpan = TextSpan(0, 3), + scope = + ScopeOption( + "scope", + AnchorForm.ExistingBlock(TextSpan(9, 9), emptyList()), + listOf(TextSpan(0, text.length + 5)), + ), + name = "value", + replaceAll = true, + ), + ) + } + + @Test + fun `the inner rung declares inside the if block`() { + val text = + "fun f(flag: Boolean, a: Int, b: Int): Int {\n" + + "\tif (flag) {\n" + + "\t\treturn a + b * 2\n" + + "\t}\n" + + "\treturn 0\n" + + "}" + val candidate = spanOf(text, "a + b * 2") + val form = + AnchorForm.ExistingBlock( + contentSpan = spanOf(text, "\n\t\treturn a + b * 2\n\t"), + statementSpans = listOf(spanOf(text, "return a + b * 2")), + ) + + val result = rewrite(text, candidate, form, listOf(candidate), "total", replaceAll = false)!! + + assertEquals( + "fun f(flag: Boolean, a: Int, b: Int): Int {\n" + + "\tif (flag) {\n" + + "\t\tval total = a + b * 2\n" + + "\t\treturn total\n" + + "\t}\n" + + "\treturn 0\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `the outer rung declares above the enclosing statement`() { + val text = + "fun f(flag: Boolean, a: Int, b: Int): Int {\n" + + "\tif (flag) {\n" + + "\t\treturn a + b * 2\n" + + "\t}\n" + + "\treturn 0\n" + + "}" + val candidate = spanOf(text, "a + b * 2") + // The function block's rung: its statements are the whole `if` and the trailing `return 0`. + val form = + AnchorForm.ExistingBlock( + contentSpan = TextSpan(text.indexOf('{') + 1, text.lastIndexOf('}')), + statementSpans = + listOf( + spanOf(text, "if (flag) {\n\t\treturn a + b * 2\n\t}"), + spanOf(text, "return 0"), + ), + ) + + val result = rewrite(text, candidate, form, listOf(candidate), "total", replaceAll = false)!! + + assertEquals( + "fun f(flag: Boolean, a: Int, b: Int): Int {\n" + + "\tval total = a + b * 2\n" + + "\tif (flag) {\n" + + "\t\treturn total\n" + + "\t}\n" + + "\treturn 0\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `position index line and column all agree`() { + val text = "aa\nbbb\nc" + val position = positionAt(text, text.indexOf('c')) + assertEquals(2, position.line) + assertEquals(0, position.column) + assertEquals(7, position.index) + } + + @Test + fun `expands a one-line lambda so the declaration lands inside the braces`() { + val text = "fun f(items: List): List {\n\treturn items.map { it.length + 1 }\n}" + val candidate = spanOf(text, "it.length + 1") + val form = + AnchorForm.ExistingBlock( + contentSpan = spanOf(text, " it.length + 1 "), + statementSpans = listOf(candidate), + ) + + val result = rewrite(text, candidate, form, listOf(candidate), "length", replaceAll = false)!! + + assertEquals( + "fun f(items: List): List {\n" + + "\treturn items.map {\n" + + "\t\tval length = it.length + 1\n" + + "\t\tlength\n" + + "\t}\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `expanding a one-line lambda keeps its parameter header on the brace line`() { + val text = "fun f(items: List): List {\n\treturn items.map { item -> item.length + 1 }\n}" + val candidate = spanOf(text, "item.length + 1") + // A lambda body block excludes the `item ->` header, so the header is outside the content span. + val form = + AnchorForm.ExistingBlock( + contentSpan = spanOf(text, " item.length + 1 "), + statementSpans = listOf(candidate), + ) + + val result = rewrite(text, candidate, form, listOf(candidate), "length", replaceAll = false)!! + + assertEquals( + "fun f(items: List): List {\n" + + "\treturn items.map { item ->\n" + + "\t\tval length = item.length + 1\n" + + "\t\tlength\n" + + "\t}\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `expands a one-line function body`() { + val text = "fun f(n: Int): Int { return n * 2 }" + val candidate = spanOf(text, "n * 2") + val form = + AnchorForm.ExistingBlock( + contentSpan = TextSpan(text.indexOf('{') + 1, text.lastIndexOf('}')), + statementSpans = listOf(spanOf(text, "return n * 2")), + ) + + val result = rewrite(text, candidate, form, listOf(candidate), "doubled", replaceAll = false)!! + + assertEquals( + "fun f(n: Int): Int {\n" + + "\tval doubled = n * 2\n" + + "\treturn doubled\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `widening is a no-op when a one-line lambda has no interior spaces`() { + val text = "fun f(items: List): List {\n\treturn items.map {it + 1}\n}" + val candidate = spanOf(text, "it + 1") + val form = + AnchorForm.ExistingBlock( + contentSpan = candidate, + statementSpans = listOf(candidate), + ) + + val result = rewrite(text, candidate, form, listOf(candidate), "value", replaceAll = false)!! + + assertEquals( + "fun f(items: List): List {\n" + + "\treturn items.map {\n" + + "\t\tval value = it + 1\n" + + "\t\tvalue\n" + + "\t}\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `keeps CRLF line endings when expanding a one-line block`() { + val text = "fun f(n: Int): Int { return n * 2 }\r\nval x = 1" + val candidate = spanOf(text, "n * 2") + val form = + AnchorForm.ExistingBlock( + contentSpan = TextSpan(text.indexOf('{') + 1, text.lastIndexOf('}')), + statementSpans = listOf(spanOf(text, "return n * 2")), + ) + + val result = rewrite(text, candidate, form, listOf(candidate), "doubled", replaceAll = false)!! + + assertEquals( + "fun f(n: Int): Int {\r\n" + + "\tval doubled = n * 2\r\n" + + "\treturn doubled\r\n" + + "}\r\nval x = 1", + apply(text, result), + ) + } + + @Test + fun `placement expands a block written on one line`() { + val text = "fun f(items: List) {\n\treturn items.map { it.length + 1 }\n}" + val content = spanOf(text, "it.length + 1") + val statement = spanOf(text, "it.length + 1") + + assertEquals( + BlockPlacement.ExpandOneLine, + blockPlacementFor( + fileText = text, + form = AnchorForm.ExistingBlock(contentSpan = content, statementSpans = listOf(statement)), + firstTarget = statement, + ), + ) + } + + @Test + fun `placement puts the declaration on the line above an ordinary multi-line block`() { + val text = "fun f(n: Int): Int {\n\tval a = n * 2\n\treturn a\n}" + val statement = spanOf(text, "val a = n * 2") + val content = TextSpan(text.indexOf('{') + 1, text.lastIndexOf('}')) + + assertEquals( + BlockPlacement.LineAbove(statement), + blockPlacementFor( + fileText = text, + form = AnchorForm.ExistingBlock(contentSpan = content, statementSpans = listOf(statement)), + firstTarget = statement, + ), + ) + } + + @Test + fun `placement refuses an anchor sharing the brace line of a multi-line block`() { + val text = "fun f(items: List) {\n\titems.forEach { log(it.length + 1)\n\t\tlog(it) }\n}" + val first = spanOf(text, "log(it.length + 1)") + val second = spanOf(text, "log(it)") + // A lambda body block does not own its braces, so its content span starts at the first token. + val content = TextSpan(first.start, second.end) + + assertEquals( + BlockPlacement.Refused, + blockPlacementFor( + fileText = text, + form = AnchorForm.ExistingBlock(contentSpan = content, statementSpans = listOf(first, second)), + firstTarget = first, + ), + ) + } + + @Test + fun `placement refuses a target no statement of the block contains`() { + val text = "fun f() {\n\tval a = 1\n}" + + assertEquals( + BlockPlacement.Refused, + blockPlacementFor( + fileText = text, + form = AnchorForm.ExistingBlock(contentSpan = TextSpan(8, text.length), statementSpans = emptyList()), + firstTarget = TextSpan(0, 3), + ), + ) + } +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt new file mode 100644 index 0000000000..8024a6746c --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt @@ -0,0 +1,1068 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.psi.KtBlockExpression +import org.jetbrains.kotlin.psi.KtIfExpression +import org.jetbrains.kotlin.psi.KtLambdaExpression +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The parts of the plan that need real symbol resolution: candidate filtering, the legal scope chain + * across lambda boundaries, occurrence matching by symbol identity, and reassignment soundness. + * + * Where a rewrite is produced, the assertion is on the **resulting file text** -- the only assertion + * that can catch an indentation or off-by-one error. + */ +class ExtractVariablePlanEndToEndTest : KtLspTest() { + private fun plan( + content: String, + start: Int, + end: Int = start, + ): ExtractionPlan { + createSourceFile("Main.kt", content) + val path = env.sourceRoots.first().resolve("Main.kt") + return buildExtractionPlan(env, path, start, end, documentVersion = 1, cancelChecker = noopCancelChecker()) + } + + private fun apply( + text: String, + rewrite: RewriteSpan, + ): String = text.substring(0, rewrite.span.start) + rewrite.newText + text.substring(rewrite.span.end) + + @Test + fun `offers the innermost three candidates, innermost first`() { + val content = + """ + package p + class B { fun c(): Int = 1 } + class A { val b: B = B() } + fun wrap(n: Int): Int = n + fun demo(a: A) { + wrap(a.b.c() * 2) + } + """.trimIndent() + + // Anchor on the call site, not the `fun c()` declaration that appears earlier in the file. + val result = plan(content, content.indexOf("a.b.c()") + "a.b.c".length) + + assertEquals( + listOf("a.b.c()", "a.b.c() * 2", "wrap(a.b.c() * 2)"), + result.candidates.map { it.label }, + ) + } + + @Test + fun `does not offer bare literals`() { + val content = + """ + package p + fun demo(n: Int): Int { + return n * 2 + } + """.trimIndent() + + val result = plan(content, content.indexOf("2", content.indexOf("n * 2"))) + + assertFalse(result.candidates.any { it.label == "2" }) + assertTrue(result.candidates.any { it.label == "n * 2" }) + } + + @Test + fun `offers nothing for a class-body property initializer`() { + val content = + """ + package p + fun compute(): Int = 1 + class C { + val x = compute() + compute() + } + """.trimIndent() + + assertTrue(plan(content, content.indexOf("compute() + compute()") + 1).isEmpty) + } + + @Test + fun `offers nothing for a default parameter value`() { + val content = + """ + package p + fun base(): Int = 1 + fun demo(n: Int = base() * 2) { + println(n) + } + """.trimIndent() + + assertTrue(plan(content, content.indexOf("base() * 2") + 1).isEmpty) + } + + @Test + fun `offers nothing when the cursor is in a comment`() { + val content = + """ + package p + fun demo() { + // nothing here + } + """.trimIndent() + + assertTrue(plan(content, content.indexOf("nothing")).isEmpty) + } + + @Test + fun `a selection matching an expression exactly resolves to that expression`() { + val content = + """ + package p + fun wrap(n: Int): Int = n + fun demo(n: Int) { + wrap(n * 2) + } + """.trimIndent() + val start = content.indexOf("n * 2") + + val result = plan(content, start, start + "n * 2".length) + + assertEquals("n * 2", result.candidates.first().label) + // The enclosing expression stays on offer: an exact selection no longer hides the chooser. + assertEquals(listOf("n * 2", "wrap(n * 2)"), result.candidates.map { it.label }) + } + + @Test + fun `an off-boundary selection still resolves`() { + val content = + """ + package p + fun wrap(n: Int): Int = n + fun demo(n: Int) { + wrap(n * 2) + } + """.trimIndent() + val start = content.indexOf("n * 2") + + // Selection stops mid-expression, as a touch-screen drag routinely does. + val result = plan(content, start, start + 3) + + assertEquals("n * 2", result.candidates.first().label) + } + + @Test + fun `a shadowed name in a nested lambda is not the same expression`() { + val content = + """ + package p + class Config(val timeout: Int) + fun log(n: Int) {} + fun demo(config: Config, list: List) { + log(config.timeout) + list.forEach { config -> log(config.timeout) } + } + """.trimIndent() + + val result = plan(content, content.indexOf("config.timeout") + 1) + val functionScope = + result.candidates + .first() + .scopes + .first() + + // `config` inside the lambda is a different declaration, so only one occurrence exists. + assertEquals(1, functionScope.occurrences.size) + } + + @Test + fun `the same expression in both branches of an if is one occurrence set`() { + val content = + """ + package p + class A(val b: Int) + fun log(n: Int) {} + fun warn(n: Int) {} + fun demo(c: Boolean, a: A) { + if (c) { + log(a.b) + } else { + warn(a.b) + } + } + """.trimIndent() + + val result = plan(content, content.indexOf("a.b") + 1) + val candidate = result.candidates.first { it.label == "a.b" } + // The outermost rung is the function body, which contains both branches. + val functionScope = candidate.scopes.last() + + assertEquals(2, functionScope.occurrences.size) + } + + @Test + fun `a reassignment between occurrences drops the unsound one`() { + val content = + """ + package p + fun wrap(n: Int): Int = n + fun demo(): Int { + var limit = 1 + wrap(limit + 1) + limit = 5 + wrap(limit + 1) + return limit + } + """.trimIndent() + + val result = plan(content, content.indexOf("limit + 1") + 1) + val candidate = result.candidates.first { it.label == "limit + 1" } + val functionScope = candidate.scopes.last() + + // Both sites are the same expression, but `limit = 5` makes the second a different value. + assertEquals(1, functionScope.occurrences.size) + } + + @Test + fun `a candidate using the implicit lambda parameter cannot be hoisted out of the lambda`() { + val content = + """ + package p + fun log(n: Int) {} + fun demo(items: List) { + items.forEach { log(it.length + 1) } + } + """.trimIndent() + + val result = plan(content, content.indexOf("it.length + 1") + 1) + val candidate = result.candidates.first { it.label == "it.length + 1" } + + // `it` belongs to the lambda, so the lambda body is the only legal anchor. + assertEquals(listOf("lambda"), candidate.scopes.map { it.label }) + } + + @Test + fun `a lambda-invariant candidate can be hoisted to the enclosing function`() { + val content = + """ + package p + class Config(val timeout: Int) + fun log(n: Int) {} + fun demo(config: Config, items: List) { + items.forEach { log(config.timeout * 2) } + } + """.trimIndent() + + val result = plan(content, content.indexOf("config.timeout * 2") + 1) + val candidate = result.candidates.first { it.label == "config.timeout * 2" } + + // Nothing lambda-scoped is referenced, so hoisting out to the function body is offered. + assertEquals(listOf("lambda", "fun demo"), candidate.scopes.map { it.label }) + } + + @Test + fun `suggests a name from the expression shape`() { + val content = + """ + package p + fun wrap(n: Int): Int = n + fun demo(items: List) { + wrap(items.size * 2) + } + """.trimIndent() + + val result = plan(content, content.indexOf("items.size") + 1) + + assertEquals("size", result.candidates.first { it.label == "items.size" }.suggestedName) + } + + @Test + fun `does not suggest a name that is already taken`() { + val content = + """ + package p + fun wrap(n: Int): Int = n + fun demo(items: List) { + val size = 0 + wrap(items.size * 2) + } + """.trimIndent() + + val result = plan(content, content.indexOf("items.size") + 1) + + assertEquals("size1", result.candidates.first { it.label == "items.size" }.suggestedName) + } + + @Test + fun `end to end rewrite replaces all occurrences in the function body`() { + val content = + """ + package p + fun wrap(n: Int): Int = n + fun demo(items: List): Int { + wrap(items.size * 2) + return items.size * 2 + } + """.trimIndent() + + val result = plan(content, content.indexOf("items.size * 2") + 1) + val candidate = result.candidates.first { it.label == "items.size * 2" } + val scope = candidate.scopes.last() + assertEquals(2, scope.occurrences.size) + + val rewrite = + buildExtractVariableRewrite(result.fileText, candidate.span, scope, "size", replaceAll = true) + assertNotNull(rewrite) + + assertEquals( + """ + package p + fun wrap(n: Int): Int = n + fun demo(items: List): Int { + val size = items.size * 2 + wrap(size) + return size + } + """.trimIndent(), + apply(content, rewrite!!), + ) + } + + @Test + fun `end to end rewrite converts an expression-bodied function to a block body`() { + val content = + """ + package p + fun area(r: Int) = r * r + r * r + """.trimIndent() + + val result = plan(content, content.indexOf("r * r") + 1) + val candidate = result.candidates.first { it.label == "r * r" } + val scope = candidate.scopes.first() + + val rewrite = + buildExtractVariableRewrite(result.fileText, candidate.span, scope, "square", replaceAll = true) + assertNotNull(rewrite) + + assertEquals( + """ + package p + fun area(r: Int): Int { + val square = r * r + return square + square + } + """.trimIndent(), + apply(content, rewrite!!), + ) + } + + @Test + fun `end to end rewrite wraps a braceless if branch`() { + val content = + """ + package p + class A(val b: Int) + fun log(n: Int) {} + fun demo(c: Boolean, a: A) { + if (c) log(a.b + 1) + } + """.trimIndent() + + val result = plan(content, content.indexOf("a.b + 1") + 1) + val candidate = result.candidates.first { it.label == "a.b + 1" } + val scope = candidate.scopes.first() + + val rewrite = + buildExtractVariableRewrite(result.fileText, candidate.span, scope, "offset", replaceAll = false) + assertNotNull(rewrite) + + assertEquals( + """ + package p + class A(val b: Int) + fun log(n: Int) {} + fun demo(c: Boolean, a: A) { + if (c) { + val offset = a.b + 1 + log(offset) + } + } + """.trimIndent(), + apply(content, rewrite!!), + ) + } + + @Test + fun `does not offer the lambda that wraps the expression`() { + val content = + """ + package p + fun demo(items: List): List { + return items.map { + it.length + 1 + } + } + """.trimIndent() + + val target = "it.length + 1" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + + // `{ it.length + 1 }` must not appear between the two: a hoisted lambda loses the `it` the call + // site was supplying. + assertEquals( + listOf("it.length + 1", "items.map { it.length + 1 }"), + result.candidates.map { it.label }, + ) + } + + @Test + fun `labels a braced if branch by its owner`() { + val content = + """ + package p + fun demo(flag: Boolean, a: Int, b: Int): Int { + if (flag) { + return a + b * 2 + } + return 0 + } + """.trimIndent() + + val target = "a + b * 2" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + + assertEquals( + listOf("if block", "fun demo"), + result.candidates + .first() + .scopes + .map { it.label }, + ) + } + + @Test + fun `labels a braced else branch by its owner`() { + val content = + """ + package p + fun demo(flag: Boolean, a: Int, b: Int): Int { + if (flag) { + return 0 + } else { + return a + b * 2 + } + } + """.trimIndent() + + val target = "a + b * 2" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + + assertEquals( + listOf("else block", "fun demo"), + result.candidates + .first() + .scopes + .map { it.label }, + ) + } + + @Test + fun `converting an inferred-type expression body writes the type out`() { + val content = + """ + package p + fun area(r: Int) = r * r + """.trimIndent() + + val target = "r * r" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + val candidate = result.candidates.first() + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes.first(), + name = "squared", + replaceAll = false, + )!! + + assertEquals( + "package p\n" + + "fun area(r: Int): Int {\n" + + "\tval squared = r * r\n" + + "\treturn squared\n" + + "}", + apply(content, rewrite), + ) + } + + @Test + fun `a declared return type is not written twice`() { + val content = + """ + package p + fun area(r: Int): Int = r * r + """.trimIndent() + + val target = "r * r" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + val candidate = result.candidates.first() + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes.first(), + name = "squared", + replaceAll = false, + )!! + + assertEquals( + "package p\n" + + "fun area(r: Int): Int {\n" + + "\tval squared = r * r\n" + + "\treturn squared\n" + + "}", + apply(content, rewrite), + ) + } + + @Test + fun `picking the outer rung hoists the declaration above the enclosing statement`() { + val content = + """ + package p + fun demo(flag: Boolean, a: Int, b: Int): Int { + if (flag) { + return a + b * 2 + } + return 0 + } + """.trimIndent() + + val target = "a + b * 2" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + val candidate = result.candidates.first() + assertEquals(listOf("if block", "fun demo"), candidate.scopes.map { it.label }) + + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes[1], + name = "total", + replaceAll = false, + )!! + + assertEquals( + "package p\n" + + "fun demo(flag: Boolean, a: Int, b: Int): Int {\n" + + "\tval total = a + b * 2\n" + + "\tif (flag) {\n" + + "\t\treturn total\n" + + "\t}\n" + + "\treturn 0\n" + + "}", + apply(content, rewrite), + ) + } + + @Test + fun `contentSpanOf finds the region inside a block's braces`() { + val content = + """ + package p + fun functionBody(a: Int, b: Int): Int { + return a + b + } + fun ifBody(flag: Boolean, a: Int, b: Int): Int { + if (flag) { + return a + b + } + return 0 + } + fun lambdaWithHeader(items: List): List { + return items.map { x -> x + 1 } + } + fun lambdaWithoutHeader(items: List): List { + return items.map { it + 1 } + } + fun emptyBody() {} + fun nestedLambda(items: List): List<() -> Int> { + return items.map { x -> { x + 1 } } + } + """.trimIndent() + val ktFile = createSourceFile("Main.kt", content) + val functions = ktFile.declarations.filterIsInstance().associateBy { it.name } + + fun contentOf(block: KtBlockExpression): String { + val span = contentSpanOf(block) + return content.substring(span.start, span.end) + } + + assertEquals("\n\treturn a + b\n", contentOf(functions.getValue("functionBody").bodyBlockExpression!!)) + + val ifBody = functions.getValue("ifBody").bodyBlockExpression!! + val ifThen = PsiTreeUtil.findChildOfType(ifBody, KtIfExpression::class.java)!!.then as KtBlockExpression + assertEquals("\n\tif (flag) {\n\t\treturn a + b\n\t}\n\treturn 0\n", contentOf(ifBody)) + assertEquals("\n\t\treturn a + b\n\t", contentOf(ifThen)) + + val lambdaWithHeaderBody = + PsiTreeUtil + .findChildOfType( + functions.getValue("lambdaWithHeader").bodyBlockExpression, + KtLambdaExpression::class.java, + )!! + .bodyExpression!! + val lambdaWithHeaderContent = contentOf(lambdaWithHeaderBody) + // The `x ->` header belongs to the enclosing function literal, not to this block. + assertFalse(lambdaWithHeaderContent.contains("->")) + assertEquals("x + 1", lambdaWithHeaderContent.trim()) + + val lambdaWithoutHeaderBody = + PsiTreeUtil + .findChildOfType( + functions.getValue("lambdaWithoutHeader").bodyBlockExpression, + KtLambdaExpression::class.java, + )!! + .bodyExpression!! + assertEquals("it + 1", contentOf(lambdaWithoutHeaderBody).trim()) + + assertEquals("", contentOf(functions.getValue("emptyBody").bodyBlockExpression!!)) + + // The outer lambda's sole statement is itself a lambda literal, so its text alone (`{ x + 1 }`) + // looks brace-owned; the content must still be that whole statement, not the inner lambda's + // interior. + val nestedOuterLambda = + PsiTreeUtil.findChildOfType( + functions.getValue("nestedLambda").bodyBlockExpression, + KtLambdaExpression::class.java, + )!! + val nestedOuterBody = nestedOuterLambda.bodyExpression!! + assertEquals("{ x + 1 }", contentOf(nestedOuterBody).trim()) + + val nestedInnerLambda = PsiTreeUtil.findChildOfType(nestedOuterBody, KtLambdaExpression::class.java)!! + assertEquals("x + 1", contentOf(nestedInnerLambda.bodyExpression!!).trim()) + } + + @Test + fun `a Unit-returning expression body gets neither a type nor a return`() { + val content = + """ + package p + fun report(value: Int) { + println(value) + } + fun show(text: String) = report(text.length + 1) + """.trimIndent() + + val target = "text.length + 1" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + val candidate = result.candidates.first() + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes.first(), + name = "length", + replaceAll = false, + )!! + + assertEquals( + "package p\n" + + "fun report(value: Int) {\n" + + "\tprintln(value)\n" + + "}\n" + + "fun show(text: String) {\n" + + "\tval length = text.length + 1\n" + + "\treport(length)\n" + + "}", + apply(content, rewrite), + ) + } + + @Test + fun `converting a Nothing-returning expression body preserves the signature`() { + val content = + """ + package p + fun boom(name: String) = error("bad " + name) + fun demo(x: Int?): Int = x ?: boom("missing") + """.trimIndent() + + val target = "\"bad \" + name" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + val candidate = result.candidates.first() + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes.first(), + name = "message", + replaceAll = false, + )!! + + // `boom`'s inferred return type is `Nothing`; folding it into the `Unit` case would drop both + // the `return` and the written-out `: Nothing`, and `x ?: boom(...)` would stop compiling. + assertEquals( + "package p\n" + + "fun boom(name: String): Nothing {\n" + + "\tval message = \"bad \" + name\n" + + "\treturn error(message)\n" + + "}\n" + + "fun demo(x: Int?): Int = x ?: boom(\"missing\")", + apply(content, rewrite), + ) + } + + @Test + fun `extracting from a one-line lambda stays inside the lambda`() { + val content = + """ + package p + fun demo(items: List): List { + return items.map { it.length + 1 } + } + """.trimIndent() + + val target = "it.length + 1" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + val candidate = result.candidates.first() + // `it` is lambda-scoped, so the lambda is the ceiling: there is no outer rung to choose. + assertEquals(listOf("lambda"), candidate.scopes.map { it.label }) + + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes.first(), + name = "length", + replaceAll = false, + )!! + + assertEquals( + "package p\n" + + "fun demo(items: List): List {\n" + + "\treturn items.map {\n" + + "\t\tval length = it.length + 1\n" + + "\t\tlength\n" + + "\t}\n" + + "}", + apply(content, rewrite), + ) + } + + @Test + fun `extracting from a multi-line lambda with a header on its own line is not collapsed`() { + val content = + """ + package p + fun demo(items: List): List { + return items.map { x -> + x + 1 + } + } + """.trimIndent() + + val target = "x + 1" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + val candidate = result.candidates.first() + // `x` is the lambda's own parameter, so the lambda is still the ceiling. + assertEquals(listOf("lambda"), candidate.scopes.map { it.label }) + + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes.first(), + name = "next", + replaceAll = false, + )!! + + // The body already starts its own line, so this is the normal path, not the one-line + // expansion: the header and the closing brace are left exactly where they were. + assertEquals( + "package p\n" + + "fun demo(items: List): List {\n" + + "\treturn items.map { x ->\n" + + "\t\tval next = x + 1\n" + + "\t\tnext\n" + + "\t}\n" + + "}", + apply(content, rewrite), + ) + } + + @Test + fun `extracting from a multi-line lambda without a header is not collapsed`() { + val content = + """ + package p + fun demo(items: List): List { + return items.map { + it + 1 + } + } + """.trimIndent() + + val target = "it + 1" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + val candidate = result.candidates.first() + assertEquals(listOf("lambda"), candidate.scopes.map { it.label }) + + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes.first(), + name = "next", + replaceAll = false, + )!! + + assertEquals( + "package p\n" + + "fun demo(items: List): List {\n" + + "\treturn items.map {\n" + + "\t\tval next = it + 1\n" + + "\t\tnext\n" + + "\t}\n" + + "}", + apply(content, rewrite), + ) + } + + @Test + fun `offers nothing when the only rung's anchor shares the brace line of a multi-line block`() { + val content = + """ + package p + fun log(n: Int) {} + fun demo(items: List) { + items.forEach { log(it.length + 1) + log(it) } + } + """.trimIndent() + + val target = "it.length + 1" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + + // `it` is lambda-scoped, so the lambda body is the only legal rung -- and its anchor statement + // shares the `items.forEach {` line while the block's own content spans two lines. Anchoring at + // that line start would put the declaration before the `{`, where `it` does not exist. The rung + // is refused, which leaves the candidate with no rung, which empties the plan: the action then + // reports "no expression to extract here" instead of opening a sheet whose confirm must fail. + assertTrue(result.isEmpty) + } + + @Test + fun `extracting from a semicolon-joined statement leaves the block multi-line`() { + val content = + """ + package p + fun demo(a: Int, b: Int): Int { + val x = a + 1; return x + b + } + """.trimIndent() + + val target = "x + b" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + val candidate = result.candidates.first() + + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes.first(), + name = "sum", + replaceAll = false, + )!! + + // A statement already precedes the candidate on this line, but the block itself spans several + // lines, so this is not a one-line block: the declaration hoists above the whole line instead + // of expanding it, and the two semicolon-joined statements stay together. + assertEquals( + "package p\n" + + "fun demo(a: Int, b: Int): Int {\n" + + "\tval sum = x + b\n" + + "\tval x = a + 1; return sum\n" + + "}", + apply(content, rewrite), + ) + } + + @Test + fun `an occurrence sharing the brace line is not offered for replace-all`() { + val content = + """ + package p + fun log(n: Int) {} + fun demo(items: List) { + items.forEach { log(it.length + 1) + log(it.length + 1) } + } + """.trimIndent() + + val target = "it.length + 1" + val second = content.indexOf(target, content.indexOf(target) + 1) + val result = plan(content, second, second + target.length) + val candidate = result.candidates.first() + + // The second site is on its own line and can host the declaration, so the rung stands. The first + // site shares the `items.forEach {` line, and anchoring on it would refuse the whole rewrite -- + // so it is not offered as an occurrence, and the count the sheet shows stays achievable. + assertEquals( + 1, + candidate.scopes + .first() + .occurrences.size, + ) + assertEquals( + listOf(TextSpan(second, second + target.length)), + candidate.scopes.first().occurrences, + ) + + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes.first(), + name = "length", + replaceAll = true, + ) + assertNotNull(rewrite) + } + + @Test + fun `a local in a sibling function does not take the name`() { + val content = + """ + package p + class Extract { + fun lengths(items: List): List { + return items.map { + val length = it.length + 1 + length + } + } + + fun oneLineLambda(items: List): List { + return items.map { it.length + 1 } + } + } + """.trimIndent() + + val target = "it.length + 1" + val start = content.indexOf(target, content.indexOf("oneLineLambda")) + val result = plan(content, start, start + target.length) + + // `val length` lives in another function's lambda: invisible here, so naming this one `length` + // is legal and must not be refused. + assertNull(validateVariableName("length", result.candidates.first().takenNames)) + } + + @Test + fun `an enclosing parameter and an enclosing local take the name`() { + val content = + """ + package p + fun wrap(n: Int): Int = n + fun demo(items: List) { + val size = 0 + wrap(items.size * 2) + } + """.trimIndent() + + val taken = plan(content, content.indexOf("items.size") + 1).candidates.first().takenNames + + assertEquals(NameProblem.AlreadyTaken, validateVariableName("items", taken)) + assertEquals(NameProblem.AlreadyTaken, validateVariableName("size", taken)) + } + + @Test + fun `a member of the enclosing class takes the name`() { + val content = + """ + package p + class Extract { + private val total = 0 + + fun demo(n: Int): Int { + return n * 2 + } + } + """.trimIndent() + + val target = "n * 2" + val taken = + plan(content, content.indexOf(target), content.indexOf(target) + target.length) + .candidates + .first() + .takenNames + + // A local `val total` would shadow the member, changing what every other `total` in the block + // means, so it stays refused. + assertEquals(NameProblem.AlreadyTaken, validateVariableName("total", taken)) + assertEquals(NameProblem.AlreadyTaken, validateVariableName("demo", taken)) + } + + @Test + fun `a Unit-returning member expression body gets neither a type nor a return`() { + val content = + """ + package p + class Extract { + fun show(text: String) = report(text.length + 1) + + private fun report(value: Int) { + println(value) + } + } + """.trimIndent() + + val target = "text.length + 1" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + val candidate = result.candidates.first() + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes.first(), + name = "length", + replaceAll = false, + )!! + + // The QA fixture's shape: a member, with the callee declared after the caller. `show` returns + // `Unit`, so the block body needs neither a `return` nor a written-out type. + assertEquals( + "package p\n" + + "class Extract {\n" + + "\tfun show(text: String) {\n" + + "\t\tval length = text.length + 1\n" + + "\t\treport(length)\n" + + "\t}\n" + + "\n" + + "\tprivate fun report(value: Int) {\n" + + "\t\tprintln(value)\n" + + "\t}\n" + + "}", + apply(content, rewrite), + ) + } + + @Test + fun `a whitespace-only selection resolves like a caret at its start`() { + val content = + """ + package p + fun demo(a: Int, b: Int, c: Int): Int { + return a + b * c + } + """.trimIndent() + + // The gap between `b` and `*`, as a touch drag over whitespace produces it rather than a caret. + val gap = content.indexOf("b * c") + 1 + val result = plan(content, gap, gap + 1) + + assertEquals(listOf("b", "b * c", "a + b * c"), result.candidates.map { it.label }) + } +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt new file mode 100644 index 0000000000..5171c807bf --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt @@ -0,0 +1,237 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The analysis-free primitives the refactoring is built from: name rules, indentation, soundness. */ +class RefactorPrimitivesTest { + @Test + fun `rejects blank names`() { + assertEquals(NameProblem.Blank, validateVariableName("", emptySet())) + assertEquals(NameProblem.Blank, validateVariableName(" ", emptySet())) + } + + @Test + fun `rejects non-identifiers`() { + assertEquals(NameProblem.NotAnIdentifier, validateVariableName("1size", emptySet())) + assertEquals(NameProblem.NotAnIdentifier, validateVariableName("my size", emptySet())) + assertEquals(NameProblem.NotAnIdentifier, validateVariableName("size!", emptySet())) + // Backticked names are legal Kotlin but deliberately unsupported for a generated local. + assertEquals(NameProblem.NotAnIdentifier, validateVariableName("`size`", emptySet())) + } + + @Test + fun `rejects hard keywords but allows soft ones`() { + assertEquals(NameProblem.Keyword, validateVariableName("val", emptySet())) + assertEquals(NameProblem.Keyword, validateVariableName("when", emptySet())) + assertEquals(NameProblem.Keyword, validateVariableName("this", emptySet())) + // `it`, `data` and `by` are soft keywords -- perfectly legal identifiers. + assertNull(validateVariableName("it", emptySet())) + assertNull(validateVariableName("data", emptySet())) + assertNull(validateVariableName("by", emptySet())) + } + + @Test + fun `rejects names already in use`() { + assertEquals(NameProblem.AlreadyTaken, validateVariableName("size", setOf("size"))) + assertNull(validateVariableName("size", setOf("count"))) + } + + @Test + fun `accepts underscores and digits`() { + assertNull(validateVariableName("_size", emptySet())) + assertNull(validateVariableName("size2", emptySet())) + } + + @Test + fun `detects a tab indent unit`() { + assertEquals("\t", detectIndentUnit("fun f() {\n\tval x = 1\n}")) + } + + @Test + fun `detects the smallest space indent unit`() { + assertEquals(" ", detectIndentUnit("fun f() {\n val x = 1\n val y = 2\n}")) + assertEquals(" ", detectIndentUnit("fun f() {\n val x = 1\n}")) + } + + @Test + fun `falls back to a tab when nothing is indented`() { + assertEquals("\t", detectIndentUnit("fun f() {}")) + } + + @Test + fun `leading indent is read from the offset's own line`() { + val text = "class C {\n\t\tval x = 1\n}" + assertEquals("\t\t", leadingIndentAt(text, text.indexOf("val x"))) + assertEquals("", leadingIndentAt(text, text.indexOf("class"))) + } + + @Test + fun `line start is found for the first and later lines`() { + val text = "aa\nbbb\nc" + assertEquals(0, lineStartOffset(text, 1)) + assertEquals(3, lineStartOffset(text, 4)) + assertEquals(7, lineStartOffset(text, 7)) + } + + @Test + fun `label collapses whitespace and truncates`() { + assertEquals("items.filter { it > 0 }", collapseForLabel("items\n\t.filter { it > 0 }")) + assertEquals("a?.b", collapseForLabel("a\n\t?.b")) + assertEquals("aaaaaaa...", collapseForLabel("aaaaaaaaaaaa", maxLength = 10)) + } + + @Test + fun `trim drops surrounding whitespace from a selection`() { + val text = " items.size " + assertEquals(2 to 12, trimToCode(text, 0, text.length)) + } + + @Test + fun `trim leaves a cursor untouched and collapses a whitespace-only selection`() { + assertEquals(3 to 3, trimToCode("a b", 3, 3)) + // A drag over whitespace is the same intent as a tap in it: resolve from where it started. + assertEquals(1 to 1, trimToCode("a b", 1, 5)) + assertNull(trimToCode("a", 0, 5)) + assertNull(trimToCode("a", -1, 1)) + assertNull(trimToCode("abc", 2, 1)) + } + + @Test + fun `soundness keeps every occurrence when nothing is written`() { + val occurrences = listOf(TextSpan(10, 20), TextSpan(30, 40), TextSpan(50, 60)) + assertEquals( + occurrences, + excludeUnsoundOccurrences(occurrences, TextSpan(30, 40), writeOffsets = emptyList()), + ) + } + + @Test + fun `soundness drops occurrences separated from the candidate by a write`() { + val occurrences = listOf(TextSpan(10, 20), TextSpan(30, 40), TextSpan(50, 60)) + // A reassignment between the second and third sites: the third no longer holds the same value. + assertEquals( + listOf(TextSpan(10, 20), TextSpan(30, 40)), + excludeUnsoundOccurrences(occurrences, TextSpan(30, 40), writeOffsets = listOf(45)), + ) + } + + @Test + fun `soundness drops earlier occurrences when the write precedes the candidate`() { + val occurrences = listOf(TextSpan(10, 20), TextSpan(30, 40), TextSpan(50, 60)) + assertEquals( + listOf(TextSpan(30, 40), TextSpan(50, 60)), + excludeUnsoundOccurrences(occurrences, TextSpan(30, 40), writeOffsets = listOf(25)), + ) + } + + @Test + fun `soundness always keeps the occurrence the user selected`() { + val occurrences = listOf(TextSpan(10, 20), TextSpan(30, 40), TextSpan(50, 60)) + // Writes on both sides isolate the candidate, but it must never be dropped. + assertEquals( + listOf(TextSpan(30, 40)), + excludeUnsoundOccurrences(occurrences, TextSpan(30, 40), writeOffsets = listOf(25, 45)), + ) + } + + @Test + fun `soundness falls back to the candidate alone when it is not among the occurrences`() { + assertEquals( + listOf(TextSpan(70, 80)), + excludeUnsoundOccurrences(listOf(TextSpan(10, 20)), TextSpan(70, 80), writeOffsets = emptyList()), + ) + } + + @Test + fun `shortens types from Kotlin's default-imported packages`() { + assertEquals("Int", shortenTypeText("kotlin.Int", emptySet(), emptySet())) + assertEquals( + "List", + shortenTypeText("kotlin.collections.List", emptySet(), emptySet()), + ) + } + + @Test + fun `keeps a type qualified when its short name would not resolve`() { + assertEquals("java.util.Date", shortenTypeText("java.util.Date", emptySet(), emptySet())) + // An import of the enclosing class is not an import of the nested one. + assertEquals( + "com.example.Outer.Inner", + shortenTypeText("com.example.Outer.Inner", setOf("com.example.Outer"), emptySet()), + ) + } + + @Test + fun `shortens a type the file already imports, by name or by star`() { + assertEquals("Date", shortenTypeText("java.util.Date", setOf("java.util.Date"), emptySet())) + assertEquals("Date", shortenTypeText("java.util.Date", emptySet(), setOf("java.util"))) + assertEquals( + "Flow", + shortenTypeText( + "kotlinx.coroutines.flow.Flow", + setOf("kotlinx.coroutines.flow.Flow", "com.example.Widget"), + emptySet(), + ), + ) + } + + @Test + fun `a star import is skipped when a colliding name is imported from elsewhere`() { + // An explicit import of a different `Date` shadows the star import, so shortening would + // resolve to the wrong type. + assertEquals( + "java.util.Date", + shortenTypeText("java.util.Date", setOf("com.example.Date"), setOf("java.util")), + ) + // With nothing colliding, the star import still shortens as before. + assertEquals( + "Date", + shortenTypeText("java.util.Date", emptySet(), setOf("java.util")), + ) + } + + @Test + fun `unrenderable type text is recognised`() { + assertTrue(isUnrenderableTypeText("")) + assertTrue(isUnrenderableTypeText("kotlin.collections.List")) + assertTrue(isUnrenderableTypeText("")) + assertTrue(isUnrenderableTypeText("ERROR CLASS: unresolved")) + assertTrue(isUnrenderableTypeText("kotlin.Any & kotlin.Comparable<*>")) + assertFalse(isUnrenderableTypeText("kotlin.Int")) + } + + @Test + fun `Unit type text is recognised qualified and short`() { + assertTrue(isUnitTypeText("Unit")) + assertTrue(isUnitTypeText("kotlin.Unit")) + assertFalse(isUnitTypeText("Int")) + assertFalse(isUnitTypeText("kotlin.Unit?")) + assertFalse(isUnitTypeText("MyUnit")) + } + + @Test + fun `a rendered Unit retracts both the return and the written type`() { + // The only way to reach here is the resolved-type check disagreeing with the text about to be + // written; the text is what lands in the file, so it wins. + assertEquals(false to null, normalizeExpressionBodyReturn(needsReturn = true, returnTypeText = "Unit")) + assertEquals(false to null, normalizeExpressionBodyReturn(needsReturn = true, returnTypeText = "kotlin.Unit")) + } + + @Test + fun `a non-Unit type keeps the return and the written type`() { + assertEquals(true to "Int", normalizeExpressionBodyReturn(needsReturn = true, returnTypeText = "Int")) + assertEquals(true to null, normalizeExpressionBodyReturn(needsReturn = true, returnTypeText = null)) + assertEquals(false to null, normalizeExpressionBodyReturn(needsReturn = false, returnTypeText = null)) + } + + @Test + fun `a retracted return retracts the written type with it`() { + // No return means a Unit return, which needs no written type either. The rewrite reads the two + // independently, so the other pairing would emit `fun f(): Int { val v = ...; expr }`. + assertEquals(false to null, normalizeExpressionBodyReturn(needsReturn = false, returnTypeText = "Int")) + } +} diff --git a/markdown-preview-plugin/src/main/res/values-in/strings.xml b/markdown-preview-plugin/src/main/res/values-in/strings.xml new file mode 100644 index 0000000000..3987c5791a --- /dev/null +++ b/markdown-preview-plugin/src/main/res/values-in/strings.xml @@ -0,0 +1,30 @@ + + + Pratinjau Markdown + Pratinjau File + Pratinjau file Markdown dan HTML dengan rendering langsung + + + Proyek + Penyimpanan + Segarkan + Sumber + Pratinjau + + + Belum ada file yang dipilih + Pilih file dari proyek atau penyimpanan perangkat Anda untuk melihat pratinjaunya + Didukung: .md, .markdown, .html, .htm + + + Memuat… + Tidak ada proyek yang tersedia + Tidak ditemukan file yang didukung + File tidak ditemukan + Tidak dapat membaca file + + + Pratinjau File + Pratinjau Markdown + Pratinjau HTML + diff --git a/plugin-api/api/plugin-api.api b/plugin-api/api/plugin-api.api index 399036d52e..af250a9c96 100644 --- a/plugin-api/api/plugin-api.api +++ b/plugin-api/api/plugin-api.api @@ -1337,6 +1337,7 @@ public abstract interface class com/itsaky/androidide/plugins/services/IdeEditor public abstract fun addContentChangeListener (Lcom/itsaky/androidide/plugins/services/EditorContentChangeListener;)V public abstract fun addFileChangeListener (Lcom/itsaky/androidide/plugins/services/FileChangeListener;)V public abstract fun appendToLine (Ljava/io/File;ILjava/lang/String;)Z + public abstract fun clearPeerCursors (Ljava/io/File;)V public abstract fun deleteLine (Ljava/io/File;I)Z public abstract fun dismissInlineSuggestion ()V public abstract fun getCurrentCursorPosition ()Lcom/itsaky/androidide/plugins/services/CursorPosition; @@ -1353,6 +1354,7 @@ public abstract interface class com/itsaky/androidide/plugins/services/IdeEditor public abstract fun getModifiedFiles ()Ljava/util/List; public abstract fun getOpenFiles ()Ljava/util/List; public abstract fun getWordAtCursor ()Ljava/lang/String; + public abstract fun hidePeerCursor (Ljava/io/File;Ljava/lang/String;)Z public abstract fun insertLineBefore (Ljava/io/File;ILjava/lang/String;)Z public abstract fun insertTextAtCursor (Ljava/lang/String;)Z public abstract fun isFileModified (Ljava/io/File;)Z @@ -1367,13 +1369,17 @@ public abstract interface class com/itsaky/androidide/plugins/services/IdeEditor public abstract fun replaceSelection (Ljava/lang/String;)Z public abstract fun saveCurrentFile ()Z public abstract fun showInlineSuggestion (Ljava/lang/String;)V + public abstract fun showPeerCursor (Ljava/io/File;IILjava/lang/String;Ljava/lang/String;I)Z } public final class com/itsaky/androidide/plugins/services/IdeEditorService$DefaultImpls { public static fun addContentChangeListener (Lcom/itsaky/androidide/plugins/services/IdeEditorService;Lcom/itsaky/androidide/plugins/services/EditorContentChangeListener;)V + public static fun clearPeerCursors (Lcom/itsaky/androidide/plugins/services/IdeEditorService;Ljava/io/File;)V public static fun dismissInlineSuggestion (Lcom/itsaky/androidide/plugins/services/IdeEditorService;)V + public static fun hidePeerCursor (Lcom/itsaky/androidide/plugins/services/IdeEditorService;Ljava/io/File;Ljava/lang/String;)Z public static fun removeContentChangeListener (Lcom/itsaky/androidide/plugins/services/IdeEditorService;Lcom/itsaky/androidide/plugins/services/EditorContentChangeListener;)V public static fun showInlineSuggestion (Lcom/itsaky/androidide/plugins/services/IdeEditorService;Ljava/lang/String;)V + public static fun showPeerCursor (Lcom/itsaky/androidide/plugins/services/IdeEditorService;Ljava/io/File;IILjava/lang/String;Ljava/lang/String;I)Z } public abstract interface class com/itsaky/androidide/plugins/services/IdeEditorTabService { @@ -1428,10 +1434,12 @@ public abstract interface class com/itsaky/androidide/plugins/services/IdeProjec public abstract fun getCurrentProject ()Lcom/itsaky/androidide/plugins/extensions/IProject; public abstract fun getModuleContext (Ljava/lang/String;)Lcom/itsaky/androidide/plugins/services/ModuleContext; public abstract fun getProjectByPath (Ljava/io/File;)Lcom/itsaky/androidide/plugins/extensions/IProject; + public abstract fun openProject (Ljava/io/File;)Z } public final class com/itsaky/androidide/plugins/services/IdeProjectService$DefaultImpls { public static fun getModuleContext (Lcom/itsaky/androidide/plugins/services/IdeProjectService;Ljava/lang/String;)Lcom/itsaky/androidide/plugins/services/ModuleContext; + public static fun openProject (Lcom/itsaky/androidide/plugins/services/IdeProjectService;Ljava/io/File;)Z } public abstract interface class com/itsaky/androidide/plugins/services/IdeSidebarService { @@ -1500,31 +1508,50 @@ public abstract interface class com/itsaky/androidide/plugins/services/LlmInfere public abstract fun getAvailableBackends ()Ljava/util/List; public abstract fun getBackend (Ljava/lang/String;)Lcom/itsaky/androidide/plugins/services/LlmInferenceService$LlmBackend; public abstract fun getEmbeddings (Ljava/lang/String;Ljava/lang/String;)Ljava/util/concurrent/CompletableFuture; + public fun getPreferredBackendId ()Ljava/lang/String; public abstract fun isBackendAvailable (Ljava/lang/String;)Z public abstract fun registerBackend (Lcom/itsaky/androidide/plugins/services/LlmInferenceService$LlmBackend;)V public abstract fun unregisterBackend (Ljava/lang/String;)V } +public abstract interface class com/itsaky/androidide/plugins/services/LlmInferenceService$CancellableBackend : com/itsaky/androidide/plugins/services/LlmInferenceService$LlmBackend { + public abstract fun cancelStreaming ()V +} + public class com/itsaky/androidide/plugins/services/LlmInferenceService$ChatMessage { public final field content Ljava/lang/String; public final field role Lcom/itsaky/androidide/plugins/services/LlmInferenceService$ChatMessage$Role; + public final field toolCallId Ljava/lang/String; + public final field toolName Ljava/lang/String; public fun (Lcom/itsaky/androidide/plugins/services/LlmInferenceService$ChatMessage$Role;Ljava/lang/String;)V + public static fun toolResult (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/itsaky/androidide/plugins/services/LlmInferenceService$ChatMessage; } public final class com/itsaky/androidide/plugins/services/LlmInferenceService$ChatMessage$Role : java/lang/Enum { public static final field ASSISTANT Lcom/itsaky/androidide/plugins/services/LlmInferenceService$ChatMessage$Role; public static final field SYSTEM Lcom/itsaky/androidide/plugins/services/LlmInferenceService$ChatMessage$Role; + public static final field TOOL Lcom/itsaky/androidide/plugins/services/LlmInferenceService$ChatMessage$Role; public static final field USER Lcom/itsaky/androidide/plugins/services/LlmInferenceService$ChatMessage$Role; public static fun valueOf (Ljava/lang/String;)Lcom/itsaky/androidide/plugins/services/LlmInferenceService$ChatMessage$Role; public static fun values ()[Lcom/itsaky/androidide/plugins/services/LlmInferenceService$ChatMessage$Role; } +public abstract interface class com/itsaky/androidide/plugins/services/LlmInferenceService$ConfigurableBackend : com/itsaky/androidide/plugins/services/LlmInferenceService$LlmBackend { + public abstract fun getSettingsFragmentClassName ()Ljava/lang/String; +} + +public abstract interface class com/itsaky/androidide/plugins/services/LlmInferenceService$HistoryCapableBackend : com/itsaky/androidide/plugins/services/LlmInferenceService$LlmBackend { + public abstract fun generateStreamingWithHistory (Ljava/util/List;Ljava/lang/String;Lcom/itsaky/androidide/plugins/services/LlmInferenceService$LlmConfig;Lcom/itsaky/androidide/plugins/services/LlmInferenceService$StreamCallback;)V +} + public abstract interface class com/itsaky/androidide/plugins/services/LlmInferenceService$LlmBackend { public abstract fun generate (Ljava/lang/String;Lcom/itsaky/androidide/plugins/services/LlmInferenceService$LlmConfig;)Ljava/util/concurrent/CompletableFuture; public abstract fun generateStreaming (Ljava/lang/String;Lcom/itsaky/androidide/plugins/services/LlmInferenceService$LlmConfig;Lcom/itsaky/androidide/plugins/services/LlmInferenceService$StreamCallback;)V public abstract fun generateWithHistory (Ljava/util/List;Ljava/lang/String;Lcom/itsaky/androidide/plugins/services/LlmInferenceService$LlmConfig;)Ljava/util/concurrent/CompletableFuture; + public fun getDefaultTemperature ()Ljava/lang/Float; public abstract fun getId ()Ljava/lang/String; public abstract fun getName ()Ljava/lang/String; + public fun getSystemPrompt (Lcom/itsaky/androidide/plugins/services/LlmInferenceService$SystemPromptRequest;)Ljava/lang/String; public abstract fun isAvailable ()Z } @@ -1556,6 +1583,13 @@ public abstract interface class com/itsaky/androidide/plugins/services/LlmInfere public abstract fun onToken (Ljava/lang/String;)V } +public class com/itsaky/androidide/plugins/services/LlmInferenceService$SystemPromptRequest { + public final field exampleFilePath Ljava/lang/String; + public final field toolCallSyntax Ljava/lang/String; + public final field tools Ljava/util/List; + public fun (Ljava/util/List;Ljava/lang/String;Ljava/lang/String;)V +} + public class com/itsaky/androidide/plugins/services/LlmInferenceService$ToolCallRequest { public field args Ljava/util/Map; public field callId Ljava/lang/String; @@ -1563,6 +1597,10 @@ public class com/itsaky/androidide/plugins/services/LlmInferenceService$ToolCall public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)V } +public abstract interface class com/itsaky/androidide/plugins/services/LlmInferenceService$ToolCallingBackend : com/itsaky/androidide/plugins/services/LlmInferenceService$LlmBackend { + public abstract fun generateStreamingWithTools (Ljava/lang/String;Ljava/util/List;Lcom/itsaky/androidide/plugins/services/LlmInferenceService$LlmConfig;Ljava/util/List;Lcom/itsaky/androidide/plugins/services/LlmInferenceService$ToolStreamCallback;)V +} + public class com/itsaky/androidide/plugins/services/LlmInferenceService$ToolDefinition { public field description Ljava/lang/String; public field name Ljava/lang/String; @@ -1629,6 +1667,43 @@ public abstract interface class com/itsaky/androidide/plugins/services/ThemeChan public abstract fun onThemeChanged (Z)V } +public abstract interface class com/itsaky/androidide/plugins/services/ToolSourceRegistry { + public static final field CONTRACT_VERSION I + public abstract fun getToolSources ()Ljava/util/List; + public abstract fun notifyToolsChanged (Ljava/lang/String;)V + public abstract fun registerToolSource (Lcom/itsaky/androidide/plugins/services/ToolSourceRegistry$ToolSource;)V + public abstract fun unregisterToolSource (Lcom/itsaky/androidide/plugins/services/ToolSourceRegistry$ToolSource;)V +} + +public abstract interface class com/itsaky/androidide/plugins/services/ToolSourceRegistry$ToolInvocation { + public abstract fun getArguments ()Ljava/util/Map; + public abstract fun getCallId ()Ljava/lang/String; + public fun getProjectRoot ()Ljava/lang/String; + public abstract fun getToolName ()Ljava/lang/String; +} + +public abstract interface class com/itsaky/androidide/plugins/services/ToolSourceRegistry$ToolOutcome { + public fun getErrorMessage ()Ljava/lang/String; + public abstract fun getOutput ()Ljava/lang/String; + public abstract fun isSuccess ()Z +} + +public abstract interface class com/itsaky/androidide/plugins/services/ToolSourceRegistry$ToolSource { + public fun cancel (Ljava/lang/String;)V + public abstract fun getDisplayName ()Ljava/lang/String; + public abstract fun getProviderId ()Ljava/lang/String; + public abstract fun invoke (Lcom/itsaky/androidide/plugins/services/ToolSourceRegistry$ToolInvocation;)Ljava/util/concurrent/CompletableFuture; + public abstract fun listTools ()Ljava/util/List; +} + +public abstract interface class com/itsaky/androidide/plugins/services/ToolSourceRegistry$ToolSpec { + public abstract fun getDescription ()Ljava/lang/String; + public abstract fun getName ()Ljava/lang/String; + public fun getParametersSchema ()Ljava/util/Map; + public fun isReadOnly ()Z + public fun requiresApproval ()Z +} + public final class com/itsaky/androidide/plugins/templates/CgtTemplateBuilder { public static final field Companion Lcom/itsaky/androidide/plugins/templates/CgtTemplateBuilder$Companion; public fun (Ljava/lang/String;)V diff --git a/plugin-api/plugin-builder/build.gradle.kts b/plugin-api/plugin-builder/build.gradle.kts index f620f80344..f0abf595e4 100644 --- a/plugin-api/plugin-builder/build.gradle.kts +++ b/plugin-api/plugin-builder/build.gradle.kts @@ -8,11 +8,11 @@ version = "1.0.0" dependencies { // AGP is provided at runtime by the plugin project's own `com.android.application`, - // and on-device plugin builds use the tooling AGP (`agp-tooling` = 8.11.0), which is - // what the harvested localMvnRepository ships. Keep it compileOnly so the published + // and on-device plugin builds use the tooling AGP (`agp-tooling`), which is what + // the harvested localMvnRepository ships. Keep it compileOnly so the published // POM stays dependency-free: forcing it as a transitive would make the coordinate // unresolvable offline whenever the harvested AGP differs from a pinned version. - compileOnly("com.android.tools.build:gradle:8.11.0") + compileOnly(libs.tooling.agp) } gradlePlugin { diff --git a/plugin-api/plugin-builder/settings.gradle.kts b/plugin-api/plugin-builder/settings.gradle.kts index b803f5ab99..a332f95858 100644 --- a/plugin-api/plugin-builder/settings.gradle.kts +++ b/plugin-api/plugin-builder/settings.gradle.kts @@ -1,9 +1,15 @@ rootProject.name = "plugin-builder" dependencyResolutionManagement { - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} \ No newline at end of file + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + + versionCatalogs { + create("libs") { + from(files("../../gradle/libs.versions.toml")) + } + } +} diff --git a/plugin-api/src/main/java/com/itsaky/androidide/plugins/services/LlmInferenceService.java b/plugin-api/src/main/java/com/itsaky/androidide/plugins/services/LlmInferenceService.java index 558ff2fcde..f9cb3b5c8b 100644 --- a/plugin-api/src/main/java/com/itsaky/androidide/plugins/services/LlmInferenceService.java +++ b/plugin-api/src/main/java/com/itsaky/androidide/plugins/services/LlmInferenceService.java @@ -2,368 +2,692 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.CompletableFuture; /** - * Service for LLM inference operations. - * Provided by ai-core plugin. + * Service for LLM inference operations. Provided by ai-core plugin. + * + *

+ * {@link LlmBackend} is the one type here that plugins implement rather than call, so it carries only what every backend can answer. Anything a backend may or may not do is a separate interface extending it -- {@link HistoryCapableBackend}, {@link ToolCallingBackend}, {@link CancellableBackend}, {@link ConfigurableBackend} -- and the consumer asks with {@code instanceof} before it calls. A capability is therefore declared by the type, not by a flag a backend can set inconsistently with the methods it overrode. */ public interface LlmInferenceService { - /** - * Configuration for LLM generation - */ - class LlmConfig { - /** The LLM backend identifier (e.g., "openai", "local"). Must not be null. */ - public String backendId; - - /** The name of the model to use for generation */ - public String modelName; - - /** Temperature for generation (0.0-1.0). Default 0.7f provides balanced creativity and coherence. */ - public float temperature = 0.7f; - - /** Maximum number of tokens to generate. Default 2048 balances response length and resource usage. */ - public int maxTokens = 2048; - - /** Optional sequences that signal end of generation */ - public List stopSequences; - - /** Optional system prompt to guide model behavior */ - public String systemPrompt; - - /** Optional backend-specific parameters */ - public Map extraParams; - - /** - * Creates a configuration for LLM generation. - * - * @param backendId the LLM backend identifier (must not be null). The backend must be - * registered with the service. - * @throws IllegalArgumentException if backendId is null - */ - public LlmConfig(String backendId) { - if (backendId == null) { - throw new IllegalArgumentException("backendId must not be null"); - } - this.backendId = backendId; - } - } - - /** - * LLM response - */ - class LlmResponse { - /** Whether the generation was successful */ - public final boolean success; - - /** Generated text (null if not successful) */ - public final String text; - - /** Error message (null if successful) */ - public final String error; - - /** Number of tokens generated in the response */ - public final int tokensGenerated; - - /** Time taken to generate the response in milliseconds */ - public final long timeMs; - - public LlmResponse(boolean success, String text, String error, - int tokensGenerated, long timeMs) { - this.success = success; - this.text = text; - this.error = error; - this.tokensGenerated = tokensGenerated; - this.timeMs = timeMs; - } - - /** - * Creates a successful response. - * - * @param text the generated text - * @param tokens the number of tokens generated - * @param timeMs the time taken in milliseconds - * @return a successful LlmResponse - */ - public static LlmResponse success(String text, int tokens, long timeMs) { - return new LlmResponse(true, text, null, tokens, timeMs); - } - - /** - * Creates a failed response. - * - * @param error the error message describing why generation failed - * @return a failed LlmResponse - */ - public static LlmResponse failure(String error) { - return new LlmResponse(false, null, error, 0, 0); - } - } - - /** - * Callback for streaming responses - */ - interface StreamCallback { - /** - * Called when a token is received. - * - * @param token the generated token - */ - void onToken(String token); - - /** - * Called when generation is complete. - * - * @param response the complete response - */ - void onComplete(LlmResponse response); - - /** - * Called when an error occurs. - * - * @param error the error message - */ - void onError(String error); - } - - /** - * Message in a conversation - */ - class ChatMessage { - /** Role of the message sender */ - public enum Role { USER, ASSISTANT, SYSTEM } - - /** The role of the message sender */ - public final Role role; - - /** The text content of the message */ - public final String content; - - /** - * Creates a chat message. - * - * @param role the role of the sender - * @param content the message content - */ - public ChatMessage(Role role, String content) { - this.role = role; - this.content = content; - } - } - - /** - * LLM backend provider - */ - interface LlmBackend { - /** - * Gets the unique identifier for this backend. - * - * @return the backend identifier - */ - String getId(); - - /** - * Gets the human-readable name of this backend. - * - * @return the backend name - */ - String getName(); - - /** - * Checks if this backend is available for use. - * - * @return true if the backend is available, false otherwise - */ - boolean isAvailable(); - - /** - * Generates a completion for the given prompt. - * - * @param prompt the input prompt - * @param config the generation configuration - * @return a future that completes with the generated response - */ - CompletableFuture generate(String prompt, LlmConfig config); - - /** - * Generates a completion with streaming output. - * - * @param prompt the input prompt - * @param config the generation configuration - * @param callback the callback to receive tokens and completion events - */ - void generateStreaming(String prompt, LlmConfig config, StreamCallback callback); - - /** - * Generates a completion based on conversation history. - * - * @param history the conversation history - * @param prompt the current prompt - * @param config the generation configuration - * @return a future that completes with the generated response - */ - CompletableFuture generateWithHistory( - List history, - String prompt, - LlmConfig config - ); - } - - /** - * Registers an LLM backend with the service. - * - * @param backend the backend to register (must not be null) - */ - void registerBackend(@NonNull LlmBackend backend); - - /** - * Unregisters an LLM backend from the service. - * - * @param backendId the backend identifier (must not be null) - */ - void unregisterBackend(@NonNull String backendId); - - /** - * Gets all available LLM backends. - * - * @return a list of available backends (never null) - */ - @NonNull List getAvailableBackends(); - - /** - * Gets a specific backend by identifier. - * - * @param backendId the backend identifier (must not be null) - * @return the backend if found, or null if not registered - */ - @Nullable LlmBackend getBackend(@NonNull String backendId); - - /** - * Generates a text completion for the given prompt. - * - * @param prompt the input prompt (must not be null) - * @param config the generation configuration (must not be null) - * @return a future that completes with the generated response (never null) - */ - @NonNull CompletableFuture generateCompletion(@NonNull String prompt, @NonNull LlmConfig config); - - /** - * Generates a text completion with streaming output. - * - * @param prompt the input prompt (must not be null) - * @param config the generation configuration (must not be null) - * @param callback the callback to receive tokens and completion events (must not be null) - */ - void generateStreaming(@NonNull String prompt, @NonNull LlmConfig config, @NonNull StreamCallback callback); - - /** - * Generates a completion based on conversation history. - * - * @param history the conversation history (must not be null) - * @param prompt the current prompt (must not be null) - * @param config the generation configuration (must not be null) - * @return a future that completes with the generated response (never null) - */ - @NonNull CompletableFuture generateWithHistory(@NonNull List history, @NonNull String prompt, @NonNull LlmConfig config); - - /** - * Generates embeddings for the given text. - * - * @param text the input text to embed (must not be null) - * @param backendId the backend to use for embedding (must not be null) - * @return a future that completes with the embedding vector (never null) - */ - @NonNull CompletableFuture getEmbeddings(@NonNull String text, @NonNull String backendId); - - /** - * Tool definition for structured function calling. - * Defines a tool that the LLM can invoke. - */ - class ToolDefinition { - public String name; - public String description; - public Map parametersSchema; - - public ToolDefinition(String name, String description, Map parametersSchema) { - this.name = name; - this.description = description; - this.parametersSchema = parametersSchema; - } - } - - /** - * A tool call request made by the LLM. - * Represents the LLM's request to invoke a tool with specific arguments. - */ - class ToolCallRequest { - public String callId; - public String name; - public Map args; - - public ToolCallRequest(String callId, String name, Map args) { - this.callId = callId; - this.name = name; - this.args = args; - } - } - - /** - * Callback for streaming responses with tool calling support. - * Handles tokens, tool calls, completion, and errors. - */ - interface ToolStreamCallback { - /** - * Called when a text token is received. - */ - void onToken(String token); - - /** - * Called when the LLM makes a tool call. - */ - void onToolCall(ToolCallRequest request); - - /** - * Called when generation is complete. - */ - void onComplete(LlmResponse response); - - /** - * Called on error. - */ - void onError(String error); - } - - /** - * Generate streaming response with tool calling support. - * The LLM can call tools, and the caller responds with tool results. - * - * @param prompt the user prompt - * @param history the conversation history (can be empty) - * @param config the generation configuration - * @param tools the available tools the LLM can call - * @param callback the callback for handling tokens, tool calls, completion, and errors - */ - void generateStreamingWithTools( - @NonNull String prompt, - @NonNull List history, - @NonNull LlmConfig config, - @NonNull List tools, - @NonNull ToolStreamCallback callback - ); - - /** - * Checks if a backend is available. - * - * @param backendId the backend identifier (must not be null) - * @return true if the backend is registered and available, false otherwise - */ - boolean isBackendAvailable(@NonNull String backendId); - - /** - * Cancels any ongoing generation operation. - */ - void cancelGeneration(); + /** + * Cancels any ongoing generation operation. + */ + void cancelGeneration(); + + /** + * Generates a text completion for the given prompt. + * + * @param prompt + * the input prompt (must not be null) + * @param config + * the generation configuration (must not be null) + * @return a future that completes with the generated response (never null) + */ + @NonNull + CompletableFuture generateCompletion(@NonNull String prompt, @NonNull LlmConfig config); + + /** + * Generates a text completion with streaming output. + * + * @param prompt + * the input prompt (must not be null) + * @param config + * the generation configuration (must not be null) + * @param callback + * the callback to receive tokens and completion events (must not be null) + */ + void generateStreaming(@NonNull String prompt, @NonNull LlmConfig config, @NonNull StreamCallback callback); + + /** + * Generate streaming response with tool calling support. The LLM can call tools, and the caller responds with tool results. + * + * @param prompt + * the user prompt + * @param history + * the conversation history (can be empty) + * @param config + * the generation configuration + * @param tools + * the available tools the LLM can call + * @param callback + * the callback for handling tokens, tool calls, completion, and errors + */ + void generateStreamingWithTools( + @NonNull String prompt, + @NonNull List history, + @NonNull LlmConfig config, + @NonNull List tools, + @NonNull ToolStreamCallback callback); + + /** + * Generates a completion based on conversation history. + * + * @param history + * the conversation history (must not be null) + * @param prompt + * the current prompt (must not be null) + * @param config + * the generation configuration (must not be null) + * @return a future that completes with the generated response (never null) + */ + @NonNull + CompletableFuture generateWithHistory(@NonNull List history, @NonNull String prompt, @NonNull LlmConfig config); + + /** + * Gets all available LLM backends. + * + * @return a list of available backends (never null) + */ + @NonNull + List getAvailableBackends(); + + /** + * Gets a specific backend by identifier. + * + * @param backendId + * the backend identifier (must not be null) + * @return the backend if found, or null if not registered + */ + @Nullable + LlmBackend getBackend(@NonNull String backendId); + + /** + * Generates embeddings for the given text. + * + * @param text + * the input text to embed (must not be null) + * @param backendId + * the backend to use for embedding (must not be null) + * @return a future that completes with the embedding vector (never null) + */ + @NonNull + CompletableFuture getEmbeddings(@NonNull String text, @NonNull String backendId); + + /** + * Gets the id of the backend the user selected, independent of whether it is registered or currently usable. + * + *

+ * Which backend is active is the router's state, not any one backend's, but a backend sometimes needs it: one that would otherwise spend seconds and gigabytes preparing itself has to know whether it is the backend about to be used. Publishing it here is what keeps a backend from having to read another plugin's preferences to find out. + * + * @return the selected backend id, or null when no selection has been expressed + */ + @Nullable + default String getPreferredBackendId() { + return null; + } + + /** + * Checks if a backend is available. + * + * @param backendId + * the backend identifier (must not be null) + * @return true if the backend is registered and available, false otherwise + */ + boolean isBackendAvailable(@NonNull String backendId); + + /** + * Registers an LLM backend with the service. + * + * @param backend + * the backend to register (must not be null) + */ + void registerBackend(@NonNull LlmBackend backend); + + /** + * Unregisters an LLM backend from the service. + * + * @param backendId + * the backend identifier (must not be null) + */ + void unregisterBackend(@NonNull String backendId); + + /** + * A backend whose in-flight streaming generation can be cancelled (Stop pressed). + */ + interface CancellableBackend extends LlmBackend { + /** + * Cancels the streaming generation currently in flight, if any. + */ + void cancelStreaming(); + } + + /** + * One turn of a conversation: what the user asked, what the model answered, or what a tool returned. + */ + class ChatMessage { + /** + * Creates the message that carries a tool's output back into the next turn. + * + *

+ * This is the return path for {@link ToolStreamCallback#onToolCall}: the consumer runs the tool, wraps the outcome here, and appends it to the history of the following request. Both correlators travel with it because providers key results differently -- by call id, or by function name -- and a backend can only forward what it was given. + * + * @param toolCallId + * the {@link ToolCallRequest#callId} this result answers + * @param toolName + * the {@link ToolCallRequest#name} that was invoked + * @param content + * the tool's output, already rendered as text + * @return a message with role {@link Role#TOOL} + */ + @NonNull + public static ChatMessage toolResult(@NonNull String toolCallId, @NonNull String toolName, @NonNull String content) { + return new ChatMessage( + Role.TOOL, + Objects.requireNonNull(content, "content must not be null"), + Objects.requireNonNull(toolCallId, "toolCallId must not be null"), + Objects.requireNonNull(toolName, "toolName must not be null")); + } + + /** The role of the message sender */ + @NonNull + public final Role role; + + /** The text content of the message */ + @NonNull + public final String content; + + /** The call this message answers; non-null exactly when {@link #role} is {@link Role#TOOL}. */ + @Nullable + public final String toolCallId; + + /** The tool this message answers for; non-null exactly when {@link #role} is {@link Role#TOOL}. */ + @Nullable + public final String toolName; + + /** + * Creates a chat message from a conversation participant. + * + * @param role + * the role of the sender; not {@link Role#TOOL}, which needs the correlators only {@link #toolResult} supplies + * @param content + * the message content + * @throws IllegalArgumentException + * if role is {@link Role#TOOL} + */ + public ChatMessage(@NonNull Role role, @NonNull String content) { + if (role == Role.TOOL) { + throw new IllegalArgumentException("A TOOL message must be built with ChatMessage.toolResult(...)"); + } + this.role = Objects.requireNonNull(role, "role must not be null"); + this.content = Objects.requireNonNull(content, "content must not be null"); + this.toolCallId = null; + this.toolName = null; + } + + private ChatMessage(@NonNull Role role, @NonNull String content, @NonNull String toolCallId, @NonNull String toolName) { + this.role = role; + this.content = content; + this.toolCallId = toolCallId; + this.toolName = toolName; + } + + /** Role of the message sender */ + public enum Role { + USER, ASSISTANT, SYSTEM, TOOL + } + } + + /** + * An {@link LlmBackend} that draws its own settings screen. Kept apart from {@code LlmBackend} so that running inference stays independent of presenting a UI: a backend with nothing to configure implements nothing, and the consumer asks with {@code instanceof} before it draws. + */ + interface ConfigurableBackend extends LlmBackend { + /** + * Gets the fully-qualified name of the {@code Fragment} this backend contributes to draw its settings. The class must live in the backend's own plugin and declare a public no-argument constructor; the consumer loads it with the backend's classloader and mounts it wherever it presents backend settings. The name is passed as a string so this contract stays free of any dependency on Android UI types. + * + *

+ * The backend owns the screen outright -- including where each value is stored, which is why nothing here describes a field or a store. A consumer cannot prefill or write a backend's settings; it can only mount them. + * + * @return the fragment class name (never null) + */ + @NonNull + String getSettingsFragmentClassName(); + } + + /** + * An {@link LlmBackend} that renders earlier turns of a conversation. + * + *

+ * Implementing this is the declaration: a backend that can only prompt single-turn does not implement it, and the consumer calls {@link LlmBackend#generateStreaming} instead of silently losing the conversation -- which reads to the user as a model that cannot follow one. + */ + interface HistoryCapableBackend extends LlmBackend { + /** + * Generates a streaming reply for a multi-turn conversation. + * + * @param history + * the conversation history + * @param prompt + * the current prompt + * @param config + * the generation configuration + * @param callback + * the callback to receive tokens and completion events + */ + void generateStreamingWithHistory( + @NonNull List history, + @NonNull String prompt, + @NonNull LlmConfig config, + @NonNull StreamCallback callback); + } + + /** + * LLM backend provider + */ + interface LlmBackend { + /** + * Generates a completion for the given prompt. + * + * @param prompt + * the input prompt + * @param config + * the generation configuration + * @return a future that completes with the generated response + */ + @NonNull + CompletableFuture generate(@NonNull String prompt, @NonNull LlmConfig config); + + /** + * Generates a completion with streaming output. + * + * @param prompt + * the input prompt + * @param config + * the generation configuration + * @param callback + * the callback to receive tokens and completion events + */ + void generateStreaming(@NonNull String prompt, @NonNull LlmConfig config, @NonNull StreamCallback callback); + + /** + * Generates a completion based on conversation history. + * + * @param history + * the conversation history + * @param prompt + * the current prompt + * @param config + * the generation configuration + * @return a future that completes with the generated response + */ + @NonNull + CompletableFuture generateWithHistory( + @NonNull List history, + @NonNull String prompt, + @NonNull LlmConfig config); + + /** + * Gets the sampling temperature this backend works best at, or null to accept the consumer's own. + * + *

+ * A backend driven by a constrained grammar wants a near-greedy value so it copies arguments rather than inventing them; a cloud model following a high-autonomy prompt usually wants more room. Neither figure is the consumer's to guess. + * + *

+ * Boxed so that "no preference" is expressible. {@link LlmConfig#temperature} is a primitive, so a consumer must null-check before it assigns: {@code config.temperature = backend.getDefaultTemperature()} unboxes null and throws. + * + * @return the preferred temperature, or null for the consumer's default + */ + @Nullable + default Float getDefaultTemperature() { + return null; + } + + /** + * Gets the unique identifier for this backend. + * + * @return the backend identifier + */ + @NonNull + String getId(); + + /** + * Gets the human-readable name of this backend. + * + * @return the backend name + */ + @NonNull + String getName(); + + /** + * Gets the system prompt to send with every request to this backend, or null to accept the consumer's own. + * + *

+ * Prompt wording is model-specific -- how much autonomy a model handles, how literally it copies an example -- so it belongs with the backend that knows the model, not with the consumer that knows the tools. The consumer still owns the call syntax: reproduce {@link SystemPromptRequest#toolCallSyntax} verbatim when it is present, or the replies this prompt produces will not parse. + * + * @param request + * the tool contract and example material to compose against + * @return the system prompt, or null to use the consumer's default + */ + @Nullable + default String getSystemPrompt(@NonNull SystemPromptRequest request) { + return null; + } + + /** + * Checks if this backend is available for use. + * + * @return true if the backend is available, false otherwise + */ + boolean isAvailable(); + } + + /** + * Configuration for LLM generation + */ + class LlmConfig { + /** The LLM backend identifier (e.g., "openai", "local"). Must not be null. */ + public String backendId; + + /** The name of the model to use for generation */ + public String modelName; + + /** Temperature for generation (0.0-1.0). Default 0.7f provides balanced creativity and coherence. */ + public float temperature = 0.7f; + + /** Maximum number of tokens to generate. Default 2048 balances response length and resource usage. */ + public int maxTokens = 2048; + + /** Optional sequences that signal end of generation */ + public List stopSequences; + + /** Optional system prompt to guide model behavior */ + public String systemPrompt; + + /** Optional backend-specific parameters */ + public Map extraParams; + + /** + * Creates a configuration for LLM generation. + * + * @param backendId + * the LLM backend identifier (must not be null). The backend must be registered with the service. + * @throws IllegalArgumentException + * if backendId is null + */ + public LlmConfig(String backendId) { + if (backendId == null) { + throw new IllegalArgumentException("backendId must not be null"); + } + this.backendId = backendId; + } + } + + /** + * LLM response + */ + class LlmResponse { + /** + * Creates a failed response. + * + * @param error + * the error message describing why generation failed + * @return a failed LlmResponse + */ + @NonNull + public static LlmResponse failure(@NonNull String error) { + return new LlmResponse(false, null, error, 0, 0); + } + + /** + * Creates a successful response. + * + * @param text + * the generated text + * @param tokens + * the number of tokens generated + * @param timeMs + * the time taken in milliseconds + * @return a successful LlmResponse + */ + @NonNull + public static LlmResponse success(@NonNull String text, int tokens, long timeMs) { + return new LlmResponse(true, text, null, tokens, timeMs); + } + + /** Whether the generation was successful */ + public final boolean success; + + /** Generated text (null if not successful) */ + @Nullable + public final String text; + + /** Error message (null if successful) */ + @Nullable + public final String error; + + /** Number of tokens generated in the response */ + public final int tokensGenerated; + + /** Time taken to generate the response in milliseconds */ + public final long timeMs; + + public LlmResponse(boolean success, @Nullable String text, @Nullable String error, + int tokensGenerated, long timeMs) { + this.success = success; + this.text = text; + this.error = error; + this.tokensGenerated = tokensGenerated; + this.timeMs = timeMs; + } + } + + /** + * Callback for streaming responses + */ + interface StreamCallback { + /** + * Called when generation is complete. + * + * @param response + * the complete response + */ + void onComplete(LlmResponse response); + + /** + * Called when an error occurs. + * + * @param error + * the error message + */ + void onError(String error); + + /** + * Called when a token is received. + * + * @param token + * the generated token + */ + void onToken(String token); + } + + /** + * What a backend is given to compose a system prompt in {@link LlmBackend#getSystemPrompt}. + * + *

+ * The consumer supplies the tool contract; the backend supplies the wording. That split matters: the consumer is the side that parses the model's reply, so a backend that invents its own call syntax produces output nothing reads back -- and it fails silently, as a model that answers in prose rather than calling a tool. + */ + class SystemPromptRequest { + /** The tools the consumer will accept calls for, in the order to present them. Never null; empty when the conversation offers no tools. The list is unmodifiable, but only its spine is copied -- the {@link ToolDefinition}s in it are the consumer's, and a backend must not edit one. */ + @NonNull + public final List tools; + + /** + * The exact envelope the consumer parses back, to be reproduced verbatim in the prompt, or null when it parses none. + * + *

+ * Null is the plain-chat case, and the case of a consumer driving {@link ToolCallingBackend} through a provider's own function calling: there is no text envelope, so a prompt must not instruct the model to emit one. Reproducing an empty envelope is the failure this type exists to prevent -- the model is told to call tools in a syntax nothing reads, and answers in prose instead. + */ + @Nullable + public final String toolCallSyntax; + + /** + * A real path from the user's project for the prompt's examples, so they imply no layout or language the project does not have. + */ + @Nullable + public final String exampleFilePath; + + /** + * Creates a system prompt request. + * + * @param tools + * the tools to present to the model; copied, so later edits to the caller's list do not reach the request + * @param toolCallSyntax + * the call envelope the consumer parses, or null when it parses none + * @param exampleFilePath + * a real project path to use in examples, or null when the project has no file to point at + */ + public SystemPromptRequest(@Nullable List tools, @Nullable String toolCallSyntax, + @Nullable String exampleFilePath) { + this.tools = tools == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(tools)); + this.toolCallSyntax = toolCallSyntax; + this.exampleFilePath = exampleFilePath; + } + } + + /** + * An {@link LlmBackend} that reports the model's tool calls as structured calls. + * + *

+ * Implementing this is the declaration, and it means {@link ToolStreamCallback#onToolCall} will fire for a call the model makes. A backend that merely wants earlier turns implements {@link HistoryCapableBackend} instead: accepting tools and never calling one leaves the consumer waiting on an action the model was never able to take. + */ + interface ToolCallingBackend extends LlmBackend { + /** + * Generates a completion with streaming output and tool calling support. + * + * @param prompt + * the input prompt + * @param history + * the conversation history, including any {@link ChatMessage#toolResult} from earlier turns (can be empty) + * @param config + * the generation configuration + * @param tools + * the available tools the LLM can call + * @param callback + * the callback to receive tokens, tool calls and completion events + */ + void generateStreamingWithTools( + @NonNull String prompt, + @NonNull List history, + @NonNull LlmConfig config, + @NonNull List tools, + @NonNull ToolStreamCallback callback); + } + + /** + * A tool call request made by the LLM. Represents the LLM's request to invoke a tool with specific arguments. + * + *

+ * The backend that reports a call owns the instance; treat it as read-only once {@link ToolStreamCallback#onToolCall} has been given it. The fields are not final and {@link #args} is held by reference, because both shipped that way in 26.28 and tightening them would break an already-built plugin that assigns them. Rewriting one after the fact means the consumer runs a call the model did not make. + */ + class ToolCallRequest { + /** Identifier correlating this call with the result the consumer sends back in {@link ChatMessage#toolResult} */ + @NonNull + public String callId; + + /** Name of the tool to invoke; matches a {@link ToolDefinition#name} the consumer offered */ + @NonNull + public String name; + + /** Arguments the model supplied, keyed by parameter name; null when the tool takes none */ + @Nullable + public Map args; + + /** + * Creates a tool call request. + * + * @param callId + * the identifier correlating this call with its result + * @param name + * the name of the tool to invoke + * @param args + * the arguments the model supplied, or null for none; held by reference, so do not edit the map afterwards + */ + public ToolCallRequest(@NonNull String callId, @NonNull String name, @Nullable Map args) { + this.callId = callId; + this.name = name; + this.args = args; + } + } + + /** + * Tool definition for structured function calling. Defines a tool that the LLM can invoke. + * + *

+ * The consumer that offers a tool owns the instance; a backend given one in {@link SystemPromptRequest#tools} must treat it as read-only. The fields are not final and {@link #parametersSchema} is held by reference, because both shipped that way in 26.28 and tightening them would break an already-built plugin that assigns them. Renaming a tool or emptying its schema after the prompt is composed leaves the consumer parsing replies against a contract it no longer offered -- and {@link SystemPromptRequest} copies only the list spine, so its copy points at these same instances. + */ + class ToolDefinition { + /** The name the model must use to call this tool */ + @NonNull + public String name; + + /** What the tool does, in wording meant for the model rather than the user */ + @NonNull + public String description; + + /** JSON-schema-shaped description of the parameters; null when the tool takes none */ + @Nullable + public Map parametersSchema; + + /** + * Creates a tool definition. + * + * @param name + * the name the model must use to call the tool + * @param description + * what the tool does + * @param parametersSchema + * the parameter schema, or null when the tool takes no parameters; held by reference, so do not edit the map afterwards + */ + public ToolDefinition(@NonNull String name, @NonNull String description, + @Nullable Map parametersSchema) { + this.name = name; + this.description = description; + this.parametersSchema = parametersSchema; + } + } + + /** + * Callback for streaming responses with tool calling support. Handles tokens, tool calls, completion, and errors. + */ + interface ToolStreamCallback { + /** + * Called when generation is complete. + * + * @param response + * the complete response + */ + void onComplete(LlmResponse response); + + /** + * Called when an error occurs. + * + * @param error + * the error message + */ + void onError(String error); + + /** + * Called when a text token is received. + * + * @param token + * the generated token + */ + void onToken(String token); + + /** + * Called when the LLM makes a tool call. The consumer runs the tool and appends the outcome to the next request's history as a {@link ChatMessage#toolResult}, which carries {@link ToolCallRequest#callId} back so a turn's several calls are correlated by id rather than by position. + * + * @param request + * the tool the model wants called, and the arguments it supplied + */ + void onToolCall(ToolCallRequest request); + } } diff --git a/plugin-api/src/main/java/com/itsaky/androidide/plugins/services/ToolSourceRegistry.java b/plugin-api/src/main/java/com/itsaky/androidide/plugins/services/ToolSourceRegistry.java new file mode 100644 index 0000000000..f98a746c1c --- /dev/null +++ b/plugin-api/src/main/java/com/itsaky/androidide/plugins/services/ToolSourceRegistry.java @@ -0,0 +1,250 @@ +package com.itsaky.androidide.plugins.services; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * Registry through which plugins contribute tools to the IDE's AI agent. + * + *

+ * The registry itself is implemented by the plugin that owns the agent (ai-core) and published under this type in {@link SharedServices}; the host defines the contract only. A contributing plugin resolves the registry on {@code activate()}, registers a {@link ToolSource}, and unregisters on {@code deactivate()} -- the same lifecycle a model backend follows with {@link LlmInferenceService#registerBackend}. When the agent plugin is not installed the lookup returns null and a provider registers nothing, which is the same clean degradation the backends already rely on. + * + *

+ * Values crossing this boundary must be JDK types ({@code String}, {@code Boolean}, {@code Integer}, {@code Double}, {@code List}, {@code Map}). Each plugin is loaded by its own class loader with the host as parent, so a class packaged in one {@code .cgp} is not resolvable from another; only types loaded by the host -- this interface and the JDK -- are common ground. A type duplicated into each plugin instead compiles cleanly and then fails on device with {@code ClassCastException}, because each loader defines its own copy. + * + *

+ * Every member here is an interface rather than a value class on purpose: {@code plugin-api} is additive-only, and adding a property to a class removes the constructor signature already-published plugins were built against. Java {@code default} methods let this contract grow without touching an implementor. The cost is a small concrete class on each side. + */ +public interface ToolSourceRegistry { + + /** + * Contract revision, bumped whenever a member is added here. + * + *

+ * It marks the revision, it does not negotiate one: javac inlines a constant into every class that reads it, so a plugin carries the value it compiled against and the host its own, and neither can read the other's. Version compatibility is enforced where the loader already enforces it, by {@code plugin.min_ide_version} in the plugin manifest. + */ + int CONTRACT_VERSION = 1; + + /** + * Gets every registered source, in registration order. + * + * @return the registered sources (never null) + */ + @NonNull + List getToolSources(); + + /** + * Signals that a provider's tool list has changed and must be read again -- an MCP server connected, a user toggled a tool off. The agent re-reads {@link ToolSource#listTools} and rebuilds whatever it derives from it. + * + * @param providerId + * the {@link ToolSource#getProviderId} whose tools changed; unknown ids are ignored + */ + void notifyToolsChanged(@NonNull String providerId); + + /** + * Adds a source's tools to the agent, replacing any source already registered under the same {@link ToolSource#getProviderId}. Re-registration is how a provider recovers after the agent plugin restarts. + * + * @param source + * the source to register (must not be null) + */ + void registerToolSource(@NonNull ToolSource source); + + /** + * Removes a source previously passed to {@link #registerToolSource}, matched by instance identity rather than by id, so a provider id a second plugin happens to reuse does not remove the first plugin's source. + * + *

+ * Identity is not proof of ownership and this is not a trust boundary between plugins: {@link #getToolSources} hands every caller the registered instances, and {@link #registerToolSource} replaces whatever is registered under the same id. What keeps a plugin out of the agent is not installing it. + * + * @param source + * the source to remove; a source that is not registered is ignored + */ + void unregisterToolSource(@NonNull ToolSource source); + + /** + * One call to a tool, constructed by the agent. + */ + interface ToolInvocation { + + /** + * Gets the arguments, keyed by schema property name. + * + *

+ * The registry implementation must hand each source a copy holding JDK value types only ({@code String}, {@code Boolean}, {@code Integer}, {@code Double}, {@code List}, {@code Map}), recursively -- a value of any other type is rejected or coerced before the call is dispatched, never passed through. Two obligations follow from the class loader split: an object defined by the agent's loader is not resolvable from a source's, and a map shared across the boundary would let either side mutate what the other reads. + * + * @return the arguments (never null; empty when the tool takes none) + */ + @NonNull + Map getArguments(); + + /** + * Gets the identifier of this call for the lifetime of the run; the key for {@link ToolSource#cancel}. + * + * @return the call identifier (never null) + */ + @NonNull + String getCallId(); + + /** + * Gets the absolute path of the open project's root. + * + * @return the project root, or null when no project is open + */ + @Nullable + default String getProjectRoot() { + return null; + } + + /** + * Gets the tool's own {@link ToolSpec#getName}, without the agent's namespace prefix. + * + * @return the tool name (never null) + */ + @NonNull + String getToolName(); + } + + /** + * The result of one call. + * + *

+ * A failing outcome must say why. When {@link #isSuccess} returns false, at least one of {@link #getErrorMessage} and {@link #getOutput} has to carry the detail -- the message for the user, the output for the model. Both is better; neither leaves the model with an unexplained refusal, which it retries. + */ + interface ToolOutcome { + + /** + * Gets one user-facing sentence explaining a failure. + * + * @return the error message, or null when {@link #isSuccess} is true or the failure is already explained by {@link #getOutput} + */ + @Nullable + default String getErrorMessage() { + return null; + } + + /** + * Gets the result as text for the model. The agent truncates it, so put the answer first. + * + * @return the output (never null) + */ + @NonNull + String getOutput(); + + /** + * Checks whether the tool did what was asked. A false outcome is reported to the model. + * + * @return true if the call succeeded, false otherwise + */ + boolean isSuccess(); + } + + /** + * A plugin's contribution of one or more agent tools. + * + *

+ * Implementations must not throw across this boundary: the agent treats a throwing source as absent, so a failing {@code .cgp} costs the user its tools rather than the whole agent. + */ + interface ToolSource { + + /** + * Best-effort cancellation of an in-flight {@link #invoke}, matched by {@link ToolInvocation#getCallId}. Called when the user stops the agent run. + * + *

+ * Best-effort covers how much work is undone, not whether the future settles: the {@link CompletableFuture} that {@code invoke} returned must still reach a terminal state. Complete it exceptionally with a {@link java.util.concurrent.CancellationException} once the work stops, or normally if it had already finished when the cancel arrived. A future left pending strands the agent's continuation until its own timeout fires. + * + * @param callId + * the call to cancel; unknown ids are ignored + */ + default void cancel(@NonNull String callId) {} + + /** + * Gets the human-readable source name, shown wherever tool provenance is surfaced. + * + * @return the display name (never null) + */ + @NonNull + String getDisplayName(); + + /** + * Gets this source's stable identity, conventionally the contributing plugin's {@code plugin.id}. + * + * @return the provider identifier (never null) + */ + @NonNull + String getProviderId(); + + /** + * Runs one tool. Must return promptly and complete the future off the caller's thread; the agent awaits it and never blocks the UI thread on it. + * + * @param invocation + * the call to run (must not be null) + * @return a future that completes with the outcome (never null) + */ + @NonNull + CompletableFuture invoke(@NonNull ToolInvocation invocation); + + /** + * Gets the tools currently offered. Called on registration and after {@link ToolSourceRegistry#notifyToolsChanged}; must be cheap and must not block on the network. + * + * @return the tools this source offers (never null) + */ + @NonNull + List listTools(); + } + + /** + * One tool a {@link ToolSource} offers. + */ + interface ToolSpec { + + /** + * Gets what the tool does, in one or two sentences -- this reaches the model's prompt. + * + * @return the description (never null) + */ + @NonNull + String getDescription(); + + /** + * Gets this tool's name, unique within its source. The agent namespaces it before exposing it to the model. + * + * @return the tool name (never null) + */ + @NonNull + String getName(); + + /** + * Gets the JSON schema for the arguments: a JSON Schema object -- {@code "type": "object"} with {@code "properties"} and {@code "required"} -- expressed in the JDK value types {@link ToolInvocation#getArguments} accepts, so it needs no conversion on the way to a model. + * + * @return the parameter schema; empty means untyped, flat string arguments, which is what the current tool-call protocol supports + */ + @NonNull + default Map getParametersSchema() { + return Collections.emptyMap(); + } + + /** + * Checks whether the tool is free of side effects, allowing the agent to run it concurrently. + * + * @return true if the tool only reads, false otherwise + */ + default boolean isReadOnly() { + return false; + } + + /** + * Checks whether the user must approve each call. + * + *

+ * Defaults to true, inverted relative to the agent's own tools: those are contained by the agent's path guard before a handler runs, while a tool contributed by a third party -- or proxied from a remote server -- is contained by nothing. The safe default is to ask. + * + * @return true if each call needs user approval, false otherwise + */ + default boolean requiresApproval() { + return true; + } + } +} diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt index 617b8a0e05..b0595c3f2b 100644 --- a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt @@ -13,53 +13,75 @@ import java.util.concurrent.CompletableFuture * that have the FILESYSTEM_READ permission. */ interface IdeProjectService { - /** - * Gets the currently active/open project. - * @return The current project, or null if no project is open - */ - fun getCurrentProject(): IProject? - - /** - * Gets all projects currently loaded in the IDE. - * @return List of all loaded projects - */ - fun getAllProjects(): List - - /** - * Finds a project by its root directory path. - * @param path The root directory path of the project - * @return The project at the given path, or null if not found - */ - fun getProjectByPath(path: File): IProject? - - /** - * Resolves the build context (compile/intermediate classpaths, runtime dex files, - * selected variant, resource APK, and whether a build is needed) for the module that - * owns the given file. - * - * Defaults to returning null so the method is binary-compatible: hosts that predate it, - * and implementors that do not override it, report "unavailable" (mirrors the default on - * [IdeUIService.openPluginScreen]). - * - * @param filePath The absolute path of a source file owned by the module - * @return The module context, or null if no module can be resolved - */ - fun getModuleContext(filePath: String): ModuleContext? = null + /** + * Gets the currently active/open project. + * @return The current project, or null if no project is open + */ + fun getCurrentProject(): IProject? + + /** + * Gets all projects currently loaded in the IDE. + * @return List of all loaded projects + */ + fun getAllProjects(): List + + /** + * Finds a project by its root directory path. + * @param path The root directory path of the project + * @return The project at the given path, or null if not found + */ + fun getProjectByPath(path: File): IProject? + + /** + * Requests the IDE to open the project rooted at [projectDir], replacing the project + * that is currently open. Dispatches asynchronously: a `true` return means the open was + * requested and the editor is being launched, not that the project has finished loading. + * Poll [getCurrentProject] to observe completion. + * + * Requires the FILESYSTEM_READ permission. + * + * Default-implemented (no-op, returns false) so adding it is a backward-compatible + * interface extension: existing implementers and any prebuilt plugin-api lib keep + * compiling; the host overrides it. + * + * @param projectDir The root directory of the project to open + * @return true if the open request was dispatched, false if it was rejected or the IDE + * has no foreground activity available to host the editor + */ + fun openProject(projectDir: File): Boolean = false + + /** + * Resolves the build context (compile/intermediate classpaths, runtime dex files, + * selected variant, resource APK, and whether a build is needed) for the module that + * owns the given file. + * + * Defaults to returning null so the method is binary-compatible: hosts that predate it, + * and implementors that do not override it, report "unavailable" (mirrors the default on + * [IdeUIService.openPluginScreen]). + * + * @param filePath The absolute path of a source file owned by the module + * @return The module context, or null if no module can be resolved + */ + fun getModuleContext(filePath: String): ModuleContext? = null } /** * 0-based cursor position inside an editor buffer. */ -data class CursorPosition(val line: Int, val column: Int, val index: Int) +data class CursorPosition( + val line: Int, + val column: Int, + val index: Int, +) /** * 0-based selection range. Inclusive of start, exclusive of end (matches the underlying editor). */ data class SelectionRange( - val startLine: Int, - val startColumn: Int, - val endLine: Int, - val endColumn: Int, + val startLine: Int, + val startColumn: Int, + val endLine: Int, + val endColumn: Int, ) /** @@ -67,7 +89,7 @@ data class SelectionRange( * or null if all files are closed. */ fun interface FileChangeListener { - fun onFileChanged(file: File?) + fun onFileChanged(file: File?) } /** @@ -75,14 +97,19 @@ fun interface FileChangeListener { * modifies editor content. Used for features like inline code suggestions. */ fun interface EditorContentChangeListener { - /** - * Called when editor content changes. - * @param fileContent The full file content after the change - * @param cursorLine The 0-based line number of the cursor - * @param cursorColumn The 0-based column number of the cursor - * @param language The language ID of the file (e.g., "kotlin", "java", "xml") - */ - fun onContentChanged(fileContent: String, cursorLine: Int, cursorColumn: Int, language: String) + /** + * Called when editor content changes. + * @param fileContent The full file content after the change + * @param cursorLine The 0-based line number of the cursor + * @param cursorColumn The 0-based column number of the cursor + * @param language The language ID of the file (e.g., "kotlin", "java", "xml") + */ + fun onContentChanged( + fileContent: String, + cursorLine: Int, + cursorColumn: Int, + language: String, + ) } /** @@ -91,100 +118,158 @@ fun interface EditorContentChangeListener { * FILESYSTEM_WRITE. */ interface IdeEditorService { - fun getCurrentFile(): File? - - fun getOpenFiles(): List - - fun isFileOpen(file: File): Boolean - - fun getCurrentSelection(): String? - - fun getCurrentFileContent(): String? - - fun getFileContent(file: File): String? - - fun getCurrentCursorPosition(): CursorPosition? - - fun getCurrentSelectionRange(): SelectionRange? - - fun getCurrentLineText(): String? - - fun getLineText(file: File, lineNumber: Int): String? - - fun getLineCount(file: File): Int - - fun getWordAtCursor(): String? - - fun getCurrentLanguageId(): String? - - fun getFileLanguageId(file: File): String? - - fun isFileModified(file: File): Boolean - - fun getModifiedFiles(): List - - /** - * Schedules the given file to be opened in the editor. The open itself runs asynchronously - * on the IDE's editor thread — a `true` return means the request was dispatched, not that - * the file is already open or that it exists, is readable, or was handled by this IDE - * rather than delegated (image viewer, another plugin, etc.). Poll [isFileOpen] if you - * need to confirm completion. - */ - fun openFile(file: File): Boolean - - /** See [openFile]. The caret is moved to the given 0-based position once the open completes. */ - fun openFileAt(file: File, line: Int, column: Int): Boolean - - /** - * Schedules a save of the active editor tab. Runs asynchronously; a `true` return means - * the save was dispatched, not that the buffer has been flushed to disk. Poll - * [isFileModified] on the current file to confirm completion. - */ - fun saveCurrentFile(): Boolean - - fun insertTextAtCursor(text: String): Boolean - - fun replaceSelection(text: String): Boolean - - fun appendToLine(file: File, line: Int, text: String): Boolean - - fun prependToLine(file: File, line: Int, text: String): Boolean - - fun replaceLine(file: File, line: Int, newText: String): Boolean + fun getCurrentFile(): File? - fun insertLineBefore(file: File, line: Int, text: String): Boolean + fun getOpenFiles(): List - fun deleteLine(file: File, line: Int): Boolean + fun isFileOpen(file: File): Boolean - fun replaceRange(file: File, range: SelectionRange, newText: String): Boolean + fun getCurrentSelection(): String? - fun addFileChangeListener(listener: FileChangeListener) + fun getCurrentFileContent(): String? - fun removeFileChangeListener(listener: FileChangeListener) + fun getFileContent(file: File): String? + + fun getCurrentCursorPosition(): CursorPosition? + + fun getCurrentSelectionRange(): SelectionRange? + + fun getCurrentLineText(): String? + + fun getLineText( + file: File, + lineNumber: Int, + ): String? + + fun getLineCount(file: File): Int + + fun getWordAtCursor(): String? - /** - * Registers a listener to be notified when editor content changes. - * @param listener The listener to register - */ - fun addContentChangeListener(listener: EditorContentChangeListener) {} + fun getCurrentLanguageId(): String? - /** - * Unregisters an editor content change listener. - * @param listener The listener to unregister - */ - fun removeContentChangeListener(listener: EditorContentChangeListener) {} + fun getFileLanguageId(file: File): String? - /** - * Shows an inline suggestion (ghost text) at the cursor position. - * The suggestion is displayed semi-transparently and can be dismissed. - * @param text The suggestion text to display - */ - fun showInlineSuggestion(text: String) {} + fun isFileModified(file: File): Boolean + + fun getModifiedFiles(): List - /** - * Dismisses any currently displayed inline suggestion. - */ - fun dismissInlineSuggestion() {} + /** + * Schedules the given file to be opened in the editor. The open itself runs asynchronously + * on the IDE's editor thread - a `true` return means the request was dispatched, not that + * the file is already open or that it exists, is readable, or was handled by this IDE + * rather than delegated (image viewer, another plugin, etc.). Poll [isFileOpen] if you + * need to confirm completion. + */ + fun openFile(file: File): Boolean + + /** See [openFile]. The caret is moved to the given 0-based position once the open completes. */ + fun openFileAt( + file: File, + line: Int, + column: Int, + ): Boolean + + /** + * Schedules a save of the active editor tab. Runs asynchronously; a `true` return means + * the save was dispatched, not that the buffer has been flushed to disk. Poll + * [isFileModified] on the current file to confirm completion. + */ + fun saveCurrentFile(): Boolean + + fun insertTextAtCursor(text: String): Boolean + + fun replaceSelection(text: String): Boolean + + fun appendToLine( + file: File, + line: Int, + text: String, + ): Boolean + + fun prependToLine( + file: File, + line: Int, + text: String, + ): Boolean + + fun replaceLine( + file: File, + line: Int, + newText: String, + ): Boolean + + fun insertLineBefore( + file: File, + line: Int, + text: String, + ): Boolean + + fun deleteLine( + file: File, + line: Int, + ): Boolean + + fun replaceRange( + file: File, + range: SelectionRange, + newText: String, + ): Boolean + + /** + * Draws (or moves) a remote peer's cursor - a small colored, named caret badge - + * inside the editor for [file] at the 0-based [line]/[column]. Cursors are keyed by + * [peerId]: calling again for the same (file, peerId) repositions the existing cursor. + * [peerColor] is an ARGB int. No-op (returns false) if the file isn't open in an editor. + * Visual overlay only - never mutates file content. Requires FILESYSTEM_READ. + * + * Default-implemented (no-op) so adding it is a backward-compatible interface extension: + * existing implementers and any prebuilt plugin-api lib keep compiling; the host overrides it. + */ + fun showPeerCursor( + file: File, + line: Int, + column: Int, + peerId: String, + peerName: String, + peerColor: Int, + ): Boolean = false + + /** Hides the cursor for [peerId] in [file], if present. Default-implemented no-op. */ + fun hidePeerCursor( + file: File, + peerId: String, + ): Boolean = false + + /** Removes all remote peer cursors in [file]. Default-implemented no-op. */ + fun clearPeerCursors(file: File) {} + + fun addFileChangeListener(listener: FileChangeListener) + + fun removeFileChangeListener(listener: FileChangeListener) + + /** + * Registers a listener to be notified when editor content changes. + * @param listener The listener to register + */ + fun addContentChangeListener(listener: EditorContentChangeListener) {} + + /** + * Unregisters an editor content change listener. + * @param listener The listener to unregister + */ + fun removeContentChangeListener(listener: EditorContentChangeListener) {} + + /** + * Shows an inline suggestion (ghost text) at the cursor position. + * The suggestion is displayed semi-transparently and can be dismissed. + * @param text The suggestion text to display + */ + fun showInlineSuggestion(text: String) {} + + /** + * Dismisses any currently displayed inline suggestion. + */ + fun dismissInlineSuggestion() {} } /** @@ -193,50 +278,50 @@ interface IdeEditorService { * that need to show dialogs or perform UI operations. */ interface IdeUIService { - /** - * Gets the current Activity context that can be used for showing dialogs. - * @return The current Activity, or null if no activity is available - */ - fun getCurrentActivity(): Activity? - - /** - * Checks if UI operations are currently possible. - * @return true if UI operations can be performed, false otherwise - */ - fun isUIAvailable(): Boolean - - /** - * Opens a fullscreen host surface for a plugin-owned Fragment. - * - * The host app owns only the generic container. The plugin owns the Fragment class and all - * feature-specific behavior. - */ - fun openPluginScreen( - pluginId: String, - fragmentClassName: String, - title: String? = null - ): Boolean = false - - /** - * Asks the IDE to rebuild the editor toolbar, re-evaluating each plugin - * [com.itsaky.androidide.plugins.extensions.ToolbarAction]'s dynamic providers - * ([com.itsaky.androidide.plugins.extensions.ToolbarAction.iconProvider], - * `isEnabledProvider`, `isVisibleProvider`). Call this after changing plugin state - * that those providers depend on — e.g. to swap a toolbar icon between - * idle/active/processing states. - * - * Safe to call from any thread; the rebuild is marshalled to the UI thread. A no-op - * when no editor is in the foreground. Default implementation does nothing so older - * hosts remain source/binary compatible. - */ - fun refreshToolbarActions() {} - - companion object { - const val ACTION_OPEN_PLUGIN_SCREEN = "com.itsaky.androidide.plugins.OPEN_PLUGIN_SCREEN" - const val EXTRA_PLUGIN_ID = "com.itsaky.androidide.plugins.extra.PLUGIN_ID" - const val EXTRA_FRAGMENT_CLASS_NAME = "com.itsaky.androidide.plugins.extra.FRAGMENT_CLASS_NAME" - const val EXTRA_TITLE = "com.itsaky.androidide.plugins.extra.TITLE" - } + /** + * Gets the current Activity context that can be used for showing dialogs. + * @return The current Activity, or null if no activity is available + */ + fun getCurrentActivity(): Activity? + + /** + * Checks if UI operations are currently possible. + * @return true if UI operations can be performed, false otherwise + */ + fun isUIAvailable(): Boolean + + /** + * Opens a fullscreen host surface for a plugin-owned Fragment. + * + * The host app owns only the generic container. The plugin owns the Fragment class and all + * feature-specific behavior. + */ + fun openPluginScreen( + pluginId: String, + fragmentClassName: String, + title: String? = null, + ): Boolean = false + + /** + * Asks the IDE to rebuild the editor toolbar, re-evaluating each plugin + * [com.itsaky.androidide.plugins.extensions.ToolbarAction]'s dynamic providers + * ([com.itsaky.androidide.plugins.extensions.ToolbarAction.iconProvider], + * `isEnabledProvider`, `isVisibleProvider`). Call this after changing plugin state + * that those providers depend on - e.g. to swap a toolbar icon between + * idle/active/processing states. + * + * Safe to call from any thread; the rebuild is marshalled to the UI thread. A no-op + * when no editor is in the foreground. Default implementation does nothing so older + * hosts remain source/binary compatible. + */ + fun refreshToolbarActions() {} + + companion object { + const val ACTION_OPEN_PLUGIN_SCREEN = "com.itsaky.androidide.plugins.OPEN_PLUGIN_SCREEN" + const val EXTRA_PLUGIN_ID = "com.itsaky.androidide.plugins.extra.PLUGIN_ID" + const val EXTRA_FRAGMENT_CLASS_NAME = "com.itsaky.androidide.plugins.extra.FRAGMENT_CLASS_NAME" + const val EXTRA_TITLE = "com.itsaky.androidide.plugins.extra.TITLE" + } } /** @@ -245,85 +330,90 @@ interface IdeUIService { * that need to monitor build status or trigger builds. */ interface IdeBuildService { - /** - * Checks if a build/sync operation is currently in progress. - * @return true if a build is running, false otherwise - */ - fun isBuildInProgress(): Boolean - - /** - * Checks if the Gradle tooling server is started and ready. - * @return true if the tooling server is available, false otherwise - */ - fun isToolingServerStarted(): Boolean - - /** - * Registers a callback to be notified when build status changes. - * @param callback The callback to register - */ - fun addBuildStatusListener(callback: BuildStatusListener) - - /** - * Unregisters a build status callback. - * @param callback The callback to unregister - */ - fun removeBuildStatusListener(callback: BuildStatusListener) - - /** - * Executes the given Gradle task paths (e.g. ":app:assembleDebug") and completes with - * true on success, false on failure/cancellation. - * - * Default completes with false so this addition is binary-compatible: hosts that predate - * the method, and any implementor that does not override it, report "not executed". - */ - fun executeTasks(vararg tasks: String): CompletableFuture = - CompletableFuture.completedFuture(false) - - /** - * Builds and runs the app on the connected device. - * @param callback The callback to be invoked when the operation completes - */ - fun runApp(callback: BuildAndLaunchCallback) { - callback.onComplete(false, "Not implemented") - } - - /** - * Triggers a Gradle sync operation. - * @param callback The callback to be invoked when the sync completes - */ - fun triggerGradleSync(callback: GradleSyncCallback) { - callback.onComplete(false, "") - } - - /** - * Gets the latest build output logs. - * @return The build output as a string, or null if no build output is available - */ - fun getBuildOutput(): String? = null + /** + * Checks if a build/sync operation is currently in progress. + * @return true if a build is running, false otherwise + */ + fun isBuildInProgress(): Boolean + + /** + * Checks if the Gradle tooling server is started and ready. + * @return true if the tooling server is available, false otherwise + */ + fun isToolingServerStarted(): Boolean + + /** + * Registers a callback to be notified when build status changes. + * @param callback The callback to register + */ + fun addBuildStatusListener(callback: BuildStatusListener) + + /** + * Unregisters a build status callback. + * @param callback The callback to unregister + */ + fun removeBuildStatusListener(callback: BuildStatusListener) + + /** + * Executes the given Gradle task paths (e.g. ":app:assembleDebug") and completes with + * true on success, false on failure/cancellation. + * + * Default completes with false so this addition is binary-compatible: hosts that predate + * the method, and any implementor that does not override it, report "not executed". + */ + fun executeTasks(vararg tasks: String): CompletableFuture = CompletableFuture.completedFuture(false) + + /** + * Builds and runs the app on the connected device. + * @param callback The callback to be invoked when the operation completes + */ + fun runApp(callback: BuildAndLaunchCallback) { + callback.onComplete(false, "Not implemented") + } + + /** + * Triggers a Gradle sync operation. + * @param callback The callback to be invoked when the sync completes + */ + fun triggerGradleSync(callback: GradleSyncCallback) { + callback.onComplete(false, "") + } + + /** + * Gets the latest build output logs. + * @return The build output as a string, or null if no build output is available + */ + fun getBuildOutput(): String? = null } /** * Callback interface for build and launch operations. */ fun interface BuildAndLaunchCallback { - /** - * Called when the build and launch operation completes. - * @param success true if the operation succeeded, false otherwise - * @param message A message describing the result - */ - fun onComplete(success: Boolean, message: String) + /** + * Called when the build and launch operation completes. + * @param success true if the operation succeeded, false otherwise + * @param message A message describing the result + */ + fun onComplete( + success: Boolean, + message: String, + ) } /** * Callback interface for Gradle sync operations. */ fun interface GradleSyncCallback { - /** - * Called when the Gradle sync operation completes. - * @param success true if the sync succeeded, false otherwise - * @param output The sync output - */ - fun onComplete(success: Boolean, output: String) + /** + * Called when the Gradle sync operation completes. + * @param success true if the sync succeeded, false otherwise + * @param output The sync output + */ + fun onComplete( + success: Boolean, + output: String, + ) } /** @@ -332,106 +422,129 @@ fun interface GradleSyncCallback { * that have the FILESYSTEM_WRITE permission. */ interface IdeFileService { - /** - * Reads the entire content of a file. - * @param file The file to read - * @return The file content as a string, or null if the file cannot be read - */ - fun readFile(file: File): String? - - /** - * Writes content to a file, replacing any existing content. - * @param file The file to write to - * @param content The content to write - * @return true if the write operation was successful, false otherwise - */ - fun writeFile(file: File, content: String): Boolean - - /** - * Appends content to the end of a file. - * @param file The file to append to - * @param content The content to append - * @return true if the append operation was successful, false otherwise - */ - fun appendToFile(file: File, content: String): Boolean - - /** - * Inserts content after the first occurrence of a pattern in a file. - * @param file The file to modify - * @param pattern The pattern to search for - * @param content The content to insert after the pattern - * @return true if the insertion was successful, false otherwise - */ - fun insertAfterPattern(file: File, pattern: String, content: String): Boolean - - /** - * Replaces all occurrences of old text with new text in a file. - * @param file The file to modify - * @param oldText The text to replace - * @param newText The replacement text - * @return true if the replacement was successful, false otherwise - */ - fun replaceInFile(file: File, oldText: String, newText: String): Boolean - - /** - * Writes binary content to a file, replacing any existing content. - * Use this instead of [writeFile] for non-text data: UTF-8 transcoding in - * [writeFile] corrupts arbitrary bytes. - * @param file The file to write to - * @param data The bytes to write - * @return true if the write operation was successful, false otherwise - */ - fun writeBinary(file: File, data: ByteArray): Boolean - - /** - * Writes content from an input stream to a file, replacing any existing - * content. Preferred for large payloads (archives, toolchain assets) since - * no intermediate buffer of the full payload is held in memory. - * - * The caller owns [input] and is responsible for closing it. - * @param file The file to write to - * @param input The stream to read from - * @return The number of bytes written, or -1 if the operation failed - */ - fun writeStream(file: File, input: InputStream): Long - - /** - * Deletes a file or directory. Directories are removed recursively. - * Required for plugins that need to clean up installed assets in - * [com.itsaky.androidide.plugins.IPlugin.deactivate]. - * @param file The file or directory to delete - * @return true if the deletion was successful, false otherwise - */ - fun delete(file: File): Boolean - - /** - * Lists files in a directory. - * @param dir The directory to list (or null for project root) - * @param recursive Whether to list recursively - * @return List of files, or empty list if the directory cannot be read - */ - fun listFiles(dir: File?, recursive: Boolean = false): List + /** + * Reads the entire content of a file. + * @param file The file to read + * @return The file content as a string, or null if the file cannot be read + */ + fun readFile(file: File): String? + + /** + * Writes content to a file, replacing any existing content. + * @param file The file to write to + * @param content The content to write + * @return true if the write operation was successful, false otherwise + */ + fun writeFile( + file: File, + content: String, + ): Boolean + + /** + * Appends content to the end of a file. + * @param file The file to append to + * @param content The content to append + * @return true if the append operation was successful, false otherwise + */ + fun appendToFile( + file: File, + content: String, + ): Boolean + + /** + * Inserts content after the first occurrence of a pattern in a file. + * @param file The file to modify + * @param pattern The pattern to search for + * @param content The content to insert after the pattern + * @return true if the insertion was successful, false otherwise + */ + fun insertAfterPattern( + file: File, + pattern: String, + content: String, + ): Boolean + + /** + * Replaces all occurrences of old text with new text in a file. + * @param file The file to modify + * @param oldText The text to replace + * @param newText The replacement text + * @return true if the replacement was successful, false otherwise + */ + fun replaceInFile( + file: File, + oldText: String, + newText: String, + ): Boolean + + /** + * Writes binary content to a file, replacing any existing content. + * Use this instead of [writeFile] for non-text data: UTF-8 transcoding in + * [writeFile] corrupts arbitrary bytes. + * @param file The file to write to + * @param data The bytes to write + * @return true if the write operation was successful, false otherwise + */ + fun writeBinary( + file: File, + data: ByteArray, + ): Boolean + + /** + * Writes content from an input stream to a file, replacing any existing + * content. Preferred for large payloads (archives, toolchain assets) since + * no intermediate buffer of the full payload is held in memory. + * + * The caller owns [input] and is responsible for closing it. + * @param file The file to write to + * @param input The stream to read from + * @return The number of bytes written, or -1 if the operation failed + */ + fun writeStream( + file: File, + input: InputStream, + ): Long + + /** + * Deletes a file or directory. Directories are removed recursively. + * Required for plugins that need to clean up installed assets in + * [com.itsaky.androidide.plugins.IPlugin.deactivate]. + * @param file The file or directory to delete + * @return true if the deletion was successful, false otherwise + */ + fun delete(file: File): Boolean + + /** + * Lists files in a directory. + * @param dir The directory to list (or null for project root) + * @param recursive Whether to list recursively + * @return List of files, or empty list if the directory cannot be read + */ + fun listFiles( + dir: File?, + recursive: Boolean = false, + ): List } /** * Callback interface for build status changes. */ interface BuildStatusListener { - /** - * Called when a build starts. - */ - fun onBuildStarted() - - /** - * Called when a build finishes successfully. - */ - fun onBuildFinished() - - /** - * Called when a build fails or is cancelled. - * @param error The error message, or null if cancelled - */ - fun onBuildFailed(error: String?) + /** + * Called when a build starts. + */ + fun onBuildStarted() + + /** + * Called when a build finishes successfully. + */ + fun onBuildFinished() + + /** + * Called when a build fails or is cancelled. + * @param error The error message, or null if cancelled + */ + fun onBuildFailed(error: String?) } /** @@ -446,28 +559,34 @@ interface BuildStatusListener { * (build files, resources, etc.). */ interface IdeProjectManipulationService { - /** - * Adds a dependency to a Gradle build file. - * @param dependencyString The dependency line including configuration, e.g., 'implementation("io.coil-kt:coil:2.6.0")' - * @param buildFilePath Relative path to build file, e.g., 'app/build.gradle.kts' - * @return true if the dependency was added successfully, false otherwise - */ - fun addDependency(dependencyString: String, buildFilePath: String): Boolean = false - - /** - * Adds a string resource to the strings.xml file. - * @param name The resource name, e.g., 'welcome_message' - * @param value The string content, e.g., 'Hello, World!' - * @return true if the string resource was added successfully, false otherwise - */ - fun addStringResource(name: String, value: String): Boolean = false - - /** - * Deletes a file from the project. - * @param path The path to the file to delete - * @return true if the file was deleted successfully, false otherwise - */ - fun deleteFile(path: String): Boolean = false + /** + * Adds a dependency to a Gradle build file. + * @param dependencyString The dependency line including configuration, e.g., 'implementation("io.coil-kt:coil:2.6.0")' + * @param buildFilePath Relative path to build file, e.g., 'app/build.gradle.kts' + * @return true if the dependency was added successfully, false otherwise + */ + fun addDependency( + dependencyString: String, + buildFilePath: String, + ): Boolean = false + + /** + * Adds a string resource to the strings.xml file. + * @param name The resource name, e.g., 'welcome_message' + * @param value The string content, e.g., 'Hello, World!' + * @return true if the string resource was added successfully, false otherwise + */ + fun addStringResource( + name: String, + value: String, + ): Boolean = false + + /** + * Deletes a file from the project. + * @param path The path to the file to delete + * @return true if the file was deleted successfully, false otherwise + */ + fun deleteFile(path: String): Boolean = false } /** @@ -478,11 +597,11 @@ interface IdeProjectManipulationService { * host-internal project types to the plugin. */ data class ModuleContext( - val modulePath: String?, - val variantName: String, - val compileClasspaths: List, - val intermediateClasspaths: List, - val runtimeDexFiles: List, - val resourceApk: File?, - val needsBuild: Boolean + val modulePath: String?, + val variantName: String, + val compileClasspaths: List, + val intermediateClasspaths: List, + val runtimeDexFiles: List, + val resourceApk: File?, + val needsBuild: Boolean, ) diff --git a/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/IdeFileServiceTest.java b/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/IdeFileServiceTest.java deleted file mode 100644 index aac3124068..0000000000 --- a/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/IdeFileServiceTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.itsaky.androidide.plugins.services; - -import org.junit.Test; -import static org.junit.Assert.*; - -public class IdeFileServiceTest { - - @Test - public void testFileOperationResultSuccess() { - IdeFileService.FileOperationResult result = - IdeFileService.FileOperationResult.success("File read", "content"); - - assertTrue(result.success); - assertEquals("File read", result.message); - assertEquals("content", result.data); - assertNull(result.error); - } - - @Test - public void testFileOperationResultFailure() { - IdeFileService.FileOperationResult result = - IdeFileService.FileOperationResult.failure("File not found"); - - assertFalse(result.success); - assertEquals("Operation failed", result.message); - assertNull(result.data); - assertEquals("File not found", result.error); - } -} diff --git a/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/IdeProjectServiceTest.java b/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/IdeProjectServiceTest.java deleted file mode 100644 index 4f0cd0dba6..0000000000 --- a/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/IdeProjectServiceTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.itsaky.androidide.plugins.services; - -import org.junit.Test; -import static org.junit.Assert.*; - -public class IdeProjectServiceTest { - - @Test - public void testProjectOperationResultSuccess() { - IdeProjectService.ProjectOperationResult result = - IdeProjectService.ProjectOperationResult.success("Sync started", "data"); - - assertTrue(result.success); - assertEquals("Sync started", result.message); - assertEquals("data", result.data); - assertNull(result.error); - } - - @Test - public void testProjectOperationResultFailure() { - IdeProjectService.ProjectOperationResult result = - IdeProjectService.ProjectOperationResult.failure("Build failed"); - - assertFalse(result.success); - assertEquals("Operation failed", result.message); - assertNull(result.data); - assertEquals("Build failed", result.error); - } -} diff --git a/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/IdeResourceServiceTest.java b/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/IdeResourceServiceTest.java deleted file mode 100644 index f5d0b62479..0000000000 --- a/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/IdeResourceServiceTest.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.itsaky.androidide.plugins.services; - -import org.junit.Test; -import static org.junit.Assert.*; - -public class IdeResourceServiceTest { - - @Test - public void testResourceOperationResultSuccess() { - IdeResourceService.ResourceOperationResult result = - IdeResourceService.ResourceOperationResult.success("Resource added"); - - assertTrue(result.success); - assertEquals("Resource added", result.message); - assertNull(result.error); - } - - @Test - public void testResourceOperationResultFailure() { - IdeResourceService.ResourceOperationResult result = - IdeResourceService.ResourceOperationResult.failure("Resource exists"); - - assertFalse(result.success); - assertEquals("Operation failed", result.message); - assertEquals("Resource exists", result.error); - } -} diff --git a/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/LlmInferenceServiceTest.java b/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/LlmInferenceServiceTest.java new file mode 100644 index 0000000000..58ad49fb9a --- /dev/null +++ b/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/LlmInferenceServiceTest.java @@ -0,0 +1,160 @@ +package com.itsaky.androidide.plugins.services; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.Test; + +/** + * Covers the validating, coalescing and copying branches of the value types plugins construct. Every consumer of this jar is an out-of-tree plugin, so a constructor that accepts a bad state here surfaces as a runtime failure nothing in this repo compiles against. + */ +public class LlmInferenceServiceTest { + + @Test + public void chatMessageCarriesNoCorrelatorsForAConversationTurn() { + LlmInferenceService.ChatMessage message = new LlmInferenceService.ChatMessage(LlmInferenceService.ChatMessage.Role.USER, "hello"); + + assertEquals(LlmInferenceService.ChatMessage.Role.USER, message.role); + assertEquals("hello", message.content); + assertNull(message.toolCallId); + assertNull(message.toolName); + } + + @Test + public void chatMessageRejectsANullRole() { + try { + new LlmInferenceService.ChatMessage(null, "hello"); + fail("expected NullPointerException"); + } catch (NullPointerException expected) { + // the role is what selects the shape; a null one has no shape + } + } + + @Test + public void chatMessageRejectsAToolRoleWithoutCorrelators() { + try { + new LlmInferenceService.ChatMessage(LlmInferenceService.ChatMessage.Role.TOOL, "result"); + fail("expected IllegalArgumentException"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("toolResult")); + } + } + + @Test + public void llmConfigRejectsAMissingBackendId() { + try { + new LlmInferenceService.LlmConfig(null); + fail("expected IllegalArgumentException"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("backendId")); + } + } + + @Test + public void llmResponseFailureCarriesErrorAndNoText() { + LlmInferenceService.LlmResponse response = LlmInferenceService.LlmResponse.failure("no model"); + + assertFalse(response.success); + assertNull(response.text); + assertEquals("no model", response.error); + } + + @Test + public void llmResponseSuccessCarriesTextAndNoError() { + LlmInferenceService.LlmResponse response = LlmInferenceService.LlmResponse.success("done", 12, 340L); + + assertTrue(response.success); + assertEquals("done", response.text); + assertNull(response.error); + assertEquals(12, response.tokensGenerated); + assertEquals(340L, response.timeMs); + } + + @Test + public void systemPromptRequestAcceptsNoCallSyntax() { + LlmInferenceService.SystemPromptRequest request = new LlmInferenceService.SystemPromptRequest(Collections.emptyList(), null, null); + + assertNull(request.toolCallSyntax); + assertNull(request.exampleFilePath); + } + + @Test + public void systemPromptRequestCoalescesNullToolsToAnEmptyList() { + LlmInferenceService.SystemPromptRequest request = new LlmInferenceService.SystemPromptRequest(null, "", "app/src/Main.kt"); + + assertTrue(request.tools.isEmpty()); + } + + @Test + public void systemPromptRequestCopiesTheToolList() { + List tools = new ArrayList<>(); + tools.add(new LlmInferenceService.ToolDefinition("read_file", "Reads a file", null)); + + LlmInferenceService.SystemPromptRequest request = new LlmInferenceService.SystemPromptRequest(tools, "", null); + tools.clear(); + + assertEquals(1, request.tools.size()); + assertEquals("read_file", request.tools.get(0).name); + } + + @Test + public void systemPromptRequestPublishesAnUnmodifiableToolList() { + LlmInferenceService.SystemPromptRequest request = new LlmInferenceService.SystemPromptRequest(Collections.emptyList(), "", null); + + try { + request.tools.add(new LlmInferenceService.ToolDefinition("x", "y", null)); + fail("expected UnsupportedOperationException"); + } catch (UnsupportedOperationException expected) { + // a backend must not add tools the consumer will not accept calls for + } + } + + @Test + public void toolCallRequestKeepsWhatTheModelAskedFor() { + Map args = new LinkedHashMap<>(); + args.put("path", "app/src/Main.kt"); + + LlmInferenceService.ToolCallRequest request = new LlmInferenceService.ToolCallRequest("call-1", "read_file", args); + + assertEquals("call-1", request.callId); + assertEquals("read_file", request.name); + assertEquals("app/src/Main.kt", request.args.get("path")); + } + + @Test + public void toolDefinitionAcceptsNoParameters() { + LlmInferenceService.ToolDefinition definition = new LlmInferenceService.ToolDefinition("build", "Builds the project", null); + + assertEquals("build", definition.name); + assertEquals("Builds the project", definition.description); + assertNull(definition.parametersSchema); + } + + @Test + public void toolResultCarriesBothCorrelators() { + LlmInferenceService.ChatMessage result = LlmInferenceService.ChatMessage.toolResult("call-1", "read_file", "file contents"); + + assertEquals(LlmInferenceService.ChatMessage.Role.TOOL, result.role); + assertEquals("file contents", result.content); + assertEquals("call-1", result.toolCallId); + assertEquals("read_file", result.toolName); + } + + @Test + public void toolResultRejectsAMissingCallId() { + try { + LlmInferenceService.ChatMessage.toolResult(null, "read_file", "file contents"); + fail("expected NullPointerException"); + } catch (NullPointerException expected) { + // without a call id the result cannot be matched to the call it answers + } + } +} diff --git a/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/ToolSourceRegistryTest.java b/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/ToolSourceRegistryTest.java new file mode 100644 index 0000000000..bd1d5214e2 --- /dev/null +++ b/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/ToolSourceRegistryTest.java @@ -0,0 +1,127 @@ +package com.itsaky.androidide.plugins.services; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import org.junit.Test; + +/** + * Pins the {@code default} methods of the contributed-tool contract. Every implementor is an out-of-tree plugin, so a default that changes here changes behaviour in plugins nothing in this repo compiles against -- {@link ToolSourceRegistry.ToolSpec#requiresApproval} most of all, since silently flipping it to false would run third-party tools without asking the user. + */ +public class ToolSourceRegistryTest { + + @Test + public void toolInvocationHasNoProjectRootUntilOneIsGiven() { + ToolSourceRegistry.ToolInvocation invocation = new MinimalInvocation(); + + assertNull(invocation.getProjectRoot()); + } + + @Test + public void toolOutcomeCarriesNoErrorMessageUntilOneIsGiven() { + ToolSourceRegistry.ToolOutcome outcome = new MinimalOutcome(); + + assertNull(outcome.getErrorMessage()); + } + + @Test + public void toolSourceIgnoresACancelItCannotHonour() { + ToolSourceRegistry.ToolSource source = new MinimalSource(); + + source.cancel("call-1"); + } + + @Test + public void toolSpecIsTreatedAsHavingSideEffectsUnlessASourceOptsIn() { + ToolSourceRegistry.ToolSpec spec = new MinimalSpec(); + + assertFalse(spec.isReadOnly()); + } + + @Test + public void toolSpecRequiresApprovalUnlessASourceOptsOut() { + ToolSourceRegistry.ToolSpec spec = new MinimalSpec(); + + assertTrue(spec.requiresApproval()); + } + + @Test + public void toolSpecTakesNoTypedArgumentsUntilASchemaIsGiven() { + ToolSourceRegistry.ToolSpec spec = new MinimalSpec(); + + assertTrue(spec.getParametersSchema().isEmpty()); + } + + /** Implements only what the contract makes abstract, so every assertion above reads a default. */ + private static final class MinimalInvocation implements ToolSourceRegistry.ToolInvocation { + + @Override + public Map getArguments() { + return Collections.emptyMap(); + } + + @Override + public String getCallId() { + return "call-1"; + } + + @Override + public String getToolName() { + return "list_files"; + } + } + + private static final class MinimalOutcome implements ToolSourceRegistry.ToolOutcome { + + @Override + public String getOutput() { + return "done"; + } + + @Override + public boolean isSuccess() { + return true; + } + } + + private static final class MinimalSource implements ToolSourceRegistry.ToolSource { + + @Override + public String getDisplayName() { + return "Example tools"; + } + + @Override + public String getProviderId() { + return "com.example.tools"; + } + + @Override + public CompletableFuture invoke(ToolSourceRegistry.ToolInvocation invocation) { + return CompletableFuture.completedFuture(new MinimalOutcome()); + } + + @Override + public List listTools() { + return Collections.singletonList(new MinimalSpec()); + } + } + + private static final class MinimalSpec implements ToolSourceRegistry.ToolSpec { + + @Override + public String getDescription() { + return "Lists files"; + } + + @Override + public String getName() { + return "list_files"; + } + } +} diff --git a/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/PluginContextTest.kt b/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/PluginContextTest.kt index 23c3c33421..9bef521e22 100644 --- a/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/PluginContextTest.kt +++ b/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/PluginContextTest.kt @@ -1,383 +1,438 @@ package com.itsaky.androidide.plugins +import android.content.SharedPreferences +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test -import org.junit.Assert.* import java.io.File import java.io.InputStream class PluginContextTest { + /** + * Mock implementation of PluginContext for testing + */ + private class TestPluginContext : PluginContext { + private val serviceRegistry = TestServiceRegistry() + private val pluginLogger = TestPluginLogger() + private val resourceManager = TestResourceManager() + + override val androidContext: android.content.Context + get() = throw UnsupportedOperationException() + override val services: ServiceRegistry + get() = serviceRegistry + override val eventBus: Any + get() = Any() + override val logger: PluginLogger + get() = pluginLogger + override val resources: ResourceManager + get() = resourceManager + override val pluginId: String + get() = "test-plugin" + + private val pluginServices = mutableMapOf() + private val activePlugins = mutableSetOf() + private val pluginVersions = mutableMapOf() + private val lifecycleListeners = mutableListOf() + + override fun getPluginService( + pluginId: String, + serviceClass: Class, + ): T? = pluginServices[pluginId] as? T - /** - * Mock implementation of PluginContext for testing - */ - private class TestPluginContext : PluginContext { - private val serviceRegistry = TestServiceRegistry() - private val pluginLogger = TestPluginLogger() - private val resourceManager = TestResourceManager() - - override val androidContext: android.content.Context - get() = throw UnsupportedOperationException() - override val services: ServiceRegistry - get() = serviceRegistry - override val eventBus: Any - get() = Any() - override val logger: PluginLogger - get() = pluginLogger - override val resources: ResourceManager - get() = resourceManager - override val pluginId: String - get() = "test-plugin" - - private val pluginServices = mutableMapOf() - private val activePlugins = mutableSetOf() - private val pluginVersions = mutableMapOf() - private val lifecycleListeners = mutableListOf() + override fun isPluginActive(pluginId: String): Boolean = activePlugins.contains(pluginId) + + override fun getPluginVersion(pluginId: String): String? = pluginVersions[pluginId] - override fun getPluginService(pluginId: String, serviceClass: Class): T? { - return pluginServices[pluginId] as? T - } - - override fun isPluginActive(pluginId: String): Boolean { - return activePlugins.contains(pluginId) - } - - override fun getPluginVersion(pluginId: String): String? { - return pluginVersions[pluginId] - } - - override fun registerService(serviceClass: Class, serviceImpl: T) { - // Delegate to the backing registry, mirroring how production PluginContextImpl - // routes registerService() to its shared ServiceRegistry. - serviceRegistry.register(serviceClass, serviceImpl) - } - - override fun unregisterService(serviceClass: Class) { - serviceRegistry.unregister(serviceClass) - } - - override fun getProvidedServices(): List { - return emptyList() - } - - override fun getPluginDataDir(): File { - return File("/data/plugins/test-plugin") - } - - override fun addPluginLifecycleListener(listener: PluginLifecycleListener) { - lifecycleListeners.add(listener) - } - - override fun removePluginLifecycleListener(listener: PluginLifecycleListener) { - lifecycleListeners.remove(listener) - } - - fun addActivePlugin(pluginId: String) { - activePlugins.add(pluginId) - } - - fun setPluginVersion(pluginId: String, version: String) { - pluginVersions[pluginId] = version - } - - fun registerPluginService(pluginId: String, service: Any) { - pluginServices[pluginId] = service - } - - fun notifyPluginActivated(pluginId: String) { - lifecycleListeners.forEach { it.onPluginActivated(pluginId) } - } - - fun notifyPluginDeactivated(pluginId: String) { - lifecycleListeners.forEach { it.onPluginDeactivated(pluginId) } - } - - fun notifyPluginUninstalled(pluginId: String) { - lifecycleListeners.forEach { it.onPluginUninstalled(pluginId) } - } - - fun getListenerCount(): Int = lifecycleListeners.size - } - - private class TestServiceRegistry : ServiceRegistry { - private val services = mutableMapOf, MutableList>() - - override fun register(serviceClass: Class, implementation: T) { - services.computeIfAbsent(serviceClass) { mutableListOf() }.add(implementation as Any) - } - - override fun get(serviceClass: Class): T? { - return services[serviceClass]?.firstOrNull() as? T - } - - override fun getAll(serviceClass: Class): List { - return (services[serviceClass] ?: emptyList()).map { it as T } - } - - override fun unregister(serviceClass: Class<*>) { - services.remove(serviceClass) - } - } - - private class TestResourceManager : ResourceManager { - override fun getPluginDirectory(): File = File("/plugins/test") - - override fun getPluginFile(path: String): File = File("/plugins/test/$path") - - override fun getPluginResource(name: String): ByteArray? = null - - override fun openPluginResource(name: String): InputStream? = null - - override fun openPluginAsset(path: String): InputStream? = null - } - - private class TestPluginLogger : PluginLogger { - override val pluginId: String = "test-plugin" - - override fun debug(message: String) {} - override fun debug(message: String, error: Throwable) {} - override fun info(message: String) {} - override fun info(message: String, error: Throwable) {} - override fun warn(message: String) {} - override fun warn(message: String, error: Throwable) {} - override fun error(message: String) {} - override fun error(message: String, error: Throwable) {} - } - - @Test - fun testGetPluginServiceReturnsNullWhenNotFound() { - val context = TestPluginContext() - val result = context.getPluginService("unknown-plugin", String::class.java) - assertNull("getPluginService should return null when service not found", result) - } - - @Test - fun testGetPluginServiceReturnsServiceWhenRegistered() { - val context = TestPluginContext() - val testService = "test-service" - context.registerPluginService("ai-core", testService) - - val result = context.getPluginService("ai-core", String::class.java) - assertNotNull("getPluginService should return registered service", result) - assertEquals("Service should match registered value", testService, result) - } - - @Test - fun testIsPluginActiveReturnsFalseForInactivePlugin() { - val context = TestPluginContext() - val result = context.isPluginActive("unknown-plugin") - assertFalse("isPluginActive should return false for inactive plugin", result) - } - - @Test - fun testIsPluginActiveReturnsTrueForActivePlugin() { - val context = TestPluginContext() - context.addActivePlugin("ai-core") - val result = context.isPluginActive("ai-core") - assertTrue("isPluginActive should return true for active plugin", result) - } - - @Test - fun testGetPluginVersionReturnsNullWhenNotFound() { - val context = TestPluginContext() - val result = context.getPluginVersion("unknown-plugin") - assertNull("getPluginVersion should return null when version not found", result) - } - - @Test - fun testGetPluginVersionReturnsVersionWhenSet() { - val context = TestPluginContext() - context.setPluginVersion("ai-core", "1.0.0") - val result = context.getPluginVersion("ai-core") - assertNotNull("getPluginVersion should return version when set", result) - assertEquals("Version should match set value", "1.0.0", result) - } - - @Test - fun testRegisterServiceAddsServiceToRegistry() { - val context = TestPluginContext() - val testService = "test-service" - context.registerService(String::class.java, testService) - - val retrieved = context.services.get(String::class.java) - assertNotNull("Service should be retrievable after registration", retrieved) - assertEquals("Service should match registered value", testService, retrieved) - } - - @Test - fun testUnregisterServiceRemovesServiceFromRegistry() { - val context = TestPluginContext() - val testService = "test-service" - context.registerService(String::class.java, testService) - - context.unregisterService(String::class.java) - val retrieved = context.services.get(String::class.java) - assertNull("Service should be null after unregistration", retrieved) - } - - @Test - fun testGetProvidedServicesReturnsEmptyList() { - val context = TestPluginContext() - val services = context.getProvidedServices() - assertNotNull("getProvidedServices should not return null", services) - assertEquals("getProvidedServices should return empty list initially", 0, services.size) - } - - @Test - fun testGetPluginDataDirReturnsValidDirectory() { - val context = TestPluginContext() - val dir = context.getPluginDataDir() - assertNotNull("getPluginDataDir should not return null", dir) - assertTrue("Plugin data dir should contain plugin ID", dir.path.contains("test-plugin")) - } - - @Test - fun testAddPluginLifecycleListenerAddsListener() { - val context = TestPluginContext() - val listener = object : PluginLifecycleListener { - override fun onPluginActivated(pluginId: String) {} - override fun onPluginDeactivated(pluginId: String) {} - override fun onPluginUninstalled(pluginId: String) {} - } - - assertEquals("Should have no listeners initially", 0, context.getListenerCount()) - context.addPluginLifecycleListener(listener) - assertEquals("Should have one listener after adding", 1, context.getListenerCount()) - } - - @Test - fun testRemovePluginLifecycleListenerRemovesListener() { - val context = TestPluginContext() - val listener = object : PluginLifecycleListener { - override fun onPluginActivated(pluginId: String) {} - override fun onPluginDeactivated(pluginId: String) {} - override fun onPluginUninstalled(pluginId: String) {} - } - - context.addPluginLifecycleListener(listener) - assertEquals("Should have one listener after adding", 1, context.getListenerCount()) - context.removePluginLifecycleListener(listener) - assertEquals("Should have no listeners after removing", 0, context.getListenerCount()) - } - - @Test - fun testLifecycleListenerNotificationOnPluginActivated() { - val context = TestPluginContext() - var notificationReceived: String? = null - - val listener = object : PluginLifecycleListener { - override fun onPluginActivated(pluginId: String) { - notificationReceived = pluginId - } - override fun onPluginDeactivated(pluginId: String) {} - override fun onPluginUninstalled(pluginId: String) {} - } - - context.addPluginLifecycleListener(listener) - context.notifyPluginActivated("ai-core") - assertEquals("Should receive onPluginActivated notification", "ai-core", notificationReceived) - } - - @Test - fun testLifecycleListenerNotificationOnPluginDeactivated() { - val context = TestPluginContext() - var notificationReceived: String? = null - - val listener = object : PluginLifecycleListener { - override fun onPluginActivated(pluginId: String) {} - override fun onPluginDeactivated(pluginId: String) { - notificationReceived = pluginId - } - override fun onPluginUninstalled(pluginId: String) {} - } - - context.addPluginLifecycleListener(listener) - context.notifyPluginDeactivated("ai-chat-agent") - assertEquals("Should receive onPluginDeactivated notification", "ai-chat-agent", notificationReceived) - } - - @Test - fun testLifecycleListenerNotificationOnPluginUninstalled() { - val context = TestPluginContext() - var notificationReceived: String? = null - - val listener = object : PluginLifecycleListener { - override fun onPluginActivated(pluginId: String) {} - override fun onPluginDeactivated(pluginId: String) {} - override fun onPluginUninstalled(pluginId: String) { - notificationReceived = pluginId - } - } - - context.addPluginLifecycleListener(listener) - context.notifyPluginUninstalled("ai-tools") - assertEquals("Should receive onPluginUninstalled notification", "ai-tools", notificationReceived) - } - - @Test - fun testMultipleLifecycleListenersReceiveNotifications() { - val context = TestPluginContext() - val activatedPlugins = mutableListOf() - - val listener1 = object : PluginLifecycleListener { - override fun onPluginActivated(pluginId: String) { - activatedPlugins.add("listener1:$pluginId") - } - override fun onPluginDeactivated(pluginId: String) {} - override fun onPluginUninstalled(pluginId: String) {} - } - - val listener2 = object : PluginLifecycleListener { - override fun onPluginActivated(pluginId: String) { - activatedPlugins.add("listener2:$pluginId") - } - override fun onPluginDeactivated(pluginId: String) {} - override fun onPluginUninstalled(pluginId: String) {} - } - - context.addPluginLifecycleListener(listener1) - context.addPluginLifecycleListener(listener2) - context.notifyPluginActivated("ai-core") - - assertEquals("Both listeners should receive notification", 2, activatedPlugins.size) - assertTrue("First listener should be notified", activatedPlugins.contains("listener1:ai-core")) - assertTrue("Second listener should be notified", activatedPlugins.contains("listener2:ai-core")) - } - - @Test - fun testServiceRegistryGetAllReturnsEmptyListWhenNoServicesRegistered() { - val registry = TestServiceRegistry() - val services = registry.getAll(String::class.java) - assertNotNull("getAll should not return null", services) - assertEquals("getAll should return empty list when no services registered", 0, services.size) - } - - @Test - fun testServiceRegistryGetAllReturnsAllRegisteredServices() { - val registry = TestServiceRegistry() - val service1 = "service1" - val service2 = "service2" - - registry.register(String::class.java, service1) - registry.register(String::class.java, service2) - - val services = registry.getAll(String::class.java) - assertNotNull("getAll should not return null", services) - assertEquals("getAll should return all registered services", 2, services.size) - assertTrue("Should contain first service", services.contains(service1)) - assertTrue("Should contain second service", services.contains(service2)) - } - - @Test - fun testResourceManagerReturnsNullForMissingResource() { - val manager = TestResourceManager() - val resource = manager.getPluginResource("missing.dat") - assertNull("getPluginResource should return null for missing resource", resource) - } - - @Test - fun testResourceManagerReturnsNullForMissingAsset() { - val manager = TestResourceManager() - val asset = manager.openPluginAsset("missing/asset.bin") - assertNull("openPluginAsset should return null for missing asset", asset) - } + override fun registerService( + serviceClass: Class, + serviceImpl: T, + ) { + // Delegate to the backing registry, mirroring how production PluginContextImpl + // routes registerService() to its shared ServiceRegistry. + serviceRegistry.register(serviceClass, serviceImpl) + } + + override fun unregisterService(serviceClass: Class) { + serviceRegistry.unregister(serviceClass) + } + + override fun getProvidedServices(): List = emptyList() + + override fun getPluginDataDir(): File = File("/data/plugins/test-plugin") + + override fun getAppFilesDir(): File = File("/data/files") + + override fun getPluginFilesDir(): File = File("/data/files/plugins/test-plugin") + + // SharedPreferences is an Android type with no JVM implementation to stub + // here; the preference-backed paths are covered by instrumented tests. + override fun getAppSharedPreferences(prefsName: String): SharedPreferences? = null + + override fun getPluginSharedPreferences(prefsName: String): SharedPreferences = throw UnsupportedOperationException() + + override fun addPluginLifecycleListener(listener: PluginLifecycleListener) { + lifecycleListeners.add(listener) + } + + override fun removePluginLifecycleListener(listener: PluginLifecycleListener) { + lifecycleListeners.remove(listener) + } + + fun addActivePlugin(pluginId: String) { + activePlugins.add(pluginId) + } + + fun setPluginVersion( + pluginId: String, + version: String, + ) { + pluginVersions[pluginId] = version + } + + fun registerPluginService( + pluginId: String, + service: Any, + ) { + pluginServices[pluginId] = service + } + + fun notifyPluginActivated(pluginId: String) { + lifecycleListeners.forEach { it.onPluginActivated(pluginId) } + } + + fun notifyPluginDeactivated(pluginId: String) { + lifecycleListeners.forEach { it.onPluginDeactivated(pluginId) } + } + + fun notifyPluginUninstalled(pluginId: String) { + lifecycleListeners.forEach { it.onPluginUninstalled(pluginId) } + } + + fun getListenerCount(): Int = lifecycleListeners.size + } + + private class TestServiceRegistry : ServiceRegistry { + private val services = mutableMapOf, MutableList>() + + override fun register( + serviceClass: Class, + implementation: T, + ) { + services.computeIfAbsent(serviceClass) { mutableListOf() }.add(implementation as Any) + } + + override fun get(serviceClass: Class): T? = services[serviceClass]?.firstOrNull() as? T + + override fun getAll(serviceClass: Class): List = (services[serviceClass] ?: emptyList()).map { it as T } + + override fun unregister(serviceClass: Class<*>) { + services.remove(serviceClass) + } + } + + private class TestResourceManager : ResourceManager { + override fun getPluginDirectory(): File = File("/plugins/test") + + override fun getPluginFile(path: String): File = File("/plugins/test/$path") + + override fun getPluginResource(name: String): ByteArray? = null + + override fun openPluginResource(name: String): InputStream? = null + + override fun openPluginAsset(path: String): InputStream? = null + } + + private class TestPluginLogger : PluginLogger { + override val pluginId: String = "test-plugin" + + override fun debug(message: String) {} + + override fun debug( + message: String, + error: Throwable, + ) {} + + override fun info(message: String) {} + + override fun info( + message: String, + error: Throwable, + ) {} + + override fun warn(message: String) {} + + override fun warn( + message: String, + error: Throwable, + ) {} + + override fun error(message: String) {} + + override fun error( + message: String, + error: Throwable, + ) {} + } + + @Test + fun testGetPluginServiceReturnsNullWhenNotFound() { + val context = TestPluginContext() + val result = context.getPluginService("unknown-plugin", String::class.java) + assertNull("getPluginService should return null when service not found", result) + } + + @Test + fun testGetPluginServiceReturnsServiceWhenRegistered() { + val context = TestPluginContext() + val testService = "test-service" + context.registerPluginService("ai-core", testService) + + val result = context.getPluginService("ai-core", String::class.java) + assertNotNull("getPluginService should return registered service", result) + assertEquals("Service should match registered value", testService, result) + } + + @Test + fun testIsPluginActiveReturnsFalseForInactivePlugin() { + val context = TestPluginContext() + val result = context.isPluginActive("unknown-plugin") + assertFalse("isPluginActive should return false for inactive plugin", result) + } + + @Test + fun testIsPluginActiveReturnsTrueForActivePlugin() { + val context = TestPluginContext() + context.addActivePlugin("ai-core") + val result = context.isPluginActive("ai-core") + assertTrue("isPluginActive should return true for active plugin", result) + } + + @Test + fun testGetPluginVersionReturnsNullWhenNotFound() { + val context = TestPluginContext() + val result = context.getPluginVersion("unknown-plugin") + assertNull("getPluginVersion should return null when version not found", result) + } + + @Test + fun testGetPluginVersionReturnsVersionWhenSet() { + val context = TestPluginContext() + context.setPluginVersion("ai-core", "1.0.0") + val result = context.getPluginVersion("ai-core") + assertNotNull("getPluginVersion should return version when set", result) + assertEquals("Version should match set value", "1.0.0", result) + } + + @Test + fun testRegisterServiceAddsServiceToRegistry() { + val context = TestPluginContext() + val testService = "test-service" + context.registerService(String::class.java, testService) + + val retrieved = context.services.get(String::class.java) + assertNotNull("Service should be retrievable after registration", retrieved) + assertEquals("Service should match registered value", testService, retrieved) + } + + @Test + fun testUnregisterServiceRemovesServiceFromRegistry() { + val context = TestPluginContext() + val testService = "test-service" + context.registerService(String::class.java, testService) + + context.unregisterService(String::class.java) + val retrieved = context.services.get(String::class.java) + assertNull("Service should be null after unregistration", retrieved) + } + + @Test + fun testGetProvidedServicesReturnsEmptyList() { + val context = TestPluginContext() + val services = context.getProvidedServices() + assertNotNull("getProvidedServices should not return null", services) + assertEquals("getProvidedServices should return empty list initially", 0, services.size) + } + + @Test + fun testGetPluginDataDirReturnsValidDirectory() { + val context = TestPluginContext() + val dir = context.getPluginDataDir() + assertNotNull("getPluginDataDir should not return null", dir) + assertTrue("Plugin data dir should contain plugin ID", dir.path.contains("test-plugin")) + } + + @Test + fun testAddPluginLifecycleListenerAddsListener() { + val context = TestPluginContext() + val listener = + object : PluginLifecycleListener { + override fun onPluginActivated(pluginId: String) {} + + override fun onPluginDeactivated(pluginId: String) {} + + override fun onPluginUninstalled(pluginId: String) {} + } + + assertEquals("Should have no listeners initially", 0, context.getListenerCount()) + context.addPluginLifecycleListener(listener) + assertEquals("Should have one listener after adding", 1, context.getListenerCount()) + } + + @Test + fun testRemovePluginLifecycleListenerRemovesListener() { + val context = TestPluginContext() + val listener = + object : PluginLifecycleListener { + override fun onPluginActivated(pluginId: String) {} + + override fun onPluginDeactivated(pluginId: String) {} + + override fun onPluginUninstalled(pluginId: String) {} + } + + context.addPluginLifecycleListener(listener) + assertEquals("Should have one listener after adding", 1, context.getListenerCount()) + context.removePluginLifecycleListener(listener) + assertEquals("Should have no listeners after removing", 0, context.getListenerCount()) + } + + @Test + fun testLifecycleListenerNotificationOnPluginActivated() { + val context = TestPluginContext() + var notificationReceived: String? = null + + val listener = + object : PluginLifecycleListener { + override fun onPluginActivated(pluginId: String) { + notificationReceived = pluginId + } + + override fun onPluginDeactivated(pluginId: String) {} + + override fun onPluginUninstalled(pluginId: String) {} + } + + context.addPluginLifecycleListener(listener) + context.notifyPluginActivated("ai-core") + assertEquals("Should receive onPluginActivated notification", "ai-core", notificationReceived) + } + + @Test + fun testLifecycleListenerNotificationOnPluginDeactivated() { + val context = TestPluginContext() + var notificationReceived: String? = null + + val listener = + object : PluginLifecycleListener { + override fun onPluginActivated(pluginId: String) {} + + override fun onPluginDeactivated(pluginId: String) { + notificationReceived = pluginId + } + + override fun onPluginUninstalled(pluginId: String) {} + } + + context.addPluginLifecycleListener(listener) + context.notifyPluginDeactivated("ai-chat-agent") + assertEquals("Should receive onPluginDeactivated notification", "ai-chat-agent", notificationReceived) + } + + @Test + fun testLifecycleListenerNotificationOnPluginUninstalled() { + val context = TestPluginContext() + var notificationReceived: String? = null + + val listener = + object : PluginLifecycleListener { + override fun onPluginActivated(pluginId: String) {} + + override fun onPluginDeactivated(pluginId: String) {} + + override fun onPluginUninstalled(pluginId: String) { + notificationReceived = pluginId + } + } + + context.addPluginLifecycleListener(listener) + context.notifyPluginUninstalled("ai-tools") + assertEquals("Should receive onPluginUninstalled notification", "ai-tools", notificationReceived) + } + + @Test + fun testMultipleLifecycleListenersReceiveNotifications() { + val context = TestPluginContext() + val activatedPlugins = mutableListOf() + + val listener1 = + object : PluginLifecycleListener { + override fun onPluginActivated(pluginId: String) { + activatedPlugins.add("listener1:$pluginId") + } + + override fun onPluginDeactivated(pluginId: String) {} + + override fun onPluginUninstalled(pluginId: String) {} + } + + val listener2 = + object : PluginLifecycleListener { + override fun onPluginActivated(pluginId: String) { + activatedPlugins.add("listener2:$pluginId") + } + + override fun onPluginDeactivated(pluginId: String) {} + + override fun onPluginUninstalled(pluginId: String) {} + } + + context.addPluginLifecycleListener(listener1) + context.addPluginLifecycleListener(listener2) + context.notifyPluginActivated("ai-core") + + assertEquals("Both listeners should receive notification", 2, activatedPlugins.size) + assertTrue("First listener should be notified", activatedPlugins.contains("listener1:ai-core")) + assertTrue("Second listener should be notified", activatedPlugins.contains("listener2:ai-core")) + } + + @Test + fun testServiceRegistryGetAllReturnsEmptyListWhenNoServicesRegistered() { + val registry = TestServiceRegistry() + val services = registry.getAll(String::class.java) + assertNotNull("getAll should not return null", services) + assertEquals("getAll should return empty list when no services registered", 0, services.size) + } + + @Test + fun testServiceRegistryGetAllReturnsAllRegisteredServices() { + val registry = TestServiceRegistry() + val service1 = "service1" + val service2 = "service2" + + registry.register(String::class.java, service1) + registry.register(String::class.java, service2) + + val services = registry.getAll(String::class.java) + assertNotNull("getAll should not return null", services) + assertEquals("getAll should return all registered services", 2, services.size) + assertTrue("Should contain first service", services.contains(service1)) + assertTrue("Should contain second service", services.contains(service2)) + } + + @Test + fun testResourceManagerReturnsNullForMissingResource() { + val manager = TestResourceManager() + val resource = manager.getPluginResource("missing.dat") + assertNull("getPluginResource should return null for missing resource", resource) + } + + @Test + fun testResourceManagerReturnsNullForMissingAsset() { + val manager = TestResourceManager() + val asset = manager.openPluginAsset("missing/asset.bin") + assertNull("openPluginAsset should return null for missing asset", asset) + } } diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt index de79b82ad5..50d401dfd6 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt @@ -84,6 +84,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION import java.io.File import java.util.concurrent.ConcurrentHashMap @@ -228,6 +229,24 @@ class PluginManager private constructor( newText: String, ): Boolean = current()?.replaceRange(file, range, newText) ?: false + override fun showPeerCursor( + file: File, + line: Int, + column: Int, + peerId: String, + peerName: String, + peerColor: Int, + ): Boolean = current()?.showPeerCursor(file, line, column, peerId, peerName, peerColor) ?: false + + override fun hidePeerCursor( + file: File, + peerId: String, + ): Boolean = current()?.hidePeerCursor(file, peerId) ?: false + + override fun clearPeerCursors(file: File) { + current()?.clearPeerCursors(file) + } + override fun addFileChangeCallback(callback: (File?) -> Unit) { synchronized(editorCallbackLock) { if (fileChangeCallbacks.add(callback)) { @@ -297,6 +316,7 @@ class PluginManager private constructor( private val loadedPlugins = ConcurrentHashMap() private val pluginStates = ConcurrentHashMap() + private val loadFailures = ConcurrentHashMap() private val pluginRegistry = PluginRegistry(context) private val securityManager = PluginSecurityManager() private val serviceRegistry = SharedServiceRegistry() @@ -366,26 +386,27 @@ class PluginManager private constructor( val pluginFiles = pluginsDir.listFiles { file -> - file.isFile && file.name.endsWith(".cgp", ignoreCase = true) + file.isFile && file.name.endsWith(".$PLUGIN_ARCHIVE_EXTENSION", ignoreCase = true) } ?: return@withContext logger.info("Found ${pluginFiles.size} plugin files") + loadFailures.clear() + // Load plugins in parallel val loadJobs = pluginFiles.map { pluginFile -> async { - try { - logger.debug("Loading plugin: ${pluginFile.name}") - val result = loadPlugin(pluginFile) - result.onFailure { error -> - logger.error("Failed to load plugin from ${pluginFile.name}: ${error.message}", error) + logger.debug("Loading plugin: ${pluginFile.name}") + val result = + try { + loadPlugin(pluginFile) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - logger.error("Failed to load plugin from ${pluginFile.name}", e) - } + result.onFailure { error -> recordLoadFailure(pluginFile, error) } } } @@ -468,7 +489,7 @@ class PluginManager private constructor( return Result.failure(IllegalArgumentException(error)) } - if (!pluginFile.name.endsWith(".cgp", ignoreCase = true)) { + if (!pluginFile.name.endsWith(".$PLUGIN_ARCHIVE_EXTENSION", ignoreCase = true)) { val error = "Only CGP plugins are supported. File: ${pluginFile.name}" logger.error(error) return Result.failure(IllegalArgumentException(error)) @@ -820,7 +841,7 @@ class PluginManager private constructor( incomingFile: File, existingPluginId: String, ): Boolean { - val existingFile = File(pluginsDir, "$existingPluginId.cgp") + val existingFile = File(pluginsDir, "$existingPluginId.$PLUGIN_ARCHIVE_EXTENSION") val incomingSig = PluginLoader(context, incomingFile).getSignatureHash() val existingSig = PluginLoader(context, existingFile).getSignatureHash() if (incomingSig == null || existingSig == null) { @@ -850,7 +871,7 @@ class PluginManager private constructor( // Find and delete the plugin file (CGP) val pluginFiles = pluginsDir.listFiles { file -> - file.isFile && file.name.endsWith(".cgp", ignoreCase = true) + file.isFile && file.name.endsWith(".$PLUGIN_ARCHIVE_EXTENSION", ignoreCase = true) } if (pluginFiles == null || pluginFiles.isEmpty()) { @@ -899,6 +920,17 @@ class PluginManager private constructor( fun getPlugin(pluginId: String): IPlugin? = loadedPlugins[pluginId]?.plugin + fun getLoadError(pluginId: String): String? = loadFailures[pluginId] + + private fun recordLoadFailure( + pluginFile: File, + error: Throwable, + ) { + logger.error("Failed to load plugin from ${pluginFile.name}", error) + val id = loadAndValidate(pluginFile).getOrNull()?.first?.id ?: pluginFile.nameWithoutExtension + loadFailures[id] = error.message ?: error.toString() + } + fun getAllPlugins(): List = loadedPlugins.values.map { loadedPlugin -> PluginInfo( @@ -1324,6 +1356,7 @@ class PluginManager private constructor( override fun getAllowedPaths(): List = validator.getAllowedPaths() } }, + activityProvider = delegatingActivityProvider, ) } @@ -1573,6 +1606,7 @@ class PluginManager private constructor( override fun getAllowedPaths(): List = validator.getAllowedPaths() } }, + activityProvider = delegatingActivityProvider, ) } diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEditorServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEditorServiceImpl.kt index aa801a5a61..388bc692e4 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEditorServiceImpl.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEditorServiceImpl.kt @@ -14,361 +14,508 @@ import java.io.File import java.util.concurrent.CopyOnWriteArrayList class IdeEditorServiceImpl( - private val pluginId: String, - private val permissions: Set, - private val editorProvider: EditorProvider, - private val readPermissions: Set = setOf(PluginPermission.FILESYSTEM_READ), - private val writePermissions: Set = setOf(PluginPermission.FILESYSTEM_WRITE), - private val pathValidator: PathValidator? = null, + private val pluginId: String, + private val permissions: Set, + private val editorProvider: EditorProvider, + private val readPermissions: Set = setOf(PluginPermission.FILESYSTEM_READ), + private val writePermissions: Set = setOf(PluginPermission.FILESYSTEM_WRITE), + private val pathValidator: PathValidator? = null, ) : IdeEditorService { + interface PathValidator { + fun isPathAllowed(file: File): Boolean - interface PathValidator { - fun isPathAllowed(file: File): Boolean - fun getAllowedPaths(): List - } - - interface EditorProvider { - fun getCurrentFile(): File? - fun getOpenFiles(): List - fun isFileOpen(file: File): Boolean - fun getCurrentSelection(): String? - fun getCurrentFileContent(): String? = null - fun getFileContent(file: File): String? = null - fun getCurrentCursorPosition(): CursorPosition? = null - fun getCurrentSelectionRange(): SelectionRange? = null - fun getCurrentLineText(): String? = null - fun getLineText(file: File, lineNumber: Int): String? = null - fun getLineCount(file: File): Int = 0 - fun getWordAtCursor(): String? = null - fun getCurrentLanguageId(): String? = null - fun getFileLanguageId(file: File): String? = null - fun isFileModified(file: File): Boolean = false - fun getModifiedFiles(): List = emptyList() - fun openFile(file: File): Boolean = false - fun openFileAt(file: File, line: Int, column: Int): Boolean = false - fun saveCurrentFile(): Boolean = false - fun insertTextAtCursor(text: String): Boolean = false - fun replaceSelection(text: String): Boolean = false - fun appendToLine(file: File, line: Int, text: String): Boolean = false - fun prependToLine(file: File, line: Int, text: String): Boolean = false - fun replaceLine(file: File, line: Int, newText: String): Boolean = false - fun insertLineBefore(file: File, line: Int, text: String): Boolean = false - fun deleteLine(file: File, line: Int): Boolean = false - fun replaceRange(file: File, range: SelectionRange, newText: String): Boolean = false - fun addFileChangeCallback(callback: (File?) -> Unit) {} - fun removeFileChangeCallback(callback: (File?) -> Unit) {} - fun addContentChangeCallback(callback: (String, Int, Int, String) -> Unit) {} - fun removeContentChangeCallback(callback: (String, Int, Int, String) -> Unit) {} - fun showInlineSuggestion(pluginId: String, text: String) {} - fun dismissInlineSuggestion(pluginId: String) {} - } - - private val fileChangeListeners = CopyOnWriteArrayList() - private val contentChangeListeners = CopyOnWriteArrayList() - - private val internalFileChangeCallback: (File?) -> Unit = { file -> - fileChangeListeners.forEach { listener -> - try { - listener.onFileChanged(file) - } catch (_: Exception) { - } - } - } - - private val internalContentChangeCallback: (String, Int, Int, String) -> Unit = { content, line, col, lang -> - contentChangeListeners.forEach { listener -> - try { - listener.onContentChanged(content, line, col, lang) - } catch (_: Exception) { - } - } - } - - init { - editorProvider.addFileChangeCallback(internalFileChangeCallback) - editorProvider.addContentChangeCallback(internalContentChangeCallback) - } - - fun dispose() { - editorProvider.removeFileChangeCallback(internalFileChangeCallback) - editorProvider.removeContentChangeCallback(internalContentChangeCallback) - fileChangeListeners.clear() - contentChangeListeners.clear() - } - - override fun getCurrentFile(): File? { - requireRead() - val file = editorProvider.getCurrentFile() ?: return null - ensureFileAccessible(file) - return file - } - - override fun getOpenFiles(): List { - requireRead() - return editorProvider.getOpenFiles().filter { isFileAccessAllowed(it) } - } - - /** - * Null-safe "what file is the user looking at, and am I allowed to see it?" used by every - * read method that short-circuits when there's no current file. Assumes the caller already - * ran [requireRead]. Doesn't log and never throws — that's the whole point: these methods - * fire constantly and can't afford to run the full public [getCurrentFile] pipeline on - * each call. - */ - private fun resolveCurrentFile(): File? { - val file = editorProvider.getCurrentFile() ?: return null - return if (isFileAccessAllowed(file)) file else null - } - - override fun isFileOpen(file: File): Boolean { - requireRead() - ensureFileAccessible(file) - return editorProvider.isFileOpen(file) - } - - override fun getCurrentSelection(): String? { - requireRead() - if (resolveCurrentFile() == null) return null - return editorProvider.getCurrentSelection() - } - - override fun getCurrentFileContent(): String? { - requireRead() - if (resolveCurrentFile() == null) return null - return editorProvider.getCurrentFileContent() - } - - override fun getFileContent(file: File): String? { - requireRead() - ensureFileAccessible(file) - return editorProvider.getFileContent(file) - } - - override fun getCurrentCursorPosition(): CursorPosition? { - requireRead() - if (resolveCurrentFile() == null) return null - return editorProvider.getCurrentCursorPosition() - } - - override fun getCurrentSelectionRange(): SelectionRange? { - requireRead() - if (resolveCurrentFile() == null) return null - return editorProvider.getCurrentSelectionRange() - } - - override fun getCurrentLineText(): String? { - requireRead() - if (resolveCurrentFile() == null) return null - return editorProvider.getCurrentLineText() - } - - override fun getLineText(file: File, lineNumber: Int): String? { - requireRead() - ensureFileAccessible(file) - return editorProvider.getLineText(file, lineNumber) - } - - override fun getLineCount(file: File): Int { - requireRead() - ensureFileAccessible(file) - return editorProvider.getLineCount(file) - } - - override fun getWordAtCursor(): String? { - requireRead() - if (resolveCurrentFile() == null) return null - return editorProvider.getWordAtCursor() - } - - override fun getCurrentLanguageId(): String? { - requireRead() - if (resolveCurrentFile() == null) return null - return editorProvider.getCurrentLanguageId() - } - - override fun getFileLanguageId(file: File): String? { - requireRead() - ensureFileAccessible(file) - return editorProvider.getFileLanguageId(file) - } - - override fun isFileModified(file: File): Boolean { - requireRead() - ensureFileAccessible(file) - return editorProvider.isFileModified(file) - } - - override fun getModifiedFiles(): List { - requireRead() - return editorProvider.getModifiedFiles().filter { isFileAccessAllowed(it) } - } - - override fun openFile(file: File): Boolean { - requireWrite() - ensureFileAccessible(file) - return editorProvider.openFile(file) - } - - override fun openFileAt(file: File, line: Int, column: Int): Boolean { - requireWrite() - ensureFileAccessible(file) - return editorProvider.openFileAt(file, line, column) - } - - override fun saveCurrentFile(): Boolean { - if (!writableCurrentFile()) return false - return editorProvider.saveCurrentFile() - } - - override fun insertTextAtCursor(text: String): Boolean { - if (!writableCurrentFile()) return false - return editorProvider.insertTextAtCursor(text) - } - - override fun replaceSelection(text: String): Boolean { - if (!writableCurrentFile()) return false - return editorProvider.replaceSelection(text) - } - - private fun writableCurrentFile(): Boolean { - requireWrite() - val file = editorProvider.getCurrentFile() ?: return false - return isFileAccessAllowed(file) - } - - override fun appendToLine(file: File, line: Int, text: String): Boolean { - requireWrite() - ensureFileAccessible(file) - return editorProvider.appendToLine(file, line, text) - } - - override fun prependToLine(file: File, line: Int, text: String): Boolean { - requireWrite() - ensureFileAccessible(file) - return editorProvider.prependToLine(file, line, text) - } - - override fun replaceLine(file: File, line: Int, newText: String): Boolean { - requireWrite() - ensureFileAccessible(file) - return editorProvider.replaceLine(file, line, newText) - } - - override fun insertLineBefore(file: File, line: Int, text: String): Boolean { - requireWrite() - ensureFileAccessible(file) - return editorProvider.insertLineBefore(file, line, text) - } - - override fun deleteLine(file: File, line: Int): Boolean { - requireWrite() - ensureFileAccessible(file) - return editorProvider.deleteLine(file, line) - } - - override fun replaceRange(file: File, range: SelectionRange, newText: String): Boolean { - requireWrite() - ensureFileAccessible(file) - return editorProvider.replaceRange(file, range, newText) - } - - override fun addFileChangeListener(listener: FileChangeListener) { - requireRead() - fileChangeListeners.addIfAbsent(listener) - } - - override fun removeFileChangeListener(listener: FileChangeListener) { - fileChangeListeners.remove(listener) - } - - override fun addContentChangeListener(listener: EditorContentChangeListener) { - contentChangeListeners.addIfAbsent(listener) - } - - override fun removeContentChangeListener(listener: EditorContentChangeListener) { - contentChangeListeners.remove(listener) - } - - override fun showInlineSuggestion(text: String) { - // Tag the suggestion with this plugin's id so concurrent plugins don't clobber or dismiss - // each other's ghost text. The public IdeEditorService signature is unchanged. - editorProvider.showInlineSuggestion(pluginId, text) - } - - override fun dismissInlineSuggestion() { - editorProvider.dismissInlineSuggestion(pluginId) - } - - private fun requireRead() { - if (!hasAll(readPermissions)) { - throw SecurityException( - "Plugin $pluginId is missing required permissions: ${readPermissions.joinToString(",") { it.name }}" - ) - } - } - - private fun requireWrite() { - if (!hasAll(writePermissions)) { - throw SecurityException( - "Plugin $pluginId is missing required permissions: ${writePermissions.joinToString(",") { it.name }}" - ) - } - } - - private fun hasAll(required: Set) = required.all { permissions.contains(it) } - - private fun ensureFileAccessible(file: File) { - if (!isFileAccessAllowed(file)) { - throw SecurityException("Plugin $pluginId does not have access to file: ${file.absolutePath}") - } - } - - private fun isFileAccessAllowed(file: File): Boolean { - pathValidator?.let { validator -> - val ok = runCatching { validator.isPathAllowed(file) }.getOrDefault(false) - if (!ok) { - Log.d(TAG, "[$pluginId] pathValidator rejected ${file.absolutePath}") - } - return ok - } - - // No validator wired by the host: if the editor itself has this file open, - // the user is already exposed to it — trust that and allow the read. - val openInEditor = runCatching { editorProvider.isFileOpen(file) }.getOrDefault(false) - if (openInEditor) return true - - val allowed = isFileAccessAllowedDefault(file) - if (!allowed) { - Log.d(TAG, "[$pluginId] static allowlist rejected ${file.absolutePath}; allowed roots=$defaultAllowedPaths") - } - return allowed - } - - private fun isFileAccessAllowedDefault(file: File): Boolean { - val canonicalPath = try { - file.canonicalPath - } catch (_: Exception) { - return false - } - return defaultAllowedPaths.any { root -> - canonicalPath == root || canonicalPath.startsWith(root + File.separator) - } - } - - // Canonicalised so symlinked roots don't bypass the check; anchored on File.separator at - // the match site so e.g. "/…/CodeOnTheGoProjects" doesn't also admit - // "/…/CodeOnTheGoProjectsBackup/". - private val defaultAllowedPaths: List by lazy { - val projects = Environment.PROJECTS_FOLDER - listOf( - "/storage/emulated/0/$projects", - "/sdcard/$projects", - (System.getProperty("user.home") ?: "/") + "/$projects", - "/tmp/CodeOnTheGoProject", - ).map { runCatching { File(it).canonicalPath }.getOrDefault(it) } - } - - companion object { - private const val TAG = "IdeEditorService" - } + fun getAllowedPaths(): List + } + + /** + * Remote-collaborator presence: draw, move and clear named peer cursors in open editors. + * Split out of [EditorProvider] so peer presence is a focused, separately-named contract + * rather than three more methods on the broad editor-access surface (interface segregation). + * The host bridge implements both through one object. Visual overlay only - never mutates + * file content. Each method defaults to a no-op so an implementer can opt in. + */ + interface PeerPresenceProvider { + fun showPeerCursor( + file: File, + line: Int, + column: Int, + peerId: String, + peerName: String, + peerColor: Int, + ): Boolean = false + + fun hidePeerCursor( + file: File, + peerId: String, + ): Boolean = false + + fun clearPeerCursors(file: File) {} + } + + interface EditorProvider : PeerPresenceProvider { + fun getCurrentFile(): File? + + fun getOpenFiles(): List + + fun isFileOpen(file: File): Boolean + + fun getCurrentSelection(): String? + + fun getCurrentFileContent(): String? = null + + fun getFileContent(file: File): String? = null + + fun getCurrentCursorPosition(): CursorPosition? = null + + fun getCurrentSelectionRange(): SelectionRange? = null + + fun getCurrentLineText(): String? = null + + fun getLineText( + file: File, + lineNumber: Int, + ): String? = null + + fun getLineCount(file: File): Int = 0 + + fun getWordAtCursor(): String? = null + + fun getCurrentLanguageId(): String? = null + + fun getFileLanguageId(file: File): String? = null + + fun isFileModified(file: File): Boolean = false + + fun getModifiedFiles(): List = emptyList() + + fun openFile(file: File): Boolean = false + + fun openFileAt( + file: File, + line: Int, + column: Int, + ): Boolean = false + + fun saveCurrentFile(): Boolean = false + + fun insertTextAtCursor(text: String): Boolean = false + + fun replaceSelection(text: String): Boolean = false + + fun appendToLine( + file: File, + line: Int, + text: String, + ): Boolean = false + + fun prependToLine( + file: File, + line: Int, + text: String, + ): Boolean = false + + fun replaceLine( + file: File, + line: Int, + newText: String, + ): Boolean = false + + fun insertLineBefore( + file: File, + line: Int, + text: String, + ): Boolean = false + + fun deleteLine( + file: File, + line: Int, + ): Boolean = false + + fun replaceRange( + file: File, + range: SelectionRange, + newText: String, + ): Boolean = false + + fun addFileChangeCallback(callback: (File?) -> Unit) {} + + fun removeFileChangeCallback(callback: (File?) -> Unit) {} + + fun addContentChangeCallback(callback: (String, Int, Int, String) -> Unit) {} + + fun removeContentChangeCallback(callback: (String, Int, Int, String) -> Unit) {} + + fun showInlineSuggestion( + pluginId: String, + text: String, + ) {} + + fun dismissInlineSuggestion(pluginId: String) {} + } + + private val fileChangeListeners = CopyOnWriteArrayList() + private val contentChangeListeners = CopyOnWriteArrayList() + + private val internalFileChangeCallback: (File?) -> Unit = { file -> + fileChangeListeners.forEach { listener -> + try { + listener.onFileChanged(file) + } catch (_: Exception) { + } + } + } + + private val internalContentChangeCallback: (String, Int, Int, String) -> Unit = { content, line, col, lang -> + contentChangeListeners.forEach { listener -> + try { + listener.onContentChanged(content, line, col, lang) + } catch (_: Exception) { + } + } + } + + init { + editorProvider.addFileChangeCallback(internalFileChangeCallback) + editorProvider.addContentChangeCallback(internalContentChangeCallback) + } + + fun dispose() { + editorProvider.removeFileChangeCallback(internalFileChangeCallback) + editorProvider.removeContentChangeCallback(internalContentChangeCallback) + fileChangeListeners.clear() + contentChangeListeners.clear() + } + + override fun getCurrentFile(): File? { + requireRead() + val file = editorProvider.getCurrentFile() ?: return null + ensureFileAccessible(file) + return file + } + + override fun getOpenFiles(): List { + requireRead() + return editorProvider.getOpenFiles().filter { isFileAccessAllowed(it) } + } + + /** + * Null-safe "what file is the user looking at, and am I allowed to see it?" used by every + * read method that short-circuits when there's no current file. Assumes the caller already + * ran [requireRead]. Doesn't log and never throws - that's the whole point: these methods + * fire constantly and can't afford to run the full public [getCurrentFile] pipeline on + * each call. + */ + private fun resolveCurrentFile(): File? { + val file = editorProvider.getCurrentFile() ?: return null + return if (isFileAccessAllowed(file)) file else null + } + + override fun isFileOpen(file: File): Boolean { + requireRead() + ensureFileAccessible(file) + return editorProvider.isFileOpen(file) + } + + override fun getCurrentSelection(): String? { + requireRead() + if (resolveCurrentFile() == null) return null + return editorProvider.getCurrentSelection() + } + + override fun getCurrentFileContent(): String? { + requireRead() + if (resolveCurrentFile() == null) return null + return editorProvider.getCurrentFileContent() + } + + override fun getFileContent(file: File): String? { + requireRead() + ensureFileAccessible(file) + return editorProvider.getFileContent(file) + } + + override fun getCurrentCursorPosition(): CursorPosition? { + requireRead() + if (resolveCurrentFile() == null) return null + return editorProvider.getCurrentCursorPosition() + } + + override fun getCurrentSelectionRange(): SelectionRange? { + requireRead() + if (resolveCurrentFile() == null) return null + return editorProvider.getCurrentSelectionRange() + } + + override fun getCurrentLineText(): String? { + requireRead() + if (resolveCurrentFile() == null) return null + return editorProvider.getCurrentLineText() + } + + override fun getLineText( + file: File, + lineNumber: Int, + ): String? { + requireRead() + ensureFileAccessible(file) + return editorProvider.getLineText(file, lineNumber) + } + + override fun getLineCount(file: File): Int { + requireRead() + ensureFileAccessible(file) + return editorProvider.getLineCount(file) + } + + override fun getWordAtCursor(): String? { + requireRead() + if (resolveCurrentFile() == null) return null + return editorProvider.getWordAtCursor() + } + + override fun getCurrentLanguageId(): String? { + requireRead() + if (resolveCurrentFile() == null) return null + return editorProvider.getCurrentLanguageId() + } + + override fun getFileLanguageId(file: File): String? { + requireRead() + ensureFileAccessible(file) + return editorProvider.getFileLanguageId(file) + } + + override fun isFileModified(file: File): Boolean { + requireRead() + ensureFileAccessible(file) + return editorProvider.isFileModified(file) + } + + override fun getModifiedFiles(): List { + requireRead() + return editorProvider.getModifiedFiles().filter { isFileAccessAllowed(it) } + } + + override fun openFile(file: File): Boolean { + requireWrite() + ensureFileAccessible(file) + return editorProvider.openFile(file) + } + + override fun openFileAt( + file: File, + line: Int, + column: Int, + ): Boolean { + requireWrite() + ensureFileAccessible(file) + return editorProvider.openFileAt(file, line, column) + } + + override fun saveCurrentFile(): Boolean { + if (!writableCurrentFile()) return false + return editorProvider.saveCurrentFile() + } + + override fun insertTextAtCursor(text: String): Boolean { + if (!writableCurrentFile()) return false + return editorProvider.insertTextAtCursor(text) + } + + override fun replaceSelection(text: String): Boolean { + if (!writableCurrentFile()) return false + return editorProvider.replaceSelection(text) + } + + private fun writableCurrentFile(): Boolean { + requireWrite() + val file = editorProvider.getCurrentFile() ?: return false + return isFileAccessAllowed(file) + } + + override fun appendToLine( + file: File, + line: Int, + text: String, + ): Boolean { + requireWrite() + ensureFileAccessible(file) + return editorProvider.appendToLine(file, line, text) + } + + override fun prependToLine( + file: File, + line: Int, + text: String, + ): Boolean { + requireWrite() + ensureFileAccessible(file) + return editorProvider.prependToLine(file, line, text) + } + + override fun replaceLine( + file: File, + line: Int, + newText: String, + ): Boolean { + requireWrite() + ensureFileAccessible(file) + return editorProvider.replaceLine(file, line, newText) + } + + override fun insertLineBefore( + file: File, + line: Int, + text: String, + ): Boolean { + requireWrite() + ensureFileAccessible(file) + return editorProvider.insertLineBefore(file, line, text) + } + + override fun deleteLine( + file: File, + line: Int, + ): Boolean { + requireWrite() + ensureFileAccessible(file) + return editorProvider.deleteLine(file, line) + } + + override fun replaceRange( + file: File, + range: SelectionRange, + newText: String, + ): Boolean { + requireWrite() + ensureFileAccessible(file) + return editorProvider.replaceRange(file, range, newText) + } + + override fun showPeerCursor( + file: File, + line: Int, + column: Int, + peerId: String, + peerName: String, + peerColor: Int, + ): Boolean { + requireRead() + ensureFileAccessible(file) + return editorProvider.showPeerCursor(file, line, column, peerId, peerName, peerColor) + } + + override fun hidePeerCursor( + file: File, + peerId: String, + ): Boolean { + requireRead() + return editorProvider.hidePeerCursor(file, peerId) + } + + override fun clearPeerCursors(file: File) { + requireRead() + editorProvider.clearPeerCursors(file) + } + + override fun addFileChangeListener(listener: FileChangeListener) { + requireRead() + fileChangeListeners.addIfAbsent(listener) + } + + override fun removeFileChangeListener(listener: FileChangeListener) { + fileChangeListeners.remove(listener) + } + + override fun addContentChangeListener(listener: EditorContentChangeListener) { + contentChangeListeners.addIfAbsent(listener) + } + + override fun removeContentChangeListener(listener: EditorContentChangeListener) { + contentChangeListeners.remove(listener) + } + + override fun showInlineSuggestion(text: String) { + // Tag the suggestion with this plugin's id so concurrent plugins don't clobber or dismiss + // each other's ghost text. The public IdeEditorService signature is unchanged. + editorProvider.showInlineSuggestion(pluginId, text) + } + + override fun dismissInlineSuggestion() { + editorProvider.dismissInlineSuggestion(pluginId) + } + + private fun requireRead() { + if (!hasAll(readPermissions)) { + throw SecurityException( + "Plugin $pluginId is missing required permissions: ${readPermissions.joinToString(",") { it.name }}", + ) + } + } + + private fun requireWrite() { + if (!hasAll(writePermissions)) { + throw SecurityException( + "Plugin $pluginId is missing required permissions: ${writePermissions.joinToString(",") { it.name }}", + ) + } + } + + private fun hasAll(required: Set) = required.all { permissions.contains(it) } + + private fun ensureFileAccessible(file: File) { + if (!isFileAccessAllowed(file)) { + throw SecurityException("Plugin $pluginId does not have access to file: ${file.absolutePath}") + } + } + + private fun isFileAccessAllowed(file: File): Boolean { + pathValidator?.let { validator -> + val ok = runCatching { validator.isPathAllowed(file) }.getOrDefault(false) + if (!ok) { + Log.d(TAG, "[$pluginId] pathValidator rejected ${file.absolutePath}") + } + return ok + } + + // No validator wired by the host: if the editor itself has this file open, + // the user is already exposed to it - trust that and allow the read. + val openInEditor = runCatching { editorProvider.isFileOpen(file) }.getOrDefault(false) + if (openInEditor) return true + + val allowed = isFileAccessAllowedDefault(file) + if (!allowed) { + Log.d(TAG, "[$pluginId] static allowlist rejected ${file.absolutePath}; allowed roots=$defaultAllowedPaths") + } + return allowed + } + + private fun isFileAccessAllowedDefault(file: File): Boolean { + val canonicalPath = + try { + file.canonicalPath + } catch (_: Exception) { + return false + } + return defaultAllowedPaths.any { root -> + canonicalPath == root || canonicalPath.startsWith(root + File.separator) + } + } + + // Canonicalised so symlinked roots don't bypass the check; anchored on File.separator at + // the match site so e.g. "/.../CodeOnTheGoProjects" doesn't also admit + // "/.../CodeOnTheGoProjectsBackup/". + private val defaultAllowedPaths: List by lazy { + val projects = Environment.PROJECTS_FOLDER + listOf( + "/storage/emulated/0/$projects", + "/sdcard/$projects", + (System.getProperty("user.home") ?: "/") + "/$projects", + "/tmp/CodeOnTheGoProject", + ).map { runCatching { File(it).canonicalPath }.getOrDefault(it) } + } + + companion object { + private const val TAG = "IdeEditorService" + } } diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeProjectServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeProjectServiceImpl.kt index f9b83f019f..6327969664 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeProjectServiceImpl.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeProjectServiceImpl.kt @@ -4,7 +4,12 @@ package com.itsaky.androidide.plugins.manager.services import com.itsaky.androidide.plugins.PluginPermission import com.itsaky.androidide.plugins.extensions.IProject +import com.itsaky.androidide.plugins.manager.core.PluginManager import com.itsaky.androidide.plugins.services.IdeProjectService +import com.itsaky.androidide.preferences.internal.GeneralPreferences +import com.itsaky.androidide.projects.ProjectManagerImpl +import com.itsaky.androidide.utils.Environment +import org.slf4j.LoggerFactory import java.io.File /** @@ -12,115 +17,205 @@ import java.io.File * with proper permission validation. */ class IdeProjectServiceImpl( - private val pluginId: String, - private val permissions: Set, - private val projectProvider: ProjectProvider, - private val requiredPermissions: Set = setOf(PluginPermission.FILESYSTEM_READ), - private val pathValidator: PathValidator? = null + private val pluginId: String, + private val permissions: Set, + private val projectProvider: ProjectProvider, + private val requiredPermissions: Set = setOf(PluginPermission.FILESYSTEM_READ), + private val pathValidator: PathValidator? = null, + private val activityProvider: PluginManager.ActivityProvider? = null, ) : IdeProjectService { + /** + * Interface for validating project path access + */ + interface PathValidator { + fun isPathAllowed(path: File): Boolean - /** - * Interface for validating project path access - */ - interface PathValidator { - fun isPathAllowed(path: File): Boolean - fun getAllowedPaths(): List - } - - /** - * Interface for providing actual project data from AndroidIDE - */ - interface ProjectProvider { - fun getCurrentProject(): IProject? - fun getAllProjects(): List - fun getProjectByPath(path: File): IProject? - } - - override fun getCurrentProject(): IProject? { - if (!hasRequiredPermissions()) { - throw SecurityException("Plugin $pluginId does not have required permissions: ${getRequiredPermissionsString()}") - } - - return try { - projectProvider.getCurrentProject() - } catch (e: Exception) { - // Log error but don't expose internal details - null - } - } - - override fun getAllProjects(): List { - if (!hasRequiredPermissions()) { - throw SecurityException("Plugin $pluginId does not have required permissions: ${getRequiredPermissionsString()}") - } - - return try { - projectProvider.getAllProjects() - } catch (e: Exception) { - // Log error but don't expose internal details - emptyList() - } - } - - override fun getProjectByPath(path: File): IProject? { - if (!hasRequiredPermissions()) { - throw SecurityException("Plugin $pluginId does not have required permissions: ${getRequiredPermissionsString()}") - } - - // Additional security check: ensure the path is not outside allowed directories - if (!isPathAllowed(path)) { - throw SecurityException("Plugin $pluginId does not have access to path: ${path.absolutePath}") - } - - return try { - projectProvider.getProjectByPath(path) - } catch (e: Exception) { - // Log error but don't expose internal details - null - } - } - - private fun hasRequiredPermissions(): Boolean { - return requiredPermissions.all { permission -> - permissions.contains(permission) - } - } - - private fun getRequiredPermissionsString(): String { - return requiredPermissions.joinToString(", ") { it.name } - } - - private fun isPathAllowed(path: File): Boolean { - // Use custom path validator if provided - pathValidator?.let { validator -> - return validator.isPathAllowed(path) - } - - // Fallback to default validation for backward compatibility - return isPathAllowedDefault(path) - } - - private fun isPathAllowedDefault(path: File): Boolean { - // Default allowed paths - this should be replaced by AndroidIDE with actual project paths - val allowedPaths = getDefaultAllowedPaths() - - val canonicalPath = try { - path.canonicalPath - } catch (e: Exception) { - return false - } - - return allowedPaths.any { allowedPath -> - canonicalPath.startsWith(allowedPath) - } - } - - private fun getDefaultAllowedPaths(): List { - return listOf( - "/storage/emulated/0/AndroidIDEProjects", - "/sdcard/AndroidIDEProjects", - System.getProperty("user.home", "/") + "/AndroidIDEProjects", - "/tmp/AndroidIDEProject" // Allow temporary project for demo purposes - ) - } -} \ No newline at end of file + fun getAllowedPaths(): List + } + + /** + * Interface for providing actual project data from AndroidIDE + */ + interface ProjectProvider { + fun getCurrentProject(): IProject? + + fun getAllProjects(): List + + fun getProjectByPath(path: File): IProject? + } + + override fun getCurrentProject(): IProject? { + if (!hasRequiredPermissions()) { + throw SecurityException("Plugin $pluginId does not have required permissions: ${getRequiredPermissionsString()}") + } + + return try { + projectProvider.getCurrentProject() + } catch (e: Exception) { + log.warn("getCurrentProject failed for plugin {}; reporting no current project", pluginId, e) + null + } + } + + override fun getAllProjects(): List { + if (!hasRequiredPermissions()) { + throw SecurityException("Plugin $pluginId does not have required permissions: ${getRequiredPermissionsString()}") + } + + return try { + projectProvider.getAllProjects() + } catch (e: Exception) { + log.warn("getAllProjects failed for plugin {}; reporting an empty project list", pluginId, e) + emptyList() + } + } + + override fun getProjectByPath(path: File): IProject? { + if (!hasRequiredPermissions()) { + throw SecurityException("Plugin $pluginId does not have required permissions: ${getRequiredPermissionsString()}") + } + + // Additional security check: ensure the path is not outside allowed directories + if (!isPathAllowed(path)) { + throw SecurityException("Plugin $pluginId does not have access to path: ${path.absolutePath}") + } + + return try { + projectProvider.getProjectByPath(path) + } catch (e: Exception) { + log.warn("getProjectByPath failed for plugin {}; reporting no project at the requested path", pluginId, e) + null + } + } + + override fun openProject(projectDir: File): Boolean { + if (!hasRequiredPermissions()) { + log.warn("openProject denied for plugin {}: missing required permissions", pluginId) + throw SecurityException("Plugin $pluginId does not have required permissions: ${getRequiredPermissionsString()}") + } + + // Validate against the canonical, containment-checked target and reuse it everywhere below, + // so a symlink/relative path can't pass the check as one path yet be switched to as another. + val resolvedProjectDir = resolveProjectDirUnderProjectsDir(projectDir) + if (resolvedProjectDir == null) { + log.warn("openProject denied for plugin {}: target is not under the IDE projects directory", pluginId) + throw SecurityException("Plugin $pluginId may only open projects under the IDE projects directory") + } + + // Apply the same path-access policy used by getProjectByPath. + if (!isPathAllowed(resolvedProjectDir)) { + log.warn("openProject denied for plugin {}: path-access policy rejected the target", pluginId) + throw SecurityException("Plugin $pluginId does not have access to the requested path") + } + + if (!resolvedProjectDir.exists() || !resolvedProjectDir.isDirectory) { + log.warn("openProject aborted for plugin {}: target does not resolve to an existing directory", pluginId) + return false + } + + val activity = activityProvider?.getCurrentActivity() + if (activity == null) { + log.warn("openProject aborted for plugin {}: no foreground activity available", pluginId) + return false + } + if (activity.isFinishing || activity.isDestroyed) { + log.warn("openProject aborted for plugin {}: host activity is finishing or destroyed", pluginId) + return false + } + + return try { + // Switch project state on the UI thread, immediately before recreate(), so the write and + // the reload are atomic with respect to the activity lifecycle: recreate() is a no-op on + // an activity that is finishing or destroyed, and mutating the path first would leave the + // IDE pointing at a project nothing ever loaded. + activity.runOnUiThread { + if (activity.isFinishing || activity.isDestroyed) { + log.warn("openProject aborted for plugin {}: host activity died before recreate", pluginId) + return@runOnUiThread + } + runCatching { + ProjectManagerImpl.getInstance().projectPath = resolvedProjectDir.absolutePath + GeneralPreferences.lastOpenedProject = resolvedProjectDir.absolutePath + + // The editor activity is launchMode=singleTask, so re-launching it only delivers + // onNewIntent (no reload). Recreating it re-runs onCreate, which loads the project + // from the projectPath we just set - the same effect as the IDE's own project switch. + activity.recreate() + }.onFailure { log.error("openProject failed for plugin {} while switching projects", pluginId, it) } + } + true + } catch (e: Exception) { + log.error("openProject failed for plugin {}", pluginId, e) + false + } + } + + private fun resolveProjectDirUnderProjectsDir(path: File): File? { + val projectsDir = runCatching { Environment.PROJECTS_DIR }.getOrNull() ?: return null + return runCatching { + val base = projectsDir.canonicalFile + val target = path.canonicalFile + target.takeIf { it.path == base.path || it.path.startsWith(base.path + File.separator) } + }.getOrNull() + } + + private fun hasRequiredPermissions(): Boolean = + requiredPermissions.all { permission -> + permissions.contains(permission) + } + + private fun getRequiredPermissionsString(): String = requiredPermissions.joinToString(", ") { it.name } + + private fun isPathAllowed(path: File): Boolean { + // Use custom path validator if provided + pathValidator?.let { validator -> + return validator.isPathAllowed(path) + } + + // Fallback to default validation for backward compatibility + return isPathAllowedDefault(path) + } + + private fun isPathAllowedDefault(path: File): Boolean { + // Default allowed paths - this should be replaced by AndroidIDE with actual project paths + val allowedPaths = getDefaultAllowedPaths() + + val canonicalPath = + try { + path.canonicalPath + } catch (e: Exception) { + return false + } + + // Anchored on File.separator so an allowed root like ".../CodeOnTheGoProjects" does not + // also admit a sibling such as ".../CodeOnTheGoProjects_evil". + return allowedPaths.any { root -> + canonicalPath == root || canonicalPath.startsWith(root + File.separator) + } + } + + // Canonicalised so a symlinked root cannot bypass the containment check by presenting a + // different textual prefix than the path being tested. + private fun getDefaultAllowedPaths(): List { + val projectsDirPaths = + runCatching { Environment.PROJECTS_DIR } + .getOrNull() + ?.let { dir -> + listOfNotNull(dir.absolutePath, runCatching { dir.canonicalPath }.getOrNull()) + }.orEmpty() + + return ( + projectsDirPaths + + listOf( + "/storage/emulated/0/CodeOnTheGoProjects", + "/sdcard/CodeOnTheGoProjects", + (System.getProperty("user.home") ?: "/") + "/CodeOnTheGoProjects", + "/tmp/AndroidIDEProject", // Allow temporary project for demo purposes + ) + ).map { runCatching { File(it).canonicalPath }.getOrDefault(it) } + } + + private companion object { + private val log = LoggerFactory.getLogger(IdeProjectServiceImpl::class.java) + } +} diff --git a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/GeneralPreferences.kt b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/GeneralPreferences.kt index 5db52dfbef..2b7e15fc90 100644 --- a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/GeneralPreferences.kt +++ b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/GeneralPreferences.kt @@ -31,7 +31,6 @@ object GeneralPreferences { const val SELECTED_LOCALE = "idpref_general_locale" const val OPEN_PROJECTS = "idepref_general_autoOpenProjects" const val CONFIRM_PROJECT_OPEN = "idepref_general_confirmProjectOpen" - const val TERMINAL_USE_SYSTEM_SHELL = "idepref_general_terminalShell" const val LAST_OPENED_PROJECT = "ide_last_project" const val LOGCAT_CAPTURE_ALL = "idepref_general_logcatCaptureAll" @@ -83,12 +82,6 @@ object GeneralPreferences { prefManager.putBoolean(CONFIRM_PROJECT_OPEN, value) } - var useSystemShell: Boolean - get() = prefManager.getBoolean(TERMINAL_USE_SYSTEM_SHELL, false) - set(value) { - prefManager.putBoolean(TERMINAL_USE_SYSTEM_SHELL, value) - } - var lastOpenedProject: String get() = prefManager.getString(LAST_OPENED_PROJECT, NO_OPENED_PROJECT)!! set(value) { diff --git a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/StatPreferences.kt b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/StatPreferences.kt index 499be8d572..af4566f9ab 100644 --- a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/StatPreferences.kt +++ b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/StatPreferences.kt @@ -17,31 +17,47 @@ package com.itsaky.androidide.preferences.internal -/** - * @author Akash Yadav - */ -@Suppress("MemberVisibilityCanBePrivate") +import android.content.Context +import android.content.SharedPreferences +import com.itsaky.androidide.app.BaseApplication + +enum class TelemetryConsent { + UNSET, + GRANTED, + DECLINED, +} + object StatPreferences { + const val TELEMETRY_CONSENT = "ide.stats.telemetryConsent" + + private const val PREFS_FILE = "ide.stats" + + @Volatile + private var cachedPrefs: SharedPreferences? = null + + @Volatile + private var cachedPrefsApp: BaseApplication? = null + + private val prefs: SharedPreferences + get() { + val app = BaseApplication.baseInstance + cachedPrefs?.takeIf { cachedPrefsApp === app }?.let { return it } + return app + .createDeviceProtectedStorageContext() + .getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE) + .also { + cachedPrefs = it + cachedPrefsApp = app + } + } - const val STAT_COLLECTION_CONSENT_SHOWN = "ide.stats.consentShown" - const val STAT_OPT_IN = "ide.stats.optIn" - const val STAT_LAST_REPORTED = "ide.stats.lastReported" - - var statConsentDialogShown: Boolean - get() = prefManager.getBoolean(STAT_COLLECTION_CONSENT_SHOWN, false) - set(value) { - prefManager.putBoolean(STAT_COLLECTION_CONSENT_SHOWN, value) - } - - var statOptIn: Boolean - get() = prefManager.getBoolean(STAT_OPT_IN, true) - set(value) { - prefManager.putBoolean(STAT_OPT_IN, value) - } - - var statLastReported: Long - get() = prefManager.getLong(STAT_LAST_REPORTED, 0L) - set(value) { - prefManager.putLong(STAT_LAST_REPORTED, value) - } -} \ No newline at end of file + var telemetryConsent: TelemetryConsent + get() = + prefs + .getString(TELEMETRY_CONSENT, null) + ?.let { stored -> TelemetryConsent.entries.firstOrNull { it.name == stored } } + ?: TelemetryConsent.UNSET + set(value) { + prefs.edit().putString(TELEMETRY_CONSENT, value.name).apply() + } +} diff --git a/profiler/build.gradle.kts b/profiler/build.gradle.kts index 0e02590550..bb061392d0 100644 --- a/profiler/build.gradle.kts +++ b/profiler/build.gradle.kts @@ -32,6 +32,7 @@ protobuf { dependencies { api(projects.actions) + implementation(projects.commonCompose) implementation(projects.logger) implementation(projects.subprojects.privilegedServices) implementation(projects.subprojects.flamegraph) diff --git a/profiler/src/main/java/org/appdevforall/cotg/profiler/ui/theme/ProfilerTheme.kt b/profiler/src/main/java/org/appdevforall/cotg/profiler/ui/theme/ProfilerTheme.kt index 7760600caa..32c74150f4 100644 --- a/profiler/src/main/java/org/appdevforall/cotg/profiler/ui/theme/ProfilerTheme.kt +++ b/profiler/src/main/java/org/appdevforall/cotg/profiler/ui/theme/ProfilerTheme.kt @@ -1,68 +1,13 @@ package org.appdevforall.cotg.profiler.ui.theme -import android.content.Context -import android.util.TypedValue -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.ColorScheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.core.content.ContextCompat -import com.google.android.material.R as MaterialR - +import com.itsaky.androidide.common.compose.IdeTheme + +/** + * Profiler content themed from the IDE's XML theme. + * + * A thin alias for [IdeTheme]: the attribute-to-role mapping this used to carry is shared, so every + * Compose surface in the app resolves colours the same way. + */ @Composable -fun ProfilerTheme(content: @Composable () -> Unit) { - val context = LocalContext.current - val darkTheme = isSystemInDarkTheme() - val colorScheme = - remember(context, darkTheme) { - context.toMaterial3ColorScheme(darkTheme) - } - MaterialTheme(colorScheme = colorScheme, content = content) -} - -private fun Context.toMaterial3ColorScheme(darkTheme: Boolean): ColorScheme { - val base = if (darkTheme) darkColorScheme() else lightColorScheme() - return base.copy( - primary = resolveColor(MaterialR.attr.colorPrimary, base.primary), - onPrimary = resolveColor(MaterialR.attr.colorOnPrimary, base.onPrimary), - primaryContainer = resolveColor(MaterialR.attr.colorPrimaryContainer, base.primaryContainer), - onPrimaryContainer = resolveColor(MaterialR.attr.colorOnPrimaryContainer, base.onPrimaryContainer), - secondary = resolveColor(MaterialR.attr.colorSecondary, base.secondary), - onSecondary = resolveColor(MaterialR.attr.colorOnSecondary, base.onSecondary), - secondaryContainer = resolveColor(MaterialR.attr.colorSecondaryContainer, base.secondaryContainer), - onSecondaryContainer = resolveColor(MaterialR.attr.colorOnSecondaryContainer, base.onSecondaryContainer), - tertiary = resolveColor(MaterialR.attr.colorTertiary, base.tertiary), - onTertiary = resolveColor(MaterialR.attr.colorOnTertiary, base.onTertiary), - background = resolveColor(android.R.attr.colorBackground, base.background), - onBackground = resolveColor(MaterialR.attr.colorOnBackground, base.onBackground), - surface = resolveColor(MaterialR.attr.colorSurface, base.surface), - onSurface = resolveColor(MaterialR.attr.colorOnSurface, base.onSurface), - surfaceVariant = resolveColor(MaterialR.attr.colorSurfaceVariant, base.surfaceVariant), - onSurfaceVariant = resolveColor(MaterialR.attr.colorOnSurfaceVariant, base.onSurfaceVariant), - outline = resolveColor(MaterialR.attr.colorOutline, base.outline), - error = resolveColor(MaterialR.attr.colorError, base.error), - onError = resolveColor(MaterialR.attr.colorOnError, base.onError), - ) -} - -private fun Context.resolveColor( - attr: Int, - fallback: Color, -): Color { - val value = TypedValue() - if (!theme.resolveAttribute(attr, value, true)) return fallback - val colorInt = - if (value.type in TypedValue.TYPE_FIRST_COLOR_INT..TypedValue.TYPE_LAST_COLOR_INT) { - value.data - } else if (value.resourceId != 0) { - ContextCompat.getColor(this, value.resourceId) - } else { - return fallback - } - return Color(colorInt) -} +fun ProfilerTheme(content: @Composable () -> Unit) = IdeTheme(content = content) diff --git a/resources/src/main/res/drawable/ic_bilibili.xml b/resources/src/main/res/drawable/ic_bilibili.xml new file mode 100644 index 0000000000..8bac47899a --- /dev/null +++ b/resources/src/main/res/drawable/ic_bilibili.xml @@ -0,0 +1,11 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_youtube.xml b/resources/src/main/res/drawable/ic_youtube.xml new file mode 100644 index 0000000000..aff8ac4f46 --- /dev/null +++ b/resources/src/main/res/drawable/ic_youtube.xml @@ -0,0 +1,11 @@ + + + + diff --git a/resources/src/main/res/values-ar-rSA/strings.xml b/resources/src/main/res/values-ar-rSA/strings.xml index 36ed509f2f..8ea2474c6e 100644 --- a/resources/src/main/res/values-ar-rSA/strings.xml +++ b/resources/src/main/res/values-ar-rSA/strings.xml @@ -159,8 +159,6 @@ إذا تم تحديده، فسيتذكر IDE آخر مشروع تم فتحه وسيتم إعادة فتحه عند بدء التشغيل التالي. تأكيد فتح المشروع اسأل قبل فتح آخر مشروع مفتوح. - استخدام shell النظام في الـterminal - إذا تم تحديده، سيتم استخدام \'/system/bin/sh\' في التيرمنال. عام حجم التبويبة حدد عدد المسافات لـ TAB diff --git a/resources/src/main/res/values-bn-rIN/strings.xml b/resources/src/main/res/values-bn-rIN/strings.xml index fa5326e467..9824e54355 100644 --- a/resources/src/main/res/values-bn-rIN/strings.xml +++ b/resources/src/main/res/values-bn-rIN/strings.xml @@ -159,8 +159,6 @@ চেক করা থাকলে, IDE শেষ খোলা প্রকল্পটি মনে রাখবে এবং পরবর্তী স্টার্টআপে এটি পুনরায় খোলা হবে৷ প্রকল্প খোলার বিষয়টি নিশ্চিত করুন শেষ খোলা প্রকল্প খোলার আগে জিজ্ঞাসা করুন৷ - টার্মিনালে সিস্টেম শেল ব্যবহার করুন - চেক করা থাকলে, টার্মিনালে \'/system/bin/sh\' ব্যবহার করা হবে। সাধারণ ট্যাবের আকার TAB এর জন্য স্পেস সংখ্যা নির্দিষ্ট করুন diff --git a/resources/src/main/res/values-de-rDE/strings.xml b/resources/src/main/res/values-de-rDE/strings.xml index 0515c92c59..7c519e07f2 100644 --- a/resources/src/main/res/values-de-rDE/strings.xml +++ b/resources/src/main/res/values-de-rDE/strings.xml @@ -158,8 +158,6 @@ Wenn diese Option aktiviert ist, merkt sich die IDE das zuletzt geöffnete Projekt und öffnet dieses beim nächsten Start. Projekt öffnung bestätigen Vor dem Öffnen des zuletzt geöffneten Projekts nachfragen. - Verwenden Sie die System-Shell im Terminal - Wenn aktiviert, wird \'/system/bin/sh\' im Terminal verwendet. Allgemein Tab-Größe Geben Sie die Anzahl der Leerzeichen für TAB an diff --git a/resources/src/main/res/values-es-rES/strings.xml b/resources/src/main/res/values-es-rES/strings.xml index 4cd2bf7775..5a279dd0d1 100644 --- a/resources/src/main/res/values-es-rES/strings.xml +++ b/resources/src/main/res/values-es-rES/strings.xml @@ -159,8 +159,6 @@ Si está marcado, el IDE recordará el último proyecto abierto y volverá a abrirse en el próximo arranque. Confirmar apertura del proyecto Preguntar antes de abrir el último proyecto abierto. - Use system shell in terminal - Si está marcado, se utilizará \'/system/bin/sh\' en el terminal. General Tamaño de la pestaña Especificar el número de espacios para la TAB diff --git a/resources/src/main/res/values-fr-rFR/strings.xml b/resources/src/main/res/values-fr-rFR/strings.xml index 4758a6bf70..59d5c68c5a 100644 --- a/resources/src/main/res/values-fr-rFR/strings.xml +++ b/resources/src/main/res/values-fr-rFR/strings.xml @@ -160,8 +160,6 @@ Dernier projet ouvert : \n%s Si il est activé, l\'IDE se rappelera du dernier projet ouvert et l\'ouvrira au prochain démarrage de l\'application Confirmer l\'ouverture du projet Demander avant d\'ouvrir le dernier projet ouvert - Utiliser le système Shell dans le terminal - Si il est activer, \'/system/bin/sh\' sera utilisé dans le terminal. Général Taille de l\'onglet Spécifier la taille de l\'onglet diff --git a/resources/src/main/res/values-hi-rIN/strings.xml b/resources/src/main/res/values-hi-rIN/strings.xml index 1680217779..4dea3e7bf7 100644 --- a/resources/src/main/res/values-hi-rIN/strings.xml +++ b/resources/src/main/res/values-hi-rIN/strings.xml @@ -158,8 +158,6 @@ यदि चेक किया गया है, तो आईडीई पिछले खुले हुए प्रोजेक्ट को याद रखेगा और इसे अगले स्टार्टअप पर फिर से खोल दिया जाएगा। प्रोजेक्ट के खोलने की पुष्टि करें अंतिम खुली प्रोजेक्ट को खोलने से पहले पूछें। - टर्मिनल में सिस्टम शेल का उपयोग करें - यदि चेक किया गया है, तो टर्मिनल में \'/system/bin/sh\' का उपयोग किया जाएगा। जनरल टैब साइज टैब के लिए स्पेसेस की संख्या निर्दिष्ट करें diff --git a/resources/src/main/res/values-in-rID/layouteditor_migrated.xml b/resources/src/main/res/values-in-rID/layouteditor_migrated.xml new file mode 100644 index 0000000000..d32ad35817 --- /dev/null +++ b/resources/src/main/res/values-in-rID/layouteditor_migrated.xml @@ -0,0 +1,30 @@ + + + + Agen AI + Batal + Hapus + Hapus + Ubah + Kunci API Gemini + Kunci API Gemini disimpan di %s + Ganti nama + Simpan Kunci + Pengaturan AI + Riwayat + Hapus proyek + Ganti nama proyek + Opsi + Buat + Masukkan nama proyek baru + Apakah Anda yakin ingin menghapus proyek ini? + Nama saat ini tidak tersedia! + Kolom tidak boleh kosong! + Kunci API disimpan dengan aman. + Kunci API telah disimpan. + Kunci API telah dihapus. + Kunci API tidak boleh kosong. + diff --git a/resources/src/main/res/values-in-rID/strings.xml b/resources/src/main/res/values-in-rID/strings.xml index 11f7f5a5ba..64f98857de 100644 --- a/resources/src/main/res/values-in-rID/strings.xml +++ b/resources/src/main/res/values-in-rID/strings.xml @@ -7,6 +7,8 @@ Teks untuk dicari Email Situs web + YouTube + Bilibili Code on the Go %1$s untuk %2$s Tidak punya komputer? Tidak ada internet? Tidak masalah. Koding aplikasi di mana saja. Gagal mengekstrak nama paket. @@ -17,7 +19,7 @@ Butuh Bantuan? Preferensi IDE Forum dukungan dan diskusi - Saluran Telegram resmi + Pengumuman di Telegram Tidak ada data Terminal Atur ulang @@ -238,7 +240,7 @@ File baru Buat file layout Class Java baru - Sumber XML baru + Sumber daya XML baru Folder baru Konfirmasi penghapusan Apakah Anda yakin ingin menghapus:\n%s? @@ -318,8 +320,6 @@ Konfirmasi pembukaan proyek Jika diaktifkan, Code on the Go akan meminta konfirmasi sebelum membuka proyek terakhir. Terjadi kesalahan saat membuka proyek. - Gunakan shell sistem di terminal - Jika dicentang, \'/system/bin/sh\' akan digunakan di terminal. Umum Ukuran tab Atur jumlah spasi yang digunakan oleh karakter tab untuk indentasi. @@ -507,6 +507,43 @@ Abaikan peringatan \'unchecked\' Hapus komentar baris Ubah menjadi statement + + + Ekstrak variabel + Ekstrak variabel + Ekspresi + Nama + Deklarasikan di + + Ganti %1$d kemunculan + Ganti semua %1$d kemunculan + + Ekstrak + Masukkan nama + Bukan nama Kotlin yang valid + Itu adalah kata kunci Kotlin + Nama itu sudah digunakan + Tidak ada ekspresi untuk diekstrak di sini + File telah berubah. Coba ekstrak lagi. + + + Ekstrak metode + Ekstrak metode + Tanda tangan + File telah berubah. Coba ekstrak lagi. + Pilih sebuah ekspresi, atau pernyataan lengkap dalam satu blok + Tidak dapat menganalisis pilihan. Coba lagi. + Pilihan menghasilkan lebih dari satu nilai: %1$s + Pilihan menghasilkan %1$s, yang tidak dapat dikembalikan sebagai nilai balik (return value) + Pilihan memberi nilai baru ke %1$s, yang dideklarasikan di luar pilihan tersebut + Pilihan keluar dari dirinya sendiri dengan return, break, atau continue + Pilihan berada di dalam fungsi ekstensi anonim + Pilihan menggunakan anggota dari penerima (receiver) %1$s yang melingkupinya + Pilihan menggunakan parameter tipe %1$s + Sebuah tipe dalam pilihan tidak dapat dituliskan + Pilihan menggunakan backing field dari properti, yang hanya ada di dalam pengakses (accessor) ini + Pilihan menggunakan %1$s di bawah smart cast yang tidak berlaku di luar pilihan tersebut + Pilihan menggunakan %1$s, yang keluar dari cakupan (scope) setelah pilihan tersebut dipindahkan Pilih field Tidak ada field yang dipilih Field tidak ditemukan @@ -599,9 +636,9 @@ Privasi Privasi & analitik - Code on the Go menggunakan Firebase Analytics dan GlitchTip untuk membantu kami meningkatkan aplikasi.\n\nFirebase Analytics mengumpulkan data penggunaan anonim untuk membantu kami memahami bagaimana aplikasi digunakan.\n\nGlitchTip membantu kami melacak dan memperbaiki masalah.\n\nTidak ada informasi pribadi yang dikumpulkan atau dibagikan. Semua data diproses sesuai dengan kebijakan privasi kami. - Saya mengerti - Pelajari lebih lanjut + Code on the Go mengumpulkan informasi penggunaan dan laporan kerusakan anonim untuk membantu kami memperbaiki bug dan meningkatkan aplikasi. Tidak ada informasi pribadi yang dikumpulkan atau dibagikan.\n\nBagikan data anonim akan mengirim informasi ini. Tetap offline tidak mengirim apa pun. + Bagikan data anonim + Tetap offline ID Unik Perangkat @@ -621,6 +658,8 @@ Log dari IDE ditampilkan di sini. Buka file untuk menampilkan hasil diagnostik. Filter baris + Tidak ditemukan entri log yang cocok. + Tidak ditemukan hasil pencarian yang cocok. Filter Cari Bagikan @@ -631,6 +670,9 @@ Info Peringatan Kesalahan + Nomor baris + Stempel waktu + Selisih waktu Cari dalam output Filter output "Build aplikasi atau jalankan task untuk melihat output build nya di sini." @@ -820,6 +862,7 @@ Pengelola Plugin Pengelola Plugin Kelola plugin dan ekstensi IDE + Tidak dapat membuka pengaturan plugin ini Plugin Tidak ada plugin yang terinstal Ketuk tombol + untuk menginstal plugin pertama Anda @@ -832,7 +875,12 @@ Aktifkan Nonaktifkan Hapus instalasi + Detail Detail plugin + oleh %1$s + Diaktifkan + Dinonaktifkan + Tidak dimuat Izin Dependensi @@ -875,6 +923,7 @@ Ada kesalahan Peringatan Informasi + Tampilkan bantuan Jalankan cepat @@ -900,6 +949,7 @@ Aktifkan pembungkus kata (word wrap) Nonaktifkan pembungkus kata (word wrap) + Buka opsi tampilan output. "Terjadi kesalahan yang tidak diketahui." Build sedang berlangsung. Permintaan baru diabaikan. @@ -945,6 +995,20 @@ Hapus file instalasi setelah terinstal Temukan plugin + Tidak dapat membaca file. Mungkin file tersebut rusak atau tidak tersedia. + Tipe file tidak didukung. Hanya file berformat .cgp dan .cgt yang dapat dibuka dengan cara ini. + Penyiapan IDE belum selesai. Silakan coba lagi setelah penyiapan selesai. + Instal Koleksi Template + Instal \'%1$s\' dengan template berikut: %2$s? + Koleksi Template Telah Terinstal + Koleksi template bernama \'%1$s\' sudah terinstal. Koleksi yang baru berisi: %2$s. Apa yang ingin Anda lakukan? + Timpa + Ganti nama & Instal + Nama koleksi baru + "%1$s" telah berhasil diinstal + File koleksi template tidak valid atau rusak. + Gagal menginstal koleksi template: %1$s + \n\nPembuatan proyek selesai dengan peringatan/kesalahan. Buka Log IDE untuk detail. @@ -1061,6 +1125,7 @@ Lihat informasi lebih lanjut, termasuk tips pemecahan masalah.]]> + Jelajahi dokumentasi.]]> Kirim masukan diff --git a/resources/src/main/res/values-pt-rBR/strings.xml b/resources/src/main/res/values-pt-rBR/strings.xml index 9b1131b50b..05d34a5447 100644 --- a/resources/src/main/res/values-pt-rBR/strings.xml +++ b/resources/src/main/res/values-pt-rBR/strings.xml @@ -159,8 +159,6 @@ Se habilitado, a IDE se lembrará do último projeto aberto e será reaberto na próxima vez. Confirmar abertura do projeto Perguntar antes de abrir o último projeto aberto. - Usar shell do sistema no terminal - Se habilitado, \'/system/bin/sh\' será usado no terminal. Geral Tamanho da tabulação Especifique o número de espaços para o TAB diff --git a/resources/src/main/res/values-ro-rRO/strings.xml b/resources/src/main/res/values-ro-rRO/strings.xml index e8abc836ef..2e68bb0eac 100644 --- a/resources/src/main/res/values-ro-rRO/strings.xml +++ b/resources/src/main/res/values-ro-rRO/strings.xml @@ -159,8 +159,6 @@ Dacă este activat, IDE va preântâmpina despre ultimul proiect deschis și va fi redeschis la următoarea pornire. Confirmați deschiderea proiectului Întrebați înainte de a deschide ultimul proiect deschis. - Utilizați shell-ul sistemului în terminal - Dacă este activat, \'/system/bin/sh\' va fi utilizat în terminal. General Mărime Tab Specificarea numărului de spații pentru TAB diff --git a/resources/src/main/res/values-ru-rRU/strings.xml b/resources/src/main/res/values-ru-rRU/strings.xml index b8c0798af1..78f265d4c5 100644 --- a/resources/src/main/res/values-ru-rRU/strings.xml +++ b/resources/src/main/res/values-ru-rRU/strings.xml @@ -157,8 +157,6 @@ Если включено, IDE будет запоминать последний открытый проект и открывать его при последующих запусках Подтверждение открытия проекта Спрашивать перед открытием последнего проекта. - Использовать системный shell в терминале - Если включено, \'/system/bin/sh\' будет использовано в терминале. Основное Размер TAB Укажите, сколько пробелов будет напечатано при нажатии TAB. diff --git a/resources/src/main/res/values-tr-rTR/strings.xml b/resources/src/main/res/values-tr-rTR/strings.xml index bcafea2fb8..2cd1cac2db 100644 --- a/resources/src/main/res/values-tr-rTR/strings.xml +++ b/resources/src/main/res/values-tr-rTR/strings.xml @@ -159,8 +159,6 @@ Eğer etkinleştirilirse, IDE en son açılan projeyi hatırlayacak ve diğer başlatmada tekrar açılacak. Projenin açılmasını onayla Son projeyi açmadan önce sor. - Terminalde sistem kabuğunu kullan - Eğer etkinleştirilirse, \'/system/bin/sh\' terminalde kullanılacak. Genel Boşluk boyutu TAB için boşluk sayısını ayarla diff --git a/resources/src/main/res/values-zh-rCN/strings.xml b/resources/src/main/res/values-zh-rCN/strings.xml index 364e73a968..494c564c4e 100644 --- a/resources/src/main/res/values-zh-rCN/strings.xml +++ b/resources/src/main/res/values-zh-rCN/strings.xml @@ -331,8 +331,6 @@ 确认打开项目 启用后,Code on the Go 在打开上次的项目前会要求确认 打开项目时出错 - 在终端中使用系统 Shell - 如果选中,终端将使用 \'/system/bin/sh\' 通用 制表符大小 设置 Tab 键缩进的空格数 @@ -659,9 +657,9 @@ 隐私 隐私和分析 - Code on the Go 使用 Firebase Analytics 和 GlitchTip 来帮助我们改进应用 \n\nFirebase Analytics 收集匿名使用数据,帮助我们了解应用的使用情况 \n\nGlitchTip 帮助我们跟踪和修复错误 \n\n不会收集或分享任何个人信息 所有数据均按照我们的隐私政策进行处理 - 我了解 - 了解更多 + Code on the Go 会收集匿名的使用情况和崩溃信息,以帮助我们修复缺陷并改进应用。我们不会收集或分享任何个人信息。\n\n选择“共享匿名数据”将发送这些信息;选择“保持离线”则不会发送任何内容。 + 共享匿名数据 + 保持离线 唯一 ID 设备 @@ -1184,7 +1182,6 @@ 支持我们的工作 https://github.com/sponsors/appdevforall http://localhost:6174/i/index.html - https://www.appdevforall.org/wp-content/uploads/2024/08/privacy_notice.pdf http://localhost:6174/i/cogo-quickstart.html info@appdevforall.org mailto:info@appdevforall.org diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 7ff3bc7f18..ed1b49609f 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -8,6 +8,8 @@ Code on the Go Email Website + YouTube + Bilibili Code on the Go v\u2022 %s Code on the Go %1$s for %2$s No computer? No Internet? No problem. Code apps anywhere. @@ -331,8 +333,6 @@ Confirm project opening When enabled, Code on the Go asks for confirmation before opening the last project. Error opening project. - Use system shell in terminal - If checked, \'/system/bin/sh\' will be used in terminal. General Tab size Set the number of spaces that the tab character indents. @@ -523,6 +523,43 @@ Suppress \'unchecked\' warning Uncomment line Convert to statement + + + Extract variable + Extract variable + Expression + Name + Declare in + + Replace %1$d occurrence + Replace all %1$d occurrences + + Extract + Enter a name + Not a valid Kotlin name + That is a Kotlin keyword + That name is already used + No expression to extract here + The file changed. Try extracting again. + + + Extract method + Extract method + Signature + The file changed. Try extracting again. + Select an expression, or whole statements inside one block + Could not analyse the selection. Try again. + The selection produces more than one value: %1$s + The selection produces %1$s, which cannot be handed back as a return value + The selection assigns to %1$s, which is declared outside it + The selection jumps out of itself with return, break or continue + The selection is inside an anonymous extension function + The selection uses members of the enclosing %1$s receiver + The selection uses type parameter %1$s + A type in the selection cannot be written out + The selection uses the property\'s backing field, which only exists inside this accessor + The selection uses %1$s under a smart cast that does not hold outside the selection + The selection uses %1$s, which goes out of scope once the selection moves Select fields No fields selected No fields found @@ -664,9 +701,9 @@ Privacy Privacy & analytics - Code on the Go uses Firebase Analytics and GlitchTip to help us improve the app.\n\nFirebase Analytics collects anonymous usage data to help us understand how the app is used. \n\nGlitchTip helps us track and fix errors.\n\nNo personal information is collected or shared. All data is processed in accordance with our privacy policy. - I understand - Learn more + Code on the Go collects anonymous usage and crash information to help us fix bugs and improve the app. No personal information is collected or shared.\n\nShare anonymous data sends this information. Keep offline sends nothing at all. + Share anonymous data + Keep offline Unique ID Device @@ -898,8 +935,8 @@ Plugin Manager - Plugin Manager - Manage IDE plugins and extensions + Extensions Manager + Manage IDE plugins and templates Could not open this plugin\'s settings Plugins No plugins installed @@ -921,6 +958,10 @@ Not Loaded Permissions Dependencies + by %1$s + Not Loaded + Disabled + Enabled Plugin crashed @@ -1012,10 +1053,13 @@ Redo Delete Add + Navigate back + Show help tooltip Search Error Warning Information + Show help Quick run @@ -1082,13 +1126,51 @@ Error uninstalling plugin: %1$s Could not delete the installation file Unsupported file type. Please select a .cgp file + Unsupported file type. Please select a .cgp plugin or .cgt template file No file manager found Failed to initialize Plugin Manager: %1$s Delete installation file after install Discover plugins https://www.appdevforall.org/contribute/ + Uninstall Plugin + Are you sure you want to uninstall \'%1$s\'? + Name + Plugin ID + Version + Author + Description + Min IDE Version + %1$s: %2$s + "- %1$s" + " - " %1$s: %2$s + Could not read the file. It may be corrupted or unavailable. + Unsupported file type. Only .cgp and .cgt files can be opened this way. + IDE setup has not finished yet. Please try again once setup completes. + Install Template Collection + Install \'%1$s\' with the following templates: %2$s? + Template Collection Already Installed + A template collection named \'%1$s\' is already installed. The new one contains: %2$s. What would you like to do? + Overwrite + Rename & Install + New collection name + Invalid or corrupted template collection file. + + + `%1$s` installed successfully + Failed to install template collection: %1$s + Failed to load templates: %1$s + Template uninstalled successfully + Failed to uninstall template: %1$s + Deleted from Downloads + Failed to delete template: %1$s + \n\nProject creation finished with warnings/errors. Open IDE Logs for details. @@ -1206,6 +1288,7 @@ %3$s]]> + Explore the documentation.]]> Send feedback @@ -1215,10 +1298,11 @@ Support our work https://github.com/sponsors/appdevforall http://localhost:6174/i/index.html - https://www.appdevforall.org/wp-content/uploads/2024/08/privacy_notice.pdf http://localhost:6174/i/cogo-quickstart.html info@appdevforall.org mailto:info@appdevforall.org + https://youtube.com/@appdevforall + https://www.bilibili.com/video/BV1AUgR6sE3G/ 💾 Saved: %1$s @@ -1248,6 +1332,38 @@ Plugin Manager + + Plugins & Templates + Plugins + Templates + No templates found + Templates you download show up here for installing + Installed + Not installed + Bundled + From plugin + Imported + + Contains %1$d template + Contains %1$d templates + + Install + Uninstall + View templates + Delete + Details + Templates in %1$s + Delete template? + This permanently deletes \'%1$s\' from Downloads. + File + Status + Location + Version + Description + Optional parameters + (unnamed) + Template error + Failed to save bitmap to file PixelCopy failed Failed to capture or save screenshot diff --git a/scripts/r8-plugin-impact/README.md b/scripts/r8-plugin-impact/README.md new file mode 100644 index 0000000000..e20a1d2ae7 --- /dev/null +++ b/scripts/r8-plugin-impact/README.md @@ -0,0 +1,137 @@ +# R8 plugin-impact analysis (ADFA-5156) + +**What this is:** tooling to prove whether a release build of the IDE strips +Kotlin stdlib members that plugins need at runtime. + +**Why it exists:** plugins are loaded *parent-first* through a stock +`DexClassLoader` (`PluginLoader.kt:92-116`, parent passed at +`PluginManager.kt:603`), so every `kotlin.**` class a plugin references resolves +from **the IDE's dex**, not from the ~1058 stdlib classes the plugin bundles. +R8 cannot see plugin call sites, so it strips every stdlib member the IDE itself +does not call. The net effect is that **a plugin can only call the subset of the +Kotlin standard library that the IDE also calls**; anything else throws +`NoSuchMethodError` at runtime. + +That failure is invisible at build time. `assemblePlugin` is green, the manifest +is fine, the `.cgp` is correct. Only on-device execution of the specific code +path reveals it — which is why this tooling exists. + +ADFA-5156 rolled R8 shrinking back (restored `-dontshrink` in +`app/proguard-rules.pro`) as a stopgap. **Any future attempt to re-enable +shrinking must be validated with these scripts before shipping.** + +## Usage + +Nothing here has third-party dependencies; the Python is stdlib-only. + +```bash +# 1. Dump a release APK's dex (do this for the build you want to check, +# and for a reference build to compare against) +./dex-dump.sh /path/to/CodeOnTheGo-v8-release.apk out/candidate +./dex-dump.sh /path/to/previous-release.apk out/reference + +# 2. Dump every plugin's dex (.cgp files are zips) +./dex-dump.sh --disassemble /path/to/plugins/*.cgp out/plugins + +# 3. Compare +uv run --no-project analyze-plugin-impact.py impact \ + out/reference/full-dump.txt out/candidate/full-dump.txt out/plugins +``` + +To pull the reference APK off a device: + +```bash +adb shell pm path com.itsaky.androidide # -> /data/app/.../base.apk +adb pull baseline.apk +``` + +## Reading the output + +Each `kotlin.*`/`kotlinx.*` call site originating in a plugin's own code is +classified by walking the superclass/interface chain in the host dex: + +| verdict | meaning | +|---|---| +| `ok` | class present in the IDE dex, method found in its hierarchy | +| `NoSuchMethod` | class present but method absent. **Guaranteed runtime failure.** This is the ADFA-5156 bug | +| `CLASS_ABSENT` | class missing from the IDE dex entirely, so the plugin's own bundled copy loads instead | + +Calls originating *inside* a plugin's bundled stdlib copy are excluded — that +copy is shadowed at runtime, so they are not real call sites. + +`CLASS_ABSENT` is not automatically a bug. It is how a plugin's own copy gets +used, and it is fine when the whole subtree is absent. It is dangerous when a +class is absent but its **supertype is present-and-stripped** in the host: the +chain jumps back into the host and dies. That is exactly the confusing shape in +the original report (`ArraysKt` facade dropped, so it loaded from the plugin's +`classes2.dex`, but its superclass `ArraysKt___ArraysKt` resolved parent-first +back into the IDE's stripped copy). Use `explain-absent` to inspect. + +## Known false positives — read before acting on results + +**Methods inherited from the Android boot classpath are reported as +`NoSuchMethod`.** `java.util.*`, `java.lang.*` and friends are not in the APK, +so the hierarchy walk runs off the end of what it can see and gives up. Known +instances, all benign: + +- `AbstractMutableSet.addAll` / `containsAll` / `removeAll` / `retainAll` -> `java.util.AbstractSet` +- `AbstractMutableMap.putAll` -> `java.util.AbstractMap` +- `IntIterator.hasNext` / `LongIterator.hasNext` -> `java.util.Iterator` +- `ArrayDeque.iterator` -> `java.util.AbstractList` + +Before treating any `NoSuchMethod` as real, run `explain-method` on it and check +whether the chain exits the APK at a `java.*` link. If it does, it is a false +positive. + +**Kotlin multifile facades declare nothing themselves.** `ArraysKt`, +`StringsKt`, `CollectionsKt` etc. extend a part class (`ArraysKt___ArraysKt`, +`StringsKt__StringsKt` — note the varying underscore counts) which holds the +actual members. Checking a facade directly for a method always fails. The +hierarchy walk handles this; ad-hoc greps do not. + +**D8 build-time synthetics never exist in the host.** Classes like +`kotlin.UByte$$ExternalSyntheticBackport0` and +`kotlin.io.path.PathTreeWalk$$ExternalSyntheticApiModelOutline0` are generated +during the *plugin's* own dexing. They show as `CLASS_ABSENT` and always will; +they are leaf synthetics with no host counterpart, so they carry no split-brain +risk. + +## Which plugin set to measure + +Use the artifact from the last successful **"Update libs from CodeOnTheGo"** +(`update-libs.yml`) run in the `plugin-examples` repo. That workflow is the only +one that deploys — it rebuilds every plugin and `scp`s the `.cgp` files to +`public_html/flags/plugins`, so its artifact is exactly what users have +installed. `build-plugins.yml` produces CI artifacts only and never deploys. + +```bash +gh run list --workflow=update-libs.yml --limit 5 # find the last success +gh run download -n plugins-cgp -D deployed +``` + +Do not measure an ad-hoc local folder of `.cgp` files. Plugin filenames have +been renamed over time (`templatemanagerplugin` -> `template-manager`, +`IconsRepository-Plugin` -> `icons-repository`, and others), so a stale local +copy can silently omit plugins and carry names that no longer exist. Cross-check +against the workflow's own mapping if in doubt. + +## Baseline from ADFA-5156 + +Measured 2026-08-15 against the deployed plugin set (`update-libs.yml` run +31626494060, 2026-08-12) — 26 plugins, 4,261 call sites — comparing the shipped +R8-shrunk release against the `-dontshrink` rollback: + +| | shipped (shrinking on) | rolled back | +|---|---:|---:| +| resolves cleanly | 2,828 | 4,101 | +| guaranteed `NoSuchMethodError` | 75 (67 real + 8 false positives) | 8 (all false positives) | +| falls through to plugin dex | 1,358 | 152 (3 D8 synthetics) | + +Zero regressions. Ten plugins carried guaranteed-failure call sites in the +shipped build: compose-preview 36, sketch-to-ui 20, client-time-tracker 5, +random-xkcd 5, ai-assistant 2, markdown-previewer 2, project-to-template 2, and +ai-literacy-course / keystore-generator / layout-editor 1 each. + +**A re-enabled-shrinking build should be measured against these numbers.** The +target is 0 real `NoSuchMethod` across all plugins, not just the one that +happened to get reported. diff --git a/scripts/r8-plugin-impact/analyze-plugin-impact.py b/scripts/r8-plugin-impact/analyze-plugin-impact.py new file mode 100644 index 0000000000..1e35e1dc0a --- /dev/null +++ b/scripts/r8-plugin-impact/analyze-plugin-impact.py @@ -0,0 +1,301 @@ +"""ADFA-5156 / R8 plugin-impact analysis. See README.md in this directory. + +Plugins load parent-first through a stock DexClassLoader, so every kotlin.** +class a plugin references resolves from the IDE's dex, not from the stdlib the +plugin bundles. R8 cannot see plugin call sites, so it strips every stdlib +member the IDE itself does not call, and plugins die with NoSuchMethodError at +runtime. This tool simulates that resolution so the breakage can be measured +from a build artifact instead of discovered on a device. + +Run this before shipping any release build that re-enables R8 shrinking. + +uv run --no-project analyze-plugin-impact.py impact +uv run --no-project analyze-plugin-impact.py explain-method +uv run --no-project analyze-plugin-impact.py explain-absent + +Dumps come from dex-dump.sh. IMPORTANT: read the "Known false positives" +section of README.md before acting on any NoSuchMethod result -- methods +inherited from the Android boot classpath are reported as missing because +java.util.* is not in the APK. +""" + +import re +import sys +from collections import defaultdict +from pathlib import Path + +CLS_RE = re.compile(r"^ Class descriptor : '([^']+)'") +SUPER_RE = re.compile(r"^ Superclass : '([^']+)'") +IFACE_RE = re.compile(r"^ #\d+ : '([^']+)'") +NAME_RE = re.compile(r"^ name : '([^']*)'") +TYPE_RE = re.compile(r"^ type : '([^']*)'") +SECTION_RE = re.compile( + r"^ (Direct methods|Virtual methods|Static fields|Instance fields|Interfaces)" +) +INVOKE_RE = re.compile(r"invoke-[a-z/-]+ \{[^}]*\}, (L[^;]+;)\.([^:]+):(\([^)]*\)\S*)") + +STDLIB_PREFIXES = ("Lkotlin/", "Lkotlinx/") + +OK = "OK" +NO_METHOD = "NO_METHOD" +CLASS_ABSENT = "CLASS_ABSENT" + + +def parse_host(dump_path): + """Index a host (IDE) dex dump. + + Returns {class: {'super': str|None, 'ifaces': [str], 'methods': {'name:sig'}}} + """ + idx = {} + cur = None + in_ifaces = False + pending = None + with open(dump_path, "r", errors="replace") as fh: + for line in fh: + m = CLS_RE.match(line) + if m: + cur = {"super": None, "ifaces": [], "methods": set()} + idx[m.group(1)] = cur + in_ifaces = False + pending = None + continue + if cur is None: + continue + m = SUPER_RE.match(line) + if m: + cur["super"] = m.group(1) + continue + m = SECTION_RE.match(line) + if m: + # Interface entries look like method index lines, so track the + # section to tell them apart. + in_ifaces = m.group(1) == "Interfaces" + continue + if in_ifaces: + m = IFACE_RE.match(line) + if m: + cur["ifaces"].append(m.group(1)) + continue + m = NAME_RE.match(line) + if m: + pending = m.group(1) + continue + m = TYPE_RE.match(line) + if m and pending is not None: + cur["methods"].add(pending + ":" + m.group(1)) + pending = None + return idx + + +def resolve(idx, cls, name, sig): + """Walk cls -> superclasses -> interfaces looking for name:sig. + + Returns OK, NO_METHOD, or CLASS_ABSENT. NO_METHOD can be a false positive + when the real declaration lives on the Android boot classpath; see README. + """ + if cls not in idx: + return CLASS_ABSENT + key = name + ":" + sig + seen, stack = set(), [cls] + while stack: + c = stack.pop() + if c in seen or c not in idx: + continue + seen.add(c) + e = idx[c] + if key in e["methods"]: + return OK + if e["super"]: + stack.append(e["super"]) + stack.extend(e["ifaces"]) + return NO_METHOD + + +def parse_plugin(dis_path): + """Call sites into kotlin/kotlinx FROM a plugin's own classes. + + Calls originating inside the plugin's bundled stdlib copy are excluded -- + that copy is shadowed by the host at runtime, so they are not real call + sites. Returns {(cls, name, sig): count}. + """ + sites = defaultdict(int) + cur = None + with open(dis_path, "r", errors="replace") as fh: + for line in fh: + m = CLS_RE.match(line) + if m: + cur = m.group(1) + continue + if cur is None or cur.startswith(STDLIB_PREFIXES): + continue + m = INVOKE_RE.search(line) + if m and m.group(1).startswith(STDLIB_PREFIXES): + sites[(m.group(1), m.group(2), m.group(3))] += 1 + return sites + + +def plugin_dumps(plugindir): + for d in sorted(Path(plugindir).iterdir()): + if not d.is_dir(): + continue + dis = d / "dis.txt" + if dis.exists(): + yield d.name, dis + + +def cmd_impact(ref_dump, cand_dump, plugindir): + sys.stderr.write("indexing reference host dex...\n") + ref = parse_host(ref_dump) + sys.stderr.write("indexing candidate host dex...\n") + cand = parse_host(cand_dump) + sys.stderr.write(f"reference classes={len(ref)} candidate classes={len(cand)}\n") + + rows, regressions, remaining = [], [], defaultdict(set) + for name, dis in plugin_dumps(plugindir): + sites = parse_plugin(dis) + cr, cc = defaultdict(int), defaultdict(int) + for (c, n, s) in sites: + vr, vc = resolve(ref, c, n, s), resolve(cand, c, n, s) + cr[vr] += 1 + cc[vc] += 1 + if vr == OK and vc != OK: + regressions.append((name, f"{c}.{n}{s}", vr, vc)) + if vc == NO_METHOD: + remaining[name].add(f"{c}.{n}{s}") + rows.append((name, len(sites), cr, cc)) + + w = 106 + print(f"\n{'plugin':<26} {'sites':>6} | {'REFERENCE':^28} | {'CANDIDATE':^28}") + print(f"{'':<26} {'':>6} | {'ok':>6} {'NoSuchMethod':>13} {'absent':>7} |" + f" {'ok':>6} {'NoSuchMethod':>13} {'absent':>7}") + print("-" * w) + tr, tc = defaultdict(int), defaultdict(int) + for name, n, cr, cc in rows: + for k in (OK, NO_METHOD, CLASS_ABSENT): + tr[k] += cr[k] + tc[k] += cc[k] + print(f"{name:<26} {n:>6} | {cr[OK]:>6} {cr[NO_METHOD]:>13} {cr[CLASS_ABSENT]:>7} |" + f" {cc[OK]:>6} {cc[NO_METHOD]:>13} {cc[CLASS_ABSENT]:>7}") + print("-" * w) + print(f"{'TOTAL':<26} {sum(r[1] for r in rows):>6} |" + f" {tr[OK]:>6} {tr[NO_METHOD]:>13} {tr[CLASS_ABSENT]:>7} |" + f" {tc[OK]:>6} {tc[NO_METHOD]:>13} {tc[CLASS_ABSENT]:>7}") + + print("\n=== REGRESSIONS (resolved in reference, broken in candidate) ===") + if regressions: + for name, sitename, a, b in regressions: + print(f" {name}: {sitename} {a} -> {b}") + else: + print(" none") + + print("\n=== NoSuchMethod in candidate ===") + print(" Check each against README 'Known false positives' -- boot-classpath") + print(" inheritance (java.util.*) is reported here but is not a real failure.") + print(" Use: analyze-plugin-impact.py explain-method ") + if remaining: + for name in sorted(remaining): + print(f" {name}:") + for site in sorted(remaining[name]): + print(f" {site}") + else: + print(" none") + + return 1 if regressions else 0 + + +def cmd_explain_method(dump, cls, method): + """Trace the resolution chain, showing where it leaves the APK.""" + idx = parse_host(dump) + if cls not in idx: + print(f"{cls}: ABSENT from this dex") + return 0 + print(f"{cls}.{method}") + seen, stack, outside = set(), [cls], False + while stack: + c = stack.pop() + if c in seen: + continue + seen.add(c) + if c in idx: + e = idx[c] + decl = any(m.split(":")[0] == method for m in e["methods"]) + print(f" in-apk {c}{' <-- declares ' + method if decl else ''}") + if e["super"]: + stack.append(e["super"]) + stack.extend(e["ifaces"]) + else: + outside = True + print(f" OUTSIDE APK (boot classpath) {c}") + if outside: + print("\n Chain exits the APK. If the method is declared on a java.* type," + "\n this is a FALSE POSITIVE -- see README 'Known false positives'.") + return 0 + + +def cmd_explain_absent(dump, plugindir): + """List CLASS_ABSENT fall-throughs and flag the split-brain shape.""" + idx = parse_host(dump) + absent = defaultdict(set) + for name, dis in plugin_dumps(plugindir): + for (c, n, s) in parse_plugin(dis): + if resolve(idx, c, n, s) == CLASS_ABSENT: + absent[name].add(c) + + allcls = set() + print("Fall-through classes (absent from the host dex, so the plugin's own copy loads):\n") + for p, cs in sorted(absent.items()): + print(f" {p} ({len(cs)} distinct)") + for c in sorted(cs): + print(f" {c}") + allcls |= cs + + print("\nPackage rollup:") + pkg = defaultdict(int) + for c in allcls: + pkg[c.rsplit("/", 1)[0]] += 1 + for p, n in sorted(pkg.items(), key=lambda kv: -kv[1]): + print(f" {n:>3} {p}") + + print("\nClasses in packages the host DOES ship (inspect these -- a class absent") + print("while its supertype is present-and-stripped is the ADFA-5156 shape):") + flagged = False + for c in sorted(allcls): + pkgname = c.rsplit("/", 1)[0] + n = sum(1 for k in idx if k.rsplit("/", 1)[0] == pkgname) + if n: + flagged = True + synth = "$$ExternalSynthetic" in c or c.endswith("$DefaultImpls;") + note = " (D8/Kotlin build-time synthetic, expected)" if synth else "" + print(f" {c} host has {n} others in that package{note}") + if not flagged: + print(" none -- every absent class is in a package the host does not ship") + return 0 + + +USAGE = """usage: +analyze-plugin-impact.py impact +analyze-plugin-impact.py explain-method +analyze-plugin-impact.py explain-absent + + is dex form, e.g. Lkotlin/collections/AbstractMutableSet; +""" + + +def main(): + if len(sys.argv) < 2: + print(USAGE, file=sys.stderr) + return 2 + cmd, rest = sys.argv[1], sys.argv[2:] + handlers = { + "impact": (3, cmd_impact), + "explain-method": (3, cmd_explain_method), + "explain-absent": (2, cmd_explain_absent), + } + if cmd not in handlers or len(rest) != handlers[cmd][0]: + print(USAGE, file=sys.stderr) + return 2 + return handlers[cmd][1](*rest) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/r8-plugin-impact/dex-dump.sh b/scripts/r8-plugin-impact/dex-dump.sh new file mode 100755 index 0000000000..090a25dd18 --- /dev/null +++ b/scripts/r8-plugin-impact/dex-dump.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# +# ADFA-5156 / R8 plugin-impact tooling. See README.md in this directory. +# +# Extracts classes*.dex from an APK or .cgp (both are zips) and produces a +# dexdump text file for analyze-plugin-impact.py to consume. +# +# ./dex-dump.sh # one artifact +# ./dex-dump.sh --disassemble ... # many, with bytecode +# +# --disassemble (dexdump -d) is required for plugins, because the analysis needs +# invoke instructions to find call sites. It is NOT needed for the IDE APK, +# where only the class/method table is read -- and it would be very slow there. + +set -uo pipefail + +DEXDUMP="${DEXDUMP:-}" +if [ -z "$DEXDUMP" ]; then + # Prefer the newest build-tools install we can find. + for sdk in "${ANDROID_HOME:-}" "${ANDROID_SDK_ROOT:-}" "$HOME/Android/Sdk" "$HOME/Library/Android/sdk"; do + [ -n "$sdk" ] || continue + cand=$(ls -d "$sdk"/build-tools/*/dexdump 2>/dev/null | sort -V | tail -1) + [ -n "$cand" ] && { DEXDUMP="$cand"; break; } + done +fi +if [ ! -x "${DEXDUMP:-}" ]; then + echo "dexdump not found. Set DEXDUMP=/path/to/build-tools//dexdump" >&2 + exit 1 +fi + +DIS=0 +if [ "${1:-}" = "--disassemble" ]; then + DIS=1 + shift +fi + +if [ "$#" -lt 2 ]; then + echo "usage: $0 [--disassemble] ... " >&2 + exit 2 +fi + +# Last argument is the output directory. +OUTROOT="${*: -1}" +set -- "${@:1:$(($#-1))}" +mkdir -p "$OUTROOT" + +for artifact in "$@"; do + name=$(basename "$artifact") + name="${name%.*}" + if [ "$#" -eq 1 ]; then + dir="$OUTROOT" # single artifact: dump straight into outdir + else + dir="$OUTROOT/$name" # many: one subdirectory each + fi + mkdir -p "$dir" + + if ! ls "$dir"/classes*.dex >/dev/null 2>&1; then + unzip -q -o "$artifact" 'classes*.dex' -d "$dir" 2>/dev/null + fi + if ! ls "$dir"/classes*.dex >/dev/null 2>&1; then + echo " $name: no dex found, skipping" >&2 + continue + fi + + out="$dir/full-dump.txt" + [ "$DIS" -eq 1 ] && out="$dir/dis.txt" + if [ ! -s "$out" ]; then + : > "$out" + for d in "$dir"/classes*.dex; do + if [ "$DIS" -eq 1 ]; then + "$DEXDUMP" -d "$d" >> "$out" 2>/dev/null + else + "$DEXDUMP" "$d" >> "$out" 2>/dev/null + fi + done + fi + printf '%-30s %2s dex %10s lines -> %s\n' \ + "$name" "$(ls "$dir"/classes*.dex | wc -l | tr -d ' ')" \ + "$(wc -l < "$out" | tr -d ' ')" "$out" +done diff --git a/settings.gradle.kts b/settings.gradle.kts index 29fb8afcd8..7ce1b50938 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -105,6 +105,7 @@ include( ":app", ":build-info", ":common", + ":common-compose", ":common-ui", ":editor", ":editor-api", diff --git a/subprojects/builder-model-impl/src/main/java/com/android/build/gradle/options/StringOption.kt b/subprojects/builder-model-impl/src/main/java/com/android/build/gradle/options/StringOption.kt index ea1def3dfd..d8b158d9a5 100644 --- a/subprojects/builder-model-impl/src/main/java/com/android/build/gradle/options/StringOption.kt +++ b/subprojects/builder-model-impl/src/main/java/com/android/build/gradle/options/StringOption.kt @@ -67,7 +67,8 @@ enum class StringOption( IDE_ANDROID_CUSTOM_CLASS_TRANSFORMS("android.advanced.profiling.transforms", ApiStage.Stable), // The exact version of Android Support plugin used, e.g. 2.4.0.6 - IDE_ANDROID_STUDIO_VERSION(AndroidProject.PROPERTY_ANDROID_SUPPORT_VERSION, ApiStage.Stable), + // AGP 9 removed AndroidProject.PROPERTY_ANDROID_SUPPORT_VERSION; its value is inlined. + IDE_ANDROID_STUDIO_VERSION("android.injected.studio.version", ApiStage.Stable), // The version of Android Game Development Extension used to orchestrate the build IDE_AGDE_VERSION("agde.version", ApiStage.Stable), diff --git a/subprojects/builder-model-impl/src/main/java/com/itsaky/androidide/builder/model/DefaultAndroidArtifact.kt b/subprojects/builder-model-impl/src/main/java/com/itsaky/androidide/builder/model/DefaultAndroidArtifact.kt index 1c218418a5..69d67fe8a8 100644 --- a/subprojects/builder-model-impl/src/main/java/com/itsaky/androidide/builder/model/DefaultAndroidArtifact.kt +++ b/subprojects/builder-model-impl/src/main/java/com/itsaky/androidide/builder/model/DefaultAndroidArtifact.kt @@ -1,55 +1,56 @@ -/* - * 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.builder.model - -import com.android.builder.model.v2.ide.AndroidArtifact -import com.android.builder.model.v2.ide.BytecodeTransformation -import com.android.builder.model.v2.ide.CodeShrinker -import com.android.builder.model.v2.ide.PrivacySandboxSdkInfo -import java.io.File -import java.io.Serializable - -/** @author Akash Yadav */ -class DefaultAndroidArtifact : AndroidArtifact, Serializable { - - private val serialVersionUID = 1L - override var applicationId: String? = "" - override var resGenTaskName: String? = null - override var abiFilters: Set? = null - override var assembleTaskOutputListingFile: File? = null - override var bundleInfo: DefaultBundleInfo? = null - override var codeShrinker: CodeShrinker? = null - override var generatedResourceFolders: Collection = emptyList() - override var isSigned: Boolean = false - override var maxSdkVersion: Int? = null - override var minSdkVersion: DefaultApiVersion = DefaultApiVersion() - override var signingConfigName: String? = null - override var sourceGenTaskName: String = "" - override var testInfo: DefaultTestInfo? = null - override var assembleTaskName: String = "" - override var classesFolders: Set = emptySet() - override var compileTaskName: String = "" - override var generatedSourceFolders: Collection = emptyList() - override var ideSetupTaskNames: Set = emptySet() - override var targetSdkVersionOverride: DefaultApiVersion? = null - override var modelSyncFiles: Collection = emptyList() - override var privacySandboxSdkInfo: PrivacySandboxSdkInfo? = null - override var desugaredMethodsFiles: Collection = emptyList() - override val generatedClassPaths: Map = emptyMap() - override val generatedAssetsFolders: Collection = emptyList() - override val bytecodeTransformations: Collection = emptyList() -} +/* + * 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.builder.model + +import com.android.builder.model.v2.ide.AndroidArtifact +import com.android.builder.model.v2.ide.BytecodeTransformation +import com.android.builder.model.v2.ide.CodeShrinker +import java.io.File +import java.io.Serializable + +/** @author Akash Yadav */ +class DefaultAndroidArtifact : + AndroidArtifact, + Serializable { + private val serialVersionUID = 1L + override var applicationId: String? = "" + override var resGenTaskName: String? = null + override var abiFilters: Set? = null + override var assembleTaskOutputListingFile: File? = null + override var bundleInfo: DefaultBundleInfo? = null + override var codeShrinker: CodeShrinker? = null + override var generatedResourceFolders: Collection = emptyList() + override var isSigned: Boolean = false + override var maxSdkVersion: Int? = null + override var minSdkVersion: DefaultApiVersion = DefaultApiVersion() + override var signingConfigName: String? = null + override var sourceGenTaskName: String = "" + override var testInfo: DefaultTestInfo? = null + override var assembleTaskName: String = "" + override var classesFolders: Set = emptySet() + override var compileTaskName: String = "" + override var generatedSourceFolders: Collection = emptyList() + override var ideSetupTaskNames: Set = emptySet() + override var targetSdkVersionOverride: DefaultApiVersion? = null + override var modelSyncFiles: Collection = emptyList() + override var desugaredMethodsFiles: Collection = emptyList() + override val generatedClassPaths: Map = emptyMap() + override val generatedAssetsFolders: Collection = emptyList() + override val bytecodeTransformations: Collection = emptyList() + override val mappingR8TextFile: File? = null + override val mappingR8PartitionFile: File? = null +} diff --git a/subprojects/builder-model-impl/src/main/java/com/itsaky/androidide/builder/model/DefaultSourceProvider.kt b/subprojects/builder-model-impl/src/main/java/com/itsaky/androidide/builder/model/DefaultSourceProvider.kt index a5743924e9..8499d6ad02 100644 --- a/subprojects/builder-model-impl/src/main/java/com/itsaky/androidide/builder/model/DefaultSourceProvider.kt +++ b/subprojects/builder-model-impl/src/main/java/com/itsaky/androidide/builder/model/DefaultSourceProvider.kt @@ -1,44 +1,49 @@ -/* - * 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.builder.model - -import com.android.builder.model.v2.ide.SourceProvider -import java.io.File -import java.io.Serializable - -/** @author Akash Yadav */ -class DefaultSourceProvider() : SourceProvider, Serializable { - private val serialVersionUID = 1L - override var aidlDirectories: Collection? = null - override var assetsDirectories: Collection? = null - override var customDirectories: Collection? = null - override var javaDirectories: Collection = emptyList() - override var jniLibsDirectories: Collection = emptyList() - override var kotlinDirectories: Collection = emptyList() - override var manifestFile: File? = NoFile - override var mlModelsDirectories: Collection? = null - override var name: String = "" - override var renderscriptDirectories: Collection? = null - override var resDirectories: Collection? = null - override var resourcesDirectories: Collection = emptyList() - override var shadersDirectories: Collection? = null - override var baselineProfileDirectories: Collection? = null - - companion object { - @JvmStatic val NoFile = File("") - } -} +/* + * 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.builder.model + +import com.android.builder.model.v2.ide.SourceProvider +import java.io.File +import java.io.Serializable + +/** @author Akash Yadav */ +class DefaultSourceProvider : + SourceProvider, + Serializable { + private val serialVersionUID = 1L + override var aidlDirectories: Collection? = null + override var assetsDirectories: Collection? = null + override var customDirectories: Collection? = null + override var javaDirectories: Collection = emptyList() + override var jniLibsDirectories: Collection = emptyList() + override var kotlinDirectories: Collection = emptyList() + override var manifestFile: File? = NoFile + override var mlModelsDirectories: Collection? = null + override var name: String = "" + override var renderscriptDirectories: Collection? = null + override var resDirectories: Collection? = null + override var resourcesDirectories: Collection = emptyList() + override var shadersDirectories: Collection? = null + override var baselineProfileDirectories: Collection? = null + + companion object { + @JvmStatic val NoFile = File("") + } + + override val keepRulesDirectories: Collection? = null + override val aarKeepRulesDirectories: Collection? = null +} diff --git a/subprojects/builder-model-impl/src/main/java/com/itsaky/androidide/builder/model/DefaultVariant.kt b/subprojects/builder-model-impl/src/main/java/com/itsaky/androidide/builder/model/DefaultVariant.kt index b4f7386037..f2266f5627 100644 --- a/subprojects/builder-model-impl/src/main/java/com/itsaky/androidide/builder/model/DefaultVariant.kt +++ b/subprojects/builder-model-impl/src/main/java/com/itsaky/androidide/builder/model/DefaultVariant.kt @@ -1,44 +1,49 @@ -/* - * 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.builder.model - -import com.android.builder.model.v2.ide.AndroidArtifact -import com.android.builder.model.v2.ide.JavaArtifact -import com.android.builder.model.v2.ide.Variant -import java.io.File -import java.io.Serializable - -/** @author Akash Yadav */ -class DefaultVariant : Variant, Serializable { - - private val serialVersionUID = 1L - @Deprecated("Contained in deviceTestArtifacts") - override var androidTestArtifact: DefaultAndroidArtifact? = null - override var displayName: String = "" - override var isInstantAppCompatible: Boolean = false - override var desugaredMethods: List = emptyList() - override var mainArtifact: DefaultAndroidArtifact = DefaultAndroidArtifact() - override var name: String = "" - override var testFixturesArtifact: DefaultAndroidArtifact? = null - override var testedTargetVariant: DefaultTestedTargetVariant? = null - @Deprecated("Contained in hostTestArtifacts") - override var unitTestArtifact: DefaultJavaArtifact? = null - override val runTestInSeparateProcess: Boolean = false - override val deviceTestArtifacts: Map = emptyMap() - override val hostTestArtifacts: Map = emptyMap() - override val experimentalProperties: Map = emptyMap() -} +/* + * 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.builder.model + +import com.android.builder.model.v2.ide.AndroidArtifact +import com.android.builder.model.v2.ide.JavaArtifact +import com.android.builder.model.v2.ide.TestSuiteArtifact +import com.android.builder.model.v2.ide.Variant +import java.io.File +import java.io.Serializable + +/** @author Akash Yadav */ +class DefaultVariant : + Variant, + Serializable { + private val serialVersionUID = 1L + + @Deprecated("Contained in deviceTestArtifacts") + override var androidTestArtifact: DefaultAndroidArtifact? = null + override var displayName: String = "" + override var isInstantAppCompatible: Boolean = false + override var desugaredMethods: List = emptyList() + override var mainArtifact: DefaultAndroidArtifact = DefaultAndroidArtifact() + override var name: String = "" + override var testFixturesArtifact: DefaultAndroidArtifact? = null + override var testedTargetVariant: DefaultTestedTargetVariant? = null + + @Deprecated("Contained in hostTestArtifacts") + override var unitTestArtifact: DefaultJavaArtifact? = null + override val runTestInSeparateProcess: Boolean = false + override val deviceTestArtifacts: Map = emptyMap() + override val hostTestArtifacts: Map = emptyMap() + override val experimentalProperties: Map = emptyMap() + override val testSuiteArtifacts: Map = emptyMap() +} diff --git a/subprojects/builder-model-impl/src/main/java/com/itsaky/androidide/builder/model/DefaultVariantDependencies.kt b/subprojects/builder-model-impl/src/main/java/com/itsaky/androidide/builder/model/DefaultVariantDependencies.kt index f77c41fcb5..1845f9e13c 100644 --- a/subprojects/builder-model-impl/src/main/java/com/itsaky/androidide/builder/model/DefaultVariantDependencies.kt +++ b/subprojects/builder-model-impl/src/main/java/com/itsaky/androidide/builder/model/DefaultVariantDependencies.kt @@ -1,37 +1,42 @@ -/* - * 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.builder.model - -import com.android.builder.model.v2.ide.ArtifactDependencies -import com.android.builder.model.v2.models.VariantDependencies -import java.io.Serializable - -/** @author Akash Yadav */ -class DefaultVariantDependencies : VariantDependencies, Serializable { - - private val serialVersionUID = 1L - @Deprecated("Contained in deviceTestArtifacts") - override var androidTestArtifact: DefaultArtifactDependencies? = null - override var libraries: Map = emptyMap() - override var mainArtifact: DefaultArtifactDependencies = DefaultArtifactDependencies() - override var name: String = "" - override var testFixturesArtifact: DefaultArtifactDependencies? = null - @Deprecated("Contained in hostTestArtifacts") - override var unitTestArtifact: DefaultArtifactDependencies? = null - override val deviceTestArtifacts: Map = emptyMap() - override val hostTestArtifacts: Map = emptyMap() -} +/* + * 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.builder.model + +import com.android.builder.model.v2.ide.ArtifactDependencies +import com.android.builder.model.v2.models.TestSuiteDependencies +import com.android.builder.model.v2.models.VariantDependencies +import java.io.Serializable + +/** @author Akash Yadav */ +class DefaultVariantDependencies : + VariantDependencies, + Serializable { + private val serialVersionUID = 1L + + @Deprecated("Contained in deviceTestArtifacts") + override var androidTestArtifact: DefaultArtifactDependencies? = null + override var libraries: Map = emptyMap() + override var mainArtifact: DefaultArtifactDependencies = DefaultArtifactDependencies() + override var name: String = "" + override var testFixturesArtifact: DefaultArtifactDependencies? = null + + @Deprecated("Contained in hostTestArtifacts") + override var unitTestArtifact: DefaultArtifactDependencies? = null + override val deviceTestArtifacts: Map = emptyMap() + override val hostTestArtifacts: Map = emptyMap() + override val testSuiteArtifacts: Map = emptyMap() +} diff --git a/subprojects/tooling-api-model/build.gradle.kts b/subprojects/tooling-api-model/build.gradle.kts index 2c85462c59..49620778b5 100644 --- a/subprojects/tooling-api-model/build.gradle.kts +++ b/subprojects/tooling-api-model/build.gradle.kts @@ -31,16 +31,3 @@ dependencies { implementation(libs.common.jkotlin) } - -tasks.register("copyToTestDir") { - from(project.layout.buildDirectory.file("libs/tooling-api-model.jar")) - into(project.rootProject.mkdir("tests/test-home/.cg/init")) - rename { "model.jar" } - - outputs.upToDateWhen { false } -} - -project.tasks.jar { - finalizedBy("copyToTestDir") - outputs.upToDateWhen { false } -} diff --git a/templates-api/src/main/java/com/itsaky/androidide/templates/parameters.kt b/templates-api/src/main/java/com/itsaky/androidide/templates/parameters.kt index ac2dd79446..276bbec8e1 100644 --- a/templates-api/src/main/java/com/itsaky/androidide/templates/parameters.kt +++ b/templates-api/src/main/java/com/itsaky/androidide/templates/parameters.kt @@ -32,262 +32,262 @@ import kotlin.concurrent.withLock enum class ParameterConstraint { - /** - * Value must be unique. - */ - UNIQUE, - - /** - * Value must be a valid Java package name. - */ - PACKAGE, - - /** - * Value must a valid fully qualified Java class name. - */ - CLASS, - - /** - * Value must be a valid Java class name. - */ - CLASS_NAME, - - /** - * Value must be a valid Gradle module name. - */ - MODULE_NAME, - - /** - * Value must not be empty or blank. - */ - NONEMPTY, - - /** - * Value must be a valid layout file name. - */ - LAYOUT, - - /** - * Value must path to a file. - */ - FILE, - - /** - * Value must path to a directory. - */ - DIRECTORY, - - /** - * Used with [FILE] and [DIRECTORY]. Asserts that the file/directory at the given path exists. - */ - EXISTS + /** + * Value must be unique. + */ + UNIQUE, + + /** + * Value must be a valid Java package name. + */ + PACKAGE, + + /** + * Value must a valid fully qualified Java class name. + */ + CLASS, + + /** + * Value must be a valid Java class name. + */ + CLASS_NAME, + + /** + * Value must be a valid Gradle module name. + */ + MODULE_NAME, + + /** + * Value must not be empty or blank. + */ + NONEMPTY, + + /** + * Value must be a valid layout file name. + */ + LAYOUT, + + /** + * Value must path to a file. + */ + FILE, + + /** + * Value must path to a directory. + */ + DIRECTORY, + + /** + * Used with [FILE] and [DIRECTORY]. Asserts that the file/directory at the given path exists. + */ + EXISTS } abstract class Parameter( - @StringRes val name: Int, - @StringRes val description: Int?, val default: T, - val tooltipTag: String? = null, - var constraints: List, - var id: Int? = null, - val nameStr: String? = null + @StringRes val name: Int, + @StringRes val description: Int?, val default: T, + val tooltipTag: String? = null, + var constraints: List, + var id: Int? = null, + val nameStr: String? = null ) { - private val observers = hashSetOf>() - private val lock = ReentrantLock() - private var _value: T? = null - - private var actionBeforeCreateView: ((Parameter) -> Unit)? = null - private var actionAfterCreateView: ((Parameter) -> Unit)? = null - - /** - * The value of this parameter. - */ - val value: T - get() = _value ?: default - - /** - * Set the new value to this parameter. - * - * @param value The new parameter value. - * @param notify Whether the observers must be notified of the change or not. - */ - fun setValue(value: T, notify: Boolean = true) { - this._value = value - - if (notify) { - notifyObservers() - } - } - - /** - * Resets the parameter value to the default value and removes any external value observers. - * - * @param notify Whether the observers should be notified about this change or not. - */ - fun reset(notify: Boolean = true) { - setValue(default, notify) - clearObservers() - } - - /** - * Adds the [Observer] instance to the list of observers. - * - * @param observer The observer to add. - * @return Whether the observer was added or not. - */ - fun observe(observer: Observer): Boolean { - return lock.withLock { - observers.add(observer) - } - } - - /** - * Removes the [Observer] instance from the list of observers. - * - * @param observer The observer to remove. - * @return Whether the observer was removed or not. - */ - fun removeObserver(observer: Observer): Boolean { - return lock.withLock { - observers.remove(observer) - } - } - - fun release() { - clearObservers() - - this.actionBeforeCreateView = null - this.actionAfterCreateView = null - this.beforeCreateViewInvoked.set(false) - } - - private fun clearObservers() { - lock.withLock { - observers.clear() - } - } - - /** - * Perform the given action before the view is created. - * - * @param action The action to execute. - * @see beforeCreateView - */ - fun doBeforeCreateView(action: (Parameter) -> Unit) { - this.actionBeforeCreateView = action - } - - /** - * Perform the given action after the view is created. - * - * @param action The action to execute. - * @see afterCreateView - */ - fun doAfterCreateView(action: (Parameter) -> Unit) { - this.actionBeforeCreateView = action - } - - private val beforeCreateViewInvoked = AtomicBoolean(false) - - /** - * Called before the layout for this widget is created. The action registered via - * [doBeforeCreateView] is invoked at most once per parameter instance — callers - * may pre-invoke this off the UI thread (e.g. before binding a RecyclerView) so - * that the bind-time call is a no-op and avoids triggering disk reads on the - * main thread. - */ - open fun beforeCreateView() { - if (!beforeCreateViewInvoked.compareAndSet(false, true)) { - return - } - this.actionBeforeCreateView?.invoke(this) - } - - /** - * Called after the layout for this widget is created. - */ - open fun afterCreateView() { - this.actionAfterCreateView?.invoke(this) - } - - private fun notifyObservers() { - lock.withLock { - observers.forEach { - if (it !is DefaultObserver || it.isEnabled) { - it.onChanged(this) - } - } - } - } - - /** - * An [Observer] observes changes to values of a [Parameter]. - */ - fun interface Observer { - - /** - * Called when the value of the parameter is changed. - * - * @param parameter The parameter that was changed (contains the new value). - */ - fun onChanged(parameter: Parameter) - } - - /** - * Default implementation of [Observer] which can enabled or disabled. - */ - abstract class DefaultObserver(var isEnabled: Boolean = true) : - Observer { - - /** - * Executes the given [action] with this observer disabled. - * - * @param action The action to perform. - */ - fun disableAndRun(action: () -> Unit) { - val enabled = isEnabled - isEnabled = false - action() - isEnabled = enabled - } - } + private val observers = hashSetOf>() + private val lock = ReentrantLock() + private var _value: T? = null + + private var actionBeforeCreateView: ((Parameter) -> Unit)? = null + private var actionAfterCreateView: ((Parameter) -> Unit)? = null + + /** + * The value of this parameter. + */ + val value: T + get() = _value ?: default + + /** + * Set the new value to this parameter. + * + * @param value The new parameter value. + * @param notify Whether the observers must be notified of the change or not. + */ + fun setValue(value: T, notify: Boolean = true) { + this._value = value + + if (notify) { + notifyObservers() + } + } + + /** + * Resets the parameter value to the default value and removes any external value observers. + * + * @param notify Whether the observers should be notified about this change or not. + */ + fun reset(notify: Boolean = true) { + setValue(default, notify) + clearObservers() + } + + /** + * Adds the [Observer] instance to the list of observers. + * + * @param observer The observer to add. + * @return Whether the observer was added or not. + */ + fun observe(observer: Observer): Boolean { + return lock.withLock { + observers.add(observer) + } + } + + /** + * Removes the [Observer] instance from the list of observers. + * + * @param observer The observer to remove. + * @return Whether the observer was removed or not. + */ + fun removeObserver(observer: Observer): Boolean { + return lock.withLock { + observers.remove(observer) + } + } + + fun release() { + clearObservers() + + this.actionBeforeCreateView = null + this.actionAfterCreateView = null + this.beforeCreateViewInvoked.set(false) + } + + private fun clearObservers() { + lock.withLock { + observers.clear() + } + } + + /** + * Perform the given action before the view is created. + * + * @param action The action to execute. + * @see beforeCreateView + */ + fun doBeforeCreateView(action: (Parameter) -> Unit) { + this.actionBeforeCreateView = action + } + + /** + * Perform the given action after the view is created. + * + * @param action The action to execute. + * @see afterCreateView + */ + fun doAfterCreateView(action: (Parameter) -> Unit) { + this.actionBeforeCreateView = action + } + + private val beforeCreateViewInvoked = AtomicBoolean(false) + + /** + * Called before the layout for this widget is created. The action registered via + * [doBeforeCreateView] is invoked at most once per parameter instance — callers + * may pre-invoke this off the UI thread (e.g. before binding a RecyclerView) so + * that the bind-time call is a no-op and avoids triggering disk reads on the + * main thread. + */ + open fun beforeCreateView() { + if (!beforeCreateViewInvoked.compareAndSet(false, true)) { + return + } + this.actionBeforeCreateView?.invoke(this) + } + + /** + * Called after the layout for this widget is created. + */ + open fun afterCreateView() { + this.actionAfterCreateView?.invoke(this) + } + + private fun notifyObservers() { + lock.withLock { + observers.forEach { + if (it !is DefaultObserver || it.isEnabled) { + it.onChanged(this) + } + } + } + } + + /** + * An [Observer] observes changes to values of a [Parameter]. + */ + fun interface Observer { + + /** + * Called when the value of the parameter is changed. + * + * @param parameter The parameter that was changed (contains the new value). + */ + fun onChanged(parameter: Parameter) + } + + /** + * Default implementation of [Observer] which can enabled or disabled. + */ + abstract class DefaultObserver(var isEnabled: Boolean = true) : + Observer { + + /** + * Executes the given [action] with this observer disabled. + * + * @param action The action to perform. + */ + fun disableAndRun(action: () -> Unit) { + val enabled = isEnabled + isEnabled = false + action() + isEnabled = enabled + } + } } abstract class ParameterBuilder { - @StringRes - var name: Int? = null + @StringRes + var name: Int? = null - @StringRes - var description: Int? = null - var default: T? = null - var tooltipTag: String? = null + @StringRes + var description: Int? = null + var default: T? = null + var tooltipTag: String? = null - var constraints: List = emptyList() + var constraints: List = emptyList() - var id: Int? = null - var nameStr: String? = null + var id: Int? = null + var nameStr: String? = null - protected open fun validate() { - val nameAll: Any? = if (name != null) name else nameStr - checkNotNull(nameAll) { "Parameter must have a name" } - checkNotNull(default) { "Parameter must have a default value" } - } + protected open fun validate() { + val nameAll: Any? = if (name != null) name else nameStr + checkNotNull(nameAll) { "Parameter must have a name" } + checkNotNull(default) { "Parameter must have a default value" } + } - abstract fun build(): Parameter + abstract fun build(): Parameter } class BooleanParameter( - @StringRes name: Int, @StringRes description: Int?, - default: Boolean, tooltipTag: String?, constraints: List, - id: Int? = null, nameStr: String? = null + @StringRes name: Int, @StringRes description: Int?, + default: Boolean, tooltipTag: String?, constraints: List, + id: Int? = null, nameStr: String? = null ) : Parameter(name, description, default, tooltipTag, constraints, id, nameStr) class BooleanParameterBuilder : ParameterBuilder() { - override fun build(): BooleanParameter { - return BooleanParameter(name!!, description, default!!, tooltipTag, constraints, id, nameStr) - } + override fun build(): BooleanParameter { + return BooleanParameter(name!!, description, default!!, tooltipTag, constraints, id, nameStr) + } } @@ -304,207 +304,209 @@ class BooleanParameterBuilder : ParameterBuilder() { * shown, allowing the user to empty the field in one tap. */ abstract class TextFieldParameter( - @StringRes name: Int, - @StringRes description: Int?, default: T, - val startIcon: ((TextFieldParameter) -> Int)?, - val endIcon: ((TextFieldParameter) -> Int)?, - val onStartIconClick: View.OnClickListener?, - val onEndIconClick: View.OnClickListener?, - val inputType: Int?, - @StyleableRes val imeOptions: Int?, - val maxLines: Int?, tooltipTag: String?, constraints: List, - id: Int?, nameStr: String?, - val showClearIcon: Boolean = false + @StringRes name: Int, + @StringRes description: Int?, default: T, + val startIcon: ((TextFieldParameter) -> Int)?, + val endIcon: ((TextFieldParameter) -> Int)?, + val onStartIconClick: View.OnClickListener?, + val onEndIconClick: View.OnClickListener?, + val inputType: Int?, + @StyleableRes val imeOptions: Int?, + val maxLines: Int?, tooltipTag: String?, constraints: List, + id: Int?, nameStr: String?, + val showClearIcon: Boolean = false ) : Parameter(name, description, default, tooltipTag, constraints, id, nameStr) abstract class TextFieldParameterBuilder( - var startIcon: ((TextFieldParameter) -> Int)? = null, - var endIcon: ((TextFieldParameter) -> Int)? = null, - var onStartIconClick: View.OnClickListener? = null, - var onEndIconClick: View.OnClickListener? = null, - var inputType: Int? = null, - var imeOptions: Int? = null, - var maxLines: Int? = null, - var showClearIcon: Boolean = false, + var startIcon: ((TextFieldParameter) -> Int)? = null, + var endIcon: ((TextFieldParameter) -> Int)? = null, + var onStartIconClick: View.OnClickListener? = null, + var onEndIconClick: View.OnClickListener? = null, + var inputType: Int? = null, + var imeOptions: Int? = null, + var maxLines: Int? = null, + var showClearIcon: Boolean = false, ) : ParameterBuilder() class StringParameter( - @StringRes name: Int, @StringRes description: Int?, - default: String, - startIcon: ((TextFieldParameter) -> Int)?, - endIcon: ((TextFieldParameter) -> Int)?, - onStartIconClick: View.OnClickListener?, - onEndIconClick: View.OnClickListener?, - inputType: Int? = null, - @StyleableRes imeOptions: Int? = null, - maxLines: Int? = null, - tooltipTag: String?, - constraints: List, - id: Int?, - nameStr: String?, - showClearIcon: Boolean = false + @StringRes name: Int, @StringRes description: Int?, + default: String, + startIcon: ((TextFieldParameter) -> Int)?, + endIcon: ((TextFieldParameter) -> Int)?, + onStartIconClick: View.OnClickListener?, + onEndIconClick: View.OnClickListener?, + inputType: Int? = null, + @StyleableRes imeOptions: Int? = null, + maxLines: Int? = null, + tooltipTag: String?, + constraints: List, + id: Int?, + nameStr: String?, + showClearIcon: Boolean = false ) : TextFieldParameter( - name, description, default, startIcon, endIcon, - onStartIconClick, onEndIconClick, inputType, imeOptions, maxLines, tooltipTag, constraints, - id, nameStr, showClearIcon + name, description, default, startIcon, endIcon, + onStartIconClick, onEndIconClick, inputType, imeOptions, maxLines, tooltipTag, constraints, + id, nameStr, showClearIcon ) class StringParameterBuilder : TextFieldParameterBuilder() { - override fun build(): StringParameter { - return StringParameter( - name = name!!, - description = description, - default = default!!, - startIcon = startIcon, - endIcon = endIcon, - onStartIconClick = onStartIconClick, - onEndIconClick = onEndIconClick, - inputType = inputType, - imeOptions = imeOptions, - maxLines = maxLines, - tooltipTag = tooltipTag, - constraints = constraints, - id = id, - nameStr = nameStr, - showClearIcon = showClearIcon - ) - } + override fun build(): StringParameter { + return StringParameter( + name = name!!, + description = description, + default = default!!, + startIcon = startIcon, + endIcon = endIcon, + onStartIconClick = onStartIconClick, + onEndIconClick = onEndIconClick, + inputType = inputType, + imeOptions = imeOptions, + maxLines = maxLines, + tooltipTag = tooltipTag, + constraints = constraints, + id = id, + nameStr = nameStr, + showClearIcon = showClearIcon + ) + } } class EnumParameter>( - @StringRes name: Int, - @StringRes description: Int?, default: T, - startIcon: ((TextFieldParameter) -> Int)?, - endIcon: ((TextFieldParameter) -> Int)?, - onStartIconClick: View.OnClickListener?, - onEndIconClick: View.OnClickListener?, - tooltipTag: String?, constraints: List, - val displayName: ((T) -> String)? = null, - val filter: ((T) -> Boolean)? = null, - id: Int? = null, nameStr: String? = null + @StringRes name: Int, + @StringRes description: Int?, default: T, + startIcon: ((TextFieldParameter) -> Int)?, + endIcon: ((TextFieldParameter) -> Int)?, + onStartIconClick: View.OnClickListener?, + onEndIconClick: View.OnClickListener?, + tooltipTag: String?, constraints: List, + val displayName: ((T) -> String)? = null, + val filter: ((T) -> Boolean)? = null, + id: Int? = null, nameStr: String? = null ) : TextFieldParameter( - name, description, default, startIcon, endIcon, onStartIconClick, - onEndIconClick, null, null, null, tooltipTag, constraints, - id, nameStr + name, description, default, startIcon, endIcon, onStartIconClick, + onEndIconClick, null, null, null, tooltipTag, constraints, + id, nameStr ) { - /** - * Get the display name for this [EnumParameter]. - */ - fun getDisplayName(): String? { - return this.displayName?.invoke(value) - } + /** + * Get the display name for this [EnumParameter]. + */ + fun getDisplayName(): String? { + return this.displayName?.invoke(value) + } } class EnumParameterBuilder> : TextFieldParameterBuilder() { - var displayName: ((T) -> String)? = null - var filter: ((T) -> Boolean)? = null - - override fun build(): EnumParameter { - return EnumParameter( - name = name!!, - description = description, - default = default!!, - startIcon = startIcon, - endIcon = endIcon, - onStartIconClick = onStartIconClick, - onEndIconClick = onEndIconClick, - tooltipTag = tooltipTag, - constraints = constraints, - displayName = displayName, - filter = filter - ) - } + var displayName: ((T) -> String)? = null + var filter: ((T) -> Boolean)? = null + + override fun build(): EnumParameter { + return EnumParameter( + name = name!!, + description = description, + default = default!!, + startIcon = startIcon, + endIcon = endIcon, + onStartIconClick = onStartIconClick, + onEndIconClick = onEndIconClick, + tooltipTag = tooltipTag, + constraints = constraints, + displayName = displayName, + filter = filter + ) + } } /** * Create a new [StringParameter] for accepting string input. */ inline fun stringParameter( - crossinline block: StringParameterBuilder.() -> Unit + crossinline block: StringParameterBuilder.() -> Unit ): StringParameter = StringParameterBuilder().apply(block).build() /** * Create a new [BooleanParameter] for accepting boolean input. */ inline fun booleanParameter( - crossinline block: BooleanParameterBuilder.() -> Unit + crossinline block: BooleanParameterBuilder.() -> Unit ): BooleanParameter = BooleanParameterBuilder().apply(block).build() inline fun > enumParameter( - crossinline block: EnumParameterBuilder.() -> Unit + crossinline block: EnumParameterBuilder.() -> Unit ): EnumParameter = EnumParameterBuilder().apply(block).build() inline fun projectNameParameter( - crossinline configure: StringParameterBuilder.() -> Unit = {} + crossinline configure: StringParameterBuilder.() -> Unit = {} ) = - stringParameter { - name = string.project_app_name - default = "My Application" - startIcon = { R.drawable.ic_android } - showClearIcon = true - constraints = listOf(NONEMPTY) - inputType = - android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS - imeOptions = android.view.inputmethod.EditorInfo.IME_ACTION_NEXT - maxLines = 1 - this.tooltipTag = "setup.app.name" - configure() - } + stringParameter { + name = string.project_app_name + default = "My Application" + startIcon = { R.drawable.ic_android } + showClearIcon = true + constraints = listOf(NONEMPTY) + inputType = + android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + imeOptions = android.view.inputmethod.EditorInfo.IME_ACTION_NEXT + maxLines = 1 + this.tooltipTag = "setup.app.name" + configure() + } inline fun packageNameParameter( - crossinline configure: StringParameterBuilder.() -> Unit = {} + crossinline configure: StringParameterBuilder.() -> Unit = {} ) = - stringParameter { - name = string.package_name - default = "com.example.myapplication" - startIcon = { R.drawable.ic_package } - constraints = listOf(NONEMPTY, PACKAGE) - inputType = - android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS - imeOptions = android.view.inputmethod.EditorInfo.IME_ACTION_NEXT - maxLines = 1 - this.tooltipTag = "setup.package.name" - configure() - } + stringParameter { + name = string.package_name + default = "com.example.myapplication" + startIcon = { R.drawable.ic_package } + constraints = listOf(NONEMPTY, PACKAGE) + inputType = + android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + imeOptions = android.view.inputmethod.EditorInfo.IME_ACTION_NEXT + maxLines = 1 + this.tooltipTag = "setup.package.name" + configure() + } inline fun projectLanguageParameter( - crossinline configure: EnumParameterBuilder.() -> Unit = {} + crossinline configure: EnumParameterBuilder.() -> Unit = {} ) = enumParameter { - name = string.wizard_language - default = Java - displayName = Language::lang - startIcon = { - if (it.value == Kotlin) { - R.drawable.ic_language_kotlin - } else { - R.drawable.ic_language_java - } - } - this.tooltipTag = "setup.project.language" - configure() + name = string.wizard_language + default = Java + displayName = Language::lang + startIcon = { + if (it.value == Kotlin) { + R.drawable.ic_language_kotlin + } else { + R.drawable.ic_language_java + } + } + this.tooltipTag = "setup.project.language" + configure() + val userFilter = filter + filter = { it != Language.Unknown && (userFilter == null || userFilter(it)) } } inline fun minSdkParameter( - crossinline configure: EnumParameterBuilder.() -> Unit = {} + crossinline configure: EnumParameterBuilder.() -> Unit = {} ) = - enumParameter { - name = string.minimum_sdk - default = Sdk.Lollipop - displayName = Sdk::displayName - startIcon = { R.drawable.ic_min_sdk } - this.tooltipTag = "setup.minimum.sdk" - configure() - } + enumParameter { + name = string.minimum_sdk + default = Sdk.Lollipop + displayName = Sdk::displayName + startIcon = { R.drawable.ic_min_sdk } + this.tooltipTag = "setup.minimum.sdk" + configure() + } inline fun useKtsParameter( - crossinline configure: BooleanParameterBuilder.() -> Unit = {} + crossinline configure: BooleanParameterBuilder.() -> Unit = {} ) = - booleanParameter { - name = string.msg_use_kts - default = true - this.tooltipTag = "setup.kotlin.script.language" - configure() - } \ No newline at end of file + booleanParameter { + name = string.msg_use_kts + default = true + this.tooltipTag = "setup.kotlin.script.language" + configure() + } diff --git a/templates-api/src/main/java/com/itsaky/androidide/templates/template.kt b/templates-api/src/main/java/com/itsaky/androidide/templates/template.kt index 4a631e51f3..5e1c4a1084 100644 --- a/templates-api/src/main/java/com/itsaky/androidide/templates/template.kt +++ b/templates-api/src/main/java/com/itsaky/androidide/templates/template.kt @@ -59,8 +59,8 @@ val data: D * Result of recipe execution for a [ProjectTemplate]. */ interface ProjectTemplateRecipeResult : TemplateRecipeResultWithData { - val hasErrorsWarnings: Boolean - get() = false + val hasErrorsWarnings: Boolean + get() = false } /** @@ -120,14 +120,6 @@ fun buildGradleFile(): File { } } -/** - * Language for source files. - */ -enum class Language(val lang: String, val ext: String) { - -Java("Java", "java"), Kotlin("Kotlin", "kt"); -} - /** * The type of module. * @@ -241,8 +233,8 @@ fun srcFolder(srcSet: SrcSet): File { * @property thumb The thumbnail for the template. */ open class Template(@StringRes open val templateName: Int, - @DrawableRes open val thumb: Int, open val tooltipTag: String?, open val widgets: List>, - open val recipe: TemplateRecipe, open val templateNameStr: String = "", open val thumbData: ByteArray? = null +@DrawableRes open val thumb: Int, open val tooltipTag: String?, open val widgets: List>, +open val recipe: TemplateRecipe, open val templateNameStr: String = "", open val thumbData: ByteArray? = null ) { /** @@ -348,7 +340,7 @@ fun build(): Template { requireNotNull(templateName) { "Template must have a name id" } requireNotNull(thumb) { "Template must have a thumbnail" } requireNotNull(recipe) { "Template must have a recipe" } - requireNotNull(templateNameStr) {"Template must have a name"} +requireNotNull(templateNameStr) {"Template must have a name"} this.widgets = this.widgets ?: emptyList() diff --git a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/TemplateProviderImpl.kt b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/TemplateProviderImpl.kt index 1acbe6a53d..2ba4aa2d76 100644 --- a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/TemplateProviderImpl.kt +++ b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/TemplateProviderImpl.kt @@ -24,10 +24,8 @@ import com.itsaky.androidide.templates.R import com.itsaky.androidide.templates.Template import com.itsaky.androidide.templates.impl.zip.ZipRecipeExecutor import com.itsaky.androidide.templates.impl.zip.ZipTemplateReader - -import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION import com.itsaky.androidide.utils.Environment.TEMPLATES_DIR - +import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION import org.slf4j.LoggerFactory import java.util.zip.ZipFile @@ -39,56 +37,55 @@ import java.util.zip.ZipFile @Suppress("unused") @AutoService(ITemplateProvider::class) class TemplateProviderImpl : ITemplateProvider { + companion object { + private val log = LoggerFactory.getLogger(TemplateProviderImpl::class.java) + } - companion object { - private val log = LoggerFactory.getLogger(TemplateProviderImpl::class.java) - } - - private val templates = mutableMapOf>() - val warnings: MutableList = mutableListOf() + private val templates = mutableMapOf>() + val warnings: MutableList = mutableListOf() - init { - reload() - } + init { + reload() + } - private fun initializeTemplates() { - val folder = TEMPLATES_DIR - val list = folder.listFiles { file -> file.extension == TEMPLATE_ARCHIVE_EXTENSION } ?: return + private fun initializeTemplates() { + val folder = TEMPLATES_DIR + val list = folder.listFiles { file -> file.extension.equals(TEMPLATE_ARCHIVE_EXTENSION, ignoreCase = true) } ?: return - for (zipFile in list) { - try { - val zipTemplates = ZipTemplateReader.read(zipFile, warnings) { json, params, path, data, defModule -> - ZipRecipeExecutor({ ZipFile(zipFile) }, json, params, path, data, defModule) - } + for (zipFile in list) { + try { + val zipTemplates = + ZipTemplateReader.read(zipFile, warnings) { json, params, path, data, defModule -> + ZipRecipeExecutor({ ZipFile(zipFile) }, json, params, path, data, defModule) + } - for (t in zipTemplates) { - templates[t.templateId] = t - } - } catch (e: Exception) { - warnings.add(TemplateWarning( - R.string.template_read_error_archive_load, - listOf(zipFile, e.message))) - log.error("Failed to load template from archive: $zipFile", e) - } - } - } + for (t in zipTemplates) { + templates[t.templateId] = t + } + } catch (e: Exception) { + warnings.add( + TemplateWarning( + R.string.template_read_error_archive_load, + listOf(zipFile, e.message), + ), + ) + log.error("Failed to load template from archive: $zipFile", e) + } + } + } - override fun getTemplates(): List> { - return ImmutableList.copyOf(templates.values) - } + override fun getTemplates(): List> = ImmutableList.copyOf(templates.values) - override fun getTemplate(templateId: String): Template<*>? { - return templates[templateId] - } + override fun getTemplate(templateId: String): Template<*>? = templates[templateId] - override fun reload() { - release() - warnings.clear() - initializeTemplates() - } + override fun reload() { + release() + warnings.clear() + initializeTemplates() + } - override fun release() { - templates.forEach { it.value.release() } - templates.clear() - } + override fun release() { + templates.forEach { it.value.release() } + templates.clear() + } } diff --git a/templates-impl/src/test/java/com/itsaky/androidide/templates/UtilTest.kt b/templates-impl/src/test/java/com/itsaky/androidide/templates/UtilTest.kt index 8f06ff909f..7d93debbcc 100644 --- a/templates-impl/src/test/java/com/itsaky/androidide/templates/UtilTest.kt +++ b/templates-impl/src/test/java/com/itsaky/androidide/templates/UtilTest.kt @@ -44,97 +44,135 @@ import java.io.File @RunWith(RobolectricTestRunner::class) @Config(application = BaseApplication::class) class UtilTest { - - @Test - fun `test module name conversion`() { - val tests = mapOf("2app" to "app", "app2" to "app2", "2app2" to "app2", - "2 app2" to "app2", "app name" to "app-name", "app name" to "app-name", - "app-name" to "app-name", "app--name" to "app-name", - "my_module" to "my_module") - - tests.forEach { (input, expected) -> - assertThat(moduleNameToDirName(input)).isEqualTo(expected) - } - } - - @Test - fun `test module name validation`() { - val tests = mapOf("2app" to false, "app2" to true, "2app2" to false, - "2 app2" to false, "app name" to false, "app name" to false, - "app-name" to true, "app--name" to false) - - tests.forEach { (name, result) -> - println("Check $name") - assertThat(isValidModuleName(":$name")).isEqualTo(result) - } - } - - @Test - fun `test constraint verifier`() { - ConstraintVerifier.apply { - - assertThat(isValid("", listOf(NONEMPTY))).isFalse() - assertThat(isValid("something", listOf(NONEMPTY))).isTrue() - - assertThat(isValid("activity_main", listOf(LAYOUT))).isTrue() - assertThat(isValid("2activity_main", listOf(LAYOUT))).isFalse() - - assertThat(isValid("2.invalid.package", listOf(PACKAGE))).isFalse() - assertThat(isValid("invalid", listOf(PACKAGE))).isFalse() - assertThat(isValid("2invalid.package", listOf(PACKAGE))).isFalse() - assertThat(isValid("invalid.package", listOf(PACKAGE))).isFalse() - assertThat(isValid("inval0d.PacKage", listOf(PACKAGE))).isFalse() - assertThat(isValid("com.itsaky.androidide", listOf(PACKAGE))).isTrue() - - assertThat(isValid("Class", listOf(CLASS))).isTrue() - assertThat(isValid("pck.name.Class", listOf(CLASS))).isTrue() - assertThat(isValid("pck.name.Class_Name", listOf(CLASS))).isTrue() - assertThat(isValid("pck.name.Class____Name", listOf(CLASS))).isTrue() - assertThat(isValid("p443ackage.Class_Name", listOf(CLASS))).isTrue() - assertThat(isValid("package.2Class", listOf(CLASS))).isFalse() - assertThat(isValid("package.Class", listOf(CLASS))).isFalse() - assertThat(isValid("package.name.Class", listOf(CLASS))).isFalse() - - assertThat(isValid("ClassName", listOf(CLASS_NAME))).isTrue() - assertThat(isValid("classname", listOf(CLASS_NAME))).isTrue() - assertThat(isValid("class_name", listOf(CLASS_NAME))).isTrue() - assertThat(isValid("class__name", listOf(CLASS_NAME))).isTrue() - assertThat(isValid("2class__name", listOf(CLASS_NAME))).isFalse() - assertThat(isValid("2class.name", listOf(CLASS_NAME))).isFalse() - - assertThat(isValid(":app", listOf(MODULE_NAME))).isTrue() - assertThat(isValid(":app-name", listOf(MODULE_NAME))).isTrue() - assertThat(isValid(":app_name", listOf(MODULE_NAME))).isTrue() - assertThat(isValid(":my_module_num_2", listOf(MODULE_NAME))).isTrue() - assertThat(isValid(":2app", listOf(MODULE_NAME))).isFalse() - assertThat(isValid("2app", listOf(MODULE_NAME))).isFalse() - assertThat(isValid(":_app", listOf(MODULE_NAME))).isFalse() - - assertThat(isValid("activity_main", listOf(LAYOUT))).isTrue() - assertThat(isValid("fragment__main", listOf(LAYOUT))).isTrue() - assertThat(isValid("layout_main", listOf(LAYOUT))).isTrue() - assertThat(isValid("Activity_Main", listOf(LAYOUT))).isFalse() - assertThat(isValid("ActivityMain", listOf(LAYOUT))).isFalse() - assertThat(isValid("2activity_main", listOf(LAYOUT))).isFalse() - assertThat(isValid("_activity_main", listOf(LAYOUT))).isFalse() - - val build = FileProvider.currentDir().resolve("build").toFile() - val file = File(build, "constraint_test_file.txt").also { it.writeText("Test file") } - val nonExisting = File(build, "non_existing_constraint_test_file.txt") - - assertThat(isValid(build.absolutePath, listOf(EXISTS))).isTrue() - assertThat(isValid(build.absolutePath, listOf(DIRECTORY))).isTrue() - assertThat(isValid(build.absolutePath, listOf(EXISTS, DIRECTORY))).isTrue() - assertThat(isValid(build.absolutePath, listOf(FILE))).isFalse() - - assertThat(isValid(file.absolutePath, listOf(EXISTS))).isTrue() - assertThat(isValid(file.absolutePath, listOf(FILE))).isTrue() - assertThat(isValid(file.absolutePath, listOf(EXISTS, FILE))).isTrue() - assertThat(isValid(file.absolutePath, listOf(DIRECTORY))).isFalse() - - assertThat(isValid(nonExisting.absolutePath, listOf(EXISTS))).isFalse() - assertThat(isValid(nonExisting.absolutePath, listOf(FILE))).isFalse() - assertThat(isValid(nonExisting.absolutePath, listOf(EXISTS, FILE))).isFalse() - } - } -} \ No newline at end of file + @Test + fun `test module name conversion`() { + val tests = + mapOf( + "2app" to "app", + "app2" to "app2", + "2app2" to "app2", + "2 app2" to "app2", + "app name" to "app-name", + "app name" to "app-name", + "app-name" to "app-name", + "app--name" to "app-name", + "my_module" to "my_module", + ) + + tests.forEach { (input, expected) -> + assertThat(moduleNameToDirName(input)).isEqualTo(expected) + } + } + + @Test + fun `test module name validation`() { + val tests = + mapOf( + "2app" to false, + "app2" to true, + "2app2" to false, + "2 app2" to false, + "app name" to false, + "app name" to false, + "app-name" to true, + "app--name" to false, + ) + + tests.forEach { (name, result) -> + println("Check $name") + assertThat(isValidModuleName(":$name")).isEqualTo(result) + } + } + + @Test + fun `test constraint verifier`() { + ConstraintVerifier.apply { + assertThat(isValid("", listOf(NONEMPTY))).isFalse() + assertThat(isValid("something", listOf(NONEMPTY))).isTrue() + + assertThat(isValid("activity_main", listOf(LAYOUT))).isTrue() + assertThat(isValid("2activity_main", listOf(LAYOUT))).isFalse() + + assertThat(isValid("2.invalid.package", listOf(PACKAGE))).isFalse() + assertThat(isValid("invalid", listOf(PACKAGE))).isFalse() + assertThat(isValid("2invalid.package", listOf(PACKAGE))).isFalse() + assertThat(isValid("invalid.package", listOf(PACKAGE))).isFalse() + assertThat(isValid("inval0d.PacKage", listOf(PACKAGE))).isFalse() + assertThat(isValid("com.itsaky.androidide", listOf(PACKAGE))).isTrue() + + assertThat(isValid("Class", listOf(CLASS))).isTrue() + assertThat(isValid("pck.name.Class", listOf(CLASS))).isTrue() + assertThat(isValid("pck.name.Class_Name", listOf(CLASS))).isTrue() + assertThat(isValid("pck.name.Class____Name", listOf(CLASS))).isTrue() + assertThat(isValid("p443ackage.Class_Name", listOf(CLASS))).isTrue() + assertThat(isValid("package.2Class", listOf(CLASS))).isFalse() + assertThat(isValid("package.Class", listOf(CLASS))).isFalse() + assertThat(isValid("package.name.Class", listOf(CLASS))).isFalse() + + assertThat(isValid("ClassName", listOf(CLASS_NAME))).isTrue() + assertThat(isValid("classname", listOf(CLASS_NAME))).isTrue() + assertThat(isValid("class_name", listOf(CLASS_NAME))).isTrue() + assertThat(isValid("class__name", listOf(CLASS_NAME))).isTrue() + assertThat(isValid("2class__name", listOf(CLASS_NAME))).isFalse() + assertThat(isValid("2class.name", listOf(CLASS_NAME))).isFalse() + + assertThat(isValid(":app", listOf(MODULE_NAME))).isTrue() + assertThat(isValid(":app-name", listOf(MODULE_NAME))).isTrue() + assertThat(isValid(":app_name", listOf(MODULE_NAME))).isTrue() + assertThat(isValid(":my_module_num_2", listOf(MODULE_NAME))).isTrue() + assertThat(isValid(":2app", listOf(MODULE_NAME))).isFalse() + assertThat(isValid("2app", listOf(MODULE_NAME))).isFalse() + assertThat(isValid(":_app", listOf(MODULE_NAME))).isFalse() + + assertThat(isValid("activity_main", listOf(LAYOUT))).isTrue() + assertThat(isValid("fragment__main", listOf(LAYOUT))).isTrue() + assertThat(isValid("layout_main", listOf(LAYOUT))).isTrue() + assertThat(isValid("Activity_Main", listOf(LAYOUT))).isFalse() + assertThat(isValid("ActivityMain", listOf(LAYOUT))).isFalse() + assertThat(isValid("2activity_main", listOf(LAYOUT))).isFalse() + assertThat(isValid("_activity_main", listOf(LAYOUT))).isFalse() + + val build = FileProvider.currentDir().resolve("build").toFile() + val file = File(build, "constraint_test_file.txt").also { it.writeText("Test file") } + val nonExisting = File(build, "non_existing_constraint_test_file.txt") + + assertThat(isValid(build.absolutePath, listOf(EXISTS))).isTrue() + assertThat(isValid(build.absolutePath, listOf(DIRECTORY))).isTrue() + assertThat(isValid(build.absolutePath, listOf(EXISTS, DIRECTORY))).isTrue() + assertThat(isValid(build.absolutePath, listOf(FILE))).isFalse() + + assertThat(isValid(file.absolutePath, listOf(EXISTS))).isTrue() + assertThat(isValid(file.absolutePath, listOf(FILE))).isTrue() + assertThat(isValid(file.absolutePath, listOf(EXISTS, FILE))).isTrue() + assertThat(isValid(file.absolutePath, listOf(DIRECTORY))).isFalse() + + assertThat(isValid(nonExisting.absolutePath, listOf(EXISTS))).isFalse() + assertThat(isValid(nonExisting.absolutePath, listOf(FILE))).isFalse() + assertThat(isValid(nonExisting.absolutePath, listOf(EXISTS, FILE))).isFalse() + } + } + + @Test + fun `test projectLanguageParameter excludes Language Unknown by default and with custom filter`() { + val defaultParam = projectLanguageParameter() + assertThat(defaultParam.filter?.invoke(Language.Java)).isTrue() + assertThat(defaultParam.filter?.invoke(Language.Kotlin)).isTrue() + assertThat(defaultParam.filter?.invoke(Language.Unknown)).isFalse() + + val customParam = + projectLanguageParameter { + filter = { it == Language.Java } + } + assertThat(customParam.filter?.invoke(Language.Java)).isTrue() + assertThat(customParam.filter?.invoke(Language.Kotlin)).isFalse() + assertThat(customParam.filter?.invoke(Language.Unknown)).isFalse() + + val permissiveParam = + projectLanguageParameter { + filter = { true } + } + assertThat(permissiveParam.filter?.invoke(Language.Java)).isTrue() + assertThat(permissiveParam.filter?.invoke(Language.Kotlin)).isTrue() + assertThat(permissiveParam.filter?.invoke(Language.Unknown)).isFalse() + } +} diff --git a/testing/resources/test-project/.cg/gradle-sync/project.pb b/testing/resources/test-project/.cg/gradle-sync/project.pb deleted file mode 100644 index 7828f6edd2..0000000000 Binary files a/testing/resources/test-project/.cg/gradle-sync/project.pb and /dev/null differ diff --git a/testing/resources/test-project/.cg/gradle-sync/sync.lock b/testing/resources/test-project/.cg/gradle-sync/sync.lock deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/testing/resources/test-project/.cg/gradle-sync/sync.pb b/testing/resources/test-project/.cg/gradle-sync/sync.pb deleted file mode 100644 index eeaa33575a..0000000000 --- a/testing/resources/test-project/.cg/gradle-sync/sync.pb +++ /dev/null @@ -1,14 +0,0 @@ - -1E/Users/john/Documents/cogo/CodeOnTheGo/testing/resources/test-project 1782139760767" -app/build.gradleV/Users/john/Documents/cogo/CodeOnTheGo/testing/resources/test-project/app/build.gradle Ժ3*@6bc0acd25b3856d9902a22dd39af0967fae7c1fd23687d76c65bbe975df4810d" -java-library/build.gradle_/Users/john/Documents/cogo/CodeOnTheGo/testing/resources/test-project/java-library/build.gradle ټՄ3*@d01681ec8f736d858ef64789286b4c2c7f738427a3bcdd76e35ae4680081df1b" --java-library/nested-java-library/build.gradles/Users/john/Documents/cogo/CodeOnTheGo/testing/resources/test-project/java-library/nested-java-library/build.gradle ټՄ3*@85c6cbc9d6fd247f630c416af4dcd78d376cb6b71d481adb893ea395fa9bb747" -!another-java-library/build.gradleg/Users/john/Documents/cogo/CodeOnTheGo/testing/resources/test-project/another-java-library/build.gradle Մ3*@85b6448ea59f0a7d7ccd65480d3e39b8c74452b5b7bbdff11fedcba22ee220f3" -5another-java-library/nested-java-library/build.gradle{/Users/john/Documents/cogo/CodeOnTheGo/testing/resources/test-project/another-java-library/nested-java-library/build.gradle Մ3*@85c6cbc9d6fd247f630c416af4dcd78d376cb6b71d481adb893ea395fa9bb747" -other-java-library/build.gradlee/Users/john/Documents/cogo/CodeOnTheGo/testing/resources/test-project/other-java-library/build.gradle ټՄ3*@c25eded2af3131d1d62a75b9c5c09ca063781b191c0c05957b86aa6d5956b55a" - build.gradleR/Users/john/Documents/cogo/CodeOnTheGo/testing/resources/test-project/build.gradle Ժ3*@aefd03f7322bcde9d5916aa5595b15dde3aa5426fcee485ade8016f2790f2b76" -gradle.propertiesW/Users/john/Documents/cogo/CodeOnTheGo/testing/resources/test-project/gradle.propertiesR ؼՄ3*@274d1aac5a469b1d085614b75e38d439237f5f1defd6ad59a8f95d58286c95b1" -settings.gradleU/Users/john/Documents/cogo/CodeOnTheGo/testing/resources/test-project/settings.gradle ڼՄ3*@117d4a4a030028d040f47e37cbe9035ae8c5d647d8eaae87d7b8ae5299c769f1" -$another-android-library/build.gradlej/Users/john/Documents/cogo/CodeOnTheGo/testing/resources/test-project/another-android-library/build.gradle 3*@5402147ec86ff784e20993dcd6e3c3ea44d12ce19080074fe2226f527a888073" -android-library/build.gradleb/Users/john/Documents/cogo/CodeOnTheGo/testing/resources/test-project/android-library/build.gradle 3*@dfccb9e9718ecb268ea4ab1cf32038d9403f334cd942d08abf7081596c69787b* -`/Users/john/Documents/cogo/CodeOnTheGo/testing/resources/test-project/.cg/gradle-sync/project.pb@cdf55c953c74b1af640b6adbce9775f7109d3f053c5be99f4b4fc8c337637b5c \ No newline at end of file