From 9f39d705a68f335c3a7f2867f66f6a8d8ec90ffb Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Mon, 10 Aug 2026 14:48:06 +0100 Subject: [PATCH 01/40] ADFA-2602: Move the on-device toolchain to Gradle 9.6.1, AGP 9.3.1, Kotlin 2.3.21 Collapses the duplicate Kotlin compilers the device was shipping. Gradle 9.6.1 embeds Kotlin 2.3.21, and AGP 9's built-in Kotlin resolves the same 2.3.21 compiler, so the build-script compiler and the app compiler are one artifact instead of two at different versions. Version choice is constrained from both ends and is not free: - AGP 9.3.1 requires Gradle 9.5.0+ - AGP 8.x fails on Gradle 9.6.0+ (it uses InternalProblems, removed there) - Gradle's embedded Kotlin is fixed per version: 9.4.1 -> 2.3.0, 9.5.1 -> 2.3.20, 9.6.1 -> 2.3.21, 9.7.0 -> 2.4.0 Bumping agp-tooling to match the device forces a model migration, so it lands here rather than separately -- splitting it would leave a commit that does not compile. AGP 9 removed PrivacySandboxSdkInfo and AndroidProject.PROPERTY_ANDROID_SUPPORT_VERSION (value inlined as "android.injected.studio.version"), and added mappingR8TextFile, mappingR8PartitionFile, keepRulesDirectories and aarKeepRulesDirectories. app/build.gradle.kts now derives the bundled asset filenames from the version constants instead of repeating "8.14.3" in six string literals. Note the asset rename: builds resolve gradle-9.6.1-bin.zip, which must be published to dev-assets before this lands or local assetsDownloadDebug returns 404. --- app/build.gradle.kts | 31 ++--- .../main/java/org/adfa/constants/constants.kt | 6 +- gradle/libs.versions.toml | 2 +- plugin-api/plugin-builder/build.gradle.kts | 4 +- .../build/gradle/options/StringOption.kt | 3 +- .../builder/model/DefaultAndroidArtifact.kt | 111 +++++++++--------- .../builder/model/DefaultSourceProvider.kt | 93 ++++++++------- .../builder/model/DefaultVariant.kt | 93 ++++++++------- .../model/DefaultVariantDependencies.kt | 79 +++++++------ 9 files changed, 221 insertions(+), 201 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2f4fdf7ddc..b07a33ffce 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,9 @@ 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.json.JSONObject import java.io.BufferedOutputStream import java.io.ByteArrayInputStream @@ -552,8 +555,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 +1200,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 +1228,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/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..9164ad6db0 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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9c4e15649b..2386258e01 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" diff --git a/plugin-api/plugin-builder/build.gradle.kts b/plugin-api/plugin-builder/build.gradle.kts index f620f80344..ab56406fa5 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 + // and on-device plugin builds use the tooling AGP (`agp-tooling` = 9.3.1), 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("com.android.tools.build:gradle:9.3.1") } gradlePlugin { 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() +} From 26ec89808054a2e1bd2e73e5e10d290fb5561b6a Mon Sep 17 00:00:00 2001 From: itsaky-adfa Date: Tue, 11 Aug 2026 14:27:07 +0530 Subject: [PATCH 02/40] feat(ADFA-4824): Find usages in the Kotlin K2 LSP (#1624) --- .../androidide/lsp/IDELanguageClientImpl.java | 110 ++-- .../androidide/lsp/SearchResultGrouping.kt | 146 +++++ .../lsp/SearchResultGroupingTest.kt | 154 +++++ ...10-navigation-resolves-via-analysis-api.md | 2 + docs/adr/0011-command-analysis-priority.md | 65 ++ docs/adr/README.md | 1 + docs/features/kotlin-find-usages.md | 278 ++++++++ docs/features/kotlin-goto-definition.md | 4 +- .../androidide/idetooltips/TooltipTag.kt | 1 + .../lsp/kotlin/KotlinCodeActionsMenu.kt | 2 + .../lsp/kotlin/KotlinLanguageServer.kt | 7 +- .../kotlin/actions/FindReferencesAction.kt | 50 ++ .../kotlin/actions/ImplementMembersAction.kt | 50 +- .../kotlin/actions/OrganizeImportsAction.kt | 38 +- .../compiler/modules/AnalysisScheduler.kt | 58 +- .../services/ModuleDependentsProvider.kt | 59 +- .../lsp/kotlin/navigation/FindUsages.kt | 604 ++++++++++++++++++ .../lsp/kotlin/navigation/GoToDefinition.kt | 75 +-- .../lsp/kotlin/navigation/ReferenceAtCaret.kt | 7 +- .../lsp/kotlin/navigation/TargetAtCaret.kt | 86 +++ .../kotlin/KotlinCodeActionTooltipTagTest.kt | 2 + .../modules/AnalysisSerializationTest.kt | 171 +++++ .../navigation/FindUsagesLiveDocumentTest.kt | 93 +++ .../lsp/kotlin/navigation/FindUsagesTest.kt | 326 ++++++++++ .../kotlin/navigation/TargetAtCaretTest.kt | 167 +++++ .../utils/ImplementMembersEndToEndTest.kt | 3 +- .../utils/OrganizeImportsEndToEndTest.kt | 9 +- 27 files changed, 2413 insertions(+), 155 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/lsp/SearchResultGrouping.kt create mode 100644 app/src/test/java/com/itsaky/androidide/lsp/SearchResultGroupingTest.kt create mode 100644 docs/adr/0011-command-analysis-priority.md create mode 100644 docs/features/kotlin-find-usages.md create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/FindReferencesAction.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/navigation/FindUsages.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/navigation/TargetAtCaret.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/navigation/FindUsagesLiveDocumentTest.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/navigation/FindUsagesTest.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/navigation/TargetAtCaretTest.kt 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/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/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/README.md b/docs/adr/README.md index 9bb6db0c4a..7139d240d5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -24,3 +24,4 @@ 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 | 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/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt index d8e71fd5dd..02a0571d1f 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -92,6 +92,7 @@ object TooltipTag { const val EDITOR_CODE_ACTIONS_KT_NULL_SAFETY_FIX = "editor.codeactions.kotlin.nullsafetyfix" 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 EXIT_TO_MAIN = "exit.to.main" 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..1188a15022 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 @@ -6,6 +6,7 @@ import com.itsaky.androidide.lsp.actions.CommentLineAction import com.itsaky.androidide.lsp.actions.IActionsMenuProvider import com.itsaky.androidide.lsp.actions.UncommentLineAction import com.itsaky.androidide.lsp.kotlin.actions.AddImportAction +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 @@ -32,6 +33,7 @@ object KotlinCodeActionsMenu : IActionsMenuProvider { TooltipTag.EDITOR_CODE_ACTIONS_KT_UNCOMMENT, ), GoToDefinitionAction(), + FindReferencesAction(), AddImportAction(), OrganizeImportsAction(), SurroundWithTryCatchAction(), 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/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/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/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/test/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionTooltipTagTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionTooltipTagTest.kt index 117ce7ef9e..352e81eaec 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 @@ -5,6 +5,7 @@ import com.itsaky.androidide.lsp.actions.CommentLineAction 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.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 @@ -34,6 +35,7 @@ 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, 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/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()) } } From ad23205e6c157c670fcfc1de081a519049d6377f Mon Sep 17 00:00:00 2001 From: Daniel-ADFA Date: Tue, 11 Aug 2026 11:26:51 +0100 Subject: [PATCH 03/40] ADFA-4942: Gate GlitchTip and Firebase analytics behind an onboarding opt-out consent (#1617) * ADFA-4942: Gate GlitchTip and Firebase analytics behind an onboarding opt-out consent * Fix spotless check * Fixes from PR review * fix strict mode violations on analytics manager --------- Co-authored-by: Daniel Alome --- .../helper/HandlePrivacyDisclosureHelper.kt | 37 +-- app/src/main/AndroidManifest.xml | 11 + .../androidide/analytics/AnalyticsManager.kt | 7 +- .../app/DeviceProtectedApplicationLoader.kt | 82 +++++-- .../onboarding/PermissionsFragment.kt | 217 +++++++++++------- .../analytics/AnalyticsManagerConsentTest.kt | 70 ++++++ .../app/TelemetryConsentMigrationTest.kt | 57 +++++ .../preferences/StatPreferencesTest.kt | 50 ++++ .../preferences/internal/StatPreferences.kt | 68 +++--- resources/src/main/res/values/strings.xml | 5 +- 10 files changed, 454 insertions(+), 150 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/analytics/AnalyticsManagerConsentTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/app/TelemetryConsentMigrationTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/preferences/StatPreferencesTest.kt 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..ceded7d292 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,36 +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 declineText = targetContext.getString(ResourcesR.string.privacy_disclosure_decline) val learnMoreText = targetContext.getString(ResourcesR.string.privacy_disclosure_learn_more) @@ -51,6 +48,10 @@ fun TestContext.handlePrivacyDisclosure() { .waitForExists(PRIVACY_DIALOG_APPEAR_TIMEOUT_MS), ) assertTrue("Accept button missing", d.findObject(UiSelector().text(acceptText)).exists()) + assertTrue( + "Keep offline button missing", + d.findObject(UiSelector().text(declineText)).exists(), + ) assertTrue( "Learn more button missing", d.findObject(UiSelector().text(learnMoreText)).exists(), @@ -60,16 +61,16 @@ fun TestContext.handlePrivacyDisclosure() { 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..cf216f8b6c 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -158,6 +158,17 @@ + + + + + when (event) { - is InstallationEvent.ShowError -> activity?.flashError(event.message) + is InstallationEvent.ShowError -> { + activity?.flashError(event.message) + } + is InstallationEvent.InstallationResultEvent -> {} } } @@ -189,18 +204,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 +227,9 @@ class PermissionsFragment : override fun onDestroyView() { super.onDestroyView() + privacyDialog?.dismiss() + privacyDialog = null + consentResolutionJob = null permissionsBinding = null recyclerView = null finishButton = null @@ -228,20 +250,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 +283,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 +303,14 @@ class PermissionsFragment : } true } - is InstallationState.InstallationError -> true - else -> false + + is InstallationState.InstallationError -> { + true + } + + else -> { + false + } } } } finally { @@ -293,34 +322,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.SYSTEM_ALERT_WINDOW -> requestOverlayPermission() - Manifest.permission.POST_NOTIFICATIONS -> + 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) + } - 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 -> {} - } - } + PermissionsHelper.OverlayPermissionState.GRANTED -> {} + } + } private fun requestStoragePermission() { if (isAtLeastR()) { @@ -367,36 +406,50 @@ 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() - } - .setCancelable(false) - .show() - } + private fun showPrivacyDialogIfNeeded() { + if (privacyDialog != null || consentResolutionJob?.isActive == true) { + return + } - private fun isPrivacyDisclosureShown(): Boolean { - return prefManager.getBoolean(KEY_PRIVACY_DISCLOSURE_SHOWN, false) + val scope = viewLifecycleScopeOrNull ?: return + consentResolutionJob = + scope.launch { + val consent = withContext(Dispatchers.IO) { StatPreferences.telemetryConsent } + if (consent == TelemetryConsent.UNSET && privacyDialog == null) { + showPrivacyDialog() + } + } } - private fun markPrivacyDisclosureAsShown() { - prefManager.putBoolean(KEY_PRIVACY_DISCLOSURE_SHOWN, true) + private fun showPrivacyDialog() { + privacyDialog = + 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, _ -> + StatPreferences.telemetryConsent = TelemetryConsent.GRANTED + DeviceProtectedApplicationLoader.onTelemetryConsentGranted(IDEApplication.instance) + dialog.dismiss() + }.setNegativeButton(com.itsaky.androidide.resources.R.string.privacy_disclosure_decline) { dialog, _ -> + StatPreferences.telemetryConsent = TelemetryConsent.DECLINED + Sentry.close() + dialog.dismiss() + }.setNeutralButton(com.itsaky.androidide.resources.R.string.privacy_disclosure_learn_more, null) + .setCancelable(false) + .show() + .also { dialog -> + dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener { + openPrivacyPolicy() + } + } } private fun openPrivacyPolicy() { @@ -409,15 +462,15 @@ class PermissionsFragment : } } - private fun enableFinishButton() { - finishButton?.isEnabled = true - if (!isTestMode()) { - finishButton?.startAnimation(pulseAnimation) - } - } - - private fun disableFinishButton() { - finishButton?.isEnabled = false - finishButton?.clearAnimation() - } + private fun enableFinishButton() { + finishButton?.isEnabled = true + if (!isTestMode()) { + finishButton?.startAnimation(pulseAnimation) + } + } + + private fun disableFinishButton() { + finishButton?.isEnabled = false + finishButton?.clearAnimation() + } } 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/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/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/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/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 7ff3bc7f18..64f3a3e41b 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -664,8 +664,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 + 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.\n\nChoose whether to share this anonymous data. If you choose Keep offline, analytics and crash reports are never sent. + Share anonymous data + Keep offline Learn more Unique ID From a1700fa37d560f4fb6b8998749e190cd2d4624f2 Mon Sep 17 00:00:00 2001 From: Dara Abijo Date: Tue, 11 Aug 2026 13:29:05 +0100 Subject: [PATCH 04/40] fix(ADFA-4892): Refresh search on re-opening search bar (#1640) --- .../com/itsaky/androidide/editor/ui/EditorSearchLayout.kt | 5 +++++ 1 file changed, 5 insertions(+) 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 } From 001990357b2da110e9d2e97b614733ba84565379 Mon Sep 17 00:00:00 2001 From: davidschachterADFA Date: Tue, 11 Aug 2026 06:40:09 -0700 Subject: [PATCH 05/40] ADFA-5035: Fix WebServer occasionally failing to start with EADDRINUSE (#1634) * ADFA-5035: Fix WebServer occasionally failing to start with EADDRINUSE start() binds serverSocket on a background thread (launched from MainActivity.startWebServer()); stop() (called from onDestroy(), main thread) only closes serverSocket if it's already initialized. If stop() runs before start() reaches bind(), it's a silent no-op -- start() then binds anyway a moment later, orphaned, holding the port until the process dies. The next start() attempt on that port fails with "Address already in use." Synchronize start()'s bind and stop()'s close on a shared lock, and have stop() record that a stop was requested so start() can abort before binding if one arrived first. Closes the race window instead of relying on timing. Also renamed HTTP_INTERNAL_SERVER_ERROR/HTTP_NOT_FOUND to camelCase (pre-existing ktlint property-naming violations, unrelated to this fix but required once this file falls under the Spotless ratchet). * ADFA-5035: Apply Spotless ratchet reformat to WebServer.kt WebServer.kt was space-indented and had several pre-existing ktlint violations (max-line-length, snake_case sql_query). Touching the file in the previous commit pulled it under the Spotless ratchet, so bring it into compliance: tabs, wrapped long lines/comments, and sql_query -> sqlQuery. No behavioral change. * ADFA-5035: Address code review findings - Close database in start()'s finally alongside serverSocket. It was opened before the stopRequested check that can now abort start() early, and was never closed on any other shutdown path either (normal accept-loop exit, exception) -- isInitialized guards the case where opening it failed and this finally still runs. - Correct stop()'s doc comment: it's no longer a full no-op before start() binds -- it still records the stop request so start() can abort before binding, which is the fix itself. Only the socket-close side stays a no-op in that case. (Reverting the behavior back to a literal no-op, as literally suggested, would reopen the exact EADDRINUSE race this ticket fixes.) - Add WebServerTest: deterministic coverage for both lifecycle orderings (stop-before-start aborts the bind; start-then-stop frees the port for reuse), synchronized via the port's own bind/connect behavior rather than fixed sleeps. Skipped: the outputStarted-timing suggestion for realHandleBsEndpoint/ realHandlePrEndpoint is the same CodeRabbit finding already considered and explicitly rejected in an existing code comment ("I disagree... --DS, 23-Feb-2026"); out of scope to unilaterally revisit here. * ADFA-5035: Fix outputStarted timing in handleBsEndpoint/handlePrEndpoint outputStarted was only set after realHandleBsEndpoint/realHandlePrEndpoint returned, so if writeNormalToClient threw partway through (e.g. after the status line but mid-body), the catch block still saw outputStarted=false and sent a second, well-formed response on top of the already-partially-written one. Pass a markOutputStarted callback into both functions and invoke it right before the first write, so the caller's flag reflects reality even when the write itself then fails. Removes the "I disagree with CodeRabbit's message" comment that had left this finding unaddressed. --- .../androidide/localWebServer/WebServer.kt | 2115 +++++++++-------- .../localWebServer/WebServerTest.kt | 124 + 2 files changed, 1231 insertions(+), 1008 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/localWebServer/WebServerTest.kt 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..0b76b64d2d 100644 --- a/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt +++ b/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt @@ -2,8 +2,18 @@ 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.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 @@ -11,7 +21,6 @@ import java.io.File import java.io.InputStream import java.io.PrintWriter import java.io.StringWriter -import android.net.TrafficStats import java.net.InetSocketAddress import java.net.ServerSocket import java.net.Socket @@ -19,781 +28,831 @@ import java.net.URLDecoder import java.sql.Date import java.text.SimpleDateFormat 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 = +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 + 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 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 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) + } + } + + /** + * 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, + ) + + 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() + + 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) + + 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, httpNotFound, "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, httpNotFound, "Not Found") + } else { + sendError( + writer, + output, + httpInternalServerError, + "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, 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 (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, + 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 +863,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 +878,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 +918,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/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..ef1e18de8f --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/localWebServer/WebServerTest.kt @@ -0,0 +1,124 @@ +package com.itsaky.androidide.localWebServer + +import android.database.sqlite.SQLiteDatabase +import android.net.TrafficStats +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +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", + ) + + 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) + } + + // 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") + } +} From 64d92229f29c8496b2b3edde5cae74f4a0d93759 Mon Sep 17 00:00:00 2001 From: Dara Abijo Date: Tue, 11 Aug 2026 19:40:02 +0100 Subject: [PATCH 06/40] ADFA-4692: Fix unknown project language (#1650) * feat(ADFA-4692): Detect project language dynamically * test(ADFA-4692): Test project language detection * fix(ADFA-4692): Avoid localized strings for unknown project language * feat(ADFA-4692): Preserve coroutine cancellation * test(ADFA-4692): Add missing test cases --- .../androidide/activities/MainActivity.kt | 141 +++-- .../recentproject/RecentProjectDao.kt | 53 +- .../androidide/ui/ProjectInfoBottomSheet.kt | 324 ++++++----- .../androidide/viewmodel/MainViewModel.kt | 171 +++--- .../viewmodel/RecentProjectsViewModel.kt | 524 +++++++++--------- .../itsaky/androidide/templates/Language.kt | 30 + .../utils/GetProjectBuildVersions.kt | 193 ++++--- .../androidide/utils/GetProjectDetails.kt | 58 +- .../utils/GetProjectBuildVersionsTest.kt | 176 ++++++ .../itsaky/androidide/templates/template.kt | 18 +- 10 files changed, 1011 insertions(+), 677 deletions(-) create mode 100644 common/src/main/java/com/itsaky/androidide/templates/Language.kt create mode 100644 common/src/test/java/com/itsaky/androidide/utils/GetProjectBuildVersionsTest.kt 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/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/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/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/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/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/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/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() From ffb63ddac0752b8aae0935e5c4f51a761dfe490d Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 11 Aug 2026 15:29:33 -0700 Subject: [PATCH 07/40] ADFA-5097: Clean up telemetry choice screen (#1658) * ADFA-5097: Clean up telemetry choice screen The consent dialog was a stock MaterialAlertDialog built from setTitle + setMessage + three buttons. Every complaint in the ticket was that widget's behavior once the message overflowed at large font scale: Material draws scroll-indicator dividers around the message pane, the borderless text buttons read as plain text, and the message scrolls while the button bar stays fixed. On the reported device the button bar was pushed off-screen entirely, leaving "Keep offline" half-cut and "Learn more" invisible - the decline option was unreachable on a non-cancelable dialog. Styling could not fix that, so the dialog now takes a custom view holding the body and both choices in one NestedScrollView: - No dividers. Material only draws them around the message pane, and there is no longer a message. - Choices are real filled/outlined MaterialButtons, full width, stacked. - Body and choices scroll together, and the scrollbar is non-fading so scrollability is visible before the user touches anything. - The choice panel sits on colorSurfaceVariant to set it off from the body. Not a surfaceContainer role: this app's themes define colorSurfaceVariant but leave the container roles to the Material3 defaults, and a Material3 dialog is itself colorSurfaceContainerHigh, so such a panel would be invisible. Copy condensed to two paragraphs, dropping the Firebase/GlitchTip names and the privacy-policy paragraph. At font scale 2.0 it now fits without scrolling at all. "Learn more" is removed. It opened an off-device PDF via ACTION_VIEW - exactly what the app tries to avoid - and the content is legal boilerplate already on the website. privacy_policy_url had no other consumer and is deleted too. Also retranslates the zh-rCN and in-rID strings, which were not merely stale: both were missing privacy_disclosure_decline entirely and rendered "accept" as "I understand" / "Saya mengerti", so those users saw a one-button dialog whose button said something the English never said. Verified on an arm64 emulator at font scale 1.0 and 2.0, plus 3.0 to force overflow and confirm the scrollbar appears and both buttons scroll into reach. Light and dark themes checked; accept persists GRANTED and decline persists DECLINED. * ADFA-5097: Italicize the choice names in the consent body Paragraph 2 names the two choices; setting them in italics makes them stand out and hints at the buttons below. Inline markup in the string resource. The layout binds via android:text, so TextView picks up the style spans directly - getString() would have stripped them, but nothing here calls it. Not applied to zh-rCN: synthetic obliquing of Han characters renders poorly, and that translation already sets the two choice names off with the conventional CJK quotation marks. --- .../helper/HandlePrivacyDisclosureHelper.kt | 6 --- .../onboarding/PermissionsFragment.kt | 54 +++++++++---------- .../layout/layout_dialog_privacy_consent.xml | 54 +++++++++++++++++++ .../src/main/res/values-in-rID/strings.xml | 6 +-- .../src/main/res/values-zh-rCN/strings.xml | 7 ++- resources/src/main/res/values/strings.xml | 4 +- 6 files changed, 87 insertions(+), 44 deletions(-) create mode 100644 app/src/main/res/layout/layout_dialog_privacy_consent.xml 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 ceded7d292..9a766b8765 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/helper/HandlePrivacyDisclosureHelper.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/HandlePrivacyDisclosureHelper.kt @@ -38,8 +38,6 @@ fun TestContext.handlePrivacyDisclosure() { val d = device.uiDevice val acceptText = targetContext.getString(ResourcesR.string.privacy_disclosure_accept) val declineText = targetContext.getString(ResourcesR.string.privacy_disclosure_decline) - val learnMoreText = - targetContext.getString(ResourcesR.string.privacy_disclosure_learn_more) assertTrue( "Dialog title missing", @@ -52,10 +50,6 @@ fun TestContext.handlePrivacyDisclosure() { "Keep offline button missing", d.findObject(UiSelector().text(declineText)).exists(), ) - assertTrue( - "Learn more button missing", - d.findObject(UiSelector().text(learnMoreText)).exists(), - ) clickFirstAccessibilityNodeByText(acceptText) d.waitForIdle() 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 13b8ba7b12..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,13 +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.appcompat.app.AlertDialog -import androidx.core.net.toUri import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.repeatOnLifecycle @@ -38,18 +38,19 @@ 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.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 @@ -430,36 +431,33 @@ class PermissionsFragment : } private fun showPrivacyDialog() { - privacyDialog = - MaterialAlertDialogBuilder(requireContext()) + 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) - .setMessage(com.itsaky.androidide.resources.R.string.privacy_disclosure_message) - .setPositiveButton(com.itsaky.androidide.resources.R.string.privacy_disclosure_accept) { dialog, _ -> - StatPreferences.telemetryConsent = TelemetryConsent.GRANTED - DeviceProtectedApplicationLoader.onTelemetryConsentGranted(IDEApplication.instance) - dialog.dismiss() - }.setNegativeButton(com.itsaky.androidide.resources.R.string.privacy_disclosure_decline) { dialog, _ -> - StatPreferences.telemetryConsent = TelemetryConsent.DECLINED - Sentry.close() - dialog.dismiss() - }.setNeutralButton(com.itsaky.androidide.resources.R.string.privacy_disclosure_learn_more, null) + .setView(binding.root) .setCancelable(false) - .show() - .also { dialog -> - dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener { - openPrivacyPolicy() - } - } - } + .create() + + binding.privacyAccept.setOnClickListener { + StatPreferences.telemetryConsent = TelemetryConsent.GRANTED + DeviceProtectedApplicationLoader.onTelemetryConsentGranted(IDEApplication.instance) + dialog.dismiss() + } - 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) + binding.privacyDecline.setOnClickListener { + StatPreferences.telemetryConsent = TelemetryConsent.DECLINED + Sentry.close() + dialog.dismiss() } + + privacyDialog = dialog + dialog.show() } private fun enableFinishButton() { 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/resources/src/main/res/values-in-rID/strings.xml b/resources/src/main/res/values-in-rID/strings.xml index 11f7f5a5ba..5a4bd3e069 100644 --- a/resources/src/main/res/values-in-rID/strings.xml +++ b/resources/src/main/res/values-in-rID/strings.xml @@ -599,9 +599,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 diff --git a/resources/src/main/res/values-zh-rCN/strings.xml b/resources/src/main/res/values-zh-rCN/strings.xml index 364e73a968..75cefcdf1e 100644 --- a/resources/src/main/res/values-zh-rCN/strings.xml +++ b/resources/src/main/res/values-zh-rCN/strings.xml @@ -659,9 +659,9 @@ 隐私 隐私和分析 - Code on the Go 使用 Firebase Analytics 和 GlitchTip 来帮助我们改进应用 \n\nFirebase Analytics 收集匿名使用数据,帮助我们了解应用的使用情况 \n\nGlitchTip 帮助我们跟踪和修复错误 \n\n不会收集或分享任何个人信息 所有数据均按照我们的隐私政策进行处理 - 我了解 - 了解更多 + Code on the Go 会收集匿名的使用情况和崩溃信息,以帮助我们修复缺陷并改进应用。我们不会收集或分享任何个人信息。\n\n选择“共享匿名数据”将发送这些信息;选择“保持离线”则不会发送任何内容。 + 共享匿名数据 + 保持离线 唯一 ID 设备 @@ -1184,7 +1184,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 64f3a3e41b..33f210b86b 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -664,10 +664,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.\n\nChoose whether to share this anonymous data. If you choose Keep offline, analytics and crash reports are never sent. + 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 - Learn more Unique ID Device @@ -1216,7 +1215,6 @@ 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 From c676f3b38755357b47f7df6d9925b01570a16097 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Wed, 12 Aug 2026 12:21:59 +0100 Subject: [PATCH 08/40] ADFA-2602: Reference the agp-tooling catalog version in plugin-builder Replace the hardcoded AGP 9.3.1 in plugin-builder's compileOnly with a tooling-agp catalog alias so the version is defined once. plugin-builder is also an included/standalone build, so import the root catalog in its settings (same pattern as composite-builds/build-logic). --- gradle/libs.versions.toml | 1 + plugin-api/plugin-builder/build.gradle.kts | 6 +++--- plugin-api/plugin-builder/settings.gradle.kts | 18 ++++++++++++------ 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2386258e01..0cde4e3960 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -291,6 +291,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/plugin-api/plugin-builder/build.gradle.kts b/plugin-api/plugin-builder/build.gradle.kts index ab56406fa5..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` = 9.3.1), 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:9.3.1") + 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")) + } + } +} From 2d7444a21fec1d190934c73d8111fca272d9853a Mon Sep 17 00:00:00 2001 From: Daniel-ADFA Date: Wed, 12 Aug 2026 18:55:54 +0100 Subject: [PATCH 09/40] ADFA-4419: Remote peer editor decoration API (#1459) * feat(plugin-api): add remote-peer editor decoration API Add IdeEditorService.addRemotePeerMarker/removeRemotePeerMarker/clearRemotePeerMarkers as default-implemented (backward-compatible) methods so a plugin can draw a remote collaborator's caret/badge inside the editor. Backed by a new EditorDecorationManager + RemotePeerMarkerWindow (an EditorPopupWindow overlay that tracks scroll via FEATURE_SCROLL_AS_CONTENT) in the app module; EditorProviderImpl resolves the live editor via EditorHandlerActivity.getEditorForFile and marshals onto the main thread, clearing markers on dispose. IdeEditorServiceImpl exposes read-gated overrides and the parallel EditorProvider contract methods. The new interface methods are default-implemented so this is an additive, non-breaking change for the generated plugin-api lib and existing implementers. Consumed by the Pair pair-programming plugin. * WIP: Add cursor markers to plugin * Implement pair programming plugin * fix(ADFA-4419): remove stray token breaking PluginManager compilation * refactor(ADFA-4419): distinguish peer-presence overlay from #1448 decorations Rename EditorDecorationManager -> PeerPresenceOverlayManager and extract a focused PeerPresenceProvider interface out of the broad EditorProvider, so the pair-programming peer-cursor overlay (floating named badges) reads as a distinct concern from the generic EditorDecorationProvider (additive color spans) added in #1448 (ADFA-4436). Host-internal only: no plugin-api contract changed and the merged rainbow- brackets plugin is unaffected. Verified with :app:compileV8DebugKotlin. * fix(ADFA-4419): address CodeRabbit review + drop dev-trace logging - PeerPresenceOverlayManager: clamp peer badge on exact-fit width (maxX >= 0) - PluginManager.loadPlugins: rethrow CancellationException instead of recording cancellation as a plugin load failure - IdeEditorServiceImpl: don't gate hidePeerCursor/clearPeerCursors on file accessibility, so overlay cleanup still works after a tab closes - IdeProjectServiceImpl.openProject: use the validated canonical path and run it through PathValidator before switching projects - PluginRepositoryImpl: delete the broken artifact when an upgraded plugin fails to load, so loadPlugins() doesn't keep retrying it - Drop PairTrace / [HOST] dev-trace Log.d (kept warn/error diagnostics) * ADFA-4419: Fix spotless/ktlint violations in branch-touched files spotlessApply reformatting across the seven files this branch touched, plus the three lints ktlint cannot auto-fix: expand the wildcard import in PluginManager, move the orphaned KDoc onto delegatingEditorProvider, and rename INSTANCE/Loader to instance/loader per property-naming. --------- Co-authored-by: Daniel Alome --- .../editor/PeerCursorOverlayManager.kt | 139 +++ .../androidide/app/EditorProviderImpl.kt | 845 +++++++++-------- .../repositories/PluginRepositoryImpl.kt | 329 ++++--- plugin-api/api/plugin-api.api | 8 + .../plugins/services/IdeServices.kt | 849 +++++++++-------- .../plugins/manager/core/PluginManager.kt | 53 +- .../manager/services/IdeEditorServiceImpl.kt | 857 ++++++++++-------- .../manager/services/IdeProjectServiceImpl.kt | 315 ++++--- 8 files changed, 2018 insertions(+), 1377 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/activities/editor/PeerCursorOverlayManager.kt 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/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/repositories/PluginRepositoryImpl.kt b/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt index e2c2d1d024..565d989c2f 100644 --- a/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt @@ -14,155 +14,182 @@ 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(".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() + + 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/plugin-api/api/plugin-api.api b/plugin-api/api/plugin-api.api index 399036d52e..b1ee917a06 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 { 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-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..ba895ebd7c 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 @@ -228,6 +228,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 +315,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() @@ -371,21 +390,22 @@ class PluginManager private constructor( 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) } } } @@ -899,6 +919,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 +1355,7 @@ class PluginManager private constructor( override fun getAllowedPaths(): List = validator.getAllowedPaths() } }, + activityProvider = delegatingActivityProvider, ) } @@ -1573,6 +1605,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) + } +} From c1c8c1677d0d0d35d9b182dda4f265005459f962 Mon Sep 17 00:00:00 2001 From: davidschachterADFA Date: Wed, 12 Aug 2026 22:08:50 -0700 Subject: [PATCH 10/40] ADFA-5107: Add Claude-facing reference for the documentation database (#1661) * ADFA-5107 | Add Claude-facing reference for the documentation database Combines the Confluence design doc and docdb-studio's CLAUDE.md/SCHEMA.md into docs/documentation-database.md, cross-checked against WebServer.kt, ToolTipManager.kt, and PluginDocumentationManager.kt so it reflects actual behavior. Linked from ARCHITECTURE.md and ADR 0001 for discoverability. * ADFA-5107 | Fix docdb doc inaccuracies flagged in review - List all three raw-SQLite exceptions (tooltips, in-app/plugin-help, local web server) as documentation.db consumers in ARCHITECTURE.md, not just two. - Stop calling Tier 3 content "tooltips" -- tooltips are Tier 1/2; Tier 3 is the web content they link to. - Fix a self-contradiction: Content.UNIQUE(path) rejects any duplicate path regardless of languageID, it does not permit same-path rows that differ only by language. One passage said otherwise. --- ARCHITECTURE.md | 2 + docs/adr/0001-prefer-room-for-persistence.md | 2 + docs/documentation-database.md | 98 ++++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 docs/documentation-database.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4be5ac177e..da105cd0ac 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -103,6 +103,8 @@ 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. +> +> 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 diff --git a/docs/adr/0001-prefer-room-for-persistence.md b/docs/adr/0001-prefer-room-for-persistence.md index 6a12919bef..dfa7770ba8 100644 --- a/docs/adr/0001-prefer-room-for-persistence.md +++ b/docs/adr/0001-prefer-room-for-persistence.md @@ -35,6 +35,8 @@ 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. diff --git a/docs/documentation-database.md b/docs/documentation-database.md new file mode 100644 index 0000000000..fd72edcc1c --- /dev/null +++ b/docs/documentation-database.md @@ -0,0 +1,98 @@ +# 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. `WebServer`'s debug logging and experiment flags are also file-flag-gated under `/sdcard/Download/` (`CodeOnTheGo.webserver.debug`, `CodeOnTheGo.exp`, `CodeOnTheGo.webserver.cs0`). + +## 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. 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 + +- **`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, decompresses Brotli when the client can't accept it (or when a Pebble template needs a string to render), 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. + +## 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. From c5445bcefeb8b44d89235143e6c660881fd90375 Mon Sep 17 00:00:00 2001 From: Dara Abijo Date: Thu, 13 Aug 2026 16:09:46 +0100 Subject: [PATCH 11/40] ADFA-4852: New file/folder dialog dismissal (#1670) * fix(ADFA-4852): Fix dialog dismissal on long-press * docs(ADFA-4852): Add KDoc to function * refactor(ADFA-4852): Show dialog before configuring its views --- .../actions/filetree/NewFileAction.kt | 5 - .../actions/filetree/RenameAction.kt | 60 ++++--- .../editor/ProjectHandlerActivity.kt | 2 +- .../androidide/utils/DialogExtensions.kt | 97 ++++++----- .../itsaky/androidide/utils/ViewExtensions.kt | 158 +++++++++++------- 5 files changed, 177 insertions(+), 145 deletions(-) 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/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/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/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 + } } } } From bf46d5e78cc3c7585cdccd468700a7736b8e10ad Mon Sep 17 00:00:00 2001 From: Daniel-ADFA Date: Thu, 13 Aug 2026 16:55:02 +0100 Subject: [PATCH 12/40] ADFA-4394: Report attached input devices and external displays to analytics and GlitchTip (#1663) * ADFA-4394: Report attached input devices and external displays to analytics and GlitchTip * ADFA-4394: Address CodeRabbit review on attached devices metric Narrow the collector guards to RuntimeException and log fallbacks, move metric collection and tracking off the main dispatcher, and assert the exact attached_devices context map in the test. --------- Co-authored-by: Daniel Alome --- .../analytics/AttachedDevicesCollector.kt | 117 +++++++++++++++++ .../analytics/AttachedDevicesMetric.kt | 19 +++ .../app/DeviceProtectedApplicationLoader.kt | 14 ++ .../handlers/GlitchTipDiagnosticsContext.kt | 13 ++ .../analytics/AttachedDevicesCollectorTest.kt | 122 ++++++++++++++++++ .../analytics/AttachedDevicesMetricTest.kt | 35 +++++ .../GlitchTipDiagnosticsContextTest.kt | 40 ++++++ 7 files changed, 360 insertions(+) create mode 100644 app/src/main/java/com/itsaky/androidide/analytics/AttachedDevicesCollector.kt create mode 100644 app/src/main/java/com/itsaky/androidide/analytics/AttachedDevicesMetric.kt create mode 100644 app/src/test/java/com/itsaky/androidide/analytics/AttachedDevicesCollectorTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/analytics/AttachedDevicesMetricTest.kt 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/DeviceProtectedApplicationLoader.kt b/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt index 69f023a985..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 @@ -162,6 +164,8 @@ internal object DeviceProtectedApplicationLoader : withContext(Dispatchers.Main) { initializeAnalytics() } + + trackAttachedDevicesMetric(app) } fun onTelemetryConsentGranted(app: IDEApplication) { @@ -194,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/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/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/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) + } } From ca80514f347e8a3a36bfbdc1f55d2964f9dec305 Mon Sep 17 00:00:00 2001 From: davidschachterADFA Date: Thu, 13 Aug 2026 12:36:25 -0700 Subject: [PATCH 13/40] ADFA-5109: Remove unused Room deps from idetooltips (#1662) ADFA-5109 | Remove unused Room deps from idetooltips ToolTipManager reads documentation.db via raw SQLiteDatabase, not Room (ADR 0001, exception 1) -- no source file in the module imports androidx.room. Drops the now-pointless kapt(room.compiler)/room.ktx deps and the kotlin-kapt plugin they were the only user of, and closes out the ADR 0001 follow-up that flagged this. --- ARCHITECTURE.md | 2 +- docs/adr/0001-prefer-room-for-persistence.md | 3 +-- idetooltips/build.gradle.kts | 4 ---- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index da105cd0ac..3ecaccc691 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -102,7 +102,7 @@ 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. diff --git a/docs/adr/0001-prefer-room-for-persistence.md b/docs/adr/0001-prefer-room-for-persistence.md index dfa7770ba8..0944a429bd 100644 --- a/docs/adr/0001-prefer-room-for-persistence.md +++ b/docs/adr/0001-prefer-room-for-persistence.md @@ -39,7 +39,7 @@ The tooltips and in-app/plugin-help rows, plus the local web server's Tier 3 ser 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 @@ -53,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/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) From f3d7dbf67f496d1cbcba2bf3621c04b7a3b5cc50 Mon Sep 17 00:00:00 2001 From: davidschachterADFA Date: Thu, 13 Aug 2026 12:58:52 -0700 Subject: [PATCH 14/40] ADFA-5123: Document docdb SQL-authoring gotchas from ADFA-5088 (#1666) * ADFA-5123: Document docdb SQL-authoring gotchas from ADFA-5088 Add two lessons learned writing SQL migration scripts against documentation.db: a local copy's on-disk schema/content can be stale independent of git history (a stale copy caused a real near-miss in ADFA-5088 - a script validated against it would have silently overwritten curated production content), and the .bail on / guard-table pattern needed for a BEGIN/COMMIT script to actually be atomic against a bad or empty Brotli payload. Co-Authored-By: Claude Sonnet 5 * ADFA-5123: Document the owner-only-workdir temp-file pattern Insecure /tmp filenames (CWE-377) were found and fixed in the ADFA-5088 docdb scripts after this doc's first pass - a fixed, guessable name under world-writable /tmp lets another local user pre-plant a symlink or race the write/read pair. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- docs/documentation-database.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/documentation-database.md b/docs/documentation-database.md index fd72edcc1c..566703ad1b 100644 --- a/docs/documentation-database.md +++ b/docs/documentation-database.md @@ -9,6 +9,7 @@ This is a **read-only, prebuilt** database — CoGo never creates or migrates it - 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. `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 @@ -91,6 +92,14 @@ Schema changes and data edits happen **outside this repo**, in `OfflineDocumenta - 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. From b516316a67dde943577d25f8f6b04faf4621de09 Mon Sep 17 00:00:00 2001 From: davidschachterADFA Date: Thu, 13 Aug 2026 13:24:14 -0700 Subject: [PATCH 15/40] ADFA-5121: Remove dead UseSytemShell preference and unused setting (#1668) UseSytemShell was never instantiated anywhere - no addPreference(UseSytemShell()) call exists in any screen builder, so it was unreachable from the Preferences UI. The setting it read/wrote (GeneralPreferences.useSystemShell / TERMINAL_USE_SYSTEM_SHELL) was likewise never consulted anywhere else in the codebase. Remove the class, the backing constant and property, and the title/summary strings across all 13 locale files (used only by this row). Co-authored-by: Claude Sonnet 5 --- .../androidide/preferences/generalPrefExts.kt | 248 ++++++++---------- .../internal/GeneralPreferences.kt | 7 - .../src/main/res/values-ar-rSA/strings.xml | 2 - .../src/main/res/values-bn-rIN/strings.xml | 2 - .../src/main/res/values-de-rDE/strings.xml | 2 - .../src/main/res/values-es-rES/strings.xml | 2 - .../src/main/res/values-fr-rFR/strings.xml | 2 - .../src/main/res/values-hi-rIN/strings.xml | 2 - .../src/main/res/values-in-rID/strings.xml | 2 - .../src/main/res/values-pt-rBR/strings.xml | 2 - .../src/main/res/values-ro-rRO/strings.xml | 2 - .../src/main/res/values-ru-rRU/strings.xml | 2 - .../src/main/res/values-tr-rTR/strings.xml | 2 - .../src/main/res/values-zh-rCN/strings.xml | 2 - resources/src/main/res/values/strings.xml | 2 - 15 files changed, 114 insertions(+), 167 deletions(-) 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/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/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/strings.xml b/resources/src/main/res/values-in-rID/strings.xml index 5a4bd3e069..fcc01cf432 100644 --- a/resources/src/main/res/values-in-rID/strings.xml +++ b/resources/src/main/res/values-in-rID/strings.xml @@ -318,8 +318,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. 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 75cefcdf1e..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 键缩进的空格数 diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 33f210b86b..b8e7660fef 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -331,8 +331,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. From 18250a218b3b07ed8d0886b8d4f42d3fb779b767 Mon Sep 17 00:00:00 2001 From: davidschachterADFA Date: Thu, 13 Aug 2026 13:44:22 -0700 Subject: [PATCH 16/40] docs: retro for ADFA-5088 (Preferences/Plugin Manager tooltips + docdb SQL) (#1667) * docs: retro for ADFA-5088 (Preferences/Plugin Manager tooltips + docdb SQL) Co-Authored-By: Claude Sonnet 5 * docs: add insecure-temp-file learning from ADFA-5088 follow-up Co-Authored-By: Claude Sonnet 5 * docs: address review feedback on retro docs - learnings.md: note that mkdir can itself fail the same way .bail can't see other .system failures, and that mktemp -d isn't a drop-in fix here since each .system line is its own subshell with no state carried to the next one. - retrospective.md: add the blank lines markdownlint (MD058) wants around the three new tables. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- CLAUDE.md | 1 + docs/process/learnings.md | 5 ++++ docs/process/retrospective.md | 45 +++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 22b9a794b6..dc3b64ee94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,7 @@ 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. - **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. 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 From d192b0cc4937dab0a6cc76a5007b8dacbf7e24d8 Mon Sep 17 00:00:00 2001 From: davidschachterADFA Date: Fri, 14 Aug 2026 07:27:34 -0700 Subject: [PATCH 17/40] ADFA-4883: Add YouTube and Bilibili links to the About page (#1672) Users can't find our instructional videos on YouTube/Bilibili since we don't rank near the top in search. Add both channels to the About page's Socials section so they're one tap away. Co-authored-by: Claude Sonnet 5 --- .../androidide/activities/AboutActivity.kt | 22 +++++++++++++++++++ .../src/main/res/drawable/ic_bilibili.xml | 11 ++++++++++ .../src/main/res/drawable/ic_youtube.xml | 11 ++++++++++ resources/src/main/res/values/strings.xml | 4 ++++ 4 files changed, 48 insertions(+) create mode 100644 resources/src/main/res/drawable/ic_bilibili.xml create mode 100644 resources/src/main/res/drawable/ic_youtube.xml 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/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/strings.xml b/resources/src/main/res/values/strings.xml index b8e7660fef..97d441fbbb 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. @@ -1216,6 +1218,8 @@ 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 From 191a97c3ed68bd178b620144760dd59d736fe860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Andr=C3=A9s=20Trujillo?= <34223334+jatezzz@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:18:13 -0500 Subject: [PATCH 18/40] ADFA-5095 | Publish optional backend capabilities on the LLM inference contract (#1660) * feat(ADFA-5095): Let an LLM backend own its prompt, config and tool calling An LlmBackend could only stream a single prompt, so anything model-specific -- prompt wording, temperature, settings UI -- had to be guessed by the consumer or read out of another plugin's preferences. Add, all as defaults so existing backends are untouched: - generateStreamingWithHistory / generateStreamingWithTools, degrading to plain streaming rather than failing - getSystemPrompt(SystemPromptRequest) and getDefaultTemperature: the consumer supplies the tool contract, the backend supplies the wording - getConfigSpecs (ConfigFieldSpec / ConfigFieldType) or getSettingsFragmentClassName for a backend that owns its own settings screen - CancellableBackend for stopping an in-flight stream - LlmInferenceService.getPreferredBackendId, so a costly backend can tell whether it is the one about to be used * fix(ADFA-5095): Annotate nullability and document the tool-calling models getDefaultTemperature() returns a boxed Float where LlmConfig.temperature is primitive, so the obvious assignment unboxes null and throws, now annotated and stated in the Javadoc, along with the four other unannotated members. * refactor(ADFA-5095): Declare backend capabilities by type, not by flag Review on #1660: capability interfaces replace the flag/method pairs that could disagree, tool results get a return path, describe-only config specs drop ToolDefinition and ToolCallRequest turn immutable, and :plugin-api's tests compile for the first time. * fix(ADFA-5095): Keep the tool models mutable and log the additions The last commit tightened ToolCallRequest and ToolDefinition to final fields with defensive copies. Both shipped non-final in 26.28, so that turned an additive PR into an ABI break: an already-built .cgp assigning one of those fields throws IllegalAccessError on putfield, and the null-checks and unmodifiable maps change behaviour for callers that were within contract before. Reverted to the published shape; the hazard the tightening was aimed at is now an ownership rule in the Javadoc instead -- who owns an instance, and that SystemPromptRequest copies only the list spine. Also adds the 26.33 changelog entry the additions were missing, so a plugin author has a min_ide_version to floor at, including the note that Role.TOOL can break an exhaustive Kotlin `when`. * docs(ADFA-5095): Correlate tool results by call id and tool name * docs(ADFA-5095): File Role.TOOL and the nullability sweep as breaking Both need a source change in plugin repos, so drop the blanket "every change is additive" claim, define the `breaking` legend entry, and document the previously unlisted nullability change. --- docs/PLUGIN_API_CHANGELOG.md | 66 +- docs/plugin-api.md | 6 +- plugin-api/api/plugin-api.api | 30 + .../plugins/services/LlmInferenceService.java | 1036 +++++++++++------ .../plugins/services/IdeFileServiceTest.java | 29 - .../services/IdeProjectServiceTest.java | 29 - .../services/IdeResourceServiceTest.java | 27 - .../services/LlmInferenceServiceTest.java | 160 +++ .../androidide/plugins/PluginContextTest.kt | 801 +++++++------ 9 files changed, 1364 insertions(+), 820 deletions(-) delete mode 100644 plugin-api/src/test/java/com/itsaky/androidide/plugins/services/IdeFileServiceTest.java delete mode 100644 plugin-api/src/test/java/com/itsaky/androidide/plugins/services/IdeProjectServiceTest.java delete mode 100644 plugin-api/src/test/java/com/itsaky/androidide/plugins/services/IdeResourceServiceTest.java create mode 100644 plugin-api/src/test/java/com/itsaky/androidide/plugins/services/LlmInferenceServiceTest.java diff --git a/docs/PLUGIN_API_CHANGELOG.md b/docs/PLUGIN_API_CHANGELOG.md index 9c5677868c..804a212381 100644 --- a/docs/PLUGIN_API_CHANGELOG.md +++ b/docs/PLUGIN_API_CHANGELOG.md @@ -26,14 +26,72 @@ 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 — 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-api.md b/docs/plugin-api.md index fb03b91287..ed5ebee0ee 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`). - 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/plugin-api/api/plugin-api.api b/plugin-api/api/plugin-api.api index b1ee917a06..24e2cb1765 100644 --- a/plugin-api/api/plugin-api.api +++ b/plugin-api/api/plugin-api.api @@ -1508,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 } @@ -1564,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; @@ -1571,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; 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/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/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) + } } From 1bb0acca746730c756b3eab65dcf2bd7dd25a42b Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Sat, 15 Aug 2026 11:39:00 -0400 Subject: [PATCH 19/40] ADFA-5156: Roll back R8 shrinking to unbreak plugins (#1679) * ADFA-5156: Roll back R8 shrinking to unbreak plugins Restores the blanket -dontshrink that ADFA-3604 (#1596) removed on 2026-07-29. This is a temporary rollback to restore plugin functionality; a targeted fix follows. 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. Sketch to UI fails on every image load with "No static method maxOrNull([F)Ljava/lang/Float; in class ArraysKt". -dontobfuscate and -dontoptimize were already set, so restoring -dontshrink reduces R8 to a pass-through and returns the release build to the configuration shipped before ADFA-3604. isMinifyEnabled and isShrinkResources are deliberately left alone, keeping resource shrinking and the build wiring unchanged. Verified by dex-scanning both APKs (baseline pulled from a release install on Samsung RFCT704HEAL): kotlin/kotlinx method declarations 30,669 -> 45,371 ArraysKt/CollectionsKt/MapsKt/ FilesKt/SequencesKt facades absent -> present 10 sketch-to-ui stdlib call sites all stripped -> all present CompletableJob$DefaultImpls.plus stripped -> present Sketch to UI now loads an image and completes detection on-device with no NoSuchMethodError in logcat. APK size: 659,307,160 -> 706,829,817 bytes (+47.5 MB, +7.2%). Note: R8 was buying less than #1596 advertised. That PR measured dex at 28.8 MB / 24,853 classes, but the shipped APK is 85 MB / 78,757 classes -- the URGENT follow-ups (#1609, #1610) added -dontoptimize plus a set of keep rules that clawed most of it back. * ADFA-5156: Add R8 plugin-impact analysis tooling The ADFA-5156 failure mode is invisible at build time -- assemblePlugin is green, the manifest is fine, the .cgp is correct, and only on-device execution of a specific code path reveals that R8 stripped a stdlib member the plugin needs. These scripts make it measurable from build artifacts instead. scripts/r8-plugin-impact/ README.md what the bug is, how to run, how to read output, the known false positives, and the ADFA-5156 baseline numbers to measure future builds against dex-dump.sh extract + dexdump an APK or .cgp analyze-plugin-impact.py simulate parent-first resolution of every kotlin.*/kotlinx.* call site in each plugin's own code against two host dexes and diff the verdicts Three subcommands: impact (the before/after table), explain-method (trace one resolution chain, showing where it leaves the APK), explain-absent (inspect fall-throughs for the split-brain shape that caused the original bug). Documents three traps that produce wrong conclusions if analysis is done ad hoc, all of which bit during this investigation: - Methods inherited from the Android boot classpath read as missing, because java.util.* is not in the APK. Eight such false positives are enumerated. - Kotlin multifile facades (ArraysKt, StringsKt) declare nothing themselves; they extend a part class whose underscore count varies (StringsKt__StringsKt vs ArraysKt___ArraysKt). Checking a facade directly always fails. - D8 build-time synthetics ($$ExternalSyntheticBackport0 and friends) never exist in the host and always show as absent. Stdlib-only Python, no third-party dependencies. Run with uv run --no-project. * ADFA-5156: Apply spotless formatting to plugin-impact scripts * ADFA-5156: Point plugin-impact baseline at the deployed plugin set The first baseline measured a local folder of .cgp files that turned out to be a pre-rename snapshot -- 5 stale filenames and 3 plugins missing. Re-runs against the artifact from the last update-libs.yml deploy (26 plugins, 4,261 call sites) and documents how to obtain that artifact, so the next person does not measure the wrong set. Conclusion is unchanged: zero regressions, 67 real failures to 0. --- app/proguard-rules.pro | 11 + scripts/r8-plugin-impact/README.md | 137 ++++++++ .../r8-plugin-impact/analyze-plugin-impact.py | 301 ++++++++++++++++++ scripts/r8-plugin-impact/dex-dump.sh | 80 +++++ 4 files changed, 529 insertions(+) create mode 100644 scripts/r8-plugin-impact/README.md create mode 100644 scripts/r8-plugin-impact/analyze-plugin-impact.py create mode 100755 scripts/r8-plugin-impact/dex-dump.sh 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/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 From 7659e3ee3167e1e80dfb4031ce876ab11e24003e Mon Sep 17 00:00:00 2001 From: itsaky-adfa Date: Mon, 17 Aug 2026 22:44:32 +0530 Subject: [PATCH 20/40] ADFA-5126: Keep volatile build metadata out of module ABIs (#1671) --- .../itsaky/androidide/utils/BuildInfoUtils.kt | 5 +- build-info/build.gradle.kts | 12 +- .../androidide/buildinfo/BuildInfo.java.in | 33 ++++- .../itsaky/androidide/utils/BuildInfoUtils.kt | 6 +- .../com/itsaky/androidide/build/config/CI.kt | 37 +++++ .../androidide/build/config/ProjectConfig.kt | 22 ++- ...012-volatile-build-metadata-out-of-abis.md | 126 ++++++++++++++++++ docs/adr/README.md | 1 + docs/process/build-ci-glossary.md | 60 +++++++++ 9 files changed, 292 insertions(+), 10 deletions(-) create mode 100644 docs/adr/0012-volatile-build-metadata-out-of-abis.md create mode 100644 docs/process/build-ci-glossary.md 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/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/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/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/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/README.md b/docs/adr/README.md index 7139d240d5..c682eefaca 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -25,3 +25,4 @@ Format is lightweight **MADR / Nygard**: Context → Decision → Consequences | [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 | 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. From 3ac1eadda1b259afb53232be04f7cdf6a4847942 Mon Sep 17 00:00:00 2001 From: Dara Abijo Date: Mon, 17 Aug 2026 21:54:59 +0100 Subject: [PATCH 21/40] ADFA-4808: Fix empty App Logs output (#1674) * fix(ADFA-4808): Re-sync app log output * fix(ADFA4808): Prevent logs from reappearing after clearing * FIX(ADFA-4808): Return an append failure to the log renderer * fix(ADFA-4808): Exception handling --- .../fragments/output/LogViewFragment.kt | 39 +++++++-- .../com/itsaky/androidide/logs/LogBuffer.kt | 9 +- .../androidide/viewmodel/LogViewModel.kt | 9 ++ .../itsaky/androidide/logs/LogBufferTest.kt | 13 +++ .../androidide/viewmodel/LogViewModelTest.kt | 81 ++++++++++++++++++ assets/core.cgt | Bin 1590967 -> 1586098 bytes .../itsaky/androidide/editor/ui/IDEEditor.kt | 14 ++- 7 files changed, 150 insertions(+), 15 deletions(-) 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/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/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/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/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/assets/core.cgt b/assets/core.cgt index 7b248673c1b8de747a4f9939c689030a5fe99d5b..c66ce2460d14367a400a5ae9c142ef4deb1b3073 100644 GIT binary patch delta 214268 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 L2(!Rp9LE0u$eNmX delta 148089 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 Date: Tue, 18 Aug 2026 13:56:12 +0100 Subject: [PATCH 22/40] fix(ADFA-5177): Exclude Unknown from template languages (#1690) * fix(ADFA-5177): Exclude Unknown from template languages * refactor(ADFA-5177): Enforce language exclusion after config --- .../itsaky/androidide/templates/parameters.kt | 774 +++++++++--------- .../itsaky/androidide/templates/UtilTest.kt | 226 ++--- 2 files changed, 520 insertions(+), 480 deletions(-) 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-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() + } +} From 50bd570673fcfcb9936a6889af2be048c3ce238f Mon Sep 17 00:00:00 2001 From: davidschachterADFA Date: Tue, 18 Aug 2026 08:04:46 -0700 Subject: [PATCH 23/40] ADFA-4934: Install a .cgp or .cgt file opened from outside the app (#1682) * ADFA-4934: Introduce shared PLUGIN_ARCHIVE_EXTENSION constant Consolidates the ".cgp" literal duplicated across ~7 sites into a single constant, mirroring the existing TEMPLATE_ARCHIVE_EXTENSION. Prep work for the external file-install feature, which needs a canonical way to recognize .cgp files. Co-Authored-By: Claude Sonnet 5 * ADFA-4934: Install a .cgp or .cgt file opened from outside the app Adds a VIEW intent-filter (ExternalFileInstallActivity) so opening a .cgp/.cgt attachment (e.g. from email) prompts to install it, instead of doing nothing. .cgp files are copied to a temp file and forwarded into PluginManagerActivity, reusing its existing install/conflict/signature-check flow verbatim rather than duplicating it. .cgt files get a new TemplateCollectionRepository, since no import/conflict backend existed for template collections before now: it validates the archive via the existing ZipTemplateReader, and on a filename collision offers overwrite / rename-and-install / ignore (the ticket's requested UX), using the archive's filename as its identity since templates.json has no collection-level name field. Co-Authored-By: Claude Sonnet 5 * ADFA-4934: Split file:// intent-filter into typed/untyped variants On-device testing (dumpsys package) showed a mimeType on any tag applies to the WHOLE intent-filter, not just that tag - so a single filter mixing content's mimeType-bearing variant with file's mimeType-less variant silently broke matching for untyped file:// intents (confirmed via `pm query-activities`: 0 matches before this fix, 2 after). content:// keeps a single filter (the OS resolves an implicit type for it regardless), but file:// now gets two dedicated filters, one typed and one not. Verified end-to-end on a physical device: the "Open with" chooser lists Code on the Go for both .cgp and .cgt, and the full install/conflict-resolve flow (fresh install, rename, overwrite, invalid-file rejection) works. Co-Authored-By: Claude Sonnet 5 * ADFA-4934: Rebuild the .cgt install dialogs in Jetpack Compose ADR 0009 requires new dialogs to be Compose, not MaterialAlertDialogBuilder - caught by an architecture-review pass before opening the PR. Enables Compose in the app module (mirroring floating-window's setup) and rewrites the three .cgt dialogs (install-confirm, name-conflict, rename) as composables, reusing FloatingTheme so they stay visually consistent with the IDE's XML theme. The .cgp path is untouched: it still forwards into PluginManagerActivity's existing (pre-ADR) dialog rather than duplicating it. Fixed two things surfaced by this rewrite: - compose-rules ktlint caught the ViewModel being forwarded into a nested composable; fixed via state hoisting (a plain suspend lambda instead). - The rename dialog's suggested name no longer visually clips its first character - that was a View EditText auto-scroll artifact from selectAll(), gone now that Compose's TextFieldValue sets the cursor position explicitly. Re-verified end-to-end on the physical device: fresh install, rename, overwrite, and the .cgp forwarding path all work with the new dialogs. Co-Authored-By: Claude Sonnet 5 * ADFA-4934: Dedupe FileProvider authority; fix manifest case/coverage gaps Code review (PR #1682) findings, mechanical/data half: - The app's FileProvider authority string (".providers.fileprovider") was duplicated inline across 7 call sites (IntentUtils, ApkInstaller, FileDragStarter, DragAndDropExtensions, FeedbackManager, FeedbackEmailHandler, and the new IDEFileProvider helper) - a rename would have needed 7 manual updates with no compiler check. Consolidated into common/FileProviderUtils.kt, shared across app and common (which can't depend on app's IDEFileProvider). - Manifest: android:pathPattern has no case-insensitive mode, so a .CGP/.CGT (uppercase) attachment previously never matched. Added uppercase variants, and combined .cgp/.cgt into 3 shared filters (down from 6) since every tag within one filter already had to share the same mimeType-bearing shape. Documented, as an explicit known limitation, that a sender whose content:// Uri path never carries the filename (e.g. some email providers' attachment Uris) can't match a pathPattern-based filter regardless of type - the alternative (a pathPattern-less mimeType="*/*" filter) would register this app as a candidate for every file-view intent on the device, which is a worse tradeoff than missing those senders. Verified on a physical device: `pm query-activities` now matches both cases of both extensions via content:// and file://, typed and untyped. Co-Authored-By: Claude Sonnet 5 * ADFA-4934: Fix correctness bugs found by code review (PR #1682) Behavioral half of the review findings: - "Delete installation file after install" silently did nothing for a .cgp forwarded from ExternalFileInstallActivity: DocumentsContract.deleteDocument() only works against a real SAF DocumentsProvider (it calls a special METHOD_DELETE_DOCUMENT via ContentProvider.call(), returning true unconditionally unless an exception is thrown), and our own IDEFileProvider doesn't implement that call. Now dispatches to plain contentResolver.delete() for our own authority (confirmed via decompiling FileProvider.class that its delete() correctly deletes the mapped file) and keeps deleteDocument() for real picker-sourced Uris. Forwarded installs also no longer show the checkbox at all - there's no source worth optionally keeping, since it's our own hidden temp copy - so it's now always cleaned up. - Cold-start race: isPluginManagerAvailable()/isTemplatesFeatureAvailable() could run before IDEApplication's async setup finishes if the OS cold-starts straight into ExternalFileInstallActivity. Both are now polled briefly (up to 3s) instead of failing on the first check. - Two process-death drops: ExternalFileInstallActivity and PluginManagerActivity both only acted `if (savedInstanceState == null)`, which also (incorrectly) skips a process-death-recreated instance - the one case that most needs to reprocess the restored intent, since it lost all in-memory state. Replaced with idempotency tracked inside each ViewModel instance (survives rotation, resets on process death, matching real recreation semantics). - Rename dialog: an in-flight async name suggestion could clobber whatever the user had already started typing. - installCollection()'s own collision check was case-sensitive, bypassing findExistingCollision()'s case-insensitive matching - both now share one lookup. - A failed template install used to delete the temp file and close the screen, forcing the user to re-open the original attachment to retry. Failure now just shows an error and leaves the current dialog open. - Wired ShowTemplateNameConflict.info into the conflict dialog instead of dropping it silently (it now shows contained template names, matching the fresh-install dialog). - Hardened temp/session file naming from timestamp to UUID (collision risk under rapid concurrent opens), moved UriFileImporter.getDisplayName() onto Dispatchers.IO (was running unguarded on Main), and stopped conflating CancellationException with real copy failures (now always cleans up the partial file either way, and doesn't show a bogus error for an ordinary cancellation). - installCollection() also tried File.renameTo() as an "atomic move" - this round-tripped through on-device testing: it silently fails on this device even within the app's own private storage (a well-known Android unreliability), so a real .cgt install regressed to always failing until a copy+delete fallback was added back. Re-verified end-to-end on a physical device after each fix: fresh install, rename, overwrite, and the delete-checkbox's absence for forwarded installs all confirmed working; the retry-after-failure behavior was directly triggered and observed holding the dialog open. Co-Authored-By: Claude Sonnet 5 * ADFA-4934: Fix correctness/reuse findings from max-effort code review Correctness: - TemplateCollectionRepositoryImpl: refuse to install/overwrite the reserved "core" basename so an external .cgt named "core" can no longer delete the bundled default templates archive; wrap findExistingCollision() in try/catch like its siblings. - ExternalFileInstallViewModel: guard confirmTemplateInstall() against a double-tap race; give the Flashbar entrance animation time to render before Finish tears the activity down. - Add uiMode/locale/fontScale/density to both activities' configChanges so a config change mid-dialog can't strand the forwarded-install flow behind a one-shot guard that already fired. - PluginManagerViewModel: clean up the forwarded source file on install failure, a conflict abort, or the user cancelling either confirmation dialog - not just on success. Reuse/simplification: - Forward a .cgp as a plain file path instead of a minted FileProvider Uri, so PluginManagerViewModel can install directly from it instead of copying it a second time; drops the now-redundant IDEFileProvider.getUriForFile wrapper. - Replace Uri-authority sniffing in deleteSourceDocument() with an explicit PluginInstallSource (ContentUri vs LocalFile) from the caller. - Extract a shared LastValueGate for the two "run at most once per forwarded value" guards that were previously duplicated with slightly different shapes. - Dedupe the template-name joinToString() formatting between dialogs. - Wire long-press help into ExternalFileInstallScreen.kt via a small reusable Compose/idetooltips interop helper, per ADR 0009/REVIEW.md guidance for a first Compose screen ahead of the ADFA-4381 bridge. Co-Authored-By: Claude Sonnet 5 * ADFA-4934: Address architecture-review findings - Use collectAsStateWithLifecycle() instead of collectAsState() per ADR 0009's explicit guidance, adding the lifecycle-runtime-compose dependency it calls for (the app had none yet). - Update ARCHITECTURE.md's PluginManagerUiEvent.InstallPlugin example to match the PluginInstallSource change from the prior commit. Co-Authored-By: Claude Sonnet 5 * ADFA-4934: Address CodeRabbit review findings - findCollisionFile: match the .cgt extension case-insensitively, consistent with the already-case-insensitive base-name match (the manifest accepts uppercase .CGT); added a regression test. - findExistingCollision: rethrow CancellationException instead of swallowing it as a null result, preserving coroutine cancellation. - longPressTooltip: add an onLongClickLabel (new cd_show_help string) so screen readers can discover the long-press help action. - LastValueGate: document that consume() is not thread-safe. Co-Authored-By: Claude Sonnet 5 * ADFA-4934: Address remaining CodeRabbit findings on PR #1682 - Reject path-traversal in TemplateCollectionRepositoryImpl.installCollection (targetBaseName can no longer contain a separator, and the resolved path is verified to stay directly under templatesDir). - Stage the incoming archive fully under templatesDir before deleting an existing collection, so a failed write can no longer destroy it. - Rethrow CancellationException before the broad catch in PluginManagerViewModel.installPlugin, and run its temp-file cleanup under NonCancellable, matching the pattern already used elsewhere. - Switch the two new files' logging (ExternalFileInstallViewModel, TemplateCollectionRepositoryImpl) from android.util.Log to SLF4J, per REVIEW.md's logging convention. - Add unit tests for FileProviderUtils, the new path-traversal guard, and the preserve-existing-on-failed-write behavior. - Use TemporaryFolder instead of the real Robolectric filesDir for ExternalFileInstallViewModelTest's output files. Co-Authored-By: Claude Sonnet 5 * ADFA-4934: Fix findings from fresh CodeRabbit review on PR #1682 - Rethrow CancellationException in inspectCollection and installCollection (both used runCatching, which was swallowing it into a Result.failure). - Fix a retry-ability regression the previous commit's staging fix introduced: installCollection now copies (rather than moves) the candidate into staging and only deletes it after the whole install succeeds, so a failed install leaves the source file intact for the caller to retry with the same file. - Add KDoc to TemplateCollectionRepositoryImpl and FileProviderUtilsTest. - Broaden test coverage: uppercase-collision install/overwrite, the remaining invalid targetBaseName cases (backslash, ".", blank), byte- content assertions on install/overwrite, and a retry-ability test. Co-Authored-By: Claude Sonnet 5 * ADFA-4934: Fix findings from max-effort code review of PR #1682 - PluginRepositoryImpl: case-insensitive .cgp extension check, fixing permanent data loss for uppercase-named plugin files. - PluginManagerViewModel: await the first loadPlugins() completion before checking for a same-ID conflict, closing a race that could skip the signature check on a cold-started install; only ever delete a forwarded LocalFile temp copy on decline/failure, never a user-picked ContentUri (matches the "delete after install" checkbox's success-only meaning); corrected a comment that overstated the deletion invariant. - PluginManagerActivity: route back-press/tap-outside through CancelPendingInstall on both install dialogs, so a forwarded temp file is never leaked by a silently-cancelable dialog. - TemplateCollectionRepositoryImpl: replace the existing collection via a backup-swap-restore instead of delete-then-write, so a failed final copy can no longer destroy it; give staging/backup files unique names so concurrent installs of the same collection don't race on the same path. - TemplateProviderImpl: case-insensitive .cgt scan, matching the repository's case-insensitive install/collision handling. - AndroidManifest: add keyboard/keyboardHidden/navigation to both install-flow activities' configChanges, closing the same dialog-dropped-on-recreation class of bug for another config axis. - ExternalFileInstallScreen: disable dismiss/cancel on all three dialogs while an install is in flight, so a fast tap can't race a delete against the in-progress install; new Flashbar await-shown helpers replace a fixed delay with the real animation-complete signal before finishing the activity. - ExternalFileInstallViewModel: widen the setup-wait budget from ~2.7s to ~8s to better match a real cold-start's unbounded init chain. Deferred: TemplateProviderImpl's per-archive parse errors are still only logged, not surfaced to installCollection's caller - closing that requires exposing per-archive load state across the templates-api/impl module boundary, which is disproportionate for this PR relative to how speculative the failure mode is. Co-Authored-By: Claude Sonnet 5 * ADFA-4934: Fix findings from high-effort code review of PR #1682 - TemplateCollectionRepositoryImpl: escalate (rather than discard) a failed backup restore after a swap failure, and no longer report a spurious install failure when only the post-swap provider reload throws - the file swap is the operation's real postcondition. - ExternalFileInstallViewModel: cap suggestUniqueBaseName's search so a pathological repository can't hang the Rename dialog forever. - New InstallTempFiles util: shared filesDir/temp staging (extracted from near-identical code in ExternalFileInstallViewModel and PluginManagerViewModel's ContentUri branch) that also sweeps hour-old orphans - covers a temp file left behind if a forwarded .cgp's hand-off to PluginManagerActivity never completes. - FlashbarActivityUtils: extracted a shared configureFlashbar() helper so showFlashBar() and showFlashBarAwaitShown() can't silently diverge in their builder setup. - AndroidManifest.xml: documented two known, accepted limitations rather than fixing them - pathPattern can't match a mixed-case extension without an disproportionate enumeration of every case permutation, and suppressing recreation on uiMode/locale/etc. for dialog continuity means already-inflated View content can look stale until back-and-return (narrower on the Compose screen, which recomposes reactively on those axes). Co-Authored-By: Claude Sonnet 5 * ADFA-4934: Fix findings from second high-effort code review of PR #1682 - CodeEditorView, FileTreeActionHandler: this PR's own new "cgt" archive type was missing from two pre-existing extension allowlists. Opening a .cgt from the file tree would edit its raw zip bytes as text (silent corruption on save) and get blocked by the 10MB file-size guard that every other archive type is exempt from. - ExternalFileInstallActivity: singleTask + onNewIntent, so a rapid double-tap on the same external file collapses into one Activity/ViewModel instance (whose receivedUriGate already dedupes by Uri) instead of spinning up a second instance that mints an independent temp file PluginManagerViewModel's path-based dedup can't recognize as the same source. Verified on-device: a duplicate launch now hits the same instance and shows one dialog, not two. - PluginManagerActivity: check the forwarded temp file still exists before showing the install-confirmation dialog, so a file removed by InstallTempFiles' stale-file sweep surfaces a clear message instead of a generic install failure. Also merged the forced/normal dialog branches into one builder so a future button/copy change can't be applied to only one and reintroduce a leaked-temp-file bug. - PluginManagerViewModel: clean up a forwarded LocalFile temp copy on cancellation too (previously only success/failure paths did), and stopped deleteSourceDocument() from swallowing CancellationException. Co-Authored-By: Claude Sonnet 5 * ADFA-4934: Fix findings from third high-effort code review of PR #1682 - ExternalFileInstallViewModel: fix a regression the singleTask change introduced - a rapid second VIEW intent for a *different* file could have its confirm-dialog effect overwritten by a slower first request that happened to finish its async work later, since the two onReceived() calls run as independent coroutines with no ordering guarantee. Added a generation counter, assigned synchronously so it always reflects real intent-arrival order; a request whose generation is no longer current abandons itself (and its temp file) instead of emitting a stale effect. Verified on-device: the previous ("clean up at start") approach left both files' temp copies on disk; this one leaves exactly one, matching whichever file's dialog is showing. - PluginManagerViewModel: fixed an ownedTempFile assignment race (a cancellation landing exactly as the ContentUri copy finished could skip the `.also{}` that recorded it, leaking the copy past `finally`'s cleanup) by assigning it as a plain statement before the copy runs, not after the whole block returns. - PluginManagerViewModel/PluginManagerActivity/PluginManagerUiState: extracted a deleteIfLocalFile() helper, replacing 6 copies of the same `if (source is PluginInstallSource.LocalFile) deleteInstallSource(source)` guard, and removed CancelPendingInstall's now-dead deleteSourceAfterInstall field (the handler stopped reading it once an earlier fix switched to checking the source type directly). Co-Authored-By: Claude Sonnet 5 * ADFA-4934: Fix findings from fourth high-effort code review of PR #1682 - TemplateCollectionRepositoryImpl: the backup step (moving an existing destFile aside before the swap) had no copy+delete fallback, unlike the swap and restore steps a few lines below - meaning an overwrite install could always fail on a device where renameTo() is unreliable even for a same-directory move (the exact issue this PR already fixed for the swap/restore steps). Applied the same fallback here too. - ExternalFileInstallViewModel: extend the generation-gating from the previous fix to installation completion, not just dialog dispatch - confirmTemplateInstall() now captures its generation and checks it before sending Finish (so a slow install for an abandoned dialog can't tear down the Activity out from under a newer, unrelated dialog) and before touching `_isInstalling` (so a stale install completing can't re-lock a newer dialog's buttons). dispatchTemplateInstall() now also resets `_isInstalling` when committing to show a new dialog, so it isn't left stuck "true" by an abandoned generation's still-running install. - InstallTempFiles: throttle sweepStale() to once per 10 minutes instead of a full directory scan on every single temp-file creation - stale entries can only appear once per hour (MAX_AGE_MS) regardless. Verification note: the physical test device was unreachable this round (disconnected mid-session) - verified via the full relevant unit test suite (including a new test locking in the isInstalling-scoping fix) and careful tracing of the generation-check logic, which builds directly on the already on-device-verified mechanism from the previous commit. Co-Authored-By: Claude Sonnet 5 * ADFA-4934: Fix findings from fifth high-effort code review of PR #1682 - PluginManagerActivity: a .cgp forwarded from ExternalFileInstallActivity could stack a second Plugin Manager instance on top of one the user already had open/backgrounded. ForwardToPluginManager's launch Intent now carries FLAG_ACTIVITY_CLEAR_TOP|FLAG_ACTIVITY_SINGLE_TOP, and PluginManagerActivity gained an onNewIntent() override (mirroring ExternalFileInstallActivity's own singleTask handling) so a reused instance still processes the forwarded install instead of silently dropping it. - PluginManagerActivity: the forwarded-install file.exists() check ran synchronously on the main thread during onCreate()/onNewIntent(); moved onto Dispatchers.IO like the other file-system checks in this flow. - PluginManagerActivity: dropped showInstallConfirmation's redundant forceDeleteSource parameter - it was 100% determined by source's runtime type at both call sites, so compute it internally instead. - ExternalFileInstallViewModel: confirmTemplateInstall's success message now includes the target base name, so the toast is unambiguous even when a slow install completes after a newer, unrelated dialog has already taken over the screen (it must still fire per the existing isInstalling-scoping test - the install genuinely succeeded). - ExternalFileInstallViewModel: collapsed the two structurally-identical plugin/template availability-check blocks into one. - InstallTempFiles: lastSweepAtMs is read/written from coroutines PluginManagerViewModel and ExternalFileInstallViewModel can launch on different dispatchers - switched to AtomicLong with compareAndSet so two near-simultaneous callers can't both pass the throttle check. Not fixed (out of scope / pre-existing, not regressions from this PR): - InstallFileAction not recognizing .cgt for the in-editor "Install" action - a new feature (wiring TemplateCollectionRepository into that action), not a bug in the external-open flow this ticket covers. - ITemplateProvider.getInstance(reload=true) rescanning all installed collections on every install - existing reload API behavior, not something introduced here. - FeedbackManager/FeedbackEmailHandler's duplicated PixelCopy capture logic - pre-existing, unrelated duplication only touched by this PR's formatting pass. Verification: full app unit test suite green; on-device (R5CN80KZCKD) reproduction of the duplicate-instance scenario (two .cgp VIEW intents in quick succession while the first's confirm dialog is still open) confirms a single PluginManagerActivity instance (same ActivityRecord/ task) handles both, no crash. Co-Authored-By: Claude Sonnet 5 * ADFA-4934: Fix ktlint line-length wrap in ExternalFileInstallUiModels Co-Authored-By: Claude Sonnet 5 * ADFA-4934: Fix findings from sixth high-effort code review of PR #1682 - ExternalFileInstallViewModel: confirmTemplateInstall() captured the live currentRequestGeneration counter instead of the generation the on-screen dialog actually belongs to. A second VIEW intent bumps that counter synchronously before its own dialog is shown, so tapping Install/Overwrite/Rename on the still-visible (but now stale) prior dialog in that window got misattributed to the newer generation - on success this incorrectly sent Finish, tearing the Activity down (and its viewModelScope) out from under the newer, still in-flight request. Fixed by tracking pendingConfirmationGeneration alongside pendingConfirmationTempFile and keying confirmTemplateInstall() off that; also guards against acting on a tempFile that's already been superseded (and deleted) entirely. - ExternalFileInstallViewModel: IgnoreTemplateInstall had the same root cause - a stale Cancel tap unconditionally sent Finish regardless of whether pendingConfirmationTempFile still matched. Now a no-op when it doesn't. - ExternalFileInstallViewModel: suggestUniqueBaseName()'s attempt-bound check ran before the collision check, so the final candidate returned when MAX_SUGGESTION_ATTEMPTS is hit was never actually checked for collision. Reordered the && operands so the bound only short-circuits after that last check has run. - ExternalFileInstallViewModel: template install failures showed a generic, non-actionable message; now includes the underlying reason (reserved name / already exists / swap failure) via a %1$s arg, matching PluginManagerViewModel's equivalent error path. - PluginManagerViewModel: _uiEffect used the default rendezvous channel, the same latent drop-before-collector-attaches bug class this PR already fixed for ExternalFileInstallViewModel's channel. Switched to Channel.BUFFERED for consistency; not user-visible today, but the channel now also serves the forwarded-.cgp path. - TemplateCollectionRepositoryImpl: extracted the renameTo()+copyTo() fallback (triplicated across the backup/swap/restore steps) into a single moveFile() helper. Not fixed (narrow races / low-value at this stage, not regressions): - installCollection() has no per-destination-name locking, so two concurrent installs to the same target name could race on the final swap. Requires two attachments sharing a name AND overlapping generations to hit; accepted as last-write-wins for now. - InstallTempFiles' hour-old sweep could delete a pending confirmation's temp file if the user leaves a dialog open that long; would need cross-ViewModel "file in use" tracking to fix properly. - installPlugin() awaits initialLoadCompleted before copying the incoming URI, when the two could run concurrently - a cold-start-only latency nicety, not a correctness issue. - dispatchTemplateInstall() does its zip-read/collision-check I/O before its own generation check - inherent to check-then-act, the I/O can't be skipped without knowing in advance it'll be superseded. Verification: full app unit test suite green, including two new regression tests for the generation-capture fix (confirming a stale dialog surfaces success without Finish-ing over a newer request; ignoring a stale dialog doesn't Finish over a newer one) and a strengthened suggestUniqueBaseName test asserting the give-up candidate was actually checked. On-device (R5CN80KZCKD): fresh install and overwrite (via the new moveFile()) both verified with a real .cgt archive - correct dialog content, clean success, no crash, no stray .tmp/.bak files left in the templates directory. Co-Authored-By: Claude Sonnet 5 * ADFA-4934: Address new CodeRabbit findings on PR #1682 - TemplateCollectionRepositoryImpl: installCollection() had no per-destination-name locking, so two concurrent installs targeting the same case-insensitive base name could both pass the collision check before either wrote destFile, and the later swap would silently clobber the earlier one. Wrapped the whole operation in a Mutex keyed by the lowercased target base name (independently flagged by both CodeRabbit and the prior code-review round, which this addresses). - InstallTempFiles: newTempFile() ran mkdirs() and its periodic directory sweep/delete on whatever dispatcher the caller happened to be on - ExternalFileInstallViewModel.onReceived() called it without a surrounding withContext(Dispatchers.IO), so that filesystem work ran on the main thread. Made newTempFile() suspend and dispatch to Dispatchers.IO internally, so no caller can repeat the mistake. Not changed (already-deliberated design decisions / false positive): - CodeRabbit suggested a superseded install's completion should suppress its ShowSuccess effect entirely, since it can render over a newer request's dialog. This was already addressed in the prior commit by including the collection's name in the message so the toast is unambiguous regardless of what's currently on screen - suppressing it outright would mean a genuinely successful install never gets reported to the user. Replied on the thread with this reasoning. - CodeRabbit's JUnit Jupiter migration suggestion doesn't correspond to any actual change in this PR - both flagged test files still use @RunWith(RobolectricTestRunner::class) and plain JUnit4 @Test/runTest, unchanged. Replied noting this appears to be a false positive. Verification: full app unit test suite green. On-device (R5CN80KZCKD): fresh install of a real .cgt archive after these changes - correct dialog, clean success, no crash, no stray .tmp/.bak files in the templates directory. Co-Authored-By: Claude Sonnet 5 * ADFA-4934: Fix a self-inflicted stuck-dialog regression from the sixth review round - ExternalFileInstallViewModel: confirmTemplateInstall() clears pendingConfirmationTempFile on entry (transferring tempFile's "ownership" to the install attempt, per the sixth-round generation fix), but never restored it on install failure. The dialog is deliberately left open so the user can retry - but with pendingConfirmationTempFile left null, every subsequent tap (Install/Overwrite/Rename again, or Cancel/back) silently no-ops forever, since both confirmTemplateInstall() and IgnoreTemplateInstall key off it matching. The excludeFromRecents=true trampoline Activity has no other way out at that point short of force-stopping the app. Fixed by restoring pendingConfirmationTempFile/Generation in the onFailure branch (gated on isCurrentGeneration, same as everything else there) so a retry or cancel on the still-open dialog matches again. - ApkInstaller: isValidApk's extension check was case-sensitive (`== "apk"`), inconsistent with every other extension check this PR touched. An uppercase .APK (common from browsers/email/file managers that preserve sender casing) silently failed with no error shown. Not changed (pre-existing gaps, not regressions from this PR, larger lifts than warranted at this point): - InstallFileAction's file-tab "Install" for .cgp calls PluginRepository.installPluginFromFile() directly, bypassing the signature-mismatch/overwrite-confirmation check every other plugin install entry point goes through - pre-existing behavior, would need routing this action through the same ViewModel-level conflict resolution. - PluginRepositoryImpl.installPluginFromFile has no backup/rollback or per-target locking, unlike TemplateCollectionRepositoryImpl - a structurally similar but substantially larger lift given plugin install's uninstall/restart semantics. - Two distinct .cgp files forwarded to an already-open PluginManagerActivity in quick succession can each pass markPendingInstallHandled's per-value dedup and stack two native AlertDialogs - would need the same generation-tracking machinery ExternalFileInstallViewModel has, ported to the plugin flow's more complex dialog chain. Verification: full app unit test suite green, including two new regression tests (retrying Install after a failed install actually re-attempts it; cancelling after a failed install still finishes) that fail against the pre-fix code and pass against the fix. Co-Authored-By: Claude Sonnet 5 * ADFA-4934: Stack the name-conflict dialog's three buttons vertically AlertDialog's default confirmButton/dismissButton row can't fit three actions (Overwrite / Rename & Install / Cancel) on one line, so it wrapped awkwardly - one button alone on the first row, the other two crammed together on a second row. Moved all three into the confirmButton slot as a right-aligned Column instead, so they stack one per row. Co-Authored-By: Claude Sonnet 5 * ADFA-4934: Add a mimeType-only manifest fallback for opaque content:// Uris QA (Daniel Alome, ticket comment) found that opening a real .cgp attachment from the Files app failed silently: Storage Access Framework providers (Android's Downloads app, most file managers) hand out opaque document IDs like content://.../document/msf%3A19, with no filename anywhere in the Uri. Every existing intent-filter here requires a pathPattern match, so none of them can ever match this - the OS instead fell through to an unrelated app that happened to declare an unconstrained VIEW+content+application/octet-stream filter (Google Pay's pkpass handler), which claimed the single unambiguous match and opened/closed with no chooser and no visible error. This exact tradeoff was already called out as a "known limitation, not fixable via manifest matching" in this file's own comment, on the reasoning that the only pathPattern-less alternative was mimeType="*/*" - which would register this app as a candidate for every file view intent on the device. That reasoning missed a middle ground: a pathPattern-less filter matching only the small, specific set of mimeTypes a binary/zip attachment actually carries (application/octet-stream, application/zip, application/x-zip-compressed) is narrow enough to be worth it. Added as a fourth intent-filter block and updated the manifest's own "known limitations" comment accordingly. This does mean the app now offers itself as an "Open with" candidate for any octet-stream/zip content from any app, not just .cgp/.cgt - accepted since the real extension is still re-validated from DISPLAY_NAME once opened (ExternalFileInstallViewModel.onReceived), so a mismatched file is rejected gracefully rather than mishandled. Verification: `./gradlew :app:processV8DebugMainManifest` succeeds. On a physical device (R5CN80KZCKD), simulated a real Downloads-provider- style opaque content Uri (content://com.android.providers.downloads. documents/document/msf%3A999, type application/octet-stream) via `am start` - confirmed via logcat/dumpsys and a screenshot that "Code on the Go" now appears in the "Open with" chooser, where before this fix it was completely absent from the candidate list (reproducing exactly the bug QA reported). No crash when actually opened. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- ARCHITECTURE.md | 2 +- app/build.gradle.kts | 16 + app/src/main/AndroidManifest.xml | 113 ++- .../actions/file/InstallFileAction.kt | 80 +- .../activities/ExternalFileInstallActivity.kt | 55 ++ .../activities/ExternalFileInstallScreen.kt | 310 +++++++ .../activities/PluginManagerActivity.kt | 92 +- .../com/itsaky/androidide/di/PluginModule.kt | 51 +- .../androidide/dnd/DragAndDropExtensions.kt | 59 +- .../itsaky/androidide/dnd/FileDragStarter.kt | 140 +-- .../handlers/FileTreeActionHandler.kt | 306 +++--- .../repositories/PluginRepositoryImpl.kt | 4 +- .../TemplateCollectionRepository.kt | 38 + .../TemplateCollectionRepositoryImpl.kt | 227 +++++ .../itsaky/androidide/ui/CodeEditorView.kt | 4 +- .../androidide/ui/compose/TooltipInterop.kt | 33 + .../ui/models/ExternalFileInstallUiModels.kt | 52 ++ .../ui/models/PluginManagerUiState.kt | 138 ++- .../itsaky/androidide/utils/ApkInstaller.kt | 53 +- .../androidide/utils/InstallTempFiles.kt | 60 ++ .../itsaky/androidide/utils/IntentUtils.kt | 8 +- .../itsaky/androidide/utils/LastValueGate.kt | 39 + .../androidide/viewmodel/BuildViewModel.kt | 3 +- .../ExternalFileInstallViewModel.kt | 374 ++++++++ .../viewmodels/PluginManagerViewModel.kt | 868 ++++++++++-------- .../TemplateCollectionRepositoryImplTest.kt | 302 ++++++ .../ExternalFileInstallViewModelTest.kt | 406 ++++++++ .../androidide/utils/FeedbackEmailHandler.kt | 188 ++-- .../androidide/utils/FeedbackManager.kt | 377 ++++---- .../androidide/utils/FileProviderUtils.kt | 24 + .../androidide/utils/FlashbarActivityUtils.kt | 100 +- .../itsaky/androidide/utils/FlashbarUtils.kt | 19 + .../androidide/utils/FileProviderUtilsTest.kt | 31 + .../main/java/org/adfa/constants/constants.kt | 3 + gradle/libs.versions.toml | 1 + .../androidide/idetooltips/TooltipTag.kt | 1 + .../plugins/manager/core/PluginManager.kt | 9 +- resources/src/main/res/values/strings.xml | 15 + .../templates/impl/TemplateProviderImpl.kt | 89 +- 39 files changed, 3598 insertions(+), 1092 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallActivity.kt create mode 100644 app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt create mode 100644 app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepository.kt create mode 100644 app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt create mode 100644 app/src/main/java/com/itsaky/androidide/ui/compose/TooltipInterop.kt create mode 100644 app/src/main/java/com/itsaky/androidide/ui/models/ExternalFileInstallUiModels.kt create mode 100644 app/src/main/java/com/itsaky/androidide/utils/InstallTempFiles.kt create mode 100644 app/src/main/java/com/itsaky/androidide/utils/LastValueGate.kt create mode 100644 app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt create mode 100644 app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt create mode 100644 common/src/main/java/com/itsaky/androidide/utils/FileProviderUtils.kt create mode 100644 common/src/test/java/com/itsaky/androidide/utils/FileProviderUtilsTest.kt diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3ecaccc691..dfcf41e4f1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -147,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/app/build.gradle.kts b/app/build.gradle.kts index 2f4fdf7ddc..21c52a5fc2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -34,6 +34,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 = @@ -102,6 +103,10 @@ android { generateLocaleConfig = true } + buildFeatures { + compose = true + } + sourceSets { getByName("androidTest") { manifest.srcFile("src/androidTest/AndroidManifest.xml") @@ -241,6 +246,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) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cf216f8b6c..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/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..16f86299d2 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt @@ -0,0 +1,310 @@ +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 +} + +@Composable +fun ExternalFileInstallScreen(viewModel: ExternalFileInstallViewModel) { + val context = LocalContext.current + var dialogState by remember { mutableStateOf(DialogUiState.None) } + val isInstalling by viewModel.isInstalling.collectAsStateWithLifecycle() + + LaunchedEffect(viewModel) { + viewModel.uiEffect.collect { effect -> + when (effect) { + is ExternalFileInstallUiEffect.ForwardToPluginManager -> { + 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, effect.filePath), + ) + (context as? Activity)?.finish() + } + + 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 -> { + (context as? Activity)?.finish() + } + } + } + } + + 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/PluginManagerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt index a3129fbffb..08fec8b4da 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt @@ -27,6 +27,7 @@ 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.PluginInstallSource import com.itsaky.androidide.ui.models.PluginManagerUiEffect import com.itsaky.androidide.ui.models.PluginManagerUiEvent import com.itsaky.androidide.utils.DURATION_INDEFINITE @@ -39,13 +40,25 @@ import com.itsaky.androidide.utils.flashbarBuilder import com.itsaky.androidide.utils.getFileName import com.itsaky.androidide.utils.showOnUiThread import com.itsaky.androidide.viewmodels.PluginManagerViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION import org.koin.androidx.viewmodel.ext.android.viewModel +import java.io.File class PluginManagerActivity : EdgeToEdgeIDEActivity() { companion object { private const val TAG = "PluginManagerActivity" - private const val PLUGIN_EXTENSION = ".cgp" + private const val PLUGIN_EXTENSION = ".$PLUGIN_ARCHIVE_EXTENSION" + + /** + * Absolute path of a `.cgp` file forwarded from + * [com.itsaky.androidide.activities.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") @@ -75,7 +88,7 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { return@let } - showInstallConfirmation(it) + showInstallConfirmation(PluginInstallSource.ContentUri(it)) } } @@ -103,6 +116,8 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { setupTooltipLongPress() setupFeedbackButton() observeViewModel() + + handlePendingInstallExtra() } catch (e: Exception) { // Log the error and finish the activity if something goes wrong e.printStackTrace() @@ -111,6 +126,40 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { } } + // 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() + } + + // No savedInstanceState guard: markPendingInstallHandled() is the idempotency check, scoped + // to the ViewModel instance rather than the Activity's recreation reason - it survives + // rotation (skips a duplicate dialog there) but resets on process death (a fresh ViewModel is + // created), so a process-death-recreated instance still shows the dialog instead of silently + // dropping the forwarded install. The intent's extra itself is preserved across both cases by + // the OS. + private fun handlePendingInstallExtra() { + intent.getStringExtra(EXTRA_PENDING_INSTALL_FILE_PATH)?.let { filePath -> + if (viewModel.markPendingInstallHandled(filePath)) { + lifecycleScope.launch { + val file = File(filePath) + val exists = withContext(Dispatchers.IO) { file.exists() } + if (exists) { + showInstallConfirmation(PluginInstallSource.LocalFile(file)) + } else { + // Can legitimately happen if InstallTempFiles' stale-file sweep (or an + // earlier failed cleanup) removed the temp file before this dialog ever + // got a chance to show it - a clear message beats a generic install error. + flashError(getString(R.string.msg_plugin_file_not_found)) + } + } + } + } + } + override fun onResume() { super.onResume() feedbackButtonManager?.loadFabPosition() @@ -300,16 +349,32 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { 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) + /** + * For a [PluginInstallSource.LocalFile] (a `.cgp` forwarded from [ExternalFileInstallActivity]), + * [source] is our own hidden temp copy, not a file the user picked - there's no checkbox to + * offer (deletion isn't optional) and no source worth keeping on decline/cancel either, so + * both the negative button and back-press/tap-outside route to [PluginManagerUiEvent.CancelPendingInstall]. + * One shared dialog builder for both cases so a future button/copy change can't be applied to + * only one branch and silently reintroduce a leaked-temp-file bug in the other. + */ + private fun showInstallConfirmation(source: PluginInstallSource) { + val forceDeleteSource = source is PluginInstallSource.LocalFile + val dialogView = if (forceDeleteSource) null else layoutInflater.inflate(R.layout.dialog_install_plugin, null) + val deleteCheckBox = dialogView?.findViewById(R.id.checkbox_delete_source) + val onCancel = { + if (forceDeleteSource) { + viewModel.onEvent(PluginManagerUiEvent.CancelPendingInstall(source)) + } + } MaterialAlertDialogBuilder(this) .setTitle(R.string.title_install_plugin) - .setView(dialogView) + .apply { dialogView?.let { setView(it) } } .setPositiveButton(R.string.btn_install) { _, _ -> - viewModel.onEvent(PluginManagerUiEvent.InstallPlugin(uri, deleteCheckBox.isChecked)) - }.setNegativeButton(android.R.string.cancel, null) + val deleteSourceAfterInstall = if (forceDeleteSource) true else deleteCheckBox?.isChecked == true + viewModel.onEvent(PluginManagerUiEvent.InstallPlugin(source, deleteSourceAfterInstall)) + }.setNegativeButton(android.R.string.cancel) { _, _ -> onCancel() } + .setOnCancelListener { onCancel() } .show() } @@ -325,10 +390,15 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { ), ).setPositiveButton(R.string.replace) { _, _ -> viewModel.onEvent( - PluginManagerUiEvent.ConfirmOverwrite(effect.uri, effect.deleteSourceAfterInstall), + PluginManagerUiEvent.ConfirmOverwrite(effect.source, effect.deleteSourceAfterInstall), ) - }.setNegativeButton(android.R.string.cancel, null) - .show() + }.setNegativeButton(android.R.string.cancel) { _, _ -> + viewModel.onEvent(PluginManagerUiEvent.CancelPendingInstall(effect.source)) + }.setOnCancelListener { + // Same reasoning as showInstallConfirmation()'s onCancelListener: back-press must + // route through CancelPendingInstall too, or a forwarded source's temp file leaks. + viewModel.onEvent(PluginManagerUiEvent.CancelPendingInstall(effect.source)) + }.show() } private fun showUninstallConfirmation(plugin: PluginInfo) { 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..fc3a7b0e0a 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(androidContext().filesDir, "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 = androidContext().filesDir, + ) + } + + viewModel { + ExternalFileInstallViewModel( + pluginRepository = get(), + templateCollectionRepository = get(), + contentResolver = androidContext().contentResolver, + filesDir = androidContext().filesDir, + ) + } + } 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/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/repositories/PluginRepositoryImpl.kt b/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt index 565d989c2f..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 /** @@ -141,7 +142,8 @@ class PluginRepositoryImpl( Log.w(TAG, "Error uninstalling existing plugin: ${e.message}") } - val fileExtension = if (pluginFile.name.endsWith(".cgp")) ".cgp" else ".apk" + val fileExtension = + if (pluginFile.name.endsWith(".$PLUGIN_ARCHIVE_EXTENSION", ignoreCase = true)) ".$PLUGIN_ARCHIVE_EXTENSION" else ".apk" val finalFileName = "${pluginId}$fileExtension" if (!pluginsDir.exists()) { 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/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/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/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..07c4642fbb 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 @@ -4,51 +4,119 @@ import android.net.Uri import androidx.annotation.StringRes import com.itsaky.androidide.plugins.PluginInfo import com.itsaky.androidide.plugins.PluginMetadata +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). + */ +sealed class PluginInstallSource { + data class ContentUri( + val uri: Uri, + ) : PluginInstallSource() + + 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() + + object OpenFilePicker : 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() + + object OpenFilePicker : 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/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/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/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..17d9f5cebe 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,19 @@ 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 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 +30,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 +38,504 @@ 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() + + /** See [pendingInstallGate] for why this, rather than an Activity `savedInstanceState` + * check, is what correctly distinguishes "already shown after a rotation" from "never shown + * because the process died". */ + fun markPendingInstallHandled(filePath: String): Boolean = pendingInstallGate.consume(filePath) + + // 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.OpenFilePicker -> { + openFilePicker() + } + + 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.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 + // 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.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( + 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.trySend(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_installed)) + loadPlugins() + _uiEffect.trySend(PluginManagerUiEffect.ShowRestartPrompt) + + if (deleteSourceAfterInstall) { + deleteInstallSource(source) + } + }.onFailure { exception -> + Log.e(TAG, "Failed to install plugin", exception) + _uiEffect.trySend( + 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.trySend( + 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.trySend(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.trySend( + 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.trySend( + 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.trySend( + 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.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 = + 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/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/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/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/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/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..4e67e29197 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 @@ -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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9c4e15649b..5648a02daf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -137,6 +137,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" } 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 02a0571d1f..48cd034416 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,7 @@ 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_TABBED_ACTIVITY = "template.tabbed.activity" const val TEMPLATE_LEGACY_PROJECT = "template.legacy.project" const val TEMPLATE_EMPTY_ACTIVITY = "template.empty.activity" 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 ba895ebd7c..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 @@ -385,7 +386,7 @@ 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") @@ -488,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)) @@ -840,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) { @@ -870,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()) { diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 97d441fbbb..15fa7d11b0 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1016,6 +1016,7 @@ Error Warning Information + Show help Quick run @@ -1089,6 +1090,20 @@ https://www.appdevforall.org/contribute/ %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 + "%1$s" installed successfully + Invalid or corrupted template collection file. + Failed to install template collection: %1$s + \n\nProject creation finished with warnings/errors. Open IDE Logs for details. 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() + } } From bcb10936b343bb5f67c516348be5a831794979e0 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 18 Aug 2026 16:51:51 -0700 Subject: [PATCH 24/40] ADFA-5067 New assetlinks.json generation workflow (#1693) * New assetlinks.json generation workflow * Serve assetlinks.json from R2 via a Worker, not an Origin Rule The Origin Rule approach the previous commit assumed cannot work on our Cloudflare Free plan: R2 selects a bucket from the Host header, and host header, SNI and DNS record overrides are all Enterprise-only. Free exposes only the destination-port override. A Worker replaces the origin fetch rather than retargeting it, and reaches the bucket through an R2 binding - an in-network handle, not a URL - so no DNS, TLS or Host header is involved and the bucket keeps public access off. - infra/well-known-worker: the Worker, wrangler.toml and a README covering the plan constraint, the bucket/token prerequisites and how to verify. - deploy-well-known-worker.yml: deploys it via cloudflare/wrangler-action. Needs a new CLOUDFLARE_WORKERS_DEPLOY_TOKEN secret; the existing CLOUDFLARE_KEY_ID / CLOUDFLARE_SECRET_ACCESS_KEY pair is an R2 S3 credential and cannot deploy a Worker. - signing-fingerprint.yml: comments and failure hints now name the Worker. No functional change - it already writes the key the Worker reads. Routes match exact paths rather than /.well-known/*, so certificate renewal via /.well-known/acme-challenge/ still reaches the origin, and a request with no matching object falls through to the origin as well. ADFA-5067 * Document the R2 read scope the Worker deploy actually needs Run 32197717567 failed with "Authentication error [code: 10000]" on GET /accounts//r2/buckets/well-known: wrangler resolves the bucket named in the r2_buckets binding before finishing the deploy, so the token needs Workers R2 Storage -> Read on top of Workers Scripts and Workers Routes. Records the API call and the exact error so the next person does not have to rediscover it from a failed run. ADFA-5067 * TEMP: probe Cloudflare token scopes in the Worker deploy The deploy fails on GET /accounts//r2/buckets/well-known even with Workers R2 Storage Read on the token. Probe /user/tokens/verify plus the bucket list and bucket detail endpoints to find which grant is missing, and to confirm the stored secret is the token we think it is. To be reverted. ADFA-5067 * Revert the token-scope probe; note the scope propagation delay The probe answered the question: Workers R2 Storage Read is both required and sufficient, and the stored secret was always the right token. The two failures came from re-running about a minute after the scope was added, before it had taken effect. Reverts the TEMP diagnostic step and records the delay next to the scope list so the next person does not read the stale error as a wrong scope. ADFA-5067 --- .../workflows/deploy-well-known-worker.yml | 91 +++++ .github/workflows/signing-fingerprint.yml | 341 ++++++++++++++++++ infra/well-known-worker/README.md | 65 ++++ infra/well-known-worker/src/index.js | 41 +++ infra/well-known-worker/wrangler.toml | 22 ++ 5 files changed, 560 insertions(+) create mode 100644 .github/workflows/deploy-well-known-worker.yml create mode 100644 .github/workflows/signing-fingerprint.yml create mode 100644 infra/well-known-worker/README.md create mode 100644 infra/well-known-worker/src/index.js create mode 100644 infra/well-known-worker/wrangler.toml 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/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" From d36a8bbc1d238be0f7f55366fa1770b000364e0e Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 18 Aug 2026 19:53:56 -0700 Subject: [PATCH 25/40] ADFA-5098: Require font-scale verification for new and changed screens (#1657) * ADFA-5098: Require font-scale verification for new and changed screens CoGo is built for developers with limited vision, but nothing in our docs asked anyone to check a screen at a large system font. REVIEW.md covered TalkBack semantics only, and CLAUDE.md had no accessibility guidance at all. Text units are already correct repo-wide (0 dp text sizes, 65 sp), so this is a layout reflow problem, not a units problem. The failure mode is text that grows into a container that cannot: an sp dimen used as a margin, a 40dp box around a label, ellipsize="none", or content with nowhere to scroll. - CLAUDE.md: new constraint bullet requiring 1.0/2.0 verification, plus the adb recipe under Build & test -> Emulator / device. - REVIEW.md: widen section 8 to cover scaling, add a checklist item and an evidence-ledger entry. Required for new/changed screens, with a one-line opt-out for surfaces with no text. - architecture-review skill: rule 12, so the section 10 deep pass checks font scale the way it checks the system bars. Manual verification rather than a test: the repo has no screenshot testing, no Compose UI-test dependency, and has never used Robolectric qualifiers. This mirrors how section 11 already handles offline verification. Every command in the new CLAUDE.md block was run against an API 36 emulator before being written down. * ADFA-5098: Close three gaps in the font-scale guidance Follow-ups to acb76fe5f, all in the docs it touched. The rule named only 2.0 in the architecture-review rule 12 and the REVIEW.md checklist item, and the REVIEW.md evidence line asked for a screenshot at 2.0 alone. All three now require 1.0 and 2.0, matching what CLAUDE.md and the REVIEW.md summary row already said. Verifying at one scale does not show that a layout reflows; it shows one snapshot of it. The architecture-review applicability line mapped 9 of its 12 rules to a change type, orphaning rules 3 (Koin), 7 (module boundaries) and 10 (strings) -- a reviewer following the line literally would skip them. Each now has a scope, plus a catch-all so a rule added to the table later is not dropped by omission. The CLAUDE.md adb recipe printed the current font_scale but never captured it, then restored a hard-coded 1.0. A device sitting at 1.15 was left at 1.0, and a device that never had the setting had one created. The value is now captured (stripping the CRLF adb shell returns), an EXIT trap restores it so the restore also fires if screencap dies partway, and the null case deletes the setting rather than inventing a value. No device was attached, so unlike the parent commit the recipe was verified against a stub adb that records writes and replays a seeded value. Seeds 1.15, null and 2.0 each end at their starting state; the old block failed the first two. --- .claude/skills/architecture-review/SKILL.md | 3 ++- CLAUDE.md | 17 +++++++++++++++++ REVIEW.md | 14 ++++++++++++-- 3 files changed, 31 insertions(+), 3 deletions(-) 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/CLAUDE.md b/CLAUDE.md index dc3b64ee94..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. @@ -39,6 +55,7 @@ See **[ARCHITECTURE.md](ARCHITECTURE.md)** — the single source of truth for th - **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/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. From 4f8d1dbccb681ca192260acbf2ab05a09cd8089f Mon Sep 17 00:00:00 2001 From: Daniel-ADFA Date: Wed, 19 Aug 2026 10:03:06 +0100 Subject: [PATCH 26/40] ADFA-5046: Add Java code action: surround with try/catch (#1686) * ADFA-5046: Add Java code action: surround with try/catch * ADFA-5046: Apply Spotless formatting to SurroundWithTryCatch.kt --------- Co-authored-by: Daniel Alome --- .../androidide/idetooltips/TooltipTag.kt | 1 + .../lsp/actions}/SurroundWithTryCatch.kt | 37 +++-- .../lsp/actions/SurroundWithTryCatchAction.kt | 131 ++++++++++++++++++ .../lsp/actions}/SurroundWithTryCatchTest.kt | 66 +++++++-- .../lsp/java/actions/JavaCodeActionsMenu.kt | 12 ++ .../lsp/kotlin/KotlinCodeActionsMenu.kt | 13 +- .../actions/SurroundWithTryCatchAction.kt | 79 ----------- .../kotlin/KotlinCodeActionTooltipTagTest.kt | 5 +- 8 files changed, 236 insertions(+), 108 deletions(-) rename lsp/{kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils => api/src/main/java/com/itsaky/androidide/lsp/actions}/SurroundWithTryCatch.kt (64%) create mode 100644 lsp/api/src/main/java/com/itsaky/androidide/lsp/actions/SurroundWithTryCatchAction.kt rename lsp/{kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils => api/src/test/java/com/itsaky/androidide/lsp/actions}/SurroundWithTryCatchTest.kt (63%) delete mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/SurroundWithTryCatchAction.kt 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 48cd034416..b4b8c1c753 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -82,6 +82,7 @@ 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). 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/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 1188a15022..288e2d095d 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,6 +4,7 @@ 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.FindReferencesAction @@ -11,12 +12,13 @@ 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( @@ -36,7 +38,14 @@ object KotlinCodeActionsMenu : IActionsMenuProvider { 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(), ) 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/test/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionTooltipTagTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionTooltipTagTest.kt index 352e81eaec..79acdcc998 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,6 +2,7 @@ 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 @@ -10,7 +11,6 @@ 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 @@ -40,7 +40,8 @@ class KotlinCodeActionTooltipTagTest { 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, ) assertEquals(expected, actualTags) } From 18ea1bbd5c91ac40f9935f885a3f0c0debb29d90 Mon Sep 17 00:00:00 2001 From: Daniel-ADFA Date: Wed, 19 Aug 2026 15:09:23 +0100 Subject: [PATCH 27/40] ADFA-4754: Capture n/a tooltips and display a friendly sorry message with link to webhelp (#1687) Co-authored-by: Daniel Alome --- .../androidide/idetooltips/ToolTipManager.kt | 27 ++++++++++++++++--- resources/src/main/res/values/strings.xml | 1 + 2 files changed, 24 insertions(+), 4 deletions(-) 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/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 15fa7d11b0..276e005269 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1221,6 +1221,7 @@ %3$s]]> + Explore the documentation.]]> Send feedback From 28e00f1e16599aab1f65021cbd258cad978be071 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Andr=C3=A9s=20Trujillo?= <34223334+jatezzz@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:20:49 -0500 Subject: [PATCH 28/40] ADFA-5148 | Add tool contribution contract for the AI agent (#1685) * feat(plugin-api): add tool contribution contract for the AI agent Versioned, additive-only registry contract; docs, changelog and ABI dump updated. * docs(plugin-api): correct contract-version and ownership claims, test defaults --- docs/PLUGIN_API_CHANGELOG.md | 22 ++ docs/plugin-api.md | 2 +- plugin-api/api/plugin-api.api | 37 +++ .../plugins/services/ToolSourceRegistry.java | 250 ++++++++++++++++++ .../services/ToolSourceRegistryTest.java | 127 +++++++++ 5 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 plugin-api/src/main/java/com/itsaky/androidide/plugins/services/ToolSourceRegistry.java create mode 100644 plugin-api/src/test/java/com/itsaky/androidide/plugins/services/ToolSourceRegistryTest.java diff --git a/docs/PLUGIN_API_CHANGELOG.md b/docs/PLUGIN_API_CHANGELOG.md index 804a212381..1611f20b16 100644 --- a/docs/PLUGIN_API_CHANGELOG.md +++ b/docs/PLUGIN_API_CHANGELOG.md @@ -36,6 +36,28 @@ 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 diff --git a/docs/plugin-api.md b/docs/plugin-api.md index ed5ebee0ee..43c52cd0d1 100644 --- a/docs/plugin-api.md +++ b/docs/plugin-api.md @@ -12,7 +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`). + - 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:** diff --git a/plugin-api/api/plugin-api.api b/plugin-api/api/plugin-api.api index 24e2cb1765..af250a9c96 100644 --- a/plugin-api/api/plugin-api.api +++ b/plugin-api/api/plugin-api.api @@ -1667,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/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/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"; + } + } +} From b24c0415ceb9aee6c8f8334ec2fbd143984294a8 Mon Sep 17 00:00:00 2001 From: itsaky-adfa Date: Thu, 20 Aug 2026 20:39:33 +0530 Subject: [PATCH 29/40] ADFA-4826: Add shared IDE Compose theming in common-compose (#1653) * ADFA-4826: Add shared IDE Compose theming in common-compose New leaf module holding the Compose theme any module can opt into: IdeColorScheme derives a Material3 scheme from the IDE's own colour resources, IdeTheme applies it and seeds LocalContentColor so text on a themed surface inherits the right colour. Compose types are exposed as `api` because consumers write Compose against them. Modules that are not Compose depend on nothing new. * ADFA-4826: Move profiler and floating-window onto the shared theming Both modules carried their own near-identical copy of the IDE colour derivation. They now delegate to common-compose, so there is one place where the IDE's Compose colours are defined. --- ARCHITECTURE.md | 2 +- common-compose/build.gradle.kts | 29 +++++ .../common/compose/IdeColorScheme.kt | 86 ++++++++++++ .../androidide/common/compose/IdeTheme.kt | 48 +++++++ .../common/compose/IdeColorSchemeTest.kt | 123 ++++++++++++++++++ floating-window/build.gradle.kts | 1 + .../androidide/floating/ui/FloatingTheme.kt | 55 ++------ profiler/build.gradle.kts | 1 + .../cotg/profiler/ui/theme/ProfilerTheme.kt | 73 ++--------- settings.gradle.kts | 1 + 10 files changed, 308 insertions(+), 111 deletions(-) create mode 100644 common-compose/build.gradle.kts create mode 100644 common-compose/src/main/java/com/itsaky/androidide/common/compose/IdeColorScheme.kt create mode 100644 common-compose/src/main/java/com/itsaky/androidide/common/compose/IdeTheme.kt create mode 100644 common-compose/src/test/java/com/itsaky/androidide/common/compose/IdeColorSchemeTest.kt diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index dfcf41e4f1..59df122920 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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):** 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/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/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/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", From 9bc17d94f460249b8be1ce6b6489b03e7f96905d Mon Sep 17 00:00:00 2001 From: itsaky-adfa Date: Thu, 20 Aug 2026 20:39:33 +0530 Subject: [PATCH 30/40] ADFA-4826: Kotlin extract variable code action (K2 LSP) (#1654) * ADFA-4826: Enable Compose in lsp/kotlin The refactoring bottom sheets are Compose (ADR 0009) and live in this module rather than a UI module because `editor` depends on it, not the reverse (ADR 0011). Adds the lifecycle-runtime-compose catalog entry for collectAsStateWithLifecycle(). * ADFA-4826: Add extract-variable analysis, plan and rewrite One background analysis pass produces a plain-data ExtractionPlan covering every candidate expression - its legal scope chain, occurrence set and suggested name - so the UI does pure offset arithmetic and never touches PSI (ADR 0011). Occurrence matching is symbol-aware, not textual: two sites match only when they are structurally equal and every name reference resolves to the same declaration. Sites made unsound by an intervening write are excluded rather than warned about. * ADFA-4826: Add the extract-variable Compose sheet One surface holding every choice - expression, name, scope, replace-all - because they are interdependent: a different expression changes the scope list and the occurrence count, and sequential dialogs would hide that. Each chooser is hidden when it has nothing to ask. State derives entirely from the plan, so the ViewModel is a plain unit test with no editor, activity or Compose. Uses the shared IdeTheme from common-compose. * ADFA-4826: Wire up the extract-variable code action execAction runs the analysis off the UI thread and returns the plan; postExec shows the sheet and turns the user's choice into one spanning TextEdit. The document version is re-read on confirm - the editor stays reachable while the sheet is open, and applying spans computed against older text would corrupt the file. No prepare() visibility gate: deciding extractability needs an analysis session, far too costly for the UI thread. Records the placement decision as ADR 0011. * ADFA-4826: Document the extract-variable requirements Requirements, scope, non-goals, acceptance criteria and the test split, following the kotlin-goto-definition.md template. Also carries the Language section for the whole refactoring family - extract method, inline variable and rename all reuse this vocabulary rather than restating it. * ADFA-4826: Stop offering the lambda that wraps the expression * ADFA-4826: Label a block rung by the construct that owns it * ADFA-4826: Fix misleading KDoc and add else block test Remove dead code path (owner.then === branch can never be true). Correct the KDoc to accurately describe that getThen()/getElse() return unwrapped body expressions, not containers, so branch identity is checked via owner.then?.parent === container. Add test for braced else branch to prevent regression. * ADFA-4826: Write the return type when converting an expression body * ADFA-4826: Anchor the declaration in the scope the user picked * ADFA-4826: Cover contentSpanOf and fix a nested-block fixture * ADFA-4826: Expand a block written on one line * ADFA-4826: Expand only a block that is really written on one line * ADFA-4826: Split the type-text renderer from its catching form * ADFA-4826: Decline a block whose statement shares the brace line A block whose first served statement shares the opening-brace line but whose content spans several lines fell through the one-line-expansion check into the normal hoist path, anchoring above the block's own opening delimiter -- outside the scope the user picked. For a lambda this put the declaration where `it` is unresolved, emitting Kotlin that does not compile. Also fix contentSpanOf: it decided brace ownership by sniffing the block's own text for a leading `{` and trailing `}`, which misreads a lambda whose sole statement is itself a lambda literal (`{ x -> { x + 1 } }`) as owning its braces, returning the inner lambda's interior instead of the outer body's content. Ownership is now decided structurally, from the block's parent. * ADFA-4826: Tidy the expression-body conversion and its docs Nothing was folded into the Unit case when deciding whether an expression-body conversion needs a `return`, so a Nothing-returning function (`fun boom() = error(...)`) lost both its `return` and its inferred return type, silently narrowing it to Unit and breaking a caller that uses it in a Nothing position (`x ?: boom()`). Only Unit is excluded now; Nothing goes through the normal return-type-writing path. Also: - Dedupe the symbol-to-return-type lookup into one KaSession.returnTypeOf, dropping the always-succeeding `as? KtDeclaration` cast. - ScopeChain: drop the unread ScopeFrame.statementSpan field and the dead `branch` local. - TypeText: document that the "anonymous"/"ERROR" substring checks in isUnrenderableTypeText are ambiguous but fail safe, and stop shortening a star-imported type when the file also imports a different type of the same simple name. - docs/features/kotlin-extract-variable.md: reword the Status line, the "Refactoring plan" glossary entry and a code comment that referenced the RefactoringPlan supertype and ADR 0013 as already landed -- both arrive with extract method (ADFA-5080); fix the "Anchor point" glossary entry to match the current anchoring behaviour; renumber the 9a/9b acceptance criteria into real ordered items. * chore: remove plan docs Signed-off-by: Akash Yadav * ADFA-4826: Refuse an unhostable block rung at plan time * ADFA-4826: Keep replace-all off an unhostable anchor * ADFA-4826: Validate the variable name against names actually in scope * ADFA-4826: Decide Unit-ness from the type text that gets written * ADFA-4826: Resolve a whitespace-only selection like a caret * ADFA-4826: Offer the expression chooser for an exact selection too * ADFA-4826: Apply Spotless formatting * ADFA-4826: Address whole-branch review findings --------- Signed-off-by: Akash Yadav --- ...oring-ui-lives-in-the-owning-lsp-module.md | 52 + docs/adr/README.md | 1 + docs/features/kotlin-extract-variable.md | 285 +++++ ...t-plugin-coordinates-localmvnrepository.md | 601 ---------- gradle/libs.versions.toml | 2 + .../androidide/idetooltips/TooltipTag.kt | 1 + lsp/kotlin/build.gradle.kts | 23 + .../lsp/kotlin/KotlinCodeActionsMenu.kt | 2 + .../kotlin/actions/ExtractVariableAction.kt | 155 +++ .../refactor/ui/ExtractVariableSheet.kt | 117 ++ .../ui/ExtractVariableSheetContent.kt | 200 +++ .../refactor/ui/ExtractVariableUiState.kt | 71 ++ .../refactor/ui/ExtractVariableViewModel.kt | 118 ++ .../utils/refactor/CandidateExpressions.kt | 219 ++++ .../utils/refactor/ExtractVariableEdit.kt | 335 ++++++ .../utils/refactor/ExtractVariablePlanner.kt | 219 ++++ .../kotlin/utils/refactor/ExtractionPlan.kt | 182 +++ .../kotlin/utils/refactor/NameSuggestion.kt | 155 +++ .../lsp/kotlin/utils/refactor/Occurrences.kt | 364 ++++++ .../lsp/kotlin/utils/refactor/ScopeChain.kt | 293 +++++ .../lsp/kotlin/utils/refactor/TypeText.kt | 143 +++ .../kotlin/KotlinCodeActionTooltipTagTest.kt | 2 + .../ui/ExtractVariableViewModelTest.kt | 185 +++ .../utils/refactor/ExtractVariableEditTest.kt | 626 ++++++++++ .../ExtractVariablePlanEndToEndTest.kt | 1068 +++++++++++++++++ .../utils/refactor/RefactorPrimitivesTest.kt | 237 ++++ resources/src/main/res/values/strings.xml | 18 + 27 files changed, 5073 insertions(+), 601 deletions(-) create mode 100644 docs/adr/0013-refactoring-ui-lives-in-the-owning-lsp-module.md create mode 100644 docs/features/kotlin-extract-variable.md delete mode 100644 docs/superpowers/plans/2026-07-28-inject-plugin-coordinates-localmvnrepository.md create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ExtractVariableAction.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableSheet.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableSheetContent.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableUiState.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModel.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/CandidateExpressions.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/NameSuggestion.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/Occurrences.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModelTest.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt 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/README.md b/docs/adr/README.md index c682eefaca..dfabb5e15e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -26,3 +26,4 @@ Format is lightweight **MADR / Nygard**: Context → Decision → Consequences | [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 | diff --git a/docs/features/kotlin-extract-variable.md b/docs/features/kotlin-extract-variable.md new file mode 100644 index 0000000000..b60f10323d --- /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 the decline-rather-than-rewrite principle, recorded as ADR 0013 alongside extract method (ADFA-5080). + +## 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 - refactorings decline rather than rewrite unselected code (lands with extract method, ADFA-5080) +- [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 +- ADFA-5080 - extract method, the sibling refactoring; it reuses this vocabulary and these primitives +- [ARCHITECTURE.md](../../ARCHITECTURE.md) 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5648a02daf..13124ed35f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } 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 b4b8c1c753..22761c2ccd 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -95,6 +95,7 @@ object TooltipTag { 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 EXIT_TO_MAIN = "exit.to.main" 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 288e2d095d..7990d4b8b5 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 @@ -7,6 +7,7 @@ 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.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 @@ -48,5 +49,6 @@ object KotlinCodeActionsMenu : IActionsMenuProvider { ), NullSafetyAction(), ImplementMembersAction(), + ExtractVariableAction(), ) } 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/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..25409974ee --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableSheetContent.kt @@ -0,0 +1,200 @@ +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.selectable +import androidx.compose.foundation.selection.selectableGroup +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.RadioButton +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.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.itsaky.androidide.lsp.kotlin.utils.refactor.NameProblem +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)) + } + } + } +} + +@Composable +private 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 +private 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 the 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/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/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/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..85a3180a4d --- /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( + val fileText: String, + val documentVersion: Int, + val candidates: List, +) { + 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/NameSuggestion.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/NameSuggestion.kt new file mode 100644 index 0000000000..3427571a18 --- /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 makeUnique(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. */ +private fun makeUnique( + 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..84ad6b7ff3 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/Occurrences.kt @@ -0,0 +1,364 @@ +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 needs its own case: `it` has **no source PSI**, so the ordinary + * psi-based lookup finds nothing and would report "unconstrained", happily hoisting `it.length` clean + * out of its lambda into code that does not compile. 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. + */ +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. */ +private 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/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 79acdcc998..4b3905fd4b 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 @@ -6,6 +6,7 @@ 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.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 @@ -42,6 +43,7 @@ class KotlinCodeActionTooltipTagTest { ImplementMembersAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_IMPLEMENT_MEMBERS, SurroundWithTryCatchAction.idFor(KT_LANG) to TooltipTag.EDITOR_CODE_ACTIONS_KT_SURROUND_TRY_CATCH, + ExtractVariableAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_EXTRACT_VARIABLE, ) assertEquals(expected, actualTags) } 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/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/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 276e005269..7d5b69df4e 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -523,6 +523,24 @@ 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. Select fields No fields selected No fields found From de4b2ed556338465c2eda046fd13337dcf145ea5 Mon Sep 17 00:00:00 2001 From: itsaky-adfa Date: Thu, 20 Aug 2026 20:39:33 +0530 Subject: [PATCH 31/40] ADFA-5080: Kotlin extract method code action (K2 LSP) (#1655) * ADFA-5080: Add extract-method requirements and ADR 0013 Requirements only - no implementation yet. R1 to R16 plus non-goals, 21 acceptance criteria, the design and the test split; shared vocabulary and primitives come from kotlin-extract-variable.md rather than being restated. ADR 0013 records the principle most of those requirements are an application of: the refactoring moves code, never edits the interior of what it moved, and declines with a specific reason where it cannot transform faithfully. Two limitations it creates are tracked separately - ADFA-5081 (multi-edit undo) and ADFA-5082 (reassigned outer var as the single output). * ADFA-5080: Hoist the shared refactoring plan supertype * ADFA-5080: Resolve a selection to an extraction region * ADFA-5080: Fix KDoc and pin fallthrough for extraction region * ADFA-5080: Add the extract-method plan model and its two rewrites * ADFA-5080: Strengthen the CRLF test and cover the Unit-expression case * ADFA-5080: Derive the extracted signature, or a typed refusal * ADFA-5080: Close the extract-method refusal gaps that emit broken Kotlin * ADFA-5080: Refuse the receiver, label and smart-cast cases that emit broken Kotlin * ADFA-5080: Declare a local extracted function before its call site * ADFA-5080: Add the extract-method sheet state and strings * ADFA-5080: Reword two extract-method refusal messages * ADFA-5080: Add the extract-method Compose sheet * ADFA-5080: Wire up the extract-method code action * ADFA-5080: Stop the analysis emitting Kotlin that does not compile Five ordinary shapes produced a broken file rather than a refusal, which ADR 0012 rules out: the refactoring moves code and declines where it cannot. - Signature types render fully qualified. A short name resolves only when the file already imports it, and a local's type usually comes from inference, so `val d = java.util.Date()` emitted an unresolved `Date`. `usedTypeOf` moves to the same renderer, or every capture would read as a smart cast. - A platform type is emitted as its flexible type's lower bound instead of `String!`, which does not parse. `!` anywhere in a rendered type now counts as unrenderable, catching the nested `List` the lower bound leaves. - `suspend` is no longer added for a call the region only makes inside a nested suspend-typed lambda. `launchIt { work() }` in a non-suspend function emitted a `suspend fun` its own call site could not call. Inline lambdas still propagate, and extracting from inside such a lambda still adds `suspend`. - The capture loop skips the selector of a qualified expression, refuses a value whose type is a class local to the enclosing declaration, and refuses rather than drops a local class or object used as a qualifier. `h.n` used to emit both a parameter named `n` that no call site had and a `Holder` type the new function could not see. - A tail return from a secondary constructor takes `Unit`, not the constructed class. `return extracted(...)` on a Unit call is legal in a constructor. Refusal quality and failure isolation, in the same pass: - `MultipleOutputs` split. It fired for three situations, two of which are one value, and rendered "produces more than one value: result". A single output the call site cannot receive back is now `OutputNotReturnable`. - `CouldNotAnalyse` added. A missing environment, an unreachable KtFile and a thrown error all reported "Select an expression, or whole statements inside one block" - the most confident message in the set, aimed at a selection nothing had looked at. Cancellation is re-thrown rather than swallowed. - `takenNamesFor` tests the local-`fun` target before the containing class, so a new local validates against its siblings instead of the class's members. - `applyChoice` runs from a Compose click handler outside every framework guard; its body is now wrapped. The feature doc's R4, R5, R7, R10, R14 and R15 are corrected to match, and its claim that the version guard lives on `RefactoringPlan` is dropped - the two actions each do their own comparison. * ADFA-5080: Revert the qualified-selector capture guard The guard added in 0a20c0d76 dropped a selector that still needed capturing, and emitted a broken file in two shapes the previous behaviour refused: - a local extension `fun` called as `h.twice()` was skipped, so nothing refused and the moved body called a function out of scope there. - pointing `innerImplicitReceiver` at the same helper skipped a *call* selector, losing the `with`-receiver refusal for a member extension -- the pervasive Compose shape, `with(density) { size.toPx() }`. Both were reproduced before the revert and re-checked after it. The shape the guard was meant to fix, a member of a local class reached as `h.f()`, refuses identically without it: the capture loop is offset-ordered, so the receiver is refused for its local type before the selector is ever reached. `innerImplicitReceiver` gets its original guard back, with a comment on why it must stay shallow. The capture loop gets a comment on why it has none, so the guard does not come back. The refusals for a local class type and for a local class or object used as a qualifier are untouched. The test that defended the guard used a top-level class, whose members the pre-existing ancestor test already skips, so it passed either way. It is replaced by one test per broken shape, both of which fail against the guard. Three doc statements the previous commit should have moved and did not: - R8 said the extracted function takes the enclosing function's return type; the secondary-constructor exception lived only in a code comment. - R16 promised a refusal for anything thrown; cancellation is now re-thrown deliberately, and the reason it is safe belongs next to the promise. - R11's preview example predated fully-qualified rendering. * ADFA-5080: Use the shared type-text helpers * ADFA-5080: Stop anchoring an extraction on an anonymous function * ADFA-5080: Detect Composable property getters when annotating * ADFA-5080: Stop counting a nested declaration's return as a region exit * ADFA-5080: Decline an extraction inside an anonymous extension function * ADFA-5080: Tighten the Composable check and the anchor assertions * ADFA-5080: Keep multi-line string literals verbatim when re-indenting * ADFA-5080: Collect only raw string literals as protected spans * ADFA-5080: Apply spotless formatting * ADFA-5080: Record the new extract-method test coverage * ADFA-5080: Require a tail return to return from the enclosing declaration * ADFA-5080: Give the anonymous-extension-function refusal its own message * ADFA-5080: Tighten the Composable lookup and the extract-method docs * ADFA-5080: Link the type-text shortening follow-up * ADFA-5080: Always offer the expression chooser The extract-variable PR below this one deleted CandidateSyntax.selectionMatchedInnermost outright, so suppressing the chooser on an exact selection is no longer expressible: with the source of that flag gone, keeping the behaviour would mean reintroducing the deleted computation. QA found the suppression actively harmful there - long-press selects one token, so with the chooser hidden there was no way to widen from `b * a` to `b * a + a` without cancelling and re-dragging - and the same reasoning applies here. Drops the flag from ExtractionRegion.Expressions and ExtractMethodPlan, and updates R2/R11 in the feature doc to match. --- ...efactorings-decline-rather-than-rewrite.md | 63 + docs/adr/README.md | 1 + docs/features/kotlin-extract-method.md | 295 ++++ docs/features/kotlin-extract-variable.md | 6 +- .../androidide/idetooltips/TooltipTag.kt | 1 + .../lsp/kotlin/KotlinCodeActionsMenu.kt | 2 + .../lsp/kotlin/actions/ExtractMethodAction.kt | 239 +++ .../kotlin/refactor/ui/ExtractMethodSheet.kt | 96 ++ .../refactor/ui/ExtractMethodSheetContent.kt | 94 ++ .../refactor/ui/ExtractMethodUiState.kt | 50 + .../refactor/ui/ExtractMethodViewModel.kt | 80 + .../ui/ExtractVariableSheetContent.kt | 69 - .../lsp/kotlin/refactor/ui/SheetComponents.kt | 85 + .../utils/refactor/ExtractMethodEdit.kt | 116 ++ .../utils/refactor/ExtractMethodPlan.kt | 198 +++ .../utils/refactor/ExtractMethodPlanner.kt | 82 + .../kotlin/utils/refactor/ExtractionPlan.kt | 6 +- .../kotlin/utils/refactor/ExtractionRegion.kt | 125 ++ .../kotlin/utils/refactor/MethodSignature.kt | 994 +++++++++++ .../kotlin/utils/refactor/NameSuggestion.kt | 4 +- .../lsp/kotlin/utils/refactor/Occurrences.kt | 15 +- .../kotlin/utils/refactor/RefactoringPlan.kt | 13 + .../kotlin/KotlinCodeActionTooltipTagTest.kt | 2 + .../refactor/ui/ExtractMethodViewModelTest.kt | 144 ++ .../utils/refactor/ExtractMethodEditTest.kt | 498 ++++++ .../refactor/ExtractMethodPlanEndToEndTest.kt | 1480 +++++++++++++++++ .../utils/refactor/ExtractMethodRegionTest.kt | 148 ++ resources/src/main/res/values/strings.xml | 19 + 28 files changed, 4842 insertions(+), 83 deletions(-) create mode 100644 docs/adr/0014-refactorings-decline-rather-than-rewrite.md create mode 100644 docs/features/kotlin-extract-method.md create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ExtractMethodAction.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodSheet.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodSheetContent.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodUiState.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodViewModel.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/SheetComponents.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEdit.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanner.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionRegion.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactoringPlan.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodViewModelTest.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEditTest.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodRegionTest.kt 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 dfabb5e15e..5b7b7fa226 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -27,3 +27,4 @@ Format is lightweight **MADR / Nygard**: Context → Decision → Consequences | [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/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 index b60f10323d..c27f5ab1e0 100644 --- a/docs/features/kotlin-extract-variable.md +++ b/docs/features/kotlin-extract-variable.md @@ -6,7 +6,7 @@ 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 the decline-rather-than-rewrite principle, recorded as ADR 0013 alongside extract method (ADFA-5080). +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 @@ -278,8 +278,8 @@ Unit tests in `:lsp:kotlin` (`flox activate -d flox/local -- ./gradlew :lsp:kotl ## Related - [ADR 0012](../adr/0012-refactoring-ui-lives-in-the-owning-lsp-module.md) - refactoring UI lives in the owning LSP module -- ADR 0013 - refactorings decline rather than rewrite unselected code (lands with extract method, ADFA-5080) +- [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 -- ADFA-5080 - extract method, the sibling refactoring; it reuses this vocabulary and these primitives +- [kotlin-extract-method.md](kotlin-extract-method.md) - ADFA-5080, the sibling refactoring - [ARCHITECTURE.md](../../ARCHITECTURE.md) 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 22761c2ccd..4fd823aa82 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -96,6 +96,7 @@ object TooltipTag { 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/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 7990d4b8b5..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 @@ -7,6 +7,7 @@ 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 @@ -50,5 +51,6 @@ object KotlinCodeActionsMenu : IActionsMenuProvider { NullSafetyAction(), ImplementMembersAction(), ExtractVariableAction(), + ExtractMethodAction(), ) } 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/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/ExtractVariableSheetContent.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableSheetContent.kt index 25409974ee..5d193ac223 100644 --- 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 @@ -6,14 +6,11 @@ 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.selectable -import androidx.compose.foundation.selection.selectableGroup 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.RadioButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -22,9 +19,7 @@ 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.text.font.FontFamily import androidx.compose.ui.unit.dp -import com.itsaky.androidide.lsp.kotlin.utils.refactor.NameProblem import com.itsaky.androidide.resources.R /** @@ -134,67 +129,3 @@ fun ExtractVariableSheetContent( } } } - -@Composable -private 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 -private 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 the 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/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/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/ExtractionPlan.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt index 85a3180a4d..6a8e6761e0 100644 --- 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 @@ -145,10 +145,10 @@ data class CandidateExpression( * time the user confirms, the plan is discarded rather than applied against shifted offsets. */ data class ExtractionPlan( - val fileText: String, - val documentVersion: Int, + override val fileText: String, + override val documentVersion: Int, val candidates: List, -) { +) : RefactoringPlan { val isEmpty: Boolean get() = candidates.isEmpty() companion object { 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 index 3427571a18..c6cc362228 100644 --- 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 @@ -103,7 +103,7 @@ fun suggestVariableName( ?: typeName?.let(::nameFromType) ?: FALLBACK_NAME val sanitised = base.takeIf { isIdentifier(it) && it !in HARD_KEYWORDS } ?: FALLBACK_NAME - return makeUnique(sanitised, takenNames) + return uniqueName(sanitised, takenNames) } private fun nameFromShape(expression: KtExpression): String? = @@ -144,7 +144,7 @@ private fun nameFromType(typeName: String): String? = private fun String.decapitaliseFirst(): String = if (isEmpty()) this else this[0].lowercaseChar() + substring(1) /** `size` -> `size1` -> `size2` until nothing in [takenNames] matches. */ -private fun makeUnique( +internal fun uniqueName( base: String, takenNames: Set, ): String { 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 index 84ad6b7ff3..9936b3cfec 100644 --- 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 @@ -147,11 +147,14 @@ internal fun KaSession.referencedDeclarationCeiling(candidate: KtExpression): Ps * 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 needs its own case: `it` has **no source PSI**, so the ordinary - * psi-based lookup finds nothing and would report "unconstrained", happily hoisting `it.length` clean - * out of its lambda into code that does not compile. 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. + * 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, @@ -258,7 +261,7 @@ internal fun KaSession.writeOffsetsFor( } /** Whether this reference is being written to rather than read. */ -private fun KtSimpleNameExpression.isWriteTarget(): Boolean { +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 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/test/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionTooltipTagTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionTooltipTagTest.kt index 4b3905fd4b..3eb0536d7c 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 @@ -6,6 +6,7 @@ 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 @@ -44,6 +45,7 @@ class KotlinCodeActionTooltipTagTest { 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/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/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/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 7d5b69df4e..e9517f1051 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -541,6 +541,25 @@ 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 From 69ddd0996fa48007138a999587965e9a1129d4d9 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Thu, 20 Aug 2026 08:31:17 -0700 Subject: [PATCH 32/40] ADFA-5206 Workflow to remove Rovo Dev advertising from Github PRs (#1702) * Workflow to remove Rovo Dev messages from Github PR descriptions * ADFA-5206: Also delete the Rovo Dev account-linking comment Atlassian nags in two places. Besides editing the PR description, atlassian[bot] posts a comment asking you to link your GitHub account to enable Rovo Dev code reviews. Delete it on issue_comment. The match is deliberately narrow - the bot as author plus the ad wording - because a genuine Rovo Dev review would come from the same bot and must survive. A comment event that is not the ad is filtered at the job level, so an ordinary comment never starts a runner. Adds a workflow_dispatch sweep that strips both nags across all open pull requests, for the ones opened before this lands. * ADFA-5206: Gate the comment path on pull requests, pin github-script Two review fixes. The issue_comment filter now requires github.event.issue.pull_request, so the job only runs for comments on pull requests. That matches the workflow_dispatch sweep, which walks pulls.list and never touched plain issues. actions/github-script moves from the mutable v7 tag to the commit it currently points at. This workflow runs on pull_request_target with pull-requests and issues write, so a moved tag would execute with those permissions. --- .github/workflows/strip-rovo-nag.yml | 132 +++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 .github/workflows/strip-rovo-nag.yml 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.` + ); From 55631fb5c51c5a3560bee1a93f620580375c4e37 Mon Sep 17 00:00:00 2001 From: Elissa-AppDevforAll Date: Thu, 20 Aug 2026 19:30:04 -0500 Subject: [PATCH 33/40] Add knowledge base link per DS request (#1708) --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 From 8f3a89d6c69ecdf9d599f7f0cf3a0067bf4e055c Mon Sep 17 00:00:00 2001 From: M Cikadu-Dev Date: Fri, 21 Aug 2026 08:21:12 +0700 Subject: [PATCH 34/40] Update Indonesian translation (#1703) * Update Indonesian translation --- .../src/main/res/values-in/strings.xml | 52 ++++++++++++++ app/src/main/res/values-in/strings.xml | 26 +++++++ logsender/src/main/res/values-in/strings.xml | 24 +++++++ .../src/main/res/values-in/strings.xml | 30 ++++++++ .../values-in-rID/layouteditor_migrated.xml | 30 ++++++++ .../src/main/res/values-in-rID/strings.xml | 69 ++++++++++++++++++- 6 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 apk-viewer-plugin/src/main/res/values-in/strings.xml create mode 100644 app/src/main/res/values-in/strings.xml create mode 100644 logsender/src/main/res/values-in/strings.xml create mode 100644 markdown-preview-plugin/src/main/res/values-in/strings.xml create mode 100644 resources/src/main/res/values-in-rID/layouteditor_migrated.xml 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..0e2638b5ad --- /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 (%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/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml new file mode 100644 index 0000000000..eb1305c6f2 --- /dev/null +++ b/app/src/main/res/values-in/strings.xml @@ -0,0 +1,26 @@ + + + + + Gunakan prompt sederhana + Gagal memperbarui sumber string yang dihasilkan. + Tidak dapat menemukan file sumber string proyek. + Perangkat ini tidak mendukung konfigurasi parser XML yang diperlukan untuk memperbarui sumber string yang dihasilkan. + Sumber string yang dihasilkan bukan XML yang valid. + Tidak dapat membuka “%1$s” di jendela mengambang. + 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/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/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..bd4ece2f24 --- /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 fcc01cf432..4eef754bbe 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 @@ -505,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 @@ -619,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 @@ -629,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." @@ -818,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 @@ -830,7 +875,12 @@ Aktifkan Nonaktifkan Hapus instalasi + Detail Detail plugin + oleh %1$s + Diaktifkan + Dinonaktifkan + Tidak dimuat Izin Dependensi @@ -873,6 +923,7 @@ Ada kesalahan Peringatan Informasi + Tampilkan bantuan Jalankan cepat @@ -898,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. @@ -943,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. @@ -1059,6 +1125,7 @@ Lihat informasi lebih lanjut, termasuk tips pemecahan masalah.]]> + Jelajahi dokumentasi.]]> Kirim masukan From 0ba730d3f55a3a1082005ca1286e3f50704d4c8d Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Thu, 20 Aug 2026 18:44:22 -0700 Subject: [PATCH 35/40] ADFA-5223: Fix Indonesian "resource" mistranslated as "sumber" (source) (#1711) The Indonesian localization rendered the Android term "resource" as "sumber", which means source. The correct term is "sumber daya". Six user-facing strings affected -- five from #1703, plus the pre-existing new_xml_resource. Left "sumber" alone where it correctly means source: idepref_java_diagnosticsEnabled_summary (Java source files), title_open_source_licenses / summary_open_source_licenses (open source), and markdown-preview's view_source. Verified by round-tripping each changed value back to English through both Gemini and Google Cloud Translate; all six now return "resource". --- apk-viewer-plugin/src/main/res/values-in/strings.xml | 2 +- app/src/main/res/values-in/strings.xml | 8 ++++---- resources/src/main/res/values-in-rID/strings.xml | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apk-viewer-plugin/src/main/res/values-in/strings.xml b/apk-viewer-plugin/src/main/res/values-in/strings.xml index 0e2638b5ad..3118ad0ff1 100644 --- a/apk-viewer-plugin/src/main/res/values-in/strings.xml +++ b/apk-viewer-plugin/src/main/res/values-in/strings.xml @@ -15,7 +15,7 @@ Struktur APK File Utama Pustaka Native (%d) - Direktori Sumber (%d) + Direktori Sumber Daya (%d) File Besar (>100KB) Metadata APK diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index eb1305c6f2..0a7e4ccd4f 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -18,9 +18,9 @@ Gunakan prompt sederhana - Gagal memperbarui sumber string yang dihasilkan. - Tidak dapat menemukan file sumber string proyek. - Perangkat ini tidak mendukung konfigurasi parser XML yang diperlukan untuk memperbarui sumber string yang dihasilkan. - Sumber string yang dihasilkan bukan XML yang valid. + 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/resources/src/main/res/values-in-rID/strings.xml b/resources/src/main/res/values-in-rID/strings.xml index 4eef754bbe..64f98857de 100644 --- a/resources/src/main/res/values-in-rID/strings.xml +++ b/resources/src/main/res/values-in-rID/strings.xml @@ -240,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? From 9c8f21777aeb0bf962d061cdd6551adffda94d78 Mon Sep 17 00:00:00 2001 From: davidschachterADFA Date: Fri, 21 Aug 2026 20:20:00 -0500 Subject: [PATCH 36/40] ADFA-5153: Decode Content rows against the shared Brotli dictionary (#1677) * ADFA-5153: Decode Content rows against the shared Brotli dictionary WebServer now always decompresses brotli content server-side rather than ever passing compressed bytes through to the client -- sidesteps needing WebView-side dictionary support entirely, since the client never sees compressed bytes. It loads CompressionDictionary once at startup, and again on the debug-DB swap, and attaches it via brotli4j's attachDictionary before decoding -- falling back to plain decode if the table doesn't exist (a database that predates the dictionary migration). Confirmed cross-tool compatibility empirically: content compressed by OfflineDocumentationTools' brotli-CLI pipeline decodes byte-for-byte correctly via brotli4j's attachDictionary, and the same in-memory dictionary buffer is safe to reuse across many decode calls (WebServer holds one for its whole lifetime). BrotliDictionaryDecodeTest embeds those real cross-tool-produced fixtures as permanent regression coverage. Also adds testImplementation(libs.brotli4j.linux.x64): JVM unit tests exercising brotli4j's real native decoder had no native lib to load at all before this and would fail with UnsatisfiedLinkError -- a pre-existing gap, not introduced by this change, just never hit until now. docs/documentation-database.md updated for CompressionDictionary and WebServer's always-decompress behavior. * Apply spotlessApply formatting Co-Authored-By: Claude Sonnet 5 * ADFA-5153: Narrow no-dictionary decode test to IOException CodeRabbit flagged this test as asserting an unsupported invariant, citing docs/documentation-database.md's claim that "wrong dictionary, or none" doesn't reliably fail loudly. Verified empirically that the two cases are actually distinct: a wrong dictionary decodes silently to incorrect bytes (its distances resolve into real, just wrong, bytes), but no dictionary at all reliably throws IOException, since distances into the dictionary region are out of bounds for any spec-compliant decoder. Narrowed the assertion from Exception to IOException and corrected the doc to describe both failure modes instead of conflating them. Co-Authored-By: Claude Sonnet 5 * ADFA-5153: Address code-review findings on the dictionary compression PR Fixes 13 findings from a max-effort /code-review pass, most significant first: - Plugin-contributed Tier 3 docs (PluginDocumentationManager/BrotliCompressor) are plain brotli with no dictionary, but WebServer unconditionally attached the shared dictionary before decoding any brotli row -- every such page 500'd. Extracted decompressBrotli(): tries the dictionary first, falls back to a plain decode on IOException. Verified empirically that a dictionary attached to a stream compressed without one reliably throws rather than silently decoding wrong bytes, so this fallback never lets a real dictionary-compressed row slip through unnoticed. - loadCompressionDictionary() now wraps its whole body in one catch-all, matching DatabaseVersionResolver's existing pattern, instead of hand-anticipating individual failure cases. Fixes three related bugs this gap caused: a failed dictionary reload during the debug-DB swap left stale state with no retry; a dictionary-load failure at server startup aborted the entire server with no retry; a NULL dictionary blob threw an uncaught NPE. - Extracted switchToDatabase() so database/databaseTimestamp/ compressionDictionary/templateCache/bookshelfTemplateId are all swapped atomically in one place instead of duplicated across start() and the debug-swap block -- also fixes templateCache never being invalidated on a debug-DB swap, and a reopen-after-close ordering bug where a failed reopen left `database` referencing an already-closed handle. - Added test coverage for the previously-untested no-dictionary/plugin-content decode path. - Corrected docs/documentation-database.md's false "no dictionary-free content left" claim (contradicted by its own PluginDocumentationManager section) and the build.gradle.kts comment falsely claiming linux-x64 is the only platform this project's dev machines run JVM tests on. - Minor: deduped the byte[]->direct-ByteBuffer idiom, removed a stale Accept-Encoding comment on a header no longer read. Separately discovered (not caused by this PR, filed as ADFA-5168 instead of fixed here): :app:testV8DebugUnitTest is flaky (~50% of full-suite runs) due to Brotli4jLoader static state shared across one JVM test process between AssetsInstallationHelperTest's mockkStatic and BrotliDictionaryDecodeTest's real native load -- confirmed present on bfb3baa87 already, independent of any change in this commit. Co-Authored-By: Claude Sonnet 5 * ADFA-5153: Scope shared-dictionary claim to migrated brotli rows CodeRabbit caught a self-contradiction: line 34 already says non-Brotli content uses format-specific compression, but the prior wording said 'every row' is dictionary-compressed. Scoped to migrated Content rows with ContentTypes.compression = 'brotli'. Co-Authored-By: Claude Sonnet 5 * ADFA-5153: Add test proving the compression dictionary loads once Per ticket comment: verifies WebServer fetches CompressionDictionary only at startup and reuses the cached instance across every request, never re-querying it per-request. Drives 3 real HTTP requests over a socket against a mocked SQLiteDatabase and asserts the dictionary query fired exactly once while the Content query fired 3 times. Co-Authored-By: Claude Sonnet 5 * ADFA-5153: Reload compression dictionary per-request, not at swap time Moved loadCompressionDictionary() out of switchToDatabase() (called at startup and on the debug-DB swap) to right before the content fetch in handleClient(). A database swap can bring in a database with a different dictionary or none at all, so loading it right where it's consumed -- rather than caching it at swap time -- keeps it directly tied to whichever database is actually active when a request needs it. Updated the WebServerTest coverage added for the prior (now-reversed) "load once, cache for app lifetime" behavior: it now asserts zero dictionary queries before any request and one dictionary query per content fetch (3 requests -> 3 queries). Updated docs/comments to match. Co-Authored-By: Claude Sonnet 5 * ADFA-5153: Load compression dictionary lazily, once per database change Corrects the prior commit, which reloaded the dictionary on every single request instead of only when the database actually changes. Added compressionDictionaryStale, set by switchToDatabase() (startup and the debug-DB swap) instead of eagerly loading the dictionary there. The content-fetch site in handleClient() -- the one place the dictionary is actually consumed -- checks the flag and only loads when stale, clearing it once loaded. Net effect: loaded lazily (not merely from starting the server), but cached across every request against the same database, and reloaded exactly once when a swap brings in a database with a different dictionary (or none). Replaced the WebServerTest coverage accordingly: one test proves the dictionary loads on first use and stays cached across repeated requests against the same database; a second drives an actual debug-DB swap and proves it reloads exactly once for the new database, not on every subsequent request. Co-Authored-By: Claude Sonnet 5 * ADFA-5153: Run the brotli tests on any host, and cover the buffer helper Review of PR #1677 found three things worth fixing. The test native was pinned to linux-x64, so :app:testV8DebugUnitTest failed with UnsatisfiedLinkError in @BeforeClass for anyone on macOS, Windows, or linux-arm64 - a comment documented the breakage rather than fixing it. Dispatch on the host's OS/arch instead, reusing the pattern already proven in build-logic/plugins' build.gradle.kts. All six natives are already in the version catalog. BrotliDictionaryDecodeTest allocated its own direct buffer, a byte-for-byte copy of production's toDirectByteBuffer, leaving the only code that builds the runtime dictionary buffer untested. The two agree today, so this is a regression risk rather than a live bug: attachDictionary reads the buffer's capacity and ignores position/limit, so a later over-allocation there (pooling, rounding, padding) would break every doc page on device while the suite stayed green. The test now calls the production helper, and that helper's KDoc records the exact-capacity requirement. loadCompressionDictionary validated a missing table, an empty table, and a NULL data column, but not a zero-length blob. That yields a 0-capacity buffer, which attachDictionary rejects, so every row would fail its dictionary decode, fall through to a plain decode that also fails, and return HTTP 500 - with nothing above DEBUG to explain it. Added to the same ladder so it gets the same one-line warning. Left alone: peak heap on the chunked PDFs (always-decompress holds the accumulator, its copy, and the output live at once) and the debug-DB swap retrying every request after a failure. Both are pre-existing design questions rather than regressions from this PR. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y4t7fFLNPJq9EiXr9S9LxF * ADFA-5153: Cut peak heap on chunked rows, and stop retrying a bad debug DB Two findings from the PR #1677 review that were deferred as design questions. Peak heap on the largest bundled PDFs. Serving a >1 MB row concatenated its chunks into a ByteArrayOutputStream and then called toByteArray(), so the doubling buffer and its full copy were both live alongside the decompressed output - roughly 35 MB transient for AndroidNotesForProfessionals.pdf (8.8 MB over 9 chunks), a plausible OOM on a low-heap device. The chunks now stay a list: brotli rows decode from a SequenceInputStream over them, and non-brotli rows are joined once into an exactly-sized array. That drops the two largest transients, leaving the compressed chunks and the decompressed output. Fully streaming the response would remove the last one too, but that means giving up Content-Length, so it is left alone. A failed debug-database swap left databaseTimestamp unadvanced, and the swap is checked per request - so a corrupt or unreadable debug DB newer than the primary was reopened on every single request, logging an ERROR each time. The failing timestamp is now remembered and skipped; a newer copy has a different timestamp and is retried, which is the case that matters, since replacing the file is how a developer fixes it. joinChunks and chunksAsStream are internal top-level functions next to toDirectByteBuffer so the tests exercise the real code, with three new cases: a compressed stream decodes identically when split at uneven chunk boundaries, joinChunks concatenates in order at an exact size, and a lone chunk comes back without a copy. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y4t7fFLNPJq9EiXr9S9LxF * ADFA-5153: Address CodeRabbit findings on the dictionary tests - Assert the sqlite_master existence-check query count alongside the data query in both dictionary tests, not just the data query -- a regression that re-ran only the existence check every request would otherwise pass unnoticed. - Set socket.soTimeout before reading the response in sendRawGetRequestAndAwaitClose, so a server that fails to close the connection fails the test instead of hanging the JVM indefinitely. Co-Authored-By: Claude Sonnet 5 * ADFA-5153: Address jatezzz's review on PR #1677 (3 of 5 findings) - loadCompressionDictionary no longer swallows exceptions into "no dictionary." It only returns null for a definitive absence (missing table, empty table, null/empty blob); an unexpected SQLiteException now propagates to the call site, which leaves compressionDictionaryStale set so the next request retries instead of permanently caching a transient failure as "no dictionary" for the rest of the database's lifetime. - brotli4jNativeForHost() in app/build.gradle.kts no longer throws on an unrecognized host. That ran at configuration time, so throwing failed every task in the build -- including :app:assembleV8Debug, which needs no desktop native at all -- not just the JVM unit-test tasks that consume it. Degrades to a logged warning and no test native instead. - Softened the chunked-content comment's memory-savings claim: the decompressed output still goes through a comparable accumulate-then-copy in decompressBrotli's own readBytes() call, so the saving from keeping compressed chunks as a list is real but doesn't eliminate that separate transient the way the prior wording implied. The two remaining findings (dictionary-first decode's theoretical silent-wrong-bytes risk, and the resulting double-decode cost for dictionary-free rows) need a design discussion, not a quick fix -- see the PR thread reply. Co-Authored-By: Claude Sonnet 5 * ADFA-5153: Warm the brotli loader before a test mocks it Order-dependent test failure between this PR's BrotliDictionaryDecodeTest and the pre-existing AssetsInstallationHelperTest: whichever runs first in a JVM decides whether the second one works. AssetsInstallationHelperTest does mockkStatic(Brotli4jLoader::class) and stubs ensureAvailability() to do nothing, since a unit test has no native library to load. brotli4j caches its availability in a static field, so a JVM whose first sight of that class is the mocked one keeps a "never loaded" state -- and BrotliDictionaryDecodeTest's @BeforeClass, which calls the real ensureAvailability(), then throws UnsatisfiedLinkError. unmockkAll() in teardown does not undo it: the damage is the cached state, not the mock. Loading it for real once, before anything mocks it, fixes it. runCatching because a host with no matching native is a legitimate configuration -- this PR's own brotli4jNativeForHost() degrades to a warning rather than failing the build -- so the warming is best-effort. CI is green on this PR because its test set happens to order favourably. The pair reproduces the failure deterministically: ./gradlew :app:testV8DebugUnitTest \ --tests "com.itsaky.androidide.assets.AssetsInstallationHelperTest" \ --tests "com.itsaky.androidide.localWebServer.BrotliDictionaryDecodeTest" Found while stacking ADFA-5176 and ADFA-5179 on this branch, where the added test class shifted the order enough to expose it. Landing the fix here keeps it with the test it protects, rather than leaving stage briefly broken after this merges. * ADFA-5153: Absorb only UnsatisfiedLinkError when warming the brotli loader Review was right that runCatching was too broad: it swallows every Throwable, so an unrelated failure in this setup would disappear silently. ensureAvailability() raises UnsatisfiedLinkError when there is no native for the host -- the one case the warming exists to tolerate -- so that is all it catches now, and it says so on stdout rather than passing in silence. * ADFA-5153: Gate the compression dictionary on the declared database version WebServer inferred the content format from whether a CompressionDictionary table happened to exist -- the heuristic ADFA-5220's version table exists to retire. It gets the answer wrong in both directions: a database carrying the table with unmigrated content makes every plain row pay a failed dictionary decode before its plain one, on every request, and a migrated database that lost the table fails quietly rather than loudly. Gate on DocumentationDatabaseVersion instead. At MAJOR >= 2 the dictionary is read and attached as before; below that, or with no version table at all, it is neither fetched nor used. The version read lives in DatabaseVersionResolver (common), so ADFA-5176's in-process transport can share the same gate rather than growing a second copy. It returns null for a definitively unversioned database and lets exceptions propagate, matching loadCompressionDictionary's existing contract: callers cache the answer per database, so a transient SQLiteException must stay distinguishable from a real absence or one hiccup would pin the database at unversioned until the next swap. The table is an append-only log, so the current version is the row inserted last, not MAX(major) -- rebuilding from an older content set is a downgrade and has to read as one. The CompressionDictionary probes stay, for a database that declares a new-enough version but has no usable dictionary row: without them the data query raises "no such table", which the caller correctly treats as transient and would then retry on every request. Tests: three new WebServer cases (major 1, no version table, major 3) asserting the dictionary queries are or are not issued -- with the dictionary cursors stubbed as available in every case, so they test the gate rather than a missing table -- and five DatabaseVersionResolver cases covering absent table, empty table, declared version, last-row-wins, and a downgrade. The two existing dictionary tests now declare a version; without that they would have kept passing while silently testing nothing. Co-Authored-By: Claude Opus 5 (1M context) * ADFA-5153: Load brotli4j's native library before decoding, not by luck Nothing in WebServer owned that load: it happened 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 skipped both reached the first brotli row with the natives unregistered, and DecoderJNI.nativeCreate raised UnsatisfiedLinkError -- an Error, not an Exception, so it escaped handleClient's catch and killed the app from a coroutine worker instead of failing one request. Reproduced on device: force-stop, launch MainActivity directly (skipping SplashActivity, whose startup path happens to warm the loader), request a brotli row. The app died and restarted -- pid 10550 -> 10785, with FATAL EXCEPTION and UnsatisfiedLinkError in the log. Android restarting a killed process straight into the editor would take the same path. Referencing Brotli4jLoader triggers the static init that performs the load, so calling ensureAvailability() before the decode *is* the warm-up; afterwards it is a single static null-check on UNAVAILABILITY_CAUSE (verified against brotli4j 1.18.0's bytecode), cheap enough to leave on the per-decode path rather than tracking warmed state of our own. Its UnsatisfiedLinkError becomes an IOException so a genuinely broken environment costs one 500 rather than the process. After the fix, the same sequence returns the full 50,440-byte page, the pid is unchanged, and the log has no fatal or link-error lines. The version gate still behaves: a database declaring 1.0.0 serves 500 for a brotli row and 200 for a compression = 'none' row, without crashing. Also documents a trap that cost real debugging time: the debug-database swap compares modification times, 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. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Sonnet 5 --- app/build.gradle.kts | 57 ++++ .../androidide/localWebServer/WebServer.kt | 304 ++++++++++++++++-- .../assets/AssetsInstallationHelperTest.kt | 13 + .../BrotliDictionaryDecodeTest.kt | 251 +++++++++++++++ .../localWebServer/WebServerTest.kt | 293 +++++++++++++++++ .../utils/DatabaseVersionResolverTest.kt | 71 +++- .../utils/DatabaseVersionResolver.kt | 54 +++- docs/documentation-database.md | 8 +- 8 files changed, 1004 insertions(+), 47 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/localWebServer/BrotliDictionaryDecodeTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 21c52a5fc2..acff8f5ee7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,7 @@ import com.itsaky.androidide.build.config.BuildConfig import com.itsaky.androidide.desugaring.utils.JavaIOReplacements.applyJavaIOReplacements import com.itsaky.androidide.plugins.AndroidIDEAssetsPlugin +import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform import org.json.JSONObject import java.io.BufferedOutputStream import java.io.ByteArrayInputStream @@ -214,6 +215,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) @@ -353,6 +403,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) 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 0b76b64d2d..a978a8f286 100644 --- a/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt +++ b/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt @@ -4,6 +4,7 @@ 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 @@ -18,15 +19,19 @@ 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 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 @@ -62,6 +67,45 @@ data class JavaExecutionResult( val timeoutLimit: Long, ) +/** + * 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, ) { @@ -76,6 +120,27 @@ class WebServer( 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() @@ -85,8 +150,6 @@ class WebServer( // Frozen at startup; restart the server to pick up a change. private val clearCacheEnabled: Boolean = File(config.clearCacheEnablePath).exists() - 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 = @@ -130,6 +193,152 @@ class WebServer( } } + /** + * 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 -- @@ -165,10 +374,8 @@ class WebServer( config.experimentsEnablePath, ) - databaseTimestamp = getDatabaseTimestamp(config.databasePath) - try { - database = SQLiteDatabase.openDatabase(config.databasePath, null, SQLiteDatabase.OPEN_READONLY) + switchToDatabase(config.databasePath, getDatabaseTimestamp(config.databasePath)) } catch (e: Exception) { log.error("Cannot open database: {}", e.message) return @@ -284,8 +491,6 @@ class WebServer( 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) { @@ -306,7 +511,7 @@ class WebServer( 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) + // Read all headers until blank line (needed for Content-Length on POST) val headers = mutableMapOf() while (true) { requestLine = readLineFromStream(input) ?: break @@ -317,7 +522,6 @@ class WebServer( 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") { @@ -332,11 +536,18 @@ class WebServer( // 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 + 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 @@ -352,6 +563,22 @@ class WebServer( } } + // 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 @@ -377,24 +604,31 @@ class WebServer( } cursor.moveToFirst() - var dbContent = cursor.getBlob(0) + 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) - if (dbContent.size == contentChunkSize) { + // 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 - val combined = ByteArrayOutputStream().apply { write(dbContent) } - var dbContent2 = dbContent - while (dbContent2.size == contentChunkSize) { + var nextChunk = firstChunk + while (nextChunk.size == contentChunkSize) { val path2 = "$path-$fragmentNumber" val cursor2 = database.rawQuery(query2, arrayOf(path2)) try { if (cursor2.moveToFirst()) { - dbContent2 = cursor2.getBlob(0) - combined.write(dbContent2) + nextChunk = cursor2.getBlob(0) + chunks.add(nextChunk) fragmentNumber++ } else { break @@ -403,19 +637,20 @@ class WebServer( 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" - } + // 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) { @@ -425,7 +660,6 @@ class WebServer( 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() @@ -446,7 +680,7 @@ class WebServer( * @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). + * @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. */ 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/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 index ef1e18de8f..e68b2e05e4 100644 --- a/app/src/test/java/com/itsaky/androidide/localWebServer/WebServerTest.kt +++ b/app/src/test/java/com/itsaky/androidide/localWebServer/WebServerTest.kt @@ -1,16 +1,20 @@ 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 @@ -54,6 +58,29 @@ class WebServerTest { 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) { @@ -102,6 +129,272 @@ class WebServerTest { 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 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/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/docs/documentation-database.md b/docs/documentation-database.md index 566703ad1b..3055c955f0 100644 --- a/docs/documentation-database.md +++ b/docs/documentation-database.md @@ -8,7 +8,7 @@ This is a **read-only, prebuilt** database — CoGo never creates or migrates it - 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. `WebServer`'s debug logging and experiment flags are also file-flag-gated under `/sdcard/Download/` (`CodeOnTheGo.webserver.debug`, `CodeOnTheGo.exp`, `CodeOnTheGo.webserver.cs0`). +- **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 @@ -34,7 +34,7 @@ CREATE TABLE Content ( 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. 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. +- **`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). @@ -62,6 +62,8 @@ CREATE TABLE Tooltips ( ### 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. @@ -80,7 +82,7 @@ All three sites below open the file with `SQLiteDatabase.openDatabase(..., OPEN_ AND C.path = ? ``` - then reassembles chunked blobs, decompresses Brotli when the client can't accept it (or when a Pebble template needs a string to render), 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). + 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. From ea658c36de9db9acfe9b4870658ede29337f1cb6 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Mon, 24 Aug 2026 15:43:17 -0700 Subject: [PATCH 37/40] ADFA-4510: Fix missing tooltips on code actions (#1712) * docs(ADFA-4510): design for code action tooltip fix * docs(ADFA-4510): implementation plan for code action tooltip fix * 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. * docs(ADFA-4510): correct Task 1 verification step git diff -w can never be empty: ktlint normalises trailing commas, expression bodies and blank lines, not just indentation. Replace with a hunk-by-hunk review plus a compile of both modules. * 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. * 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. * 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. * 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. * docs(ADFA-4510): add missing assets side-load step to Task 6 :app:assembleV8Debug does not bundle the large assets. Without building :app:assembleV8Assets and pushing the payload to /sdcard/Download, a debug install has no templates, no bootstrap, no SDK and no documentation.db, so nothing about the fix can be verified on device. * fix(ADFA-4510): keep the documentation fallback for untagged actions Dropping the contentDescription fallback also dropped the ADFA-4754 popup. That fallback made the tag non-empty for every action, so a long-press on an untagged action reached showTooltip(), missed in the DB, and rendered "Sorry, we don't have a tooltip for that. Explore the documentation." Returning early on an empty tag turned that into a dead gesture for the eight untagged Java actions and the two this branch un-tagged. Still log the warning, but let the empty tag through so the miss renders the fallback. * test(ADFA-4510): cover the try/catch action and close the kotlin blind spot Rebasing onto stage brought SurroundWithTryCatchAction into JavaCodeActionsMenu, which the expected map did not list, so the suite failed on 23 actual vs 22 expected entries. Pin it to EDITOR_CODE_ACTIONS_TRY_CATCH. Point the Kotlin twin at retrieveTooltipTag() too. Reading the property is the exact hole that let ADFA-4510 through on the Java side while that suite stayed green. Add the GPL header both new test files were missing. * test(ADFA-4510): drop Robolectric, assert through Truth ActionTooltipResolutionTest exercises findAction(Int) and retrieveTooltipTag() -- an id.hashCode() lookup and a String property. Its only Android type is a Drawable? assigned null and never called, so every class in it bootstrapped an SDK sandbox for nothing. JavaCodeActionTooltipTagTest used raw JUnit asserts. ARCHITECTURE.md prefers Truth, and containsExactlyEntriesIn names the offending key instead of dumping both maps -- which is what the missing try/catch entry cost to read. * docs(ADFA-4510): derive the repo root, validate the Firebase donor path The plan hardcoded /Users/eisen/src/cogo/ADFA-4510, so the commands only ran on one machine. Derive it with git rev-parse --show-toplevel. The google-services.json fallback was worse: it copied from a hardcoded sibling checkout with no check that the path existed or belonged to this project. Require the donor as GOOGLE_SERVICES_SRC and verify it is a file first. * docs(ADFA-4510): mark the try/catch tag as reserved ahead of content The suite grouped surroundWithTryCatch with the tags that have authored tooltips, but documentation.db has no editor.codeactions.trycatch row, so long-press renders the documentation fallback. Its Kotlin twin, editor.codeactions.kotlin.trycatch, is authored - this is an authoring gap, not a wiring one. Verified against the current documentation.db (46,105 tooltips, wholedb 2026-08-20), not the stale local asset copy. Comment-only. The tag stays pinned: dropping it would change production behavior, and the tag is correctly wired. * fix(ADFA-4510): give the Kotlin import chooser a tooltip tag AddImportAction opens a chooser dialog when a reference resolves to more than one importable classifier, but wired no tooltip tag, so long-pressing anywhere in that dialog did nothing. Same defect this branch already fixed on the Java side for the override-superclass dialog. Follows that precedent: applyLongPressRecursively bails out of ListView subtrees, so the rows get their own OnItemLongClickListener and the dialog chrome is wired in setOnShowListener. The chooser construction moves into showImportChooser() because the listener needs the created dialog, not the builder. New tag editor.codeactions.kotlin.importclass.dialog has no row in documentation.db yet, so long-press renders the ADFA-4754 documentation fallback until content is authored - a live link, not a dead press. 471 tests across actions, idetooltips, lsp/java, lsp/kotlin: 0 failures. * style(ADFA-4510): reindent Java AddImportAction to tabs Space-indented, so the file-level Spotless ratchet reformats it whole the moment it is touched. Isolating that churn here keeps the tooltip fix that follows reviewable. Whitespace plus the usual ktlint normalisations, verified with git diff -w: two blank lines removed after a declaration opens, one trailing comma added, and postExec's parameter list exploded one-per-line. No identifier, literal, condition, or call argument changed. * fix(ADFA-4510): give the Java import chooser a tooltip tag Java's AddImportAction has the same gap just fixed on the Kotlin side: the chooser shown when a simple name resolves to several importable types wired no tooltip tag, so long-pressing it did nothing. Same shape as the Kotlin fix and the override-superclass dialog already on this branch: build, create(), wire the rows via OnItemLongClickListener and the chrome via setOnShowListener, then show. applyLongPressRecursively bails out of ListView subtrees, which is why both are needed. New tag editor.codeactions.fiximports.dialog has no row in documentation.db yet, so long-press renders the ADFA-4754 documentation fallback until content is authored. 471 tests across actions, idetooltips, lsp/java, lsp/kotlin: 0 failures. * fix(ADFA-4510): give the Kotlin null-safety chooser a tooltip tag NullSafetyAction offers three fixes for an UNSAFE_CALL - assert non-null, safe call, Elvis fallback - in a chooser dialog that wired no tooltip tag, so long-pressing it did nothing. The action tag itself is authored, making the dialog the only dead surface on this path. Same pattern as the two import choosers: create(), rows via OnItemLongClickListener, chrome via setOnShowListener. New tag editor.codeactions.kotlin.nullsafetyfix.dialog has no row in documentation.db yet, so long-press renders the ADFA-4754 documentation fallback until content is authored. * style(ADFA-4510): reindent AutoFixImportsAction to tabs Space-indented, so the file-level Spotless ratchet reformats it whole on the first touch. Isolating that churn keeps the tooltip fix that follows small. Whitespace plus the usual ktlint normalisations, verified with git diff -w: two blank lines removed after a declaration opens, five parameter lists exploded one-per-line, getFileImports collapsed to an expression body, the dialog builder chain rewrapped, and a redundant "${klass}" reduced to "$klass". No identifier, condition, or call argument changed. * fix(ADFA-4510): give the Java class chooser a tooltip tag AutoFixImportsAction asks which class to import when a simple name is ambiguous, one dialog per name. It wired no tooltip tag, so long-pressing it did nothing. Last of the four unwired code-action dialogs. Reuses editor.codeactions.fiximports.dialog rather than minting a new tag: same question asked of the user as AddImportAction's chooser, and the two actions already share an action tag. Note this dialog is built through DialogUtils.newMaterialDialogBuilder directly, not the newDialogBuilder helper the other three use - which is why it did not turn up in the first sweep for unwired dialogs. The nullable `e` is captured into a local `entry` so the listener body does not smart-cast a var across a lambda boundary. 481 tests across actions, editor, idetooltips, lsp/java, lsp/kotlin: 0 failures. --- actions/build.gradle.kts | 1 + .../itsaky/androidide/actions/ActionItem.kt | 486 +++++----- .../itsaky/androidide/actions/ActionMenu.kt | 125 +-- .../actions/ActionTooltipResolutionTest.kt | 100 ++ ...026-08-06-adfa-4510-codeaction-tooltips.md | 891 ++++++++++++++++++ ...06-adfa-4510-codeaction-tooltips-design.md | 200 ++++ .../androidide/editor/ui/EditorActionsMenu.kt | 41 +- .../androidide/idetooltips/TooltipTag.kt | 5 + .../actions/diagnostics/AddImportAction.kt | 416 ++++---- .../diagnostics/AutoFixImportsAction.kt | 381 ++++---- .../actions/diagnostics/FieldToBlockAction.kt | 176 ++-- .../diagnostics/VariableToStatementAction.kt | 180 ++-- .../OverrideSuperclassMethodsAction.kt | 4 +- .../actions/JavaCodeActionTooltipTagTest.kt | 92 ++ .../lsp/kotlin/actions/AddImportAction.kt | 66 +- .../lsp/kotlin/actions/NullSafetyAction.kt | 62 +- .../kotlin/KotlinCodeActionTooltipTagTest.kt | 2 +- 17 files changed, 2374 insertions(+), 854 deletions(-) create mode 100644 actions/src/test/java/com/itsaky/androidide/actions/ActionTooltipResolutionTest.kt create mode 100644 docs/superpowers/plans/2026-08-06-adfa-4510-codeaction-tooltips.md create mode 100644 docs/superpowers/specs/2026-08-06-adfa-4510-codeaction-tooltips-design.md create mode 100644 lsp/java/src/test/java/com/itsaky/androidide/lsp/java/actions/JavaCodeActionTooltipTagTest.kt 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/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/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt index 4fd823aa82..66183a6d71 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -71,6 +71,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" @@ -89,9 +90,13 @@ object TooltipTag { 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" 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/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/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/test/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionTooltipTagTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionTooltipTagTest.kt index 3eb0536d7c..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 @@ -28,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`() { From 113b57f92e242a43d2a0e7d597fa5e4bd60005af Mon Sep 17 00:00:00 2001 From: davidschachterADFA Date: Tue, 25 Aug 2026 18:10:04 -0700 Subject: [PATCH 38/40] ADFA-5264: Stop tracking the test project's sync cache (#1740) testing/resources/test-project/.cg/gradle-sync/{project.pb,sync.pb,sync.lock} were tracked, and a test run rewrites them with the local machine's absolute paths. So they turn up modified in everyone's working tree, and a `git add -A` buries them in an unrelated commit -- including a 13 MB binary. That is how they last changed: the most recent commit touching them is an AI-plugin extraction refactor that had no reason to. They are a cache, not a fixture. ProjectSyncHelper writes them, and the only test that mentions the cache is "WHEN sync files are unreadable THEN sync anyway" -- it exercises their absence. Nothing reads a committed copy. A lock file was tracked too. Verified: with them untracked and ignored, running :lsp:java:testV8DebugUnitTest -- the suite that used to rewrite them -- leaves a clean working tree. Note for whoever picks up ADFA-5068: :subprojects:tooling-api-impl is a java-library, so its tests never ran in CI (the aggregate depends only on testV8DebugUnitTest), and ToolingApiServerImplTest does not currently compile on stage -- "No value passed for parameter 'buildId'". Once that gap is closed, that failure becomes visible. Co-authored-by: Claude Opus 5 --- .gitignore | 5 +++++ .../test-project/.cg/gradle-sync/project.pb | Bin 12678925 -> 0 bytes .../test-project/.cg/gradle-sync/sync.lock | 0 .../test-project/.cg/gradle-sync/sync.pb | 14 -------------- 4 files changed, 5 insertions(+), 14 deletions(-) delete mode 100644 testing/resources/test-project/.cg/gradle-sync/project.pb delete mode 100644 testing/resources/test-project/.cg/gradle-sync/sync.lock delete mode 100644 testing/resources/test-project/.cg/gradle-sync/sync.pb diff --git a/.gitignore b/.gitignore index af44d5bf1c..94a0f8a5f9 100755 --- a/.gitignore +++ b/.gitignore @@ -193,3 +193,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/testing/resources/test-project/.cg/gradle-sync/project.pb b/testing/resources/test-project/.cg/gradle-sync/project.pb deleted file mode 100644 index 7828f6edd280f53ff93859422cf690583e1b91f0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12678925 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­C0w`D6G1(GyLv)c@*k!ke; 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

- - - - 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/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/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/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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 13124ed35f..192e0a9dc6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -272,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" } 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 66183a6d71..1a46ea1775 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -51,6 +51,7 @@ object TooltipTag { 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" diff --git a/resources/src/main/res/values-in-rID/layouteditor_migrated.xml b/resources/src/main/res/values-in-rID/layouteditor_migrated.xml index bd4ece2f24..d32ad35817 100644 --- a/resources/src/main/res/values-in-rID/layouteditor_migrated.xml +++ b/resources/src/main/res/values-in-rID/layouteditor_migrated.xml @@ -1,9 +1,9 @@ - - + + Agen AI Batal Hapus diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index e9517f1051..ed1b49609f 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -935,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 @@ -958,6 +958,10 @@ Not Loaded Permissions Dependencies + by %1$s + Not Loaded + Disabled + Enabled Plugin crashed @@ -1049,6 +1053,8 @@ Redo Delete Add + Navigate back + Show help tooltip Search Error Warning @@ -1120,11 +1126,23 @@ 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. @@ -1137,9 +1155,21 @@ Overwrite Rename & Install New collection name - "%1$s" installed successfully 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. @@ -1302,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

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 U(c-xW(XvTu9wT*&^L#q^A5`)!oB#j- 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 From 059629232db0070960ac3b934c3b9de1304ec3ec Mon Sep 17 00:00:00 2001 From: davidschachterADFA Date: Tue, 25 Aug 2026 18:23:16 -0700 Subject: [PATCH 39/40] ADFA-5263: Delete copyToTestDir, which copied a jar nothing reads (#1739) The task copied tooling-api-model.jar to /tests/test-home/.cg/init/model.jar. Nothing reads that file, and nothing reads that directory. The only consumer of a "test home" is gradle-plugin's test helper, which resolves FileProvider.testHomeDir() -- testing/resources/test-home, a different directory -- and then *writes its own* init script there, with a classpath from Gradle's PluginUnderTestMetadataReading. It never asks for a model jar. A grep for model.jar across the repo returned only the task that produced it. The destination had drifted before: 2a841748b (Feb 2023) is "fix: invalid path specified in copyToTestDir", and #1161 renamed .androidide to .cg inside it. Removing it takes three problems with it: - into(rootProject.mkdir(...)) ran at configuration time, so merely realizing the task created directories in the source tree -- on --dry-run, and again after every clean. That is why tests/test-home kept reappearing. - outputs.upToDateWhen { false } on both the copy and jar meant any build touching this module re-jarred and re-copied unconditionally. - Its output being a directory inside the source tree is what tripped Gradle's implicit-dependency validation against Spotless (ADFA-5244). That was worked around at the consumer by excluding the directory from the Spotless walk. Verified: the jar still builds; tests/ is no longer created at configuration time; :app:assembleV8Debug succeeds with the task absent from the graph; and `:common:compileV8DebugKotlin spotlessCheck` -- the exact invocation ADFA-5244 was filed for -- now passes on this branch, which carries no Spotless exclude at all. The two .gitignore entries that existed only for this task's output go too. Co-authored-by: Claude Opus 5 --- .gitignore | 2 -- subprojects/tooling-api-model/build.gradle.kts | 13 ------------- 2 files changed, 15 deletions(-) diff --git a/.gitignore b/.gitignore index 94a0f8a5f9..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/ 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 } -} From f13ddd629751bc566daffc819f42305b894c3dac Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 25 Aug 2026 19:44:51 -0700 Subject: [PATCH 40/40] ADFA-4928 create a single manager for plugins and templates (#1627) * ADFA-4928: Wire up Jetpack Compose in the app module Adds the Compose plugin/buildFeatures/dependencies to app/build.gradle.kts, mirroring the floating-window/profiler modules' setup, plus a shared ManagerTheme composable that resolves Theme.AndroidIDE's Material3 attrs (same technique as FloatingTheme). This is the first commit of the Plugin Manager + Template Manager merge (ADR 0009 requires new screens to be Compose); the theme/build wiring lands separately from any screen code so it's independently reviewable and buildable. * ADFA-4928: Port Plugin Manager screen to Compose Rebuilds PluginManagerActivity's screen in Jetpack Compose (ADR 0009), preserving every capability of the old RecyclerView/dialogs UI: install via SAF picker, enable/disable/uninstall, overwrite and signature-mismatch conflict handling, restart prompt, and the discover-plugins action. PluginManagerViewModel/PluginRepository are reused unchanged. The six long-press tooltip anchor points collapse to two (list items, and the screen's background/empty state) since they all showed the same TooltipTag.PLUGIN_MANAGER content anyway - verified on-device that the long-press still correctly reaches TooltipManager. Also moves two dialogs' hardcoded English strings (uninstall confirmation, plugin details labels) into string resources. Note: taken together with the prior commit, this is the buildable/ tested state; the prior commit's PluginListAdapter.kt deletion was accidentally bundled with the build-wiring commit rather than this one, so that earlier commit alone doesn't compile in isolation - only the combined history does (verified via :app:assembleV8Debug and a manual on-device pass). * ADFA-4928: Add Templates data layer Ports the parsing/model layer from appdevforall/TemplateManagerPlugin (CgtTemplateReader, TemplateMetadata/CgtFileItem, plus their unit tests) into the app module as the basis for the new Templates tab. Adds TemplateRepository/TemplateRepositoryImpl, which reimplement the plugin's install/uninstall/delete semantics as direct file operations on Environment.TEMPLATES_DIR + the Downloads folder, since the host app doesn't need IdeTemplateService's plugin-facing permission gate. Provenance (bundled/plugin/user) is inferred from the same filename convention IdeTemplateServiceImpl/PluginProjectManager already use. Adds TemplateManagerViewModel (UDF shape matching PluginManagerViewModel) and a Koin di/TemplateModule, registered in IDEApplication alongside pluginModule. No UI yet - this commit is data-layer only. CgtTemplateReaderTest needs @RunWith(RobolectricTestRunner::class): org.json.JSONObject throws "not mocked" under a plain JVM unit test, same as other app-module tests that touch real android.jar classes. * ADFA-4928: Add Templates tab Compose UI Adds the Compose UI for the Templates tab, backed by the data layer from the previous commit: TemplateListItem (card - tapping only opens the multi-template sub-list, matching the reference plugin's design), TemplateManagerDialogs (delete confirmation, file-level details, per-template details, multi-template sub-list), and TemplateManagerScreen (content composable wiring the ViewModel's uiState/uiEffect, same long-press pointerInput tooltip shim as the Plugins tab, new TooltipTag.TEMPLATE_MANAGER). TemplateManagerScreen is content-only (no Scaffold/TopAppBar/FAB) - unlike the Plugins tab there's no install-flow FAB, matching the ported plugin's passive Downloads-folder scanning. It's meant to be composed as one tab's body inside the shared manager screen; wiring the two tabs together is the next commit. * ADFA-4928: Wire Plugins and Templates tabs together New ManagerScreen composable owns the shared Scaffold/TopAppBar/TabRow + HorizontalPager, hosting Plugins and Templates as pages (Plugins default). The FAB and discover-plugins action only render on the Plugins tab, since Templates is a passive Downloads-folder scan with no equivalent action. Refactors the old PluginManagerScreen into PluginManagerContent - a Scaffold-free content composable, matching TemplateManagerScreen's shape - so both tabs plug into ManagerScreen's single Scaffold instead of nesting their own. PluginManagerActivity now resolves both PluginManagerViewModel and TemplateManagerViewModel and renders ManagerScreen; its class name and entry points (Settings, the crash-recovery dialog) are unchanged. Updates ARCHITECTURE.md: this is the first production Compose screen in app (ADR 0009), and templates/manager is a new data-layer package. Verified end-to-end on a physical device: assembleV8Debug, installed APK, exercised both tabs from Settings -> Plugin Manager. Templates tab correctly scanned Environment.TEMPLATES_DIR + Downloads (found real pre-existing .cgt fixtures on the test device), and a full install/uninstall round-trip moved files between Downloads and TEMPLATES_DIR and refreshed the list correctly. No crashes. * ADFA-4928: Complete tab wiring (PluginManagerContent refactor + activity + docs) Finishes the previous commit: a staging mistake (a `git add` call hit a stale pathspec and aborted before reaching these files) left `81e3797ab` with only the new `ManagerScreen.kt` and a content-less file rename, referencing a `PluginManagerContent` composable that didn't exist yet in that commit alone - not independently buildable. This commit adds what was missed: the actual `PluginManagerContent.kt` refactor (Scaffold/TopAppBar/FAB stripped out, now content-only), `PluginManagerActivity.kt` wired to render `ManagerScreen` with both view models, the `ARCHITECTURE.md` updates, and the `title_manager` string. Combined history through this commit compiles (:app:compileV8DebugKotlin) and matches what was already verified end-to-end on-device in the previous message. * ADFA-4928: Rename the preferences entry to Extensions Manager The Settings entry that opens the merged Plugins/Templates screen was still titled "Plugin Manager" with a summary mentioning "extensions" (the old plugin-only wording). Renamed to "Extensions Manager" with a summary reflecting both tabs it now opens: "Manage IDE plugins and templates". Verified on-device: preferences list and the opened screen both render correctly. * ADFA-4928: Warm Application.filesDir off the main thread PluginModule's Koin factories called Context.filesDir directly, which does a real File.exists() check on every call, not just the first. That trips StrictMode's DiskReadViolation the first time the Extensions Manager screen resolves PluginRepository/PluginManagerViewModel on the main thread. Cache the resolved File once, off-main, during app startup (IDEApplication.cachedFilesDir), and have PluginModule read that instead - later reads are then a plain field access rather than a syscall. Co-Authored-By: Claude Sonnet 5 * ADFA-4928: Narrow the plugin install file picker to .cgp-like files The SAF picker launched with "*/*", showing every file regardless of type. SAF filters by MIME, not extension, and .cgp has no registered MIME type, so the closest working filter is "application/octet-stream" - what document providers report for files with an unrecognized extension. This hides files with a known type (zips, jars, images, ...) while leaving .cgp files selectable. isSupportedPluginFile() still validates the actual pick, since this is an approximation, not an exact extension filter (SAF has no such thing). Co-Authored-By: Claude Sonnet 5 * ADFA-4928: Harden TemplateRepositoryImpl file operations Address CodeRabbit review feedback on PR #1627: - Use SLF4J logging instead of android.util.Log - Narrow runCatching to expected I/O/parsing exceptions, rethrowing CancellationException instead of swallowing it - Refuse to install/uninstall over an existing same-name destination file instead of silently overwriting it - Treat a failed source-file delete as an install/uninstall failure and roll back the copied destination file * ADFA-4928: Keep plugin picker/install work off the main thread Address CodeRabbit review feedback on PR #1627: - Warm IDEApplication.cachedFilesDir on an IO thread before Koin starts, eliminating the race where pluginModule/templateModule could resolve it on the main thread first - Bound FileImage's bitmap decode with inSampleSize and move the file-existence check inside the IO dispatcher; narrow its catch to recoverable failures and let CancellationException propagate - Move the picked plugin file's name/extension validation (a ContentResolver IPC call for content:// URIs) off the picker callback and into PluginManagerViewModel on a background dispatcher, routed back through a new ShowInstallConfirmation effect - Replace android.util.Log with SLF4J logging in PluginManagerContent - Narrow the file-picker launch catch to ActivityNotFoundException and log it instead of silently swallowing any Exception * ADFA-4928: Fix manager UI correctness/accessibility issues Address CodeRabbit review feedback on PR #1627: - Avoid double system-bar insets by zeroing ManagerScreen's Scaffold contentWindowInsets, since the activity's root already applies them - Fix the back button's TalkBack announcement (was "Cancel") with a dedicated cd_navigate_back string - Wire long-press tooltips to the discover-plugins action and install FAB - Always show Uninstall for a listed plugin, even when it failed to load, so a broken plugin has a recovery action - Move the detail-row "label: value" format into a string resource so translators control ordering/punctuation - Only treat a template card as clickable when it bundles more than one template, instead of always exposing tap/press semantics - Use an Android plurals resource for the template count string instead of a fixed "templates" string - Buffer TemplateManagerViewModel's uiEffect channel and use send() instead of trySend() so effects aren't dropped before a collector is ready * ADFA-4928: Add KDoc for TemplateMetadata/CgtFileItem Address CodeRabbit review feedback on PR #1627: document the model contracts, including the meaning of installed/provenance and the one-archive-to-many-templates relationship. Co-Authored-By: Claude Sonnet 5 * ADFA-4928: Enable JUnit Jupiter for app unit tests Address CodeRabbit review feedback on PR #1627 (matches the JUnit Jupiter + Truth strategy ARCHITECTURE.md already documents for unit tests, which the app module hadn't wired up yet): - Run app unit tests on the JUnit Platform, with the vintage engine so existing JUnit 4/Robolectric tests keep running unchanged - Migrate CgtFileItemTest (no Robolectric dependency) to org.junit.jupiter.api.Test with Truth assertions - Keep CgtTemplateReaderTest on JUnit 4/RobolectricTestRunner (no built-in Jupiter integration) but switch its assertions to Truth Verified all 22 app unit test classes still run under :app:testV8DebugUnitTest with 0 failures. * ADFA-4928: Fix regressions CodeRabbit's re-review found in prior fixes Address CodeRabbit follow-up review feedback on PR #1627: - Only run the cachedFilesDir warmup eagerly in onCreate() when credential-protected storage is already unlocked - the default Context.getFilesDir() throws during Direct Boot. When locked, warm it instead from CredentialProtectedApplicationLoader.load(), which only proceeds once that storage is confirmed accessible. - Base FileImage's inSampleSize loop on the larger image dimension instead of requiring both dimensions to exceed the target, so a wide-but-short (or tall-but-narrow) image still gets downsampled - Log FileImage's swallowed SecurityException/OutOfMemoryError icon-load failures via a throttled SLF4J warning, without logging the file path - Buffer PluginManagerViewModel's uiEffect channel and use send() instead of trySend(), same fix already applied to TemplateManagerViewModel, so effects (e.g. the new ShowInstallConfirmation) aren't dropped - Narrow UriExtensions.getFileName's second catch to SecurityException/ IllegalArgumentException instead of blanket Exception, so unexpected ContentResolver failures surface instead of being silently mislabeled as "Unknown File" (and then downstream as an unsupported plugin file); switch its logging to a class-scoped SLF4J logger * ADFA-4928: Address human review comments from hal-eisen-adfa - Disable the install FAB while a plugin install is in flight, so a second tap can't start a concurrent installPlugin() coroutine. The Compose ManagerScreen replaced the old Activity, which disabled the FAB via binding.fabInstallPlugin.isEnabled = !state.isInstalling; nothing carried that behavior over. - Fix PLUGIN_AUTHORING.md pointers left dangling by the PluginListAdapter.kt -> PluginListItem.kt/FileImage.kt migration. The delete-failure-handling and cachedFilesDir warmup comments from the same review were already addressed by prior commits on this branch; verified against current HEAD, no further changes needed. * ADFA-4928: Fix double system-bar insets and non-lifecycle-scoped effect collection - ManagerScreen's TopAppBar still used its default status-bar insets on top of PluginManagerActivity.onApplySystemBarInsets, which already pads the root view by the full system-bar insets (that padding doesn't consume the insets, so Compose saw them a second time). Zero out TopAppBar's windowInsets to match the Scaffold's contentWindowInsets, which was already zeroed. - PluginManagerContent and TemplateManagerScreen collected viewModel.uiEffect in a bare LaunchedEffect, so it kept collecting while the activity was stopped. A plugin install finishing while the app is backgrounded could then run DialogUtils.showRestartPrompt or a flashbar builder against a stopped activity. Wrap both collectors in repeatOnLifecycle(STARTED), matching the old repeatOnLifecycle(STARTED) pattern used elsewhere in the app. * ADFA-4928: Address second review pass from hal-eisen-adfa - Long-press tooltips on the FAB and Discover-plugins IconButton never fired: pointerInput(detectTapGestures) placed on the caller-side modifier loses the down event to the button's own internal clickable, which runs first on the Main pointer pass. Drive the tooltip off the button's own MutableInteractionSource instead (press duration vs LocalViewConfiguration's longPressTimeoutMillis), which observes the same press stream the button already dispatches rather than racing it for the raw pointer event. - CgtTemplateReader.readTemplates read a zip entry's bytes unbounded, so a corrupt/hostile .cgt sitting in the public Downloads folder could OOM the app; bound the read and throw IOException past 1 MiB. parseCgtFile also didn't catch IllegalArgumentException, which ZipInputStream.nextEntry throws for a non-UTF-8 entry name - that propagated out of the bare viewModelScope.launch in TemplateManagerViewModel.loadTemplates (no CoroutineExceptionHandler) and crashed the app. Both are now handled per-file, so one bad archive is skipped instead of failing the whole scan. - UriExtensions.getFileName's catch was narrowed to SecurityException/IllegalArgumentException, but a misbehaving content provider can throw other RuntimeExceptions from query()/getString() (CursorWindowAllocationException, a wrapped DeadObjectException, ...). Broadened back to Exception, since this is a best-effort display-name lookup, not a path that should ever crash the caller. - TemplateManagerDialogs' DetailRow still built "$label: $value" with string concatenation instead of the R.string.label_value fix that landed in the plugin dialog, and the optional-tags list hardcoded a non-ASCII "*" bullet in code (CLAUDE.md's ASCII rule). Added R.string.template_optional_tag and reused R.string.label_value. TemplateListItem's status/provenance row had the same hardcoded-separator shape; extracted it to R.string.label_separator. - PluginManagerActivity's try/catch around setContent no longer caught anything: setContent only registers the composable, and its lambda (where both ViewModels first resolve via Koin) runs at first layout, after onCreate has already returned past the catch. Force-resolve both `by viewModel()` delegates inside the try, before setContent. - PluginListItem.pluginVersionLabel duplicated CgtFileItem.versionLabel and disagreed with it on blank input (a stray "v" chip vs the tested ""). Deleted the duplicate and reused the tested helper. Added a CgtTemplateReaderTest regression case covering the bounded-read cap. Verified via :app:testV8DebugUnitTest (all passing) and spotlessCheck. * ADFA-4928: Guard FileImage's downsampling loop against a non-positive bound CodeRabbit flagged (2026-08-05 review, still unresolved) that decodeBounded()'s inSampleSize loop assumes maxDimensionPx > 0. If it's ever <= 0 - e.g. the 40.dp default rounding to a sub-pixel size at an unusual density - the loop condition (a non-negative quotient >= a non-positive bound) is permanently true, hanging on an unbounded doubling of inSampleSize instead of throwing. Skip the downsampling loop entirely in that case and decode at inSampleSize = 1. * ADFA-4928: Address third review pass from hal-eisen-adfa - Wrap the cachedFilesDir warm-up in runCatching in both IDEApplication.onCreate and CredentialProtectedApplicationLoader.load, so a filesDir failure unrelated to Direct Boot lock state (ADFA-2358) degrades to a disk read on first use instead of crashing the process. - Replace the Discover-plugins IconButton with a Box+combinedClickable so a single gesture detector owns long-press and click, and make the FAB consume a one-shot suppression flag set at the long-press timeout, so long-pressing either control shows the tooltip without also firing its click action. Verified on-device: long-press shows the tooltip and leaves the file picker/browser closed; a normal tap still opens each. Co-Authored-By: Claude Sonnet 5 * ADFA-4928: Address fourth review pass from hal-eisen-adfa - Fix the FAB long-press suppression flag latching and eating a later, unrelated tap: reset it at press start instead of relying on the click to clear it, so a long press that never ends in a tap-up on the FAB (slide-off, or the tooltip popup stealing the gesture) can no longer swallow the next real Install tap. Blocking. - Restore accessibility parity the IconButton -> Box swap dropped for the Discover-plugins control: role = Role.Button and onLongClickLabel so TalkBack again announces it as a button and names the long-press action. - Broaden the plugin file-picker SAF filter to application/octet-stream, application/zip, */* - some providers report a .cgp as application/zip rather than octet-stream, which the single-type filter hid with no way to reach them. - Delete the dead TemplateOperation sealed class (zero references). - Wire TemplateManagerUiState.isLoading through installTemplate/ uninstallTemplate/confirmDeleteDownloadFile (previously only loadTemplates set it) and render it as a top-aligned LinearProgressIndicator, so install/ uninstall/delete get visible feedback between the tap and the flashbar. - Make the Templates and Plugins tab dialog state rememberSaveable, so rotating or switching tabs (HorizontalPager disposes the off-screen page's state) no longer silently dismisses an open confirmation dialog. Neither CgtFileItem nor PluginInfo is Parcelable, so state is keyed on the file path / plugin id and the live item is resolved from uiState at the point of use - this avoids adding Parcelable to plugin-api's public API surface. - Add TemplateRepositoryImpl tests pinning the two riskiest branches: 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 for installTemplate and uninstallTemplate). Add TemplateManagerViewModel tests covering the init load, install success/failure, and the effect buffering Channel.BUFFERED was chosen to protect. Not changed: the two threads Hal already marked resolved (cachedFilesDir runCatching, FAB/Discover long-press-vs-click) needed no further action. The template.manager/plugin.manager tooltip-body question is answered in a PR reply - the bundled documentation.db has neither tag, pre-existing and not fixable from this repo (the asset is fetched from an external URL, not seeded here). Co-Authored-By: Claude Sonnet 5 * ADFA-4928: Offer the add-extension button on both manager tabs The FAB and the discover-plugins action were both gated on the Plugins tab, justified by the Templates tab being "a passive scan of the Downloads folder with no equivalent action". Merging stage falsified that: ADFA-4934 brought in TemplateCollectionRepository and a tested .cgt install flow (confirm -> name conflict -> overwrite/rename), reachable until now only by opening a file from outside the app. The screen is the Extensions Manager, so one "+" means "add an extension" on either tab. The picked file is routed by extension and the owning tab is brought forward, so the result is visible where it landed. Discover stays Plugins-only - it opens a plugin catalog, which has no meaning on the Templates tab. - Move the SAF launcher from PluginManagerContent up to ManagerScreen. HorizontalPager disposes the off-screen page, so a launcher owned by the Plugins page would not exist while Templates is showing. Routing also brings the target tab forward *before* dispatching, because uiEffect is a receiveAsFlow() Channel with a single consumer that lives on that page. - Extract ExternalFileInstallDialogs from ExternalFileInstallScreen, so the manager reuses ADFA-4934's confirm/conflict/rename flow rather than growing a second one. The activity keeps its own finish behaviour via onFinish. - Keep .cgp on the existing ContentUri path instead of routing it through onReceived. That path hands the ViewModel a LocalFile temp copy, for which the install dialog's "delete installation file after install" checkbox is meaningless - picked plugins would have silently lost that option. - Drop the now-dead OpenFilePicker event, effect and openFilePicker(). - Add msg_unsupported_extension_file for a pick that is neither type. Verified on the emulator: the "+" is present on both tabs and Discover is not; picking a .cgt stays on Templates and opens the collection-install dialog naming all nine templates in the archive; picking a .cgp brings the Plugins tab forward with the plugin dialog and its delete-source checkbox; picking a .exp shows the new message. * ADFA-4928: Quote the archive name with backticks in the install message msg_template_installed was written as "%1$s" installed successfully, but Android strips unescaped double quotes from a string value, so the quotes never reached the screen - it rendered as `qa-hello installed successfully` with the name unquoted. Backticks are not stripped, and strings.xml is already inconsistent about quoting %1$s, so this avoids adding another escaped-quote variant. Verified on the emulator: the flashbar now reads `qa-hello` installed successfully. * ADFA-4928: Show one card per template archive, not one per file on disk scanTemplates() concatenated the two directory scans with no de-duplication, so a .cgt present in both the template store and Downloads produced two cards. They render identically apart from the status line - same title (which is the first bundled template's name, not the archive's), same filename, and neither shows a location - and the Downloads twin is a dead end, since installTemplate refuses to overwrite and its Install can only ever fail. Let the installed copy win. Names are compared case-insensitively to match the stricter of the two install paths (TemplateCollectionRepository .findExistingCollision), so any row still listed as "not installed" is one the user can actually install. Filtering happens before parsing, so a shadowed archive is not unzipped just to be discarded. Tests move to Robolectric: the new cases build real .cgt archives, and parsing one reaches org.json.JSONObject, a "not mocked" stub under plain android.jar - the same reason CgtTemplateReaderTest already uses it. Added cases cover the twin being hidden, case-insensitive matching, and - the one that catches a sloppy filter - downloads that are not twins surviving. Known interaction: with the twin hidden, uninstallTemplate still refuses while a same-named file sits in Downloads, and that file no longer has a row. The failure names it exactly ("A download named 'x.cgt' already exists in /storage/emulated/0/Download"), so it is recoverable; changing uninstall's semantics is deliberately left out of this change. Verified on the emulator by reproducing the reported state - qa-hello.cgt installed and a second copy pushed to Downloads - which previously showed two identical cards and now shows one, marked Installed. --------- Co-authored-by: yaturner Co-authored-by: Claude Sonnet 5 Co-authored-by: jimturner-adfa --- ARCHITECTURE.md | 14 +- app/build.gradle.kts | 7 + .../activities/ExternalFileInstallScreen.kt | 52 ++- .../activities/PluginManagerActivity.kt | 377 ++---------------- .../androidide/adapters/PluginListAdapter.kt | 172 -------- .../CredentialProtectedApplicationLoader.kt | 7 + .../itsaky/androidide/app/IDEApplication.kt | 29 +- .../com/itsaky/androidide/di/PluginModule.kt | 6 +- .../itsaky/androidide/di/TemplateModule.kt | 30 ++ .../repositories/TemplateRepository.kt | 28 ++ .../repositories/TemplateRepositoryImpl.kt | 186 +++++++++ .../templates/manager/models/CgtFileItem.kt | 70 ++++ .../manager/parsing/CgtTemplateReader.kt | 86 ++++ .../androidide/ui/compose/ManagerScreen.kt | 324 +++++++++++++++ .../androidide/ui/compose/common/FileImage.kt | 123 ++++++ .../ui/compose/plugins/PluginListItem.kt | 153 +++++++ .../compose/plugins/PluginManagerContent.kt | 298 ++++++++++++++ .../compose/plugins/PluginManagerDialogs.kt | 131 ++++++ .../ui/compose/templates/TemplateListItem.kt | 184 +++++++++ .../templates/TemplateManagerDialogs.kt | 165 ++++++++ .../templates/TemplateManagerScreen.kt | 267 +++++++++++++ .../ui/compose/theme/ManagerTheme.kt | 59 +++ .../ui/models/PluginManagerUiState.kt | 29 +- .../ui/models/TemplateManagerUiState.kt | 60 +++ .../viewmodels/PluginManagerViewModel.kt | 103 +++-- .../viewmodels/TemplateManagerViewModel.kt | 166 ++++++++ .../res/layout/activity_plugin_manager.xml | 77 +--- .../main/res/layout/dialog_install_plugin.xml | 17 - app/src/main/res/layout/item_plugin.xml | 106 ----- app/src/main/res/menu/menu_plugin_manager.xml | 11 - .../TemplateRepositoryImplTest.kt | 217 ++++++++++ .../manager/models/CgtFileItemTest.kt | 73 ++++ .../manager/parsing/CgtTemplateReaderTest.kt | 134 +++++++ .../TemplateManagerViewModelTest.kt | 116 ++++++ .../itsaky/androidide/utils/UriExtensions.kt | 55 +-- docs/PLUGIN_AUTHORING.md | 6 +- gradle/libs.versions.toml | 1 + .../androidide/idetooltips/TooltipTag.kt | 1 + .../values-in-rID/layouteditor_migrated.xml | 10 +- resources/src/main/res/values/strings.xml | 68 +++- 40 files changed, 3211 insertions(+), 807 deletions(-) delete mode 100644 app/src/main/java/com/itsaky/androidide/adapters/PluginListAdapter.kt create mode 100644 app/src/main/java/com/itsaky/androidide/di/TemplateModule.kt create mode 100644 app/src/main/java/com/itsaky/androidide/repositories/TemplateRepository.kt create mode 100644 app/src/main/java/com/itsaky/androidide/repositories/TemplateRepositoryImpl.kt create mode 100644 app/src/main/java/com/itsaky/androidide/templates/manager/models/CgtFileItem.kt create mode 100644 app/src/main/java/com/itsaky/androidide/templates/manager/parsing/CgtTemplateReader.kt create mode 100644 app/src/main/java/com/itsaky/androidide/ui/compose/ManagerScreen.kt create mode 100644 app/src/main/java/com/itsaky/androidide/ui/compose/common/FileImage.kt create mode 100644 app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginListItem.kt create mode 100644 app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerContent.kt create mode 100644 app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerDialogs.kt create mode 100644 app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateListItem.kt create mode 100644 app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateManagerDialogs.kt create mode 100644 app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateManagerScreen.kt create mode 100644 app/src/main/java/com/itsaky/androidide/ui/compose/theme/ManagerTheme.kt create mode 100644 app/src/main/java/com/itsaky/androidide/ui/models/TemplateManagerUiState.kt create mode 100644 app/src/main/java/com/itsaky/androidide/viewmodels/TemplateManagerViewModel.kt delete mode 100644 app/src/main/res/layout/dialog_install_plugin.xml delete mode 100644 app/src/main/res/layout/item_plugin.xml delete mode 100644 app/src/main/res/menu/menu_plugin_manager.xml create mode 100644 app/src/test/java/com/itsaky/androidide/repositories/TemplateRepositoryImplTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/templates/manager/models/CgtFileItemTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/templates/manager/parsing/CgtTemplateReaderTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/viewmodels/TemplateManagerViewModelTest.kt diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 59df122920..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.) ``` ┌─────────────────────────────────────────────┐ @@ -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). | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index acff8f5ee7..3da5b0c43f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -95,6 +95,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() } } } @@ -391,6 +394,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") diff --git a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt index 16f86299d2..530f115285 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt @@ -54,26 +54,55 @@ private sealed interface DialogUiState { ) : 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 -> { - 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, effect.filePath), - ) - (context as? Activity)?.finish() + currentOnForwardPlugin(effect.filePath) } is ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation -> { @@ -101,7 +130,8 @@ fun ExternalFileInstallScreen(viewModel: ExternalFileInstallViewModel) { } is ExternalFileInstallUiEffect.Finish -> { - (context as? Activity)?.finish() + dialogState = DialogUiState.None + currentOnFinish() } } } 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 08fec8b4da..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,62 +1,27 @@ - - 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.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.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.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION +import com.itsaky.androidide.viewmodels.TemplateManagerViewModel import org.koin.androidx.viewmodel.ext.android.viewModel -import java.io.File class PluginManagerActivity : EdgeToEdgeIDEActivity() { companion object { - private const val TAG = "PluginManagerActivity" - private const val PLUGIN_EXTENSION = ".$PLUGIN_ARCHIVE_EXTENSION" - /** - * Absolute path of a `.cgp` file forwarded from - * [com.itsaky.androidide.activities.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. + * 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" } @@ -66,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(PluginInstallSource.ContentUri(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) @@ -101,22 +49,31 @@ 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 @@ -135,28 +92,14 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { handlePendingInstallExtra() } - // No savedInstanceState guard: markPendingInstallHandled() is the idempotency check, scoped - // to the ViewModel instance rather than the Activity's recreation reason - it survives - // rotation (skips a duplicate dialog there) but resets on process death (a fresh ViewModel is - // created), so a process-death-recreated instance still shows the dialog instead of silently - // dropping the forwarded install. The intent's extra itself is preserved across both cases by - // the OS. + /** + * 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 -> - if (viewModel.markPendingInstallHandled(filePath)) { - lifecycleScope.launch { - val file = File(filePath) - val exists = withContext(Dispatchers.IO) { file.exists() } - if (exists) { - showInstallConfirmation(PluginInstallSource.LocalFile(file)) - } else { - // Can legitimately happen if InstallTempFiles' stale-file sweep (or an - // earlier failed cleanup) removed the temp file before this dialog ever - // got a chance to show it - a clear message beats a generic install error. - flashError(getString(R.string.msg_plugin_file_not_found)) - } - } - } + pluginViewModel.onPendingInstallFile(filePath) } } @@ -165,29 +108,6 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { feedbackButtonManager?.loadFabPosition() } - 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 - } - } - 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 onDestroy() { super.onDestroy() _binding = null @@ -202,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( @@ -255,178 +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) - - /** - * For a [PluginInstallSource.LocalFile] (a `.cgp` forwarded from [ExternalFileInstallActivity]), - * [source] is our own hidden temp copy, not a file the user picked - there's no checkbox to - * offer (deletion isn't optional) and no source worth keeping on decline/cancel either, so - * both the negative button and back-press/tap-outside route to [PluginManagerUiEvent.CancelPendingInstall]. - * One shared dialog builder for both cases so a future button/copy change can't be applied to - * only one branch and silently reintroduce a leaked-temp-file bug in the other. - */ - private fun showInstallConfirmation(source: PluginInstallSource) { - val forceDeleteSource = source is PluginInstallSource.LocalFile - val dialogView = if (forceDeleteSource) null else layoutInflater.inflate(R.layout.dialog_install_plugin, null) - val deleteCheckBox = dialogView?.findViewById(R.id.checkbox_delete_source) - val onCancel = { - if (forceDeleteSource) { - viewModel.onEvent(PluginManagerUiEvent.CancelPendingInstall(source)) - } - } - - MaterialAlertDialogBuilder(this) - .setTitle(R.string.title_install_plugin) - .apply { dialogView?.let { setView(it) } } - .setPositiveButton(R.string.btn_install) { _, _ -> - val deleteSourceAfterInstall = if (forceDeleteSource) true else deleteCheckBox?.isChecked == true - viewModel.onEvent(PluginManagerUiEvent.InstallPlugin(source, deleteSourceAfterInstall)) - }.setNegativeButton(android.R.string.cancel) { _, _ -> onCancel() } - .setOnCancelListener { onCancel() } - .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.source, effect.deleteSourceAfterInstall), - ) - }.setNegativeButton(android.R.string.cancel) { _, _ -> - viewModel.onEvent(PluginManagerUiEvent.CancelPendingInstall(effect.source)) - }.setOnCancelListener { - // Same reasoning as showInstallConfirmation()'s onCancelListener: back-press must - // route through CancelPendingInstall too, or a forwarded source's temp file leaks. - viewModel.onEvent(PluginManagerUiEvent.CancelPendingInstall(effect.source)) - }.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/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/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/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 fc3a7b0e0a..abc42ee177 100644 --- a/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt +++ b/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt @@ -22,7 +22,7 @@ val pluginModule = single { PluginRepositoryImpl( pluginManagerProvider = { IDEApplication.getPluginManager() }, - pluginsDir = File(androidContext().filesDir, "plugins"), + pluginsDir = File(IDEApplication.cachedFilesDir, "plugins"), ) } @@ -35,7 +35,7 @@ val pluginModule = PluginManagerViewModel( pluginRepository = get(), contentResolver = androidContext().contentResolver, - filesDir = androidContext().filesDir, + filesDir = IDEApplication.cachedFilesDir, ) } @@ -44,7 +44,7 @@ val pluginModule = pluginRepository = get(), templateCollectionRepository = get(), contentResolver = androidContext().contentResolver, - filesDir = androidContext().filesDir, + 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/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/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/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/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/PluginManagerUiState.kt b/app/src/main/java/com/itsaky/androidide/ui/models/PluginManagerUiState.kt index 07c4642fbb..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,9 +1,11 @@ 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( @@ -24,12 +26,18 @@ data class PluginManagerUiState( * 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 { +sealed class PluginInstallSource : Parcelable { + @Parcelize data class ContentUri( val uri: Uri, ) : PluginInstallSource() + @Parcelize data class LocalFile( val file: File, ) : PluginInstallSource() @@ -64,7 +72,14 @@ sealed class PluginManagerUiEvent { val source: PluginInstallSource, ) : PluginManagerUiEvent() - object OpenFilePicker : 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, @@ -85,7 +100,15 @@ sealed class PluginManagerUiEffect { val plugin: PluginInfo, ) : PluginManagerUiEffect() - object OpenFilePicker : 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, 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/viewmodels/PluginManagerViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt index 17d9f5cebe..c4a27463f4 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt @@ -18,6 +18,7 @@ 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 @@ -59,10 +60,34 @@ class PluginManagerViewModel( // skip the same-ID signature check by seeing an still-empty list. private val initialLoadCompleted = CompletableDeferred() - /** See [pendingInstallGate] for why this, rather than an Activity `savedInstanceState` - * check, is what correctly distinguishes "already shown after a rotation" from "never shown - * because the process died". */ - fun markPendingInstallHandled(filePath: String): Boolean = pendingInstallGate.consume(filePath) + /** + * 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 = @@ -135,8 +160,8 @@ class PluginManagerViewModel( viewModelScope.launch { deleteIfLocalFile(event.source) } } - is PluginManagerUiEvent.OpenFilePicker -> { - openFilePicker() + is PluginManagerUiEvent.FileSelected -> { + handleFileSelected(event.uri) } is PluginManagerUiEvent.ShowPluginDetails -> { @@ -175,7 +200,7 @@ class PluginManagerViewModel( _uiState.update { it.copy(isLoading = false) } - _uiEffect.trySend( + _uiEffect.send( PluginManagerUiEffect.ShowError( R.string.msg_plugin_load_failed, listOf(exception.message ?: ""), @@ -205,15 +230,15 @@ class PluginManagerViewModel( .onSuccess { success -> if (success) { Log.d(TAG, "Plugin enabled successfully: $pluginId") - _uiEffect.trySend(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_enabled)) + _uiEffect.send(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)) + _uiEffect.send(PluginManagerUiEffect.ShowError(R.string.msg_plugin_enable_failed)) } }.onFailure { exception -> Log.e(TAG, "Error enabling plugin: $pluginId", exception) - _uiEffect.trySend( + _uiEffect.send( PluginManagerUiEffect.ShowError( R.string.msg_plugin_enable_error, listOf(exception.message ?: ""), @@ -237,15 +262,15 @@ class PluginManagerViewModel( .onSuccess { success -> if (success) { Log.d(TAG, "Plugin disabled successfully: $pluginId") - _uiEffect.trySend(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_disabled)) + _uiEffect.send(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)) + _uiEffect.send(PluginManagerUiEffect.ShowError(R.string.msg_plugin_disable_failed)) } }.onFailure { exception -> Log.e(TAG, "Error disabling plugin: $pluginId", exception) - _uiEffect.trySend( + _uiEffect.send( PluginManagerUiEffect.ShowError( R.string.msg_plugin_disable_error, listOf(exception.message ?: ""), @@ -264,7 +289,7 @@ class PluginManagerViewModel( val plugin = _uiState.value.plugins.find { it.metadata.id == pluginId } if (plugin != null) { viewModelScope.launch { - _uiEffect.trySend(PluginManagerUiEffect.ShowUninstallConfirmation(plugin)) + _uiEffect.send(PluginManagerUiEffect.ShowUninstallConfirmation(plugin)) } } } @@ -281,16 +306,16 @@ class PluginManagerViewModel( .onSuccess { success -> if (success) { Log.d(TAG, "Plugin uninstalled successfully: $pluginId") - _uiEffect.trySend(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_uninstalled)) + _uiEffect.send(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_uninstalled)) loadPlugins() - _uiEffect.trySend(PluginManagerUiEffect.ShowRestartPrompt) + _uiEffect.send(PluginManagerUiEffect.ShowRestartPrompt) } else { Log.w(TAG, "Failed to uninstall plugin: $pluginId") - _uiEffect.trySend(PluginManagerUiEffect.ShowError(R.string.msg_plugin_uninstall_failed)) + _uiEffect.send(PluginManagerUiEffect.ShowError(R.string.msg_plugin_uninstall_failed)) } }.onFailure { exception -> Log.e(TAG, "Error uninstalling plugin: $pluginId", exception) - _uiEffect.trySend( + _uiEffect.send( PluginManagerUiEffect.ShowError( R.string.msg_plugin_uninstall_error, listOf(exception.message ?: ""), @@ -371,16 +396,16 @@ class PluginManagerViewModel( .installPluginFromFile(pluginFile) .onSuccess { Log.d(TAG, "Plugin installed successfully") - _uiEffect.trySend(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_installed)) + _uiEffect.send(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_installed)) loadPlugins() - _uiEffect.trySend(PluginManagerUiEffect.ShowRestartPrompt) + _uiEffect.send(PluginManagerUiEffect.ShowRestartPrompt) if (deleteSourceAfterInstall) { deleteInstallSource(source) } }.onFailure { exception -> Log.e(TAG, "Failed to install plugin", exception) - _uiEffect.trySend( + _uiEffect.send( PluginManagerUiEffect.ShowError( R.string.msg_plugin_install_failed, listOf(exception.message ?: ""), @@ -398,7 +423,7 @@ class PluginManagerViewModel( throw e } catch (exception: Exception) { Log.e(TAG, "Error installing plugin from URI", exception) - _uiEffect.trySend( + _uiEffect.send( PluginManagerUiEffect.ShowError( R.string.msg_plugin_install_failed, listOf(exception.message ?: ""), @@ -427,7 +452,7 @@ class PluginManagerViewModel( val incoming = pluginRepository.getPluginMetadataFromFile(pluginFile).getOrNull() if (incoming == null) { Log.w(TAG, "Failed to read plugin metadata from ${pluginFile.name}; aborting install") - _uiEffect.trySend(PluginManagerUiEffect.ShowError(R.string.msg_plugin_invalid_file)) + _uiEffect.send(PluginManagerUiEffect.ShowError(R.string.msg_plugin_invalid_file)) deleteIfLocalFile(source) return true } @@ -442,7 +467,7 @@ class PluginManagerViewModel( .getOrDefault(false) if (!signaturesMatch) { - _uiEffect.trySend( + _uiEffect.send( PluginManagerUiEffect.ShowError( R.string.msg_plugin_signature_mismatch, listOf(existing.metadata.name), @@ -455,7 +480,7 @@ class PluginManagerViewModel( // 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.trySend( + _uiEffect.send( PluginManagerUiEffect.ShowOverwriteConfirmation( existing = existing, incomingMetadata = incoming, @@ -495,7 +520,7 @@ class PluginManagerViewModel( withContext(Dispatchers.IO) { try { if (!DocumentsContract.deleteDocument(contentResolver, uri)) { - _uiEffect.trySend( + _uiEffect.send( PluginManagerUiEffect.ShowError(R.string.msg_source_delete_failed), ) } @@ -503,7 +528,7 @@ class PluginManagerViewModel( throw e } catch (e: Exception) { Log.w(TAG, "Failed to delete source document", e) - _uiEffect.trySend( + _uiEffect.send( PluginManagerUiEffect.ShowError(R.string.msg_source_delete_failed), ) } @@ -511,11 +536,27 @@ class PluginManagerViewModel( } /** - * Open file picker + * 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 openFilePicker() { + private fun handleFileSelected(uri: Uri) { viewModelScope.launch { - _uiEffect.trySend(PluginManagerUiEffect.OpenFilePicker) + 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)) + } } } @@ -524,7 +565,7 @@ class PluginManagerViewModel( */ private fun showPluginDetails(plugin: PluginInfo) { viewModelScope.launch { - _uiEffect.trySend(PluginManagerUiEffect.ShowPluginDetails(plugin)) + _uiEffect.send(PluginManagerUiEffect.ShowPluginDetails(plugin)) } } 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/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 @@ - -