From 5d57420b44e55ca88c5c73ea2731c0cae00b42b3 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Tue, 4 Aug 2026 12:02:36 -0500 Subject: [PATCH 1/3] feat(ADFA-1816): list plugin settings screens in IDE Preferences Adds the SettingsExtension extension point, collects entries in PluginManager, and appends one row per entry to Configuration, rechecked on resume. --- .../activities/PluginScreenActivity.kt | 223 +- .../activities/PluginScreenNavigator.kt | 36 + .../activities/PreferencesActivity.kt | 232 +- .../androidide/preferences/pluginPrefExts.kt | 117 +- .../androidide/preferences/rootPrefExts.kt | 3 +- .../res/layout/layout_settings_gemini_api.xml | 65 - .../PluginSettingsEntryPreferenceTest.kt | 92 + docs/plugin-api.md | 4 +- plugin-api/api/plugin-api.api | 30 + .../plugins/extensions/SettingsExtension.kt | 46 + .../plugins/manager/core/PluginManager.kt | 3344 +++++++++-------- .../manager/core/PluginSettingsEntries.kt | 33 + .../manager/core/PluginSettingsEntriesTest.kt | 124 + .../preferences/IPreferenceGroup.kt | 49 +- resources/src/main/res/values/strings.xml | 1 + 15 files changed, 2485 insertions(+), 1914 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/activities/PluginScreenNavigator.kt delete mode 100644 app/src/main/res/layout/layout_settings_gemini_api.xml create mode 100644 app/src/test/java/com/itsaky/androidide/preferences/PluginSettingsEntryPreferenceTest.kt create mode 100644 plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/SettingsExtension.kt create mode 100644 plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginSettingsEntries.kt create mode 100644 plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/core/PluginSettingsEntriesTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/activities/PluginScreenActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/PluginScreenActivity.kt index 3e4daec7f1..d724bb153a 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/PluginScreenActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/PluginScreenActivity.kt @@ -1,5 +1,7 @@ package com.itsaky.androidide.activities +import android.content.Context +import android.content.Intent import android.os.Bundle import android.util.Log import android.view.View @@ -11,103 +13,126 @@ import com.itsaky.androidide.plugins.manager.fragment.PluginFragmentFactory import com.itsaky.androidide.plugins.services.IdeUIService class PluginScreenActivity : IDEActivity() { - - private var pluginId: String? = null - private var fragmentClassName: String? = null - - override fun onCreate(savedInstanceState: Bundle?) { - pluginId = intent.getStringExtra(IdeUIService.EXTRA_PLUGIN_ID) - fragmentClassName = intent.getStringExtra(IdeUIService.EXTRA_FRAGMENT_CLASS_NAME) - - val pluginId = pluginId - val fragmentClassName = fragmentClassName - - if (pluginId.isNullOrBlank() || fragmentClassName.isNullOrBlank()) { - super.onCreate(savedInstanceState) - finish() - return - } - - val classLoader = IDEApplication.getPluginManager() - ?.getClassLoaderForPluginId(pluginId) - - if (classLoader == null) { - super.onCreate(savedInstanceState) - finish() - return - } - - registerPluginFragmentFactory( - pluginId = pluginId, - classLoader = classLoader, - fragmentClassName = fragmentClassName - ) - - super.onCreate(savedInstanceState) - - if (savedInstanceState == null) { - loadPluginFragment(classLoader, fragmentClassName) - } - } - - override fun bindLayout(): View { - return FrameLayout(this).apply { - id = R.id.plugin_screen_container - layoutParams = FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.MATCH_PARENT - ) - } - } - - private fun registerPluginFragmentFactory( - pluginId: String, - classLoader: ClassLoader, - fragmentClassName: String - ) { - supportFragmentManager.fragmentFactory = - PluginFragmentFactory(supportFragmentManager.fragmentFactory) - - PluginFragmentFactory.registerPluginClassLoader( - pluginId, - classLoader, - listOf(fragmentClassName) - ) - } - - private fun loadPluginFragment( - classLoader: ClassLoader, - fragmentClassName: String - ) { - val fragment = runCatching { - supportFragmentManager.fragmentFactory.instantiate( - classLoader, - fragmentClassName - ) - }.getOrElse { error -> - Log.e(TAG, "Failed to instantiate plugin fragment: $fragmentClassName", error) - finish() - return - } - - supportFragmentManager.beginTransaction() - .replace(R.id.plugin_screen_container, fragment, TAG_PLUGIN_SCREEN) - .commit() - } - - override fun onDestroy() { - val pluginId = pluginId - val fragmentClassName = fragmentClassName - - if (!pluginId.isNullOrBlank() && !fragmentClassName.isNullOrBlank()) { - PluginFragmentFactory.unregisterPluginClassLoader(pluginId, listOf(fragmentClassName)) - } - - super.onDestroy() - } - - companion object { - private const val TAG = "PluginScreenActivity" - private const val TAG_PLUGIN_SCREEN = "plugin_screen" - } + private var pluginId: String? = null + private var fragmentClassName: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + pluginId = intent.getStringExtra(IdeUIService.EXTRA_PLUGIN_ID) + fragmentClassName = intent.getStringExtra(IdeUIService.EXTRA_FRAGMENT_CLASS_NAME) + + val pluginId = pluginId + val fragmentClassName = fragmentClassName + + if (pluginId.isNullOrBlank() || fragmentClassName.isNullOrBlank()) { + super.onCreate(savedInstanceState) + finish() + return + } + + val classLoader = + IDEApplication + .getPluginManager() + ?.getClassLoaderForPluginId(pluginId) + + if (classLoader == null) { + super.onCreate(savedInstanceState) + finish() + return + } + + registerPluginFragmentFactory( + pluginId = pluginId, + classLoader = classLoader, + fragmentClassName = fragmentClassName, + ) + + super.onCreate(savedInstanceState) + + if (savedInstanceState == null) { + loadPluginFragment(classLoader, fragmentClassName) + } + } + + override fun bindLayout(): View = + FrameLayout(this).apply { + id = R.id.plugin_screen_container + layoutParams = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ) + } + + private fun registerPluginFragmentFactory( + pluginId: String, + classLoader: ClassLoader, + fragmentClassName: String, + ) { + supportFragmentManager.fragmentFactory = + PluginFragmentFactory(supportFragmentManager.fragmentFactory) + + PluginFragmentFactory.registerPluginClassLoader( + pluginId, + classLoader, + listOf(fragmentClassName), + ) + } + + private fun loadPluginFragment( + classLoader: ClassLoader, + fragmentClassName: String, + ) { + val fragment = + runCatching { + supportFragmentManager.fragmentFactory.instantiate( + classLoader, + fragmentClassName, + ) + }.getOrElse { error -> + Log.e(TAG, "Failed to instantiate plugin fragment: $fragmentClassName", error) + finish() + return + } + + supportFragmentManager + .beginTransaction() + .replace(R.id.plugin_screen_container, fragment, TAG_PLUGIN_SCREEN) + .commit() + } + + override fun onDestroy() { + val pluginId = pluginId + val fragmentClassName = fragmentClassName + + if (!pluginId.isNullOrBlank() && !fragmentClassName.isNullOrBlank()) { + PluginFragmentFactory.unregisterPluginClassLoader(pluginId, listOf(fragmentClassName)) + } + + super.onDestroy() + } + + companion object { + private const val TAG = "PluginScreenActivity" + private const val TAG_PLUGIN_SCREEN = "plugin_screen" + + /** + * The intent that mounts [fragmentClassName] of [pluginId] on this screen - the same one + * `IdeUIServiceImpl.openPluginScreen` builds, so both entry points stay in step. Action-based + * rather than explicit-component to match the manifest intent-filter; [setPackage] keeps it + * inside the app. + */ + fun newIntent( + context: Context, + pluginId: String, + fragmentClassName: String, + title: String? = null, + ): Intent = + Intent(IdeUIService.ACTION_OPEN_PLUGIN_SCREEN).apply { + setPackage(context.packageName) + putExtra(IdeUIService.EXTRA_PLUGIN_ID, pluginId) + putExtra(IdeUIService.EXTRA_FRAGMENT_CLASS_NAME, fragmentClassName) + putExtra(IdeUIService.EXTRA_TITLE, title) + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + } } diff --git a/app/src/main/java/com/itsaky/androidide/activities/PluginScreenNavigator.kt b/app/src/main/java/com/itsaky/androidide/activities/PluginScreenNavigator.kt new file mode 100644 index 0000000000..0e7dc57f85 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/activities/PluginScreenNavigator.kt @@ -0,0 +1,36 @@ +package com.itsaky.androidide.activities + +import android.content.Context +import org.slf4j.LoggerFactory + +/** + * Launches a plugin-owned screen in [PluginScreenActivity]. + * + * Kept out of the callers so that "how a plugin screen is opened" lives in one place: today the + * contributed preference rows, later any other surface that wants to mount plugin UI. Callers decide + * what the user sees when it fails - the message belongs to the surface, not to the launch. + */ +object PluginScreenNavigator { + private val log = LoggerFactory.getLogger("PluginScreenNavigator") + + /** + * Opens [fragmentClassName] of the plugin [pluginId], full-screen. + * + * @return true when the screen was launched; false (already logged) when it could not be, so the + * caller can tell the user. + */ + fun openPluginScreen( + context: Context, + pluginId: String, + fragmentClassName: String, + title: String? = null, + ): Boolean = + runCatching { + context.startActivity( + PluginScreenActivity.newIntent(context, pluginId, fragmentClassName, title), + ) + true + }.onFailure { error -> + log.error("Failed to open screen $fragmentClassName of plugin $pluginId", error) + }.getOrDefault(false) +} diff --git a/app/src/main/java/com/itsaky/androidide/activities/PreferencesActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/PreferencesActivity.kt index c5d1fd31ed..0a5993cefb 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/PreferencesActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/PreferencesActivity.kt @@ -29,106 +29,142 @@ import com.itsaky.androidide.app.EdgeToEdgeIDEActivity import com.itsaky.androidide.databinding.ActivityPreferencesBinding import com.itsaky.androidide.fragments.IDEPreferencesFragment import com.itsaky.androidide.idetooltips.TooltipManager +import com.itsaky.androidide.preferences.PluginSettingsEntryPreference import com.itsaky.androidide.preferences.addRootPreferences +import com.itsaky.androidide.preferences.pluginSettingsPreferences import com.itsaky.androidide.preferences.IDEPreferences as prefs class PreferencesActivity : EdgeToEdgeIDEActivity() { - - private var _binding: ActivityPreferencesBinding? = null - private val binding: ActivityPreferencesBinding - get() = checkNotNull(_binding) { "Activity has been destroyed" } - private var feedbackButtonManager: FeedbackButtonManager? = null - - private val rootFragment by lazy { - IDEPreferencesFragment() - } - - private val gestureDetector by lazy { - GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() { - override fun onLongPress(e: MotionEvent) { - binding.root.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) - val currentFragment = supportFragmentManager.findFragmentById(binding.fragmentContainer.id) as? IDEPreferencesFragment - val tooltipTag = currentFragment?.getCurrentScreenTooltip() ?: "" - TooltipManager.showIdeCategoryTooltip(this@PreferencesActivity, binding.root, tooltipTag) - } - }) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setSupportActionBar(binding.toolbar) - supportActionBar!!.setTitle(R.string.ide_preferences) - supportActionBar!!.setDisplayHomeAsUpEnabled(true) - - binding.toolbar.setNavigationOnClickListener { onBackPressedDispatcher.onBackPressed() } - - feedbackButtonManager = FeedbackButtonManager( - activity = this, - feedbackFab = binding.fabFeedback.root, - ) - feedbackButtonManager?.setupDraggableFab() - - if (savedInstanceState != null) { - return - } - - (prefs.children as MutableList?)?.clear() - - prefs.addRootPreferences() - - val args = Bundle() - args.putParcelableArrayList( - IDEPreferencesFragment.EXTRA_CHILDREN, - ArrayList(prefs.children) - ) - - rootFragment.arguments = args - loadFragment(rootFragment) - } - - override fun onApplySystemBarInsets(insets: Insets) { - val binding = _binding ?: return - val toolbar: View = binding.toolbar - toolbar.setPadding( - toolbar.paddingLeft + insets.left, - toolbar.paddingTop, - toolbar.paddingRight + insets.right, - toolbar.paddingBottom - ) - - val fragmentContainer: View = binding.fragmentContainerParent - fragmentContainer.setPadding( - fragmentContainer.paddingLeft + insets.left, - fragmentContainer.paddingTop, - fragmentContainer.paddingRight + insets.right, - fragmentContainer.paddingBottom - ) - } - - override fun bindLayout(): View { - _binding = ActivityPreferencesBinding.inflate( - layoutInflater - ) - return binding.root - } - - private fun loadFragment(fragment: Fragment) { - super.loadFragment(fragment, binding.fragmentContainer.id) - } - - override fun onDestroy() { - super.onDestroy() - _binding = null - } - - override fun dispatchTouchEvent(ev: MotionEvent): Boolean { - gestureDetector.onTouchEvent(ev) - return super.dispatchTouchEvent(ev) - } - - override fun onResume() { - super.onResume() - feedbackButtonManager?.loadFabPosition() - } + @Suppress("ktlint:standard:backing-property-naming") + private var _binding: ActivityPreferencesBinding? = null + private val binding: ActivityPreferencesBinding + get() = checkNotNull(_binding) { "Activity has been destroyed" } + private var feedbackButtonManager: FeedbackButtonManager? = null + + /** + * The plugin-contributed rows present in the tree currently on screen. Plugins load + * asynchronously at startup, so a tree built in [onCreate] can miss rows that exist moments + * later; re-reading them on resume also picks up an install, uninstall, enable or disable done + * in the Plugin Manager, with no IDE restart. + */ + private var contributedPreferences: List? = null + + private val gestureDetector by lazy { + GestureDetector( + this, + object : GestureDetector.SimpleOnGestureListener() { + override fun onLongPress(e: MotionEvent) { + binding.root.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + val currentFragment = supportFragmentManager.findFragmentById(binding.fragmentContainer.id) as? IDEPreferencesFragment + val tooltipTag = currentFragment?.getCurrentScreenTooltip() ?: "" + TooltipManager.showIdeCategoryTooltip(this@PreferencesActivity, binding.root, tooltipTag) + } + }, + ) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setSupportActionBar(binding.toolbar) + supportActionBar!!.setTitle(R.string.ide_preferences) + supportActionBar!!.setDisplayHomeAsUpEnabled(true) + + binding.toolbar.setNavigationOnClickListener { onBackPressedDispatcher.onBackPressed() } + + feedbackButtonManager = + FeedbackButtonManager( + activity = this, + feedbackFab = binding.fabFeedback.root, + ) + feedbackButtonManager?.setupDraggableFab() + + if (savedInstanceState != null) { + // The restored fragment already carries its children; only record what it was built from. + contributedPreferences = pluginSettingsPreferences() + return + } + + loadRootFragment() + } + + private fun loadRootFragment() { + // Snapshot before building the tree: a plugin that loads in between then reads as a change on + // the next resume instead of being silently missed. + contributedPreferences = pluginSettingsPreferences() + + prefs.clearPreferences() + + prefs.addRootPreferences() + + val args = Bundle() + args.putParcelableArrayList( + IDEPreferencesFragment.EXTRA_CHILDREN, + ArrayList(prefs.children), + ) + + // A fresh instance every time: arguments cannot be set on a fragment whose state was saved. + loadFragment(IDEPreferencesFragment().also { it.arguments = args }) + } + + private fun reloadRootFragmentIfContributedRowsChanged() { + // Only while the root screen is showing - rebuilding it under a sub-screen would pop the user + // out of the screen they are on. + if (supportFragmentManager.backStackEntryCount > 0) { + return + } + + if (pluginSettingsPreferences() == contributedPreferences) { + return + } + + loadRootFragment() + } + + override fun onApplySystemBarInsets(insets: Insets) { + val binding = _binding ?: return + val toolbar: View = binding.toolbar + toolbar.setPadding( + toolbar.paddingLeft + insets.left, + toolbar.paddingTop, + toolbar.paddingRight + insets.right, + toolbar.paddingBottom, + ) + + val fragmentContainer: View = binding.fragmentContainerParent + fragmentContainer.setPadding( + fragmentContainer.paddingLeft + insets.left, + fragmentContainer.paddingTop, + fragmentContainer.paddingRight + insets.right, + fragmentContainer.paddingBottom, + ) + } + + override fun bindLayout(): View { + _binding = + ActivityPreferencesBinding.inflate( + layoutInflater, + ) + return binding.root + } + + private fun loadFragment(fragment: Fragment) { + super.loadFragment(fragment, binding.fragmentContainer.id) + } + + override fun onDestroy() { + super.onDestroy() + _binding = null + } + + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + gestureDetector.onTouchEvent(ev) + return super.dispatchTouchEvent(ev) + } + + override fun onResume() { + super.onResume() + feedbackButtonManager?.loadFabPosition() + reloadRootFragmentIfContributedRowsChanged() + } } diff --git a/app/src/main/java/com/itsaky/androidide/preferences/pluginPrefExts.kt b/app/src/main/java/com/itsaky/androidide/preferences/pluginPrefExts.kt index 57d77a8db5..ae8098d04a 100644 --- a/app/src/main/java/com/itsaky/androidide/preferences/pluginPrefExts.kt +++ b/app/src/main/java/com/itsaky/androidide/preferences/pluginPrefExts.kt @@ -5,28 +5,115 @@ import android.content.Context import android.content.Intent import androidx.preference.Preference import com.itsaky.androidide.activities.PluginManagerActivity +import com.itsaky.androidide.activities.PluginScreenNavigator +import com.itsaky.androidide.app.IDEApplication +import com.itsaky.androidide.plugins.manager.core.PluginManager import com.itsaky.androidide.resources.R.drawable import com.itsaky.androidide.resources.R.string +import com.itsaky.androidide.utils.flashError import kotlinx.parcelize.Parcelize @Parcelize class PluginManagerEntry( - override val key: String = "idepref_plugin_manager", - override val title: Int = string.plugin_manager_title, - override val summary: Int? = string.plugin_manager_summary, + override val key: String = "idepref_plugin_manager", + override val title: Int = string.plugin_manager_title, + override val summary: Int? = string.plugin_manager_summary, ) : BasePreference() { + override fun onCreatePreference(context: Context): Preference { + return Preference(context) + } - override fun onCreatePreference(context: Context): Preference { - return Preference(context) - } + override fun onPreferenceClick(preference: Preference): Boolean { + val context = preference.context - override fun onPreferenceClick(preference: Preference): Boolean { - val context = preference.context + val intent = Intent(context, PluginManagerActivity::class.java) + // Add flags to prevent multiple instances + intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + context.startActivity(intent) + return true + } +} - val intent = Intent(context, PluginManagerActivity::class.java) - // Add flags to prevent multiple instances - intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - context.startActivity(intent) - return true - } -} \ No newline at end of file +/** + * A Configuration row contributed by a plugin through + * [com.itsaky.androidide.plugins.extensions.SettingsExtension]. Tapping it asks + * [PluginScreenNavigator] to mount the plugin's own fragment full-screen; the row itself only holds + * the data, builds the widget, and reports a failed launch. + * + * Deliberately not a [BasePreference]: [IPreference.title] is an `@StringRes` resolved against the + * IDE's resources, while a plugin's title is a runtime string, so this row builds its own + * [Preference]. + * + * Carries only strings - no click lambda - so it can rebuild the launch after process death, + * unlike [SimpleClickablePreference], whose callback is dropped from the parcel. + * + * A data class on purpose: `PreferencesActivity` compares the contributed rows across resumes to + * decide whether the preference tree needs rebuilding. + */ +@Parcelize +data class PluginSettingsEntryPreference( + val pluginId: String, + val entryId: String, + val titleText: String, + val summaryText: String?, + val fragmentClassName: String, +) : IPreference() { + override val key: String + get() = "idepref_plugin_settings_$pluginId.$entryId" + + // Unused: the row sets a literal title in onCreateView rather than resolving an IDE resource. + override val title: Int + get() = 0 + + override fun onCreateView(context: Context): Preference { + val preference = Preference(context) + preference.key = key + preference.title = titleText + preference.summary = summaryText + // No icon in v1: leave the slot unreserved so the row aligns with PluginManagerEntry. + preference.isIconSpaceReserved = false + preference.setOnPreferenceClickListener { onClick(it.context) } + return preference + } + + private fun onClick(context: Context): Boolean { + val opened = + PluginScreenNavigator.openPluginScreen( + context = context, + pluginId = pluginId, + fragmentClassName = fragmentClassName, + title = titleText, + ) + + // Without this the row looks dead: the tap would do nothing the user can see. + if (!opened) { + flashError(string.msg_open_plugin_settings_failed) + } + + // The click was ours either way, so don't let androidx-preference fall through to its own + // fragment/intent handling. + return true + } +} + +/** + * The preference rows contributed by enabled plugins. Empty when no plugin contributes any, and + * also while the asynchronous plugin load is still in flight - `PreferencesActivity` re-checks on + * resume and rebuilds the tree if the set changed. + * + * [pluginManager] defaults to the running IDE's instance; tests pass their own. + */ +internal fun pluginSettingsPreferences( + pluginManager: PluginManager? = IDEApplication.getPluginManager(), +): List = + pluginManager + ?.getPluginSettingsEntries() + ?.map { (pluginId, entry) -> + PluginSettingsEntryPreference( + pluginId = pluginId, + entryId = entry.id, + titleText = entry.title, + summaryText = entry.summary, + fragmentClassName = entry.fragmentClassName, + ) + }.orEmpty() diff --git a/app/src/main/java/com/itsaky/androidide/preferences/rootPrefExts.kt b/app/src/main/java/com/itsaky/androidide/preferences/rootPrefExts.kt index 5ce47c50e8..2d8ede5c16 100644 --- a/app/src/main/java/com/itsaky/androidide/preferences/rootPrefExts.kt +++ b/app/src/main/java/com/itsaky/androidide/preferences/rootPrefExts.kt @@ -39,7 +39,8 @@ class ConfigurationPreferences( addPreference(BuildAndRunPreferences()) addPreference(TermuxPreferences()) addPreference(GitPreferencesScreen()) - addPreference(PluginManagerEntry()) + addPreference(PluginManagerEntry()) + pluginSettingsPreferences().forEach { addPreference(it) } addPreference(about) } diff --git a/app/src/main/res/layout/layout_settings_gemini_api.xml b/app/src/main/res/layout/layout_settings_gemini_api.xml deleted file mode 100644 index 731fb3b3a0..0000000000 --- a/app/src/main/res/layout/layout_settings_gemini_api.xml +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - -