diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4be5ac177e..92dc9dd56e 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 90545a5698..b001aee8fb 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 = @@ -93,6 +94,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() } } } @@ -180,6 +184,10 @@ android { targetCompatibility = JavaVersion.VERSION_17 isCoreLibraryDesugaringEnabled = true } + + buildFeatures { + compose = true + } } // Sentry gradle plugin config (crash reporting to GlitchTip). @@ -263,6 +271,17 @@ dependencies { implementation(libs.google.flexbox) implementation(libs.libsu.core) + // Compose (plugin/template manager screen; ADR 0009) + 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.ui.tooling.preview) + implementation(libs.androidx.lifecycle.runtime.compose) + debugImplementation(libs.compose.ui.tooling) + // Kotlin implementation(libs.androidx.core.ktx) implementation(libs.common.kotlin) @@ -325,6 +344,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/PluginManagerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt index a3129fbffb..b3b635ffb8 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt @@ -2,82 +2,30 @@ package com.itsaky.androidide.activities -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Intent -import android.net.Uri import android.os.Bundle -import android.util.Log -import android.view.Menu -import android.view.MenuItem import android.view.View -import android.widget.CheckBox -import androidx.activity.result.contract.ActivityResultContracts import androidx.core.graphics.Insets -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.itsaky.androidide.FeedbackButtonManager import com.itsaky.androidide.R -import com.itsaky.androidide.adapters.PluginListAdapter import com.itsaky.androidide.app.EdgeToEdgeIDEActivity import com.itsaky.androidide.databinding.ActivityPluginManagerBinding -import com.itsaky.androidide.idetooltips.TooltipManager -import com.itsaky.androidide.idetooltips.TooltipTag -import com.itsaky.androidide.plugins.PluginInfo -import com.itsaky.androidide.ui.models.PluginManagerUiEffect -import com.itsaky.androidide.ui.models.PluginManagerUiEvent -import com.itsaky.androidide.utils.DURATION_INDEFINITE -import com.itsaky.androidide.utils.DialogUtils.showRestartPrompt -import com.itsaky.androidide.utils.UrlManager -import com.itsaky.androidide.utils.errorIcon +import com.itsaky.androidide.ui.compose.ManagerScreen +import com.itsaky.androidide.ui.compose.theme.ManagerTheme import com.itsaky.androidide.utils.flashError -import com.itsaky.androidide.utils.flashSuccess -import com.itsaky.androidide.utils.flashbarBuilder -import com.itsaky.androidide.utils.getFileName -import com.itsaky.androidide.utils.showOnUiThread import com.itsaky.androidide.viewmodels.PluginManagerViewModel -import kotlinx.coroutines.launch +import com.itsaky.androidide.viewmodels.TemplateManagerViewModel import org.koin.androidx.viewmodel.ext.android.viewModel class PluginManagerActivity : EdgeToEdgeIDEActivity() { - companion object { - private const val TAG = "PluginManagerActivity" - private const val PLUGIN_EXTENSION = ".cgp" - } - @Suppress("ktlint:standard:backing-property-naming") private var _binding: ActivityPluginManagerBinding? = null 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 - } - - showInstallConfirmation(it) - } - } + private val pluginViewModel: PluginManagerViewModel by viewModel() + private val templateViewModel: TemplateManagerViewModel by viewModel() override fun bindLayout(): View { _binding = ActivityPluginManagerBinding.inflate(layoutInflater) @@ -88,21 +36,17 @@ 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() + binding.composeView.setContent { + ManagerTheme { + ManagerScreen( + activity = this, + pluginViewModel = pluginViewModel, + templateViewModel = templateViewModel, + ) + } } - setupRecyclerView() - setupFab() - setupTooltipLongPress() setupFeedbackButton() - observeViewModel() } catch (e: Exception) { // Log the error and finish the activity if something goes wrong e.printStackTrace() @@ -116,29 +60,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 @@ -153,51 +74,6 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { ) } - private fun setupRecyclerView() { - adapter = - PluginListAdapter { plugin, action -> - when (action) { - PluginListAdapter.Action.ENABLE -> viewModel.onEvent(PluginManagerUiEvent.EnablePlugin(plugin.metadata.id)) - PluginListAdapter.Action.DISABLE -> viewModel.onEvent(PluginManagerUiEvent.DisablePlugin(plugin.metadata.id)) - PluginListAdapter.Action.UNINSTALL -> viewModel.onEvent(PluginManagerUiEvent.UninstallPlugin(plugin.metadata.id)) - PluginListAdapter.Action.DETAILS -> viewModel.onEvent(PluginManagerUiEvent.ShowPluginDetails(plugin)) - } - } - - binding.recyclerView.apply { - layoutManager = LinearLayoutManager(this@PluginManagerActivity) - adapter = this@PluginManagerActivity.adapter - } - } - - private fun setupFab() { - binding.fabInstallPlugin.setOnClickListener { - viewModel.onEvent(PluginManagerUiEvent.OpenFilePicker) - } - } - - private fun setupTooltipLongPress() { - val showTooltip: (View) -> Unit = { view -> - TooltipManager.showIdeCategoryTooltip(this, view, TooltipTag.PLUGIN_MANAGER) - } - binding.toolbar.setOnLongClickListener { - showTooltip(it) - true - } - binding.fabInstallPlugin.setOnLongClickListener { - showTooltip(it) - true - } - binding.emptyState.setOnLongClickListener { - showTooltip(it) - true - } - binding.recyclerView.setOnLongClickListener { - showTooltip(it) - true - } - } - private fun setupFeedbackButton() { feedbackButtonManager = FeedbackButtonManager( @@ -206,157 +82,4 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { ) feedbackButtonManager?.setupDraggableFab() } - - private fun observeViewModel() { - // Observe UI state - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState.collect { state -> - updateUI(state) - } - } - } - - // Observe UI effects - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiEffect.collect { effect -> - handleUiEffect(effect) - } - } - } - } - - private fun updateUI(state: com.itsaky.androidide.ui.models.PluginManagerUiState) { - // Update plugin list - adapter.submitList(state.plugins) - - // Update empty state - if (state.showEmptyState) { - binding.recyclerView.visibility = View.GONE - binding.emptyState.visibility = View.VISIBLE - } else { - binding.recyclerView.visibility = View.VISIBLE - binding.emptyState.visibility = View.GONE - } - - // Update install button state - binding.fabInstallPlugin.isEnabled = !state.isInstalling - } - - private fun handleUiEffect(effect: PluginManagerUiEffect) { - when (effect) { - is PluginManagerUiEffect.ShowError -> { - val errorMessage = getString(effect.messageResId, *effect.formatArgs.toTypedArray()) - val builder = - flashbarBuilder(duration = if (effect.formatArgs.isEmpty()) 5000L else DURATION_INDEFINITE) - .errorIcon() - .message(errorMessage) - if (effect.formatArgs.isNotEmpty()) { - builder - .positiveActionText(R.string.copy) - .positiveActionTapListener { bar -> - (getSystemService(ClipboardManager::class.java)) - ?.setPrimaryClip(ClipData.newPlainText(getString(R.string.msg_plugin_error_clip_label), errorMessage)) - bar.dismiss() - } - } - builder.showOnUiThread() - } - - is PluginManagerUiEffect.ShowSuccess -> { - flashSuccess(getString(effect.messageResId)) - } - - is PluginManagerUiEffect.ShowPluginDetails -> { - showPluginDetails(effect.plugin) - } - - is PluginManagerUiEffect.OpenFilePicker -> { - openFilePicker() - } - - is PluginManagerUiEffect.ShowUninstallConfirmation -> { - showUninstallConfirmation(effect.plugin) - } - - is PluginManagerUiEffect.ShowRestartPrompt -> { - showRestartPrompt(this) - } - - is PluginManagerUiEffect.ShowOverwriteConfirmation -> { - showOverwriteConfirmation(effect) - } - } - } - - private fun openFilePicker() { - try { - pluginPickerLauncher.launch(arrayOf("*/*")) - } catch (_: Exception) { - flashError(getString(R.string.msg_no_file_manager)) - } - } - - private fun Uri.isSupportedPluginFile(): Boolean = getFileName(this@PluginManagerActivity).endsWith(PLUGIN_EXTENSION, ignoreCase = true) - - private fun showInstallConfirmation(uri: Uri) { - val dialogView = layoutInflater.inflate(R.layout.dialog_install_plugin, null) - val deleteCheckBox = dialogView.findViewById(R.id.checkbox_delete_source) - - MaterialAlertDialogBuilder(this) - .setTitle(R.string.title_install_plugin) - .setView(dialogView) - .setPositiveButton(R.string.btn_install) { _, _ -> - viewModel.onEvent(PluginManagerUiEvent.InstallPlugin(uri, deleteCheckBox.isChecked)) - }.setNegativeButton(android.R.string.cancel, null) - .show() - } - - private fun showOverwriteConfirmation(effect: PluginManagerUiEffect.ShowOverwriteConfirmation) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.title_plugin_already_installed) - .setMessage( - getString( - R.string.msg_plugin_overwrite_confirm, - effect.existing.metadata.name, - effect.existing.metadata.version, - effect.incomingMetadata.version, - ), - ).setPositiveButton(R.string.replace) { _, _ -> - viewModel.onEvent( - PluginManagerUiEvent.ConfirmOverwrite(effect.uri, effect.deleteSourceAfterInstall), - ) - }.setNegativeButton(android.R.string.cancel, null) - .show() - } - - private fun showUninstallConfirmation(plugin: PluginInfo) { - MaterialAlertDialogBuilder(this) - .setTitle("Uninstall Plugin") - .setMessage("Are you sure you want to uninstall '${plugin.metadata.name}'?") - .setPositiveButton("Uninstall") { _, _ -> - viewModel.confirmUninstallPlugin(plugin.metadata.id) - }.setNegativeButton("Cancel", null) - .show() - } - - private fun showPluginDetails(plugin: PluginInfo) { - val details = - buildString { - append("Name: ${plugin.metadata.name}\n") - append("Plugin ID: ${plugin.metadata.id}\n") - append("Version: ${plugin.metadata.version}\n") - append("Author: ${plugin.metadata.author}\n") - append("Description: ${plugin.metadata.description}\n") - append("Min IDE Version: ${plugin.metadata.minIdeVersion}\n") - append("Permissions: ${plugin.metadata.permissions.joinToString(", ")}\n") - } - - MaterialAlertDialogBuilder(this) - .setTitle(plugin.metadata.name) - .setMessage(details) - .setPositiveButton("OK", null) - .show() - } } diff --git a/app/src/main/java/com/itsaky/androidide/adapters/PluginListAdapter.kt b/app/src/main/java/com/itsaky/androidide/adapters/PluginListAdapter.kt deleted file mode 100644 index e9d7b2c652..0000000000 --- a/app/src/main/java/com/itsaky/androidide/adapters/PluginListAdapter.kt +++ /dev/null @@ -1,154 +0,0 @@ - -package com.itsaky.androidide.adapters - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.PopupMenu -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.Glide -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 { - ENABLE, - DISABLE, - UNINSTALL, - 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 = "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) - .placeholder(R.drawable.ic_extension) - .error(R.drawable.ic_extension) - .into(pluginIcon) - } else { - Glide.with(pluginIcon).clear(pluginIcon) - pluginIcon.setImageResource(R.drawable.ic_extension) - } - - // Set status - val statusText = when { - !plugin.isLoaded -> "Not Loaded" - !plugin.isEnabled -> "Disabled" - else -> "Enabled" - } - pluginStatus.text = statusText - - // Set status color - 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) - - // Add menu items based on plugin state - if (plugin.isLoaded) { - if (plugin.isEnabled) { - popup.menu.add(0, 1, 0, "Disable") - } else { - popup.menu.add(0, 2, 0, "Enable") - } - popup.menu.add(0, 3, 0, "Uninstall") - } - popup.menu.add(0, 4, 0, "Details") - - popup.setOnMenuItemClickListener { menuItem -> - when (menuItem.itemId) { - 1 -> onActionClick(plugin, Action.DISABLE) - 2 -> onActionClick(plugin, Action.ENABLE) - 3 -> onActionClick(plugin, Action.UNINSTALL) - 4 -> onActionClick(plugin, Action.DETAILS) - } - true - } - - popup.show() - } - } -} - -class PluginDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: PluginInfo, newItem: PluginInfo): Boolean { - return oldItem.metadata.id == newItem.metadata.id - } - - override fun areContentsTheSame(oldItem: PluginInfo, newItem: PluginInfo): Boolean { - return oldItem == newItem - } -} \ No newline at end of file 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..d1c67fc8fb 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,10 @@ 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. + withContext(Dispatchers.IO) { IDEApplication.cachedFilesDir } + 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..be014622f3 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,18 @@ 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) { + runBlocking(Dispatchers.IO) { cachedFilesDir } + } + ensureKoinStarted() coroutineScope.launch(Dispatchers.Default) { @@ -208,7 +234,7 @@ class IDEApplication : runCatching { GlobalContext.get() }.getOrNull()?.let { return } startKoin { androidContext(this@IDEApplication) - modules(coreModule, pluginModule) + modules(coreModule, pluginModule, templateModule) } } diff --git a/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt b/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt index 0152cc285b..cc7fc479eb 100644 --- a/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt +++ b/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt @@ -12,22 +12,23 @@ import java.io.File /** * Koin module for plugin-related dependencies */ -val pluginModule = module { +val pluginModule = + module { - // Repository - single { - PluginRepositoryImpl( - pluginManagerProvider = { IDEApplication.getPluginManager() }, - pluginsDir = File(androidContext().filesDir, "plugins") - ) - } + // Repository + single { + PluginRepositoryImpl( + pluginManagerProvider = { IDEApplication.getPluginManager() }, + pluginsDir = File(IDEApplication.cachedFilesDir, "plugins"), + ) + } - // ViewModel - viewModel { - PluginManagerViewModel( - pluginRepository = get(), - contentResolver = androidContext().contentResolver, - filesDir = androidContext().filesDir - ) - } -} \ No newline at end of file + // ViewModel + viewModel { + PluginManagerViewModel( + pluginRepository = get(), + contentResolver = androidContext().contentResolver, + filesDir = IDEApplication.cachedFilesDir, + ) + } + } diff --git a/app/src/main/java/com/itsaky/androidide/di/TemplateModule.kt b/app/src/main/java/com/itsaky/androidide/di/TemplateModule.kt new file mode 100644 index 0000000000..efddd25b84 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/di/TemplateModule.kt @@ -0,0 +1,30 @@ +package com.itsaky.androidide.di + +import com.itsaky.androidide.repositories.TemplateRepository +import com.itsaky.androidide.repositories.TemplateRepositoryImpl +import com.itsaky.androidide.utils.Environment +import com.itsaky.androidide.viewmodels.TemplateManagerViewModel +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module + +/** + * Koin module for template-related dependencies + */ +val templateModule = + module { + + // Repository + single { + TemplateRepositoryImpl( + templatesDir = Environment.TEMPLATES_DIR, + downloadDir = Environment.DOWNLOAD_DIR, + ) + } + + // ViewModel + viewModel { + TemplateManagerViewModel( + templateRepository = get(), + ) + } + } diff --git a/app/src/main/java/com/itsaky/androidide/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..195ac026b7 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/repositories/TemplateRepositoryImpl.kt @@ -0,0 +1,165 @@ +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) + } + } + + private fun scanTemplates(): List { + val installed = cgtFilesIn(templatesDir).map { file -> parseCgtFile(file, installed = true) } + val downloaded = cgtFilesIn(downloadDir).map { file -> parseCgtFile(file, installed = false) } + return (installed + downloaded).filterNotNull() + } + + 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 + } + 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..549312b23d --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/templates/manager/parsing/CgtTemplateReader.kt @@ -0,0 +1,61 @@ +package com.itsaky.androidide.templates.manager.parsing + +import com.itsaky.androidide.templates.manager.models.TemplateMetadata +import org.json.JSONObject +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" + + /** + * 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(zip.readBytes().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 + } + + /** + * 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..46bdf7f4bd --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/ManagerScreen.kt @@ -0,0 +1,168 @@ +package com.itsaky.androidide.ui.compose + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.detectTapGestures +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.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +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.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +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.lifecycle.compose.collectAsStateWithLifecycle +import com.itsaky.androidide.R +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.utils.UrlManager +import com.itsaky.androidide.viewmodels.PluginManagerViewModel +import com.itsaky.androidide.viewmodels.TemplateManagerViewModel +import kotlinx.coroutines.launch + +/** 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 + +/** + * 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 FAB and + * discover-plugins action only apply to the Plugins tab, since the Templates tab is a passive + * scan of the Downloads folder with no equivalent action. + * + * 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, + 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) + } + + Scaffold( + modifier = modifier, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_manager)) }, + 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) { + IconButton( + onClick = { + UrlManager.openUrl(activity.getString(R.string.url_discover_plugins), null, activity) + }, + modifier = + Modifier.pointerInput(Unit) { + detectTapGestures(onLongPress = { showTooltip() }) + }, + ) { + Icon( + painter = painterResource(R.drawable.ic_download), + contentDescription = stringResource(R.string.action_discover_plugins), + ) + } + } + }, + ) + }, + floatingActionButton = { + if (pagerState.currentPage == TAB_PLUGINS) { + FloatingActionButton( + onClick = { + if (!pluginUiState.isInstalling) { + pluginViewModel.onEvent(PluginManagerUiEvent.OpenFilePicker) + } + }, + modifier = + Modifier + .alpha(if (pluginUiState.isInstalling) DISABLED_ALPHA else 1f) + .pointerInput(Unit) { + detectTapGestures(onLongPress = { showTooltip() }) + }, + ) { + 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..25e5bd00e4 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/common/FileImage.kt @@ -0,0 +1,112 @@ +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() } + + val bitmap by produceState(initialValue = null, file, 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 + 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..9048a1f8c6 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginListItem.kt @@ -0,0 +1,155 @@ +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.ui.compose.common.FileImage +import com.itsaky.androidide.utils.isSystemInDarkMode +import java.io.File + +/** Matches the plugin list's version-chip truncation: `vX.Y.Z...` past three dot-segments. */ +internal fun pluginVersionLabel(version: String): String { + val segments = version.split('.') + return if (segments.size > 3) "v${segments.take(3).joinToString(".")}..." else "v$version" +} + +@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 { + Text(pluginVersionLabel(plugin.metadata.version), 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..e8d05c71f4 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerContent.kt @@ -0,0 +1,285 @@ +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 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.remember +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.compose.collectAsStateWithLifecycle +import com.itsaky.androidide.R +import com.itsaky.androidide.idetooltips.TooltipManager +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.plugins.PluginInfo +import com.itsaky.androidide.plugins.PluginMetadata +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 org.slf4j.LoggerFactory + +private val log = LoggerFactory.getLogger("PluginManagerContent") + +private sealed interface PluginManagerDialogState { + data object None : PluginManagerDialogState + + data class InstallConfirm( + val uri: Uri, + ) : PluginManagerDialogState + + data class OverwriteConfirm( + val existing: PluginInfo, + val incomingMetadata: PluginMetadata, + val uri: Uri, + val deleteSourceAfterInstall: Boolean, + ) : PluginManagerDialogState + + data class UninstallConfirm( + val plugin: PluginInfo, + ) : PluginManagerDialogState + + data class Details( + val plugin: PluginInfo, + ) : 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 remember { mutableStateOf(PluginManagerDialogState.None) } + val rootView = LocalView.current + + fun showTooltip() { + TooltipManager.showIdeCategoryTooltip(activity, rootView, TooltipTag.PLUGIN_MANAGER) + } + + val filePickerLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> + uri?.let { + try { + activity.contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (e: SecurityException) { + log.warn("Could not take persistable URI permission", e) + } + + viewModel.onEvent(PluginManagerUiEvent.FileSelected(it)) + } + } + + LaunchedEffect(viewModel) { + 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) + } + + is PluginManagerUiEffect.OpenFilePicker -> { + try { + // SAF filters by MIME type, not extension, and .cgp has no registered MIME + // type. "application/octet-stream" is what document providers report for + // files with an unrecognized extension, so this hides files with a known + // type (images, zips, apks, ...) without excluding .cgp files. The + // FileSelected event still validates the actual pick, since this is an + // approximation. + filePickerLauncher.launch(arrayOf("application/octet-stream")) + } catch (e: ActivityNotFoundException) { + log.warn("No document provider available for the plugin file picker", e) + activity.flashError(activity.getString(R.string.msg_no_file_manager)) + } + } + + is PluginManagerUiEffect.ShowInstallConfirmation -> { + dialogState = PluginManagerDialogState.InstallConfirm(effect.uri) + } + + is PluginManagerUiEffect.ShowUninstallConfirmation -> { + dialogState = PluginManagerDialogState.UninstallConfirm(effect.plugin) + } + + is PluginManagerUiEffect.ShowRestartPrompt -> { + DialogUtils.showRestartPrompt(activity) + } + + is PluginManagerUiEffect.ShowOverwriteConfirmation -> { + dialogState = + PluginManagerDialogState.OverwriteConfirm( + existing = effect.existing, + incomingMetadata = effect.incomingMetadata, + uri = effect.uri, + 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.uri, deleteSource)) + dialogState = PluginManagerDialogState.None + }, + onDismiss = { dialogState = PluginManagerDialogState.None }, + ) + } + + is PluginManagerDialogState.OverwriteConfirm -> { + OverwriteConfirmationDialog( + existing = dialog.existing, + incomingMetadata = dialog.incomingMetadata, + onConfirm = { + viewModel.onEvent(PluginManagerUiEvent.ConfirmOverwrite(dialog.uri, dialog.deleteSourceAfterInstall)) + dialogState = PluginManagerDialogState.None + }, + onDismiss = { dialogState = PluginManagerDialogState.None }, + ) + } + + is PluginManagerDialogState.UninstallConfirm -> { + UninstallConfirmationDialog( + plugin = dialog.plugin, + onConfirm = { + viewModel.confirmUninstallPlugin(dialog.plugin.metadata.id) + dialogState = PluginManagerDialogState.None + }, + onDismiss = { dialogState = PluginManagerDialogState.None }, + ) + } + + is PluginManagerDialogState.Details -> { + PluginDetailsDialog( + plugin = dialog.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..e4af5c4458 --- /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(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..c4c1258404 --- /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("• $tag") } + } +} + +@Composable +private fun DetailRow( + label: String, + value: String, +) { + Text("$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..7a0bea8996 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateManagerScreen.kt @@ -0,0 +1,211 @@ +package com.itsaky.androidide.ui.compose.templates + +import android.content.ClipData +import android.content.ClipboardManager +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.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.remember +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.compose.collectAsStateWithLifecycle +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.templates.manager.models.TemplateMetadata +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 + +private sealed interface TemplateManagerDialogState { + data object None : TemplateManagerDialogState + + data class DeleteConfirm( + val item: CgtFileItem, + ) : TemplateManagerDialogState + + data class FileDetails( + val item: CgtFileItem, + ) : TemplateManagerDialogState + + data class TemplateList( + val item: CgtFileItem, + ) : TemplateManagerDialogState +} + +/** + * 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 remember { mutableStateOf(TemplateManagerDialogState.None) } + var selectedTemplateDetails by remember { mutableStateOf(null) } + val rootView = LocalView.current + + fun showTooltip() { + TooltipManager.showIdeCategoryTooltip(activity, rootView, TooltipTag.TEMPLATE_MANAGER) + } + + LaunchedEffect(viewModel) { + 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)) + } + + is TemplateManagerUiEffect.ShowDeleteConfirmation -> { + dialogState = TemplateManagerDialogState.DeleteConfirm(effect.item) + } + + is TemplateManagerUiEffect.ShowTemplateDetails -> { + dialogState = TemplateManagerDialogState.FileDetails(effect.item) + } + + is TemplateManagerUiEffect.ShowTemplateList -> { + dialogState = TemplateManagerDialogState.TemplateList(effect.item) + } + } + } + } + + 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), + ) + } + } + } + } + + when (val dialog = dialogState) { + is TemplateManagerDialogState.None -> {} + + is TemplateManagerDialogState.DeleteConfirm -> { + DeleteTemplateConfirmationDialog( + item = dialog.item, + onConfirm = { + viewModel.confirmDeleteDownloadFile(dialog.item) + dialogState = TemplateManagerDialogState.None + }, + onDismiss = { dialogState = TemplateManagerDialogState.None }, + ) + } + + is TemplateManagerDialogState.FileDetails -> { + TemplateFileDetailsDialog( + item = dialog.item, + onDismiss = { dialogState = TemplateManagerDialogState.None }, + ) + } + + is TemplateManagerDialogState.TemplateList -> { + TemplateListDialog( + item = dialog.item, + onSelectTemplate = { template -> selectedTemplateDetails = template }, + onDismiss = { dialogState = TemplateManagerDialogState.None }, + ) + } + } + + selectedTemplateDetails?.let { template -> + TemplateDetailsDialog(template = template, onDismiss = { selectedTemplateDetails = 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 151d631f4c..ec6d8f2bce 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 @@ -6,49 +6,104 @@ import com.itsaky.androidide.plugins.PluginInfo import com.itsaky.androidide.plugins.PluginMetadata 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 } 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 uri: Uri, + val deleteSourceAfterInstall: Boolean, + ) : PluginManagerUiEvent() + + data class ConfirmOverwrite( + val uri: Uri, + val deleteSourceAfterInstall: Boolean, + ) : PluginManagerUiEvent() + + object OpenFilePicker : PluginManagerUiEvent() + + data class FileSelected( + val uri: Uri, + ) : PluginManagerUiEvent() + + data class ShowPluginDetails( + val plugin: PluginInfo, + ) : PluginManagerUiEvent() } sealed class PluginManagerUiEffect { - data class ShowError(@StringRes val messageResId: Int, val formatArgs: List = emptyList()) : PluginManagerUiEffect() - data class ShowSuccess(@StringRes val messageResId: Int) : PluginManagerUiEffect() - data class ShowPluginDetails(val plugin: PluginInfo) : PluginManagerUiEffect() - object OpenFilePicker : PluginManagerUiEffect() - data class ShowUninstallConfirmation(val plugin: PluginInfo) : PluginManagerUiEffect() - object ShowRestartPrompt : PluginManagerUiEffect() - data class ShowOverwriteConfirmation( - val existing: PluginInfo, - val incomingMetadata: PluginMetadata, - val uri: Uri, - val deleteSourceAfterInstall: Boolean - ) : PluginManagerUiEffect() + data class ShowError( + @StringRes val messageResId: Int, + val formatArgs: List = emptyList(), + ) : PluginManagerUiEffect() + + data class ShowSuccess( + @StringRes val messageResId: Int, + ) : PluginManagerUiEffect() + + data class ShowPluginDetails( + val plugin: PluginInfo, + ) : PluginManagerUiEffect() + + object OpenFilePicker : PluginManagerUiEffect() + + data class ShowInstallConfirmation( + val uri: Uri, + ) : 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() } sealed class PluginOperation { - object None : PluginOperation() - object Loading : PluginOperation() - object Installing : PluginOperation() - data class Enabling(val pluginId: String) : PluginOperation() - data class Disabling(val pluginId: String) : PluginOperation() - data class Uninstalling(val pluginId: String) : PluginOperation() -} \ No newline at end of file + object None : PluginOperation() + + object Loading : PluginOperation() + + object Installing : PluginOperation() + + data class Enabling( + val pluginId: String, + ) : PluginOperation() + + data class Disabling( + val pluginId: String, + ) : PluginOperation() + + data class Uninstalling( + val pluginId: String, + ) : PluginOperation() +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/models/TemplateManagerUiState.kt b/app/src/main/java/com/itsaky/androidide/ui/models/TemplateManagerUiState.kt new file mode 100644 index 0000000000..a5f1f45dbe --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/models/TemplateManagerUiState.kt @@ -0,0 +1,77 @@ +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, + ) : TemplateManagerUiEffect() + + data class ShowDeleteConfirmation( + val item: CgtFileItem, + ) : TemplateManagerUiEffect() + + data class ShowTemplateDetails( + val item: CgtFileItem, + ) : TemplateManagerUiEffect() + + data class ShowTemplateList( + val item: CgtFileItem, + ) : TemplateManagerUiEffect() +} + +sealed class TemplateOperation { + object None : TemplateOperation() + + object Loading : TemplateOperation() + + data class Installing( + val file: java.io.File, + ) : TemplateOperation() + + data class Uninstalling( + val file: java.io.File, + ) : TemplateOperation() + + data class Deleting( + val file: java.io.File, + ) : TemplateOperation() +} 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..525578292e 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt @@ -15,6 +15,7 @@ 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.UriFileImporter +import com.itsaky.androidide.utils.getFileName import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -31,367 +32,424 @@ 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" + private const val PLUGIN_EXTENSION = ".cgp" + } + + // 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(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.uri, + event.deleteSourceAfterInstall, + ) + } + + is PluginManagerUiEvent.ConfirmOverwrite -> { + installPlugin( + event.uri, + event.deleteSourceAfterInstall, + checkConflict = false, + ) + } + + is PluginManagerUiEvent.OpenFilePicker -> { + openFilePicker() + } + + is PluginManagerUiEvent.FileSelected -> { + handleFileSelected(event.uri) + } + + is PluginManagerUiEvent.ShowPluginDetails -> { + showPluginDetails(event.plugin) + } + } + } + + /** + * Load all plugins + */ + private fun loadPlugins() { + if (!pluginRepository.isPluginManagerAvailable()) { + _uiState.update { it.copy(isPluginManagerAvailable = false) } + return + } + + viewModelScope.launch { + _currentOperation.value = PluginOperation.Loading + _uiState.update { it.copy(isLoading = true) } + + pluginRepository + .getAllPlugins() + .onSuccess { plugins -> + Log.d(TAG, "Loaded ${plugins.size} plugins") + _uiState.update { + it.copy( + isLoading = false, + plugins = plugins, + isPluginManagerAvailable = true, + ) + } + }.onFailure { exception -> + Log.e(TAG, "Failed to load plugins", exception) + _uiState.update { + it.copy(isLoading = false) + } + _uiEffect.send( + PluginManagerUiEffect.ShowError( + R.string.msg_plugin_load_failed, + listOf(exception.message ?: ""), + ), + ) + } + + // Keep the editor decoration providers in sync with the enabled plugin set. + EditorDecorationBridge.refresh() + + _currentOperation.value = PluginOperation.None + } + } + + /** + * Enable a plugin + */ + private fun enablePlugin(pluginId: String) { + viewModelScope.launch { + _currentOperation.value = PluginOperation.Enabling(pluginId) + + pluginRepository + .enablePlugin(pluginId) + .onSuccess { success -> + if (success) { + Log.d(TAG, "Plugin enabled successfully: $pluginId") + _uiEffect.send(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_enabled)) + loadPlugins() + } else { + Log.w(TAG, "Failed to enable plugin: $pluginId") + _uiEffect.send(PluginManagerUiEffect.ShowError(R.string.msg_plugin_enable_failed)) + } + }.onFailure { exception -> + Log.e(TAG, "Error enabling plugin: $pluginId", exception) + _uiEffect.send( + PluginManagerUiEffect.ShowError( + R.string.msg_plugin_enable_error, + listOf(exception.message ?: ""), + ), + ) + } + + _currentOperation.value = PluginOperation.None + } + } + + /** + * Disable a plugin + */ + private fun disablePlugin(pluginId: String) { + viewModelScope.launch { + _currentOperation.value = PluginOperation.Disabling(pluginId) + + pluginRepository + .disablePlugin(pluginId) + .onSuccess { success -> + if (success) { + Log.d(TAG, "Plugin disabled successfully: $pluginId") + _uiEffect.send(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_disabled)) + loadPlugins() + } else { + Log.w(TAG, "Failed to disable plugin: $pluginId") + _uiEffect.send(PluginManagerUiEffect.ShowError(R.string.msg_plugin_disable_failed)) + } + }.onFailure { exception -> + Log.e(TAG, "Error disabling plugin: $pluginId", exception) + _uiEffect.send( + PluginManagerUiEffect.ShowError( + R.string.msg_plugin_disable_error, + listOf(exception.message ?: ""), + ), + ) + } + + _currentOperation.value = PluginOperation.None + } + } + + /** + * Show uninstall confirmation dialog + */ + private fun showUninstallConfirmation(pluginId: String) { + val plugin = _uiState.value.plugins.find { it.metadata.id == pluginId } + if (plugin != null) { + viewModelScope.launch { + _uiEffect.send(PluginManagerUiEffect.ShowUninstallConfirmation(plugin)) + } + } + } + + /** + * Uninstall a plugin (called after confirmation) + */ + fun confirmUninstallPlugin(pluginId: String) { + viewModelScope.launch { + _currentOperation.value = PluginOperation.Uninstalling(pluginId) + + pluginRepository + .uninstallPlugin(pluginId) + .onSuccess { success -> + if (success) { + Log.d(TAG, "Plugin uninstalled successfully: $pluginId") + _uiEffect.send(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_uninstalled)) + loadPlugins() + _uiEffect.send(PluginManagerUiEffect.ShowRestartPrompt) + } else { + Log.w(TAG, "Failed to uninstall plugin: $pluginId") + _uiEffect.send(PluginManagerUiEffect.ShowError(R.string.msg_plugin_uninstall_failed)) + } + }.onFailure { exception -> + Log.e(TAG, "Error uninstalling plugin: $pluginId", exception) + _uiEffect.send( + PluginManagerUiEffect.ShowError( + R.string.msg_plugin_uninstall_error, + listOf(exception.message ?: ""), + ), + ) + } + + _currentOperation.value = PluginOperation.None + } + } + + private fun installPlugin( + 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.send(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_installed)) + loadPlugins() + _uiEffect.send(PluginManagerUiEffect.ShowRestartPrompt) + + if (deleteSourceAfterInstall) { + deleteSourceDocument(uri) + } + }.onFailure { exception -> + Log.e(TAG, "Failed to install plugin", exception) + _uiEffect.send( + PluginManagerUiEffect.ShowError( + R.string.msg_plugin_install_failed, + listOf(exception.message ?: ""), + ), + ) + } + } catch (exception: Exception) { + Log.e(TAG, "Error installing plugin from URI", exception) + _uiEffect.send( + PluginManagerUiEffect.ShowError( + R.string.msg_plugin_install_failed, + listOf(exception.message ?: ""), + ), + ) + } 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.send(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.send(effect) + return true + } + + private suspend fun deleteSourceDocument(uri: Uri) { + withContext(Dispatchers.IO) { + try { + val deleted = DocumentsContract.deleteDocument(contentResolver, uri) + if (!deleted) { + _uiEffect.send( + PluginManagerUiEffect.ShowError(R.string.msg_source_delete_failed), + ) + } + } catch (e: Exception) { + Log.w(TAG, "Failed to delete source document", e) + _uiEffect.send( + PluginManagerUiEffect.ShowError(R.string.msg_source_delete_failed), + ) + } + } + } + + /** + * Open file picker + */ + private fun openFilePicker() { + viewModelScope.launch { + _uiEffect.send(PluginManagerUiEffect.OpenFilePicker) + } + } + + /** + * 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. + */ + private fun handleFileSelected(uri: Uri) { + viewModelScope.launch { + val isSupported = + withContext(Dispatchers.IO) { + uri.getFileName(contentResolver).endsWith(PLUGIN_EXTENSION, ignoreCase = true) + } + + if (isSupported) { + _uiEffect.send(PluginManagerUiEffect.ShowInstallConfirmation(uri)) + } else { + _uiEffect.send(PluginManagerUiEffect.ShowError(R.string.msg_unsupported_plugin_file)) + } + } + } + + /** + * Show plugin details + */ + private fun showPluginDetails(plugin: PluginInfo) { + viewModelScope.launch { + _uiEffect.send(PluginManagerUiEffect.ShowPluginDetails(plugin)) + } + } + + /** + * Check if a specific plugin operation is in progress + */ + fun isPluginOperationInProgress(pluginId: String): Boolean = + when (val operation = _currentOperation.value) { + is PluginOperation.Enabling -> operation.pluginId == pluginId + is PluginOperation.Disabling -> operation.pluginId == pluginId + is PluginOperation.Uninstalling -> operation.pluginId == pluginId + else -> false + } } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/TemplateManagerViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/TemplateManagerViewModel.kt new file mode 100644 index 0000000000..34a0f4e3f2 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/TemplateManagerViewModel.kt @@ -0,0 +1,151 @@ +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.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 { + templateRepository + .installTemplate(item) + .onSuccess { + Log.d(TAG, "Template installed successfully: ${item.name}") + _uiEffect.send(TemplateManagerUiEffect.ShowSuccess(R.string.msg_template_installed)) + loadTemplates() + }.onFailure { exception -> + Log.e(TAG, "Failed to install template: ${item.name}", exception) + _uiEffect.send( + TemplateManagerUiEffect.ShowError( + R.string.msg_template_install_failed, + listOf(exception.message ?: ""), + ), + ) + } + } + } + + private fun uninstallTemplate(item: CgtFileItem) { + viewModelScope.launch { + 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) + _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 { + 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) + _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 @@ - - - - - - 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..1b33d587a4 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/templates/manager/parsing/CgtTemplateReaderTest.kt @@ -0,0 +1,120 @@ +package com.itsaky.androidide.templates.manager.parsing + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +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() + } +} 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..729f2cad1b 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,38 @@ 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: SecurityException) { + log.warn("Denied access while reading URI: {}://{}", scheme, authority, e) + } catch (e: IllegalArgumentException) { + // No registered provider for this URI, or the provider rejected the query args. + log.warn("No provider could resolve 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 3f23efbed2..4f9548c548 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -111,7 +111,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 @@ -139,7 +139,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. @@ -251,7 +251,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 9c4e15649b..169910bc45 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -96,6 +96,7 @@ 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" } +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" } @@ -269,6 +270,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 a94cc33edf..e039b56046 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -49,6 +49,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 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/strings.xml b/resources/src/main/res/values/strings.xml index c56ca9ac40..7d3cfbd23a 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -893,8 +893,8 @@ Plugin Manager - Plugin Manager - Manage IDE plugins and extensions + Extensions Manager + Manage IDE plugins and templates Plugins No plugins installed Tap the + button to install your first plugin @@ -910,6 +910,10 @@ Plugin Details Permissions Dependencies + by %1$s + Not Loaded + Disabled + Enabled Plugin crashed @@ -1001,6 +1005,7 @@ Redo Delete Add + Navigate back Search Error Warning @@ -1075,8 +1080,25 @@ 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: %2$s + Failed to load templates: %1$s + Template installed successfully + Failed to install template: %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. @@ -1236,6 +1258,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