From dde4bf279710839d474d1df5f1d18abf8a8bc331 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 24 Jul 2026 14:50:11 -0700 Subject: [PATCH 1/9] ADFA-4649: Replace SizeUtils/ConvertUtils dp-px conversions with native equivalents Adds Context.dpToPx()/spToPx() extensions to common's ContextUtils.kt, matching blankj SizeUtils' rounding formula exactly, and swaps all 22 com.blankj.utilcode SizeUtils/ConvertUtils.dp2px call sites across app, editor, uidesigner, xml-inflater, and common-ui to use them. First of several staged commits removing the com.blankj:utilcodex dependency. Co-Authored-By: Claude Sonnet 5 --- .../androidide/activities/AboutActivity.kt | 577 ++-- .../activities/editor/BaseEditorActivity.kt | 8 +- .../editor/ProjectHandlerActivity.kt | 4 +- .../adapters/TemplateListAdapter.kt | 35 +- .../OnboardingPermissionsAdapter.kt | 118 +- .../viewholders/FileTreeViewHolder.java | 173 +- .../fragments/SearchFieldToolbar.kt | 328 +-- .../fragments/sheets/OptionsListFragment.java | 139 +- .../fragments/sidebar/FileTreeFragment.kt | 1053 +++---- .../itsaky/androidide/ui/CodeEditorView.kt | 128 +- .../itsaky/androidide/ui/EditorBottomSheet.kt | 4 +- .../utils/WindowInsetsExtensions.kt | 130 +- .../androidide/FabPositionCalculator.kt | 208 +- .../itsaky/androidide/utils/ContextUtils.kt | 68 +- .../editor/ui/BaseEditorWindow.java | 158 +- .../androidide/editor/ui/EditorActionsMenu.kt | 20 +- .../itsaky/androidide/editor/ui/IDEEditor.kt | 2494 +++++++++-------- .../drawable/UiViewLayeredForeground.kt | 20 +- .../fragments/DesignerWorkspaceFragment.kt | 321 +-- .../uidesigner/views/LayoutHierarchyView.kt | 268 +- .../internal/adapters/ListViewAdapter.kt | 38 +- .../internal/adapters/TextViewAdapter.kt | 294 +- .../androidide/inflater/models/UiWidget.kt | 111 +- 23 files changed, 3437 insertions(+), 3260 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/AboutActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/AboutActivity.kt index 9eb8ddbccc..c85b3ac2b4 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/AboutActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/AboutActivity.kt @@ -1,288 +1,289 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ -package com.itsaky.androidide.activities - -import android.content.Context -import android.os.Bundle -import android.text.SpannableStringBuilder -import android.text.style.ForegroundColorSpan -import android.view.View -import androidx.annotation.ColorInt -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.core.content.ContextCompat -import androidx.core.graphics.Insets -import androidx.core.view.updatePaddingRelative -import com.blankj.utilcode.util.ClipboardUtils -import com.blankj.utilcode.util.SizeUtils -import com.itsaky.androidide.BuildConfig -import com.itsaky.androidide.R -import com.itsaky.androidide.adapters.SimpleIconTitleDescriptionAdapter -import com.itsaky.androidide.app.EdgeToEdgeIDEActivity -import com.itsaky.androidide.app.configuration.IDEBuildConfigProvider -import com.itsaky.androidide.buildinfo.BuildInfo -import com.itsaky.androidide.databinding.ActivityAboutBinding -import com.itsaky.androidide.models.IconTitleDescriptionItem -import com.itsaky.androidide.models.SimpleIconTitleDescriptionItem -import com.itsaky.androidide.utils.BasicBuildInfo -import com.itsaky.androidide.utils.BuildInfoUtils -import com.itsaky.androidide.utils.UrlManager -import com.itsaky.androidide.utils.flashSuccess -import com.itsaky.androidide.utils.resolveAttr -import com.itsaky.androidide.FeedbackButtonManager - -class AboutActivity : EdgeToEdgeIDEActivity() { - - private var _binding: ActivityAboutBinding? = null - private val binding: ActivityAboutBinding - get() = checkNotNull(_binding) { - "Activity has been destroyed" - } - - override fun bindLayout(): View { - _binding = ActivityAboutBinding.inflate(layoutInflater) - return _binding!!.root - } - - private var feedbackButtonManager: FeedbackButtonManager? = null - - companion object { - - private var id = 0 - private val ACTION_WEBSITE = id++ - private val ACTION_EMAIL = id++ - private val ACTION_TG_CHANNEL = id++ - private val ACTION_GH_FORUM = id++ - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - binding.apply { - - setSupportActionBar(toolbar) - supportActionBar!!.setDisplayHomeAsUpEnabled(true) - supportActionBar!!.setTitle(R.string.about) - toolbar.setNavigationOnClickListener { onBackPressedDispatcher.onBackPressed() } - feedbackButtonManager = FeedbackButtonManager(this@AboutActivity, fabFeedback.root) - feedbackButtonManager?.setupDraggableFab() - - aboutHeader.apply { - ideVersion.text = createVersionText() - ideVersion.isClickable = true - ideVersion.isFocusable = true - ideVersion.setBackgroundResource(R.drawable.bg_ripple) - ideVersion.setOnClickListener { - ClipboardUtils.copyText(BuildInfoUtils.getBuildInfoHeader()) - flashSuccess(R.string.copied) - } - - supportButton.setOnClickListener { - UrlManager.openUrl(getString(R.string.github_sponsors_url), null, this@AboutActivity) - } - } - - socials.apply { - sectionTitle.setText(R.string.title_socials) - sectionItems.adapter = AboutSocialItemsAdapter(createSocialItems(), ::handleActionClick) - } - } - } - - override fun onApplySystemBarInsets(insets: Insets) { - binding.toolbar.apply { - setPaddingRelative( - paddingStart + insets.left, - paddingTop, - paddingEnd + insets.right, - paddingBottom - ) - } - } - - private fun handleActionClick(action: SimpleIconTitleDescriptionItem) { - when (action.id) { - ACTION_WEBSITE -> UrlManager.openUrl(BuildInfo.PROJECT_SITE, null, this) - ACTION_EMAIL -> UrlManager.openUrl(getString(R.string.mail_to_adfa), null, this) - ACTION_GH_FORUM -> UrlManager.openUrl(getString(R.string.github_discussions_url), context = this) - ACTION_TG_CHANNEL -> UrlManager.openUrl(getString(R.string.telegram_channel_url), "org.telegram.messenger", this) - } - } - - private fun createSocialItems(): List { - return mutableListOf().apply { - add( - createSimpleIconTextItem( - this@AboutActivity, - ACTION_WEBSITE, - R.drawable.ic_website, - R.string.about_option_website, - BuildInfo.PROJECT_SITE - ) - ) - add( - createSimpleIconTextItem( - this@AboutActivity, - ACTION_EMAIL, - R.drawable.ic_email, - R.string.about_option_email, - getString(R.string.adfa_email) - ) - ) - add( - createSimpleIconTextItem( - this@AboutActivity, - ACTION_GH_FORUM, - R.drawable.ic_github, - R.string.discussions_on_telegram, - getString(R.string.github_discussions_url) - ) - ) - add( - createSimpleIconTextItem( - this@AboutActivity, - ACTION_TG_CHANNEL, - R.drawable.ic_telegram, - R.string.official_tg_channel, - getString(R.string.telegram_channel_url) - ) - ) - } - } - - private fun createSimpleIconTextItem( - context: Context, - id: Int, - @DrawableRes icon: Int, - @StringRes title: Int, - description: CharSequence - ): SimpleIconTitleDescriptionItem { - return SimpleIconTitleDescriptionItem( - id, - ContextCompat.getDrawable(context, icon), - ContextCompat.getString(context, title), - description - ) - } - - /** - * Create the version name string that should be displayed to the user. - * - * Format of the version name string is : - * - * `v[version-name]-[variant] ([build-type]/[[UN]OFFICIAL])` - */ - @Suppress("KDocUnresolvedReference") - private fun createVersionText(): CharSequence { - val builder = SpannableStringBuilder() - builder.append("v") - builder.append(BasicBuildInfo.formatVersion()) - builder.append("-") - builder.append(IDEBuildConfigProvider.getInstance().cpuAbiName) - builder.append(" ") - - val colorPositive = ContextCompat.getColor(this, R.color.color_success) - val colorNegative = ContextCompat.getColor(this, R.color.color_error) - - appendBuildType(builder, colorPositive, colorNegative) - - return builder - } - - private fun appendBuildType( - builder: SpannableStringBuilder, - @ColorInt - colorPositive: Int, - @ColorInt - colorNegative: Int - ) { - @Suppress("KotlinConstantConditions") - var color = if (BuildConfig.BUILD_TYPE != "release") { - colorNegative - } else { - colorPositive - } - - builder.append("(") - appendForegroundSpan(builder, BuildConfig.BUILD_TYPE, color) - - val isOfficialBuild = BuildInfoUtils.isOfficialBuild(this) - - color = if (isOfficialBuild) { - colorPositive - } else { - colorNegative - } - - builder.append("/") - appendForegroundSpan( - builder, - BuildInfoUtils.getBuildType(this).lowercase(), - color - ) - - builder.append(")") - } - - private fun appendForegroundSpan( - builder: SpannableStringBuilder, - text: CharSequence, - color: Int - ) { - builder.append( - text, - ForegroundColorSpan(color), - SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } - - override fun onResume() { - super.onResume() - feedbackButtonManager?.loadFabPosition() - } - - override fun onDestroy() { - super.onDestroy() - _binding = null - } - - class AboutSocialItemsAdapter( - items: List, - private val onClickListener: (SimpleIconTitleDescriptionItem) -> Unit - ) : SimpleIconTitleDescriptionAdapter(items) { - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - super.onBindViewHolder(holder, position) - val binding = holder.binding - val item = getItem(position) as SimpleIconTitleDescriptionItem - val dp8 = SizeUtils.dp2px(8f) - binding.icon.updatePaddingRelative(dp8, dp8, dp8, dp8) - binding.title.setTextAppearance(R.style.TextAppearance_Material3_TitleSmall) - - binding.description.maxLines = 3 - binding.description.setTextAppearance(R.style.TextAppearance_Material3_BodySmall) - binding.description.setTextColor(binding.description.context.resolveAttr(R.attr.colorPrimary)) - - binding.root.isClickable = true - binding.root.isFocusable = true - binding.root.setBackgroundResource(R.drawable.bg_ripple) - binding.root.setOnClickListener { - onClickListener(item) - } - } - } -} +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ +package com.itsaky.androidide.activities + +import android.content.Context +import android.os.Bundle +import android.text.SpannableStringBuilder +import android.text.style.ForegroundColorSpan +import android.view.View +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import androidx.core.graphics.Insets +import androidx.core.view.updatePaddingRelative +import com.blankj.utilcode.util.ClipboardUtils +import com.itsaky.androidide.BuildConfig +import com.itsaky.androidide.FeedbackButtonManager +import com.itsaky.androidide.R +import com.itsaky.androidide.adapters.SimpleIconTitleDescriptionAdapter +import com.itsaky.androidide.app.EdgeToEdgeIDEActivity +import com.itsaky.androidide.app.configuration.IDEBuildConfigProvider +import com.itsaky.androidide.buildinfo.BuildInfo +import com.itsaky.androidide.databinding.ActivityAboutBinding +import com.itsaky.androidide.models.IconTitleDescriptionItem +import com.itsaky.androidide.models.SimpleIconTitleDescriptionItem +import com.itsaky.androidide.utils.BasicBuildInfo +import com.itsaky.androidide.utils.BuildInfoUtils +import com.itsaky.androidide.utils.UrlManager +import com.itsaky.androidide.utils.dpToPx +import com.itsaky.androidide.utils.flashSuccess +import com.itsaky.androidide.utils.resolveAttr + +class AboutActivity : EdgeToEdgeIDEActivity() { + @Suppress("ktlint:standard:backing-property-naming") + private var _binding: ActivityAboutBinding? = null + private val binding: ActivityAboutBinding + get() = + checkNotNull(_binding) { + "Activity has been destroyed" + } + + override fun bindLayout(): View { + _binding = ActivityAboutBinding.inflate(layoutInflater) + return _binding!!.root + } + + private var feedbackButtonManager: FeedbackButtonManager? = null + + companion object { + private var id = 0 + private val ACTION_WEBSITE = id++ + private val ACTION_EMAIL = id++ + private val ACTION_TG_CHANNEL = id++ + private val ACTION_GH_FORUM = id++ + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding.apply { + setSupportActionBar(toolbar) + supportActionBar!!.setDisplayHomeAsUpEnabled(true) + supportActionBar!!.setTitle(R.string.about) + toolbar.setNavigationOnClickListener { onBackPressedDispatcher.onBackPressed() } + feedbackButtonManager = FeedbackButtonManager(this@AboutActivity, fabFeedback.root) + feedbackButtonManager?.setupDraggableFab() + + aboutHeader.apply { + ideVersion.text = createVersionText() + ideVersion.isClickable = true + ideVersion.isFocusable = true + ideVersion.setBackgroundResource(R.drawable.bg_ripple) + ideVersion.setOnClickListener { + ClipboardUtils.copyText(BuildInfoUtils.getBuildInfoHeader()) + flashSuccess(R.string.copied) + } + + supportButton.setOnClickListener { + UrlManager.openUrl(getString(R.string.github_sponsors_url), null, this@AboutActivity) + } + } + + socials.apply { + sectionTitle.setText(R.string.title_socials) + sectionItems.adapter = AboutSocialItemsAdapter(createSocialItems(), ::handleActionClick) + } + } + } + + override fun onApplySystemBarInsets(insets: Insets) { + binding.toolbar.apply { + setPaddingRelative( + paddingStart + insets.left, + paddingTop, + paddingEnd + insets.right, + paddingBottom, + ) + } + } + + private fun handleActionClick(action: SimpleIconTitleDescriptionItem) { + when (action.id) { + ACTION_WEBSITE -> UrlManager.openUrl(BuildInfo.PROJECT_SITE, null, this) + ACTION_EMAIL -> UrlManager.openUrl(getString(R.string.mail_to_adfa), null, this) + ACTION_GH_FORUM -> UrlManager.openUrl(getString(R.string.github_discussions_url), context = this) + ACTION_TG_CHANNEL -> UrlManager.openUrl(getString(R.string.telegram_channel_url), "org.telegram.messenger", this) + } + } + + private fun createSocialItems(): List = + mutableListOf().apply { + add( + createSimpleIconTextItem( + this@AboutActivity, + ACTION_WEBSITE, + R.drawable.ic_website, + R.string.about_option_website, + BuildInfo.PROJECT_SITE, + ), + ) + add( + createSimpleIconTextItem( + this@AboutActivity, + ACTION_EMAIL, + R.drawable.ic_email, + R.string.about_option_email, + getString(R.string.adfa_email), + ), + ) + add( + createSimpleIconTextItem( + this@AboutActivity, + ACTION_GH_FORUM, + R.drawable.ic_github, + R.string.discussions_on_telegram, + getString(R.string.github_discussions_url), + ), + ) + add( + createSimpleIconTextItem( + this@AboutActivity, + ACTION_TG_CHANNEL, + R.drawable.ic_telegram, + R.string.official_tg_channel, + getString(R.string.telegram_channel_url), + ), + ) + } + + private fun createSimpleIconTextItem( + context: Context, + id: Int, + @DrawableRes icon: Int, + @StringRes title: Int, + description: CharSequence, + ): SimpleIconTitleDescriptionItem = + SimpleIconTitleDescriptionItem( + id, + ContextCompat.getDrawable(context, icon), + ContextCompat.getString(context, title), + description, + ) + +/** +* Create the version name string that should be displayed to the user. +* +* Format of the version name string is : +* +* `v[version-name]-[variant] ([build-type]/[[UN]OFFICIAL])` +*/ + @Suppress("KDocUnresolvedReference") + private fun createVersionText(): CharSequence { + val builder = SpannableStringBuilder() + builder.append("v") + builder.append(BasicBuildInfo.formatVersion()) + builder.append("-") + builder.append(IDEBuildConfigProvider.getInstance().cpuAbiName) + builder.append(" ") + + val colorPositive = ContextCompat.getColor(this, R.color.color_success) + val colorNegative = ContextCompat.getColor(this, R.color.color_error) + + appendBuildType(builder, colorPositive, colorNegative) + + return builder + } + + private fun appendBuildType( + builder: SpannableStringBuilder, + @ColorInt + colorPositive: Int, + @ColorInt + colorNegative: Int, + ) { + @Suppress("KotlinConstantConditions") + var color = + if (BuildConfig.BUILD_TYPE != "release") { + colorNegative + } else { + colorPositive + } + + builder.append("(") + appendForegroundSpan(builder, BuildConfig.BUILD_TYPE, color) + + val isOfficialBuild = BuildInfoUtils.isOfficialBuild(this) + + color = + if (isOfficialBuild) { + colorPositive + } else { + colorNegative + } + + builder.append("/") + appendForegroundSpan( + builder, + BuildInfoUtils.getBuildType(this).lowercase(), + color, + ) + + builder.append(")") + } + + private fun appendForegroundSpan( + builder: SpannableStringBuilder, + text: CharSequence, + color: Int, + ) { + builder.append( + text, + ForegroundColorSpan(color), + SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + + override fun onResume() { + super.onResume() + feedbackButtonManager?.loadFabPosition() + } + + override fun onDestroy() { + super.onDestroy() + _binding = null + } + + class AboutSocialItemsAdapter( + items: List, + private val onClickListener: (SimpleIconTitleDescriptionItem) -> Unit, + ) : SimpleIconTitleDescriptionAdapter(items) { + override fun onBindViewHolder( + holder: ViewHolder, + position: Int, + ) { + super.onBindViewHolder(holder, position) + val binding = holder.binding + val item = getItem(position) as SimpleIconTitleDescriptionItem + val dp8 = binding.icon.context.dpToPx(8f) + binding.icon.updatePaddingRelative(dp8, dp8, dp8, dp8) + binding.title.setTextAppearance(R.style.TextAppearance_Material3_TitleSmall) + + binding.description.maxLines = 3 + binding.description.setTextAppearance(R.style.TextAppearance_Material3_BodySmall) + binding.description.setTextColor(binding.description.context.resolveAttr(R.attr.colorPrimary)) + + binding.root.isClickable = true + binding.root.isFocusable = true + binding.root.setBackgroundResource(R.drawable.bg_ripple) + binding.root.setOnClickListener { + onClickListener(item) + } + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index 5ef86e9113..616d5e418e 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -68,7 +68,6 @@ import androidx.lifecycle.repeatOnLifecycle import com.blankj.utilcode.constant.MemoryConstants import com.blankj.utilcode.util.ConvertUtils.byte2MemorySize import com.blankj.utilcode.util.FileUtils -import com.blankj.utilcode.util.SizeUtils import com.blankj.utilcode.util.ThreadUtils import com.github.mikephil.charting.components.AxisBase import com.github.mikephil.charting.data.Entry @@ -135,6 +134,7 @@ import com.itsaky.androidide.utils.applyBottomSheetAnchorForOrientation import com.itsaky.androidide.utils.applyImmersiveModeInsets import com.itsaky.androidide.utils.applyResponsiveAppBarInsets import com.itsaky.androidide.utils.applyRootSystemInsetsAsPadding +import com.itsaky.androidide.utils.dpToPx import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashMessage import com.itsaky.androidide.utils.getOrStoreInitialPadding @@ -191,7 +191,7 @@ abstract class BaseEditorActivity : private val fileManagerViewModel by viewModels() private var feedbackButtonManager: FeedbackButtonManager? = null private var fullscreenManager: FullscreenManager? = null - private val topEdgeThreshold by lazy { SizeUtils.dp2px(TOP_EDGE_SWIPE_THRESHOLD_DP) } + private val topEdgeThreshold by lazy { dpToPx(TOP_EDGE_SWIPE_THRESHOLD_DP) } var isDestroying = false protected set @@ -408,8 +408,8 @@ abstract class BaseEditorActivity : protected var optionsMenuInvalidator: Runnable? = null private var gestureDetector: GestureDetector? = null - private val flingDistanceThreshold by lazy { SizeUtils.dp2px(100f) } - private val flingVelocityThreshold by lazy { SizeUtils.dp2px(100f) } + private val flingDistanceThreshold by lazy { dpToPx(100f) } + private val flingVelocityThreshold by lazy { dpToPx(100f) } private var editorAppBarInsetTop: Int = 0 diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt index 362e9b08ae..e8e0494c11 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt @@ -33,7 +33,6 @@ import androidx.appcompat.app.AlertDialog import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import com.blankj.utilcode.util.SizeUtils import com.google.android.material.bottomsheet.BottomSheetBehavior import com.itsaky.androidide.R import com.itsaky.androidide.actions.ActionData @@ -93,6 +92,7 @@ import com.itsaky.androidide.utils.DURATION_INDEFINITE import com.itsaky.androidide.utils.DialogUtils.newMaterialDialogBuilder import com.itsaky.androidide.utils.DialogUtils.showRestartPrompt import com.itsaky.androidide.utils.RecursiveFileSearcher +import com.itsaky.androidide.utils.dpToPx import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.utils.flashbarBuilder @@ -844,7 +844,7 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { check.isChecked = true val params = MarginLayoutParams(-2, -2) - params.bottomMargin = SizeUtils.dp2px(4f) + params.bottomMargin = dpToPx(4f) binding.modulesContainer.addView(check, params) srcDirs.add(src) } diff --git a/app/src/main/java/com/itsaky/androidide/adapters/TemplateListAdapter.kt b/app/src/main/java/com/itsaky/androidide/adapters/TemplateListAdapter.kt index 3f1bbf1029..4d8323bc1b 100644 --- a/app/src/main/java/com/itsaky/androidide/adapters/TemplateListAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/adapters/TemplateListAdapter.kt @@ -17,17 +17,17 @@ package com.itsaky.androidide.adapters -import android.widget.ImageView import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.ImageView import androidx.recyclerview.widget.RecyclerView -import com.blankj.utilcode.util.ConvertUtils import com.bumptech.glide.Glide import com.google.android.material.shape.CornerFamily import com.itsaky.androidide.adapters.TemplateListAdapter.ViewHolder import com.itsaky.androidide.databinding.LayoutTemplateListItemBinding import com.itsaky.androidide.templates.Template +import com.itsaky.androidide.utils.dpToPx /** * [RecyclerView.Adapter] for showing templates in a [RecyclerView]. @@ -39,7 +39,6 @@ class TemplateListAdapter( private val onClick: ((Template<*>, ViewHolder) -> Unit)? = null, private val onLongClick: ((Template<*>, View) -> Unit)? = null, ) : RecyclerView.Adapter() { - private val templates = templates.toMutableList() class ViewHolder( @@ -64,29 +63,29 @@ class TemplateListAdapter( holder: ViewHolder, position: Int, ) { - - holder.binding.apply { - val template = templates[position] - if (template == Template.EMPTY) { + holder.binding.apply { + val template = templates[position] + if (template == Template.EMPTY) { root.visibility = View.INVISIBLE return@apply } - templateName.text = template.templateNameStr - if (template.thumbData != null) { - templateIcon.scaleType = ImageView.ScaleType.FIT_CENTER - Glide.with(templateIcon.context) - .asBitmap() - .load(template.thumbData) - .into(templateIcon) - } else { - templateIcon.setImageResource(template.thumb) - } + templateName.text = template.templateNameStr + if (template.thumbData != null) { + templateIcon.scaleType = ImageView.ScaleType.FIT_CENTER + Glide + .with(templateIcon.context) + .asBitmap() + .load(template.thumbData) + .into(templateIcon) + } else { + templateIcon.setImageResource(template.thumb) + } templateIcon.shapeAppearanceModel = templateIcon.shapeAppearanceModel .toBuilder() - .setAllCorners(CornerFamily.ROUNDED, ConvertUtils.dp2px(8f).toFloat()) + .setAllCorners(CornerFamily.ROUNDED, templateIcon.context.dpToPx(8f).toFloat()) .build() root.setOnClickListener { diff --git a/app/src/main/java/com/itsaky/androidide/adapters/onboarding/OnboardingPermissionsAdapter.kt b/app/src/main/java/com/itsaky/androidide/adapters/onboarding/OnboardingPermissionsAdapter.kt index 2fdc7505b7..3a2418731f 100644 --- a/app/src/main/java/com/itsaky/androidide/adapters/onboarding/OnboardingPermissionsAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/adapters/onboarding/OnboardingPermissionsAdapter.kt @@ -23,75 +23,83 @@ import android.view.ViewGroup import androidx.core.content.ContextCompat import androidx.core.graphics.ColorUtils import androidx.recyclerview.widget.RecyclerView -import com.blankj.utilcode.util.SizeUtils import com.google.android.material.button.MaterialButton import com.google.android.material.color.MaterialColors import com.itsaky.androidide.R import com.itsaky.androidide.databinding.LayoutOnboardingPermissionItemBinding import com.itsaky.androidide.models.OnboardingPermissionItem +import com.itsaky.androidide.utils.dpToPx /** * @author Akash Yadav */ -class OnboardingPermissionsAdapter(private val permissions: List, - private val requestPermission: (String) -> Unit) : - RecyclerView.Adapter() { +class OnboardingPermissionsAdapter( + private val permissions: List, + private val requestPermission: (String) -> Unit, +) : RecyclerView.Adapter() { + class ViewHolder( + val binding: LayoutOnboardingPermissionItemBinding, + ) : RecyclerView.ViewHolder(binding.root) { + val titleColor: Int = MaterialColors.getColor(binding.root, R.attr.colorOnSurface) + val descriptionColor: Int = MaterialColors.getColor(binding.root, R.attr.colorOnSurfaceVariant) + val disabledTitleColor: Int = ColorUtils.setAlphaComponent(titleColor, (255 * 0.38f).toInt()) + val disabledDescriptionColor: Int = ColorUtils.setAlphaComponent(descriptionColor, (255 * 0.38f).toInt()) + } - class ViewHolder(val binding: LayoutOnboardingPermissionItemBinding) : - RecyclerView.ViewHolder(binding.root) { - val titleColor: Int = MaterialColors.getColor(binding.root, R.attr.colorOnSurface) - val descriptionColor: Int = MaterialColors.getColor(binding.root, R.attr.colorOnSurfaceVariant) - val disabledTitleColor: Int = ColorUtils.setAlphaComponent(titleColor, (255 * 0.38f).toInt()) - val disabledDescriptionColor: Int = ColorUtils.setAlphaComponent(descriptionColor, (255 * 0.38f).toInt()) - } + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): ViewHolder = + ViewHolder( + LayoutOnboardingPermissionItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ), + ) - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - return ViewHolder( - LayoutOnboardingPermissionItemBinding.inflate(LayoutInflater.from(parent.context), parent, - false)) - } + override fun onBindViewHolder( + holder: ViewHolder, + position: Int, + ) { + val binding = holder.binding + val permission = permissions[position] + val context = binding.root.context - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val binding = holder.binding - val permission = permissions[position] - val context = binding.root.context + binding.infoContent.apply { + title.setText(permission.title) + description.setText(permission.description) + title.setTextColor(if (permission.isSupportedOnDevice) holder.titleColor else holder.disabledTitleColor) + description.setTextColor(if (permission.isSupportedOnDevice) holder.descriptionColor else holder.disabledDescriptionColor) + } - binding.infoContent.apply { - title.setText(permission.title) - description.setText(permission.description) - title.setTextColor(if (permission.isSupportedOnDevice) holder.titleColor else holder.disabledTitleColor) - description.setTextColor(if (permission.isSupportedOnDevice) holder.descriptionColor else holder.disabledDescriptionColor) - } + binding.grantButton.apply { + isEnabled = permission.isSupportedOnDevice + text = context.getString(R.string.title_grant) + icon = null + iconTint = null + iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START + iconPadding = 0 + iconSize = 0 + } - binding.grantButton.apply { - isEnabled = permission.isSupportedOnDevice - text = context.getString(R.string.title_grant) - icon = null - iconTint = null - iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START - iconPadding = 0 - iconSize = 0 - } + binding.grantButton.setOnClickListener { + requestPermission(permission.permission) + } - binding.grantButton.setOnClickListener { - requestPermission(permission.permission) - } + if (permission.isGranted) { + binding.grantButton.apply { + isEnabled = false + text = "" + icon = ContextCompat.getDrawable(context, R.drawable.ic_ok) + iconTint = + ColorStateList.valueOf(ContextCompat.getColor(context, R.color.green_500)) + iconGravity = MaterialButton.ICON_GRAVITY_TEXT_TOP + iconPadding = 0 + iconSize = context.dpToPx(28f) + } + } + } - if (permission.isGranted) { - binding.grantButton.apply { - isEnabled = false - text = "" - icon = ContextCompat.getDrawable(context, R.drawable.ic_ok) - iconTint = ColorStateList.valueOf( - ContextCompat.getColor(context, R.color.green_500)) - iconGravity = MaterialButton.ICON_GRAVITY_TEXT_TOP - iconPadding = 0 - iconSize = SizeUtils.dp2px(28f) - } - } - } - - override fun getItemCount(): Int { - return permissions.size - } + override fun getItemCount(): Int = permissions.size } diff --git a/app/src/main/java/com/itsaky/androidide/adapters/viewholders/FileTreeViewHolder.java b/app/src/main/java/com/itsaky/androidide/adapters/viewholders/FileTreeViewHolder.java index 041161f5f8..a7a9bdbf69 100755 --- a/app/src/main/java/com/itsaky/androidide/adapters/viewholders/FileTreeViewHolder.java +++ b/app/src/main/java/com/itsaky/androidide/adapters/viewholders/FileTreeViewHolder.java @@ -28,99 +28,100 @@ import androidx.transition.ChangeImageTransform; import androidx.transition.TransitionManager; -import com.blankj.utilcode.util.SizeUtils; import com.itsaky.androidide.databinding.LayoutFiletreeItemBinding; import com.itsaky.androidide.models.FileExtension; import com.itsaky.androidide.resources.R; +import com.itsaky.androidide.utils.ContextUtilsKt; import com.unnamed.b.atv.model.TreeNode; import java.io.File; public class FileTreeViewHolder extends TreeNode.BaseNodeViewHolder { - public interface ExternalDropHandler { - void onNodeBound(TreeNode node, File file, View view); - } - - private LayoutFiletreeItemBinding binding; - private final ExternalDropHandler externalDropHandler; - - public FileTreeViewHolder(Context context) { - this(context, null); - } - - public FileTreeViewHolder(Context context, ExternalDropHandler externalDropHandler) { - super(context); - this.externalDropHandler = externalDropHandler; - } - - @Override - public View createNodeView(TreeNode node, File file) { - this.binding = LayoutFiletreeItemBinding.inflate(LayoutInflater.from(context)); - - final var dp15 = SizeUtils.dp2px(15); - final boolean isDir = node.isDirectory(); - final var icon = getIconForFile(file, isDir); - final var chevron = binding.filetreeChevron; - binding.filetreeName.setText(file.getName()); - binding.filetreeIcon.setImageResource(icon); - - final var root = applyPadding(node, binding, dp15); - - if (isDir) { - chevron.setVisibility(View.VISIBLE); - updateChevronIcon(node.isExpanded()); - } else { - chevron.setVisibility(View.INVISIBLE); - } - - if (externalDropHandler != null) { - externalDropHandler.onNodeBound(node, file, root); - } - - return root; - } - - private void updateChevronIcon(boolean expanded) { - final int chevronIcon; - if (expanded) { - chevronIcon = R.drawable.ic_chevron_down; - } else { - chevronIcon = R.drawable.ic_chevron_right; - } - - TransitionManager.beginDelayedTransition(binding.getRoot(), new ChangeImageTransform()); - binding.filetreeChevron.setImageResource(chevronIcon); - } - - protected LinearLayout applyPadding( - final TreeNode node, final LayoutFiletreeItemBinding binding, final int padding) { - final var root = binding.getRoot(); - root.setPaddingRelative( - root.getPaddingLeft() + (padding * (node.getLevel() - 1)), - root.getPaddingTop(), - root.getPaddingRight(), - root.getPaddingBottom()); - return root; - } - - protected int getIconForFile(final File file, boolean isDirectory) { - return FileExtension.Factory.forFile(file, isDirectory).getIcon(); - } - - public void updateChevron(boolean expanded) { - setLoading(false); - updateChevronIcon(expanded); - } - - public void setLoading(boolean loading) { - final int viewIndex; - if (loading) { - viewIndex = 1; - } else { - viewIndex = 0; - } - - binding.chevronLoadingSwitcher.setDisplayedChild(viewIndex); - } + private LayoutFiletreeItemBinding binding; + + private final ExternalDropHandler externalDropHandler; + + public FileTreeViewHolder(Context context) { + this(context, null); + } + + public FileTreeViewHolder(Context context, ExternalDropHandler externalDropHandler) { + super(context); + this.externalDropHandler = externalDropHandler; + } + + @Override + public View createNodeView(TreeNode node, File file) { + this.binding = LayoutFiletreeItemBinding.inflate(LayoutInflater.from(context)); + + final var dp15 = ContextUtilsKt.dpToPx(context, 15); + final boolean isDir = node.isDirectory(); + final var icon = getIconForFile(file, isDir); + final var chevron = binding.filetreeChevron; + binding.filetreeName.setText(file.getName()); + binding.filetreeIcon.setImageResource(icon); + + final var root = applyPadding(node, binding, dp15); + + if (isDir) { + chevron.setVisibility(View.VISIBLE); + updateChevronIcon(node.isExpanded()); + } else { + chevron.setVisibility(View.INVISIBLE); + } + + if (externalDropHandler != null) { + externalDropHandler.onNodeBound(node, file, root); + } + + return root; + } + + public void setLoading(boolean loading) { + final int viewIndex; + if (loading) { + viewIndex = 1; + } else { + viewIndex = 0; + } + + binding.chevronLoadingSwitcher.setDisplayedChild(viewIndex); + } + + public void updateChevron(boolean expanded) { + setLoading(false); + updateChevronIcon(expanded); + } + + protected LinearLayout applyPadding( + final TreeNode node, final LayoutFiletreeItemBinding binding, final int padding) { + final var root = binding.getRoot(); + root.setPaddingRelative( + root.getPaddingLeft() + (padding * (node.getLevel() - 1)), + root.getPaddingTop(), + root.getPaddingRight(), + root.getPaddingBottom()); + return root; + } + + protected int getIconForFile(final File file, boolean isDirectory) { + return FileExtension.Factory.forFile(file, isDirectory).getIcon(); + } + + private void updateChevronIcon(boolean expanded) { + final int chevronIcon; + if (expanded) { + chevronIcon = R.drawable.ic_chevron_down; + } else { + chevronIcon = R.drawable.ic_chevron_right; + } + + TransitionManager.beginDelayedTransition(binding.getRoot(), new ChangeImageTransform()); + binding.filetreeChevron.setImageResource(chevronIcon); + } + + public interface ExternalDropHandler { + void onNodeBound(TreeNode node, File file, View view); + } } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/SearchFieldToolbar.kt b/app/src/main/java/com/itsaky/androidide/fragments/SearchFieldToolbar.kt index d5aa1e9360..92509c13b8 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/SearchFieldToolbar.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/SearchFieldToolbar.kt @@ -11,10 +11,7 @@ import android.widget.EditText import android.widget.LinearLayout import android.widget.PopupWindow import androidx.core.graphics.drawable.toDrawable -import com.blankj.utilcode.util.SizeUtils import com.itsaky.androidide.actions.ActionData -import com.itsaky.androidide.utils.resolveAttr -import com.itsaky.androidide.common.R import com.itsaky.androidide.actions.ActionItem import com.itsaky.androidide.actions.ActionsRegistry import com.itsaky.androidide.actions.BaseEditorAction @@ -26,163 +23,182 @@ import com.itsaky.androidide.actions.editor.PasteAction import com.itsaky.androidide.actions.editor.SelectAllAction import com.itsaky.androidide.actions.file.ShowTooltipAction import com.itsaky.androidide.actions.internal.DefaultActionsRegistry +import com.itsaky.androidide.common.R import com.itsaky.androidide.editor.databinding.LayoutPopupMenuItemBinding +import com.itsaky.androidide.utils.dpToPx +import com.itsaky.androidide.utils.resolveAttr import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch -class SearchFieldToolbar(private val anchor: EditText) { - - private val context: Context = anchor.context - private val container = LinearLayout(context) - private val popupWindow: PopupWindow - private val textAdapter = EditTextAdapter(anchor) - private var uiScope: CoroutineScope? = null - private var currentActions: List = emptyList() - - companion object { - private val KEEP_OPEN_ACTIONS = setOf(SelectAllAction.ID, PasteAction.ID) - } - - private val allowedActionIds = listOf( - SelectAllAction.ID, - CutAction.ID, - CopyAction.ID, - PasteAction.ID, - ShowTooltipAction.ID - ) - - init { - val drawable = GradientDrawable() - drawable.shape = GradientDrawable.RECTANGLE - drawable.cornerRadius = SizeUtils.dp2px(28f).toFloat() - drawable.color = ColorStateList.valueOf(context.resolveAttr(R.attr.colorSurface)) - drawable.setStroke(SizeUtils.dp2px(1f), context.resolveAttr(R.attr.colorOutline)) - - container.background = drawable - container.orientation = LinearLayout.HORIZONTAL - container.layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) - - val verticalPadding = SizeUtils.dp2px(2f) - val horizontalPadding = SizeUtils.dp2px(8f) - container.setPaddingRelative(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding) - - popupWindow = PopupWindow( - container, - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - false - ).apply { - elevation = SizeUtils.dp2px(8f).toFloat() - setBackgroundDrawable(0.toDrawable()) - isOutsideTouchable = true - isFocusable = false - inputMethodMode = PopupWindow.INPUT_METHOD_NEEDED - setOnDismissListener { - uiScope?.cancel() - uiScope = null - } - } - } - - private fun performAction(action: ActionItem) { - uiScope?.launch { - val data = ActionData.create(context).apply { - put(MutableTextTarget::class.java, textAdapter) - } - - (ActionsRegistry.getInstance() as? DefaultActionsRegistry)?.executeAction(action, data) - - when (action.id) { - in KEEP_OPEN_ACTIONS -> show() - else -> popupWindow.dismiss() - } - } - } - - fun show() { - val registry = ActionsRegistry.getInstance() - val allTextActions = registry.getActions(ActionItem.Location.EDITOR_TEXT_ACTIONS) - val actionsToShow = allowedActionIds.mapNotNull { id -> - allTextActions[id] - } - - if (actionsToShow.isEmpty()) { - popupWindow.dismiss() - return - } - - this.currentActions = actionsToShow - - uiScope?.cancel() - uiScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) - - container.removeAllViews() - val addedCount = setupActions(actionsToShow) - - if (addedCount == 0) { - popupWindow.dismiss() - return - } - - if (!popupWindow.isShowing) { - showPopup() - } else { popupWindow.update() } - } - - private fun setupActions(actions: List): Int { - val data = ActionData.create(context) - data.put(MutableTextTarget::class.java, textAdapter) - - val visibleActions = actions - .onEach { (it as? BaseEditorAction)?.prepare(data) } - .filter { it.visible } - - visibleActions.forEach(::addActionToToolbar) - return visibleActions.size - } - - private fun addActionToToolbar(action: ActionItem) { - val icon = action.icon - - val tooltip = action.label - - addButton(icon, tooltip, action.enabled) { - performAction(action) - } - } - - private fun addButton(icon: Drawable?, tooltip: String, isEnabled: Boolean, onClick: () -> Unit) { - val binding = LayoutPopupMenuItemBinding.inflate(LayoutInflater.from(context), container, false) - val button = binding.root - - button.text = "" - button.tooltipText = tooltip - button.icon = icon - - button.isEnabled = isEnabled - button.alpha = if (isEnabled) 1.0f else 0.4f - - button.setOnClickListener { onClick() } - - container.addView(button) - } - - private fun showPopup() { - container.measure(View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), - View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)) - - val popupWidth = container.measuredWidth - val popupHeight = container.measuredHeight - - val xOff = (anchor.width / 2) - (popupWidth / 2) - val yOff = -(anchor.height + popupHeight + SizeUtils.dp2px(8f)) - - popupWindow.showAsDropDown(anchor, xOff, yOff) - } -} \ No newline at end of file +class SearchFieldToolbar( + private val anchor: EditText, +) { + private val context: Context = anchor.context + private val container = LinearLayout(context) + private val popupWindow: PopupWindow + private val textAdapter = EditTextAdapter(anchor) + private var uiScope: CoroutineScope? = null + private var currentActions: List = emptyList() + + companion object { + private val KEEP_OPEN_ACTIONS = setOf(SelectAllAction.ID, PasteAction.ID) + } + + private val allowedActionIds = + listOf( + SelectAllAction.ID, + CutAction.ID, + CopyAction.ID, + PasteAction.ID, + ShowTooltipAction.ID, + ) + + init { + val drawable = GradientDrawable() + drawable.shape = GradientDrawable.RECTANGLE + drawable.cornerRadius = context.dpToPx(28f).toFloat() + drawable.color = ColorStateList.valueOf(context.resolveAttr(R.attr.colorSurface)) + drawable.setStroke(context.dpToPx(1f), context.resolveAttr(R.attr.colorOutline)) + + container.background = drawable + container.orientation = LinearLayout.HORIZONTAL + container.layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + + val verticalPadding = context.dpToPx(2f) + val horizontalPadding = context.dpToPx(8f) + container.setPaddingRelative(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding) + + popupWindow = + PopupWindow( + container, + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + false, + ).apply { + elevation = context.dpToPx(8f).toFloat() + setBackgroundDrawable(0.toDrawable()) + isOutsideTouchable = true + isFocusable = false + inputMethodMode = PopupWindow.INPUT_METHOD_NEEDED + setOnDismissListener { + uiScope?.cancel() + uiScope = null + } + } + } + + private fun performAction(action: ActionItem) { + uiScope?.launch { + val data = + ActionData.create(context).apply { + put(MutableTextTarget::class.java, textAdapter) + } + + (ActionsRegistry.getInstance() as? DefaultActionsRegistry)?.executeAction(action, data) + + when (action.id) { + in KEEP_OPEN_ACTIONS -> show() + else -> popupWindow.dismiss() + } + } + } + + fun show() { + val registry = ActionsRegistry.getInstance() + val allTextActions = registry.getActions(ActionItem.Location.EDITOR_TEXT_ACTIONS) + val actionsToShow = + allowedActionIds.mapNotNull { id -> + allTextActions[id] + } + + if (actionsToShow.isEmpty()) { + popupWindow.dismiss() + return + } + + this.currentActions = actionsToShow + + uiScope?.cancel() + uiScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + + container.removeAllViews() + val addedCount = setupActions(actionsToShow) + + if (addedCount == 0) { + popupWindow.dismiss() + return + } + + if (!popupWindow.isShowing) { + showPopup() + } else { + popupWindow.update() + } + } + + private fun setupActions(actions: List): Int { + val data = ActionData.create(context) + data.put(MutableTextTarget::class.java, textAdapter) + + val visibleActions = + actions + .onEach { (it as? BaseEditorAction)?.prepare(data) } + .filter { it.visible } + + visibleActions.forEach(::addActionToToolbar) + return visibleActions.size + } + + private fun addActionToToolbar(action: ActionItem) { + val icon = action.icon + + val tooltip = action.label + + addButton(icon, tooltip, action.enabled) { + performAction(action) + } + } + + private fun addButton( + icon: Drawable?, + tooltip: String, + isEnabled: Boolean, + onClick: () -> Unit, + ) { + val binding = LayoutPopupMenuItemBinding.inflate(LayoutInflater.from(context), container, false) + val button = binding.root + + button.text = "" + button.tooltipText = tooltip + button.icon = icon + + button.isEnabled = isEnabled + button.alpha = if (isEnabled) 1.0f else 0.4f + + button.setOnClickListener { onClick() } + + container.addView(button) + } + + private fun showPopup() { + container.measure( + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + ) + + val popupWidth = container.measuredWidth + val popupHeight = container.measuredHeight + + val xOff = (anchor.width / 2) - (popupWidth / 2) + val yOff = -(anchor.height + popupHeight + context.dpToPx(8f)) + + popupWindow.showAsDropDown(anchor, xOff, yOff) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/fragments/sheets/OptionsListFragment.java b/app/src/main/java/com/itsaky/androidide/fragments/sheets/OptionsListFragment.java index 86908886e4..53c1f26040 100755 --- a/app/src/main/java/com/itsaky/androidide/fragments/sheets/OptionsListFragment.java +++ b/app/src/main/java/com/itsaky/androidide/fragments/sheets/OptionsListFragment.java @@ -24,89 +24,84 @@ import android.view.View; import android.view.ViewGroup; import android.view.ViewGroup.LayoutParams; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; - -import com.blankj.utilcode.util.SizeUtils; import com.itsaky.androidide.adapters.OptionsSheetAdapter; import com.itsaky.androidide.events.FileContextMenuItemClickEvent; import com.itsaky.androidide.events.FileContextMenuItemLongClickEvent; import com.itsaky.androidide.models.SheetOption; - -import org.greenrobot.eventbus.EventBus; - +import com.itsaky.androidide.utils.ContextUtilsKt; import java.util.ArrayList; import java.util.List; +import org.greenrobot.eventbus.EventBus; public class OptionsListFragment extends BaseBottomSheetFragment { - private final List mOptions = new ArrayList<>(); - protected boolean dismissOnItemClick = true; - private RecyclerView mList; - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState - ) { - final var dp8 = SizeUtils.dp2px(8); - final var dp16 = dp8 * 2; - mList = new RecyclerView(requireContext()); - mList.setLayoutParams(new LayoutParams(-1, -1)); - mList.setPaddingRelative(dp16, dp8, dp16, dp8); - return mList; - } - - @Override - public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - mList.setLayoutManager(new LinearLayoutManager(getContext())); - mList.setAdapter(new OptionsSheetAdapter(mOptions, - new OptionsSheetAdapter.OnOptionsClickListener() { - @Override - public void onOptionClick(SheetOption option) { - - if (dismissOnItemClick) { - dismiss(); - } - final var event = new FileContextMenuItemClickEvent(option); - event.put(Context.class, requireContext()); - EventBus.getDefault().post(event); - - } - - @Override - public void onOptionLongClick(SheetOption option) { - if (dismissOnItemClick) { - dismiss(); - } - final var event = new FileContextMenuItemLongClickEvent(option); - event.put(Context.class, requireContext()); - EventBus.getDefault().post(event); - } - })); - } - - public void addOption(SheetOption option) { - if (!mOptions.contains(option)) { - mOptions.add(option); - } - } - - public OptionsListFragment removeOption(int optionIndex) { - return removeOption(mOptions.get(optionIndex)); - } - - public OptionsListFragment removeOption(SheetOption option) { - mOptions.remove(option); - return this; - } - - public OptionsListFragment setDismissOnItemClick(boolean dissmiss) { - this.dismissOnItemClick = dissmiss; - return this; - } + private final List mOptions = new ArrayList<>(); + protected boolean dismissOnItemClick = true; + private RecyclerView mList; + + public void addOption(SheetOption option) { + if (!mOptions.contains(option)) { + mOptions.add(option); + } + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + final var dp8 = ContextUtilsKt.dpToPx(requireContext(), 8); + final var dp16 = dp8 * 2; + mList = new RecyclerView(requireContext()); + mList.setLayoutParams(new LayoutParams(-1, -1)); + mList.setPaddingRelative(dp16, dp8, dp16, dp8); + return mList; + } + + @Override + public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + mList.setLayoutManager(new LinearLayoutManager(getContext())); + mList.setAdapter(new OptionsSheetAdapter(mOptions, + new OptionsSheetAdapter.OnOptionsClickListener() { + @Override + public void onOptionClick(SheetOption option) { + + if (dismissOnItemClick) { + dismiss(); + } + final var event = new FileContextMenuItemClickEvent(option); + event.put(Context.class, requireContext()); + EventBus.getDefault().post(event); + + } + + @Override + public void onOptionLongClick(SheetOption option) { + if (dismissOnItemClick) { + dismiss(); + } + final var event = new FileContextMenuItemLongClickEvent(option); + event.put(Context.class, requireContext()); + EventBus.getDefault().post(event); + } + })); + } + + public OptionsListFragment removeOption(int optionIndex) { + return removeOption(mOptions.get(optionIndex)); + } + + public OptionsListFragment removeOption(SheetOption option) { + mOptions.remove(option); + return this; + } + + public OptionsListFragment setDismissOnItemClick(boolean dissmiss) { + this.dismissOnItemClick = dissmiss; + return this; + } } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeFragment.kt index 936bcaecc0..27f520d3a1 100755 --- a/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/sidebar/FileTreeFragment.kt @@ -1,506 +1,547 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ -package com.itsaky.androidide.fragments.sidebar - -import android.content.Context -import android.os.Bundle -import android.text.TextUtils -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.WindowInsetsCompat.Type.statusBars -import androidx.core.view.updatePadding -import androidx.fragment.app.viewModels -import androidx.transition.ChangeBounds -import androidx.transition.TransitionManager -import com.blankj.utilcode.util.SizeUtils -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.itsaky.androidide.R -import com.itsaky.androidide.adapters.viewholders.FileTreeViewHolder -import com.itsaky.androidide.databinding.LayoutEditorFileTreeBinding -import com.itsaky.androidide.dnd.FileDragError -import com.itsaky.androidide.dnd.FileDragResult -import com.itsaky.androidide.dnd.FileDragStarter -import com.itsaky.androidide.eventbus.events.filetree.FileClickEvent -import com.itsaky.androidide.eventbus.events.filetree.FileLongClickEvent -import com.itsaky.androidide.eventbus.events.filetree.PluginFilesChangedEvent -import com.itsaky.androidide.events.CollapseTreeNodeRequestEvent -import com.itsaky.androidide.events.ExpandTreeNodeRequestEvent -import com.itsaky.androidide.events.ListProjectFilesRequestEvent -import com.itsaky.androidide.projects.IProjectManager -import com.itsaky.androidide.resources.R.drawable -import com.itsaky.androidide.tasks.TaskExecutor.executeAsync -import com.itsaky.androidide.tasks.callables.FileTreeCallable -import com.itsaky.androidide.tasks.callables.FileTreeCallable.SortFileName -import com.itsaky.androidide.tasks.callables.FileTreeCallable.SortFolder -import com.itsaky.androidide.utils.doOnApplyWindowInsets -import com.itsaky.androidide.utils.flashError -import com.itsaky.androidide.utils.flashSuccess -import com.itsaky.androidide.viewmodel.FileTreeViewModel -import com.unnamed.b.atv.model.TreeNode -import com.unnamed.b.atv.model.TreeNode.TreeNodeClickListener -import com.unnamed.b.atv.model.TreeNode.TreeNodeLongClickListener -import com.unnamed.b.atv.view.AndroidTreeView -import org.greenrobot.eventbus.EventBus -import org.greenrobot.eventbus.Subscribe -import org.greenrobot.eventbus.ThreadMode.MAIN -import java.io.File -import java.util.Arrays - -class FileTreeFragment : BottomSheetDialogFragment(), TreeNodeClickListener, - TreeNodeLongClickListener, TreeNode.TreeNodeDragListener { - - private var binding: LayoutEditorFileTreeBinding? = null - private var fileTreeView: AndroidTreeView? = null - - // Root of the current tree; used to walk expanded nodes on refresh. - private var treeRoot: TreeNode? = null - - private val pluginRefreshRunnable = Runnable { - if (isVisible && context != null) { - refreshExpandedNodes() - } - } - - private val viewModel by viewModels(ownerProducer = { requireActivity() }) - private val fileDragStarter by lazy(LazyThreadSafetyMode.NONE) { - FileDragStarter(requireContext()) - } - private var _dropController: FileTreeDropController? = null - private val dropController: FileTreeDropController - get() { - if (_dropController == null) { - _dropController = FileTreeDropController( - activity = requireActivity(), - onDropCompleted = ::onExternalDropCompleted, - onDropFailed = ::flashError, - ) - } - return _dropController!! - } - - private val externalDropHandler: FileTreeViewHolder.ExternalDropHandler - get() = dropController.nodeBinder - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - if (!EventBus.getDefault().isRegistered(this)) { - EventBus.getDefault().register(this) - } - - binding = LayoutEditorFileTreeBinding.inflate(inflater, container, false) - binding?.root?.doOnApplyWindowInsets { view, insets, _, _ -> - insets.getInsets(statusBars()) - .apply { view.updatePadding(top = top + SizeUtils.dp2px(8f)) } - } - return binding!!.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - listProjectFiles() - } - - override fun onDestroyView() { - super.onDestroyView() - EventBus.getDefault().unregister(this) - - binding?.root?.removeCallbacks(pluginRefreshRunnable) - saveTreeState() - - binding = null - fileTreeView = null - treeRoot = null - _dropController = null - } - - fun saveTreeState() { - viewModel.saveState(fileTreeView) - } - - override fun onClick(node: TreeNode, value: Any) { - val file = value as File - if (!file.exists()) { - return - } - if (file.isDirectory) { - if (node.isExpanded) { - collapseNode(node) - } else { - setLoading(node) - listNode(node) { expandNode(node) } - } - } - val event = FileClickEvent(file) - event.put(Context::class.java, requireContext()) - EventBus.getDefault().post(event) - } - - private fun updateChevron(node: TreeNode) { - if (node.viewHolder is FileTreeViewHolder) { - (node.viewHolder as FileTreeViewHolder).updateChevron(node.isExpanded) - } - } - - private fun expandNode(node: TreeNode, animate: Boolean = true) { - if (fileTreeView == null) { - return - } - if (animate) { - TransitionManager.beginDelayedTransition(binding!!.root, ChangeBounds()) - } - fileTreeView!!.expandNode(node) - updateChevron(node) - } - - private fun collapseNode( - node: TreeNode, - animate: Boolean = true, - includeSubnodes: Boolean = false - ) { - if (fileTreeView == null) { - return - } - if (animate) { - TransitionManager.beginDelayedTransition(binding!!.root, ChangeBounds()) - } - fileTreeView!!.collapseNode(node, includeSubnodes) - updateChevron(node) - } - - private fun setLoading(node: TreeNode) { - if (node.viewHolder is FileTreeViewHolder) { - (node.viewHolder as FileTreeViewHolder).setLoading(true) - } - } - - private fun listNode(node: TreeNode, onListed: () -> Unit) { - val safeContext = context ?: return - val safeDropHandler = externalDropHandler - - node.children.clear() - node.isExpanded = false - executeAsync({ - listFilesForNode( - node.value.listFiles() ?: return@executeAsync null, - node, - safeContext, - safeDropHandler - ) - var temp = node - while (temp.size() == 1) { - temp = temp.childAt(0) - if (!temp.value.isDirectory) { - break - } - listFilesForNode( - temp.value.listFiles() ?: continue, - temp, - safeContext, - safeDropHandler - ) - temp.isExpanded = true - } - null - }) { - onListed() - } - } - - private fun listFilesForNode( - files: Array, - parent: TreeNode, - context: Context, - dropHandler: FileTreeViewHolder.ExternalDropHandler - ) { - Arrays.sort(files, SortFileName()) - Arrays.sort(files, SortFolder()) - for (file in files) { - parent.addChild(createFileNode(file, context, dropHandler), false) - } - } - - private fun createFileNode( - file: File, - context: Context, - dropHandler: FileTreeViewHolder.ExternalDropHandler - ): TreeNode { - return TreeNode(file).apply { - viewHolder = FileTreeViewHolder(context, dropHandler) - } - } - - override fun onLongClick(node: TreeNode, value: Any): Boolean { - val event = FileLongClickEvent((value as File)) - event.put(Context::class.java, requireContext()) - event.put(TreeNode::class.java, node) - EventBus.getDefault().post(event) - return true - } - - override fun onStartDrag(node: TreeNode, value: Any) { - val file = value as? File ?: return - val sourceView = node.viewHolder?.view ?: return - - when (val result = fileDragStarter.startDrag(sourceView, file)) { - FileDragResult.Started -> Unit - - is FileDragResult.Failed -> { - val message = when (result.error) { - FileDragError.FileNotFound -> getString(R.string.msg_file_tree_drag_file_not_found) - FileDragError.NotAFile -> getString(R.string.msg_file_tree_drag_not_a_file) - FileDragError.SystemRejected -> getString(R.string.msg_file_tree_drag_failed) - is FileDragError.Exception -> getString(R.string.msg_file_tree_drag_error) - } - flashError(message) - } - } - } - - @Suppress("unused", "UNUSED_PARAMETER") - @Subscribe(threadMode = MAIN) - fun onGetListFilesRequested(event: ListProjectFilesRequestEvent?) { - if (!isVisible || context == null) { - return - } - listProjectFiles() - } - - @Suppress("unused", "UNUSED_PARAMETER") - @Subscribe(threadMode = MAIN) - fun onPluginFilesChanged(event: PluginFilesChangedEvent) { - if (!isVisible || context == null) { - return - } - val root = binding?.root ?: return - // Debounce bursts of writes into a single refresh. - root.removeCallbacks(pluginRefreshRunnable) - root.postDelayed(pluginRefreshRunnable, PLUGIN_REFRESH_DEBOUNCE_MS) - } - - @Suppress("unused") - @Subscribe(threadMode = MAIN) - fun onGetExpandTreeNodeRequest(event: ExpandTreeNodeRequestEvent) { - if (!isVisible || context == null) { - return - } else { - event.node - } - expandNode(event.node) - } - - @Suppress("unused") - @Subscribe(threadMode = MAIN) - fun onGetCollapseTreeNodeRequest(event: CollapseTreeNodeRequestEvent) { - if (!isVisible || context == null) { - return - } else { - event.node - } - collapseNode(event.node, event.includeSubnodes) - - setLoading(event.node) - listNode(event.node) { expandNode(event.node) } - } - - fun listProjectFiles() { - if (binding == null) { - // Fragment has been destroyed - return - } - val projectDirPath = IProjectManager.getInstance().projectDirPath - val projectDir = File(projectDirPath) - val rootNode = TreeNode(File("")) - rootNode.viewHolder = FileTreeViewHolder(requireContext(), externalDropHandler) - - val projectRoot = TreeNode.root(projectDir) - projectRoot.viewHolder = FileTreeViewHolder(context, externalDropHandler) - rootNode.addChild(projectRoot, false) - treeRoot = rootNode - - binding!!.horizontalCroll.visibility = View.GONE - binding!!.horizontalCroll.visibility = View.VISIBLE - executeAsync(FileTreeCallable(context, projectRoot, projectDir, externalDropHandler)) { - if (binding == null) { - // Fragment has been destroyed - return@executeAsync - } - binding!!.horizontalCroll.visibility = View.VISIBLE - binding!!.loading.visibility = View.GONE - val tree = createTreeView(rootNode) - if (tree != null) { - tree.setUseAutoToggle(false) - tree.setDefaultNodeClickListener(this@FileTreeFragment) - tree.setDefaultNodeLongClickListener(this@FileTreeFragment) - binding!!.horizontalCroll.removeAllViews() - val view = tree.view - binding!!.horizontalCroll.addView(view) - dropController.bindRootTarget(binding!!.horizontalCroll, projectDir) - - view.post { tryRestoreState(rootNode) } - } - } - } - - /** - * Re-lists expanded folders whose on-disk contents changed, so plugin-created - * files appear without a manual refresh. - */ - fun refreshExpandedNodes() { - if (binding == null || context == null) return - - treeRoot?.let(::refreshChangedDirectories) - } - - private fun refreshChangedDirectories(node: TreeNode) { - node.children.forEach { child -> - val file = child.value ?: return@forEach - - if (!file.isDirectory || !child.isExpanded) { - return@forEach - } - - if (!hasDirectoryContentsChanged(child)) { - refreshChangedDirectories(child) - return@forEach - } - - val expandedPaths = hashSetOf() - collectExpandedPaths(child, expandedPaths) - relistPreservingExpansion(child, expandedPaths) - } - } - - private fun collectExpandedPaths(node: TreeNode, into: MutableSet) { - for (child in node.children) { - val file = child.value ?: continue - if (file.isDirectory && child.isExpanded) { - into.add(file.absolutePath) - collectExpandedPaths(child, into) - } - } - } - - private fun relistPreservingExpansion(node: TreeNode, expandedPaths: Set) { - listNode(node) { - expandNode(node, false) - for (child in node.children) { - val file = child.value ?: continue - if (file.isDirectory && file.absolutePath in expandedPaths) { - relistPreservingExpansion(child, expandedPaths) - } - } - } - } - - private fun hasDirectoryContentsChanged(node: TreeNode): Boolean { - val displayed = node.children.mapNotNull { it.value?.name }.toHashSet() - val files = node.value.listFiles() ?: return displayed.isNotEmpty() - if (files.size != displayed.size) { - return true - } - - return files.any { it.name !in displayed } - } - - private fun createTreeView(node: TreeNode): AndroidTreeView? { - return if (context == null) { - null - } else AndroidTreeView(context, node, drawable.bg_ripple).also { - fileTreeView = it - it.setDefaultNodeDragListener(this) - } - } - - private fun tryRestoreState(rootNode: TreeNode, state: String? = viewModel.savedState) { - if (!TextUtils.isEmpty(state) && fileTreeView != null) { - fileTreeView!!.collapseAll() - val openNodes = - state!!.split(AndroidTreeView.NODES_PATH_SEPARATOR.toRegex()) - .dropLastWhile { it.isEmpty() } - restoreNodeState(rootNode, HashSet(openNodes)) - } - - if (rootNode.children.isNotEmpty()) { - rootNode.childAt(0)?.let { projectRoot -> expandNode(projectRoot, false) } - } - } - - private fun restoreNodeState(root: TreeNode, openNodes: Set) { - for (node in root.children) { - if (openNodes.contains(node.path)) { - listNode(node) { - expandNode(node, false) - restoreNodeState(node, openNodes) - } - } - } - } - - private fun onExternalDropCompleted( - targetNode: TreeNode?, - targetFile: File, - importedCount: Int - ) { - refreshNodeAfterDrop(targetNode, targetFile) - flashSuccess( - if (importedCount == 1) { - getString(R.string.msg_file_tree_drop_imported_single) - } else { - getString(R.string.msg_file_tree_drop_imported_multiple, importedCount) - } - ) - } - - private fun refreshNodeAfterDrop(targetNode: TreeNode?, targetFile: File) { - if (targetNode == null) { - listProjectFiles() - return - } - - if (targetFile.isDirectory) { - setLoading(targetNode) - listNode(targetNode) { expandNode(targetNode) } - return - } - - val parentNode = targetNode.parent - if (parentNode?.value?.isDirectory == true) { - setLoading(parentNode) - listNode(parentNode) { expandNode(parentNode) } - } else { - listProjectFiles() - } - } - - companion object { - - // Should be same as defined in layout/activity_layouteditor.xml - const val TAG = "editor.fileTree" - - // Debounce window for coalescing write bursts into one refresh. - private const val PLUGIN_REFRESH_DEBOUNCE_MS = 250L - - @JvmStatic - fun newInstance(): FileTreeFragment { - return FileTreeFragment() - } - } -} +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ +package com.itsaky.androidide.fragments.sidebar + +import android.content.Context +import android.os.Bundle +import android.text.TextUtils +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.WindowInsetsCompat.Type.statusBars +import androidx.core.view.updatePadding +import androidx.fragment.app.viewModels +import androidx.transition.ChangeBounds +import androidx.transition.TransitionManager +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.itsaky.androidide.R +import com.itsaky.androidide.adapters.viewholders.FileTreeViewHolder +import com.itsaky.androidide.databinding.LayoutEditorFileTreeBinding +import com.itsaky.androidide.dnd.FileDragError +import com.itsaky.androidide.dnd.FileDragResult +import com.itsaky.androidide.dnd.FileDragStarter +import com.itsaky.androidide.eventbus.events.filetree.FileClickEvent +import com.itsaky.androidide.eventbus.events.filetree.FileLongClickEvent +import com.itsaky.androidide.eventbus.events.filetree.PluginFilesChangedEvent +import com.itsaky.androidide.events.CollapseTreeNodeRequestEvent +import com.itsaky.androidide.events.ExpandTreeNodeRequestEvent +import com.itsaky.androidide.events.ListProjectFilesRequestEvent +import com.itsaky.androidide.projects.IProjectManager +import com.itsaky.androidide.resources.R.drawable +import com.itsaky.androidide.tasks.TaskExecutor.executeAsync +import com.itsaky.androidide.tasks.callables.FileTreeCallable +import com.itsaky.androidide.tasks.callables.FileTreeCallable.SortFileName +import com.itsaky.androidide.tasks.callables.FileTreeCallable.SortFolder +import com.itsaky.androidide.utils.doOnApplyWindowInsets +import com.itsaky.androidide.utils.dpToPx +import com.itsaky.androidide.utils.flashError +import com.itsaky.androidide.utils.flashSuccess +import com.itsaky.androidide.viewmodel.FileTreeViewModel +import com.unnamed.b.atv.model.TreeNode +import com.unnamed.b.atv.model.TreeNode.TreeNodeClickListener +import com.unnamed.b.atv.model.TreeNode.TreeNodeLongClickListener +import com.unnamed.b.atv.view.AndroidTreeView +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode.MAIN +import java.io.File +import java.util.Arrays + +class FileTreeFragment : + BottomSheetDialogFragment(), + TreeNodeClickListener, + TreeNodeLongClickListener, + TreeNode.TreeNodeDragListener { + private var binding: LayoutEditorFileTreeBinding? = null + private var fileTreeView: AndroidTreeView? = null + + // Root of the current tree; used to walk expanded nodes on refresh. + private var treeRoot: TreeNode? = null + + private val pluginRefreshRunnable = + Runnable { + if (isVisible && context != null) { + refreshExpandedNodes() + } + } + + private val viewModel by viewModels(ownerProducer = { requireActivity() }) + private val fileDragStarter by lazy(LazyThreadSafetyMode.NONE) { + FileDragStarter(requireContext()) + } + + @Suppress("ktlint:standard:backing-property-naming") + private var _dropController: FileTreeDropController? = null + private val dropController: FileTreeDropController + get() { + if (_dropController == null) { + _dropController = + FileTreeDropController( + activity = requireActivity(), + onDropCompleted = ::onExternalDropCompleted, + onDropFailed = ::flashError, + ) + } + return _dropController!! + } + + private val externalDropHandler: FileTreeViewHolder.ExternalDropHandler + get() = dropController.nodeBinder + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + if (!EventBus.getDefault().isRegistered(this)) { + EventBus.getDefault().register(this) + } + + binding = LayoutEditorFileTreeBinding.inflate(inflater, container, false) + binding?.root?.doOnApplyWindowInsets { view, insets, _, _ -> + insets + .getInsets(statusBars()) + .apply { view.updatePadding(top = top + view.context.dpToPx(8f)) } + } + return binding!!.root + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + listProjectFiles() + } + + override fun onDestroyView() { + super.onDestroyView() + EventBus.getDefault().unregister(this) + + binding?.root?.removeCallbacks(pluginRefreshRunnable) + saveTreeState() + + binding = null + fileTreeView = null + treeRoot = null + _dropController = null + } + + fun saveTreeState() { + viewModel.saveState(fileTreeView) + } + + override fun onClick( + node: TreeNode, + value: Any, + ) { + val file = value as File + if (!file.exists()) { + return + } + if (file.isDirectory) { + if (node.isExpanded) { + collapseNode(node) + } else { + setLoading(node) + listNode(node) { expandNode(node) } + } + } + val event = FileClickEvent(file) + event.put(Context::class.java, requireContext()) + EventBus.getDefault().post(event) + } + + private fun updateChevron(node: TreeNode) { + if (node.viewHolder is FileTreeViewHolder) { + (node.viewHolder as FileTreeViewHolder).updateChevron(node.isExpanded) + } + } + + private fun expandNode( + node: TreeNode, + animate: Boolean = true, + ) { + if (fileTreeView == null) { + return + } + if (animate) { + TransitionManager.beginDelayedTransition(binding!!.root, ChangeBounds()) + } + fileTreeView!!.expandNode(node) + updateChevron(node) + } + + private fun collapseNode( + node: TreeNode, + animate: Boolean = true, + includeSubnodes: Boolean = false, + ) { + if (fileTreeView == null) { + return + } + if (animate) { + TransitionManager.beginDelayedTransition(binding!!.root, ChangeBounds()) + } + fileTreeView!!.collapseNode(node, includeSubnodes) + updateChevron(node) + } + + private fun setLoading(node: TreeNode) { + if (node.viewHolder is FileTreeViewHolder) { + (node.viewHolder as FileTreeViewHolder).setLoading(true) + } + } + + private fun listNode( + node: TreeNode, + onListed: () -> Unit, + ) { + val safeContext = context ?: return + val safeDropHandler = externalDropHandler + + node.children.clear() + node.isExpanded = false + executeAsync({ + listFilesForNode( + node.value.listFiles() ?: return@executeAsync null, + node, + safeContext, + safeDropHandler, + ) + var temp = node + while (temp.size() == 1) { + temp = temp.childAt(0) + if (!temp.value.isDirectory) { + break + } + listFilesForNode( + temp.value.listFiles() ?: continue, + temp, + safeContext, + safeDropHandler, + ) + temp.isExpanded = true + } + null + }) { + onListed() + } + } + + private fun listFilesForNode( + files: Array, + parent: TreeNode, + context: Context, + dropHandler: FileTreeViewHolder.ExternalDropHandler, + ) { + Arrays.sort(files, SortFileName()) + Arrays.sort(files, SortFolder()) + for (file in files) { + parent.addChild(createFileNode(file, context, dropHandler), false) + } + } + + private fun createFileNode( + file: File, + context: Context, + dropHandler: FileTreeViewHolder.ExternalDropHandler, + ): TreeNode = + TreeNode(file).apply { + viewHolder = FileTreeViewHolder(context, dropHandler) + } + + override fun onLongClick( + node: TreeNode, + value: Any, + ): Boolean { + val event = FileLongClickEvent((value as File)) + event.put(Context::class.java, requireContext()) + event.put(TreeNode::class.java, node) + EventBus.getDefault().post(event) + return true + } + + override fun onStartDrag( + node: TreeNode, + value: Any, + ) { + val file = value as? File ?: return + val sourceView = node.viewHolder?.view ?: return + + when (val result = fileDragStarter.startDrag(sourceView, file)) { + FileDragResult.Started -> { + Unit + } + + is FileDragResult.Failed -> { + val message = + when (result.error) { + FileDragError.FileNotFound -> getString(R.string.msg_file_tree_drag_file_not_found) + FileDragError.NotAFile -> getString(R.string.msg_file_tree_drag_not_a_file) + FileDragError.SystemRejected -> getString(R.string.msg_file_tree_drag_failed) + is FileDragError.Exception -> getString(R.string.msg_file_tree_drag_error) + } + flashError(message) + } + } + } + + @Suppress("unused", "UNUSED_PARAMETER") + @Subscribe(threadMode = MAIN) + fun onGetListFilesRequested(event: ListProjectFilesRequestEvent?) { + if (!isVisible || context == null) { + return + } + listProjectFiles() + } + + @Suppress("unused", "UNUSED_PARAMETER") + @Subscribe(threadMode = MAIN) + fun onPluginFilesChanged(event: PluginFilesChangedEvent) { + if (!isVisible || context == null) { + return + } + val root = binding?.root ?: return + // Debounce bursts of writes into a single refresh. + root.removeCallbacks(pluginRefreshRunnable) + root.postDelayed(pluginRefreshRunnable, PLUGIN_REFRESH_DEBOUNCE_MS) + } + + @Suppress("unused") + @Subscribe(threadMode = MAIN) + fun onGetExpandTreeNodeRequest(event: ExpandTreeNodeRequestEvent) { + if (!isVisible || context == null) { + return + } else { + event.node + } + expandNode(event.node) + } + + @Suppress("unused") + @Subscribe(threadMode = MAIN) + fun onGetCollapseTreeNodeRequest(event: CollapseTreeNodeRequestEvent) { + if (!isVisible || context == null) { + return + } else { + event.node + } + collapseNode(event.node, event.includeSubnodes) + + setLoading(event.node) + listNode(event.node) { expandNode(event.node) } + } + + fun listProjectFiles() { + if (binding == null) { + // Fragment has been destroyed + return + } + val projectDirPath = IProjectManager.getInstance().projectDirPath + val projectDir = File(projectDirPath) + val rootNode = TreeNode(File("")) + rootNode.viewHolder = FileTreeViewHolder(requireContext(), externalDropHandler) + + val projectRoot = TreeNode.root(projectDir) + projectRoot.viewHolder = FileTreeViewHolder(context, externalDropHandler) + rootNode.addChild(projectRoot, false) + treeRoot = rootNode + + binding!!.horizontalCroll.visibility = View.GONE + binding!!.horizontalCroll.visibility = View.VISIBLE + executeAsync(FileTreeCallable(context, projectRoot, projectDir, externalDropHandler)) { + if (binding == null) { + // Fragment has been destroyed + return@executeAsync + } + binding!!.horizontalCroll.visibility = View.VISIBLE + binding!!.loading.visibility = View.GONE + val tree = createTreeView(rootNode) + if (tree != null) { + tree.setUseAutoToggle(false) + tree.setDefaultNodeClickListener(this@FileTreeFragment) + tree.setDefaultNodeLongClickListener(this@FileTreeFragment) + binding!!.horizontalCroll.removeAllViews() + val view = tree.view + binding!!.horizontalCroll.addView(view) + dropController.bindRootTarget(binding!!.horizontalCroll, projectDir) + + view.post { tryRestoreState(rootNode) } + } + } + } + + /** + * Re-lists expanded folders whose on-disk contents changed, so plugin-created + * files appear without a manual refresh. + */ + fun refreshExpandedNodes() { + if (binding == null || context == null) return + + treeRoot?.let(::refreshChangedDirectories) + } + + private fun refreshChangedDirectories(node: TreeNode) { + node.children.forEach { child -> + val file = child.value ?: return@forEach + + if (!file.isDirectory || !child.isExpanded) { + return@forEach + } + + if (!hasDirectoryContentsChanged(child)) { + refreshChangedDirectories(child) + return@forEach + } + + val expandedPaths = hashSetOf() + collectExpandedPaths(child, expandedPaths) + relistPreservingExpansion(child, expandedPaths) + } + } + + private fun collectExpandedPaths( + node: TreeNode, + into: MutableSet, + ) { + for (child in node.children) { + val file = child.value ?: continue + if (file.isDirectory && child.isExpanded) { + into.add(file.absolutePath) + collectExpandedPaths(child, into) + } + } + } + + private fun relistPreservingExpansion( + node: TreeNode, + expandedPaths: Set, + ) { + listNode(node) { + expandNode(node, false) + for (child in node.children) { + val file = child.value ?: continue + if (file.isDirectory && file.absolutePath in expandedPaths) { + relistPreservingExpansion(child, expandedPaths) + } + } + } + } + + private fun hasDirectoryContentsChanged(node: TreeNode): Boolean { + val displayed = node.children.mapNotNull { it.value?.name }.toHashSet() + val files = node.value.listFiles() ?: return displayed.isNotEmpty() + if (files.size != displayed.size) { + return true + } + + return files.any { it.name !in displayed } + } + + private fun createTreeView(node: TreeNode): AndroidTreeView? = + if (context == null) { + null + } else { + AndroidTreeView(context, node, drawable.bg_ripple).also { + fileTreeView = it + it.setDefaultNodeDragListener(this) + } + } + + private fun tryRestoreState( + rootNode: TreeNode, + state: String? = viewModel.savedState, + ) { + if (!TextUtils.isEmpty(state) && fileTreeView != null) { + fileTreeView!!.collapseAll() + val openNodes = + state!! + .split(AndroidTreeView.NODES_PATH_SEPARATOR.toRegex()) + .dropLastWhile { it.isEmpty() } + restoreNodeState(rootNode, HashSet(openNodes)) + } + + if (rootNode.children.isNotEmpty()) { + rootNode.childAt(0)?.let { projectRoot -> expandNode(projectRoot, false) } + } + } + + private fun restoreNodeState( + root: TreeNode, + openNodes: Set, + ) { + for (node in root.children) { + if (openNodes.contains(node.path)) { + listNode(node) { + expandNode(node, false) + restoreNodeState(node, openNodes) + } + } + } + } + + private fun onExternalDropCompleted( + targetNode: TreeNode?, + targetFile: File, + importedCount: Int, + ) { + refreshNodeAfterDrop(targetNode, targetFile) + flashSuccess( + if (importedCount == 1) { + getString(R.string.msg_file_tree_drop_imported_single) + } else { + getString(R.string.msg_file_tree_drop_imported_multiple, importedCount) + }, + ) + } + + private fun refreshNodeAfterDrop( + targetNode: TreeNode?, + targetFile: File, + ) { + if (targetNode == null) { + listProjectFiles() + return + } + + if (targetFile.isDirectory) { + setLoading(targetNode) + listNode(targetNode) { expandNode(targetNode) } + return + } + + val parentNode = targetNode.parent + if (parentNode?.value?.isDirectory == true) { + setLoading(parentNode) + listNode(parentNode) { expandNode(parentNode) } + } else { + listProjectFiles() + } + } + + companion object { + // Should be same as defined in layout/activity_layouteditor.xml + const val TAG = "editor.fileTree" + + // Debounce window for coalescing write bursts into one refresh. + private const val PLUGIN_REFRESH_DEBOUNCE_MS = 250L + + @JvmStatic + fun newInstance(): FileTreeFragment = FileTreeFragment() + } +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt b/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt index e9ae18e221..eeb2f573e9 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt @@ -30,7 +30,6 @@ import android.view.LayoutInflater import android.view.MotionEvent import androidx.appcompat.widget.LinearLayoutCompat import androidx.core.view.isVisible -import com.blankj.utilcode.util.SizeUtils import com.itsaky.androidide.activities.editor.BaseEditorActivity import com.itsaky.androidide.editor.api.IEditor import com.itsaky.androidide.editor.databinding.LayoutCodeEditorBinding @@ -57,6 +56,7 @@ import com.itsaky.androidide.syntax.colorschemes.SchemeAndroidIDE import com.itsaky.androidide.tasks.cancelIfActive import com.itsaky.androidide.tasks.runOnUiThread import com.itsaky.androidide.utils.customOrJBMono +import com.itsaky.androidide.utils.dpToPx import io.github.rosemoe.sora.event.ClickEvent import io.github.rosemoe.sora.event.InterceptTarget import io.github.rosemoe.sora.event.TextSizeChangeEvent @@ -160,7 +160,7 @@ class CodeEditorView( binding.editor.apply { isHighlightCurrentBlock = true - dividerWidth = SizeUtils.dp2px(2f).toFloat() + dividerWidth = context.dpToPx(2f).toFloat() colorScheme = SchemeAndroidIDE.newInstance(context) lineSeparator = LineSeparator.LF @@ -209,13 +209,14 @@ class CodeEditorView( subscribeEvent(TextSizeChangeEvent::class.java) { event, _ -> val metrics = context.resources.displayMetrics - val newFontSize = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - TypedValue.deriveDimension(COMPLEX_UNIT_SP, event.newTextSize, metrics) - } else { - @Suppress("DEPRECATION") - event.newTextSize / metrics.scaledDensity - } - + val newFontSize = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + TypedValue.deriveDimension(COMPLEX_UNIT_SP, event.newTextSize, metrics) + } else { + @Suppress("DEPRECATION") + event.newTextSize / metrics.scaledDensity + } + val currentFontSize = EditorPreferences.fontSize val diff = abs(newFontSize - currentFontSize) if (newFontSize in MIN_FONT_SIZE..MAX_FONT_SIZE && diff > 0.01f) { @@ -252,12 +253,13 @@ class CodeEditorView( true } - _searchLayout = EditorSearchLayout(context, binding.editor).apply { - onSearchModeChanged = { isActive -> - (this@CodeEditorView.context as? BaseEditorActivity) - ?.setEditorSearchModeActive(isActive) + _searchLayout = + EditorSearchLayout(context, binding.editor).apply { + onSearchModeChanged = { isActive -> + (this@CodeEditorView.context as? BaseEditorActivity) + ?.setEditorSearchModeActive(isActive) + } } - } orientation = VERTICAL removeAllViews() @@ -429,9 +431,10 @@ class CodeEditorView( updateReadWriteProgress(0) if (file.extension.lowercase() in ARCHIVE_EXTENSIONS) { - val listing = withContext(readWriteContext) { - generateArchiveListing(file) - } + val listing = + withContext(readWriteContext) { + generateArchiveListing(file) + } initializeArchiveContent(listing, file) _binding?.rwProgress?.isVisible = false } else { @@ -477,12 +480,16 @@ class CodeEditorView( } } - private fun initializeArchiveContent(listing: String, file: File) { + private fun initializeArchiveContent( + listing: String, + file: File, + ) { val ideEditor = binding.editor ideEditor.postInLifecycle { - val args = Bundle().apply { - putString(IEditor.KEY_FILE, file.absolutePath) - } + val args = + Bundle().apply { + putString(IEditor.KEY_FILE, file.absolutePath) + } ideEditor.setText(Content(listing), args) markUnmodified() ideEditor.isEditable = false @@ -494,7 +501,9 @@ class CodeEditorView( private fun generateArchiveListing(file: File): String { val builder = StringBuilder() - val dateFormatter = java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + val dateFormatter = + java.time.format.DateTimeFormatter + .ofPattern("yyyy-MM-dd HH:mm:ss") val zone = java.time.ZoneId.systemDefault() try { java.util.zip.ZipFile(file).use { zip -> @@ -504,8 +513,15 @@ class CodeEditorView( builder.appendLine("Entries: ${entries.size}") builder.appendLine() builder.appendLine( - String.format("%-10s %-10s %-6s %-8s %-20s %s", - "Length", "Compressed", "Method", "CRC-32", "Date & Time", "Name") + String.format( + "%-10s %-10s %-6s %-8s %-20s %s", + "Length", + "Compressed", + "Method", + "CRC-32", + "Date & Time", + "Name", + ), ) builder.appendLine("-".repeat(90)) @@ -521,26 +537,42 @@ class CodeEditorView( val crc = if (entry.crc >= 0) String.format("%08x", entry.crc) else "-" val sizeStr = if (sizeKnown) entry.size.toString() else "-" val compressedStr = if (compressedKnown) entry.compressedSize.toString() else "-" - val time = if (entry.time > 0) { - val instant = java.time.Instant.ofEpochMilli(entry.time) - val dt = java.time.LocalDateTime.ofInstant(instant, zone) - dt.format(dateFormatter) - } else { - "----" - } + val time = + if (entry.time > 0) { + val instant = java.time.Instant.ofEpochMilli(entry.time) + val dt = java.time.LocalDateTime.ofInstant(instant, zone) + dt.format(dateFormatter) + } else { + "----" + } builder.appendLine( - String.format("%-10s %-10s %-6s %-8s %-20s %s", - sizeStr, compressedStr, method, crc, time, entry.name) + String.format( + "%-10s %-10s %-6s %-8s %-20s %s", + sizeStr, + compressedStr, + method, + crc, + time, + entry.name, + ), ) } builder.appendLine("-".repeat(90)) - val ratio = if (totalSize > 0) { - "%.1f%%".format((1.0 - totalCompressed.toDouble() / totalSize) * 100) - } else "0.0%" + val ratio = + if (totalSize > 0) { + "%.1f%%".format((1.0 - totalCompressed.toDouble() / totalSize) * 100) + } else { + "0.0%" + } builder.appendLine( - String.format("%-10d %-10d %-6s %s", - totalSize, totalCompressed, ratio, "${entries.size} files") + String.format( + "%-10d %-10d %-6s %s", + totalSize, + totalCompressed, + ratio, + "${entries.size} files", + ), ) } } catch (e: Exception) { @@ -713,6 +745,7 @@ class CodeEditorView( when (event.key) { EditorPreferences.FONT_SIZE -> onFontSizePrefChanged() + EditorPreferences.FONT_LIGATURES -> onFontLigaturesPrefChanged() EditorPreferences.FLAG_LINE_BREAK, @@ -723,13 +756,21 @@ class CodeEditorView( -> onPrintingFlagsPrefChanged() EditorPreferences.FLAG_PASSWORD -> onInputTypePrefChanged() + EditorPreferences.WORD_WRAP -> onWordwrapPrefChanged() + EditorPreferences.USE_MAGNIFER -> onMagnifierPrefChanged() + EditorPreferences.USE_ICU -> onUseIcuPrefChanged() + EditorPreferences.USE_CUSTOM_FONT -> onCustomFontPrefChanged() + EditorPreferences.DELETE_EMPTY_LINES -> onDeleteEmptyLinesPrefChanged() + EditorPreferences.DELETE_TABS_ON_BACKSPACE -> onDeleteTabsPrefChanged() + EditorPreferences.STICKY_SCROLL_ENABLED -> onStickyScrollEnabeldPrefChanged() + EditorPreferences.PIN_LINE_NUMBERS -> onPinLineNumbersPrefChanged() } } @@ -767,13 +808,16 @@ class CodeEditorView( private fun changeFontSizeBy(delta: Float) { val current = EditorPreferences.fontSize val newSize = computeNewEditorFontSize(current, delta) - if (newSize == current) return - binding.editor.setTextSize(newSize) - EditorPreferences.fontSize = newSize + if (newSize == current) return + binding.editor.setTextSize(newSize) + EditorPreferences.fontSize = newSize } } -internal fun computeNewEditorFontSize(current: Float, delta: Float): Float { +internal fun computeNewEditorFontSize( + current: Float, + delta: Float, +): Float { val base = if (current < MIN_FONT_SIZE || current > MAX_FONT_SIZE) { DEFAULT_FONT_SIZE diff --git a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt index 8358a11aa8..5b08d4fdbd 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt @@ -43,7 +43,6 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.transition.TransitionManager import com.blankj.utilcode.util.KeyboardUtils -import com.blankj.utilcode.util.SizeUtils import com.blankj.utilcode.util.ThreadUtils.runOnUiThread import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.tabs.TabLayout.OnTabSelectedListener @@ -65,6 +64,7 @@ import com.itsaky.androidide.resources.R.string import com.itsaky.androidide.utils.DiagnosticsFormatter import com.itsaky.androidide.utils.IntentUtils.shareFile import com.itsaky.androidide.utils.Symbols.forFile +import com.itsaky.androidide.utils.dpToPx import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.viewmodel.ApkInstallationViewModel @@ -395,7 +395,7 @@ class EditorBottomSheet object : ViewTreeObserver.OnGlobalLayoutListener { override fun onGlobalLayout() { view.viewTreeObserver.removeOnGlobalLayoutListener(this) - anchorOffset = view.height + SizeUtils.dp2px(1f) + anchorOffset = view.height + view.context.dpToPx(1f) behavior.peekHeight = collapsedHeight.roundToInt() behavior.expandedOffset = anchorOffset diff --git a/app/src/main/java/com/itsaky/androidide/utils/WindowInsetsExtensions.kt b/app/src/main/java/com/itsaky/androidide/utils/WindowInsetsExtensions.kt index e3dd2bfc7a..93ad82081d 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/WindowInsetsExtensions.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/WindowInsetsExtensions.kt @@ -2,30 +2,33 @@ package com.itsaky.androidide.utils import android.content.res.Configuration import android.view.View +import android.view.ViewGroup +import androidx.core.graphics.Insets import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.doOnAttach +import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import com.itsaky.androidide.R import com.itsaky.androidide.databinding.ContentEditorBinding -import com.blankj.utilcode.util.SizeUtils -import androidx.core.graphics.Insets -import android.view.ViewGroup -import androidx.core.view.updateLayoutParams -data class InitialPadding(val left: Int, val top: Int, val right: Int, val bottom: Int) +data class InitialPadding( + val left: Int, + val top: Int, + val right: Int, + val bottom: Int, +) /** * Gets or stores the view's original padding to prevent infinite accumulation when applying insets. * * @return The original [InitialPadding]. */ -fun View.getOrStoreInitialPadding(): InitialPadding { - return (getTag(R.id.tag_initial_padding) as? InitialPadding) - ?: InitialPadding(paddingLeft, paddingTop, paddingRight, paddingBottom).also { - setTag(R.id.tag_initial_padding, it) - } -} +fun View.getOrStoreInitialPadding(): InitialPadding = + (getTag(R.id.tag_initial_padding) as? InitialPadding) + ?: InitialPadding(paddingLeft, paddingTop, paddingRight, paddingBottom).also { + setTag(R.id.tag_initial_padding, it) + } /** * Applies top window insets responsively. Hides the AppBar in landscape mode and adjusts [appbarContent]. @@ -34,16 +37,16 @@ fun View.getOrStoreInitialPadding(): InitialPadding { * @param appbarContent The inner content view to pad in landscape mode. */ fun View.applyResponsiveAppBarInsets(appbarContent: View) { - ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInsets -> - val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInsets -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - view.updatePadding(top = insets.top) - appbarContent.updatePadding(top = 0) + view.updatePadding(top = insets.top) + appbarContent.updatePadding(top = 0) - windowInsets - } + windowInsets + } - doOnAttach { it.requestApplyInsets() } + doOnAttach { it.requestApplyInsets() } } /** @@ -56,36 +59,39 @@ fun View.applyResponsiveAppBarInsets(appbarContent: View) { * @param applyBottom Apply bottom inset. */ fun View.applyRootSystemInsetsAsPadding( - applyLeft: Boolean = false, - applyTop: Boolean = false, - applyRight: Boolean = false, - applyBottom: Boolean = false + applyLeft: Boolean = false, + applyTop: Boolean = false, + applyRight: Boolean = false, + applyBottom: Boolean = false, ) { - val initial = getOrStoreInitialPadding() - - fun applyInsets(view: View, windowInsets: WindowInsetsCompat) { - val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - - view.updatePadding( - left = initial.left + if (applyLeft) insets.left else 0, - top = initial.top + if (applyTop) insets.top else 0, - right = initial.right + if (applyRight) insets.right else 0, - bottom = initial.bottom + if (applyBottom) insets.bottom else 0 - ) - } - - ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInsets -> - applyInsets(view, windowInsets) - windowInsets - } - - doOnAttach { view -> - ViewCompat.requestApplyInsets(view) - - ViewCompat.getRootWindowInsets(view)?.let { rootInsets -> - applyInsets(view, rootInsets) - } - } + val initial = getOrStoreInitialPadding() + + fun applyInsets( + view: View, + windowInsets: WindowInsetsCompat, + ) { + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + + view.updatePadding( + left = initial.left + if (applyLeft) insets.left else 0, + top = initial.top + if (applyTop) insets.top else 0, + right = initial.right + if (applyRight) insets.right else 0, + bottom = initial.bottom + if (applyBottom) insets.bottom else 0, + ) + } + + ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInsets -> + applyInsets(view, windowInsets) + windowInsets + } + + doOnAttach { view -> + ViewCompat.requestApplyInsets(view) + + ViewCompat.getRootWindowInsets(view)?.let { rootInsets -> + applyInsets(view, rootInsets) + } + } } /** @@ -94,17 +100,17 @@ fun View.applyRootSystemInsetsAsPadding( * Keeps toggle buttons and bottom sheet aligned with system bars (status/nav). */ fun ContentEditorBinding.applyImmersiveModeInsets(systemBars: Insets) { - val baseMargin = SizeUtils.dp2px(16f) - val isRtl = root.layoutDirection == View.LAYOUT_DIRECTION_RTL + val baseMargin = root.context.dpToPx(16f) + val isRtl = root.layoutDirection == View.LAYOUT_DIRECTION_RTL - val startInset = if (isRtl) systemBars.right else systemBars.left + val startInset = if (isRtl) systemBars.right else systemBars.left - btnFullscreenToggle.updateLayoutParams { - bottomMargin = baseMargin + systemBars.bottom - marginStart = baseMargin + startInset - } + btnFullscreenToggle.updateLayoutParams { + bottomMargin = baseMargin + systemBars.bottom + marginStart = baseMargin + startInset + } - bottomSheet.updatePadding(top = systemBars.top) + bottomSheet.updatePadding(top = systemBars.top) } /** @@ -112,11 +118,11 @@ fun ContentEditorBinding.applyImmersiveModeInsets(systemBars: Insets) { * fully in landscape to save vertical screen real estate. */ fun ContentEditorBinding.applyBottomSheetAnchorForOrientation(orientation: Int) { - root.post { - if (orientation == Configuration.ORIENTATION_LANDSCAPE) { - bottomSheet.resetOffsetAnchor() - } else { - bottomSheet.setOffsetAnchor(editorAppBarLayout) - } - } + root.post { + if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + bottomSheet.resetOffsetAnchor() + } else { + bottomSheet.setOffsetAnchor(editorAppBarLayout) + } + } } diff --git a/common-ui/src/main/java/com/itsaky/androidide/FabPositionCalculator.kt b/common-ui/src/main/java/com/itsaky/androidide/FabPositionCalculator.kt index 0904ad7ff3..2e8df75345 100644 --- a/common-ui/src/main/java/com/itsaky/androidide/FabPositionCalculator.kt +++ b/common-ui/src/main/java/com/itsaky/androidide/FabPositionCalculator.kt @@ -5,104 +5,118 @@ import android.view.View import android.view.ViewGroup import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat -import com.blankj.utilcode.util.SizeUtils import com.google.android.material.floatingactionbutton.FloatingActionButton +import com.itsaky.androidide.utils.dpToPx internal class FabPositionCalculator { - - /** - * Calculate safe bounds for FAB positioning, accounting for system UI elements. - * Returns a Rect with the safe dragging area (left, top, right, bottom). - */ - fun getSafeDraggingBounds(parentView: ViewGroup, fabView: FloatingActionButton): Rect { - val defaultMargin = SizeUtils.dp2px(16f) - val margins = resolvePhysicalMargins(parentView, fabView, defaultMargin) - - val insets = ViewCompat.getRootWindowInsets(parentView) - ?.getInsets(WindowInsetsCompat.Type.systemBars()) - - val insetLeft = insets?.left ?: 0 - val insetTop = insets?.top ?: 0 - val insetRight = insets?.right ?: 0 - val insetBottom = insets?.bottom ?: 0 - - return Rect( - insetLeft + margins.left, - insetTop + margins.top, - (parentView.width - fabView.width - insetRight - margins.right) - .coerceAtLeast(insetLeft + margins.left), - (parentView.height - fabView.height - insetBottom - margins.bottom) - .coerceAtLeast(insetTop + margins.top) - ) - } - - /** - * Validates if the given position is within safe bounds. - * If not, clamps it to the nearest valid position within the safe area. - */ - fun validateAndCorrectPosition( - x: Float, - y: Float, - parentView: ViewGroup, - fabView: FloatingActionButton - ): Pair { - val safeBounds = getSafeDraggingBounds(parentView, fabView) - - val correctedX = x.coerceIn(safeBounds.left.toFloat(), safeBounds.right.toFloat()) - val correctedY = y.coerceIn(safeBounds.top.toFloat(), safeBounds.bottom.toFloat()) - - return correctedX to correctedY - } - - fun toRatio(value: Float, min: Int, availableSpace: Float): Float { - if (availableSpace > 0f) { - return ((value - min) / availableSpace).coerceIn(0f, 1f) - } - return 0f - } - - fun fromRatio(ratio: Float, min: Int, availableSpace: Float): Float { - if (availableSpace > 0f) { - return min + (availableSpace * ratio.coerceIn(0f, 1f)) - } - return min.toFloat() - } - - private fun resolvePhysicalMargins( - parentView: ViewGroup, - fabView: FloatingActionButton, - defaultMargin: Int - ): PhysicalMargins { - val layoutParams = fabView.layoutParams as? ViewGroup.MarginLayoutParams - ?: return PhysicalMargins( - left = defaultMargin, - top = defaultMargin, - right = defaultMargin, - bottom = defaultMargin - ) - - val isRtl = parentView.layoutDirection == View.LAYOUT_DIRECTION_RTL - val start = layoutParams.marginStart.takeIf { it >= 0 } - val end = layoutParams.marginEnd.takeIf { it >= 0 } - - val (resolvedLeft, resolvedRight) = if (isRtl) { - end to start - } else { - start to end - } - - return PhysicalMargins( - left = resolvedLeft ?: layoutParams.leftMargin, - top = layoutParams.topMargin, - right = resolvedRight ?: layoutParams.rightMargin, - bottom = layoutParams.bottomMargin - ) - } - - private data class PhysicalMargins( - val left: Int, - val top: Int, - val right: Int, - val bottom: Int - ) + /** + * Calculate safe bounds for FAB positioning, accounting for system UI elements. + * Returns a Rect with the safe dragging area (left, top, right, bottom). + */ + fun getSafeDraggingBounds( + parentView: ViewGroup, + fabView: FloatingActionButton, + ): Rect { + val defaultMargin = parentView.context.dpToPx(16f) + val margins = resolvePhysicalMargins(parentView, fabView, defaultMargin) + + val insets = + ViewCompat + .getRootWindowInsets(parentView) + ?.getInsets(WindowInsetsCompat.Type.systemBars()) + + val insetLeft = insets?.left ?: 0 + val insetTop = insets?.top ?: 0 + val insetRight = insets?.right ?: 0 + val insetBottom = insets?.bottom ?: 0 + + return Rect( + insetLeft + margins.left, + insetTop + margins.top, + (parentView.width - fabView.width - insetRight - margins.right) + .coerceAtLeast(insetLeft + margins.left), + (parentView.height - fabView.height - insetBottom - margins.bottom) + .coerceAtLeast(insetTop + margins.top), + ) + } + + /** + * Validates if the given position is within safe bounds. + * If not, clamps it to the nearest valid position within the safe area. + */ + fun validateAndCorrectPosition( + x: Float, + y: Float, + parentView: ViewGroup, + fabView: FloatingActionButton, + ): Pair { + val safeBounds = getSafeDraggingBounds(parentView, fabView) + + val correctedX = x.coerceIn(safeBounds.left.toFloat(), safeBounds.right.toFloat()) + val correctedY = y.coerceIn(safeBounds.top.toFloat(), safeBounds.bottom.toFloat()) + + return correctedX to correctedY + } + + fun toRatio( + value: Float, + min: Int, + availableSpace: Float, + ): Float { + if (availableSpace > 0f) { + return ((value - min) / availableSpace).coerceIn(0f, 1f) + } + return 0f + } + + fun fromRatio( + ratio: Float, + min: Int, + availableSpace: Float, + ): Float { + if (availableSpace > 0f) { + return min + (availableSpace * ratio.coerceIn(0f, 1f)) + } + return min.toFloat() + } + + private fun resolvePhysicalMargins( + parentView: ViewGroup, + fabView: FloatingActionButton, + defaultMargin: Int, + ): PhysicalMargins { + val layoutParams = + fabView.layoutParams as? ViewGroup.MarginLayoutParams + ?: return PhysicalMargins( + left = defaultMargin, + top = defaultMargin, + right = defaultMargin, + bottom = defaultMargin, + ) + + val isRtl = parentView.layoutDirection == View.LAYOUT_DIRECTION_RTL + val start = layoutParams.marginStart.takeIf { it >= 0 } + val end = layoutParams.marginEnd.takeIf { it >= 0 } + + val (resolvedLeft, resolvedRight) = + if (isRtl) { + end to start + } else { + start to end + } + + return PhysicalMargins( + left = resolvedLeft ?: layoutParams.leftMargin, + top = layoutParams.topMargin, + right = resolvedRight ?: layoutParams.rightMargin, + bottom = layoutParams.bottomMargin, + ) + } + + private data class PhysicalMargins( + val left: Int, + val top: Int, + val right: Int, + val bottom: Int, + ) } diff --git a/common/src/main/java/com/itsaky/androidide/utils/ContextUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/ContextUtils.kt index 840049cf95..0dad50bff7 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/ContextUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/ContextUtils.kt @@ -33,43 +33,53 @@ import kotlin.system.exitProcess * Check if the given accessibility service is enabled. */ inline fun Context.isAccessibilityEnabled(): Boolean { - try { - val enabled = Settings.Secure.getInt(contentResolver, Settings.Secure.ACCESSIBILITY_ENABLED) - if (enabled != 1) return false + try { + val enabled = Settings.Secure.getInt(contentResolver, Settings.Secure.ACCESSIBILITY_ENABLED) + if (enabled != 1) return false - val name = ComponentName(applicationContext, T::class.java) - val services = Settings.Secure.getString(contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES) - return services?.contains(name.flattenToString()) ?: false - } catch (e: Settings.SettingNotFoundException) { - return false - } + val name = ComponentName(applicationContext, T::class.java) + val services = Settings.Secure.getString(contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES) + return services?.contains(name.flattenToString()) ?: false + } catch (e: Settings.SettingNotFoundException) { + return false + } } -fun Context.isSystemInDarkMode(): Boolean { - return this.resources.configuration.isSystemInDarkMode() -} +fun Context.isSystemInDarkMode(): Boolean = this.resources.configuration.isSystemInDarkMode() -fun Configuration.isSystemInDarkMode(): Boolean { - return (uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES -} +fun Configuration.isSystemInDarkMode(): Boolean = (uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES @JvmOverloads -fun Context.resolveAttr(id: Int, resolveRefs: Boolean = true): Int { - return theme.resolveAttr(id, resolveRefs) -} +fun Context.resolveAttr( + id: Int, + resolveRefs: Boolean = true, +): Int = theme.resolveAttr(id, resolveRefs) @JvmOverloads -fun Theme.resolveAttr(id: Int, resolveRefs: Boolean = true): Int = - TypedValue().let { - resolveAttribute(id, it, resolveRefs) - it.data - } +fun Theme.resolveAttr( + id: Int, + resolveRefs: Boolean = true, +): Int = + TypedValue().let { + resolveAttribute(id, it, resolveRefs) + it.data + } fun Activity.restartApp() { - val intent = packageManager.getLaunchIntentForPackage(packageName) - intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) - intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) - startActivity(intent) - finishAffinity() - exitProcess(0) + val intent = packageManager.getLaunchIntentForPackage(packageName) + intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + startActivity(intent) + finishAffinity() + exitProcess(0) } + +/** + * Converts a dp value to pixels, using this context's display metrics. + */ +fun Context.dpToPx(dp: Float): Int = (dp * resources.displayMetrics.density + 0.5f).toInt() + +/** + * Converts an sp value to pixels, using this context's display metrics. + */ +fun Context.spToPx(sp: Float): Int = (sp * resources.displayMetrics.scaledDensity + 0.5f).toInt() diff --git a/editor/src/main/java/com/itsaky/androidide/editor/ui/BaseEditorWindow.java b/editor/src/main/java/com/itsaky/androidide/editor/ui/BaseEditorWindow.java index 8b826eb0a5..cd61000aa8 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/ui/BaseEditorWindow.java +++ b/editor/src/main/java/com/itsaky/androidide/editor/ui/BaseEditorWindow.java @@ -19,7 +19,6 @@ import static android.view.View.MeasureSpec.AT_MOST; import static android.view.View.MeasureSpec.makeMeasureSpec; -import static com.blankj.utilcode.util.SizeUtils.dp2px; import android.content.Context; import android.graphics.drawable.Drawable; @@ -40,82 +39,83 @@ */ public abstract class BaseEditorWindow extends AbstractPopupWindow { - protected final TextView text; - - /** - * Create a popup window for editor - * - * @param editor The editor - * @see #FEATURE_SCROLL_AS_CONTENT - * @see #FEATURE_SHOW_OUTSIDE_VIEW_ALLOWED - * @see #FEATURE_HIDE_WHEN_FAST_SCROLL - */ - public BaseEditorWindow(@NonNull IDEEditor editor) { - super(editor, getFeatureFlags()); - - this.text = onCreateTextView(editor); - setContentView(onCreateContentView(editor.getContext())); - } - - private static int getFeatureFlags() { - return FEATURE_SCROLL_AS_CONTENT | FEATURE_SHOW_OUTSIDE_VIEW_ALLOWED; - } - - protected View onCreateContentView(@NonNull Context context) { - return this.text; - } - - protected TextView onCreateTextView(@NonNull IDEEditor editor) { - final var context = editor.getContext(); - final var dp4 = dp2px(4); - final var dp8 = dp4 * 2; - - final var text = new TextView(context); - text.setBackground(createBackground(context)); - text.setTextColor(ContextUtilsKt.resolveAttr(context, R.attr.colorOnPrimaryContainer)); - text.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); - text.setClickable(false); - text.setFocusable(false); - text.setPaddingRelative(dp8, dp4, dp8, dp4); - text.setLayoutParams( - new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - return text; - } - - protected Drawable createBackground(final Context context) { - GradientDrawable background = new GradientDrawable(); - background.setShape(GradientDrawable.RECTANGLE); - background.setColor(ContextUtilsKt.resolveAttr(context, R.attr.colorSurface)); - background.setStroke(dp2px(1f), ContextUtilsKt.resolveAttr(context, R.attr.colorOutline)); - background.setCornerRadius(8); - return background; - } - - public void displayWindow() { - final var dp16 = dp2px(16f); - final int width = getEditor().getWidth() - dp16; - final int height = getEditor().getHeight() - dp16; - final var widthMeasureSpec = makeMeasureSpec(width, AT_MOST); - final var heightMeasureSpec = makeMeasureSpec(height, AT_MOST); - this.getRootView().measure(widthMeasureSpec, heightMeasureSpec); - this.setSize(this.getRootView().getMeasuredWidth(), this.getRootView().getMeasuredHeight()); - - final var line = getEditor().getCursor().getLeftLine(); - final var column = getEditor().getCursor().getLeftColumn(); - int x = (int) ((getEditor().getOffset(line, column) - (getWidth() / 2))); - int y = (int) (getEditor().getRowHeight() * line) - getEditor().getOffsetY() - getHeight() - 5; - setLocationAbsolutely(x, y); - show(); - } - - protected View getRootView() { - return this.text; - } - - @NonNull - @Override - public IDEEditor getEditor() { - return (IDEEditor) super.getEditor(); - } + private static int getFeatureFlags() { + return FEATURE_SCROLL_AS_CONTENT | FEATURE_SHOW_OUTSIDE_VIEW_ALLOWED; + } + + protected final TextView text; + + /** + * Create a popup window for editor + * + * @param editor + * The editor + * @see #FEATURE_SCROLL_AS_CONTENT + * @see #FEATURE_SHOW_OUTSIDE_VIEW_ALLOWED + * @see #FEATURE_HIDE_WHEN_FAST_SCROLL + */ + public BaseEditorWindow(@NonNull IDEEditor editor) { + super(editor, getFeatureFlags()); + + this.text = onCreateTextView(editor); + setContentView(onCreateContentView(editor.getContext())); + } + + public void displayWindow() { + final var dp16 = ContextUtilsKt.dpToPx(getEditor().getContext(), 16f); + final int width = getEditor().getWidth() - dp16; + final int height = getEditor().getHeight() - dp16; + final var widthMeasureSpec = makeMeasureSpec(width, AT_MOST); + final var heightMeasureSpec = makeMeasureSpec(height, AT_MOST); + this.getRootView().measure(widthMeasureSpec, heightMeasureSpec); + this.setSize(this.getRootView().getMeasuredWidth(), this.getRootView().getMeasuredHeight()); + + final var line = getEditor().getCursor().getLeftLine(); + final var column = getEditor().getCursor().getLeftColumn(); + int x = (int) ((getEditor().getOffset(line, column) - (getWidth() / 2))); + int y = (int) (getEditor().getRowHeight() * line) - getEditor().getOffsetY() - getHeight() - 5; + setLocationAbsolutely(x, y); + show(); + } + + @NonNull + @Override + public IDEEditor getEditor() { + return (IDEEditor) super.getEditor(); + } + + protected Drawable createBackground(final Context context) { + GradientDrawable background = new GradientDrawable(); + background.setShape(GradientDrawable.RECTANGLE); + background.setColor(ContextUtilsKt.resolveAttr(context, R.attr.colorSurface)); + background.setStroke(ContextUtilsKt.dpToPx(context, 1f), ContextUtilsKt.resolveAttr(context, R.attr.colorOutline)); + background.setCornerRadius(8); + return background; + } + + protected View getRootView() { + return this.text; + } + + protected View onCreateContentView(@NonNull Context context) { + return this.text; + } + + protected TextView onCreateTextView(@NonNull IDEEditor editor) { + final var context = editor.getContext(); + final var dp4 = ContextUtilsKt.dpToPx(context, 4); + final var dp8 = dp4 * 2; + + final var text = new TextView(context); + text.setBackground(createBackground(context)); + text.setTextColor(ContextUtilsKt.resolveAttr(context, R.attr.colorOnPrimaryContainer)); + text.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); + text.setClickable(false); + text.setFocusable(false); + text.setPaddingRelative(dp8, dp4, dp8, dp4); + text.setLayoutParams( + new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + return text; + } } diff --git a/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorActionsMenu.kt b/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorActionsMenu.kt index ee1674faac..12e08c012f 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorActionsMenu.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorActionsMenu.kt @@ -34,7 +34,6 @@ import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.transition.ChangeBounds import androidx.transition.TransitionManager -import com.blankj.utilcode.util.SizeUtils import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.actions.ActionItem import com.itsaky.androidide.actions.ActionsRegistry @@ -55,6 +54,7 @@ import com.itsaky.androidide.lsp.models.DiagnosticsInSelection import com.itsaky.androidide.lsp.util.DiagnosticUtil import com.itsaky.androidide.lsp.xml.XMLLanguageServer import com.itsaky.androidide.resources.R +import com.itsaky.androidide.utils.dpToPx import com.itsaky.androidide.utils.resolveAttr import io.github.rosemoe.sora.event.HandleStateChangeEvent import io.github.rosemoe.sora.event.ScrollEvent @@ -90,7 +90,7 @@ open class EditorActionsMenu( private val contentHeight by lazy { // approximated size is around 56dp - SizeUtils.dp2px(56f) + editor.context.dpToPx(56f) } private val menu: MenuBuilder = MenuBuilder(editor.context) @@ -111,8 +111,8 @@ open class EditorActionsMenu( ViewGroup.LayoutParams.WRAP_CONTENT, ) - setFadingEdgeLength(SizeUtils.dp2px(42f)) - setPaddingRelative(paddingStart, paddingTop, SizeUtils.dp2px(16f), paddingBottom) + setFadingEdgeLength(context.dpToPx(42f)) + setPaddingRelative(paddingStart, paddingTop, context.dpToPx(16f), paddingBottom) } popup.contentView = this.list @@ -198,9 +198,9 @@ open class EditorActionsMenu( protected open fun applyBackground() { val drawable = GradientDrawable() drawable.shape = GradientDrawable.RECTANGLE - drawable.cornerRadius = SizeUtils.dp2px(28f).toFloat() // Recommeneded size is 28dp + drawable.cornerRadius = editor.context.dpToPx(28f).toFloat() // Recommeneded size is 28dp drawable.color = ColorStateList.valueOf(editor.context.resolveAttr(R.attr.colorSurface)) - drawable.setStroke(SizeUtils.dp2px(1f), editor.context.resolveAttr(R.attr.colorOutline)) + drawable.setStroke(editor.context.dpToPx(1f), editor.context.resolveAttr(R.attr.colorOutline)) list.background = drawable } @@ -275,9 +275,9 @@ open class EditorActionsMenu( } else { var x = (handleLeftX - (width / 2f)).toInt() if (x <= 0) { - x = (handleRightX + SizeUtils.dp2px(10f)).toInt() + x = (handleRightX + editor.context.dpToPx(10f)).toInt() } else if (x >= editor.width) { - x = editor.width - SizeUtils.dp2px(10f) + x = editor.width - editor.context.dpToPx(10f) } x } @@ -369,13 +369,13 @@ open class EditorActionsMenu( measureActionsList() val height = list.measuredHeight - val width = min(editor.width - SizeUtils.dp2px(32f), list.measuredWidth) + val width = min(editor.width - editor.context.dpToPx(32f), list.measuredWidth) setSize(width, height) super.show() } private fun measureActionsList() { - val dp8 = SizeUtils.dp2px(8f) + val dp8 = editor.context.dpToPx(8f) val dp16 = dp8 * 2 this.list.measure( MeasureSpec.makeMeasureSpec(editor.width - dp16 * 2, MeasureSpec.AT_MOST), diff --git a/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt b/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt index 82d23ce497..15a4170ebc 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt @@ -31,7 +31,6 @@ import android.view.inputmethod.InputConnection import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import com.blankj.utilcode.util.FileUtils -import com.blankj.utilcode.util.SizeUtils import com.itsaky.androidide.editor.R import com.itsaky.androidide.editor.R.string import com.itsaky.androidide.editor.adapters.CompletionListAdapter @@ -46,12 +45,12 @@ import com.itsaky.androidide.editor.language.treesitter.TreeSitterLanguage import com.itsaky.androidide.editor.language.treesitter.TreeSitterLanguageProvider import com.itsaky.androidide.editor.processing.ProcessContext import com.itsaky.androidide.editor.processing.TextProcessorEngine -import com.itsaky.androidide.editor.utils.getOperatorRangeAt import com.itsaky.androidide.editor.schemes.IDEColorScheme import com.itsaky.androidide.editor.schemes.IDEColorSchemeProvider import com.itsaky.androidide.editor.snippets.AbstractSnippetVariableResolver import com.itsaky.androidide.editor.snippets.FileVariableResolver import com.itsaky.androidide.editor.snippets.WorkspaceVariableResolver +import com.itsaky.androidide.editor.utils.getOperatorRangeAt import com.itsaky.androidide.eventbus.events.editor.ChangeType import com.itsaky.androidide.eventbus.events.editor.ColorSchemeInvalidatedEvent import com.itsaky.androidide.eventbus.events.editor.DocumentChangeEvent @@ -83,6 +82,7 @@ import com.itsaky.androidide.tasks.cancelIfActive import com.itsaky.androidide.tasks.doAsyncWithProgress import com.itsaky.androidide.utils.BasicBuildInfo import com.itsaky.androidide.utils.DocumentUtils +import com.itsaky.androidide.utils.dpToPx import com.itsaky.androidide.utils.flashError import io.github.rosemoe.sora.event.ContentChangeEvent import io.github.rosemoe.sora.event.SelectionChangeEvent @@ -112,7 +112,7 @@ import java.io.File import kotlin.coroutines.resume fun interface OnEditorLongPressListener { - fun onLongPress(event: MotionEvent) + fun onLongPress(event: MotionEvent) } /** @@ -121,1238 +121,1258 @@ fun interface OnEditorLongPressListener { * @author Akash Yadav */ open class IDEEditor -@JvmOverloads -constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0, - defStyleRes: Int = 0, - private val editorFeatures: EditorFeatures = EditorFeatures(), -) : CodeEditor(context, attrs, defStyleAttr, defStyleRes), - IEditor by editorFeatures, - ILspEditor { - - // A flag to track when a long press has occurred within a single touch gesture. - private var mLongPressHandled = false - - @Suppress("PropertyName") - internal var _file: File? = null - - private var actionsMenu: EditorActionsMenu? = null - private var _signatureHelpWindow: SignatureHelpWindow? = null - private var _diagnosticWindow: DiagnosticWindow? = null - private var fileVersion = 0 - internal var isModified = false - - // Length and content hash of the content the last time the file was loaded or saved. - // Used to detect when edits (e.g. undoing every change) return the content to its - // saved state, so the modified indicator can be cleared. The length is compared first - // as an O(1) guard so the content hash is only computed when the lengths match. - private var savedContentLength = 0 - private var savedContentHash = 0L - - private val selectionChangeHandler = Handler(Looper.getMainLooper()) - private var selectionChangeRunner: Runnable? = - Runnable { - val languageClient = languageClient ?: return@Runnable - val cursor = this.cursor ?: return@Runnable - - if (cursor.isSelected || _signatureHelpWindow?.isShowing == true) { - return@Runnable - } - - diagnosticWindow.showDiagnostic( - languageClient.getDiagnosticAt(file, cursor.leftLine, cursor.leftColumn), - ) - } - - /** - * The [CoroutineScope] for the editor. - * - * All the jobs in this scope are cancelled when the editor is released. - */ - val editorScope = CoroutineScope(Dispatchers.Default + CoroutineName("IDEEditor")) - - var isReadOnlyContext = false - protected val eventDispatcher = EditorEventDispatcher() - - private var setupTsLanguageJob: Job? = null - private var sigHelpCancelChecker: ICancelChecker? = null - - private var _includeDebugInfoOnCopy = false - var includeDebugInfoOnCopy: Boolean - get() = _includeDebugInfoOnCopy - set(value) { - _includeDebugInfoOnCopy = value - } - - var languageServer: ILanguageServer? = null - private set - - var languageClient: ILanguageClient? = null - private set - - /** - * Whether the cursor position change animation is enabled for the editor. - */ - var isEnsurePosAnimEnabled = true - - /** - * The text searcher for the editor. - */ - lateinit var searcher: IDEEditorSearcher - - /** - * The signature help window for the editor. - */ - val signatureHelpWindow: SignatureHelpWindow - get() { - return _signatureHelpWindow ?: SignatureHelpWindow(this).also { - _signatureHelpWindow = it - } - } - - /** - * The diagnostic window for the editor. - */ - val diagnosticWindow: DiagnosticWindow - get() { - return _diagnosticWindow ?: DiagnosticWindow(this).also { _diagnosticWindow = it } - } - - val isReadyToAppend: Boolean - get() = !isReleased && isAttachedToWindow && isLaidOut && width > 0 - - companion object { - private const val TAG = "TrackpadScrollDebug" - private const val SELECTION_CHANGE_DELAY = 500L - private const val LARGE_FILE_LINE_THRESHOLD = 10000 - private const val FNV_OFFSET_BASIS = -3750763034362895579L - private const val FNV_PRIME = 1099511628211L - internal val log = LoggerFactory.getLogger(IDEEditor::class.java) - - /** - * Create input type flags for the editor. - */ - fun createInputTypeFlags(): Int { - var flags = - EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE or EditorInfo.TYPE_TEXT_FLAG_NO_SUGGESTIONS - if (EditorPreferences.visiblePasswordFlag) { - flags = flags or EditorInfo.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD - } - return flags - } - } - - init { - context.theme - .obtainStyledAttributes( - attrs, - R.styleable.IDEEditor, - 0, - 0, - ).apply { - try { - // Get the boolean value for 'isReadOnlyContext', defaulting to 'false'. - // The value is assigned to your existing 'isOutputParent' property. - isReadOnlyContext = getBoolean(R.styleable.IDEEditor_isReadOnlyContext, false) - } finally { - // Always recycle the TypedArray to free up resources. - recycle() - } - } - run { - editorFeatures.editor = this - eventDispatcher.editor = this - eventDispatcher.init(editorScope) - initEditor() - } - } - - /** - * Set the file for this editor. - */ - fun setFile(file: File?) { - if (isReleased) { - return - } - - this._file = file - dispatchEvent(FileUpdateEvent(file, this)) - - file?.also { - dispatchDocumentOpenEvent() - } - } - - /** - * Suspends the current coroutine until the editor has valid dimensions (`width > 0`). - * - * This is a **reactive** alternative to busy-waiting or `postDelayed`. It ensures that - * no text insertion is attempted before the editor's internal layout engine is ready, - * preventing the `ArrayIndexOutOfBoundsException`. - * - * @param onForceVisible A callback invoked immediately if the view is not ready. - * Used to set the view to `VISIBLE` and trigger the layout pass. - */ - suspend fun awaitLayout(onForceVisible: () -> Unit) { - if (isReadyToAppend) return - - withContext(Dispatchers.Main) { - onForceVisible() - } - - return suspendCancellableCoroutine { continuation -> - val listener = object : OnLayoutChangeListener { - override fun onLayoutChange( - v: View?, left: Int, top: Int, right: Int, bottom: Int, - oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int - ) { - if ((v?.width ?: 0) > 0) { - v?.removeOnLayoutChangeListener(this) - if (continuation.isActive) { - continuation.resume(Unit) - } - } - } - } - - addOnLayoutChangeListener(listener) - - continuation.invokeOnCancellation { - removeOnLayoutChangeListener(listener) - } - } - } - - /** - * Appends a block of text to the editor safely. - * - * It performs a final check on [isReadyToAppend] and wraps the underlying append operation - * in [runCatching]. This prevents the app from crashing if the editor's internal layout - * calculation fails during the insertion. - */ - fun appendBatch(text: String) { - if (isReadyToAppend) { - runCatching { append(text) } - } - } - - override fun setLanguageServer(server: ILanguageServer?) { - if (isReleased) { - return - } - this.languageServer = server - server?.also { - this.languageClient = it.client - snippetController.apply { - fileVariableResolver = FileVariableResolver(this@IDEEditor) - workspaceVariableResolver = WorkspaceVariableResolver() - } - } - } - - override fun setLanguageClient(client: ILanguageClient?) { - if (isReleased) { - return - } - this.languageClient = client - } - - override fun executeCommand(command: Command?) { - if (isReleased) { - return - } - if (command == null) { - log.warn("Cannot execute command in editor. Command is null.") - return - } - - log.info(String.format("Executing command '%s' for completion item.", command.title)) - when (command.command) { - Command.TRIGGER_COMPLETION -> { - val completion = getComponent(EditorAutoCompletion::class.java) - completion.requireCompletion() - } - - Command.TRIGGER_PARAMETER_HINTS -> signatureHelp() - Command.FORMAT_CODE -> formatCodeAsync() - } - } - - override fun signatureHelp() { - if (isReleased) { - return - } - val languageServer = this.languageServer ?: return - val file = this.file ?: return - - this.languageClient ?: return - - sigHelpCancelChecker?.also { it.cancel() } - - val cancelChecker = - JobCancelChecker().also { - this.sigHelpCancelChecker = it - } - - editorScope - .launch(Dispatchers.Default) { - cancelChecker.job = coroutineContext[Job] - - val help = - safeGet("signature help request") { - val params = - SignatureHelpParams(file.toPath(), cursorLSPPosition, cancelChecker) - languageServer.signatureHelp(params) - } - - withContext(Dispatchers.Main) { - showSignatureHelp(help) - } - }.logError("signature help request") - } - - override fun showSignatureHelp(help: SignatureHelp?) { - if (isReleased) { - return - } - signatureHelpWindow.setupAndDisplay(help) - } - - override fun findDefinition() { - if (isReleased) { - return - } - val languageServer = this.languageServer ?: return - val file = file ?: return - - launchCancellableAsyncWithProgress(string.msg_finding_definition) { _, cancelChecker -> - val result = - safeGet("definition request") { - val params = DefinitionParams(file.toPath(), cursorLSPPosition, cancelChecker) - languageServer.findDefinition(params) - } - - onFindDefinitionResult(result) - }?.logError("definition request") - } - - override fun findReferences() { - if (isReleased) { - return - } - val languageServer = this.languageServer ?: return - val file = file ?: return - - launchCancellableAsyncWithProgress(string.msg_finding_references) { _, cancelChecker -> - val result = - safeGet("references request") { - val params = - ReferenceParams(file.toPath(), cursorLSPPosition, true, cancelChecker) - languageServer.findReferences(params) - } - - onFindReferencesResult(result) - }?.logError("references request") - } - - override fun expandSelection() { - if (isReleased) { - return - } - val languageServer = this.languageServer ?: return - val file = file ?: return - - launchCancellableAsyncWithProgress(string.please_wait) { _, _ -> - val initialRange = cursorLSPRange - val result = - safeGet("expand selection request") { - val params = ExpandSelectionParams(file.toPath(), initialRange) - languageServer.expandSelection(params) - } ?: initialRange - - withContext(Dispatchers.Main) { - setSelection(result) - } - }?.logError("expand selection request") - } - - override fun ensureWindowsDismissed() { - if (_diagnosticWindow?.isShowing == true) { - _diagnosticWindow?.dismiss() - } - - if (_signatureHelpWindow?.isShowing == true) { - _signatureHelpWindow?.dismiss() - } - - if (actionsMenu?.isShowing == true) { - actionsMenu?.dismiss() - } - - // Dismiss autocomplete window if showing - val completion = getComponent(EditorAutoCompletion::class.java) - if (completion.isShowing) { - completion.hide() - } - } - - fun dismissPopupWindows() { - if (_diagnosticWindow?.isShowing == true) { - _diagnosticWindow?.dismiss() - } - - if (_signatureHelpWindow?.isShowing == true) { - _signatureHelpWindow?.dismiss() - } - - if (actionsMenu?.isShowing == true) { - actionsMenu?.dismiss() - } - } - - // not overridable! - final override fun replaceComponent( - clazz: Class, - replacement: T & Any, - ) { - super.replaceComponent(clazz, replacement) - } - - // not overridable - final override fun getComponent(clazz: Class): T & Any = - super.getComponent(clazz) - - override fun release() { - ensureWindowsDismissed() - - if (isReleased) { - return - } - - super.release() - - snippetController.apply { - (fileVariableResolver as? AbstractSnippetVariableResolver?)?.close() - (workspaceVariableResolver as? AbstractSnippetVariableResolver?)?.close() - - fileVariableResolver = null - workspaceVariableResolver = null - } - - actionsMenu?.destroy() - - actionsMenu = null - _signatureHelpWindow = null - _diagnosticWindow = null - - languageServer = null - languageClient = null - - _file = null - fileVersion = 0 - markUnmodified() - - editorFeatures.editor = null - eventDispatcher.editor = null - - eventDispatcher.destroy() - - selectionChangeRunner?.also { selectionChangeHandler.removeCallbacks(it) } - selectionChangeRunner = null - - if (EventBus.getDefault().isRegistered(this)) { - EventBus.getDefault().unregister(this) - } - - setupTsLanguageJob?.cancel("Editor is releasing resources.") - - if (editorScope.isActive) { - editorScope.cancelIfActive("Editor is releasing resources.") - } - } - - override fun getSearcher(): EditorSearcher = this.searcher - - /** - * Disables IME text extraction for large files (exceeding [LARGE_FILE_LINE_THRESHOLD] lines) to prevent massive - * IPC (Binder) payloads, fixing "oneway spamming" errors and UI lag during typing. - */ - override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? { - val connection = super.onCreateInputConnection(outAttrs) - - if (this.lineCount > LARGE_FILE_LINE_THRESHOLD) { - outAttrs.imeOptions = outAttrs.imeOptions or EditorInfo.IME_FLAG_NO_EXTRACT_UI - } - - return connection - } - - override fun getExtraArguments(): Bundle = - super.getExtraArguments().apply { - putString(IEditor.KEY_FILE, file?.absolutePath) - } - - override fun ensurePositionVisible( - line: Int, - column: Int, - noAnimation: Boolean, - ) { - super.ensurePositionVisible(line, column, !isEnsurePosAnimEnabled || noAnimation) - } - - override fun getTabWidth(): Int = EditorPreferences.tabSize - - override fun beginSearchMode(): Unit = - throw UnsupportedOperationException( - "Search ActionMode is not supported. Use CodeEditorView.beginSearch() instead.", - ) - - override fun onFocusChanged( - gainFocus: Boolean, - direction: Int, - previouslyFocusedRect: Rect?, - ) { - super.onFocusChanged(gainFocus, direction, previouslyFocusedRect) - if (!gainFocus) { - ensureWindowsDismissed() - } - } - - override fun copyTextToClipboard( - text: CharSequence, - start: Int, - end: Int, - ) { - if (includeDebugInfoOnCopy) { - // Extract selected text first, then prepend build info - val selectedText = text.subSequence(start, end) - val textWithBuildInfo = BasicBuildInfo.BASIC_INFO + System.lineSeparator() + selectedText - doCopy(textWithBuildInfo, 0, textWithBuildInfo.length) - } else { - doCopy(text, start, end) - } - } - - @VisibleForTesting - fun doCopy( - text: CharSequence, - start: Int, - end: Int, - ) { - // This method MUST not contain any other statements - // It is only used for testing purposes - // We mock this method in instrumentation tests so that we could capture - // whatever is being copied to the clipboard, which can then be verified - super.copyTextToClipboard(text, start, end) - } - - /** - * Analyze the opened file and publish the diagnostics result. - */ - open fun analyze() { - if (isReleased) { - return - } - if (editorLanguage !is IDELanguage) { - return - } - - val languageServer = languageServer ?: return - val file = file ?: return - - editorScope - .launch { - val result = safeGet("LSP file analysis") { languageServer.analyze(file.toPath()) } - languageClient?.publishDiagnostics(result) - }.logError("LSP file analysis") - } - - /** - * Mark this editor as NOT modified. - * - * Snapshots the current content so that later edits which return the content to this - * state (for example, undoing every change) can clear the modified flag again. - */ - open fun markUnmodified() { - this.isModified = false - snapshotSavedContent() - } - - /** - * Mark this editor as modified. - */ - open fun markModified() { - this.isModified = true - } - - /** - * Recomputes [isModified] by comparing the current content against the snapshot captured - * the last time the file was loaded or saved. The content length is - * checked first as a cheap guard so the full content hash is only computed when the lengths - * match - i.e. when the edits may have restored the saved state. - */ - private fun refreshModifiedState() { - val content = text - isModified = content.length != savedContentLength || - computeContentHash(content) != savedContentHash - } - - private fun snapshotSavedContent() { - val content = text - savedContentLength = content.length - savedContentHash = computeContentHash(content) - } - - /** - * Computes a 64-bit FNV-1a hash of the given content. - * Suitable for fast change detection. - */ - private fun computeContentHash(content: CharSequence): Long { - var hash = FNV_OFFSET_BASIS - for (i in content.indices) { - hash = (hash xor content[i].code.toLong()) * FNV_PRIME - } - return hash - } - - /** - * Notify the language server that the file in this editor is about to be closed. - */ - open fun notifyClose() { - if (isReleased) { - return - } - file ?: run { - log.info("Cannot notify language server. File is null.") - return - } - - dispatchDocumentCloseEvent() - - actionsMenu?.unsubscribeEvents() - selectionChangeRunner?.also { - selectionChangeHandler.removeCallbacks(it) - } - - selectionChangeRunner = null - - ensureWindowsDismissed() - } - - /** - * Called when this editor is selected and visible to the user. - */ - open fun onEditorSelected() { - if (isReleased) { - return - } - - file ?: return - dispatchDocumentSelectedEvent() - } - - /** - * Dispatches the [DocumentSaveEvent] for this editor. - */ - open fun dispatchDocumentSaveEvent() { - if (isReleased) { - return - } - if (file == null) { - return - } - eventDispatcher.dispatch(DocumentSaveEvent(file!!.toPath())) - } - - /** - * Called when the color scheme has been invalidated. This usually happens when the user reloads - * the color schemes. - */ - @Suppress("unused") - @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) - open fun onColorSchemeInvalidated(event: ColorSchemeInvalidatedEvent?) { - val file = file ?: return - setupLanguage(file) - } - - /** - * Setup the editor language for the given [file]. - * - * This applies a proper [Language] and the color scheme to the editor. - */ - open fun setupLanguage(file: File?) { - if (isReleased) { - return - } - if (file == null) { - return - } - - createLanguage(file) { language -> - val extension = file.extension - if (language is TreeSitterLanguage) { - IDEColorSchemeProvider.readSchemeAsync( - context = context, - coroutineScope = editorScope, - type = extension, - ) { scheme -> - applyTreeSitterLang(language, extension, scheme) - } - } else { - setEditorLanguage(language) - } - } - } - - /** - * Applies the given [TreeSitterLanguage] and the [color scheme][scheme] for the given [file type][type]. - */ - open fun applyTreeSitterLang( - language: TreeSitterLanguage, - type: String, - scheme: SchemeAndroidIDE?, - ) { - applyTreeSitterLangInternal(language, type, scheme) - } - - private fun applyTreeSitterLangInternal( - language: TreeSitterLanguage, - type: String, - scheme: SchemeAndroidIDE?, - ) { - if (isReleased) { - return - } - var finalScheme = - if (scheme != null) { - scheme - } else { - log.error("Failed to read current color scheme") - SchemeAndroidIDE.newInstance(context) - } - - if (finalScheme is IDEColorScheme) { - language.setupWith(finalScheme) - - if (finalScheme.getLanguageScheme(type) == null) { - log.warn("Color scheme does not support file type '{}'", type) - finalScheme = SchemeAndroidIDE.newInstance(context) - } - } - - if (finalScheme is DynamicColorScheme) { - finalScheme.apply(context) - } - - colorScheme = finalScheme!! - setEditorLanguage(language) - } - - private inline fun createLanguage( - file: File, - crossinline callback: (Language?) -> Unit, - ) { - // 1 -> If the given File object does not represent a file, return emtpy language - if (!file.isFile) { - return callback(EmptyLanguage()) - } - - // 2 -> In case a TreeSitterLanguage has been registered for this file type, - // Initialize the TreeSitterLanguage asynchronously and then invoke the callback - if (TreeSitterLanguageProvider.hasTsLanguage(file)) { - // lazily create TS languages as they need to read files from assets - setupTsLanguageJob = - editorScope - .launch { - callback(TreeSitterLanguageProvider.forFile(file, context)) - }.also { job -> - job.invokeOnCompletion { err -> - if (err != null) { - log.error( - "Failed to setup tree sitter language for file: {}", - file, - err - ) - } - - setupTsLanguageJob = null - } - } - - return - } - - // 3 -> Check if we have ANTLR4 lexer-based languages for this file, - // return the language if we do, otherwise return an empty language - val lang = - when (FileUtils.getFileExtension(file)) { - "gradle" -> GroovyLanguage() - "c", "h", "cc", "cpp", "cxx" -> CppLanguage() - else -> EmptyLanguage() - } - - callback(lang) - } - - override fun setEditorLanguage(lang: Language?) { - super.setEditorLanguage(lang) - if (isReleased) { - return - } - dispatchEvent(LanguageUpdateEvent(lang, this)) - } - - private val textProcessorEngine = TextProcessorEngine() - - /** - * Initialize the editor. - */ - protected open fun initEditor() { - lineNumberMarginLeft = SizeUtils.dp2px(2f).toFloat() - - actionsMenu = - EditorActionsMenu(this).also { - it.init() - } - - markUnmodified() - - searcher = IDEEditorSearcher(this) - colorScheme = SchemeAndroidIDE.newInstance(context) - inputType = createInputTypeFlags() - - val window = EditorCompletionWindow(this) - window.setAdapter(CompletionListAdapter()) - replaceComponent(EditorAutoCompletion::class.java, window) - - getComponent(EditorTextActionWindow::class.java).isEnabled = false - - subscribeEvent(ContentChangeEvent::class.java) { event, _ -> - if (isReleased) { - return@subscribeEvent - } - - refreshModifiedState() - // A pending inline suggestion is anchored to the pre-edit cursor position; any edit - // invalidates it. The plugin re-issues one after its debounce. - dismissInlineSuggestion() - file ?: return@subscribeEvent - - editorScope.launch { - dispatchDocumentChangeEvent(event) - checkForSignatureHelp(event) - handleCustomTextReplacement(event) - } - } - - subscribeEvent(SelectionChangeEvent::class.java) { _, _ -> - if (isReleased) { - return@subscribeEvent - } - - // Moving the cursor away from the anchor makes ghost text meaningless. - dismissInlineSuggestion() - - if (_diagnosticWindow?.isShowing == true) { - _diagnosticWindow?.dismiss() - } - - selectionChangeRunner?.also { - selectionChangeHandler.removeCallbacks(it) - selectionChangeHandler.postDelayed(it, SELECTION_CHANGE_DELAY) - } - } - - EventBus.getDefault().register(this) - } - - // --- Inline suggestions (ghost text) ------------------------------------ - - /** [GhostTextRenderer] extends [TracingEditorRenderer], so this keeps the block-line - * data-race guards while also drawing inline ghost text. */ - override fun onCreateRenderer(): EditorRenderer = GhostTextRenderer(this) - - private val ghostRenderer: GhostTextRenderer? - get() = renderer as? GhostTextRenderer - - /** - * Shows [text] as dimmed inline ghost text anchored at the current cursor position, owned by - * [pluginId]. Called by the host editor provider when a plugin returns an inline suggestion. - * The suggestion is tagged with its owner so a later [dismissInlineSuggestion] from a different - * plugin can't clear it. No-op if the editor is released or has no cursor. - */ - fun showInlineSuggestion(pluginId: String, text: String) { - if (isReleased || text.isEmpty()) return - val renderer = ghostRenderer ?: return - val cursor = cursor ?: return - renderer.setSuggestion(text, cursor.leftLine, cursor.leftColumn, pluginId) - invalidate() - } - - /** Removes the showing ghost text only if it is owned by [pluginId]. */ - fun dismissInlineSuggestion(pluginId: String) { - val renderer = ghostRenderer ?: return - if (renderer.clearSuggestionFor(pluginId)) { - invalidate() - } - } - - /** - * Unconditionally removes any showing ghost text, regardless of owner. Used for IDE-internal - * invalidation (an edit or cursor move makes the anchored suggestion meaningless). - */ - fun dismissInlineSuggestion() { - val renderer = ghostRenderer ?: return - if (renderer.hasSuggestion) { - renderer.clearSuggestion() - invalidate() - } - } - - override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { - // Accept a showing suggestion on Tab: commit it at the cursor and consume the key. - if (keyCode == KeyEvent.KEYCODE_TAB && ghostRenderer?.hasSuggestion == true) { - val suggestion = ghostRenderer?.takeSuggestion() - invalidate() - if (!suggestion.isNullOrEmpty()) { - commitText(suggestion) - return true - } - } - return super.onKeyDown(keyCode, event) - } - - private fun handleCustomTextReplacement(event: ContentChangeEvent) { - val isEnterPress = - event.action == ContentChangeEvent.ACTION_INSERT && - event.changedText.toString().contains("\n") - - if (!isEnterPress) { - return - } - - val currentFile = this.file ?: return - - editorScope.launch { - dispatchDocumentSaveEvent() - - val lineToProcess = event.changeStart.line - - val context = - ProcessContext( - content = text, - file = currentFile, - cursor = - text.cursor.apply { + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0, + private val editorFeatures: EditorFeatures = EditorFeatures(), + ) : CodeEditor(context, attrs, defStyleAttr, defStyleRes), + IEditor by editorFeatures, + ILspEditor { + // A flag to track when a long press has occurred within a single touch gesture. + private var mLongPressHandled = false + + @Suppress("PropertyName") + internal var _file: File? = null + + private var actionsMenu: EditorActionsMenu? = null + private var _signatureHelpWindow: SignatureHelpWindow? = null + private var _diagnosticWindow: DiagnosticWindow? = null + private var fileVersion = 0 + internal var isModified = false + + // Length and content hash of the content the last time the file was loaded or saved. + // Used to detect when edits (e.g. undoing every change) return the content to its + // saved state, so the modified indicator can be cleared. The length is compared first + // as an O(1) guard so the content hash is only computed when the lengths match. + private var savedContentLength = 0 + private var savedContentHash = 0L + + private val selectionChangeHandler = Handler(Looper.getMainLooper()) + private var selectionChangeRunner: Runnable? = + Runnable { + val languageClient = languageClient ?: return@Runnable + val cursor = this.cursor ?: return@Runnable + + if (cursor.isSelected || _signatureHelpWindow?.isShowing == true) { + return@Runnable + } + + diagnosticWindow.showDiagnostic( + languageClient.getDiagnosticAt(file, cursor.leftLine, cursor.leftColumn), + ) + } + + /** + * The [CoroutineScope] for the editor. + * + * All the jobs in this scope are cancelled when the editor is released. + */ + val editorScope = CoroutineScope(Dispatchers.Default + CoroutineName("IDEEditor")) + + var isReadOnlyContext = false + protected val eventDispatcher = EditorEventDispatcher() + + private var setupTsLanguageJob: Job? = null + private var sigHelpCancelChecker: ICancelChecker? = null + + private var _includeDebugInfoOnCopy = false + var includeDebugInfoOnCopy: Boolean + get() = _includeDebugInfoOnCopy + set(value) { + _includeDebugInfoOnCopy = value + } + + var languageServer: ILanguageServer? = null + private set + + var languageClient: ILanguageClient? = null + private set + + /** + * Whether the cursor position change animation is enabled for the editor. + */ + var isEnsurePosAnimEnabled = true + + /** + * The text searcher for the editor. + */ + lateinit var searcher: IDEEditorSearcher + + /** + * The signature help window for the editor. + */ + val signatureHelpWindow: SignatureHelpWindow + get() { + return _signatureHelpWindow ?: SignatureHelpWindow(this).also { + _signatureHelpWindow = it + } + } + + /** + * The diagnostic window for the editor. + */ + val diagnosticWindow: DiagnosticWindow + get() { + return _diagnosticWindow ?: DiagnosticWindow(this).also { _diagnosticWindow = it } + } + + val isReadyToAppend: Boolean + get() = !isReleased && isAttachedToWindow && isLaidOut && width > 0 + + companion object { + private const val TAG = "TrackpadScrollDebug" + private const val SELECTION_CHANGE_DELAY = 500L + private const val LARGE_FILE_LINE_THRESHOLD = 10000 + private const val FNV_OFFSET_BASIS = -3750763034362895579L + private const val FNV_PRIME = 1099511628211L + internal val log = LoggerFactory.getLogger(IDEEditor::class.java) + + /** + * Create input type flags for the editor. + */ + fun createInputTypeFlags(): Int { + var flags = + EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE or EditorInfo.TYPE_TEXT_FLAG_NO_SUGGESTIONS + if (EditorPreferences.visiblePasswordFlag) { + flags = flags or EditorInfo.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD + } + return flags + } + } + + init { + context.theme + .obtainStyledAttributes( + attrs, + R.styleable.IDEEditor, + 0, + 0, + ).apply { + try { + // Get the boolean value for 'isReadOnlyContext', defaulting to 'false'. + // The value is assigned to your existing 'isOutputParent' property. + isReadOnlyContext = getBoolean(R.styleable.IDEEditor_isReadOnlyContext, false) + } finally { + // Always recycle the TypedArray to free up resources. + recycle() + } + } + run { + editorFeatures.editor = this + eventDispatcher.editor = this + eventDispatcher.init(editorScope) + initEditor() + } + } + + /** + * Set the file for this editor. + */ + fun setFile(file: File?) { + if (isReleased) { + return + } + + this._file = file + dispatchEvent(FileUpdateEvent(file, this)) + + file?.also { + dispatchDocumentOpenEvent() + } + } + + /** + * Suspends the current coroutine until the editor has valid dimensions (`width > 0`). + * + * This is a **reactive** alternative to busy-waiting or `postDelayed`. It ensures that + * no text insertion is attempted before the editor's internal layout engine is ready, + * preventing the `ArrayIndexOutOfBoundsException`. + * + * @param onForceVisible A callback invoked immediately if the view is not ready. + * Used to set the view to `VISIBLE` and trigger the layout pass. + */ + suspend fun awaitLayout(onForceVisible: () -> Unit) { + if (isReadyToAppend) return + + withContext(Dispatchers.Main) { + onForceVisible() + } + + return suspendCancellableCoroutine { continuation -> + val listener = + object : OnLayoutChangeListener { + override fun onLayoutChange( + v: View?, + left: Int, + top: Int, + right: Int, + bottom: Int, + oldLeft: Int, + oldTop: Int, + oldRight: Int, + oldBottom: Int, + ) { + if ((v?.width ?: 0) > 0) { + v?.removeOnLayoutChangeListener(this) + if (continuation.isActive) { + continuation.resume(Unit) + } + } + } + } + + addOnLayoutChangeListener(listener) + + continuation.invokeOnCancellation { + removeOnLayoutChangeListener(listener) + } + } + } + + /** + * Appends a block of text to the editor safely. + * + * It performs a final check on [isReadyToAppend] and wraps the underlying append operation + * in [runCatching]. This prevents the app from crashing if the editor's internal layout + * calculation fails during the insertion. + */ + fun appendBatch(text: String) { + if (isReadyToAppend) { + runCatching { append(text) } + } + } + + override fun setLanguageServer(server: ILanguageServer?) { + if (isReleased) { + return + } + this.languageServer = server + server?.also { + this.languageClient = it.client + snippetController.apply { + fileVariableResolver = FileVariableResolver(this@IDEEditor) + workspaceVariableResolver = WorkspaceVariableResolver() + } + } + } + + override fun setLanguageClient(client: ILanguageClient?) { + if (isReleased) { + return + } + this.languageClient = client + } + + override fun executeCommand(command: Command?) { + if (isReleased) { + return + } + if (command == null) { + log.warn("Cannot execute command in editor. Command is null.") + return + } + + log.info(String.format("Executing command '%s' for completion item.", command.title)) + when (command.command) { + Command.TRIGGER_COMPLETION -> { + val completion = getComponent(EditorAutoCompletion::class.java) + completion.requireCompletion() + } + + Command.TRIGGER_PARAMETER_HINTS -> { + signatureHelp() + } + + Command.FORMAT_CODE -> { + formatCodeAsync() + } + } + } + + override fun signatureHelp() { + if (isReleased) { + return + } + val languageServer = this.languageServer ?: return + val file = this.file ?: return + + this.languageClient ?: return + + sigHelpCancelChecker?.also { it.cancel() } + + val cancelChecker = + JobCancelChecker().also { + this.sigHelpCancelChecker = it + } + + editorScope + .launch(Dispatchers.Default) { + cancelChecker.job = coroutineContext[Job] + + val help = + safeGet("signature help request") { + val params = + SignatureHelpParams(file.toPath(), cursorLSPPosition, cancelChecker) + languageServer.signatureHelp(params) + } + + withContext(Dispatchers.Main) { + showSignatureHelp(help) + } + }.logError("signature help request") + } + + override fun showSignatureHelp(help: SignatureHelp?) { + if (isReleased) { + return + } + signatureHelpWindow.setupAndDisplay(help) + } + + override fun findDefinition() { + if (isReleased) { + return + } + val languageServer = this.languageServer ?: return + val file = file ?: return + + launchCancellableAsyncWithProgress(string.msg_finding_definition) { _, cancelChecker -> + val result = + safeGet("definition request") { + val params = DefinitionParams(file.toPath(), cursorLSPPosition, cancelChecker) + languageServer.findDefinition(params) + } + + onFindDefinitionResult(result) + }?.logError("definition request") + } + + override fun findReferences() { + if (isReleased) { + return + } + val languageServer = this.languageServer ?: return + val file = file ?: return + + launchCancellableAsyncWithProgress(string.msg_finding_references) { _, cancelChecker -> + val result = + safeGet("references request") { + val params = + ReferenceParams(file.toPath(), cursorLSPPosition, true, cancelChecker) + languageServer.findReferences(params) + } + + onFindReferencesResult(result) + }?.logError("references request") + } + + override fun expandSelection() { + if (isReleased) { + return + } + val languageServer = this.languageServer ?: return + val file = file ?: return + + launchCancellableAsyncWithProgress(string.please_wait) { _, _ -> + val initialRange = cursorLSPRange + val result = + safeGet("expand selection request") { + val params = ExpandSelectionParams(file.toPath(), initialRange) + languageServer.expandSelection(params) + } ?: initialRange + + withContext(Dispatchers.Main) { + setSelection(result) + } + }?.logError("expand selection request") + } + + override fun ensureWindowsDismissed() { + if (_diagnosticWindow?.isShowing == true) { + _diagnosticWindow?.dismiss() + } + + if (_signatureHelpWindow?.isShowing == true) { + _signatureHelpWindow?.dismiss() + } + + if (actionsMenu?.isShowing == true) { + actionsMenu?.dismiss() + } + + // Dismiss autocomplete window if showing + val completion = getComponent(EditorAutoCompletion::class.java) + if (completion.isShowing) { + completion.hide() + } + } + + fun dismissPopupWindows() { + if (_diagnosticWindow?.isShowing == true) { + _diagnosticWindow?.dismiss() + } + + if (_signatureHelpWindow?.isShowing == true) { + _signatureHelpWindow?.dismiss() + } + + if (actionsMenu?.isShowing == true) { + actionsMenu?.dismiss() + } + } + + // not overridable! + final override fun replaceComponent( + clazz: Class, + replacement: T & Any, + ) { + super.replaceComponent(clazz, replacement) + } + + // not overridable + final override fun getComponent(clazz: Class): T & Any = super.getComponent(clazz) + + override fun release() { + ensureWindowsDismissed() + + if (isReleased) { + return + } + + super.release() + + snippetController.apply { + (fileVariableResolver as? AbstractSnippetVariableResolver?)?.close() + (workspaceVariableResolver as? AbstractSnippetVariableResolver?)?.close() + + fileVariableResolver = null + workspaceVariableResolver = null + } + + actionsMenu?.destroy() + + actionsMenu = null + _signatureHelpWindow = null + _diagnosticWindow = null + + languageServer = null + languageClient = null + + _file = null + fileVersion = 0 + markUnmodified() + + editorFeatures.editor = null + eventDispatcher.editor = null + + eventDispatcher.destroy() + + selectionChangeRunner?.also { selectionChangeHandler.removeCallbacks(it) } + selectionChangeRunner = null + + if (EventBus.getDefault().isRegistered(this)) { + EventBus.getDefault().unregister(this) + } + + setupTsLanguageJob?.cancel("Editor is releasing resources.") + + if (editorScope.isActive) { + editorScope.cancelIfActive("Editor is releasing resources.") + } + } + + override fun getSearcher(): EditorSearcher = this.searcher + + /** + * Disables IME text extraction for large files (exceeding [LARGE_FILE_LINE_THRESHOLD] lines) to prevent massive + * IPC (Binder) payloads, fixing "oneway spamming" errors and UI lag during typing. + */ + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? { + val connection = super.onCreateInputConnection(outAttrs) + + if (this.lineCount > LARGE_FILE_LINE_THRESHOLD) { + outAttrs.imeOptions = outAttrs.imeOptions or EditorInfo.IME_FLAG_NO_EXTRACT_UI + } + + return connection + } + + override fun getExtraArguments(): Bundle = + super.getExtraArguments().apply { + putString(IEditor.KEY_FILE, file?.absolutePath) + } + + override fun ensurePositionVisible( + line: Int, + column: Int, + noAnimation: Boolean, + ) { + super.ensurePositionVisible(line, column, !isEnsurePosAnimEnabled || noAnimation) + } + + override fun getTabWidth(): Int = EditorPreferences.tabSize + + override fun beginSearchMode(): Unit = + throw UnsupportedOperationException( + "Search ActionMode is not supported. Use CodeEditorView.beginSearch() instead.", + ) + + override fun onFocusChanged( + gainFocus: Boolean, + direction: Int, + previouslyFocusedRect: Rect?, + ) { + super.onFocusChanged(gainFocus, direction, previouslyFocusedRect) + if (!gainFocus) { + ensureWindowsDismissed() + } + } + + override fun copyTextToClipboard( + text: CharSequence, + start: Int, + end: Int, + ) { + if (includeDebugInfoOnCopy) { + // Extract selected text first, then prepend build info + val selectedText = text.subSequence(start, end) + val textWithBuildInfo = BasicBuildInfo.BASIC_INFO + System.lineSeparator() + selectedText + doCopy(textWithBuildInfo, 0, textWithBuildInfo.length) + } else { + doCopy(text, start, end) + } + } + + @VisibleForTesting + fun doCopy( + text: CharSequence, + start: Int, + end: Int, + ) { + // This method MUST not contain any other statements + // It is only used for testing purposes + // We mock this method in instrumentation tests so that we could capture + // whatever is being copied to the clipboard, which can then be verified + super.copyTextToClipboard(text, start, end) + } + + /** + * Analyze the opened file and publish the diagnostics result. + */ + open fun analyze() { + if (isReleased) { + return + } + if (editorLanguage !is IDELanguage) { + return + } + + val languageServer = languageServer ?: return + val file = file ?: return + + editorScope + .launch { + val result = safeGet("LSP file analysis") { languageServer.analyze(file.toPath()) } + languageClient?.publishDiagnostics(result) + }.logError("LSP file analysis") + } + + /** + * Mark this editor as NOT modified. + * + * Snapshots the current content so that later edits which return the content to this + * state (for example, undoing every change) can clear the modified flag again. + */ + open fun markUnmodified() { + this.isModified = false + snapshotSavedContent() + } + + /** + * Mark this editor as modified. + */ + open fun markModified() { + this.isModified = true + } + + /** + * Recomputes [isModified] by comparing the current content against the snapshot captured + * the last time the file was loaded or saved. The content length is + * checked first as a cheap guard so the full content hash is only computed when the lengths + * match - i.e. when the edits may have restored the saved state. + */ + private fun refreshModifiedState() { + val content = text + isModified = content.length != savedContentLength || + computeContentHash(content) != savedContentHash + } + + private fun snapshotSavedContent() { + val content = text + savedContentLength = content.length + savedContentHash = computeContentHash(content) + } + + /** + * Computes a 64-bit FNV-1a hash of the given content. + * Suitable for fast change detection. + */ + private fun computeContentHash(content: CharSequence): Long { + var hash = FNV_OFFSET_BASIS + for (i in content.indices) { + hash = (hash xor content[i].code.toLong()) * FNV_PRIME + } + return hash + } + + /** + * Notify the language server that the file in this editor is about to be closed. + */ + open fun notifyClose() { + if (isReleased) { + return + } + file ?: run { + log.info("Cannot notify language server. File is null.") + return + } + + dispatchDocumentCloseEvent() + + actionsMenu?.unsubscribeEvents() + selectionChangeRunner?.also { + selectionChangeHandler.removeCallbacks(it) + } + + selectionChangeRunner = null + + ensureWindowsDismissed() + } + + /** + * Called when this editor is selected and visible to the user. + */ + open fun onEditorSelected() { + if (isReleased) { + return + } + + file ?: return + dispatchDocumentSelectedEvent() + } + + /** + * Dispatches the [DocumentSaveEvent] for this editor. + */ + open fun dispatchDocumentSaveEvent() { + if (isReleased) { + return + } + if (file == null) { + return + } + eventDispatcher.dispatch(DocumentSaveEvent(file!!.toPath())) + } + + /** + * Called when the color scheme has been invalidated. This usually happens when the user reloads + * the color schemes. + */ + @Suppress("unused") + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + open fun onColorSchemeInvalidated(event: ColorSchemeInvalidatedEvent?) { + val file = file ?: return + setupLanguage(file) + } + + /** + * Setup the editor language for the given [file]. + * + * This applies a proper [Language] and the color scheme to the editor. + */ + open fun setupLanguage(file: File?) { + if (isReleased) { + return + } + if (file == null) { + return + } + + createLanguage(file) { language -> + val extension = file.extension + if (language is TreeSitterLanguage) { + IDEColorSchemeProvider.readSchemeAsync( + context = context, + coroutineScope = editorScope, + type = extension, + ) { scheme -> + applyTreeSitterLang(language, extension, scheme) + } + } else { + setEditorLanguage(language) + } + } + } + + /** + * Applies the given [TreeSitterLanguage] and the [color scheme][scheme] for the given [file type][type]. + */ + open fun applyTreeSitterLang( + language: TreeSitterLanguage, + type: String, + scheme: SchemeAndroidIDE?, + ) { + applyTreeSitterLangInternal(language, type, scheme) + } + + private fun applyTreeSitterLangInternal( + language: TreeSitterLanguage, + type: String, + scheme: SchemeAndroidIDE?, + ) { + if (isReleased) { + return + } + var finalScheme = + if (scheme != null) { + scheme + } else { + log.error("Failed to read current color scheme") + SchemeAndroidIDE.newInstance(context) + } + + if (finalScheme is IDEColorScheme) { + language.setupWith(finalScheme) + + if (finalScheme.getLanguageScheme(type) == null) { + log.warn("Color scheme does not support file type '{}'", type) + finalScheme = SchemeAndroidIDE.newInstance(context) + } + } + + if (finalScheme is DynamicColorScheme) { + finalScheme.apply(context) + } + + colorScheme = finalScheme!! + setEditorLanguage(language) + } + + private inline fun createLanguage( + file: File, + crossinline callback: (Language?) -> Unit, + ) { + // 1 -> If the given File object does not represent a file, return emtpy language + if (!file.isFile) { + return callback(EmptyLanguage()) + } + + // 2 -> In case a TreeSitterLanguage has been registered for this file type, + // Initialize the TreeSitterLanguage asynchronously and then invoke the callback + if (TreeSitterLanguageProvider.hasTsLanguage(file)) { + // lazily create TS languages as they need to read files from assets + setupTsLanguageJob = + editorScope + .launch { + callback(TreeSitterLanguageProvider.forFile(file, context)) + }.also { job -> + job.invokeOnCompletion { err -> + if (err != null) { + log.error( + "Failed to setup tree sitter language for file: {}", + file, + err, + ) + } + + setupTsLanguageJob = null + } + } + + return + } + + // 3 -> Check if we have ANTLR4 lexer-based languages for this file, + // return the language if we do, otherwise return an empty language + val lang = + when (FileUtils.getFileExtension(file)) { + "gradle" -> GroovyLanguage() + "c", "h", "cc", "cpp", "cxx" -> CppLanguage() + else -> EmptyLanguage() + } + + callback(lang) + } + + override fun setEditorLanguage(lang: Language?) { + super.setEditorLanguage(lang) + if (isReleased) { + return + } + dispatchEvent(LanguageUpdateEvent(lang, this)) + } + + private val textProcessorEngine = TextProcessorEngine() + + /** + * Initialize the editor. + */ + protected open fun initEditor() { + lineNumberMarginLeft = context.dpToPx(2f).toFloat() + + actionsMenu = + EditorActionsMenu(this).also { + it.init() + } + + markUnmodified() + + searcher = IDEEditorSearcher(this) + colorScheme = SchemeAndroidIDE.newInstance(context) + inputType = createInputTypeFlags() + + val window = EditorCompletionWindow(this) + window.setAdapter(CompletionListAdapter()) + replaceComponent(EditorAutoCompletion::class.java, window) + + getComponent(EditorTextActionWindow::class.java).isEnabled = false + + subscribeEvent(ContentChangeEvent::class.java) { event, _ -> + if (isReleased) { + return@subscribeEvent + } + + refreshModifiedState() + // A pending inline suggestion is anchored to the pre-edit cursor position; any edit + // invalidates it. The plugin re-issues one after its debounce. + dismissInlineSuggestion() + file ?: return@subscribeEvent + + editorScope.launch { + dispatchDocumentChangeEvent(event) + checkForSignatureHelp(event) + handleCustomTextReplacement(event) + } + } + + subscribeEvent(SelectionChangeEvent::class.java) { _, _ -> + if (isReleased) { + return@subscribeEvent + } + + // Moving the cursor away from the anchor makes ghost text meaningless. + dismissInlineSuggestion() + + if (_diagnosticWindow?.isShowing == true) { + _diagnosticWindow?.dismiss() + } + + selectionChangeRunner?.also { + selectionChangeHandler.removeCallbacks(it) + selectionChangeHandler.postDelayed(it, SELECTION_CHANGE_DELAY) + } + } + + EventBus.getDefault().register(this) + } + + // --- Inline suggestions (ghost text) ------------------------------------ + + /** [GhostTextRenderer] extends [TracingEditorRenderer], so this keeps the block-line + * data-race guards while also drawing inline ghost text. */ + override fun onCreateRenderer(): EditorRenderer = GhostTextRenderer(this) + + private val ghostRenderer: GhostTextRenderer? + get() = renderer as? GhostTextRenderer + + /** + * Shows [text] as dimmed inline ghost text anchored at the current cursor position, owned by + * [pluginId]. Called by the host editor provider when a plugin returns an inline suggestion. + * The suggestion is tagged with its owner so a later [dismissInlineSuggestion] from a different + * plugin can't clear it. No-op if the editor is released or has no cursor. + */ + fun showInlineSuggestion( + pluginId: String, + text: String, + ) { + if (isReleased || text.isEmpty()) return + val renderer = ghostRenderer ?: return + val cursor = cursor ?: return + renderer.setSuggestion(text, cursor.leftLine, cursor.leftColumn, pluginId) + invalidate() + } + + /** Removes the showing ghost text only if it is owned by [pluginId]. */ + fun dismissInlineSuggestion(pluginId: String) { + val renderer = ghostRenderer ?: return + if (renderer.clearSuggestionFor(pluginId)) { + invalidate() + } + } + + /** + * Unconditionally removes any showing ghost text, regardless of owner. Used for IDE-internal + * invalidation (an edit or cursor move makes the anchored suggestion meaningless). + */ + fun dismissInlineSuggestion() { + val renderer = ghostRenderer ?: return + if (renderer.hasSuggestion) { + renderer.clearSuggestion() + invalidate() + } + } + + override fun onKeyDown( + keyCode: Int, + event: KeyEvent, + ): Boolean { + // Accept a showing suggestion on Tab: commit it at the cursor and consume the key. + if (keyCode == KeyEvent.KEYCODE_TAB && ghostRenderer?.hasSuggestion == true) { + val suggestion = ghostRenderer?.takeSuggestion() + invalidate() + if (!suggestion.isNullOrEmpty()) { + commitText(suggestion) + return true + } + } + return super.onKeyDown(keyCode, event) + } + + private fun handleCustomTextReplacement(event: ContentChangeEvent) { + val isEnterPress = + event.action == ContentChangeEvent.ACTION_INSERT && + event.changedText.toString().contains("\n") + + if (!isEnterPress) { + return + } + + val currentFile = this.file ?: return + + editorScope.launch { + dispatchDocumentSaveEvent() + + val lineToProcess = event.changeStart.line + + val context = + ProcessContext( + content = text, + file = currentFile, + cursor = + text.cursor.apply { // set(lineToProcess, text.getLine(lineToProcess).length) - }, - ) - - val result = textProcessorEngine.process(context, isEnterPress) - - if (result != null) { - withContext(Dispatchers.Main) { - post { - val start = result.range.start - val end = result.range.end - - text.replace( - start.line, - start.column, - end.line, - end.column, - result.replacement, - ) - - val newCursorLine = start.line + result.replacement.lines().size - 1 - val newCursorColumn = - result.replacement - .lines() - .last() - .length - - setSelection(newCursorLine, newCursorColumn) - } - } - } - } - } - - private inline fun launchCancellableAsyncWithProgress( - @StringRes message: Int, - crossinline action: suspend CoroutineScope.(flashbar: Flashbar, cancelChecker: ICancelChecker) -> Unit, - ): Job? { - if (isReleased) { - return null - } - - return editorScope.launch { - doAsyncWithProgress( - action = action, - configureFlashbar = { builder, cancelChecker -> - configureFlashbar(builder, message, cancelChecker) - }, - ) - } - } - - protected open suspend fun onFindDefinitionResult(result: DefinitionResult?) = - withContext(Dispatchers.Main) { - if (isReleased) { - return@withContext - } - - val languageClient = - languageClient ?: run { - log.error("No language client found to handle the definitions result") - return@withContext - } - - if (result == null) { - log.error("Invalid definitions result from language server (null)") - flashError(string.msg_no_definition) - return@withContext - } - - val locations = result.locations - if (locations.isEmpty()) { - log.error("No definitions found") - flashError(string.msg_no_definition) - return@withContext - } - - if (locations.size != 1) { - languageClient.showLocations(locations) - return@withContext - } - - val (file1, range) = locations[0] - if (DocumentUtils.isSameFile(file1, file!!.toPath())) { - setSelection(range) - return@withContext - } - - languageClient.showDocument(ShowDocumentParams(file1, range)) - } - - protected open suspend fun onFindReferencesResult(result: ReferenceResult?) = - withContext(Dispatchers.Main) { - if (isReleased) { - return@withContext - } - - val languageClient = - languageClient ?: run { - log.error("No language client found to handle the references result") - return@withContext - } - - if (result == null) { - log.error("Invalid references result from language server (null)") - flashError(string.msg_no_references) - return@withContext - } - - val locations = result.locations - if (locations.isEmpty()) { - log.error("No references found") - flashError(string.msg_no_references) - return@withContext - } - - if (result.locations.size == 1) { - val (file, range) = result.locations[0] - - if (DocumentUtils.isSameFile(file, getFile()!!.toPath())) { - setSelection(range) - return@withContext - } - } - - languageClient.showLocations(result.locations) - } - - protected open fun dispatchDocumentOpenEvent() { - if (isReleased) { - return - } - - val file = this.file ?: return - - this.fileVersion = 0 - - val openEvent = DocumentOpenEvent(file.toPath(), text.toString(), fileVersion) - - eventDispatcher.dispatch(openEvent) - } - - protected open fun dispatchDocumentChangeEvent(event: ContentChangeEvent) { - if (isReleased) { - return - } - - val file = file?.toPath() ?: return - var type = ChangeType.INSERT - if (event.action == ContentChangeEvent.ACTION_DELETE) { - type = ChangeType.DELETE - } else if (event.action == ContentChangeEvent.ACTION_SET_NEW_TEXT) { - type = ChangeType.NEW_TEXT - } - var changeDelta = if (type == ChangeType.NEW_TEXT) 0 else event.changedText.length - if (type == ChangeType.DELETE) { - changeDelta = -changeDelta - } - val start = event.changeStart - val end = event.changeEnd - val changeRange = - Range( - Position(start.line, start.column, start.index), - Position(end.line, end.column, end.index), - ) - val changedText = event.changedText.toString() - val changeEvent = - DocumentChangeEvent( - file, - changedText, - text.toString(), - ++fileVersion, - type, - changeDelta, - changeRange, - ) - - eventDispatcher.dispatch(changeEvent) - } - - protected open fun dispatchDocumentSelectedEvent() { - if (isReleased) { - return - } - val file = file ?: return - eventDispatcher.dispatch(DocumentSelectedEvent(file.toPath())) - } - - protected open fun dispatchDocumentCloseEvent() { - if (isReleased) { - return - } - val file = file ?: return - - eventDispatcher.dispatch(DocumentCloseEvent(file.toPath(), cursorLSPRange)) - } - - /** - * Checks if the content change event should trigger signature help. Signature help trigger - * characters are : - * - * - * * `'('` (parentheses) - * * `','` (comma) - * - * - * @param event The content change event. - */ - private fun checkForSignatureHelp(event: ContentChangeEvent) { - if (isReleased) { - return - } - if (languageServer == null) { - return - } - val changeLength = event.changedText.length - if (event.action != ContentChangeEvent.ACTION_INSERT || changeLength < 1 || changeLength > 2) { - // change length will be 1 if ',' is inserted - // changeLength will be 2 as '(' and ')' are inserted at the same time - return - } - - val ch = event.changedText[0] - if (ch == '(' || ch == ',') { - signatureHelp() - } - } - - private fun configureFlashbar( - builder: Flashbar.Builder, - @StringRes message: Int, - cancelChecker: ICancelChecker, - ) { - builder - .message(message) - .primaryActionText(android.R.string.cancel) - .primaryActionTapListener { bar: Flashbar -> - cancelChecker.cancel() - bar.dismiss() - } - } - - private inline fun safeGet( - name: String, - action: () -> T, - ): T? = - try { - action() - } catch (err: Throwable) { - logError(err, name) - null - } - - private fun Job.logError(action: String): Job = - apply { - invokeOnCompletion { err -> logError(err, action) } - } - - private fun logError( - err: Throwable?, - action: String, - ) { - err ?: return - if (CancelChecker.isCancelled(err)) { - log.warn("{} has been cancelled", action) - } else { - log.error("{} failed", action) - } - } - - override fun setSelectionAround( - line: Int, - column: Int, - ) { - editorFeatures.setSelectionAround(line, column) - } - - fun setSelectionFromPoint(x: Float, y: Float) { - if (isReleased) return - - try { - val packedPos = getPointPosition(x, y) - - val line = (packedPos ushr 32).toInt() - val column = (packedPos and 0xffffffffL).toInt() - if (line < 0 || column < 0) return - - setSelection(line, column) - } catch (e: Exception) { - log.error("Error setting selection from point", e) - } - } - - /** - * Selects the word at the cursor, or if none (e.g. on an operator), selects - * the operator at the cursor so the code-action toolbar can be shown. - */ - fun selectWordOrOperatorAtCursor() { - if (isReleased) return - selectCurrentWord() - if (cursor.isSelected) return - val line = cursor.leftLine - val column = cursor.leftColumn - val columnCount = text.getColumnCount(line) - if (column < 0 || column >= columnCount) return - val range = text.getOperatorRangeAt(line, column) ?: return - val (startCol, endCol) = range - setSelectionRegion(line, startCol, line, endCol) - } -} + }, + ) + + val result = textProcessorEngine.process(context, isEnterPress) + + if (result != null) { + withContext(Dispatchers.Main) { + post { + val start = result.range.start + val end = result.range.end + + text.replace( + start.line, + start.column, + end.line, + end.column, + result.replacement, + ) + + val newCursorLine = start.line + result.replacement.lines().size - 1 + val newCursorColumn = + result.replacement + .lines() + .last() + .length + + setSelection(newCursorLine, newCursorColumn) + } + } + } + } + } + + private inline fun launchCancellableAsyncWithProgress( + @StringRes message: Int, + crossinline action: suspend CoroutineScope.(flashbar: Flashbar, cancelChecker: ICancelChecker) -> Unit, + ): Job? { + if (isReleased) { + return null + } + + return editorScope.launch { + doAsyncWithProgress( + action = action, + configureFlashbar = { builder, cancelChecker -> + configureFlashbar(builder, message, cancelChecker) + }, + ) + } + } + + protected open suspend fun onFindDefinitionResult(result: DefinitionResult?) = + withContext(Dispatchers.Main) { + if (isReleased) { + return@withContext + } + + val languageClient = + languageClient ?: run { + log.error("No language client found to handle the definitions result") + return@withContext + } + + if (result == null) { + log.error("Invalid definitions result from language server (null)") + flashError(string.msg_no_definition) + return@withContext + } + + val locations = result.locations + if (locations.isEmpty()) { + log.error("No definitions found") + flashError(string.msg_no_definition) + return@withContext + } + + if (locations.size != 1) { + languageClient.showLocations(locations) + return@withContext + } + + val (file1, range) = locations[0] + if (DocumentUtils.isSameFile(file1, file!!.toPath())) { + setSelection(range) + return@withContext + } + + languageClient.showDocument(ShowDocumentParams(file1, range)) + } + + protected open suspend fun onFindReferencesResult(result: ReferenceResult?) = + withContext(Dispatchers.Main) { + if (isReleased) { + return@withContext + } + + val languageClient = + languageClient ?: run { + log.error("No language client found to handle the references result") + return@withContext + } + + if (result == null) { + log.error("Invalid references result from language server (null)") + flashError(string.msg_no_references) + return@withContext + } + + val locations = result.locations + if (locations.isEmpty()) { + log.error("No references found") + flashError(string.msg_no_references) + return@withContext + } + + if (result.locations.size == 1) { + val (file, range) = result.locations[0] + + if (DocumentUtils.isSameFile(file, getFile()!!.toPath())) { + setSelection(range) + return@withContext + } + } + + languageClient.showLocations(result.locations) + } + + protected open fun dispatchDocumentOpenEvent() { + if (isReleased) { + return + } + + val file = this.file ?: return + + this.fileVersion = 0 + + val openEvent = DocumentOpenEvent(file.toPath(), text.toString(), fileVersion) + + eventDispatcher.dispatch(openEvent) + } + + protected open fun dispatchDocumentChangeEvent(event: ContentChangeEvent) { + if (isReleased) { + return + } + + val file = file?.toPath() ?: return + var type = ChangeType.INSERT + if (event.action == ContentChangeEvent.ACTION_DELETE) { + type = ChangeType.DELETE + } else if (event.action == ContentChangeEvent.ACTION_SET_NEW_TEXT) { + type = ChangeType.NEW_TEXT + } + var changeDelta = if (type == ChangeType.NEW_TEXT) 0 else event.changedText.length + if (type == ChangeType.DELETE) { + changeDelta = -changeDelta + } + val start = event.changeStart + val end = event.changeEnd + val changeRange = + Range( + Position(start.line, start.column, start.index), + Position(end.line, end.column, end.index), + ) + val changedText = event.changedText.toString() + val changeEvent = + DocumentChangeEvent( + file, + changedText, + text.toString(), + ++fileVersion, + type, + changeDelta, + changeRange, + ) + + eventDispatcher.dispatch(changeEvent) + } + + protected open fun dispatchDocumentSelectedEvent() { + if (isReleased) { + return + } + val file = file ?: return + eventDispatcher.dispatch(DocumentSelectedEvent(file.toPath())) + } + + protected open fun dispatchDocumentCloseEvent() { + if (isReleased) { + return + } + val file = file ?: return + + eventDispatcher.dispatch(DocumentCloseEvent(file.toPath(), cursorLSPRange)) + } + + /** + * Checks if the content change event should trigger signature help. Signature help trigger + * characters are : + * + * + * * `'('` (parentheses) + * * `','` (comma) + * + * + * @param event The content change event. + */ + private fun checkForSignatureHelp(event: ContentChangeEvent) { + if (isReleased) { + return + } + if (languageServer == null) { + return + } + val changeLength = event.changedText.length + if (event.action != ContentChangeEvent.ACTION_INSERT || changeLength < 1 || changeLength > 2) { + // change length will be 1 if ',' is inserted + // changeLength will be 2 as '(' and ')' are inserted at the same time + return + } + + val ch = event.changedText[0] + if (ch == '(' || ch == ',') { + signatureHelp() + } + } + + private fun configureFlashbar( + builder: Flashbar.Builder, + @StringRes message: Int, + cancelChecker: ICancelChecker, + ) { + builder + .message(message) + .primaryActionText(android.R.string.cancel) + .primaryActionTapListener { bar: Flashbar -> + cancelChecker.cancel() + bar.dismiss() + } + } + + private inline fun safeGet( + name: String, + action: () -> T, + ): T? = + try { + action() + } catch (err: Throwable) { + logError(err, name) + null + } + + private fun Job.logError(action: String): Job = + apply { + invokeOnCompletion { err -> logError(err, action) } + } + + private fun logError( + err: Throwable?, + action: String, + ) { + err ?: return + if (CancelChecker.isCancelled(err)) { + log.warn("{} has been cancelled", action) + } else { + log.error("{} failed", action) + } + } + + override fun setSelectionAround( + line: Int, + column: Int, + ) { + editorFeatures.setSelectionAround(line, column) + } + + fun setSelectionFromPoint( + x: Float, + y: Float, + ) { + if (isReleased) return + + try { + val packedPos = getPointPosition(x, y) + + val line = (packedPos ushr 32).toInt() + val column = (packedPos and 0xffffffffL).toInt() + if (line < 0 || column < 0) return + + setSelection(line, column) + } catch (e: Exception) { + log.error("Error setting selection from point", e) + } + } + + /** + * Selects the word at the cursor, or if none (e.g. on an operator), selects + * the operator at the cursor so the code-action toolbar can be shown. + */ + fun selectWordOrOperatorAtCursor() { + if (isReleased) return + selectCurrentWord() + if (cursor.isSelected) return + val line = cursor.leftLine + val column = cursor.leftColumn + val columnCount = text.getColumnCount(line) + if (column < 0 || column >= columnCount) return + val range = text.getOperatorRangeAt(line, column) ?: return + val (startCol, endCol) = range + setSelectionRegion(line, startCol, line, endCol) + } + } diff --git a/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/drawable/UiViewLayeredForeground.kt b/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/drawable/UiViewLayeredForeground.kt index a7e37739c1..181d2573f3 100644 --- a/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/drawable/UiViewLayeredForeground.kt +++ b/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/drawable/UiViewLayeredForeground.kt @@ -20,8 +20,8 @@ package com.itsaky.androidide.uidesigner.drawable import android.content.Context import android.graphics.drawable.Drawable import android.graphics.drawable.LayerDrawable -import com.blankj.utilcode.util.SizeUtils import com.itsaky.androidide.uidesigner.utils.bgDesignerView +import com.itsaky.androidide.utils.dpToPx /** * Marker class to be able to differentiate between normal foregrounds and already layered @@ -29,12 +29,14 @@ import com.itsaky.androidide.uidesigner.utils.bgDesignerView * * @author Akash Yadav */ -class UiViewLayeredForeground(context: Context, val src: Drawable) : LayerDrawable(emptyArray()) -{ - init { - val dp1 = SizeUtils.dp2px(1f) - val index = addLayer(src) - setLayerInsetRelative(index, dp1, dp1, dp1, dp1) - bgDesignerView(context)?.let { addLayer(it) } - } +class UiViewLayeredForeground( + context: Context, + val src: Drawable, +) : LayerDrawable(emptyArray()) { + init { + val dp1 = context.dpToPx(1f) + val index = addLayer(src) + setLayerInsetRelative(index, dp1, dp1, dp1, dp1) + bgDesignerView(context)?.let { addLayer(it) } + } } diff --git a/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/fragments/DesignerWorkspaceFragment.kt b/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/fragments/DesignerWorkspaceFragment.kt index 185b95efc8..9501f5f936 100644 --- a/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/fragments/DesignerWorkspaceFragment.kt +++ b/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/fragments/DesignerWorkspaceFragment.kt @@ -24,7 +24,6 @@ import android.view.ViewConfiguration.get import android.view.ViewGroup import android.widget.LinearLayout import androidx.fragment.app.viewModels -import com.blankj.utilcode.util.SizeUtils import com.itsaky.androidide.fragments.BaseFragment import com.itsaky.androidide.inflater.IView import com.itsaky.androidide.inflater.internal.LayoutFile @@ -45,6 +44,7 @@ import com.itsaky.androidide.uidesigner.utils.UiLayoutInflater import com.itsaky.androidide.uidesigner.utils.bgDesignerView import com.itsaky.androidide.uidesigner.utils.layeredForeground import com.itsaky.androidide.uidesigner.viewmodel.WorkspaceViewModel +import com.itsaky.androidide.utils.dpToPx import org.slf4j.LoggerFactory import java.io.File @@ -54,160 +54,167 @@ import java.io.File * @author Akash Yadav */ class DesignerWorkspaceFragment : BaseFragment() { - - private var binding: FragmentDesignerWorkspaceBinding? = null - internal val viewModel by viewModels(ownerProducer = { requireActivity() }) - - private val touchSlop by lazy { get(requireContext()).scaledTouchSlop } - - internal var isInflating = false - internal val workspaceView by lazy { - RootWorkspaceView( - LayoutFile( - File("") - , "" - ), - LinearLayout::class.qualifiedName!!, - binding!!.workspace - ) - } - - val undoManager: UndoManager - get() = viewModel.undoManager - - internal val placeholder by lazy { - val view = - View(requireContext()).apply { - setBackgroundResource(R.drawable.bg_widget_drag_placeholder) - layoutParams = - ViewGroup.LayoutParams( - SizeUtils.dp2px(PLACEHOLDER_WIDTH_DP), - SizeUtils.dp2px(PLACEHOLDER_HEIGHT_DP) - ) - } - PlaceholderView(view) - } - - private val hierarchyHandler by lazy { WorkspaceViewHierarchyHandler() } - private val attrHandler by lazy { WorkspaceViewAttrHandler() } - - companion object { - - private val log = LoggerFactory.getLogger(DesignerWorkspaceFragment::class.java) - - const val DRAGGING_WIDGET = "DRAGGING_WIDGET" - const val DRAGGING_WIDGET_MIME = "androidide/uidesigner_widget" - const val HIERARCHY_CHANGE_TRANSITION_DURATION = 100L - - private const val PLACEHOLDER_WIDTH_DP = 40f - private const val PLACEHOLDER_HEIGHT_DP = 20f - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - this.binding = FragmentDesignerWorkspaceBinding.inflate(inflater, container, false) - hierarchyHandler.init(this) - attrHandler.init(this) - return this.binding!!.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - viewModel._workspaceScreen.observe(viewLifecycleOwner) { binding?.flipper?.displayedChild = it } - viewModel._errText.observe(viewLifecycleOwner) { binding?.errText?.text = it } - - val inflationHandler = WorkspaceLayoutInflationHandler() - inflationHandler.init(this) - - val inflater = UiLayoutInflater() - inflater.inflationEventListener = inflationHandler - - val inflated = - try { - startParse(viewModel.file) - inflater.inflate(viewModel.file, workspaceView).also { - viewModel.layoutHasError = false - } - } catch (e: Throwable) { - log.error("Failed to inflate layout", e) - viewModel.errText = "${e.message}${e.cause?.message?.let { "\n$it" } ?: ""}" - viewModel.layoutHasError = true - emptyList() - } finally { - inflationHandler.release() - inflater.close() - } - - if (inflated.isEmpty() && !viewModel.layoutHasError) { - viewModel.errText = getString(R.string.msg_empty_ui_layout) - } - - binding!! - .workspace - .setOnDragListener(WidgetDragListener(workspaceView, this.placeholder, touchSlop)) - } - - override fun onDestroyView() { - super.onDestroyView() - this.binding = null - this.hierarchyHandler.release() - this.attrHandler.release() - - endParse() - } - - internal fun setupView(view: IView) { - if (view is CommonUiView && !view.needSetup) { - return - } - - view.registerAttributeChangeListener(attrHandler) - view.view.setOnTouchListener( - WidgetTouchListener(view, requireContext()) { - showViewInfo(it) - true - } - ) - - when (val fg = view.view.foreground) { - null -> view.view.foreground = bgDesignerView(requireContext()) - is UiViewLayeredForeground -> - log.warn("Attempt to reset UiViewLayeredForeground on view {} with foreground drawable type {}", view.name, fg::class.java) - - else -> view.view.foreground = layeredForeground(requireContext(), fg) - } - - if (view is UiViewGroup && view.canModifyChildViews()) { - setupViewGroup(view) - } - - if (view is CommonUiView) { - view.needSetup = false - } - } - - internal fun showViewInfo(view: IView) { - viewModel.view = view - - val existing = childFragmentManager.findFragmentByTag(ViewInfoSheet.TAG) - if (existing == null) { - val viewInfo = ViewInfoSheet() - viewInfo.show(childFragmentManager, ViewInfoSheet.TAG) - } - } - - private fun setupViewGroup(viewGroup: UiViewGroup) { - viewGroup.view.setOnDragListener(WidgetDragListener(viewGroup, placeholder, touchSlop)) - viewGroup.addOnHierarchyChangeListener(hierarchyHandler) - } - - fun updateHierarchy() { - if (workspaceView.childCount > 0) { - (requireActivity() as UIDesignerActivity).setupHierarchy(workspaceView[0]) - } - } + private var binding: FragmentDesignerWorkspaceBinding? = null + internal val viewModel by viewModels(ownerProducer = { requireActivity() }) + + private val touchSlop by lazy { get(requireContext()).scaledTouchSlop } + + internal var isInflating = false + internal val workspaceView by lazy { + RootWorkspaceView( + LayoutFile( + File(""), + "", + ), + LinearLayout::class.qualifiedName!!, + binding!!.workspace, + ) + } + + val undoManager: UndoManager + get() = viewModel.undoManager + + internal val placeholder by lazy { + val view = + View(requireContext()).apply { + setBackgroundResource(R.drawable.bg_widget_drag_placeholder) + layoutParams = + ViewGroup.LayoutParams( + requireContext().dpToPx(PLACEHOLDER_WIDTH_DP), + requireContext().dpToPx(PLACEHOLDER_HEIGHT_DP), + ) + } + PlaceholderView(view) + } + + private val hierarchyHandler by lazy { WorkspaceViewHierarchyHandler() } + private val attrHandler by lazy { WorkspaceViewAttrHandler() } + + companion object { + private val log = LoggerFactory.getLogger(DesignerWorkspaceFragment::class.java) + + const val DRAGGING_WIDGET = "DRAGGING_WIDGET" + const val DRAGGING_WIDGET_MIME = "androidide/uidesigner_widget" + const val HIERARCHY_CHANGE_TRANSITION_DURATION = 100L + + private const val PLACEHOLDER_WIDTH_DP = 40f + private const val PLACEHOLDER_HEIGHT_DP = 20f + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + this.binding = FragmentDesignerWorkspaceBinding.inflate(inflater, container, false) + hierarchyHandler.init(this) + attrHandler.init(this) + return this.binding!!.root + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + viewModel._workspaceScreen.observe(viewLifecycleOwner) { binding?.flipper?.displayedChild = it } + viewModel._errText.observe(viewLifecycleOwner) { binding?.errText?.text = it } + + val inflationHandler = WorkspaceLayoutInflationHandler() + inflationHandler.init(this) + + val inflater = UiLayoutInflater() + inflater.inflationEventListener = inflationHandler + + val inflated = + try { + startParse(viewModel.file) + inflater.inflate(viewModel.file, workspaceView).also { + viewModel.layoutHasError = false + } + } catch (e: Throwable) { + log.error("Failed to inflate layout", e) + viewModel.errText = "${e.message}${e.cause?.message?.let { "\n$it" } ?: ""}" + viewModel.layoutHasError = true + emptyList() + } finally { + inflationHandler.release() + inflater.close() + } + + if (inflated.isEmpty() && !viewModel.layoutHasError) { + viewModel.errText = getString(R.string.msg_empty_ui_layout) + } + + binding!! + .workspace + .setOnDragListener(WidgetDragListener(workspaceView, this.placeholder, touchSlop)) + } + + override fun onDestroyView() { + super.onDestroyView() + this.binding = null + this.hierarchyHandler.release() + this.attrHandler.release() + + endParse() + } + + internal fun setupView(view: IView) { + if (view is CommonUiView && !view.needSetup) { + return + } + + view.registerAttributeChangeListener(attrHandler) + view.view.setOnTouchListener( + WidgetTouchListener(view, requireContext()) { + showViewInfo(it) + true + }, + ) + + when (val fg = view.view.foreground) { + null -> { + view.view.foreground = bgDesignerView(requireContext()) + } + + is UiViewLayeredForeground -> { + log.warn("Attempt to reset UiViewLayeredForeground on view {} with foreground drawable type {}", view.name, fg::class.java) + } + + else -> { + view.view.foreground = layeredForeground(requireContext(), fg) + } + } + + if (view is UiViewGroup && view.canModifyChildViews()) { + setupViewGroup(view) + } + + if (view is CommonUiView) { + view.needSetup = false + } + } + + internal fun showViewInfo(view: IView) { + viewModel.view = view + + val existing = childFragmentManager.findFragmentByTag(ViewInfoSheet.TAG) + if (existing == null) { + val viewInfo = ViewInfoSheet() + viewInfo.show(childFragmentManager, ViewInfoSheet.TAG) + } + } + + private fun setupViewGroup(viewGroup: UiViewGroup) { + viewGroup.view.setOnDragListener(WidgetDragListener(viewGroup, placeholder, touchSlop)) + viewGroup.addOnHierarchyChangeListener(hierarchyHandler) + } + + fun updateHierarchy() { + if (workspaceView.childCount > 0) { + (requireActivity() as UIDesignerActivity).setupHierarchy(workspaceView[0]) + } + } } diff --git a/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/views/LayoutHierarchyView.kt b/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/views/LayoutHierarchyView.kt index 6e23565410..4a04793a9e 100644 --- a/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/views/LayoutHierarchyView.kt +++ b/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/views/LayoutHierarchyView.kt @@ -29,10 +29,10 @@ import android.view.View.OnClickListener import android.widget.LinearLayout import androidx.core.view.updateMarginsRelative import androidx.core.view.updatePaddingRelative -import com.blankj.utilcode.util.SizeUtils import com.google.android.material.textview.MaterialTextView import com.itsaky.androidide.inflater.IView import com.itsaky.androidide.uidesigner.R +import com.itsaky.androidide.utils.dpToPx import com.itsaky.androidide.utils.resolveAttr /** @@ -41,131 +41,141 @@ import com.itsaky.androidide.utils.resolveAttr * @author Akash Yadav */ class LayoutHierarchyView -@JvmOverloads -constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0, - defStyleRes: Int = 0 -) : LinearLayout(context, attrs, defStyleAttr, defStyleRes) { - - private val textToIView = mutableMapOf() - private var onClick: ((IView) -> Unit)? = null - - private val clickListener = OnClickListener { view -> - onClick?.let { click -> textToIView[view]?.let(click) } - } - - private val paint = Paint() - private val pointRadius = SizeUtils.dp2px(3f).toFloat() - private val dp8 = SizeUtils.dp2px(8f) - private val dp16 = dp8 * 2 - - init { - orientation = VERTICAL - - paint.color = context.resolveAttr(R.attr.colorOutline) - paint.strokeWidth = SizeUtils.dp2px(1f).toFloat() - paint.isAntiAlias = true - } - - fun setupWithView(view: IView, onClick: ((IView) -> Unit)? = null) { - removeAllViews() - textToIView.clear() - - this.onClick = onClick - addViews(view, 1) - } - - private fun addViews(view: IView, depth: Int) { - val text = - HierarchyText(context, depth, dp16).apply { - this.text = view.tag - setOnClickListener(clickListener) - } - addView(text) - - if (view is com.itsaky.androidide.inflater.IViewGroup) { - text.childCount = computeChildCount(view) - view.forEach { addViews(it, depth + 1) } - } - - textToIView[text] = view - } - - private fun computeChildCount(view: com.itsaky.androidide.inflater.IViewGroup): Int { - var count = view.childCount - view.forEachIndexed { index, child -> - if (index != view.childCount - 1 && child is com.itsaky.androidide.inflater.IViewGroup) { - count += computeChildCount(child) - } - } - return count - } - - override fun dispatchDraw(canvas: Canvas) { - super.dispatchDraw(canvas) - - if (childCount == 0) { - return - } - - for (i in 0 until childCount) { - drawView(getChildAt(i) as HierarchyText, canvas) - } - } - - private fun drawView(view: HierarchyText, canvas: Canvas) { - - val left = view.left - val top = view.top - val h = view.height - - val mid = top + (h / 2) - val pX = left.toFloat() - val pY = mid.toFloat() - - if (view.hasChildren) { - canvas.drawRect( - left - pointRadius, - mid - pointRadius, - left + pointRadius, - mid + pointRadius, - paint - ) - - val endY = pY + (h * view.childCount) - canvas.drawLine(pX, pY, pX, endY, paint) - } else { - canvas.drawCircle(pX, pY, pointRadius, paint) - } - if (view.depth > 1) { - canvas.drawLine(pX - dp16, pY, pX, pY, paint) - } - } - - @SuppressLint("ViewConstructor") - class HierarchyText(context: Context, var depth: Int, private val offset: Int) : - MaterialTextView(context) { - - var childCount = 0 - val hasChildren: Boolean - get() = childCount > 0 - - init { - maxLines = 1 - isClickable = true - isFocusable = true - ellipsize = MIDDLE - gravity = Gravity.CENTER_VERTICAL - layoutParams = - MarginLayoutParams(LayoutParams.MATCH_PARENT, SizeUtils.dp2px(48f)).apply { - updateMarginsRelative(start = depth * offset) - } - setBackgroundResource(R.drawable.bg_ripple) - setTextAppearance(R.style.TextAppearance_Material3_BodyMedium) - setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) - updatePaddingRelative(start = offset, end = offset) - } - } -} + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0, + ) : LinearLayout(context, attrs, defStyleAttr, defStyleRes) { + private val textToIView = mutableMapOf() + private var onClick: ((IView) -> Unit)? = null + + private val clickListener = + OnClickListener { view -> + onClick?.let { click -> textToIView[view]?.let(click) } + } + + private val paint = Paint() + private val pointRadius = context.dpToPx(3f).toFloat() + private val dp8 = context.dpToPx(8f) + private val dp16 = dp8 * 2 + + init { + orientation = VERTICAL + + paint.color = context.resolveAttr(R.attr.colorOutline) + paint.strokeWidth = context.dpToPx(1f).toFloat() + paint.isAntiAlias = true + } + + fun setupWithView( + view: IView, + onClick: ((IView) -> Unit)? = null, + ) { + removeAllViews() + textToIView.clear() + + this.onClick = onClick + addViews(view, 1) + } + + private fun addViews( + view: IView, + depth: Int, + ) { + val text = + HierarchyText(context, depth, dp16).apply { + this.text = view.tag + setOnClickListener(clickListener) + } + addView(text) + + if (view is com.itsaky.androidide.inflater.IViewGroup) { + text.childCount = computeChildCount(view) + view.forEach { addViews(it, depth + 1) } + } + + textToIView[text] = view + } + + private fun computeChildCount(view: com.itsaky.androidide.inflater.IViewGroup): Int { + var count = view.childCount + view.forEachIndexed { index, child -> + if (index != view.childCount - 1 && child is com.itsaky.androidide.inflater.IViewGroup) { + count += computeChildCount(child) + } + } + return count + } + + override fun dispatchDraw(canvas: Canvas) { + super.dispatchDraw(canvas) + + if (childCount == 0) { + return + } + + for (i in 0 until childCount) { + drawView(getChildAt(i) as HierarchyText, canvas) + } + } + + private fun drawView( + view: HierarchyText, + canvas: Canvas, + ) { + val left = view.left + val top = view.top + val h = view.height + + val mid = top + (h / 2) + val pX = left.toFloat() + val pY = mid.toFloat() + + if (view.hasChildren) { + canvas.drawRect( + left - pointRadius, + mid - pointRadius, + left + pointRadius, + mid + pointRadius, + paint, + ) + + val endY = pY + (h * view.childCount) + canvas.drawLine(pX, pY, pX, endY, paint) + } else { + canvas.drawCircle(pX, pY, pointRadius, paint) + } + if (view.depth > 1) { + canvas.drawLine(pX - dp16, pY, pX, pY, paint) + } + } + + @SuppressLint("ViewConstructor") + class HierarchyText( + context: Context, + var depth: Int, + private val offset: Int, + ) : MaterialTextView(context) { + var childCount = 0 + val hasChildren: Boolean + get() = childCount > 0 + + init { + maxLines = 1 + isClickable = true + isFocusable = true + ellipsize = MIDDLE + gravity = Gravity.CENTER_VERTICAL + layoutParams = + MarginLayoutParams(LayoutParams.MATCH_PARENT, context.dpToPx(48f)).apply { + updateMarginsRelative(start = depth * offset) + } + setBackgroundResource(R.drawable.bg_ripple) + setTextAppearance(R.style.TextAppearance_Material3_BodyMedium) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) + updatePaddingRelative(start = offset, end = offset) + } + } + } diff --git a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/adapters/ListViewAdapter.kt b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/adapters/ListViewAdapter.kt index 7885c52193..0c090ef2f3 100644 --- a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/adapters/ListViewAdapter.kt +++ b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/adapters/ListViewAdapter.kt @@ -20,7 +20,6 @@ package com.itsaky.androidide.inflater.internal.adapters import android.R.layout import android.widget.ArrayAdapter import android.widget.ListView -import com.blankj.utilcode.util.SizeUtils import com.itsaky.androidide.annotations.inflater.ViewAdapter import com.itsaky.androidide.annotations.uidesigner.IncludeInDesigner import com.itsaky.androidide.annotations.uidesigner.IncludeInDesigner.Group.WIDGETS @@ -28,6 +27,7 @@ import com.itsaky.androidide.inflater.AttributeHandlerScope import com.itsaky.androidide.inflater.models.UiWidget import com.itsaky.androidide.resources.R.drawable import com.itsaky.androidide.resources.R.string +import com.itsaky.androidide.utils.dpToPx /** * Attribute adapter for [ListView]. @@ -37,24 +37,22 @@ import com.itsaky.androidide.resources.R.string @ViewAdapter(ListView::class) @IncludeInDesigner(group = WIDGETS) open class ListViewAdapter : AbsListViewAdapter() { + override fun createAttrHandlers(create: (String, AttributeHandlerScope.() -> Unit) -> Unit) { + super.createAttrHandlers(create) + create("divider") { view.divider = parseDrawable(context, value) } + create("dividerHeight") { + view.dividerHeight = parseDimension(context, value, context.dpToPx(1f)) + } + create("entries") { + val entries = parseStringArray(value) + view.adapter = ArrayAdapter(context, layout.simple_list_item_1, entries) + } + create("footerDividersEnabled") { view.setFooterDividersEnabled(parseBoolean(value)) } + create("headerDividersEnabled") { view.setHeaderDividersEnabled(parseBoolean(value)) } + } - override fun createAttrHandlers(create: (String, AttributeHandlerScope.() -> Unit) -> Unit) { - super.createAttrHandlers(create) - create("divider") { view.divider = parseDrawable(context, value) } - create("dividerHeight") { - view.dividerHeight = parseDimension(context, value, SizeUtils.dp2px(1f)) - } - create("entries") { - val entries = parseStringArray(value) - view.adapter = ArrayAdapter(context, layout.simple_list_item_1, entries) - } - create("footerDividersEnabled") { view.setFooterDividersEnabled(parseBoolean(value)) } - create("headerDividersEnabled") { view.setHeaderDividersEnabled(parseBoolean(value)) } - } - - override fun createUiWidgets(): List { - return listOf( - UiWidget(ListView::class.java, string.widget_listview, drawable.ic_widget_list_view) - ) - } + override fun createUiWidgets(): List = + listOf( + UiWidget(ListView::class.java, string.widget_listview, drawable.ic_widget_list_view), + ) } diff --git a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/adapters/TextViewAdapter.kt b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/adapters/TextViewAdapter.kt index 9710c7c3c7..3532da035c 100644 --- a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/adapters/TextViewAdapter.kt +++ b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/adapters/TextViewAdapter.kt @@ -23,13 +23,13 @@ import android.text.TextUtils import android.text.util.Linkify import android.util.TypedValue import android.widget.TextView -import com.blankj.utilcode.util.SizeUtils import com.itsaky.androidide.annotations.uidesigner.IncludeInDesigner import com.itsaky.androidide.annotations.uidesigner.IncludeInDesigner.Group.WIDGETS import com.itsaky.androidide.inflater.AttributeHandlerScope import com.itsaky.androidide.inflater.IView import com.itsaky.androidide.inflater.models.UiWidget import com.itsaky.androidide.resources.R +import com.itsaky.androidide.utils.spToPx import java.util.regex.Pattern /** @@ -40,161 +40,155 @@ import java.util.regex.Pattern @com.itsaky.androidide.annotations.inflater.ViewAdapter(TextView::class) @IncludeInDesigner(group = WIDGETS) open class TextViewAdapter : ViewAdapter() { + override fun createAttrHandlers(create: (String, AttributeHandlerScope.() -> Unit) -> Unit) { + super.createAttrHandlers(create) + create("autoLink") { view.autoLinkMask = parseAutoLinkMask(value) } + create("drawableLeft") { + val drawables = view.compoundDrawables + view.setCompoundDrawables( + parseDrawable(context, value), + drawables[1], + drawables[2], + drawables[3], + ) + } + create("drawableTop") { + val drawables = view.compoundDrawables + view.setCompoundDrawables( + drawables[0], + parseDrawable(context, value), + drawables[2], + drawables[3], + ) + } + create("drawableRight") { + val drawables = view.compoundDrawables + view.setCompoundDrawables( + drawables[0], + drawables[1], + parseDrawable(context, value), + drawables[3], + ) + } + create("drawableBottom") { + val drawables = view.compoundDrawables + view.setCompoundDrawables( + drawables[0], + drawables[1], + drawables[2], + parseDrawable(context, value), + ) + } + create("drawableStart") { + val drawablesRelative = view.compoundDrawablesRelative + view.setCompoundDrawables( + parseDrawable(context, value), + drawablesRelative[1], + drawablesRelative[2], + drawablesRelative[3], + ) + } + create("drawableEnd") { + val drawablesRelative = view.compoundDrawablesRelative + view.setCompoundDrawables( + drawablesRelative[0], + drawablesRelative[1], + parseDrawable(context, value), + drawablesRelative[3], + ) + } + create("drawablePadding") { view.compoundDrawablePadding = parseDimension(context, value, 0) } + create("ellipsize") { view.ellipsize = parseEllipsize(value) } + create("gravity") { view.gravity = parseGravity(value) } + create("hint") { view.hint = parseString(value) } + create("letterSpacing") { view.letterSpacing = parseFloat(value) } + create("lineHeight") { view.setLines(parseInteger(value, Int.MAX_VALUE)) } + create("linksClickable") { view.linksClickable = parseBoolean(value) } + create("marqueeRepeatLimit") { view.marqueeRepeatLimit = parseInteger(value, Int.MAX_VALUE) } + create("maxLines") { view.maxLines = parseInteger(value, Int.MAX_VALUE) } + create("minLines") { view.minLines = parseInteger(value, 1) } + create("singleLine") { view.isSingleLine = parseBoolean(value) } + create("text") { view.text = parseString(value) } + create("textAllCaps") { view.isAllCaps = parseBoolean(value) } + create("textColor") { view.setTextColor(parseColor(context, value)) } + create("textColorHint") { view.setHintTextColor(parseColor(context, value)) } + create("textSize") { + view.setTextSize( + TypedValue.COMPLEX_UNIT_PX, + parseDimensionF(context, value, context.spToPx(14f).toFloat()), + ) + } + create("textStyle") { view.setTypeface(view.typeface, parseTextStyle(value)) } + create("typeface") { view.typeface = parseTypeface(value) } + } - override fun createAttrHandlers(create: (String, AttributeHandlerScope.() -> Unit) -> Unit) { - super.createAttrHandlers(create) - create("autoLink") { view.autoLinkMask = parseAutoLinkMask(value) } - create("drawableLeft") { - val drawables = view.compoundDrawables - view.setCompoundDrawables( - parseDrawable(context, value), - drawables[1], - drawables[2], - drawables[3] - ) - } - create("drawableTop") { - val drawables = view.compoundDrawables - view.setCompoundDrawables( - drawables[0], - parseDrawable(context, value), - drawables[2], - drawables[3] - ) - } - create("drawableRight") { - val drawables = view.compoundDrawables - view.setCompoundDrawables( - drawables[0], - drawables[1], - parseDrawable(context, value), - drawables[3] - ) - } - create("drawableBottom") { - val drawables = view.compoundDrawables - view.setCompoundDrawables( - drawables[0], - drawables[1], - drawables[2], - parseDrawable(context, value) - ) - } - create("drawableStart") { - val drawablesRelative = view.compoundDrawablesRelative - view.setCompoundDrawables( - parseDrawable(context, value), - drawablesRelative[1], - drawablesRelative[2], - drawablesRelative[3] - ) - } - create("drawableEnd") { - val drawablesRelative = view.compoundDrawablesRelative - view.setCompoundDrawables( - drawablesRelative[0], - drawablesRelative[1], - parseDrawable(context, value), - drawablesRelative[3] - ) - } - create("drawablePadding") { view.compoundDrawablePadding = parseDimension(context, value, 0) } - create("ellipsize") { view.ellipsize = parseEllipsize(value) } - create("gravity") { view.gravity = parseGravity(value) } - create("hint") { view.hint = parseString(value) } - create("letterSpacing") { view.letterSpacing = parseFloat(value) } - create("lineHeight") { view.setLines(parseInteger(value, Int.MAX_VALUE)) } - create("linksClickable") { view.linksClickable = parseBoolean(value) } - create("marqueeRepeatLimit") { view.marqueeRepeatLimit = parseInteger(value, Int.MAX_VALUE) } - create("maxLines") { view.maxLines = parseInteger(value, Int.MAX_VALUE) } - create("minLines") { view.minLines = parseInteger(value, 1) } - create("singleLine") { view.isSingleLine = parseBoolean(value) } - create("text") { view.text = parseString(value) } - create("textAllCaps") { view.isAllCaps = parseBoolean(value) } - create("textColor") { view.setTextColor(parseColor(context, value)) } - create("textColorHint") { view.setHintTextColor(parseColor(context, value)) } - create("textSize") { - view.setTextSize( - TypedValue.COMPLEX_UNIT_PX, - parseDimensionF(context, value, SizeUtils.sp2px(14f).toFloat()) - ) - } - create("textStyle") { view.setTypeface(view.typeface, parseTextStyle(value)) } - create("typeface") { view.typeface = parseTypeface(value) } - } + override fun createUiWidgets(): List = + listOf( + UiWidget(TextView::class.java, R.string.widget_textview, R.drawable.ic_widget_textview), + ) - override fun createUiWidgets(): List { - return listOf( - UiWidget(TextView::class.java, R.string.widget_textview, R.drawable.ic_widget_textview) - ) - } + @SuppressLint("SetTextI18n") + override fun applyBasic(view: IView) { + super.applyBasic(view) + (view.view as? TextView)?.text = "AndroidIDE" + } - @SuppressLint("SetTextI18n") - override fun applyBasic(view: IView) { - super.applyBasic(view) - (view.view as? TextView)?.text = "AndroidIDE" - } + protected open fun parseTextStyle(value: String): Int { + val splits: Array = + value.split(Pattern.quote("|").toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + var mask = 0 + for (split in splits) { + mask = mask or textStyleFor(split) + } + return mask + } - protected open fun parseTextStyle(value: String): Int { - val splits: Array = - value.split(Pattern.quote("|").toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - var mask = 0 - for (split in splits) { - mask = mask or textStyleFor(split) - } - return mask - } + protected open fun textStyleFor(split: String): Int = + when (split) { + "bold" -> Typeface.BOLD + "italic" -> Typeface.ITALIC + "normal" -> Typeface.NORMAL + else -> Typeface.NORMAL + } - protected open fun textStyleFor(split: String): Int { - return when (split) { - "bold" -> Typeface.BOLD - "italic" -> Typeface.ITALIC - "normal" -> Typeface.NORMAL - else -> Typeface.NORMAL - } - } + protected open fun parseTypeface(value: String): Typeface? = + when (value) { + "sans" -> Typeface.SANS_SERIF + "serif" -> Typeface.SERIF + "monospace" -> Typeface.MONOSPACE + "normal" -> Typeface.DEFAULT + else -> Typeface.DEFAULT + } - protected open fun parseTypeface(value: String): Typeface? { - return when (value) { - "sans" -> Typeface.SANS_SERIF - "serif" -> Typeface.SERIF - "monospace" -> Typeface.MONOSPACE - "normal" -> Typeface.DEFAULT - else -> Typeface.DEFAULT - } - } + protected open fun parseEllipsize(value: String): TextUtils.TruncateAt? = + when (value) { + "end" -> TextUtils.TruncateAt.END + "start" -> TextUtils.TruncateAt.START + "marquee" -> TextUtils.TruncateAt.MARQUEE + "middle" -> TextUtils.TruncateAt.MIDDLE + "none" -> null + else -> null + } - protected open fun parseEllipsize(value: String): TextUtils.TruncateAt? { - return when (value) { - "end" -> TextUtils.TruncateAt.END - "start" -> TextUtils.TruncateAt.START - "marquee" -> TextUtils.TruncateAt.MARQUEE - "middle" -> TextUtils.TruncateAt.MIDDLE - "none" -> null - else -> null - } - } + protected open fun parseAutoLinkMask(value: String): Int { + val splits: Array = + value.split(Pattern.quote("|").toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + var mask = 0 + for (split in splits) { + mask = mask or autoLinkMaskFor(split) + } + return mask + } - protected open fun parseAutoLinkMask(value: String): Int { - val splits: Array = - value.split(Pattern.quote("|").toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - var mask = 0 - for (split in splits) { - mask = mask or autoLinkMaskFor(split) - } - return mask - } - - @Suppress("DEPRECATION") - protected open fun autoLinkMaskFor(mask: String): Int { - return when (mask) { - "all" -> Linkify.ALL - "web" -> Linkify.WEB_URLS - "phone" -> Linkify.PHONE_NUMBERS - "map" -> Linkify.MAP_ADDRESSES - "email" -> Linkify.EMAIL_ADDRESSES - "none" -> 0 - else -> 0 - } - } + @Suppress("DEPRECATION") + protected open fun autoLinkMaskFor(mask: String): Int = + when (mask) { + "all" -> Linkify.ALL + "web" -> Linkify.WEB_URLS + "phone" -> Linkify.PHONE_NUMBERS + "map" -> Linkify.MAP_ADDRESSES + "email" -> Linkify.EMAIL_ADDRESSES + "none" -> 0 + else -> 0 + } } diff --git a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/models/UiWidget.kt b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/models/UiWidget.kt index d6fd6d126a..1ed4fb9a11 100644 --- a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/models/UiWidget.kt +++ b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/models/UiWidget.kt @@ -23,7 +23,6 @@ import android.view.ViewGroup import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.core.view.updatePaddingRelative -import com.blankj.utilcode.util.SizeUtils import com.itsaky.androidide.inflater.IView import com.itsaky.androidide.inflater.IViewGroup import com.itsaky.androidide.inflater.internal.LayoutFile @@ -33,64 +32,76 @@ import com.itsaky.androidide.inflater.internal.utils.ViewFactory import com.itsaky.androidide.inflater.internal.utils.ViewFactory.generateLayoutParams import com.itsaky.androidide.inflater.utils.lookupComponentFactory import com.itsaky.androidide.inflater.viewAdapter +import com.itsaky.androidide.utils.dpToPx -open class UiWidget(val name: String, @StringRes val label: Int, @DrawableRes val icon: Int) { +open class UiWidget( + val name: String, + @StringRes val label: Int, + @DrawableRes val icon: Int, +) { + constructor( + klass: Class, + @StringRes label: Int, + @DrawableRes icon: Int, + ) : this(klass.name, label, icon) - constructor( - klass: Class, - @StringRes label: Int, - @DrawableRes icon: Int - ) : this(klass.name, label, icon) + /** + * Creates an [IView] for this widget. + * + * @param context The context that will be used for creating (View)[android.view.View] instance. + * @param layoutFile The layout file that is being edited. + */ + open fun createView( + context: Context, + parent: ViewGroup, + layoutFile: LayoutFile, + ): IView { + val v = ViewFactory.createViewInstance(name, context) - /** - * Creates an [IView] for this widget. - * - * @param context The context that will be used for creating (View)[android.view.View] instance. - * @param layoutFile The layout file that is being edited. - */ - open fun createView(context: Context, parent: ViewGroup, layoutFile: LayoutFile): IView { - val v = ViewFactory.createViewInstance(name, context) + val view: IView = createView(layoutFile, v) - val view: IView = createView(layoutFile, v) - - (view as? IViewGroup)?.apply { - val dp8 = SizeUtils.dp2px(8f) - this.view.updatePaddingRelative(start = dp8, top = dp8, end = dp8, bottom = dp8) - } + (view as? IViewGroup)?.apply { + val dp8 = context.dpToPx(8f) + this.view.updatePaddingRelative(start = dp8, top = dp8, end = dp8, bottom = dp8) + } - view.view.layoutParams = generateLayoutParams(parent) - val adapter = - view.viewAdapter - ?: throw IllegalStateException("No attribute adapter found for '${view.name}'") - adapter.applyBasic(view) - return view - } + view.view.layoutParams = generateLayoutParams(parent) + val adapter = + view.viewAdapter + ?: throw IllegalStateException("No attribute adapter found for '${view.name}'") + adapter.applyBasic(view) + return view + } - private fun createView(layoutFile: LayoutFile, v: View) = - (lookupComponentFactory()?.createView(layoutFile, name, v) - ?: if (v is ViewGroup) { - ViewGroupImpl(layoutFile, name, v) - } else ViewImpl(layoutFile, name, v)) + private fun createView( + layoutFile: LayoutFile, + v: View, + ) = ( + lookupComponentFactory()?.createView(layoutFile, name, v) + ?: if (v is ViewGroup) { + ViewGroupImpl(layoutFile, name, v) + } else { + ViewImpl(layoutFile, name, v) + } + ) - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is UiWidget) return false + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is UiWidget) return false - if (name != other.name) return false - if (label != other.label) return false - if (icon != other.icon) return false + if (name != other.name) return false + if (label != other.label) return false + if (icon != other.icon) return false - return true - } + return true + } - override fun hashCode(): Int { - var result = name.hashCode() - result = 31 * result + label - result = 31 * result + icon - return result - } + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + label + result = 31 * result + icon + return result + } - override fun toString(): String { - return "UiWidget(name='$name', label=$label, icon=$icon)" - } + override fun toString(): String = "UiWidget(name='$name', label=$label, icon=$icon)" } From a31227cf5826cb6552a34378cb9ac5d827f6680f Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 24 Jul 2026 14:55:45 -0700 Subject: [PATCH 2/9] ADFA-4649: Replace ThreadUtils with native Handler-based main-thread helpers Adds a shared mainThreadHandler to common's TaskExecutor.kt and updates its existing runOnUiThread() to post through it instead of blankj's ThreadUtils. Swaps all com.blankj.utilcode ThreadUtils.runOnUiThread/ getMainHandler/runOnUiThreadDelayed call sites across app, lsp/models, lsp/java, and uidesigner to use it - preserving the same Handler instance for post/removeCallbacks pairs where cancellation depends on it. Second of several staged commits removing com.blankj:utilcodex. Co-Authored-By: Claude Sonnet 5 --- .../activities/editor/BaseEditorActivity.kt | 8 +- .../androidide/adapters/SearchListAdapter.kt | 4 +- .../fragments/RunTasksDialogFragment.kt | 6 +- .../itsaky/androidide/ui/EditorBottomSheet.kt | 2 +- .../itsaky/androidide/tasks/TaskExecutor.kt | 16 +- .../lsp/java/actions/FieldBasedAction.kt | 4 +- .../generators/GenerateConstructorAction.kt | 379 ++++----- .../GenerateSettersAndGettersAction.kt | 416 +++++----- .../GenerateToStringMethodAction.kt | 379 ++++----- .../OverrideSuperclassMethodsAction.kt | 758 +++++++++--------- .../lsp/edits/DefaultEditHandler.kt | 33 +- .../utils/ValueCompletionProvider.kt | 147 ++-- .../androidide/uidesigner/utils/ViewToXml.kt | 223 +++--- 13 files changed, 1216 insertions(+), 1159 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index 616d5e418e..d816df83dd 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -68,7 +68,6 @@ import androidx.lifecycle.repeatOnLifecycle import com.blankj.utilcode.constant.MemoryConstants import com.blankj.utilcode.util.ConvertUtils.byte2MemorySize import com.blankj.utilcode.util.FileUtils -import com.blankj.utilcode.util.ThreadUtils import com.github.mikephil.charting.components.AxisBase import com.github.mikephil.charting.data.Entry import com.github.mikephil.charting.data.LineData @@ -118,6 +117,7 @@ import com.itsaky.androidide.projects.IProjectManager import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.services.debug.DebuggerService import com.itsaky.androidide.tasks.cancelIfActive +import com.itsaky.androidide.tasks.mainThreadHandler import com.itsaky.androidide.ui.CodeEditorView import com.itsaky.androidide.ui.ContentTranslatingDrawerLayout import com.itsaky.androidide.ui.SwipeRevealLayout @@ -471,7 +471,7 @@ abstract class BaseEditorActivity : runCatching { onBackPressedCallback.remove() } runCatching { debuggerServiceStopHandler.removeCallbacks(debuggerServiceStopRunnable) } - optionsMenuInvalidator?.also { ThreadUtils.getMainHandler().removeCallbacks(it) } + optionsMenuInvalidator?.also { mainThreadHandler.removeCallbacks(it) } optionsMenuInvalidator = null apkInstallationViewModel.destroy(this) @@ -984,7 +984,7 @@ abstract class BaseEditorActivity : } override fun invalidateOptionsMenu() { - val mainHandler = ThreadUtils.getMainHandler() + val mainHandler = mainThreadHandler optionsMenuInvalidator?.also { mainHandler.removeCallbacks(it) mainHandler.postDelayed(it, OPTIONS_MENU_INVALIDATION_DELAY) @@ -1440,7 +1440,7 @@ abstract class BaseEditorActivity : bottomSheetViewModel.sheetBehaviorState != BottomSheetBehavior.STATE_EXPANDED ) { bottomSheetViewModel.setSheetState(BottomSheetBehavior.STATE_EXPANDED) - ThreadUtils.runOnUiThreadDelayed({ + mainThreadHandler.postDelayed({ bottomSheetViewModel.setSheetState(STATE_COLLAPSED) app.prefManager.putBoolean(KEY_BOTTOM_SHEET_SHOWN, true) }, 1500) diff --git a/app/src/main/java/com/itsaky/androidide/adapters/SearchListAdapter.kt b/app/src/main/java/com/itsaky/androidide/adapters/SearchListAdapter.kt index 7ab7b139c6..af51364f8f 100755 --- a/app/src/main/java/com/itsaky/androidide/adapters/SearchListAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/adapters/SearchListAdapter.kt @@ -6,7 +6,6 @@ import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView.ViewHolder -import com.blankj.utilcode.util.ThreadUtils import com.itsaky.androidide.R import com.itsaky.androidide.databinding.LayoutSearchResultGroupBinding import com.itsaky.androidide.databinding.LayoutSearchResultItemBinding @@ -15,6 +14,7 @@ import com.itsaky.androidide.models.FileExtension import com.itsaky.androidide.models.SearchResult import com.itsaky.androidide.syntax.colorschemes.SchemeAndroidIDE import com.itsaky.androidide.syntax.highlighters.JavaHighlighter +import com.itsaky.androidide.tasks.runOnUiThread import com.itsaky.androidide.utils.resolveAttr import com.itsaky.androidide.viewmodel.EditorViewModel.SearchResultSection import org.slf4j.LoggerFactory @@ -107,7 +107,7 @@ class SearchListAdapter( try { val scheme = SchemeAndroidIDE.newInstance(text.context) val sb = JavaHighlighter().highlight(scheme, match.line, match.match) - ThreadUtils.runOnUiThread { + runOnUiThread { if (text.tag === match) { text.text = sb } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/RunTasksDialogFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/RunTasksDialogFragment.kt index e6856ec812..f7c5052cc8 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/RunTasksDialogFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/RunTasksDialogFragment.kt @@ -34,7 +34,6 @@ import androidx.core.view.updateMargins import androidx.core.view.updatePadding import androidx.fragment.app.viewModels import androidx.transition.TransitionManager -import com.blankj.utilcode.util.ThreadUtils import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.transition.MaterialSharedAxis @@ -52,6 +51,7 @@ import com.itsaky.androidide.projects.IProjectManager import com.itsaky.androidide.projects.builder.BuildService import com.itsaky.androidide.resources.R import com.itsaky.androidide.tasks.executeAsync +import com.itsaky.androidide.tasks.mainThreadHandler import com.itsaky.androidide.utils.SingleTextWatcher import com.itsaky.androidide.utils.applyLongPressRecursively import com.itsaky.androidide.utils.doOnApplyWindowInsets @@ -157,8 +157,8 @@ class RunTasksDialogFragment : BottomSheetDialogFragment() { } override fun afterTextChanged(s: Editable?) { - ThreadUtils.getMainHandler().removeCallbacks(searchRunner) - ThreadUtils.runOnUiThreadDelayed(searchRunner, SEARCH_DELAY) + mainThreadHandler.removeCallbacks(searchRunner) + mainThreadHandler.postDelayed(searchRunner, SEARCH_DELAY) } }, ) diff --git a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt index 5b08d4fdbd..86d15aadba 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt @@ -43,7 +43,6 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.transition.TransitionManager import com.blankj.utilcode.util.KeyboardUtils -import com.blankj.utilcode.util.ThreadUtils.runOnUiThread import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.tabs.TabLayout.OnTabSelectedListener import com.google.android.material.tabs.TabLayout.Tab @@ -61,6 +60,7 @@ import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.lsp.IDELanguageClientImpl import com.itsaky.androidide.models.LogLine import com.itsaky.androidide.resources.R.string +import com.itsaky.androidide.tasks.runOnUiThread import com.itsaky.androidide.utils.DiagnosticsFormatter import com.itsaky.androidide.utils.IntentUtils.shareFile import com.itsaky.androidide.utils.Symbols.forFile diff --git a/common/src/main/java/com/itsaky/androidide/tasks/TaskExecutor.kt b/common/src/main/java/com/itsaky/androidide/tasks/TaskExecutor.kt index 987ed01b28..d3091f0135 100755 --- a/common/src/main/java/com/itsaky/androidide/tasks/TaskExecutor.kt +++ b/common/src/main/java/com/itsaky/androidide/tasks/TaskExecutor.kt @@ -18,8 +18,8 @@ package com.itsaky.androidide.tasks import android.app.ProgressDialog import android.content.Context +import android.os.Handler import android.os.Looper -import com.blankj.utilcode.util.ThreadUtils import com.itsaky.androidide.common.R import org.slf4j.LoggerFactory import java.util.concurrent.Callable @@ -43,7 +43,7 @@ object TaskExecutor { log.error("An error occurred while executing Callable in background thread.", th) return@supplyAsync null } - }.whenComplete { result, _ -> ThreadUtils.runOnUiThread { callback?.complete(result) } } + }.whenComplete { result, _ -> runOnUiThread { callback?.complete(result) } } } @JvmOverloads @@ -61,7 +61,7 @@ object TaskExecutor { throw CompletionException(th) } }.whenComplete { result, throwable -> - ThreadUtils.runOnUiThread { callback?.complete(result, throwable) } + runOnUiThread { callback?.complete(result, throwable) } } } @@ -104,10 +104,18 @@ fun executeAsyncProvideError( callback: (R?, Throwable?) -> Unit, ): CompletableFuture = TaskExecutor.executeAsyncProvideError(callable, callback) +/** + * Shared [Handler] bound to the main looper. Exposed (rather than creating a new [Handler] per + * call) so that code posting delayed work and later cancelling it via [Handler.removeCallbacks] + * goes through the same instance - `removeCallbacks` only removes callbacks posted by the exact + * same [Handler]. + */ +val mainThreadHandler: Handler by lazy { Handler(Looper.getMainLooper()) } + fun runOnUiThread(action: () -> Unit) { if (Looper.getMainLooper().isCurrentThread) { action() } else { - ThreadUtils.runOnUiThread(action) + mainThreadHandler.post(action) } } diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/FieldBasedAction.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/FieldBasedAction.kt index 6c32e77888..5e7ccfc7f1 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/FieldBasedAction.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/FieldBasedAction.kt @@ -19,7 +19,6 @@ package com.itsaky.androidide.lsp.java.actions import android.content.Context import android.view.View -import com.blankj.utilcode.util.ThreadUtils import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.actions.hasRequiredData import com.itsaky.androidide.actions.markInvisible @@ -38,6 +37,7 @@ import com.itsaky.androidide.lsp.java.visitors.FindTypeDeclarationAt import com.itsaky.androidide.models.Range import com.itsaky.androidide.projects.IProjectManager import com.itsaky.androidide.resources.R +import com.itsaky.androidide.tasks.runOnUiThread import com.itsaky.androidide.utils.applyLongPressRecursively import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashInfo @@ -145,7 +145,7 @@ abstract class FieldBasedAction : BaseJavaCodeAction() { ) { val triple = findFields(task, file, range) if (triple == null) { - ThreadUtils.runOnUiThread { + runOnUiThread { val context = data[Context::class.java] if (context != null) { flashError(context.getString(R.string.msg_no_fields_found)) diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/GenerateConstructorAction.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/GenerateConstructorAction.kt index 471e6c1f8b..91566a4570 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/GenerateConstructorAction.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/GenerateConstructorAction.kt @@ -1,186 +1,193 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ - -package com.itsaky.androidide.lsp.java.actions.generators - -import android.content.Context -import com.blankj.utilcode.util.ThreadUtils -import com.github.javaparser.StaticJavaParser -import com.github.javaparser.ast.Modifier -import com.github.javaparser.ast.body.ConstructorDeclaration -import com.itsaky.androidide.actions.ActionData -import com.itsaky.androidide.actions.requireFile -import com.itsaky.androidide.actions.requirePath -import com.itsaky.androidide.idetooltips.TooltipTag -import com.itsaky.androidide.lsp.java.JavaCompilerProvider -import com.itsaky.androidide.lsp.java.actions.FieldBasedAction -import com.itsaky.androidide.lsp.java.compiler.CompileTask -import com.itsaky.androidide.lsp.java.utils.EditHelper -import com.itsaky.androidide.lsp.java.utils.ShortTypePrinter.NO_PACKAGE -import com.itsaky.androidide.models.Range -import com.itsaky.androidide.preferences.utils.indentationString -import com.itsaky.androidide.projects.IProjectManager -import com.itsaky.androidide.resources.R.string -import com.itsaky.androidide.utils.StopWatch -import com.itsaky.androidide.utils.flashError -import io.github.rosemoe.sora.widget.CodeEditor -import openjdk.source.tree.ClassTree -import openjdk.source.tree.VariableTree -import openjdk.source.util.TreePath -import openjdk.tools.javac.api.JavacTrees -import openjdk.tools.javac.code.Symbol.ClassSymbol -import openjdk.tools.javac.code.Symbol.VarSymbol -import openjdk.tools.javac.code.Type -import openjdk.tools.javac.tree.JCTree -import openjdk.tools.javac.tree.TreeInfo -import openjdk.tools.javac.util.ListBuffer -import org.slf4j.LoggerFactory -import java.util.concurrent.CompletableFuture - -/** - * Allows the user to select fields and generate a constructor which has parameters same as the - * selected fields. - * - * The generated constructor has statements which assigns the fields to the parameter types. - * - * @author Akash Yadav - */ -class GenerateConstructorAction : FieldBasedAction() { - - override val titleTextRes: Int = string.action_generate_constructor - override val id: String = "ide.editor.lsp.java.generator.constructor" - override var label: String = "" - override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_GEN_CONSTRUCTOR - - companion object { - - private val log = LoggerFactory.getLogger(GenerateConstructorAction::class.java) - } - - override fun onGetFields(fields: List, data: ActionData) { - showFieldSelector( - fields = fields, data = data, actionId = id, - listener = { selected -> - CompletableFuture.runAsync { generateConstructor(data, selected) } - .whenComplete { _, error -> - if (error != null) { - log.error( - "Unable to generate constructor for the selected fields", - error - ) - flashError(string.msg_cannot_generate_constructor) - } - } - } - ) - } - - private fun generateConstructor(data: ActionData, selected: MutableSet) { - val compiler = - JavaCompilerProvider.get( - IProjectManager.getInstance().findModuleForFile(data.requireFile(), false) ?: return - ) - val range = data[Range::class.java]!! - val file = data.requirePath() - - compiler.compile(file).run { task -> - withValidFields(data, task, file, range) { typeFinder, type, fields -> - fields.removeIf { !selected.contains("${it.name}: ${it.type}") } - generateForFields(data, task, type, fields.map { TreePath(typeFinder.path, it) }) - } - } - } - - private fun generateForFields( - data: ActionData, - task: CompileTask, - type: ClassTree, - paths: List - ) { - val editor = data[CodeEditor::class.java]!! - val trees = JavacTrees.instance(task.task) - val sym = TreeInfo.symbolFor(type as JCTree) as ClassSymbol - val varTypes = mapTypes(paths) - val varNames = paths.map { it.leaf as VariableTree }.map { it.name.toString() } - - if (paths.isEmpty() || trees.findConstructor(sym, varTypes) != null) { - log.warn( - "A constructor with same parameter types is already available in class {}", - type.simpleName - ) - flashError(data[Context::class.java]!!.getString(string.msg_constructor_available)) - return - } - - val stopWatch = StopWatch("generateConstructorForFields()") - val constructor = - newConstructor( - type.simpleName.toString(), - varTypes.toTypedArray(), - varNames.toTypedArray() - ) - val body = constructor.createBody() - for (varName in varNames) { - body.addStatement(StaticJavaParser.parseStatement("this.$varName = $varName;")) - } - - stopWatch.lap("Constructor generated") - log.info("Inserting constructor into editor...") - - val insertAt = EditHelper.insertAfter(task.task, task.root(), paths.last().leaf) - val indent = EditHelper.indent(task.task, task.root(), paths.last().leaf) - var text = constructor.toString() - text = text.replace("\n", "\n${indentationString(indent)}") - text += "\n" - - ThreadUtils.runOnUiThread { - editor.text.insert(insertAt.line, insertAt.column, text) - editor.formatCodeAsync() - stopWatch.log() - } - } - - private fun newConstructor( - name: String, - paramTypes: Array, - paramNames: Array - ): ConstructorDeclaration { - val constructor = ConstructorDeclaration() - constructor.setName(name) - constructor.addModifier(Modifier.Keyword.PUBLIC) - - for (i in paramTypes.indices) { - val paramType = paramTypes[i] - val paramName = paramNames[i] - - constructor.addParameter(NO_PACKAGE.print(paramType), paramName) - } - - return constructor - } - - private fun mapTypes(paths: List): openjdk.tools.javac.util.List { - val buffer = ListBuffer() - for (path in paths) { - val leaf = path.leaf - val sym = TreeInfo.symbolFor(leaf as JCTree) as VarSymbol - buffer.add(sym.type) - } - - return buffer.toList() - } -} +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.lsp.java.actions.generators + +import android.content.Context +import com.github.javaparser.StaticJavaParser +import com.github.javaparser.ast.Modifier +import com.github.javaparser.ast.body.ConstructorDeclaration +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.actions.requirePath +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.lsp.java.JavaCompilerProvider +import com.itsaky.androidide.lsp.java.actions.FieldBasedAction +import com.itsaky.androidide.lsp.java.compiler.CompileTask +import com.itsaky.androidide.lsp.java.utils.EditHelper +import com.itsaky.androidide.lsp.java.utils.ShortTypePrinter.NO_PACKAGE +import com.itsaky.androidide.models.Range +import com.itsaky.androidide.preferences.utils.indentationString +import com.itsaky.androidide.projects.IProjectManager +import com.itsaky.androidide.resources.R.string +import com.itsaky.androidide.tasks.runOnUiThread +import com.itsaky.androidide.utils.StopWatch +import com.itsaky.androidide.utils.flashError +import io.github.rosemoe.sora.widget.CodeEditor +import openjdk.source.tree.ClassTree +import openjdk.source.tree.VariableTree +import openjdk.source.util.TreePath +import openjdk.tools.javac.api.JavacTrees +import openjdk.tools.javac.code.Symbol.ClassSymbol +import openjdk.tools.javac.code.Symbol.VarSymbol +import openjdk.tools.javac.code.Type +import openjdk.tools.javac.tree.JCTree +import openjdk.tools.javac.tree.TreeInfo +import openjdk.tools.javac.util.ListBuffer +import org.slf4j.LoggerFactory +import java.util.concurrent.CompletableFuture + +/** + * Allows the user to select fields and generate a constructor which has parameters same as the + * selected fields. + * + * The generated constructor has statements which assigns the fields to the parameter types. + * + * @author Akash Yadav + */ +class GenerateConstructorAction : FieldBasedAction() { + override val titleTextRes: Int = string.action_generate_constructor + override val id: String = "ide.editor.lsp.java.generator.constructor" + override var label: String = "" + override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_GEN_CONSTRUCTOR + + companion object { + private val log = LoggerFactory.getLogger(GenerateConstructorAction::class.java) + } + + override fun onGetFields( + fields: List, + data: ActionData, + ) { + showFieldSelector( + fields = fields, + data = data, + actionId = id, + listener = { selected -> + CompletableFuture + .runAsync { generateConstructor(data, selected) } + .whenComplete { _, error -> + if (error != null) { + log.error( + "Unable to generate constructor for the selected fields", + error, + ) + flashError(string.msg_cannot_generate_constructor) + } + } + }, + ) + } + + private fun generateConstructor( + data: ActionData, + selected: MutableSet, + ) { + val compiler = + JavaCompilerProvider.get( + IProjectManager.getInstance().findModuleForFile(data.requireFile(), false) ?: return, + ) + val range = data[Range::class.java]!! + val file = data.requirePath() + + compiler.compile(file).run { task -> + withValidFields(data, task, file, range) { typeFinder, type, fields -> + fields.removeIf { !selected.contains("${it.name}: ${it.type}") } + generateForFields(data, task, type, fields.map { TreePath(typeFinder.path, it) }) + } + } + } + + private fun generateForFields( + data: ActionData, + task: CompileTask, + type: ClassTree, + paths: List, + ) { + val editor = data[CodeEditor::class.java]!! + val trees = JavacTrees.instance(task.task) + val sym = TreeInfo.symbolFor(type as JCTree) as ClassSymbol + val varTypes = mapTypes(paths) + val varNames = paths.map { it.leaf as VariableTree }.map { it.name.toString() } + + if (paths.isEmpty() || trees.findConstructor(sym, varTypes) != null) { + log.warn( + "A constructor with same parameter types is already available in class {}", + type.simpleName, + ) + flashError(data[Context::class.java]!!.getString(string.msg_constructor_available)) + return + } + + val stopWatch = StopWatch("generateConstructorForFields()") + val constructor = + newConstructor( + type.simpleName.toString(), + varTypes.toTypedArray(), + varNames.toTypedArray(), + ) + val body = constructor.createBody() + for (varName in varNames) { + body.addStatement(StaticJavaParser.parseStatement("this.$varName = $varName;")) + } + + stopWatch.lap("Constructor generated") + log.info("Inserting constructor into editor...") + + val insertAt = EditHelper.insertAfter(task.task, task.root(), paths.last().leaf) + val indent = EditHelper.indent(task.task, task.root(), paths.last().leaf) + var text = constructor.toString() + text = text.replace("\n", "\n${indentationString(indent)}") + text += "\n" + + runOnUiThread { + editor.text.insert(insertAt.line, insertAt.column, text) + editor.formatCodeAsync() + stopWatch.log() + } + } + + private fun newConstructor( + name: String, + paramTypes: Array, + paramNames: Array, + ): ConstructorDeclaration { + val constructor = ConstructorDeclaration() + constructor.setName(name) + constructor.addModifier(Modifier.Keyword.PUBLIC) + + for (i in paramTypes.indices) { + val paramType = paramTypes[i] + val paramName = paramNames[i] + + constructor.addParameter(NO_PACKAGE.print(paramType), paramName) + } + + return constructor + } + + private fun mapTypes(paths: List): openjdk.tools.javac.util.List { + val buffer = ListBuffer() + for (path in paths) { + val leaf = path.leaf + val sym = TreeInfo.symbolFor(leaf as JCTree) as VarSymbol + buffer.add(sym.type) + } + + return buffer.toList() + } +} diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/GenerateSettersAndGettersAction.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/GenerateSettersAndGettersAction.kt index dfefd23edb..a06523af87 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/GenerateSettersAndGettersAction.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/GenerateSettersAndGettersAction.kt @@ -1,202 +1,214 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ -package com.itsaky.androidide.lsp.java.actions.generators - -import android.content.Context -import com.blankj.utilcode.util.ThreadUtils -import com.github.javaparser.StaticJavaParser -import com.github.javaparser.ast.Modifier -import com.github.javaparser.ast.body.MethodDeclaration -import com.github.javaparser.ast.expr.SimpleName -import com.github.javaparser.ast.stmt.BlockStmt -import com.github.javaparser.ast.type.Type -import com.github.javaparser.ast.type.VoidType -import com.itsaky.androidide.actions.ActionData -import com.itsaky.androidide.actions.requireFile -import com.itsaky.androidide.actions.requirePath -import com.itsaky.androidide.idetooltips.TooltipTag -import com.itsaky.androidide.lsp.java.JavaCompilerProvider -import com.itsaky.androidide.lsp.java.actions.FieldBasedAction -import com.itsaky.androidide.lsp.java.compiler.CompileTask -import com.itsaky.androidide.lsp.java.utils.EditHelper -import com.itsaky.androidide.lsp.java.utils.JavaParserUtils -import com.itsaky.androidide.lsp.java.utils.TypeUtils.toType -import com.itsaky.androidide.preferences.internal.EditorPreferences -import com.itsaky.androidide.preferences.utils.indentationString -import com.itsaky.androidide.projects.IProjectManager -import com.itsaky.androidide.resources.R -import com.itsaky.androidide.utils.flashError -import io.github.rosemoe.sora.widget.CodeEditor -import jdkx.lang.model.element.Modifier.FINAL -import jdkx.lang.model.element.VariableElement -import openjdk.source.tree.ClassTree -import openjdk.source.util.TreePath -import openjdk.source.util.Trees -import org.slf4j.LoggerFactory -import java.util.concurrent.CompletableFuture - -/** - * Allows the user to select fields from the current class, then generates setters and getters for - * the selected fields. - * - * @author Akash Yadav - */ -class GenerateSettersAndGettersAction : FieldBasedAction() { - - override val id: String = "ide.editor.lsp.java.generator.settersAndGetters" - override var label: String = "" - - override val titleTextRes: Int = R.string.action_generate_setters_getters - override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_SETTER_GETTER - - companion object { - - private val log = LoggerFactory.getLogger(GenerateSettersAndGettersAction::class.java) - } - - private fun showErrorMessage(error: Throwable, context: Context?) { - log.error("Unable to generate setters and getters", error) - context ?: return - ThreadUtils.runOnUiThread { - flashError(context.getString(R.string.msg_cannot_generate_setters_getters)) - } - } - - override fun onGetFields(fields: List, data: ActionData) { - - showFieldSelector(fields = fields, data = data, actionId = id, listener = { checkedNames -> - CompletableFuture.runAsync { generateForFields(data, checkedNames) } - .whenComplete { - _, error, - -> - if (error != null) { - showErrorMessage(error, data[Context::class.java]) - return@whenComplete - } - } - }) - } - - private fun generateForFields(data: ActionData, names: MutableSet) { - val compiler = - JavaCompilerProvider.get( - IProjectManager.getInstance().findModuleForFile(data.requireFile(), false) ?: return - ) - val range = data[com.itsaky.androidide.models.Range::class.java]!! - val file = data.requirePath() - - compiler.compile(file).run { task -> - withValidFields(data, task, file, range) { typeFinder, type, fields -> - fields.removeIf { !names.contains("${it.name}: ${it.type}") } - generateForFields(data, task, type, fields.map { TreePath(typeFinder.path, it) }) - } - } - } - - private fun generateForFields( - data: ActionData, - task: CompileTask, - type: ClassTree, - paths: List, - ) { - val file = data.requirePath() - val editor = data[CodeEditor::class.java]!! - val trees = Trees.instance(task.task) - val insert = EditHelper.insertAtEndOfClass(task.task, task.root(file), type) - val sb = StringBuilder() - - for (path in paths) { - val element = trees.getElement(path) ?: continue - if (element !is VariableElement) { - continue - } - - val leaf = path.leaf - val indent = - EditHelper.indent(task.task, task.root(file), leaf) + EditorPreferences.tabSize - sb.append(createGetter(element, indent)) - if (!element.modifiers.contains(FINAL)) { - sb.append(createSetter(element, indent)) - } - } - - ThreadUtils.runOnUiThread { - try { - editor.text.insert(insert.line, insert.column, sb) - editor.formatCodeAsync() - } catch (e: StringIndexOutOfBoundsException) { - showErrorMessage(e, data[Context::class.java]) - } - } - } - - private fun createGetter(variable: VariableElement, indent: Int): String { - val name = variable.simpleName.toString() - val method = - createMethod(variable, "get", toType(variable.asType())) { _, body -> - body.addStatement(createReturnStmt(name)) - } - var text = "\n" + JavaParserUtils.prettyPrint(method) { false } - text = text.replace("\n", "\n${indentationString(indent)}") - - return text - } - - private fun createReturnStmt(name: String) = - StaticJavaParser.parseStatement("return this.$name;") - - private fun createSetter(variable: VariableElement, indent: Int): String { - val name: String = variable.simpleName.toString() - val method = - createMethod(variable, "set", VoidType()) { method, body -> - method.addParameter(toType(variable.asType()), name) - body.addStatement(createAssignmentStmt(name)) - } - - var text = "\n" + JavaParserUtils.prettyPrint(method) { false } - text = text.replace("\n", "\n${indentationString(indent)}") - - return text - } - - private fun createMethod( - variable: VariableElement, - prefix: String, - returnType: Type, - vararg modifiers: Modifier.Keyword = arrayOf(Modifier.Keyword.PUBLIC), - block: (MethodDeclaration, BlockStmt) -> Unit - ): MethodDeclaration { - val name = variable.simpleName.toString() - val method = MethodDeclaration() - val body = method.createBody() - method.name = SimpleName(createName(name, prefix)) - method.type = returnType - method.addModifier(*modifiers) - block(method, body) - return method - } - - private fun createAssignmentStmt(name: String) = - StaticJavaParser.parseStatement("this.$name = $name;") - - private fun createName(name: String, prefix: String): String { - val sb = StringBuilder(name) - sb.setCharAt(0, Character.toUpperCase(sb[0])) - sb.insert(0, prefix) - return sb.toString() - } -} +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ +package com.itsaky.androidide.lsp.java.actions.generators + +import android.content.Context +import com.github.javaparser.StaticJavaParser +import com.github.javaparser.ast.Modifier +import com.github.javaparser.ast.body.MethodDeclaration +import com.github.javaparser.ast.expr.SimpleName +import com.github.javaparser.ast.stmt.BlockStmt +import com.github.javaparser.ast.type.Type +import com.github.javaparser.ast.type.VoidType +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.actions.requirePath +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.lsp.java.JavaCompilerProvider +import com.itsaky.androidide.lsp.java.actions.FieldBasedAction +import com.itsaky.androidide.lsp.java.compiler.CompileTask +import com.itsaky.androidide.lsp.java.utils.EditHelper +import com.itsaky.androidide.lsp.java.utils.JavaParserUtils +import com.itsaky.androidide.lsp.java.utils.TypeUtils.toType +import com.itsaky.androidide.preferences.internal.EditorPreferences +import com.itsaky.androidide.preferences.utils.indentationString +import com.itsaky.androidide.projects.IProjectManager +import com.itsaky.androidide.resources.R +import com.itsaky.androidide.tasks.runOnUiThread +import com.itsaky.androidide.utils.flashError +import io.github.rosemoe.sora.widget.CodeEditor +import jdkx.lang.model.element.Modifier.FINAL +import jdkx.lang.model.element.VariableElement +import openjdk.source.tree.ClassTree +import openjdk.source.util.TreePath +import openjdk.source.util.Trees +import org.slf4j.LoggerFactory +import java.util.concurrent.CompletableFuture + +/** + * Allows the user to select fields from the current class, then generates setters and getters for + * the selected fields. + * + * @author Akash Yadav + */ +class GenerateSettersAndGettersAction : FieldBasedAction() { + override val id: String = "ide.editor.lsp.java.generator.settersAndGetters" + override var label: String = "" + + override val titleTextRes: Int = R.string.action_generate_setters_getters + override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_SETTER_GETTER + + companion object { + private val log = LoggerFactory.getLogger(GenerateSettersAndGettersAction::class.java) + } + + private fun showErrorMessage( + error: Throwable, + context: Context?, + ) { + log.error("Unable to generate setters and getters", error) + context ?: return + runOnUiThread { + flashError(context.getString(R.string.msg_cannot_generate_setters_getters)) + } + } + + override fun onGetFields( + fields: List, + data: ActionData, + ) { + showFieldSelector(fields = fields, data = data, actionId = id, listener = { checkedNames -> + CompletableFuture + .runAsync { generateForFields(data, checkedNames) } + .whenComplete { _, error -> + if (error != null) { + showErrorMessage(error, data[Context::class.java]) + return@whenComplete + } + } + }) + } + + private fun generateForFields( + data: ActionData, + names: MutableSet, + ) { + val compiler = + JavaCompilerProvider.get( + IProjectManager.getInstance().findModuleForFile(data.requireFile(), false) ?: return, + ) + val range = data[com.itsaky.androidide.models.Range::class.java]!! + val file = data.requirePath() + + compiler.compile(file).run { task -> + withValidFields(data, task, file, range) { typeFinder, type, fields -> + fields.removeIf { !names.contains("${it.name}: ${it.type}") } + generateForFields(data, task, type, fields.map { TreePath(typeFinder.path, it) }) + } + } + } + + private fun generateForFields( + data: ActionData, + task: CompileTask, + type: ClassTree, + paths: List, + ) { + val file = data.requirePath() + val editor = data[CodeEditor::class.java]!! + val trees = Trees.instance(task.task) + val insert = EditHelper.insertAtEndOfClass(task.task, task.root(file), type) + val sb = StringBuilder() + + for (path in paths) { + val element = trees.getElement(path) ?: continue + if (element !is VariableElement) { + continue + } + + val leaf = path.leaf + val indent = + EditHelper.indent(task.task, task.root(file), leaf) + EditorPreferences.tabSize + sb.append(createGetter(element, indent)) + if (!element.modifiers.contains(FINAL)) { + sb.append(createSetter(element, indent)) + } + } + + runOnUiThread { + try { + editor.text.insert(insert.line, insert.column, sb) + editor.formatCodeAsync() + } catch (e: StringIndexOutOfBoundsException) { + showErrorMessage(e, data[Context::class.java]) + } + } + } + + private fun createGetter( + variable: VariableElement, + indent: Int, + ): String { + val name = variable.simpleName.toString() + val method = + createMethod(variable, "get", toType(variable.asType())) { _, body -> + body.addStatement(createReturnStmt(name)) + } + var text = "\n" + JavaParserUtils.prettyPrint(method) { false } + text = text.replace("\n", "\n${indentationString(indent)}") + + return text + } + + private fun createReturnStmt(name: String) = StaticJavaParser.parseStatement("return this.$name;") + + private fun createSetter( + variable: VariableElement, + indent: Int, + ): String { + val name: String = variable.simpleName.toString() + val method = + createMethod(variable, "set", VoidType()) { method, body -> + method.addParameter(toType(variable.asType()), name) + body.addStatement(createAssignmentStmt(name)) + } + + var text = "\n" + JavaParserUtils.prettyPrint(method) { false } + text = text.replace("\n", "\n${indentationString(indent)}") + + return text + } + + private fun createMethod( + variable: VariableElement, + prefix: String, + returnType: Type, + vararg modifiers: Modifier.Keyword = arrayOf(Modifier.Keyword.PUBLIC), + block: (MethodDeclaration, BlockStmt) -> Unit, + ): MethodDeclaration { + val name = variable.simpleName.toString() + val method = MethodDeclaration() + val body = method.createBody() + method.name = SimpleName(createName(name, prefix)) + method.type = returnType + method.addModifier(*modifiers) + block(method, body) + return method + } + + private fun createAssignmentStmt(name: String) = StaticJavaParser.parseStatement("this.$name = $name;") + + private fun createName( + name: String, + prefix: String, + ): String { + val sb = StringBuilder(name) + sb.setCharAt(0, Character.toUpperCase(sb[0])) + sb.insert(0, prefix) + return sb.toString() + } +} diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/GenerateToStringMethodAction.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/GenerateToStringMethodAction.kt index cd6ce87e51..3f90379f99 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/GenerateToStringMethodAction.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/GenerateToStringMethodAction.kt @@ -1,186 +1,193 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ -package com.itsaky.androidide.lsp.java.actions.generators - -import android.content.Context -import com.blankj.utilcode.util.ThreadUtils -import com.github.javaparser.StaticJavaParser -import com.github.javaparser.ast.Modifier -import com.github.javaparser.ast.body.MethodDeclaration -import com.github.javaparser.ast.stmt.ReturnStmt -import com.itsaky.androidide.actions.ActionData -import com.itsaky.androidide.actions.requireFile -import com.itsaky.androidide.actions.requirePath -import com.itsaky.androidide.idetooltips.TooltipTag -import com.itsaky.androidide.lsp.java.JavaCompilerProvider -import com.itsaky.androidide.lsp.java.actions.FieldBasedAction -import com.itsaky.androidide.lsp.java.compiler.CompileTask -import com.itsaky.androidide.lsp.java.utils.EditHelper -import com.itsaky.androidide.preferences.internal.EditorPreferences -import com.itsaky.androidide.preferences.utils.indentationString -import com.itsaky.androidide.projects.IProjectManager -import com.itsaky.androidide.resources.R -import com.itsaky.androidide.resources.R.string -import com.itsaky.androidide.utils.flashError -import io.github.rosemoe.sora.widget.CodeEditor -import jdkx.lang.model.element.VariableElement -import openjdk.source.tree.ClassTree -import openjdk.source.tree.VariableTree -import openjdk.source.util.TreePath -import openjdk.tools.javac.api.JavacTrees -import openjdk.tools.javac.code.Symbol.ClassSymbol -import openjdk.tools.javac.code.Symbol.MethodSymbol -import openjdk.tools.javac.tree.JCTree -import openjdk.tools.javac.tree.TreeInfo -import openjdk.tools.javac.util.Names -import org.slf4j.LoggerFactory -import java.util.concurrent.CompletableFuture - -/** - * Generates the `toString()` method for the current class. - * - * @author Akash Yadav - */ -class GenerateToStringMethodAction : FieldBasedAction() { - - override val titleTextRes: Int = R.string.action_generate_toString - override val id: String = "ide.editor.lsp.java.generator.toString" - override var label: String = "" - override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_GEN_TO_STRING - - companion object { - private val log = LoggerFactory.getLogger(GenerateToStringMethodAction::class.java) - } - - override fun onGetFields(fields: List, data: ActionData) { - showFieldSelector(fields = fields, data = data, actionId = id, listener = { selected -> - CompletableFuture.runAsync { generateToString(data, selected) } - .whenComplete { _, error -> - if (error != null) { - log.error("Unable to generate toString() implementation", error) - ThreadUtils.runOnUiThread { - flashError( - data[Context::class.java]!!.getString(R.string.msg_cannot_generate_toString) - ) - } - return@whenComplete - } - } - }) - } - - private fun generateToString(data: ActionData, selected: MutableSet) { - val compiler = - JavaCompilerProvider.get( - IProjectManager.getInstance().findModuleForFile(data.requireFile(), false) ?: return - ) - val range = data[com.itsaky.androidide.models.Range::class.java]!! - val file = data.requirePath() - - compiler.compile(file).run { task -> - withValidFields(data, task, file, range) { typeFinder, type, fields -> - fields.removeIf { !selected.contains("${it.name}: ${it.type}") } - generateForFields(data, task, type, fields.map { TreePath(typeFinder.path, it) }) - } - } - } - - private fun generateForFields( - data: ActionData, - task: CompileTask, - type: ClassTree, - paths: List - ) { - if (isToStringOverridden(task, type)) { - ThreadUtils.runOnUiThread { - flashError(data[Context::class.java]!!.getString(string.msg_toString_overridden)) - } - log.warn("toString() method has already been overridden in class {}", type.simpleName) - return - } - - val file = data.requirePath() - val editor = data[CodeEditor::class.java]!! - val trees = JavacTrees.instance(task.task) - val indent = EditHelper.indent(task.task, task.root(), type) + EditorPreferences.tabSize - val insert = EditHelper.insertAtEndOfClass(task.task, task.root(file), type) - val string = StringBuilder() - var isFirst = true - - string.append("\"") - string.append(type.simpleName) - string.append('[') - for (path in paths) { - val element = trees.getElement(path) ?: continue - if (element !is VariableElement) { - continue - } - - val leaf = path.leaf as VariableTree - if (!isFirst) { - string.append(", ") - } - string.append(leaf.name) - string.append("=") - string.append("\" + ") - string.append(leaf.name) - string.append(" + \"") - - // "ClassName[field1=' + field1 + ', field2=' + field2 + ']" - - isFirst = false - } - string.append("]\"") - - val method = overrideToString() - val body = method.createBody() - body.addStatement(createReturnStatement(string.toString())) - - var text = "\n" + method.toString() - text = text.replace("\n", "\n${indentationString(indent)}") - text += "\n" - - ThreadUtils.runOnUiThread { - editor.text.insert(insert.line, insert.column, text) - editor.formatCodeAsync() - } - } - - private fun isToStringOverridden(task: CompileTask, type: ClassTree): Boolean { - val names = Names.instance(task.task.context) - val sym = TreeInfo.symbolFor(type as JCTree) as ClassSymbol - val toStrings = - sym.members().getSymbolsByName(names.toString).filterIsInstance().filter { - it.params.isEmpty() - } - - return toStrings.isNotEmpty() - } - - private fun createReturnStatement(string: String): ReturnStmt { - return StaticJavaParser.parseStatement("return $string;") as ReturnStmt - } - - private fun overrideToString(): MethodDeclaration { - val method = MethodDeclaration() - method.addMarkerAnnotation("Override") - method.addModifier(Modifier.Keyword.PUBLIC) - method.setType("String") - method.setName("toString") - return method - } -} +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ +package com.itsaky.androidide.lsp.java.actions.generators + +import android.content.Context +import com.github.javaparser.StaticJavaParser +import com.github.javaparser.ast.Modifier +import com.github.javaparser.ast.body.MethodDeclaration +import com.github.javaparser.ast.stmt.ReturnStmt +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.actions.requirePath +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.lsp.java.JavaCompilerProvider +import com.itsaky.androidide.lsp.java.actions.FieldBasedAction +import com.itsaky.androidide.lsp.java.compiler.CompileTask +import com.itsaky.androidide.lsp.java.utils.EditHelper +import com.itsaky.androidide.preferences.internal.EditorPreferences +import com.itsaky.androidide.preferences.utils.indentationString +import com.itsaky.androidide.projects.IProjectManager +import com.itsaky.androidide.resources.R +import com.itsaky.androidide.resources.R.string +import com.itsaky.androidide.tasks.runOnUiThread +import com.itsaky.androidide.utils.flashError +import io.github.rosemoe.sora.widget.CodeEditor +import jdkx.lang.model.element.VariableElement +import openjdk.source.tree.ClassTree +import openjdk.source.tree.VariableTree +import openjdk.source.util.TreePath +import openjdk.tools.javac.api.JavacTrees +import openjdk.tools.javac.code.Symbol.ClassSymbol +import openjdk.tools.javac.code.Symbol.MethodSymbol +import openjdk.tools.javac.tree.JCTree +import openjdk.tools.javac.tree.TreeInfo +import openjdk.tools.javac.util.Names +import org.slf4j.LoggerFactory +import java.util.concurrent.CompletableFuture + +/** + * Generates the `toString()` method for the current class. + * + * @author Akash Yadav + */ +class GenerateToStringMethodAction : FieldBasedAction() { + override val titleTextRes: Int = R.string.action_generate_toString + override val id: String = "ide.editor.lsp.java.generator.toString" + override var label: String = "" + override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_GEN_TO_STRING + + companion object { + private val log = LoggerFactory.getLogger(GenerateToStringMethodAction::class.java) + } + + override fun onGetFields( + fields: List, + data: ActionData, + ) { + showFieldSelector(fields = fields, data = data, actionId = id, listener = { selected -> + CompletableFuture + .runAsync { generateToString(data, selected) } + .whenComplete { _, error -> + if (error != null) { + log.error("Unable to generate toString() implementation", error) + runOnUiThread { + flashError( + data[Context::class.java]!!.getString(R.string.msg_cannot_generate_toString), + ) + } + return@whenComplete + } + } + }) + } + + private fun generateToString( + data: ActionData, + selected: MutableSet, + ) { + val compiler = + JavaCompilerProvider.get( + IProjectManager.getInstance().findModuleForFile(data.requireFile(), false) ?: return, + ) + val range = data[com.itsaky.androidide.models.Range::class.java]!! + val file = data.requirePath() + + compiler.compile(file).run { task -> + withValidFields(data, task, file, range) { typeFinder, type, fields -> + fields.removeIf { !selected.contains("${it.name}: ${it.type}") } + generateForFields(data, task, type, fields.map { TreePath(typeFinder.path, it) }) + } + } + } + + private fun generateForFields( + data: ActionData, + task: CompileTask, + type: ClassTree, + paths: List, + ) { + if (isToStringOverridden(task, type)) { + runOnUiThread { + flashError(data[Context::class.java]!!.getString(string.msg_toString_overridden)) + } + log.warn("toString() method has already been overridden in class {}", type.simpleName) + return + } + + val file = data.requirePath() + val editor = data[CodeEditor::class.java]!! + val trees = JavacTrees.instance(task.task) + val indent = EditHelper.indent(task.task, task.root(), type) + EditorPreferences.tabSize + val insert = EditHelper.insertAtEndOfClass(task.task, task.root(file), type) + val string = StringBuilder() + var isFirst = true + + string.append("\"") + string.append(type.simpleName) + string.append('[') + for (path in paths) { + val element = trees.getElement(path) ?: continue + if (element !is VariableElement) { + continue + } + + val leaf = path.leaf as VariableTree + if (!isFirst) { + string.append(", ") + } + string.append(leaf.name) + string.append("=") + string.append("\" + ") + string.append(leaf.name) + string.append(" + \"") + + // "ClassName[field1=' + field1 + ', field2=' + field2 + ']" + + isFirst = false + } + string.append("]\"") + + val method = overrideToString() + val body = method.createBody() + body.addStatement(createReturnStatement(string.toString())) + + var text = "\n" + method.toString() + text = text.replace("\n", "\n${indentationString(indent)}") + text += "\n" + + runOnUiThread { + editor.text.insert(insert.line, insert.column, text) + editor.formatCodeAsync() + } + } + + private fun isToStringOverridden( + task: CompileTask, + type: ClassTree, + ): Boolean { + val names = Names.instance(task.task.context) + val sym = TreeInfo.symbolFor(type as JCTree) as ClassSymbol + val toStrings = + sym.members().getSymbolsByName(names.toString).filterIsInstance().filter { + it.params.isEmpty() + } + + return toStrings.isNotEmpty() + } + + private fun createReturnStatement(string: String): ReturnStmt = StaticJavaParser.parseStatement("return $string;") as ReturnStmt + + private fun overrideToString(): MethodDeclaration { + val method = MethodDeclaration() + method.addMarkerAnnotation("Override") + method.addModifier(Modifier.Keyword.PUBLIC) + method.setType("String") + method.setName("toString") + return method + } +} diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/OverrideSuperclassMethodsAction.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/OverrideSuperclassMethodsAction.kt index 7e9de536ee..f1f71dfe6e 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/OverrideSuperclassMethodsAction.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/OverrideSuperclassMethodsAction.kt @@ -1,372 +1,386 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ -package com.itsaky.androidide.lsp.java.actions.generators - -import android.content.Context -import android.view.View -import com.blankj.utilcode.util.ThreadUtils -import com.itsaky.androidide.actions.ActionData -import com.itsaky.androidide.actions.hasRequiredData -import com.itsaky.androidide.actions.markInvisible -import com.itsaky.androidide.actions.newDialogBuilder -import com.itsaky.androidide.actions.requireFile -import com.itsaky.androidide.actions.requirePath -import com.itsaky.androidide.idetooltips.TooltipManager -import com.itsaky.androidide.idetooltips.TooltipTag -import com.itsaky.androidide.lsp.java.JavaCompilerProvider -import com.itsaky.androidide.lsp.java.actions.BaseJavaCodeAction -import com.itsaky.androidide.lsp.java.compiler.CompileTask -import com.itsaky.androidide.lsp.java.compiler.CompilerProvider -import com.itsaky.androidide.lsp.java.parser.ParseTask -import com.itsaky.androidide.lsp.java.rewrite.AddImport -import com.itsaky.androidide.lsp.java.utils.EditHelper -import com.itsaky.androidide.lsp.java.utils.FindHelper -import com.itsaky.androidide.lsp.java.utils.JavaParserUtils -import com.itsaky.androidide.lsp.java.utils.MethodPtr -import com.itsaky.androidide.lsp.java.visitors.FindTypeDeclarationAt -import com.itsaky.androidide.models.Position -import com.itsaky.androidide.preferences.internal.EditorPreferences -import com.itsaky.androidide.preferences.utils.indentationString -import com.itsaky.androidide.projects.IProjectManager -import com.itsaky.androidide.resources.R -import com.itsaky.androidide.utils.applyLongPressRecursively -import com.itsaky.androidide.utils.flashError -import io.github.rosemoe.sora.widget.CodeEditor -import jdkx.lang.model.element.ElementKind -import jdkx.lang.model.element.ExecutableElement -import jdkx.lang.model.element.Modifier -import jdkx.lang.model.element.TypeElement -import jdkx.lang.model.type.DeclaredType -import jdkx.lang.model.type.ExecutableType -import jdkx.tools.JavaFileObject -import openjdk.source.tree.MethodTree -import openjdk.source.util.Trees -import org.slf4j.LoggerFactory -import java.util.Arrays -import java.util.Optional -import java.util.concurrent.CompletableFuture - -/** - * Allows the user to override multiple methods from superclass at once. - * - * @author Akash Yadav - */ -class OverrideSuperclassMethodsAction : BaseJavaCodeAction() { - - override val titleTextRes: Int = R.string.action_override_superclass_methods - override val id: String = "ide.editor.lsp.java.generator.overrideSuperclassMethods" - override var label: String = "" - override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_OVERRIDE_SUPER - private var position: Long = -1 - - companion object { - - private val log = LoggerFactory.getLogger(OverrideSuperclassMethodsAction::class.java) - } - - override fun prepare(data: ActionData) { - super.prepare(data) - - if ( - !visible || - !data.hasRequiredData( - com.itsaky.androidide.models.Range::class.java, - CodeEditor::class.java - ) - ) { - markInvisible() - return - } - - visible = true - enabled = true - } - - override suspend fun execAction(data: ActionData): Any { - val range = data[com.itsaky.androidide.models.Range::class.java]!! - val compiler = - JavaCompilerProvider.get( - IProjectManager.getInstance().findModuleForFile(data.requireFile(), false) - ?: return Any() - ) - val file = data.requirePath() - - return compiler.compile(file).get { task -> - // 1-based line and column index - val startLine = range.start.line + 1 - val startColumn = range.start.column + 1 - val endLine = range.end.line + 1 - val endColumn = range.end.column + 1 - val lines = task.root().lineMap - val start = lines.getPosition(startLine.toLong(), startColumn.toLong()) - val end = lines.getPosition(endLine.toLong(), endColumn.toLong()) - - if (start == (-1).toLong() || end == (-1).toLong()) { - return@get false - } - - this.position = start - val typeFinder = FindTypeDeclarationAt(task.task) - var type = typeFinder.scan(task.root(file), start) - if (type == null) { - type = typeFinder.scan(task.root(file), end) - position = end - } - - if (type == null) { - return@get false - } - - val overridable = mutableListOf() - val trees = Trees.instance(task.task) - val elements = task.task.elements - val classPath = typeFinder.path - val element = trees.getElement(classPath) as TypeElement - - for (member in elements.getAllMembers(element)) { - if (member.modifiers.contains(Modifier.FINAL) || member.kind != ElementKind.METHOD) { - continue - } - - val method = member as ExecutableElement - val methodSource = member.getEnclosingElement() as TypeElement - if ( - methodSource.qualifiedName.contentEquals("java.lang.Object") || methodSource == element - ) { - continue - } - - val pointer = MethodPtr(task.task, method) - overridable.add(pointer) - } - - return@get overridable - } - } - - @Suppress("UNCHECKED_CAST") - override fun postExec(data: ActionData, result: Any) { - if (result !is List<*> || result.isEmpty() || position < 0) { - log.warn("Unable to find any overridable method") - flashError(data[Context::class.java]!!.getString(R.string.msg_no_overridable_methods)) - return - } - - val methods = (result as List).sortedBy { it.methodName } - val checkedMethods = mutableListOf() - val names = mapMethodPointers(methods) - val builder = newDialogBuilder(data) - val context = data[Context::class.java]!! - builder.setTitle(data[Context::class.java]!!.getString(R.string.msg_select_methods)) - builder.setMultiChoiceItems(names, BooleanArray(names.size)) { _, which, isChecked -> - checkedMethods.apply { - if (isChecked) { - add(methods[which]) - } else { - remove(methods[which]) - } - } - } - builder.setPositiveButton(android.R.string.ok) { dialog, _ -> - dialog.dismiss() - - if (checkedMethods.isEmpty()) { - flashError(data[Context::class.java]!!.getString(R.string.msg_no_methods_selected)) - return@setPositiveButton - } - - CompletableFuture.runAsync { overrideMethods(data, checkedMethods) } - .whenComplete { - _, error, - -> - if (error != null) { - log.error("An error occurred overriding methods") - - ThreadUtils.runOnUiThread { - flashError( - context.getString(R.string.msg_cannot_override_methods) - ) - } - } - } - } - builder.setNegativeButton(android.R.string.cancel, null) - - val dialog = builder.create() - - val listView = dialog.listView - listView.setOnItemLongClickListener { _, view, position, _ -> - showTooltip(context, view, tooltipTag) - true - } - - dialog.setOnShowListener { - val root = dialog.window?.decorView ?: return@setOnShowListener - - root.applyLongPressRecursively { - showTooltip(context, root, tooltipTag) - true - } - } - dialog.show() - } - - private fun showTooltip(context: Context, anchor: View, toolTip: String) { - TooltipManager.showIdeCategoryTooltip(context, anchor, toolTip) - } - - private fun overrideMethods(data: ActionData, checkedMethods: MutableList) { - val compiler = - JavaCompilerProvider.get( - IProjectManager.getInstance().findModuleForFile(data.requireFile(), false) ?: return - ) - val file = data.requirePath() - - compiler.compile(file).run { task -> - val types = task.task.types - val trees = Trees.instance(task.task) - val sb = StringBuilder() - val imports = mutableSetOf() - - val typeFinder = FindTypeDeclarationAt(task.task) - val classTree = typeFinder.scan(task.root(), position) - val thisClass = trees.getElement(typeFinder.path) as TypeElement - val indent = - EditHelper.indent(task.task, task.root(), classTree) + EditorPreferences.tabSize - val fileImports = - task.root(file).imports.map { it.qualifiedIdentifier.toString() }.toSet() - val filePackage = task.root(file).`package`.packageName.toString() - - for (pointer in checkedMethods) { - val superMethod = - FindHelper.findMethod( - task, - pointer.className, - pointer.methodName, - pointer.erasedParameterTypes - ) ?: continue - - val thisDeclaredType = thisClass.asType() as DeclaredType - val executableType = - types.asMemberOf(thisDeclaredType, superMethod) as ExecutableType - val source = findSource(compiler, task, superMethod) - val method = - if (source != null) { - JavaParserUtils.printMethod(superMethod, executableType, source) - } else { - JavaParserUtils.printMethod(superMethod, executableType, superMethod) - } - - val newImports = JavaParserUtils.collectImports(executableType) - sb.append("\n") - sb.append(method.toString()) - sb.replace(Regex(Regex.escape("\n")), "\n${indentationString(indent)}") - sb.append("\n") - - newImports.removeIf { - it.startsWith("java.lang.") || it.startsWith(filePackage) || fileImports.contains( - it - ) - } - - imports.addAll(newImports) - } - - ThreadUtils.runOnUiThread { - performEdits( - data, - sb, - imports, - EditHelper.insertAtEndOfClass(task.task, task.root(file), classTree) - ) - } - } - } - - private fun performEdits( - data: ActionData, - sb: StringBuilder, - imports: MutableSet, - position: Position, - ) { - val compiler = - JavaCompilerProvider.get( - IProjectManager.getInstance().findModuleForFile(data.requireFile(), false) ?: return - ) - val editor = data[CodeEditor::class.java]!! - val file = data.requirePath() - val text = editor.text - - text.beginBatchEdit() - - text.insert(position.line, position.column, sb) - - for (name in imports) { - val rewrite = AddImport(file, name) - val edits = rewrite.rewrite(compiler)[file] - if (edits.isNullOrEmpty()) { - continue - } - - for (edit in edits) { - if (edit.range.start == edit.range.end) { - text.insert(edit.range.start.line, edit.range.start.column, edit.newText) - } else { - text.replace( - edit.range.start.line, - edit.range.start.column, - edit.range.end.line, - edit.range.end.column, - edit.newText - ) - } - } - } - - text.endBatchEdit() - editor.formatCodeAsync() - } - - private fun mapMethodPointers(methods: List): Array { - return methods - .map { Arrays.toString(it.simplifiedErasedParameterTypes) } - .map { - val arr = it.toCharArray() - arr[0] = '(' - arr[arr.size - 1] = ')' - - String(arr) - } - .mapIndexed { index, params -> "${methods[index].methodName}$params" } - .toTypedArray() - } - - private fun findSource( - compiler: CompilerProvider, - task: CompileTask, - method: ExecutableElement, - ): MethodTree? { - val superClass = method.enclosingElement as TypeElement - val superClassName = superClass.qualifiedName.toString() - val methodName = method.simpleName.toString() - val erasedParameterTypes = FindHelper.erasedParameterTypes(task, method) - val sourceFile: Optional = compiler.findAnywhere(superClassName) - if (!sourceFile.isPresent) return null - val parse: ParseTask = compiler.parse(sourceFile.get()) - return FindHelper.findMethod(parse, superClassName, methodName, erasedParameterTypes) - } -} +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ +package com.itsaky.androidide.lsp.java.actions.generators + +import android.content.Context +import android.view.View +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.hasRequiredData +import com.itsaky.androidide.actions.markInvisible +import com.itsaky.androidide.actions.newDialogBuilder +import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.actions.requirePath +import com.itsaky.androidide.idetooltips.TooltipManager +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.lsp.java.JavaCompilerProvider +import com.itsaky.androidide.lsp.java.actions.BaseJavaCodeAction +import com.itsaky.androidide.lsp.java.compiler.CompileTask +import com.itsaky.androidide.lsp.java.compiler.CompilerProvider +import com.itsaky.androidide.lsp.java.parser.ParseTask +import com.itsaky.androidide.lsp.java.rewrite.AddImport +import com.itsaky.androidide.lsp.java.utils.EditHelper +import com.itsaky.androidide.lsp.java.utils.FindHelper +import com.itsaky.androidide.lsp.java.utils.JavaParserUtils +import com.itsaky.androidide.lsp.java.utils.MethodPtr +import com.itsaky.androidide.lsp.java.visitors.FindTypeDeclarationAt +import com.itsaky.androidide.models.Position +import com.itsaky.androidide.preferences.internal.EditorPreferences +import com.itsaky.androidide.preferences.utils.indentationString +import com.itsaky.androidide.projects.IProjectManager +import com.itsaky.androidide.resources.R +import com.itsaky.androidide.tasks.runOnUiThread +import com.itsaky.androidide.utils.applyLongPressRecursively +import com.itsaky.androidide.utils.flashError +import io.github.rosemoe.sora.widget.CodeEditor +import jdkx.lang.model.element.ElementKind +import jdkx.lang.model.element.ExecutableElement +import jdkx.lang.model.element.Modifier +import jdkx.lang.model.element.TypeElement +import jdkx.lang.model.type.DeclaredType +import jdkx.lang.model.type.ExecutableType +import jdkx.tools.JavaFileObject +import openjdk.source.tree.MethodTree +import openjdk.source.util.Trees +import org.slf4j.LoggerFactory +import java.util.Arrays +import java.util.Optional +import java.util.concurrent.CompletableFuture + +/** + * Allows the user to override multiple methods from superclass at once. + * + * @author Akash Yadav + */ +class OverrideSuperclassMethodsAction : BaseJavaCodeAction() { + override val titleTextRes: Int = R.string.action_override_superclass_methods + override val id: String = "ide.editor.lsp.java.generator.overrideSuperclassMethods" + override var label: String = "" + override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_OVERRIDE_SUPER + private var position: Long = -1 + + companion object { + private val log = LoggerFactory.getLogger(OverrideSuperclassMethodsAction::class.java) + } + + override fun prepare(data: ActionData) { + super.prepare(data) + + if ( + !visible || + !data.hasRequiredData( + com.itsaky.androidide.models.Range::class.java, + CodeEditor::class.java, + ) + ) { + markInvisible() + return + } + + visible = true + enabled = true + } + + override suspend fun execAction(data: ActionData): Any { + val range = data[com.itsaky.androidide.models.Range::class.java]!! + val compiler = + JavaCompilerProvider.get( + IProjectManager.getInstance().findModuleForFile(data.requireFile(), false) + ?: return Any(), + ) + val file = data.requirePath() + + return compiler.compile(file).get { task -> + // 1-based line and column index + val startLine = range.start.line + 1 + val startColumn = range.start.column + 1 + val endLine = range.end.line + 1 + val endColumn = range.end.column + 1 + val lines = task.root().lineMap + val start = lines.getPosition(startLine.toLong(), startColumn.toLong()) + val end = lines.getPosition(endLine.toLong(), endColumn.toLong()) + + if (start == (-1).toLong() || end == (-1).toLong()) { + return@get false + } + + this.position = start + val typeFinder = FindTypeDeclarationAt(task.task) + var type = typeFinder.scan(task.root(file), start) + if (type == null) { + type = typeFinder.scan(task.root(file), end) + position = end + } + + if (type == null) { + return@get false + } + + val overridable = mutableListOf() + val trees = Trees.instance(task.task) + val elements = task.task.elements + val classPath = typeFinder.path + val element = trees.getElement(classPath) as TypeElement + + for (member in elements.getAllMembers(element)) { + if (member.modifiers.contains(Modifier.FINAL) || member.kind != ElementKind.METHOD) { + continue + } + + val method = member as ExecutableElement + val methodSource = member.getEnclosingElement() as TypeElement + if ( + methodSource.qualifiedName.contentEquals("java.lang.Object") || methodSource == element + ) { + continue + } + + val pointer = MethodPtr(task.task, method) + overridable.add(pointer) + } + + return@get overridable + } + } + + @Suppress("UNCHECKED_CAST") + override fun postExec( + data: ActionData, + result: Any, + ) { + if (result !is List<*> || result.isEmpty() || position < 0) { + log.warn("Unable to find any overridable method") + flashError(data[Context::class.java]!!.getString(R.string.msg_no_overridable_methods)) + return + } + + val methods = (result as List).sortedBy { it.methodName } + val checkedMethods = mutableListOf() + val names = mapMethodPointers(methods) + val builder = newDialogBuilder(data) + val context = data[Context::class.java]!! + builder.setTitle(data[Context::class.java]!!.getString(R.string.msg_select_methods)) + builder.setMultiChoiceItems(names, BooleanArray(names.size)) { _, which, isChecked -> + checkedMethods.apply { + if (isChecked) { + add(methods[which]) + } else { + remove(methods[which]) + } + } + } + builder.setPositiveButton(android.R.string.ok) { dialog, _ -> + dialog.dismiss() + + if (checkedMethods.isEmpty()) { + flashError(data[Context::class.java]!!.getString(R.string.msg_no_methods_selected)) + return@setPositiveButton + } + + CompletableFuture + .runAsync { overrideMethods(data, checkedMethods) } + .whenComplete { _, error -> + if (error != null) { + log.error("An error occurred overriding methods") + + runOnUiThread { + flashError( + context.getString(R.string.msg_cannot_override_methods), + ) + } + } + } + } + builder.setNegativeButton(android.R.string.cancel, null) + + val dialog = builder.create() + + val listView = dialog.listView + listView.setOnItemLongClickListener { _, view, position, _ -> + showTooltip(context, view, tooltipTag) + true + } + + dialog.setOnShowListener { + val root = dialog.window?.decorView ?: return@setOnShowListener + + root.applyLongPressRecursively { + showTooltip(context, root, tooltipTag) + true + } + } + dialog.show() + } + + private fun showTooltip( + context: Context, + anchor: View, + toolTip: String, + ) { + TooltipManager.showIdeCategoryTooltip(context, anchor, toolTip) + } + + private fun overrideMethods( + data: ActionData, + checkedMethods: MutableList, + ) { + val compiler = + JavaCompilerProvider.get( + IProjectManager.getInstance().findModuleForFile(data.requireFile(), false) ?: return, + ) + val file = data.requirePath() + + compiler.compile(file).run { task -> + val types = task.task.types + val trees = Trees.instance(task.task) + val sb = StringBuilder() + val imports = mutableSetOf() + + val typeFinder = FindTypeDeclarationAt(task.task) + val classTree = typeFinder.scan(task.root(), position) + val thisClass = trees.getElement(typeFinder.path) as TypeElement + val indent = + EditHelper.indent(task.task, task.root(), classTree) + EditorPreferences.tabSize + val fileImports = + task + .root(file) + .imports + .map { it.qualifiedIdentifier.toString() } + .toSet() + val filePackage = + task + .root(file) + .`package`.packageName + .toString() + + for (pointer in checkedMethods) { + val superMethod = + FindHelper.findMethod( + task, + pointer.className, + pointer.methodName, + pointer.erasedParameterTypes, + ) ?: continue + + val thisDeclaredType = thisClass.asType() as DeclaredType + val executableType = + types.asMemberOf(thisDeclaredType, superMethod) as ExecutableType + val source = findSource(compiler, task, superMethod) + val method = + if (source != null) { + JavaParserUtils.printMethod(superMethod, executableType, source) + } else { + JavaParserUtils.printMethod(superMethod, executableType, superMethod) + } + + val newImports = JavaParserUtils.collectImports(executableType) + sb.append("\n") + sb.append(method.toString()) + sb.replace(Regex(Regex.escape("\n")), "\n${indentationString(indent)}") + sb.append("\n") + + newImports.removeIf { + it.startsWith("java.lang.") || it.startsWith(filePackage) || + fileImports.contains( + it, + ) + } + + imports.addAll(newImports) + } + + runOnUiThread { + performEdits( + data, + sb, + imports, + EditHelper.insertAtEndOfClass(task.task, task.root(file), classTree), + ) + } + } + } + + private fun performEdits( + data: ActionData, + sb: StringBuilder, + imports: MutableSet, + position: Position, + ) { + val compiler = + JavaCompilerProvider.get( + IProjectManager.getInstance().findModuleForFile(data.requireFile(), false) ?: return, + ) + val editor = data[CodeEditor::class.java]!! + val file = data.requirePath() + val text = editor.text + + text.beginBatchEdit() + + text.insert(position.line, position.column, sb) + + for (name in imports) { + val rewrite = AddImport(file, name) + val edits = rewrite.rewrite(compiler)[file] + if (edits.isNullOrEmpty()) { + continue + } + + for (edit in edits) { + if (edit.range.start == edit.range.end) { + text.insert(edit.range.start.line, edit.range.start.column, edit.newText) + } else { + text.replace( + edit.range.start.line, + edit.range.start.column, + edit.range.end.line, + edit.range.end.column, + edit.newText, + ) + } + } + } + + text.endBatchEdit() + editor.formatCodeAsync() + } + + private fun mapMethodPointers(methods: List): Array = + methods + .map { Arrays.toString(it.simplifiedErasedParameterTypes) } + .map { + val arr = it.toCharArray() + arr[0] = '(' + arr[arr.size - 1] = ')' + + String(arr) + }.mapIndexed { index, params -> "${methods[index].methodName}$params" } + .toTypedArray() + + private fun findSource( + compiler: CompilerProvider, + task: CompileTask, + method: ExecutableElement, + ): MethodTree? { + val superClass = method.enclosingElement as TypeElement + val superClassName = superClass.qualifiedName.toString() + val methodName = method.simpleName.toString() + val erasedParameterTypes = FindHelper.erasedParameterTypes(task, method) + val sourceFile: Optional = compiler.findAnywhere(superClassName) + if (!sourceFile.isPresent) return null + val parse: ParseTask = compiler.parse(sourceFile.get()) + return FindHelper.findMethod(parse, superClassName, methodName, erasedParameterTypes) + } +} diff --git a/lsp/models/src/main/java/com/itsaky/androidide/lsp/edits/DefaultEditHandler.kt b/lsp/models/src/main/java/com/itsaky/androidide/lsp/edits/DefaultEditHandler.kt index 19375860e1..7fe800ef98 100644 --- a/lsp/models/src/main/java/com/itsaky/androidide/lsp/edits/DefaultEditHandler.kt +++ b/lsp/models/src/main/java/com/itsaky/androidide/lsp/edits/DefaultEditHandler.kt @@ -18,11 +18,11 @@ package com.itsaky.androidide.lsp.edits import android.os.Looper -import com.blankj.utilcode.util.ThreadUtils import com.itsaky.androidide.lsp.models.Command import com.itsaky.androidide.lsp.models.CompletionItem import com.itsaky.androidide.lsp.models.InsertTextFormat.SNIPPET import com.itsaky.androidide.lsp.util.RewriteHelper +import com.itsaky.androidide.tasks.runOnUiThread import io.github.rosemoe.sora.lang.completion.snippet.parser.CodeSnippetParser import io.github.rosemoe.sora.text.Content import io.github.rosemoe.sora.text.batchEdit @@ -35,9 +35,7 @@ import org.slf4j.LoggerFactory * @author Akash Yadav */ open class DefaultEditHandler : IEditHandler { - companion object { - private val log = LoggerFactory.getLogger(DefaultEditHandler::class.java) } @@ -47,17 +45,17 @@ open class DefaultEditHandler : IEditHandler { text: Content, line: Int, column: Int, - index: Int + index: Int, ) { if (Looper.myLooper() != Looper.getMainLooper()) { - ThreadUtils.runOnUiThread { + runOnUiThread { performEditsInternal( item, editor, text, line, column, - index + index, ) } return @@ -72,7 +70,7 @@ open class DefaultEditHandler : IEditHandler { text: Content, line: Int, column: Int, - index: Int + index: Int, ) { if (item.insertTextFormat == SNIPPET) { insertSnippet(item, editor, text, line, column, index) @@ -87,7 +85,14 @@ open class DefaultEditHandler : IEditHandler { executeCommand(editor, item.command) } - protected open fun performAdditionalEdits(item: CompletionItem, editor: CodeEditor, text: Content, line: Int, column: Int, index: Int) { + protected open fun performAdditionalEdits( + item: CompletionItem, + editor: CodeEditor, + text: Content, + line: Int, + column: Int, + index: Int, + ) { text.batchEdit { item.additionalEditHandler?.performEdits(item, editor, text, line, column, index) ?: item.additionalTextEdits?.also { RewriteHelper.performEdits(it, editor) } @@ -100,7 +105,7 @@ open class DefaultEditHandler : IEditHandler { text: Content, line: Int, column: Int, - index: Int + index: Int, ) { val snippetDescription = item.snippetDescription!! val snippet = CodeSnippetParser.parse(item.insertText) @@ -120,7 +125,10 @@ open class DefaultEditHandler : IEditHandler { } } - protected open fun executeCommand(editor: CodeEditor, command: Command?) { + protected open fun executeCommand( + editor: CodeEditor, + command: Command?, + ) { if (command == null) { return } @@ -135,7 +143,10 @@ open class DefaultEditHandler : IEditHandler { } } - protected open fun getIdentifierStart(text: CharSequence, end: Int): Int { + protected open fun getIdentifierStart( + text: CharSequence, + end: Int, + ): Int { var start = end while (start > 0) { if (isPartialPart(text[start - 1])) { diff --git a/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/utils/ValueCompletionProvider.kt b/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/utils/ValueCompletionProvider.kt index 8b67b0f9d6..3747ae9e6f 100644 --- a/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/utils/ValueCompletionProvider.kt +++ b/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/utils/ValueCompletionProvider.kt @@ -18,12 +18,12 @@ package com.itsaky.androidide.uidesigner.utils import android.text.Editable -import com.blankj.utilcode.util.ThreadUtils import com.itsaky.androidide.inflater.internal.ViewImpl import com.itsaky.androidide.lsp.util.setupLookupForCompletion import com.itsaky.androidide.lsp.xml.models.XMLServerSettings import com.itsaky.androidide.lsp.xml.providers.XmlCompletionProvider import com.itsaky.androidide.lsp.xml.providers.completion.AttrValueCompletionProvider +import com.itsaky.androidide.tasks.runOnUiThread import com.itsaky.androidide.utils.SingleTextWatcher import org.slf4j.LoggerFactory import java.io.File @@ -34,79 +34,76 @@ import java.io.File * @author Akash Yadav */ internal class ValueCompletionProvider( - private val file: File, - private val view: ViewImpl, - private val attribute: com.itsaky.androidide.inflater.IAttribute, - private val onComplete: (List) -> Unit + private val file: File, + private val view: ViewImpl, + private val attribute: com.itsaky.androidide.inflater.IAttribute, + private val onComplete: (List) -> Unit, ) : SingleTextWatcher() { - - private val completionProvider = - AttrValueCompletionProvider(XmlCompletionProvider(XMLServerSettings)).apply { - setNamespaces(view.findNamespaces().map { it.prefix to it.uri }.toSet()) - } - - private var completionThread: CompletionThread? = null - - override fun afterTextChanged(s: Editable?) { - if (completionThread?.isAlive == true) { - completionThread?.cancel() - completionThread = null - } - - setupLookupForCompletion(file) - val value = s?.toString() ?: "" - completionThread = - CompletionThread(completionProvider) { ThreadUtils.runOnUiThread { onComplete(it) } } - .apply { - this.attribute = this@ValueCompletionProvider.attribute - this.prefix = value - this.start() - } - } - - class CompletionThread( - private val completionProvider: AttrValueCompletionProvider, - private val onComplete: (List) -> Unit - ) : Thread("AttributeValueCompletionThread") { - - var prefix: String = "" - var attribute: com.itsaky.androidide.inflater.IAttribute? = null - - companion object { - - private val log = LoggerFactory.getLogger(CompletionThread::class.java) - } - - fun cancel() { - interrupt() - } - - override fun run() { - val attribute = - this.attribute - ?: run { - onComplete(emptyList()) - return - } - - val ns = attribute.namespace?.prefix?.let { "${it}:" } ?: "" - log.info("Complete attribute value: '{}{}'", ns, attribute.name) - - val result = - completionProvider.completeValue( - namespace = attribute.namespace?.uri, - prefix = prefix, - attrName = attribute.name, - attrValue = prefix - ) - - log.debug( - "Found {} items{}", - result.items.size, - if (result.isIncomplete) "(incomplete)" else "", - ) - - onComplete(result.items.map { it.ideLabel }) - } - } + private val completionProvider = + AttrValueCompletionProvider(XmlCompletionProvider(XMLServerSettings)).apply { + setNamespaces(view.findNamespaces().map { it.prefix to it.uri }.toSet()) + } + + private var completionThread: CompletionThread? = null + + override fun afterTextChanged(s: Editable?) { + if (completionThread?.isAlive == true) { + completionThread?.cancel() + completionThread = null + } + + setupLookupForCompletion(file) + val value = s?.toString() ?: "" + completionThread = + CompletionThread(completionProvider) { runOnUiThread { onComplete(it) } } + .apply { + this.attribute = this@ValueCompletionProvider.attribute + this.prefix = value + this.start() + } + } + + class CompletionThread( + private val completionProvider: AttrValueCompletionProvider, + private val onComplete: (List) -> Unit, + ) : Thread("AttributeValueCompletionThread") { + var prefix: String = "" + var attribute: com.itsaky.androidide.inflater.IAttribute? = null + + companion object { + private val log = LoggerFactory.getLogger(CompletionThread::class.java) + } + + fun cancel() { + interrupt() + } + + override fun run() { + val attribute = + this.attribute + ?: run { + onComplete(emptyList()) + return + } + + val ns = attribute.namespace?.prefix?.let { "$it:" } ?: "" + log.info("Complete attribute value: '{}{}'", ns, attribute.name) + + val result = + completionProvider.completeValue( + namespace = attribute.namespace?.uri, + prefix = prefix, + attrName = attribute.name, + attrValue = prefix, + ) + + log.debug( + "Found {} items{}", + result.items.size, + if (result.isIncomplete) "(incomplete)" else "", + ) + + onComplete(result.items.map { it.ideLabel }) + } + } } diff --git a/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/utils/ViewToXml.kt b/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/utils/ViewToXml.kt index 6f13907da8..aa8e0a772c 100644 --- a/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/utils/ViewToXml.kt +++ b/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/utils/ViewToXml.kt @@ -18,13 +18,13 @@ package com.itsaky.androidide.uidesigner.utils import android.content.Context -import com.blankj.utilcode.util.ThreadUtils import com.itsaky.androidide.inflater.IView import com.itsaky.androidide.inflater.IViewGroup import com.itsaky.androidide.inflater.internal.ViewGroupImpl import com.itsaky.androidide.inflater.internal.ViewImpl import com.itsaky.androidide.lsp.xml.utils.XMLBuilder import com.itsaky.androidide.tasks.executeAsyncProvideError +import com.itsaky.androidide.tasks.runOnUiThread import com.itsaky.androidide.uidesigner.R import com.itsaky.androidide.utils.DialogUtils import org.slf4j.LoggerFactory @@ -37,114 +37,115 @@ import java.util.concurrent.CompletionException * @author Akash Yadav */ object ViewToXml { - - private val log = LoggerFactory.getLogger(ViewToXml::class.java) - - @JvmStatic - @JvmOverloads - fun generateXml( - context: Context, - workspace: ViewGroupImpl, - onGenerated: (String) -> Unit = {}, - onFailure: (result: String?, error: Throwable?) -> Unit - ) { - context.apply { - val future: CompletableFuture = - executeAsyncProvideError({ - if (workspace.childCount == 0) { - throw CompletionException( - IllegalStateException("No views have been added to workspace") - ) - } - - if (workspace.childCount > 1) { - throw CompletionException( - IllegalStateException("Invalid view hierarchy. More than one root views found.") - ) - } - - return@executeAsyncProvideError generateXml(workspace[0] as ViewImpl) - }) { _, _ -> } - - val progress = - DialogUtils.newProgressDialog( - this, - getString(R.string.title_generating_xml), - getString(R.string.please_wait), - false - ) { _, _ -> - future.cancel(true) - } - .show() - - future.whenComplete { result, error -> - ThreadUtils.runOnUiThread { - progress.dismiss() - - if (result.isNullOrBlank() || error != null) { - log.error("Unable to generate XML code", error) - onFailure(result, error) - return@runOnUiThread - } - - onGenerated(result) - } - } - } - } - - @JvmStatic - fun generateXml(view: ViewImpl): String { - val builder = XMLBuilder("", System.lineSeparator()) - builder.apply { - appendXmlProlog() - linefeed() - appendView(view) - } - return builder.toString() - } - - private fun XMLBuilder.appendView(view: ViewImpl, indent: Int = 1) { - startElement(view.tag, false) - - for (namespace in view.namespaceDecls) { - linefeed() - indent(indent) - addSingleAttribute("xmlns:${namespace.prefix}", namespace.uri, true) - } - - for (attr in view.attributes) { - linefeed() - indent(indent) - - val ns = attr.namespace?.prefix?.let { "${it}:" } ?: "" - addSingleAttribute("${ns}${attr.name}", attr.value, true) - } - - if (view is IViewGroup) { - - closeStartElement() - - for (i in 0 until view.childCount) { - linefeed() - linefeed() - indent(indent) - appendView(view[i] as ViewImpl, indent + 1) - } - - linefeed() - linefeed() - indent(indent - 1) - endElement(view.tag) - } else { - selfCloseElement() - } - } - - private fun XMLBuilder.appendXmlProlog() { - startPrologOrPI("xml") - addSingleAttribute("version", "1.0", true) - addSingleAttribute("encoding", "utf-8", true) - endPrologOrPI() - } + private val log = LoggerFactory.getLogger(ViewToXml::class.java) + + @JvmStatic + @JvmOverloads + fun generateXml( + context: Context, + workspace: ViewGroupImpl, + onGenerated: (String) -> Unit = {}, + onFailure: (result: String?, error: Throwable?) -> Unit, + ) { + context.apply { + val future: CompletableFuture = + executeAsyncProvideError({ + if (workspace.childCount == 0) { + throw CompletionException( + IllegalStateException("No views have been added to workspace"), + ) + } + + if (workspace.childCount > 1) { + throw CompletionException( + IllegalStateException("Invalid view hierarchy. More than one root views found."), + ) + } + + return@executeAsyncProvideError generateXml(workspace[0] as ViewImpl) + }) { _, _ -> } + + val progress = + DialogUtils + .newProgressDialog( + this, + getString(R.string.title_generating_xml), + getString(R.string.please_wait), + false, + ) { _, _ -> + future.cancel(true) + }.show() + + future.whenComplete { result, error -> + runOnUiThread { + progress.dismiss() + + if (result.isNullOrBlank() || error != null) { + log.error("Unable to generate XML code", error) + onFailure(result, error) + return@runOnUiThread + } + + onGenerated(result) + } + } + } + } + + @JvmStatic + fun generateXml(view: ViewImpl): String { + val builder = XMLBuilder("", System.lineSeparator()) + builder.apply { + appendXmlProlog() + linefeed() + appendView(view) + } + return builder.toString() + } + + private fun XMLBuilder.appendView( + view: ViewImpl, + indent: Int = 1, + ) { + startElement(view.tag, false) + + for (namespace in view.namespaceDecls) { + linefeed() + indent(indent) + addSingleAttribute("xmlns:${namespace.prefix}", namespace.uri, true) + } + + for (attr in view.attributes) { + linefeed() + indent(indent) + + val ns = attr.namespace?.prefix?.let { "$it:" } ?: "" + addSingleAttribute("${ns}${attr.name}", attr.value, true) + } + + if (view is IViewGroup) { + closeStartElement() + + for (i in 0 until view.childCount) { + linefeed() + linefeed() + indent(indent) + appendView(view[i] as ViewImpl, indent + 1) + } + + linefeed() + linefeed() + indent(indent - 1) + endElement(view.tag) + } else { + selfCloseElement() + } + } + + private fun XMLBuilder.appendXmlProlog() { + startPrologOrPI("xml") + addSingleAttribute("version", "1.0", true) + addSingleAttribute("encoding", "utf-8", true) + endPrologOrPI() + } } From bf8021932a5c4d74318526e180e04431e3e653ce Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 24 Jul 2026 15:01:08 -0700 Subject: [PATCH 3/9] ADFA-4649: Replace FileUtils/FileIOUtils with native java.io equivalents Adds native FileUtils/FileIOUtils objects to common's utils package, matching blankj's method names/signatures (isUtf8, readFile2String, writeFileFromString, listFilesInDirWithFilter, delete, rename, getFileExtension, createOrExistsDir) so call sites only needed an import swap, not a rewrite. Covers app, editor, xml-inflater, and common modules. Third of several staged commits removing com.blankj:utilcodex. Co-Authored-By: Claude Sonnet 5 --- .../androidide/actions/FileActionManager.kt | 61 +- .../actions/filetree/DeleteAction.kt | 123 +- .../actions/filetree/NewFileAction.kt | 852 +++++++------ .../activities/editor/BaseEditorActivity.kt | 2 +- .../adapters/RecentProjectsAdapter.kt | 525 ++++---- .../androidide/lsp/IDELanguageClientImpl.java | 4 +- .../callables/ListDirectoryCallable.java | 31 +- .../utils/RecursiveFileSearcher.java | 239 ++-- .../androidide/viewmodel/EditorViewModel.kt | 2 +- .../viewmodel/FileManagerViewModel.kt | 80 +- .../androidide/managers/ToolsManager.java | 4 +- .../itsaky/androidide/utils/Environment.java | 5 +- .../com/itsaky/androidide/utils/FileUtils.kt | 102 ++ .../itsaky/androidide/editor/ui/IDEEditor.kt | 2 +- .../drawable/DrawableParserFactory.java | 383 +++--- .../vectormaster/VectorMasterDrawable.java | 1068 +++++++++-------- 16 files changed, 1851 insertions(+), 1632 deletions(-) create mode 100644 common/src/main/java/com/itsaky/androidide/utils/FileUtils.kt diff --git a/app/src/main/java/com/itsaky/androidide/actions/FileActionManager.kt b/app/src/main/java/com/itsaky/androidide/actions/FileActionManager.kt index f131eaee6e..f6fa2c1908 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/FileActionManager.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/FileActionManager.kt @@ -1,9 +1,8 @@ package com.itsaky.androidide.actions - -import com.blankj.utilcode.util.FileIOUtils import com.itsaky.androidide.actions.observers.FileActionObserver import com.itsaky.androidide.eventbus.events.file.FileCreationEvent +import com.itsaky.androidide.utils.FileIOUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -13,33 +12,39 @@ import org.greenrobot.eventbus.EventBus import java.io.File class FileActionManager { - private val scope = CoroutineScope(Dispatchers.IO) + private val scope = CoroutineScope(Dispatchers.IO) - fun createFile(baseDir: File, path: String, content: String, observer: FileActionObserver? = null) { - scope.launch { - val result = try { - val targetFile = File(baseDir, path) + fun createFile( + baseDir: File, + path: String, + content: String, + observer: FileActionObserver? = null, + ) { + scope.launch { + val result = + try { + val targetFile = File(baseDir, path) - val formattedContent = StringEscapeUtils.unescapeJava(content) - if (!FileIOUtils.writeFileFromString(targetFile, formattedContent)) { - Result.failure(Exception("Failed to write to file at path: $path")) - } else { - EventBus.getDefault().post(FileCreationEvent(targetFile)) - Result.success(targetFile) - } - } catch (e: Exception) { - Result.failure(e) - } + val formattedContent = StringEscapeUtils.unescapeJava(content) + if (!FileIOUtils.writeFileFromString(targetFile, formattedContent)) { + Result.failure(Exception("Failed to write to file at path: $path")) + } else { + EventBus.getDefault().post(FileCreationEvent(targetFile)) + Result.success(targetFile) + } + } catch (e: Exception) { + Result.failure(e) + } - withContext(Dispatchers.Main) { - if (result.isSuccess) { - val file = result.getOrNull() - observer?.onActionSuccess("File '${file?.name}' created successfully.", file) - } else { - val error = result.exceptionOrNull()?.message ?: "An unknown error occurred." - observer?.onActionFailure("Error: $error") - } - } - } - } + withContext(Dispatchers.Main) { + if (result.isSuccess) { + val file = result.getOrNull() + observer?.onActionSuccess("File '${file?.name}' created successfully.", file) + } else { + val error = result.exceptionOrNull()?.message ?: "An unknown error occurred." + observer?.onActionFailure("Error: $error") + } + } + } + } } diff --git a/app/src/main/java/com/itsaky/androidide/actions/filetree/DeleteAction.kt b/app/src/main/java/com/itsaky/androidide/actions/filetree/DeleteAction.kt index 7111331a38..144f1de025 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/filetree/DeleteAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/filetree/DeleteAction.kt @@ -19,7 +19,6 @@ package com.itsaky.androidide.actions.filetree import android.app.ProgressDialog import android.content.Context -import com.blankj.utilcode.util.FileUtils import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.actions.requireFile import com.itsaky.androidide.eventbus.events.file.FileDeletionEvent @@ -28,6 +27,7 @@ import com.itsaky.androidide.projects.FileManager import com.itsaky.androidide.resources.R import com.itsaky.androidide.tasks.executeAsync import com.itsaky.androidide.utils.DialogUtils +import com.itsaky.androidide.utils.FileUtils import com.itsaky.androidide.utils.FlashType import com.itsaky.androidide.utils.flashMessage import com.itsaky.androidide.utils.showWithLongPressTooltip @@ -39,74 +39,77 @@ import java.io.File * * @author Akash Yadav */ -class DeleteAction(context: Context, override val order: Int) : - BaseFileTreeAction(context, labelRes = R.string.delete_file, iconRes = R.drawable.ic_delete) { +class DeleteAction( + context: Context, + override val order: Int, +) : BaseFileTreeAction(context, labelRes = R.string.delete_file, iconRes = R.drawable.ic_delete) { + override val id: String = "ide.editor.fileTree.delete" - override val id: String = "ide.editor.fileTree.delete" - override fun retrieveTooltipTag(isAlternateContext: Boolean): String = - TooltipTag.PROJECT_ITEM_DELETE + override fun retrieveTooltipTag(isAlternateContext: Boolean): String = TooltipTag.PROJECT_ITEM_DELETE - override suspend fun execAction(data: ActionData) { - val context = data.requireActivity() - val file = data.requireFile() - val lastHeld = data.getTreeNode() - DialogUtils.newMaterialDialogBuilder(context) - .setNegativeButton(R.string.no, null) - .setPositiveButton(R.string.yes) { dialogInterface, _ -> - dialogInterface.dismiss() - @Suppress("DEPRECATION") - val progressDialog = - ProgressDialog.show(context, null, context.getString(R.string.please_wait), true, false) - executeAsync({ FileUtils.delete(file) }) { - progressDialog.dismiss() + override suspend fun execAction(data: ActionData) { + val context = data.requireActivity() + val file = data.requireFile() + val lastHeld = data.getTreeNode() + DialogUtils + .newMaterialDialogBuilder(context) + .setNegativeButton(R.string.no, null) + .setPositiveButton(R.string.yes) { dialogInterface, _ -> + dialogInterface.dismiss() + @Suppress("DEPRECATION") + val progressDialog = + ProgressDialog.show(context, null, context.getString(R.string.please_wait), true, false) + executeAsync({ FileUtils.delete(file) }) { + progressDialog.dismiss() - val deleted = it ?: false + val deleted = it ?: false - flashMessage( - if (deleted) R.string.deleted else R.string.delete_failed, - if (deleted) FlashType.SUCCESS else FlashType.ERROR - ) + flashMessage( + if (deleted) R.string.deleted else R.string.delete_failed, + if (deleted) FlashType.SUCCESS else FlashType.ERROR, + ) - if (!deleted) { - return@executeAsync - } + if (!deleted) { + return@executeAsync + } - notifyFileDeleted(file, context) + notifyFileDeleted(file, context) - if (lastHeld != null) { - val parent = lastHeld.parent - parent.deleteChild(lastHeld) - requestExpandNode(parent) - } else { - requestFileListing() - } + if (lastHeld != null) { + val parent = lastHeld.parent + parent.deleteChild(lastHeld) + requestExpandNode(parent) + } else { + requestFileListing() + } - val frag = context.getEditorForFile(file) - if (frag != null) { - context.closeFile(context.findIndexOfEditorByFile(frag.file)) - } - } - } - .setTitle(R.string.title_confirm_delete) - .setMessage( - context.getString( - R.string.msg_confirm_delete, - String.format("%s [%s]", file.name, file.absolutePath) - ) - ) - .setCancelable(false) - .showWithLongPressTooltip( - context = context, - tooltipTag = TooltipTag.PROJECT_CONFIRM_DELETE - ) - } + val frag = context.getEditorForFile(file) + if (frag != null) { + context.closeFile(context.findIndexOfEditorByFile(frag.file)) + } + } + }.setTitle(R.string.title_confirm_delete) + .setMessage( + context.getString( + R.string.msg_confirm_delete, + String.format("%s [%s]", file.name, file.absolutePath), + ), + ).setCancelable(false) + .showWithLongPressTooltip( + context = context, + tooltipTag = TooltipTag.PROJECT_CONFIRM_DELETE, + ) + } - private fun notifyFileDeleted(file: File, context: Context) { - val deletionEvent = FileDeletionEvent(file) + private fun notifyFileDeleted( + file: File, + context: Context, + ) { + val deletionEvent = FileDeletionEvent(file) - // Notify FileManager first - FileManager.onFileDeleted(deletionEvent) + // Notify FileManager first + FileManager.onFileDeleted(deletionEvent) - EventBus.getDefault().post(deletionEvent.putData(context)) - } + EventBus.getDefault().post(deletionEvent.putData(context)) + } } diff --git a/app/src/main/java/com/itsaky/androidide/actions/filetree/NewFileAction.kt b/app/src/main/java/com/itsaky/androidide/actions/filetree/NewFileAction.kt index b9555927a3..24450b6288 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/filetree/NewFileAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/filetree/NewFileAction.kt @@ -20,7 +20,6 @@ package com.itsaky.androidide.actions.filetree import android.content.Context import android.view.LayoutInflater import androidx.core.view.isVisible -import com.blankj.utilcode.util.FileIOUtils import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.actions.FileActionManager import com.itsaky.androidide.actions.observers.FileActionObserver @@ -37,6 +36,7 @@ import com.itsaky.androidide.templates.base.models.Dependency import com.itsaky.androidide.utils.ClassBuilder.SourceLanguage import com.itsaky.androidide.utils.DialogUtils import com.itsaky.androidide.utils.Environment +import com.itsaky.androidide.utils.FileIOUtils import com.itsaky.androidide.utils.ProjectWriter import com.itsaky.androidide.utils.SingleTextWatcher import com.itsaky.androidide.utils.flashError @@ -57,401 +57,457 @@ import java.util.regex.Pattern * * @author Akash Yadav */ -class NewFileAction(val context: Context, override val order: Int) : - BaseDirNodeAction( - context = context, - labelRes = R.string.new_file, - iconRes = R.drawable.ic_new_file - ), KoinComponent, FileActionObserver { - - private val fileActionManager: FileActionManager = get() - - private var currentNode: TreeNode? = null - - override val id: String = "ide.editor.fileTree.newFile" - - override fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = - TooltipTag.PROJECT_FOLDER_NEWFILE - - companion object { - - const val RES_PATH_REGEX = "/.*/src/.*/res" - const val LAYOUT_RES_PATH_REGEX = "/.*/src/.*/res/layout" - const val MENU_RES_PATH_REGEX = "/.*/src/.*/res/menu" - const val DRAWABLE_RES_PATH_REGEX = "/.*/src/.*/res/drawable" - const val JAVA_PATH_REGEX = "/.*/src/.*/java" - - private val log = LoggerFactory.getLogger(NewFileAction::class.java) - } - - override suspend fun execAction(data: ActionData) { - val context = data.requireActivity() - val file = data.requireFile() - val node = data.getTreeNode() - try { - createNewFile(context, node, file, false) - } catch (e: Exception) { - log.error("Failed to create new file", e) - flashError(e.cause?.message ?: e.message) - } - } - - private fun createNewFile( - context: Context, - node: TreeNode?, - file: File, - forceUnknownType: Boolean - ) { - if (forceUnknownType) { - createNewEmptyFile(context, node, file) - return - } - - val projectDir = IProjectManager.getInstance().projectDirPath - Objects.requireNonNull(projectDir) - val isJava = - Pattern.compile(Pattern.quote(projectDir) + JAVA_PATH_REGEX).matcher(file.absolutePath).find() - val isRes = - Pattern.compile(Pattern.quote(projectDir) + RES_PATH_REGEX).matcher(file.absolutePath).find() - val isLayoutRes = - Pattern.compile(Pattern.quote(projectDir) + LAYOUT_RES_PATH_REGEX) - .matcher(file.absolutePath) - .find() - val isMenuRes = - Pattern.compile(Pattern.quote(projectDir) + MENU_RES_PATH_REGEX) - .matcher(file.absolutePath) - .find() - val isDrawableRes = - Pattern.compile(Pattern.quote(projectDir) + DRAWABLE_RES_PATH_REGEX) - .matcher(file.absolutePath) - .find() - - if (isJava) { - createJavaClass(context, node, file) - return - } - - if (isLayoutRes && file.name == "layout") { - createLayoutRes(context, node, file) - return - } - - if (isMenuRes && file.name == "menu") { - createMenuRes(context, node, file) - return - } - - if (isDrawableRes && file.name == "drawable") { - createDrawableRes(context, node, file) - return - } - - if (isRes && file.name == "res") { - createNewResource(context, node, file) - return - } - - createNewEmptyFile(context, node, file) - } - - private fun createJavaClass(context: Context, node: TreeNode?, file: File) { - val builder = DialogUtils.newMaterialDialogBuilder(context) - val binding: LayoutCreateFileJavaBinding = - LayoutCreateFileJavaBinding.inflate(LayoutInflater.from(context)) - binding.typeGroup.addOnButtonCheckedListener { _, _, _ -> - binding.createLayout.isVisible = binding.typeGroup.checkedButtonId == binding.typeActivity.id - } - binding.name.editText?.addTextChangedListener( - object : SingleTextWatcher() { - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { - if (isValidJavaName(s)) { - binding.name.isErrorEnabled = true - binding.name.error = context.getString(R.string.msg_invalid_name) - } else { - binding.name.isErrorEnabled = false - } - } - } - ) - builder.setView(binding.root) - builder.setTitle(R.string.new_file) - builder.setPositiveButton(R.string.text_create) { dialogInterface, _ -> - dialogInterface.dismiss() - try { - doCreateSourceFile(binding, file, context, node) - } catch (e: Exception) { - log.error("Failed to create source file", e) - flashError(e.cause?.message ?: e.message) - } - } - builder.setNegativeButton(android.R.string.cancel, null) - builder.setCancelable(false) - .showWithLongPressTooltip( - context = context, - tooltipTag = TooltipTag.PROJECT_FOLDER_NEWTYPE, - binding.typeClass, - binding.typeActivity, - binding.typeInterface, - binding.typeEnum - ) - } - - private fun doCreateSourceFile( - binding: LayoutCreateFileJavaBinding, - file: File, - context: Context, - node: TreeNode? - ) { - if (binding.name.isErrorEnabled) { - flashError(R.string.msg_invalid_name) - return - } - - val name: String = binding.name.editText!!.text.toString().trim() - if (name.isBlank()) { - flashError(R.string.msg_invalid_name) - return - } - - val isKotlin = binding.languageGroup.checkedButtonId == binding.langKotlin.id - val language = if (isKotlin) SourceLanguage.KOTLIN else SourceLanguage.JAVA - val extension = if (isKotlin) ".kt" else ".java" - - val autoLayout = - binding.typeGroup.checkedButtonId == binding.typeActivity.id && - binding.createLayout.isChecked - val pkgName = ProjectWriter.getPackageName(file) - - val id: Int = binding.typeGroup.checkedButtonId - val fileName = if (name.endsWith(extension)) name else "$name$extension" - val className = if (!name.contains(".")) name else name.substring(0, name.lastIndexOf(".")) - - val sourceFileDirectory = if (pkgName == "com") { - val subDir = File(file, "com") - if (subDir.exists() && subDir.isDirectory) subDir else file - } else { - file - } - - when (id) { - binding.typeClass.id -> - createFile( - node, - sourceFileDirectory, - fileName, - ProjectWriter.createClass(pkgName, className, language), - ) - - binding.typeInterface.id -> - createFile( - node, - sourceFileDirectory, - fileName, - ProjectWriter.createInterface(pkgName, className, language) - ) - - binding.typeEnum.id -> - createFile( - node, - sourceFileDirectory, - fileName, - ProjectWriter.createEnum(pkgName, className, language) - ) - - binding.typeActivity.id -> { - val appCompat = Dependency.AndroidX.AppCompat - val projectManager = ProjectManagerImpl.getInstance() - val hasAppCompatDependency = projectManager.findModuleForFile(file) - ?.hasExternalDependency(appCompat.group, appCompat.artifact) - createFile( - node, - sourceFileDirectory, - fileName, - ProjectWriter.createActivity( - pkgName, - className, - hasAppCompatDependency ?: false, - language - ) - ) - } - - else -> createFile(node, sourceFileDirectory, name, "") - } - - if (autoLayout) { - val packagePath = pkgName.toString().replace(".", "/") - createAutoLayout(context, sourceFileDirectory, name, packagePath, isKotlin) - } - } - - private fun isValidJavaName(s: CharSequence?) = - s == null || !SourceVersion.isName(s) || SourceVersion.isKeyword(s) - - private fun createLayoutRes(context: Context, node: TreeNode?, file: File) { - createNewFileWithContent( - context, - node, - Environment.mkdirIfNotExists(file), - ProjectWriter.createLayout(), - ".xml" - ) - } - - private fun createAutoLayout( - context: Context, - directory: File, - fileName: String, - packagePath: String, - isKotlin: Boolean = false - ) { - val dir = directory.toString().replace("java/$packagePath", "res/layout/") - val sourceExtension = if (isKotlin) ".kt" else ".java" - val layoutName = ProjectWriter.createLayoutName(fileName.replace(sourceExtension, ".xml")) - val newFileLayout = File(dir, layoutName) - if (newFileLayout.exists()) { - flashError(R.string.msg_layout_file_exists) - return - } - - if (!FileIOUtils.writeFileFromString(newFileLayout, ProjectWriter.createLayout())) { - flashError(R.string.msg_layout_file_creation_failed) - return - } - - notifyFileCreated(newFileLayout, context) - } - - private fun createMenuRes(context: Context, node: TreeNode?, file: File) { - createNewFileWithContent( - context, - node, - Environment.mkdirIfNotExists(file), - ProjectWriter.createMenu(), - ".xml" - ) - } - - private fun createDrawableRes(context: Context, node: TreeNode?, file: File) { - createNewFileWithContent( - context, - node, - Environment.mkdirIfNotExists(file), - ProjectWriter.createDrawable(), - ".xml" - ) - } - - private fun createNewResource(context: Context, node: TreeNode?, file: File) { - val labels = - arrayOf( - context.getString(R.string.restype_drawable), - context.getString(R.string.restype_layout), - context.getString(R.string.restype_menu), - context.getString(R.string.restype_other) - ) - val builder = DialogUtils.newMaterialDialogBuilder(context) - builder.setTitle(R.string.new_xml_resource) - builder.setItems(labels) { _, position -> - when (position) { - 0 -> createDrawableRes(context, node, File(file, "drawable")) - 1 -> createLayoutRes(context, node, File(file, "layout")) - 2 -> createMenuRes(context, node, File(file, "menu")) - 3 -> createNewFile(context, node, file, true) - } - } - .showWithLongPressTooltip( - context = context, - tooltipTag = TooltipTag.PROJECT_FOLDER_NEWXML - ) - } - - private fun createNewEmptyFile(context: Context, node: TreeNode?, file: File) { - createNewFileWithContent(context, node, file, "") - } - - private fun createNewFileWithContent( - context: Context, - node: TreeNode?, - file: File, - content: String - ) { - createNewFileWithContent(context, node, file, content, null) - } - - private fun createNewFileWithContent( - context: Context, - node: TreeNode?, - folder: File, - content: String, - extension: String?, - ) { - val binding = LayoutDialogTextInputBinding.inflate(LayoutInflater.from(context)) - val builder = DialogUtils.newMaterialDialogBuilder(context) - binding.name.editText!!.setHint(R.string.file_name) - builder.setTitle(R.string.new_file) - builder.setMessage( - context.getString(R.string.msg_can_contain_slashes) + - "\n\n" + - context.getString(R.string.msg_newfile_dest, folder.absolutePath) - ) - builder.setView(binding.root) - builder.setCancelable(false) - builder.setPositiveButton(R.string.text_create) { dialogInterface, _ -> - dialogInterface.dismiss() - var name = binding.name.editText!!.text.toString().trim() - if (name.isBlank()) { - flashError(R.string.msg_invalid_name) - return@setPositiveButton - } - - if (extension != null && extension.trim { it <= ' ' }.isNotEmpty()) { - name = if (name.endsWith(extension)) name else name + extension - } - - try { - createFile(node, folder, name, content) - } catch (e: Exception) { - log.error("Failed to create file", e) - flashError(e.cause?.message ?: e.message) - } - } - builder.setNegativeButton(android.R.string.cancel, null) - .showWithLongPressTooltip( - context = context, - tooltipTag = TooltipTag.PROJECT_NEWFILE_DIALOG - ) - } - - private fun createFile( - node: TreeNode?, - directory: File, - name: String, - content: String - ) { - if (name.length !in 1..40 || name.startsWith("/")) { - flashError(R.string.msg_invalid_name) - return - } - this.currentNode = node - fileActionManager.createFile(directory, name, content, this) - } - - private fun notifyFileCreated(file: File, context: Context) { - EventBus.getDefault().post(FileCreationEvent(file).putData(context)) - } - - override fun onActionSuccess(message: String, createdFile: File?) { - flashSuccess(R.string.msg_file_created) - if (currentNode != null) { - requestCollapseNode(currentNode!!, false) - requestExpandNode(currentNode!!) - } else { - requestFileListing() - } - } - - override fun onActionFailure(errorMessage: String) { - flashError(errorMessage) - } +class NewFileAction( + val context: Context, + override val order: Int, +) : BaseDirNodeAction( + context = context, + labelRes = R.string.new_file, + iconRes = R.drawable.ic_new_file, + ), + KoinComponent, + FileActionObserver { + private val fileActionManager: FileActionManager = get() + + private var currentNode: TreeNode? = null + + override val id: String = "ide.editor.fileTree.newFile" + + override fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = TooltipTag.PROJECT_FOLDER_NEWFILE + + companion object { + const val RES_PATH_REGEX = "/.*/src/.*/res" + const val LAYOUT_RES_PATH_REGEX = "/.*/src/.*/res/layout" + const val MENU_RES_PATH_REGEX = "/.*/src/.*/res/menu" + const val DRAWABLE_RES_PATH_REGEX = "/.*/src/.*/res/drawable" + const val JAVA_PATH_REGEX = "/.*/src/.*/java" + + private val log = LoggerFactory.getLogger(NewFileAction::class.java) + } + + override suspend fun execAction(data: ActionData) { + val context = data.requireActivity() + val file = data.requireFile() + val node = data.getTreeNode() + try { + createNewFile(context, node, file, false) + } catch (e: Exception) { + log.error("Failed to create new file", e) + flashError(e.cause?.message ?: e.message) + } + } + + private fun createNewFile( + context: Context, + node: TreeNode?, + file: File, + forceUnknownType: Boolean, + ) { + if (forceUnknownType) { + createNewEmptyFile(context, node, file) + return + } + + val projectDir = IProjectManager.getInstance().projectDirPath + Objects.requireNonNull(projectDir) + val isJava = + Pattern.compile(Pattern.quote(projectDir) + JAVA_PATH_REGEX).matcher(file.absolutePath).find() + val isRes = + Pattern.compile(Pattern.quote(projectDir) + RES_PATH_REGEX).matcher(file.absolutePath).find() + val isLayoutRes = + Pattern + .compile(Pattern.quote(projectDir) + LAYOUT_RES_PATH_REGEX) + .matcher(file.absolutePath) + .find() + val isMenuRes = + Pattern + .compile(Pattern.quote(projectDir) + MENU_RES_PATH_REGEX) + .matcher(file.absolutePath) + .find() + val isDrawableRes = + Pattern + .compile(Pattern.quote(projectDir) + DRAWABLE_RES_PATH_REGEX) + .matcher(file.absolutePath) + .find() + + if (isJava) { + createJavaClass(context, node, file) + return + } + + if (isLayoutRes && file.name == "layout") { + createLayoutRes(context, node, file) + return + } + + if (isMenuRes && file.name == "menu") { + createMenuRes(context, node, file) + return + } + + if (isDrawableRes && file.name == "drawable") { + createDrawableRes(context, node, file) + return + } + + if (isRes && file.name == "res") { + createNewResource(context, node, file) + return + } + + createNewEmptyFile(context, node, file) + } + + private fun createJavaClass( + context: Context, + node: TreeNode?, + file: File, + ) { + val builder = DialogUtils.newMaterialDialogBuilder(context) + val binding: LayoutCreateFileJavaBinding = + LayoutCreateFileJavaBinding.inflate(LayoutInflater.from(context)) + binding.typeGroup.addOnButtonCheckedListener { _, _, _ -> + binding.createLayout.isVisible = binding.typeGroup.checkedButtonId == binding.typeActivity.id + } + binding.name.editText?.addTextChangedListener( + object : SingleTextWatcher() { + override fun onTextChanged( + s: CharSequence?, + start: Int, + before: Int, + count: Int, + ) { + if (isValidJavaName(s)) { + binding.name.isErrorEnabled = true + binding.name.error = context.getString(R.string.msg_invalid_name) + } else { + binding.name.isErrorEnabled = false + } + } + }, + ) + builder.setView(binding.root) + builder.setTitle(R.string.new_file) + builder.setPositiveButton(R.string.text_create) { dialogInterface, _ -> + dialogInterface.dismiss() + try { + doCreateSourceFile(binding, file, context, node) + } catch (e: Exception) { + log.error("Failed to create source file", e) + flashError(e.cause?.message ?: e.message) + } + } + builder.setNegativeButton(android.R.string.cancel, null) + builder + .setCancelable(false) + .showWithLongPressTooltip( + context = context, + tooltipTag = TooltipTag.PROJECT_FOLDER_NEWTYPE, + binding.typeClass, + binding.typeActivity, + binding.typeInterface, + binding.typeEnum, + ) + } + + private fun doCreateSourceFile( + binding: LayoutCreateFileJavaBinding, + file: File, + context: Context, + node: TreeNode?, + ) { + if (binding.name.isErrorEnabled) { + flashError(R.string.msg_invalid_name) + return + } + + val name: String = + binding.name.editText!! + .text + .toString() + .trim() + if (name.isBlank()) { + flashError(R.string.msg_invalid_name) + return + } + + val isKotlin = binding.languageGroup.checkedButtonId == binding.langKotlin.id + val language = if (isKotlin) SourceLanguage.KOTLIN else SourceLanguage.JAVA + val extension = if (isKotlin) ".kt" else ".java" + + val autoLayout = + binding.typeGroup.checkedButtonId == binding.typeActivity.id && + binding.createLayout.isChecked + val pkgName = ProjectWriter.getPackageName(file) + + val id: Int = binding.typeGroup.checkedButtonId + val fileName = if (name.endsWith(extension)) name else "$name$extension" + val className = if (!name.contains(".")) name else name.substring(0, name.lastIndexOf(".")) + + val sourceFileDirectory = + if (pkgName == "com") { + val subDir = File(file, "com") + if (subDir.exists() && subDir.isDirectory) subDir else file + } else { + file + } + + when (id) { + binding.typeClass.id -> { + createFile( + node, + sourceFileDirectory, + fileName, + ProjectWriter.createClass(pkgName, className, language), + ) + } + + binding.typeInterface.id -> { + createFile( + node, + sourceFileDirectory, + fileName, + ProjectWriter.createInterface(pkgName, className, language), + ) + } + + binding.typeEnum.id -> { + createFile( + node, + sourceFileDirectory, + fileName, + ProjectWriter.createEnum(pkgName, className, language), + ) + } + + binding.typeActivity.id -> { + val appCompat = Dependency.AndroidX.AppCompat + val projectManager = ProjectManagerImpl.getInstance() + val hasAppCompatDependency = + projectManager + .findModuleForFile(file) + ?.hasExternalDependency(appCompat.group, appCompat.artifact) + createFile( + node, + sourceFileDirectory, + fileName, + ProjectWriter.createActivity( + pkgName, + className, + hasAppCompatDependency ?: false, + language, + ), + ) + } + + else -> { + createFile(node, sourceFileDirectory, name, "") + } + } + + if (autoLayout) { + val packagePath = pkgName.toString().replace(".", "/") + createAutoLayout(context, sourceFileDirectory, name, packagePath, isKotlin) + } + } + + private fun isValidJavaName(s: CharSequence?) = s == null || !SourceVersion.isName(s) || SourceVersion.isKeyword(s) + + private fun createLayoutRes( + context: Context, + node: TreeNode?, + file: File, + ) { + createNewFileWithContent( + context, + node, + Environment.mkdirIfNotExists(file), + ProjectWriter.createLayout(), + ".xml", + ) + } + + private fun createAutoLayout( + context: Context, + directory: File, + fileName: String, + packagePath: String, + isKotlin: Boolean = false, + ) { + val dir = directory.toString().replace("java/$packagePath", "res/layout/") + val sourceExtension = if (isKotlin) ".kt" else ".java" + val layoutName = ProjectWriter.createLayoutName(fileName.replace(sourceExtension, ".xml")) + val newFileLayout = File(dir, layoutName) + if (newFileLayout.exists()) { + flashError(R.string.msg_layout_file_exists) + return + } + + if (!FileIOUtils.writeFileFromString(newFileLayout, ProjectWriter.createLayout())) { + flashError(R.string.msg_layout_file_creation_failed) + return + } + + notifyFileCreated(newFileLayout, context) + } + + private fun createMenuRes( + context: Context, + node: TreeNode?, + file: File, + ) { + createNewFileWithContent( + context, + node, + Environment.mkdirIfNotExists(file), + ProjectWriter.createMenu(), + ".xml", + ) + } + + private fun createDrawableRes( + context: Context, + node: TreeNode?, + file: File, + ) { + createNewFileWithContent( + context, + node, + Environment.mkdirIfNotExists(file), + ProjectWriter.createDrawable(), + ".xml", + ) + } + + private fun createNewResource( + context: Context, + node: TreeNode?, + file: File, + ) { + val labels = + arrayOf( + context.getString(R.string.restype_drawable), + context.getString(R.string.restype_layout), + context.getString(R.string.restype_menu), + context.getString(R.string.restype_other), + ) + val builder = DialogUtils.newMaterialDialogBuilder(context) + builder.setTitle(R.string.new_xml_resource) + builder + .setItems(labels) { _, position -> + when (position) { + 0 -> createDrawableRes(context, node, File(file, "drawable")) + 1 -> createLayoutRes(context, node, File(file, "layout")) + 2 -> createMenuRes(context, node, File(file, "menu")) + 3 -> createNewFile(context, node, file, true) + } + }.showWithLongPressTooltip( + context = context, + tooltipTag = TooltipTag.PROJECT_FOLDER_NEWXML, + ) + } + + private fun createNewEmptyFile( + context: Context, + node: TreeNode?, + file: File, + ) { + createNewFileWithContent(context, node, file, "") + } + + private fun createNewFileWithContent( + context: Context, + node: TreeNode?, + file: File, + content: String, + ) { + createNewFileWithContent(context, node, file, content, null) + } + + private fun createNewFileWithContent( + context: Context, + node: TreeNode?, + folder: File, + content: String, + extension: String?, + ) { + val binding = LayoutDialogTextInputBinding.inflate(LayoutInflater.from(context)) + val builder = DialogUtils.newMaterialDialogBuilder(context) + binding.name.editText!!.setHint(R.string.file_name) + builder.setTitle(R.string.new_file) + builder.setMessage( + context.getString(R.string.msg_can_contain_slashes) + + "\n\n" + + context.getString(R.string.msg_newfile_dest, folder.absolutePath), + ) + builder.setView(binding.root) + builder.setCancelable(false) + builder.setPositiveButton(R.string.text_create) { dialogInterface, _ -> + dialogInterface.dismiss() + var name = + binding.name.editText!! + .text + .toString() + .trim() + if (name.isBlank()) { + flashError(R.string.msg_invalid_name) + return@setPositiveButton + } + + if (extension != null && extension.trim { it <= ' ' }.isNotEmpty()) { + name = if (name.endsWith(extension)) name else name + extension + } + + try { + createFile(node, folder, name, content) + } catch (e: Exception) { + log.error("Failed to create file", e) + flashError(e.cause?.message ?: e.message) + } + } + builder + .setNegativeButton(android.R.string.cancel, null) + .showWithLongPressTooltip( + context = context, + tooltipTag = TooltipTag.PROJECT_NEWFILE_DIALOG, + ) + } + + private fun createFile( + node: TreeNode?, + directory: File, + name: String, + content: String, + ) { + if (name.length !in 1..40 || name.startsWith("/")) { + flashError(R.string.msg_invalid_name) + return + } + this.currentNode = node + fileActionManager.createFile(directory, name, content, this) + } + + private fun notifyFileCreated( + file: File, + context: Context, + ) { + EventBus.getDefault().post(FileCreationEvent(file).putData(context)) + } + + override fun onActionSuccess( + message: String, + createdFile: File?, + ) { + flashSuccess(R.string.msg_file_created) + if (currentNode != null) { + requestCollapseNode(currentNode!!, false) + requestExpandNode(currentNode!!) + } else { + requestFileListing() + } + } + + override fun onActionFailure(errorMessage: String) { + flashError(errorMessage) + } } diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index d816df83dd..c70063f772 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -67,7 +67,6 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.blankj.utilcode.constant.MemoryConstants import com.blankj.utilcode.util.ConvertUtils.byte2MemorySize -import com.blankj.utilcode.util.FileUtils import com.github.mikephil.charting.components.AxisBase import com.github.mikephil.charting.data.Entry import com.github.mikephil.charting.data.LineData @@ -124,6 +123,7 @@ import com.itsaky.androidide.ui.SwipeRevealLayout import com.itsaky.androidide.uidesigner.UIDesignerActivity import com.itsaky.androidide.utils.ActionMenuUtils.showPopupWindow import com.itsaky.androidide.utils.DialogUtils.newMaterialDialogBuilder +import com.itsaky.androidide.utils.FileUtils import com.itsaky.androidide.utils.FlashType import com.itsaky.androidide.utils.InstallationResultHandler.onResult import com.itsaky.androidide.utils.IntentUtils diff --git a/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt b/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt index b92b323678..41af7f4c48 100644 --- a/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt @@ -12,10 +12,10 @@ import android.widget.PopupWindow import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.TooltipCompat import androidx.recyclerview.widget.RecyclerView -import com.blankj.utilcode.util.FileUtils import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputLayout import com.itsaky.androidide.R +import com.itsaky.androidide.databinding.RenameProjectTextinputBinding import com.itsaky.androidide.databinding.SavedRecentProjectItemBinding import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag.DELETE_PROJECT @@ -24,99 +24,115 @@ import com.itsaky.androidide.idetooltips.TooltipTag.PROJECT_INFO_TOOLTIP import com.itsaky.androidide.idetooltips.TooltipTag.PROJECT_RECENT_RENAME import com.itsaky.androidide.idetooltips.TooltipTag.PROJECT_RECENT_TOP import com.itsaky.androidide.idetooltips.TooltipTag.PROJECT_RENAME_DIALOG +import com.itsaky.androidide.models.ProjectFile import com.itsaky.androidide.tasks.executeAsync +import com.itsaky.androidide.utils.FileUtils import com.itsaky.androidide.utils.applyLongPressRecursively import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess -import com.itsaky.androidide.databinding.RenameProjectTextinputBinding -import com.itsaky.androidide.models.ProjectFile import org.slf4j.LoggerFactory import java.io.File class RecentProjectsAdapter( - private var projects: List, - private val onProjectClick: (File) -> Unit, - private val onRemoveProjectClick: (ProjectFile) -> Unit, - private val onFileRenamed: (RenamedFile) -> Unit, - private val onInfoClick: (ProjectFile) -> Unit, - private val nameExists: (String) -> Boolean, + private var projects: List, + private val onProjectClick: (File) -> Unit, + private val onRemoveProjectClick: (ProjectFile) -> Unit, + private val onFileRenamed: (RenamedFile) -> Unit, + private val onInfoClick: (ProjectFile) -> Unit, + private val nameExists: (String) -> Boolean, ) : RecyclerView.Adapter() { - private var projectOptionsPopup: PopupWindow? = null - private companion object { + private companion object { private val logger = LoggerFactory.getLogger(RecentProjectsAdapter::class.java) - const val VIEW_TYPE_PROJECT = 0 - const val VIEW_TYPE_OPEN_FOLDER = 1 - } + const val VIEW_TYPE_PROJECT = 0 + const val VIEW_TYPE_OPEN_FOLDER = 1 + } - override fun getItemCount(): Int = projects.size + override fun getItemCount(): Int = projects.size - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProjectViewHolder { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): ProjectViewHolder { val inflater = LayoutInflater.from(parent.context) val binding = SavedRecentProjectItemBinding.inflate(inflater, parent, false) return ProjectViewHolder(binding) } - override fun onBindViewHolder(holder: ProjectViewHolder, position: Int) { + override fun onBindViewHolder( + holder: ProjectViewHolder, + position: Int, + ) { holder.bind(projects[position], position) } - fun updateProjects(newProjects: List) { - projects = newProjects + fun updateProjects(newProjects: List) { + projects = newProjects // noinspection NotifyDataSetChanged - notifyDataSetChanged() - } - - fun renderDate(binding: SavedRecentProjectItemBinding, project: ProjectFile) { - binding.projectDate.text = project.renderDateText(binding.root.context) - } - - inner class ProjectViewHolder(private val binding: SavedRecentProjectItemBinding) : - RecyclerView.ViewHolder(binding.root) { - - fun bind(project: ProjectFile, position: Int) { - binding.projectName.text = project.name - - renderDate(binding, project) - - binding.icon.text = project.name - .split(" ") - .mapNotNull { it.firstOrNull()?.uppercaseChar() } - .take(2) - .joinToString("") - - TooltipCompat.setTooltipText( - binding.menu, - binding.root.context.getString(R.string.options) - ) - TooltipCompat.setTooltipText(binding.root, project.name) - binding.root.animation = - AnimationUtils.loadAnimation(binding.root.context, R.anim.project_list_animation) - - binding.root.setOnClickListener { - onProjectClick(File(project.path)) - } - binding.root.setOnLongClickListener { + notifyDataSetChanged() + } + + fun renderDate( + binding: SavedRecentProjectItemBinding, + project: ProjectFile, + ) { + binding.projectDate.text = project.renderDateText(binding.root.context) + } + + inner class ProjectViewHolder( + private val binding: SavedRecentProjectItemBinding, + ) : RecyclerView.ViewHolder(binding.root) { + fun bind( + project: ProjectFile, + position: Int, + ) { + binding.projectName.text = project.name + + renderDate(binding, project) + + binding.icon.text = + project.name + .split(" ") + .mapNotNull { it.firstOrNull()?.uppercaseChar() } + .take(2) + .joinToString("") + + TooltipCompat.setTooltipText( + binding.menu, + binding.root.context.getString(R.string.options), + ) + TooltipCompat.setTooltipText(binding.root, project.name) + binding.root.animation = + AnimationUtils.loadAnimation(binding.root.context, R.anim.project_list_animation) + + binding.root.setOnClickListener { + onProjectClick(File(project.path)) + } + binding.root.setOnLongClickListener { TooltipManager.showIdeCategoryTooltip( binding.root.context, binding.root, - PROJECT_RECENT_TOP + PROJECT_RECENT_TOP, ) true } - binding.menu.setOnClickListener { view -> - showPopupMenu(view, project, position) + binding.menu.setOnClickListener { view -> + showPopupMenu(view, project, position) } } } - private fun showPopupMenu(view: View, project: ProjectFile, position: Int) { - val inflater = LayoutInflater.from(view.context) + private fun showPopupMenu( + view: View, + project: ProjectFile, + position: Int, + ) { + val inflater = LayoutInflater.from(view.context) // noinspection InflateParams - val popupView = inflater.inflate(R.layout.custom_popup_menu, null) + val popupView = inflater.inflate(R.layout.custom_popup_menu, null) projectOptionsPopup?.dismiss() projectOptionsPopup = @@ -129,191 +145,216 @@ class RecentProjectsAdapter( val popupWindow = projectOptionsPopup!! - val infoItem = popupView.findViewById(R.id.menu_info) - val renameItem = popupView.findViewById(R.id.menu_rename) - val deleteItem = popupView.findViewById(R.id.menu_delete) - - infoItem.setOnClickListener { - popupWindow.dismiss() - onInfoClick(project) - } - - infoItem.setOnLongClickListener { - popupWindow.dismiss() - TooltipManager.showIdeCategoryTooltip( - context = view.context, - anchorView = view, - tag = PROJECT_INFO_TOOLTIP - ) - true - } - - renameItem.setOnClickListener { - promptRenameProject(view, project, position) - popupWindow.dismiss() - } - - renameItem.setOnLongClickListener { - popupWindow.dismiss() - TooltipManager.showIdeCategoryTooltip( - context = view.context, - anchorView = view, - tag = PROJECT_RECENT_RENAME - ) - true - } - - deleteItem.setOnClickListener { - showDeleteDialog(view.context, project) - popupWindow.dismiss() - } - - deleteItem.setOnLongClickListener { - popupWindow.dismiss() - TooltipManager.showIdeCategoryTooltip( - context = view.context, - anchorView = view, - tag = DELETE_PROJECT - ) - true - } - - popupWindow.showAsDropDown(view, 0, 0) - } - - private fun showDeleteDialog(context: Context, project: ProjectFile) { - val dialog = MaterialAlertDialogBuilder(context) - .setTitle(R.string.delete_project) - .setMessage(R.string.msg_delete_project) - .setNegativeButton(R.string.no) { dialog, _ -> dialog.dismiss() } - .setPositiveButton(R.string.yes) { _, _ -> - onRemoveProjectClick(project) - executeAsync({ FileUtils.delete(project.path) }) { - val deleted = it ?: false - if (!deleted) { - return@executeAsync - } - } - } - .create() - - - val contentView = dialog.window?.decorView - - dialog.setOnShowListener { - contentView?.applyLongPressRecursively { - TooltipManager.showIdeCategoryTooltip( - context = context, - anchorView = contentView, - tag = DELETE_PROJECT_DIALOG - ) - true - } - } - - dialog.show() - } - - private fun promptRenameProject(view: View, project: ProjectFile, position: Int) { - val context = view.context - val oldName = project.name - val builder = MaterialAlertDialogBuilder(context).setTitle(R.string.rename_project) - - val binding = RenameProjectTextinputBinding.inflate(LayoutInflater.from(context)) - binding.textinputEdittext.setText(project.name) - binding.textinputLayout.hint = context.getString(R.string.msg_new_project_name) - val padding = (16 * context.resources.displayMetrics.density).toInt() - builder.setView(binding.root, padding, padding, padding, padding) - - builder.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } - builder.setPositiveButton(R.string.rename) { _, _ -> - val newName = binding.textinputEdittext.text.toString().trim() - val oldPath = project.path - val newPath = oldPath.substringBeforeLast("/") + "/" + newName - try { - project.rename(newPath) - flashSuccess(R.string.renamed) - onFileRenamed(RenamedFile(oldName, newName, oldPath, newPath)) - notifyItemChanged(position) - } catch (e: Exception) { + val infoItem = popupView.findViewById(R.id.menu_info) + val renameItem = popupView.findViewById(R.id.menu_rename) + val deleteItem = popupView.findViewById(R.id.menu_delete) + + infoItem.setOnClickListener { + popupWindow.dismiss() + onInfoClick(project) + } + + infoItem.setOnLongClickListener { + popupWindow.dismiss() + TooltipManager.showIdeCategoryTooltip( + context = view.context, + anchorView = view, + tag = PROJECT_INFO_TOOLTIP, + ) + true + } + + renameItem.setOnClickListener { + promptRenameProject(view, project, position) + popupWindow.dismiss() + } + + renameItem.setOnLongClickListener { + popupWindow.dismiss() + TooltipManager.showIdeCategoryTooltip( + context = view.context, + anchorView = view, + tag = PROJECT_RECENT_RENAME, + ) + true + } + + deleteItem.setOnClickListener { + showDeleteDialog(view.context, project) + popupWindow.dismiss() + } + + deleteItem.setOnLongClickListener { + popupWindow.dismiss() + TooltipManager.showIdeCategoryTooltip( + context = view.context, + anchorView = view, + tag = DELETE_PROJECT, + ) + true + } + + popupWindow.showAsDropDown(view, 0, 0) + } + + private fun showDeleteDialog( + context: Context, + project: ProjectFile, + ) { + val dialog = + MaterialAlertDialogBuilder(context) + .setTitle(R.string.delete_project) + .setMessage(R.string.msg_delete_project) + .setNegativeButton(R.string.no) { dialog, _ -> dialog.dismiss() } + .setPositiveButton(R.string.yes) { _, _ -> + onRemoveProjectClick(project) + executeAsync({ FileUtils.delete(project.path) }) { + val deleted = it ?: false + if (!deleted) { + return@executeAsync + } + } + }.create() + + val contentView = dialog.window?.decorView + + dialog.setOnShowListener { + contentView?.applyLongPressRecursively { + TooltipManager.showIdeCategoryTooltip( + context = context, + anchorView = contentView, + tag = DELETE_PROJECT_DIALOG, + ) + true + } + } + + dialog.show() + } + + private fun promptRenameProject( + view: View, + project: ProjectFile, + position: Int, + ) { + val context = view.context + val oldName = project.name + val builder = MaterialAlertDialogBuilder(context).setTitle(R.string.rename_project) + + val binding = RenameProjectTextinputBinding.inflate(LayoutInflater.from(context)) + binding.textinputEdittext.setText(project.name) + binding.textinputLayout.hint = context.getString(R.string.msg_new_project_name) + val padding = (16 * context.resources.displayMetrics.density).toInt() + builder.setView(binding.root, padding, padding, padding, padding) + + builder.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } + builder.setPositiveButton(R.string.rename) { _, _ -> + val newName = + binding.textinputEdittext.text + .toString() + .trim() + val oldPath = project.path + val newPath = oldPath.substringBeforeLast("/") + "/" + newName + try { + project.rename(newPath) + flashSuccess(R.string.renamed) + onFileRenamed(RenamedFile(oldName, newName, oldPath, newPath)) + notifyItemChanged(position) + } catch (e: Exception) { logger.error("Failed to rename project", e) - flashError(R.string.rename_failed) - } - } - - val dialog = builder.create() - dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE) - - val contentView = dialog.window?.decorView - - dialog.setOnShowListener { - contentView?.applyLongPressRecursively { - TooltipManager.showIdeCategoryTooltip( - context = context, - anchorView = contentView, - tag = PROJECT_RENAME_DIALOG - ) - true - } - } - - dialog.show() - - binding.textinputEdittext.addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} - override fun afterTextChanged(s: Editable?) { - validateProjectName(binding.textinputLayout, s.toString().trim(), oldName, dialog) - } - }) - - validateProjectName( - binding.textinputLayout, - binding.textinputEdittext.text.toString().trim(), - oldName, - dialog - ) - } - - private fun validateProjectName( - inputLayout: TextInputLayout, + flashError(R.string.rename_failed) + } + } + + val dialog = builder.create() + dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE) + + val contentView = dialog.window?.decorView + + dialog.setOnShowListener { + contentView?.applyLongPressRecursively { + TooltipManager.showIdeCategoryTooltip( + context = context, + anchorView = contentView, + tag = PROJECT_RENAME_DIALOG, + ) + true + } + } + + dialog.show() + + binding.textinputEdittext.addTextChangedListener( + object : TextWatcher { + override fun beforeTextChanged( + s: CharSequence?, + start: Int, + count: Int, + after: Int, + ) {} + + override fun onTextChanged( + s: CharSequence?, + start: Int, + before: Int, + count: Int, + ) {} + + override fun afterTextChanged(s: Editable?) { + validateProjectName(binding.textinputLayout, s.toString().trim(), oldName, dialog) + } + }, + ) + + validateProjectName( + binding.textinputLayout, + binding.textinputEdittext.text + .toString() + .trim(), + oldName, + dialog, + ) + } + + private fun validateProjectName( + inputLayout: TextInputLayout, newName: String, oldName: String, - dialog: AlertDialog - ) { - val positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE) - when { - newName.isEmpty() -> { - inputLayout.error = dialog.context.getString(R.string.msg_cannnot_empty) - positiveButton.isEnabled = false - } - - newName.contains('/') || - newName.contains('\\') || - newName.contains(File.separatorChar) || - newName == "." || - newName == ".." -> { - inputLayout.error = dialog.context.getString(R.string.msg_invalid_name) - positiveButton.isEnabled = false - } - - newName != oldName && nameExists(newName) -> { - inputLayout.error = - dialog.context.getString(R.string.msg_current_name_unavailable) - positiveButton.isEnabled = false - } - - else -> { - inputLayout.error = null - positiveButton.isEnabled = true - } - } - } - - data class RenamedFile( - val oldName: String, - val newName: String, - val oldPath: String, - val newPath: String - ) + dialog: AlertDialog, + ) { + val positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE) + when { + newName.isEmpty() -> { + inputLayout.error = dialog.context.getString(R.string.msg_cannnot_empty) + positiveButton.isEnabled = false + } + + newName.contains('/') || + newName.contains('\\') || + newName.contains(File.separatorChar) || + newName == "." || + newName == ".." -> { + inputLayout.error = dialog.context.getString(R.string.msg_invalid_name) + positiveButton.isEnabled = false + } + + newName != oldName && nameExists(newName) -> { + inputLayout.error = + dialog.context.getString(R.string.msg_current_name_unavailable) + positiveButton.isEnabled = false + } + + else -> { + inputLayout.error = null + positiveButton.isEnabled = true + } + } + } + + data class RenamedFile( + val oldName: String, + val newName: String, + val oldPath: String, + val newPath: String, + ) } diff --git a/app/src/main/java/com/itsaky/androidide/lsp/IDELanguageClientImpl.java b/app/src/main/java/com/itsaky/androidide/lsp/IDELanguageClientImpl.java index 3478df5e5a..64bc5de9fa 100755 --- a/app/src/main/java/com/itsaky/androidide/lsp/IDELanguageClientImpl.java +++ b/app/src/main/java/com/itsaky/androidide/lsp/IDELanguageClientImpl.java @@ -24,8 +24,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.blankj.utilcode.util.FileIOUtils; -import com.blankj.utilcode.util.FileUtils; import com.itsaky.androidide.activities.editor.EditorHandlerActivity; import com.itsaky.androidide.adapters.DiagnosticsAdapter; import com.itsaky.androidide.adapters.SearchListAdapter; @@ -46,6 +44,8 @@ import com.itsaky.androidide.models.SearchResult; import com.itsaky.androidide.tasks.TaskExecutor; import com.itsaky.androidide.ui.CodeEditorView; +import com.itsaky.androidide.utils.FileIOUtils; +import com.itsaky.androidide.utils.FileUtils; import com.itsaky.androidide.utils.FlashbarActivityUtilsKt; import com.itsaky.androidide.utils.FlashbarUtilsKt; import com.itsaky.androidide.utils.LSPUtils; diff --git a/app/src/main/java/com/itsaky/androidide/tasks/callables/ListDirectoryCallable.java b/app/src/main/java/com/itsaky/androidide/tasks/callables/ListDirectoryCallable.java index 223b9715c8..c0a5f0fb0b 100755 --- a/app/src/main/java/com/itsaky/androidide/tasks/callables/ListDirectoryCallable.java +++ b/app/src/main/java/com/itsaky/androidide/tasks/callables/ListDirectoryCallable.java @@ -21,28 +21,27 @@ package com.itsaky.androidide.tasks.callables; import android.text.TextUtils; -import com.blankj.utilcode.util.FileUtils; +import com.itsaky.androidide.utils.FileUtils; import java.io.File; import java.io.FileFilter; public class ListDirectoryCallable implements java.util.concurrent.Callable { - private final FileFilter ARCHIVE_FILTER = - new FileFilter() { + private final FileFilter ARCHIVE_FILTER = new FileFilter() { - @Override - public boolean accept(File p1) { - return p1.isFile() && (p1.getName().endsWith(".tar.xz") || p1.getName().endsWith(".zip")); - } - }; - private File file; + @Override + public boolean accept(File p1) { + return p1.isFile() && (p1.getName().endsWith(".tar.xz") || p1.getName().endsWith(".zip")); + } + }; + private File file; - public ListDirectoryCallable(File file) { - this.file = file; - } + public ListDirectoryCallable(File file) { + this.file = file; + } - @Override - public String call() throws Exception { - return TextUtils.join("\n", FileUtils.listFilesInDirWithFilter(file, ARCHIVE_FILTER, false)); - } + @Override + public String call() throws Exception { + return TextUtils.join("\n", FileUtils.listFilesInDirWithFilter(file, ARCHIVE_FILTER, false)); + } } diff --git a/app/src/main/java/com/itsaky/androidide/utils/RecursiveFileSearcher.java b/app/src/main/java/com/itsaky/androidide/utils/RecursiveFileSearcher.java index effb7bc25a..7eb162d1a1 100755 --- a/app/src/main/java/com/itsaky/androidide/utils/RecursiveFileSearcher.java +++ b/app/src/main/java/com/itsaky/androidide/utils/RecursiveFileSearcher.java @@ -19,8 +19,6 @@ **************************************************************************************/ package com.itsaky.androidide.utils; -import com.blankj.utilcode.util.FileIOUtils; -import com.blankj.utilcode.util.FileUtils; import com.itsaky.androidide.models.Position; import com.itsaky.androidide.models.Range; import com.itsaky.androidide.models.SearchResult; @@ -44,122 +42,123 @@ */ public class RecursiveFileSearcher { - /** - * Search the given text in files recursively in given search directories - * - * @param text Text to search - * @param exts Extentions of file to search. Maybe null. - * @param searchDirs Directories to search in. Subdirectories will be included - * @param callback A listener that will listen to the search result - */ - public static void searchRecursiveAsync( - String text, List exts, List searchDirs, Callback callback) { - // Cannot search empty or null text - if (text == null || text.isEmpty()) { - return; - } - - // If there is no listener to the search, search is meaningless - if (callback == null) { - return; - } - - // Avoid searching if no directories are specified - if (searchDirs == null || searchDirs.isEmpty()) { - return; - } - - TaskExecutor.executeAsync(new Searcher(text, exts, searchDirs), callback::onResult); - } - - private static class Searcher implements Callable>> { - - private final String query; - private final List exts; - private final List dirs; - - public Searcher(String query, List exts, List dirs) { - this.query = query; - this.exts = exts; - this.dirs = dirs; - } - - @Override - public Map> call() throws Exception { - final Map> result = new HashMap<>(); - for (int i = 0; i < dirs.size(); i++) { - final File dir = dirs.get(i); - final List files = - FileUtils.listFilesInDirWithFilter(dir, new MultiFileFilter(exts), true); - for (int j = 0; files != null && j < files.size(); j++) { - final File file = files.get(j); - if (file.isDirectory()) { - continue; - } - final String text = FileIOUtils.readFile2String(file); - if (text == null || text.trim().isEmpty()) { - continue; - } - final Content content = new Content(text); - final List ranges = new ArrayList<>(); - Matcher matcher = Pattern - .compile(Pattern.quote(this.query), Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE) - .matcher(text); - while (matcher.find()) { - final Range range = new Range(); - final CharPosition start = content.getIndexer().getCharPosition(matcher.start()); - final CharPosition end = content.getIndexer().getCharPosition(matcher.end()); - range.setStart(new Position(start.line, start.column)); - range.setEnd(new Position(end.line, end.column)); - String sub = - "..." - .concat( - text.substring( - Math.max(0, matcher.start() - 30), - Math.min(matcher.end() + 31, text.length()))) - .trim() - .concat("..."); - String match = - content.subContent(start.line, start.column, end.line, end.column).toString(); - ranges.add(new SearchResult(range, file, sub.replaceAll("\\s+", " "), match)); - } - if (ranges.size() > 0) { - result.put(file, ranges); - } - } - } - return result; - } - } - - private static class MultiFileFilter implements FileFilter { - - private final List exts; - - public MultiFileFilter(List exts) { - this.exts = exts; - } - - @Override - public boolean accept(File file) { - boolean accept = false; - if (exts == null || exts.isEmpty() || file.isDirectory()) { - accept = true; - } else { - for (String ext : exts) { - if (file.getName().endsWith(ext)) { - accept = true; - break; - } - } - } - - return accept && FileUtils.isUtf8(file); - } - } - - public static interface Callback { - - void onResult(Map> results); - } + /** + * Search the given text in files recursively in given search directories + * + * @param text + * Text to search + * @param exts + * Extentions of file to search. Maybe null. + * @param searchDirs + * Directories to search in. Subdirectories will be included + * @param callback + * A listener that will listen to the search result + */ + public static void searchRecursiveAsync( + String text, List exts, List searchDirs, Callback callback) { + // Cannot search empty or null text + if (text == null || text.isEmpty()) { + return; + } + + // If there is no listener to the search, search is meaningless + if (callback == null) { + return; + } + + // Avoid searching if no directories are specified + if (searchDirs == null || searchDirs.isEmpty()) { + return; + } + + TaskExecutor.executeAsync(new Searcher(text, exts, searchDirs), callback::onResult); + } + + public static interface Callback { + + void onResult(Map> results); + } + + private static class MultiFileFilter implements FileFilter { + + private final List exts; + + public MultiFileFilter(List exts) { + this.exts = exts; + } + + @Override + public boolean accept(File file) { + boolean accept = false; + if (exts == null || exts.isEmpty() || file.isDirectory()) { + accept = true; + } else { + for (String ext : exts) { + if (file.getName().endsWith(ext)) { + accept = true; + break; + } + } + } + + return accept && FileUtils.isUtf8(file); + } + } + + private static class Searcher implements Callable>> { + + private final String query; + private final List exts; + private final List dirs; + + public Searcher(String query, List exts, List dirs) { + this.query = query; + this.exts = exts; + this.dirs = dirs; + } + + @Override + public Map> call() throws Exception { + final Map> result = new HashMap<>(); + for (int i = 0; i < dirs.size(); i++) { + final File dir = dirs.get(i); + final List files = FileUtils.listFilesInDirWithFilter(dir, new MultiFileFilter(exts), true); + for (int j = 0; files != null && j < files.size(); j++) { + final File file = files.get(j); + if (file.isDirectory()) { + continue; + } + final String text = FileIOUtils.readFile2String(file); + if (text == null || text.trim().isEmpty()) { + continue; + } + final Content content = new Content(text); + final List ranges = new ArrayList<>(); + Matcher matcher = Pattern + .compile(Pattern.quote(this.query), Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE) + .matcher(text); + while (matcher.find()) { + final Range range = new Range(); + final CharPosition start = content.getIndexer().getCharPosition(matcher.start()); + final CharPosition end = content.getIndexer().getCharPosition(matcher.end()); + range.setStart(new Position(start.line, start.column)); + range.setEnd(new Position(end.line, end.column)); + String sub = "..." + .concat( + text.substring( + Math.max(0, matcher.start() - 30), + Math.min(matcher.end() + 31, text.length()))) + .trim() + .concat("..."); + String match = content.subContent(start.line, start.column, end.line, end.column).toString(); + ranges.add(new SearchResult(range, file, sub.replaceAll("\\s+", " "), match)); + } + if (ranges.size() > 0) { + result.put(file, ranges); + } + } + } + return result; + } + } } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt index bb945b1261..6d80d40d7b 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt @@ -22,13 +22,13 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.blankj.utilcode.util.FileUtils import com.google.gson.GsonBuilder import com.itsaky.androidide.models.OpenedFilesCache import com.itsaky.androidide.models.SearchResult import com.itsaky.androidide.projects.IProjectManager import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.utils.Environment +import com.itsaky.androidide.utils.FileUtils import com.itsaky.androidide.utils.ILogger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/FileManagerViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/FileManagerViewModel.kt index 7041f446bc..cfbb230a31 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/FileManagerViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/FileManagerViewModel.kt @@ -4,10 +4,10 @@ import android.content.Context import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.blankj.utilcode.util.FileUtils import com.itsaky.androidide.eventbus.events.file.FileRenameEvent import com.itsaky.androidide.projects.FileManager import com.itsaky.androidide.resources.R +import com.itsaky.androidide.utils.FileUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -17,42 +17,52 @@ import org.greenrobot.eventbus.EventBus import java.io.File sealed class FileOpResult { - data class Success(val messageRes: Int) : FileOpResult() - data class Error(val messageRes: Int) : FileOpResult() + data class Success( + val messageRes: Int, + ) : FileOpResult() + + data class Error( + val messageRes: Int, + ) : FileOpResult() } class FileManagerViewModel : ViewModel() { + // Use a SharedFlow for one-time events like showing a toast. + private val _operationResult = MutableSharedFlow() + val operationResult = _operationResult.asSharedFlow() - // Use a SharedFlow for one-time events like showing a toast. - private val _operationResult = MutableSharedFlow() - val operationResult = _operationResult.asSharedFlow() - - fun renameFile(file: File, newName: String, context: Context? = null, onResult: ((Boolean) -> Unit)? = null) { - viewModelScope.launch { - val destFile = File(file.parentFile, newName) - val renamed = withContext(Dispatchers.IO) { - if (file.name.equals(newName, ignoreCase = true)) { - val uniqueSuffix = System.currentTimeMillis() - val tempFile = File(file.parentFile, "$newName-$uniqueSuffix.cotg") - file.renameTo(tempFile) && tempFile.renameTo(destFile) - } else { - FileUtils.rename(file, newName) - } - } + fun renameFile( + file: File, + newName: String, + context: Context? = null, + onResult: ((Boolean) -> Unit)? = null, + ) { + viewModelScope.launch { + val destFile = File(file.parentFile, newName) + val renamed = + withContext(Dispatchers.IO) { + if (file.name.equals(newName, ignoreCase = true)) { + val uniqueSuffix = System.currentTimeMillis() + val tempFile = File(file.parentFile, "$newName-$uniqueSuffix.cotg") + file.renameTo(tempFile) && tempFile.renameTo(destFile) + } else { + FileUtils.rename(file, newName) + } + } - if (renamed) { - // Notify system of the rename - val renameEvent = FileRenameEvent(file, File(file.parent, newName)) - if (context != null) { - renameEvent.put(Context::class.java, context) - } - FileManager.onFileRenamed(renameEvent) - EventBus.getDefault().post(renameEvent) - _operationResult.emit(FileOpResult.Success(R.string.renamed)) - } else { - _operationResult.emit(FileOpResult.Error(R.string.rename_failed)) - } - onResult?.invoke(renamed) - } - } -} \ No newline at end of file + if (renamed) { + // Notify system of the rename + val renameEvent = FileRenameEvent(file, File(file.parent, newName)) + if (context != null) { + renameEvent.put(Context::class.java, context) + } + FileManager.onFileRenamed(renameEvent) + EventBus.getDefault().post(renameEvent) + _operationResult.emit(FileOpResult.Success(R.string.renamed)) + } else { + _operationResult.emit(FileOpResult.Error(R.string.rename_failed)) + } + onResult?.invoke(renamed) + } + } +} diff --git a/common/src/main/java/com/itsaky/androidide/managers/ToolsManager.java b/common/src/main/java/com/itsaky/androidide/managers/ToolsManager.java index c810b56f9c..52b29e2b98 100755 --- a/common/src/main/java/com/itsaky/androidide/managers/ToolsManager.java +++ b/common/src/main/java/com/itsaky/androidide/managers/ToolsManager.java @@ -26,13 +26,13 @@ import androidx.annotation.WorkerThread; import com.aayushatharva.brotli4j.Brotli4jLoader; import com.aayushatharva.brotli4j.decoder.BrotliInputStream; -import com.blankj.utilcode.util.FileIOUtils; -import com.blankj.utilcode.util.FileUtils; import com.blankj.utilcode.util.ResourceUtils; import com.itsaky.androidide.app.BaseApplication; import com.itsaky.androidide.app.configuration.IDEBuildConfigProvider; import com.itsaky.androidide.app.configuration.IJdkDistributionProvider; import com.itsaky.androidide.utils.Environment; +import com.itsaky.androidide.utils.FileIOUtils; +import com.itsaky.androidide.utils.FileUtils; import com.itsaky.androidide.utils.IoUtilsKt; import com.itsaky.androidide.utils.SelfSignedCertUtils; import java.io.File; diff --git a/common/src/main/java/com/itsaky/androidide/utils/Environment.java b/common/src/main/java/com/itsaky/androidide/utils/Environment.java index 5ef6788bbc..9b7df27341 100755 --- a/common/src/main/java/com/itsaky/androidide/utils/Environment.java +++ b/common/src/main/java/com/itsaky/androidide/utils/Environment.java @@ -22,11 +22,9 @@ import android.annotation.SuppressLint; import android.content.Context; import androidx.annotation.NonNull; -import com.blankj.utilcode.util.FileUtils; import com.itsaky.androidide.app.configuration.IDEBuildConfigProvider; import com.itsaky.androidide.buildinfo.BuildInfo; import com.itsaky.androidide.javac.config.JavacConfigProvider; - import java.io.File; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; @@ -149,8 +147,7 @@ public static void init(Context context) { HOME = mkdirIfNotExists(new File(ROOT, "home")); File ideHomeCandidate = new File(HOME, SharedEnvironment.PROJECT_CACHE_DIR_NAME); File legacyIdeHome = new File(HOME, SharedEnvironment.LEGACY_PROJECT_CACHE_DIR_NAME); - File ideHomeResolved = - LegacyIdeDataDirMigration.migrateLegacyIdeDataDirIfNeeded(legacyIdeHome, ideHomeCandidate); + File ideHomeResolved = LegacyIdeDataDirMigration.migrateLegacyIdeDataDirIfNeeded(legacyIdeHome, ideHomeCandidate); ANDROIDIDE_HOME = mkdirIfNotExists(ideHomeResolved); TMP_DIR = mkdirIfNotExists(new File(PREFIX, "tmp")); BIN_DIR = mkdirIfNotExists(new File(PREFIX, "bin")); diff --git a/common/src/main/java/com/itsaky/androidide/utils/FileUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/FileUtils.kt new file mode 100644 index 0000000000..d830850be3 --- /dev/null +++ b/common/src/main/java/com/itsaky/androidide/utils/FileUtils.kt @@ -0,0 +1,102 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import java.io.File +import java.io.FileFilter +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction + +object FileUtils { + @JvmStatic + fun isUtf8(file: File): Boolean { + if (!file.isFile) { + return false + } + + val decoder = Charsets.UTF_8.newDecoder() + decoder.onMalformedInput(CodingErrorAction.REPORT) + decoder.onUnmappableCharacter(CodingErrorAction.REPORT) + + return try { + file.inputStream().use { input -> decoder.decode(ByteBuffer.wrap(input.readBytes())) } + true + } catch (e: Exception) { + false + } + } + + @JvmStatic + fun listFilesInDirWithFilter( + dir: File, + filter: FileFilter, + recursive: Boolean, + ): List { + if (!dir.isDirectory) { + return emptyList() + } + + val files = dir.listFiles() ?: return emptyList() + val result = mutableListOf() + for (file in files) { + if (filter.accept(file)) { + result.add(file) + } + if (recursive && file.isDirectory) { + result.addAll(listFilesInDirWithFilter(file, filter, true)) + } + } + return result + } + + @JvmStatic + fun delete(file: File): Boolean = file.deleteRecursively() + + @JvmStatic + fun delete(path: String): Boolean = delete(File(path)) + + @JvmStatic + fun rename( + file: File, + newName: String, + ): Boolean = file.renameTo(File(file.parentFile, newName)) + + @JvmStatic + fun getFileExtension(file: File): String = file.extension + + @JvmStatic + fun createOrExistsDir(file: File): Boolean = file.isDirectory || file.mkdirs() +} + +object FileIOUtils { + @JvmStatic + fun readFile2String(file: File): String = file.readText(Charsets.UTF_8) + + @JvmStatic + fun writeFileFromString( + file: File, + content: String, + ): Boolean = + try { + file.parentFile?.mkdirs() + file.writeText(content) + true + } catch (e: Exception) { + false + } +} diff --git a/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt b/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt index 15a4170ebc..ccac57b655 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt @@ -30,7 +30,6 @@ import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputConnection import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting -import com.blankj.utilcode.util.FileUtils import com.itsaky.androidide.editor.R import com.itsaky.androidide.editor.R.string import com.itsaky.androidide.editor.adapters.CompletionListAdapter @@ -82,6 +81,7 @@ import com.itsaky.androidide.tasks.cancelIfActive import com.itsaky.androidide.tasks.doAsyncWithProgress import com.itsaky.androidide.utils.BasicBuildInfo import com.itsaky.androidide.utils.DocumentUtils +import com.itsaky.androidide.utils.FileUtils import com.itsaky.androidide.utils.dpToPx import com.itsaky.androidide.utils.flashError import io.github.rosemoe.sora.event.ContentChangeEvent diff --git a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/drawable/DrawableParserFactory.java b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/drawable/DrawableParserFactory.java index a33abab72b..3e341bdfae 100644 --- a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/drawable/DrawableParserFactory.java +++ b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/drawable/DrawableParserFactory.java @@ -1,190 +1,193 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ - -package com.itsaky.androidide.inflater.drawable; - -import android.content.Context; -import android.graphics.NinePatch; -import android.graphics.drawable.BitmapDrawable; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.NinePatchDrawable; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import com.blankj.utilcode.util.FileIOUtils; -import com.blankj.utilcode.util.ImageUtils; -import com.itsaky.androidide.inflater.vectormaster.VectorMasterDrawable; -import java.io.File; -import java.io.IOException; -import java.io.Reader; -import java.io.StringReader; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.xmlpull.v1.XmlPullParser; -import org.xmlpull.v1.XmlPullParserException; -import org.xmlpull.v1.XmlPullParserFactory; - -/** - * Utility methods for creating instances of suitable drawable parsers for a file or an XML code. - * - * @author Akash Yadav - */ -public abstract class DrawableParserFactory { - - private static final Logger LOG = LoggerFactory.getLogger(DrawableParserFactory.class); - - /** - * Create a new drawable parser for the given file. If the given file is not an XML Document, then - * a no-op parser is returned which simply returns the drawable for that file. If the file is an - * XML document, then a suitable parser is determined by looking at the root tag of the drawable. - * - * @param context The context. This will be used for obtaining app resources. - * @param file The file to parse. - * @return A new {@link IDrawableParser} instance if the inputs are valid, or {@code null} if - * there were any issues creating the parser. - * @throws XmlPullParserException Thrown by the {@link XmlPullParser#setInput(Reader)}. - * @throws IOException Thrown by {@link XmlPullParser#next()} - */ - @Nullable - public static IDrawableParser newParser(@NonNull final Context context, @NonNull final File file) - throws XmlPullParserException, IOException { - if (file.getName().endsWith(".xml")) { - final var code = FileIOUtils.readFile2String(file); - return DrawableParserFactory.newXmlDrawableParser(code); - } else { - final var bitmap = ImageUtils.getBitmap(file); - if (bitmap == null) { - return null; - } - - final var chunk = bitmap.getNinePatchChunk(); - if (NinePatch.isNinePatchChunk(chunk)) { - return new NoParser( - new NinePatchDrawable(context.getResources(), new NinePatch(bitmap, chunk))); - } - - return new NoParser(new BitmapDrawable(context.getResources(), bitmap)); - } - } - - /** - * Create a new drawable parser for the given xml code. - * - * @param xmlDrawable The XML code. This must be valid. - * @return A new {@link IDrawableParser} instance if the inputs are valid, or {@code null} if - * there were any issues creating the parser. - * @throws XmlPullParserException Thrown by the {@link XmlPullParser#setInput(Reader)}. - * @throws IOException Thrown by {@link XmlPullParser#next()} - */ - @Nullable - public static IDrawableParser newXmlDrawableParser(String xmlDrawable) - throws XmlPullParserException, IOException { - final var factory = XmlPullParserFactory.newInstance(); - factory.setNamespaceAware(true); - - final var parser = factory.newPullParser(); - parser.setInput(new StringReader(xmlDrawable)); - - var event = parser.getEventType(); - while (event != XmlPullParser.END_DOCUMENT) { - if (event == XmlPullParser.START_TAG) { - final var name = parser.getName(); - return parserForTag(xmlDrawable, parser, name); - } - event = parser.next(); - } - - // TODO Implement parsers for these root tags if possible - // 1. ---------- DONE - // 2. ------ DONE - // 3. ------ DONE - // 4. - // 5. - // 6. - // 7. ----------- DONE - // 8. ------------ DONE - // 9. ----------- DONE - // Cannot parse this type of drawable - return null; - } - - @Nullable - public static IDrawableParser parserForTag( - String xmlDrawable, XmlPullParser parser, @NonNull String name) - throws XmlPullParserException { - Class impl = null; - switch (name) { - case "shape": - impl = ShapeDrawableParser.class; - break; - case "inset": - impl = InsetDrawableParser.class; - break; - case "layer-list": - impl = LayerListParser.class; - break; - case "bitmap": - impl = BitmapDrawableParser.class; - break; - case "scale": - impl = ScaleDrawableParser.class; - break; - case "clip": - impl = ClipDrawableParser.class; - break; - case "selector": - impl = StateListParser.class; - break; - case "vector": - if (xmlDrawable != null) { - return new NoParser(VectorMasterDrawable.fromXML(xmlDrawable)); - } - return null; - } - - try { - if (impl != null) { - final var constructor = impl.getDeclaredConstructor(XmlPullParser.class, int.class); - return constructor.newInstance(parser, IDrawableParser.ANY_DEPTH); - } - } catch (Throwable th) { - LOG.error("No drawable parser found for tag: {}", name, th); - } - - return null; - } - - /** Instead of parsing anything, this returns the provided drawable. */ - private static class NoParser extends IDrawableParser { - - private final Drawable parsed; - - protected NoParser(final Drawable parsed) { - super(null, ANY_DEPTH); - this.parsed = parsed; - } - - @Override - public Drawable parse(final Context context) throws Exception { - return parsed; - } - - @Override - public Drawable parseDrawable(final Context context) { - return parsed; - } - } -} +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.inflater.drawable; + +import android.content.Context; +import android.graphics.NinePatch; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.NinePatchDrawable; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.blankj.utilcode.util.ImageUtils; +import com.itsaky.androidide.inflater.vectormaster.VectorMasterDrawable; +import com.itsaky.androidide.utils.FileIOUtils; +import java.io.File; +import java.io.IOException; +import java.io.Reader; +import java.io.StringReader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; +import org.xmlpull.v1.XmlPullParserFactory; + +/** + * Utility methods for creating instances of suitable drawable parsers for a file or an XML code. + * + * @author Akash Yadav + */ +public abstract class DrawableParserFactory { + + private static final Logger LOG = LoggerFactory.getLogger(DrawableParserFactory.class); + + /** + * Create a new drawable parser for the given file. If the given file is not an XML Document, then a no-op parser is returned which simply returns the drawable for that file. If the file is an XML document, then a suitable parser is determined by looking at the root tag of the drawable. + * + * @param context + * The context. This will be used for obtaining app resources. + * @param file + * The file to parse. + * @return A new {@link IDrawableParser} instance if the inputs are valid, or {@code null} if there were any issues creating the parser. + * @throws XmlPullParserException + * Thrown by the {@link XmlPullParser#setInput(Reader)}. + * @throws IOException + * Thrown by {@link XmlPullParser#next()} + */ + @Nullable + public static IDrawableParser newParser(@NonNull final Context context, @NonNull final File file) + throws XmlPullParserException, IOException { + if (file.getName().endsWith(".xml")) { + final var code = FileIOUtils.readFile2String(file); + return DrawableParserFactory.newXmlDrawableParser(code); + } else { + final var bitmap = ImageUtils.getBitmap(file); + if (bitmap == null) { + return null; + } + + final var chunk = bitmap.getNinePatchChunk(); + if (NinePatch.isNinePatchChunk(chunk)) { + return new NoParser( + new NinePatchDrawable(context.getResources(), new NinePatch(bitmap, chunk))); + } + + return new NoParser(new BitmapDrawable(context.getResources(), bitmap)); + } + } + + /** + * Create a new drawable parser for the given xml code. + * + * @param xmlDrawable + * The XML code. This must be valid. + * @return A new {@link IDrawableParser} instance if the inputs are valid, or {@code null} if there were any issues creating the parser. + * @throws XmlPullParserException + * Thrown by the {@link XmlPullParser#setInput(Reader)}. + * @throws IOException + * Thrown by {@link XmlPullParser#next()} + */ + @Nullable + public static IDrawableParser newXmlDrawableParser(String xmlDrawable) + throws XmlPullParserException, IOException { + final var factory = XmlPullParserFactory.newInstance(); + factory.setNamespaceAware(true); + + final var parser = factory.newPullParser(); + parser.setInput(new StringReader(xmlDrawable)); + + var event = parser.getEventType(); + while (event != XmlPullParser.END_DOCUMENT) { + if (event == XmlPullParser.START_TAG) { + final var name = parser.getName(); + return parserForTag(xmlDrawable, parser, name); + } + event = parser.next(); + } + + // TODO Implement parsers for these root tags if possible + // 1. ---------- DONE + // 2. ------ DONE + // 3. ------ DONE + // 4. + // 5. + // 6. + // 7. ----------- DONE + // 8. ------------ DONE + // 9. ----------- DONE + // Cannot parse this type of drawable + return null; + } + + @Nullable + public static IDrawableParser parserForTag( + String xmlDrawable, XmlPullParser parser, @NonNull String name) + throws XmlPullParserException { + Class impl = null; + switch (name) { + case "shape": + impl = ShapeDrawableParser.class; + break; + case "inset": + impl = InsetDrawableParser.class; + break; + case "layer-list": + impl = LayerListParser.class; + break; + case "bitmap": + impl = BitmapDrawableParser.class; + break; + case "scale": + impl = ScaleDrawableParser.class; + break; + case "clip": + impl = ClipDrawableParser.class; + break; + case "selector": + impl = StateListParser.class; + break; + case "vector": + if (xmlDrawable != null) { + return new NoParser(VectorMasterDrawable.fromXML(xmlDrawable)); + } + return null; + } + + try { + if (impl != null) { + final var constructor = impl.getDeclaredConstructor(XmlPullParser.class, int.class); + return constructor.newInstance(parser, IDrawableParser.ANY_DEPTH); + } + } catch (Throwable th) { + LOG.error("No drawable parser found for tag: {}", name, th); + } + + return null; + } + + /** Instead of parsing anything, this returns the provided drawable. */ + private static class NoParser extends IDrawableParser { + + private final Drawable parsed; + + protected NoParser(final Drawable parsed) { + super(null, ANY_DEPTH); + this.parsed = parsed; + } + + @Override + public Drawable parse(final Context context) throws Exception { + return parsed; + } + + @Override + public Drawable parseDrawable(final Context context) { + return parsed; + } + } +} diff --git a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/vectormaster/VectorMasterDrawable.java b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/vectormaster/VectorMasterDrawable.java index f47db563f2..e19a858962 100644 --- a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/vectormaster/VectorMasterDrawable.java +++ b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/vectormaster/VectorMasterDrawable.java @@ -26,12 +26,12 @@ import android.graphics.Rect; import android.graphics.drawable.Drawable; import androidx.annotation.NonNull; -import com.blankj.utilcode.util.FileIOUtils; import com.itsaky.androidide.inflater.vectormaster.models.ClipPathModel; import com.itsaky.androidide.inflater.vectormaster.models.GroupModel; import com.itsaky.androidide.inflater.vectormaster.models.PathModel; import com.itsaky.androidide.inflater.vectormaster.models.VectorModel; import com.itsaky.androidide.inflater.vectormaster.utilities.Utils; +import com.itsaky.androidide.utils.FileIOUtils; import java.io.File; import java.io.IOException; import java.io.StringReader; @@ -43,535 +43,539 @@ public class VectorMasterDrawable extends Drawable { - private VectorModel vectorModel; - - private int resID = -1; - private boolean useLegacyParser = true; - - private float offsetX = 0.0f, offsetY = 0.0f; - private float scaleX = 1.0f, scaleY = 1.0f; - private Matrix scaleMatrix; - - private float scaleRatio, strokeRatio; - private int width = -1, height = -1; - private int left = 0, top = 0; - private XmlPullParser xpp; - - public VectorMasterDrawable(Context context, int resID) { - this(context, resID, 0.0f, 0.0f); - } - - public VectorMasterDrawable(Context context, int resID, float offsetX, float offsetY) { - this(context, resID, offsetX, offsetY, 1.0f, 1.0f); - } - - public VectorMasterDrawable( - @NonNull Context context, - int resID, - float offsetX, - float offsetY, - float scaleX, - float scaleY) { - this.resID = resID; - this.offsetX = offsetX; - this.offsetY = offsetY; - this.scaleX = scaleX; - this.scaleY = scaleY; - - final var resources = context.getResources(); - - if (resID == -1) { - vectorModel = null; - return; - } - - this.xpp = resources.getXml(resID); - buildVectorModel(); - } - - private void buildVectorModel() { - int tempPosition; - PathModel pathModel = new PathModel(); - vectorModel = new VectorModel(); - GroupModel groupModel = new GroupModel(); - ClipPathModel clipPathModel = new ClipPathModel(); - Stack groupModelStack = new Stack<>(); - - try { - int event = xpp.getEventType(); - while (event != XmlPullParser.END_DOCUMENT) { - String name = xpp.getName(); - switch (event) { - case XmlPullParser.START_TAG: - switch (name) { - case "vector": - tempPosition = getAttrPosition(xpp, "viewportWidth"); - vectorModel.setViewportWidth( - (tempPosition != -1) - ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) - : DefaultValues.VECTOR_VIEWPORT_WIDTH); - - tempPosition = getAttrPosition(xpp, "viewportHeight"); - vectorModel.setViewportHeight( - (tempPosition != -1) - ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) - : DefaultValues.VECTOR_VIEWPORT_HEIGHT); - - tempPosition = getAttrPosition(xpp, "alpha"); - vectorModel.setAlpha( - (tempPosition != -1) - ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) - : DefaultValues.VECTOR_ALPHA); - - tempPosition = getAttrPosition(xpp, "name"); - vectorModel.setName( - (tempPosition != -1) ? xpp.getAttributeValue(tempPosition) : null); - - tempPosition = getAttrPosition(xpp, "width"); - vectorModel.setWidth( - (tempPosition != -1) - ? Utils.getFloatFromDimensionString(xpp.getAttributeValue(tempPosition)) - : DefaultValues.VECTOR_WIDTH); - - tempPosition = getAttrPosition(xpp, "height"); - vectorModel.setHeight( - (tempPosition != -1) - ? Utils.getFloatFromDimensionString(xpp.getAttributeValue(tempPosition)) - : DefaultValues.VECTOR_HEIGHT); - break; - case "path": - pathModel = new PathModel(); - - tempPosition = getAttrPosition(xpp, "name"); - pathModel.setName( - (tempPosition != -1) ? xpp.getAttributeValue(tempPosition) : null); - - tempPosition = getAttrPosition(xpp, "fillAlpha"); - pathModel.setFillAlpha( - (tempPosition != -1) - ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) - : DefaultValues.PATH_FILL_ALPHA); - - tempPosition = getAttrPosition(xpp, "fillColor"); - pathModel.setFillColor( - (tempPosition != -1) - ? Utils.getColorFromString(xpp.getAttributeValue(tempPosition)) - : DefaultValues.PATH_FILL_COLOR); - - tempPosition = getAttrPosition(xpp, "fillType"); - pathModel.setFillType( - (tempPosition != -1) - ? Utils.getFillTypeFromString(xpp.getAttributeValue(tempPosition)) - : DefaultValues.PATH_FILL_TYPE); - - tempPosition = getAttrPosition(xpp, "pathData"); - pathModel.setPathData( - (tempPosition != -1) ? xpp.getAttributeValue(tempPosition) : null); - - tempPosition = getAttrPosition(xpp, "strokeAlpha"); - pathModel.setStrokeAlpha( - (tempPosition != -1) - ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) - : DefaultValues.PATH_STROKE_ALPHA); - - tempPosition = getAttrPosition(xpp, "strokeColor"); - pathModel.setStrokeColor( - (tempPosition != -1) - ? Utils.getColorFromString(xpp.getAttributeValue(tempPosition)) - : DefaultValues.PATH_STROKE_COLOR); - - tempPosition = getAttrPosition(xpp, "strokeLineCap"); - pathModel.setStrokeLineCap( - (tempPosition != -1) - ? Utils.getLineCapFromString(xpp.getAttributeValue(tempPosition)) - : DefaultValues.PATH_STROKE_LINE_CAP); - - tempPosition = getAttrPosition(xpp, "strokeLineJoin"); - pathModel.setStrokeLineJoin( - (tempPosition != -1) - ? Utils.getLineJoinFromString(xpp.getAttributeValue(tempPosition)) - : DefaultValues.PATH_STROKE_LINE_JOIN); - - tempPosition = getAttrPosition(xpp, "strokeMiterLimit"); - pathModel.setStrokeMiterLimit( - (tempPosition != -1) - ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) - : DefaultValues.PATH_STROKE_MITER_LIMIT); - - tempPosition = getAttrPosition(xpp, "strokeWidth"); - pathModel.setStrokeWidth( - (tempPosition != -1) - ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) - : DefaultValues.PATH_STROKE_WIDTH); - - tempPosition = getAttrPosition(xpp, "trimPathEnd"); - pathModel.setTrimPathEnd( - (tempPosition != -1) - ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) - : DefaultValues.PATH_TRIM_PATH_END); - - tempPosition = getAttrPosition(xpp, "trimPathOffset"); - pathModel.setTrimPathOffset( - (tempPosition != -1) - ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) - : DefaultValues.PATH_TRIM_PATH_OFFSET); - - tempPosition = getAttrPosition(xpp, "trimPathStart"); - pathModel.setTrimPathStart( - (tempPosition != -1) - ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) - : DefaultValues.PATH_TRIM_PATH_START); - - pathModel.buildPath(useLegacyParser); - break; - case "group": - groupModel = new GroupModel(); - - tempPosition = getAttrPosition(xpp, "name"); - groupModel.setName( - (tempPosition != -1) ? xpp.getAttributeValue(tempPosition) : null); - - tempPosition = getAttrPosition(xpp, "pivotX"); - groupModel.setPivotX( - (tempPosition != -1) - ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) - : DefaultValues.GROUP_PIVOT_X); - - tempPosition = getAttrPosition(xpp, "pivotY"); - groupModel.setPivotY( - (tempPosition != -1) - ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) - : DefaultValues.GROUP_PIVOT_Y); - - tempPosition = getAttrPosition(xpp, "rotation"); - groupModel.setRotation( - (tempPosition != -1) - ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) - : DefaultValues.GROUP_ROTATION); - - tempPosition = getAttrPosition(xpp, "scaleX"); - groupModel.setScaleX( - (tempPosition != -1) - ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) - : DefaultValues.GROUP_SCALE_X); - - tempPosition = getAttrPosition(xpp, "scaleY"); - groupModel.setScaleY( - (tempPosition != -1) - ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) - : DefaultValues.GROUP_SCALE_Y); - - tempPosition = getAttrPosition(xpp, "translateX"); - groupModel.setTranslateX( - (tempPosition != -1) - ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) - : DefaultValues.GROUP_TRANSLATE_X); - - tempPosition = getAttrPosition(xpp, "translateY"); - groupModel.setTranslateY( - (tempPosition != -1) - ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) - : DefaultValues.GROUP_TRANSLATE_Y); - - groupModelStack.push(groupModel); - break; - case "clip-path": - clipPathModel = new ClipPathModel(); - - tempPosition = getAttrPosition(xpp, "name"); - clipPathModel.setName( - (tempPosition != -1) ? xpp.getAttributeValue(tempPosition) : null); - - tempPosition = getAttrPosition(xpp, "pathData"); - clipPathModel.setPathData( - (tempPosition != -1) ? xpp.getAttributeValue(tempPosition) : null); - - clipPathModel.buildPath(useLegacyParser); - break; - } - break; - - case XmlPullParser.END_TAG: - switch (name) { - case "path": - if (groupModelStack.size() == 0) { - vectorModel.addPathModel(pathModel); - } else { - groupModelStack.peek().addPathModel(pathModel); - } - vectorModel.getFullpath().addPath(pathModel.getPath()); - break; - case "clip-path": - if (groupModelStack.size() == 0) { - vectorModel.addClipPathModel(clipPathModel); - } else { - groupModelStack.peek().addClipPathModel(clipPathModel); - } - break; - case "group": - GroupModel topGroupModel = groupModelStack.pop(); - if (groupModelStack.size() == 0) { - topGroupModel.setParent(null); - vectorModel.addGroupModel(topGroupModel); - } else { - topGroupModel.setParent(groupModelStack.peek()); - groupModelStack.peek().addGroupModel(topGroupModel); - } - break; - case "vector": - vectorModel.buildTransformMatrices(); - break; - } - break; - } - event = xpp.next(); - } - } catch (XmlPullParserException | IOException e) { - e.printStackTrace(); - } - } - - private int getAttrPosition(@NonNull XmlPullParser xpp, String attrName) { - for (int i = 0; i < xpp.getAttributeCount(); i++) { - if (xpp.getAttributeName(i).equals(attrName)) { - return i; - } - } - return -1; - } - - public VectorMasterDrawable(XmlPullParser parser) { - this.xpp = parser; - buildVectorModel(); - } - - @NonNull - public static VectorMasterDrawable fromXMLFile(File file) throws XmlPullParserException { - final var source = FileIOUtils.readFile2String(file); - return VectorMasterDrawable.fromXML(source); - } - - @NonNull - @Contract("_ -> new") - public static VectorMasterDrawable fromXML(@NonNull String vectorXML) - throws XmlPullParserException { - final var factory = XmlPullParserFactory.newInstance(); - factory.setNamespaceAware(true); - - final var parser = factory.newPullParser(); - parser.setInput(new StringReader(vectorXML)); - return new VectorMasterDrawable(parser); - } - - public int getResID() { - return resID; - } - - public void setResID(int resID) { - this.resID = resID; - buildVectorModel(); - scaleMatrix = null; - } - - public boolean isUseLegacyParser() { - return useLegacyParser; - } - - public void setUseLegacyParser(boolean useLegacyParser) { - this.useLegacyParser = useLegacyParser; - buildVectorModel(); - scaleMatrix = null; - } - - @Override - public void draw(Canvas canvas) { - - if (vectorModel == null) { - return; - } - - if (scaleMatrix == null) { - int temp1 = Utils.dpToPx((int) vectorModel.getWidth()); - int temp2 = Utils.dpToPx((int) vectorModel.getHeight()); - - setBounds(0, 0, temp1, temp2); - } - - setAlpha(Utils.getAlphaFromFloat(vectorModel.getAlpha())); - - if (left != 0 || top != 0) { - int tempSaveCount = canvas.save(); - canvas.translate(left, top); - vectorModel.drawPaths(canvas, offsetX, offsetY, scaleX, scaleY); - canvas.restoreToCount(tempSaveCount); - } else { - vectorModel.drawPaths(canvas, offsetX, offsetY, scaleX, scaleY); - } - } - - @Override - public void setAlpha(int i) { - vectorModel.setAlpha(Utils.getAlphaFromInt(i)); - } - - @Override - public void setColorFilter(ColorFilter colorFilter) {} - - @Override - public int getOpacity() { - return PixelFormat.TRANSLUCENT; - } - - @Override - protected void onBoundsChange(Rect bounds) { - super.onBoundsChange(bounds); - - if (bounds.width() != 0 && bounds.height() != 0) { - - left = bounds.left; - top = bounds.top; - - width = bounds.width(); - height = bounds.height(); - - buildScaleMatrix(); - scaleAllPaths(); - scaleAllStrokes(); - } - } - - @Override - public int getIntrinsicWidth() { - return Utils.dpToPx((int) vectorModel.getWidth()); - } - - @Override - public int getIntrinsicHeight() { - return Utils.dpToPx((int) vectorModel.getHeight()); - } - - private void buildScaleMatrix() { - scaleMatrix = new Matrix(); - - scaleMatrix.postTranslate( - width / 2 - vectorModel.getViewportWidth() / 2, - height / 2 - vectorModel.getViewportHeight() / 2); - - float widthRatio = width / vectorModel.getViewportWidth(); - float heightRatio = height / vectorModel.getViewportHeight(); - float ratio = Math.min(widthRatio, heightRatio); - - scaleRatio = ratio; - - scaleMatrix.postScale(ratio, ratio, width / 2, height / 2); - } - - private void scaleAllPaths() { - vectorModel.scaleAllPaths(scaleMatrix); - } - - private void scaleAllStrokes() { - strokeRatio = Math.min(width / vectorModel.getWidth(), height / vectorModel.getHeight()); - vectorModel.scaleAllStrokeWidth(strokeRatio); - } - - public Path getFullPath() { - if (vectorModel != null) { - return vectorModel.getFullpath(); - } - return null; - } - - public GroupModel getGroupModelByName(String name) { - GroupModel gModel; - for (GroupModel groupModel : vectorModel.getGroupModels()) { - if (Utils.isEqual(groupModel.getName(), name)) { - return groupModel; - } else { - gModel = groupModel.getGroupModelByName(name); - if (gModel != null) return gModel; - } - } - return null; - } - - public PathModel getPathModelByName(String name) { - PathModel pModel = null; - for (PathModel pathModel : vectorModel.getPathModels()) { - if (Utils.isEqual(pathModel.getName(), name)) { - return pathModel; - } - } - for (GroupModel groupModel : vectorModel.getGroupModels()) { - pModel = groupModel.getPathModelByName(name); - if (pModel != null && Utils.isEqual(pModel.getName(), name)) return pModel; - } - return pModel; - } - - public ClipPathModel getClipPathModelByName(String name) { - ClipPathModel cModel = null; - for (ClipPathModel clipPathModel : vectorModel.getClipPathModels()) { - if (Utils.isEqual(clipPathModel.getName(), name)) { - return clipPathModel; - } - } - for (GroupModel groupModel : vectorModel.getGroupModels()) { - cModel = groupModel.getClipPathModelByName(name); - if (cModel != null && Utils.isEqual(cModel.getName(), name)) return cModel; - } - return cModel; - } - - public void update() { - invalidateSelf(); - } - - public float getScaleRatio() { - return scaleRatio; - } - - public float getStrokeRatio() { - return strokeRatio; - } - - public Matrix getScaleMatrix() { - return scaleMatrix; - } - - public float getOffsetX() { - return offsetX; - } - - public void setOffsetX(float offsetX) { - this.offsetX = offsetX; - invalidateSelf(); - } - - public float getOffsetY() { - return offsetY; - } - - public void setOffsetY(float offsetY) { - this.offsetY = offsetY; - invalidateSelf(); - } - - public float getScaleX() { - return scaleX; - } - - public void setScaleX(float scaleX) { - this.scaleX = scaleX; - invalidateSelf(); - } - - public float getScaleY() { - return scaleY; - } - - public void setScaleY(float scaleY) { - this.scaleY = scaleY; - invalidateSelf(); - } + @NonNull + @Contract("_ -> new") + public static VectorMasterDrawable fromXML(@NonNull String vectorXML) + throws XmlPullParserException { + final var factory = XmlPullParserFactory.newInstance(); + factory.setNamespaceAware(true); + + final var parser = factory.newPullParser(); + parser.setInput(new StringReader(vectorXML)); + return new VectorMasterDrawable(parser); + } + + @NonNull + public static VectorMasterDrawable fromXMLFile(File file) throws XmlPullParserException { + final var source = FileIOUtils.readFile2String(file); + return VectorMasterDrawable.fromXML(source); + } + + private VectorModel vectorModel; + + private int resID = -1; + private boolean useLegacyParser = true; + private float offsetX = 0.0f, offsetY = 0.0f; + + private float scaleX = 1.0f, scaleY = 1.0f; + private Matrix scaleMatrix; + private float scaleRatio, strokeRatio; + private int width = -1, height = -1; + + private int left = 0, top = 0; + + private XmlPullParser xpp; + + public VectorMasterDrawable(Context context, int resID) { + this(context, resID, 0.0f, 0.0f); + } + + public VectorMasterDrawable(Context context, int resID, float offsetX, float offsetY) { + this(context, resID, offsetX, offsetY, 1.0f, 1.0f); + } + + public VectorMasterDrawable( + @NonNull Context context, + int resID, + float offsetX, + float offsetY, + float scaleX, + float scaleY) { + this.resID = resID; + this.offsetX = offsetX; + this.offsetY = offsetY; + this.scaleX = scaleX; + this.scaleY = scaleY; + + final var resources = context.getResources(); + + if (resID == -1) { + vectorModel = null; + return; + } + + this.xpp = resources.getXml(resID); + buildVectorModel(); + } + + public VectorMasterDrawable(XmlPullParser parser) { + this.xpp = parser; + buildVectorModel(); + } + + @Override + public void draw(Canvas canvas) { + + if (vectorModel == null) { + return; + } + + if (scaleMatrix == null) { + int temp1 = Utils.dpToPx((int) vectorModel.getWidth()); + int temp2 = Utils.dpToPx((int) vectorModel.getHeight()); + + setBounds(0, 0, temp1, temp2); + } + + setAlpha(Utils.getAlphaFromFloat(vectorModel.getAlpha())); + + if (left != 0 || top != 0) { + int tempSaveCount = canvas.save(); + canvas.translate(left, top); + vectorModel.drawPaths(canvas, offsetX, offsetY, scaleX, scaleY); + canvas.restoreToCount(tempSaveCount); + } else { + vectorModel.drawPaths(canvas, offsetX, offsetY, scaleX, scaleY); + } + } + + public ClipPathModel getClipPathModelByName(String name) { + ClipPathModel cModel = null; + for (ClipPathModel clipPathModel : vectorModel.getClipPathModels()) { + if (Utils.isEqual(clipPathModel.getName(), name)) { + return clipPathModel; + } + } + for (GroupModel groupModel : vectorModel.getGroupModels()) { + cModel = groupModel.getClipPathModelByName(name); + if (cModel != null && Utils.isEqual(cModel.getName(), name)) + return cModel; + } + return cModel; + } + + public Path getFullPath() { + if (vectorModel != null) { + return vectorModel.getFullpath(); + } + return null; + } + + public GroupModel getGroupModelByName(String name) { + GroupModel gModel; + for (GroupModel groupModel : vectorModel.getGroupModels()) { + if (Utils.isEqual(groupModel.getName(), name)) { + return groupModel; + } else { + gModel = groupModel.getGroupModelByName(name); + if (gModel != null) + return gModel; + } + } + return null; + } + + @Override + public int getIntrinsicHeight() { + return Utils.dpToPx((int) vectorModel.getHeight()); + } + + @Override + public int getIntrinsicWidth() { + return Utils.dpToPx((int) vectorModel.getWidth()); + } + + public float getOffsetX() { + return offsetX; + } + + public float getOffsetY() { + return offsetY; + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } + + public PathModel getPathModelByName(String name) { + PathModel pModel = null; + for (PathModel pathModel : vectorModel.getPathModels()) { + if (Utils.isEqual(pathModel.getName(), name)) { + return pathModel; + } + } + for (GroupModel groupModel : vectorModel.getGroupModels()) { + pModel = groupModel.getPathModelByName(name); + if (pModel != null && Utils.isEqual(pModel.getName(), name)) + return pModel; + } + return pModel; + } + + public int getResID() { + return resID; + } + + public Matrix getScaleMatrix() { + return scaleMatrix; + } + + public float getScaleRatio() { + return scaleRatio; + } + + public float getScaleX() { + return scaleX; + } + + public float getScaleY() { + return scaleY; + } + + public float getStrokeRatio() { + return strokeRatio; + } + + public boolean isUseLegacyParser() { + return useLegacyParser; + } + + @Override + public void setAlpha(int i) { + vectorModel.setAlpha(Utils.getAlphaFromInt(i)); + } + + @Override + public void setColorFilter(ColorFilter colorFilter) {} + + public void setOffsetX(float offsetX) { + this.offsetX = offsetX; + invalidateSelf(); + } + + public void setOffsetY(float offsetY) { + this.offsetY = offsetY; + invalidateSelf(); + } + + public void setResID(int resID) { + this.resID = resID; + buildVectorModel(); + scaleMatrix = null; + } + + public void setScaleX(float scaleX) { + this.scaleX = scaleX; + invalidateSelf(); + } + + public void setScaleY(float scaleY) { + this.scaleY = scaleY; + invalidateSelf(); + } + + public void setUseLegacyParser(boolean useLegacyParser) { + this.useLegacyParser = useLegacyParser; + buildVectorModel(); + scaleMatrix = null; + } + + public void update() { + invalidateSelf(); + } + + @Override + protected void onBoundsChange(Rect bounds) { + super.onBoundsChange(bounds); + + if (bounds.width() != 0 && bounds.height() != 0) { + + left = bounds.left; + top = bounds.top; + + width = bounds.width(); + height = bounds.height(); + + buildScaleMatrix(); + scaleAllPaths(); + scaleAllStrokes(); + } + } + + private void buildScaleMatrix() { + scaleMatrix = new Matrix(); + + scaleMatrix.postTranslate( + width / 2 - vectorModel.getViewportWidth() / 2, + height / 2 - vectorModel.getViewportHeight() / 2); + + float widthRatio = width / vectorModel.getViewportWidth(); + float heightRatio = height / vectorModel.getViewportHeight(); + float ratio = Math.min(widthRatio, heightRatio); + + scaleRatio = ratio; + + scaleMatrix.postScale(ratio, ratio, width / 2, height / 2); + } + + private void buildVectorModel() { + int tempPosition; + PathModel pathModel = new PathModel(); + vectorModel = new VectorModel(); + GroupModel groupModel = new GroupModel(); + ClipPathModel clipPathModel = new ClipPathModel(); + Stack groupModelStack = new Stack<>(); + + try { + int event = xpp.getEventType(); + while (event != XmlPullParser.END_DOCUMENT) { + String name = xpp.getName(); + switch (event) { + case XmlPullParser.START_TAG: + switch (name) { + case "vector": + tempPosition = getAttrPosition(xpp, "viewportWidth"); + vectorModel.setViewportWidth( + (tempPosition != -1) + ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) + : DefaultValues.VECTOR_VIEWPORT_WIDTH); + + tempPosition = getAttrPosition(xpp, "viewportHeight"); + vectorModel.setViewportHeight( + (tempPosition != -1) + ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) + : DefaultValues.VECTOR_VIEWPORT_HEIGHT); + + tempPosition = getAttrPosition(xpp, "alpha"); + vectorModel.setAlpha( + (tempPosition != -1) + ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) + : DefaultValues.VECTOR_ALPHA); + + tempPosition = getAttrPosition(xpp, "name"); + vectorModel.setName( + (tempPosition != -1) ? xpp.getAttributeValue(tempPosition) : null); + + tempPosition = getAttrPosition(xpp, "width"); + vectorModel.setWidth( + (tempPosition != -1) + ? Utils.getFloatFromDimensionString(xpp.getAttributeValue(tempPosition)) + : DefaultValues.VECTOR_WIDTH); + + tempPosition = getAttrPosition(xpp, "height"); + vectorModel.setHeight( + (tempPosition != -1) + ? Utils.getFloatFromDimensionString(xpp.getAttributeValue(tempPosition)) + : DefaultValues.VECTOR_HEIGHT); + break; + case "path": + pathModel = new PathModel(); + + tempPosition = getAttrPosition(xpp, "name"); + pathModel.setName( + (tempPosition != -1) ? xpp.getAttributeValue(tempPosition) : null); + + tempPosition = getAttrPosition(xpp, "fillAlpha"); + pathModel.setFillAlpha( + (tempPosition != -1) + ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) + : DefaultValues.PATH_FILL_ALPHA); + + tempPosition = getAttrPosition(xpp, "fillColor"); + pathModel.setFillColor( + (tempPosition != -1) + ? Utils.getColorFromString(xpp.getAttributeValue(tempPosition)) + : DefaultValues.PATH_FILL_COLOR); + + tempPosition = getAttrPosition(xpp, "fillType"); + pathModel.setFillType( + (tempPosition != -1) + ? Utils.getFillTypeFromString(xpp.getAttributeValue(tempPosition)) + : DefaultValues.PATH_FILL_TYPE); + + tempPosition = getAttrPosition(xpp, "pathData"); + pathModel.setPathData( + (tempPosition != -1) ? xpp.getAttributeValue(tempPosition) : null); + + tempPosition = getAttrPosition(xpp, "strokeAlpha"); + pathModel.setStrokeAlpha( + (tempPosition != -1) + ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) + : DefaultValues.PATH_STROKE_ALPHA); + + tempPosition = getAttrPosition(xpp, "strokeColor"); + pathModel.setStrokeColor( + (tempPosition != -1) + ? Utils.getColorFromString(xpp.getAttributeValue(tempPosition)) + : DefaultValues.PATH_STROKE_COLOR); + + tempPosition = getAttrPosition(xpp, "strokeLineCap"); + pathModel.setStrokeLineCap( + (tempPosition != -1) + ? Utils.getLineCapFromString(xpp.getAttributeValue(tempPosition)) + : DefaultValues.PATH_STROKE_LINE_CAP); + + tempPosition = getAttrPosition(xpp, "strokeLineJoin"); + pathModel.setStrokeLineJoin( + (tempPosition != -1) + ? Utils.getLineJoinFromString(xpp.getAttributeValue(tempPosition)) + : DefaultValues.PATH_STROKE_LINE_JOIN); + + tempPosition = getAttrPosition(xpp, "strokeMiterLimit"); + pathModel.setStrokeMiterLimit( + (tempPosition != -1) + ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) + : DefaultValues.PATH_STROKE_MITER_LIMIT); + + tempPosition = getAttrPosition(xpp, "strokeWidth"); + pathModel.setStrokeWidth( + (tempPosition != -1) + ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) + : DefaultValues.PATH_STROKE_WIDTH); + + tempPosition = getAttrPosition(xpp, "trimPathEnd"); + pathModel.setTrimPathEnd( + (tempPosition != -1) + ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) + : DefaultValues.PATH_TRIM_PATH_END); + + tempPosition = getAttrPosition(xpp, "trimPathOffset"); + pathModel.setTrimPathOffset( + (tempPosition != -1) + ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) + : DefaultValues.PATH_TRIM_PATH_OFFSET); + + tempPosition = getAttrPosition(xpp, "trimPathStart"); + pathModel.setTrimPathStart( + (tempPosition != -1) + ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) + : DefaultValues.PATH_TRIM_PATH_START); + + pathModel.buildPath(useLegacyParser); + break; + case "group": + groupModel = new GroupModel(); + + tempPosition = getAttrPosition(xpp, "name"); + groupModel.setName( + (tempPosition != -1) ? xpp.getAttributeValue(tempPosition) : null); + + tempPosition = getAttrPosition(xpp, "pivotX"); + groupModel.setPivotX( + (tempPosition != -1) + ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) + : DefaultValues.GROUP_PIVOT_X); + + tempPosition = getAttrPosition(xpp, "pivotY"); + groupModel.setPivotY( + (tempPosition != -1) + ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) + : DefaultValues.GROUP_PIVOT_Y); + + tempPosition = getAttrPosition(xpp, "rotation"); + groupModel.setRotation( + (tempPosition != -1) + ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) + : DefaultValues.GROUP_ROTATION); + + tempPosition = getAttrPosition(xpp, "scaleX"); + groupModel.setScaleX( + (tempPosition != -1) + ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) + : DefaultValues.GROUP_SCALE_X); + + tempPosition = getAttrPosition(xpp, "scaleY"); + groupModel.setScaleY( + (tempPosition != -1) + ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) + : DefaultValues.GROUP_SCALE_Y); + + tempPosition = getAttrPosition(xpp, "translateX"); + groupModel.setTranslateX( + (tempPosition != -1) + ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) + : DefaultValues.GROUP_TRANSLATE_X); + + tempPosition = getAttrPosition(xpp, "translateY"); + groupModel.setTranslateY( + (tempPosition != -1) + ? Float.parseFloat(xpp.getAttributeValue(tempPosition)) + : DefaultValues.GROUP_TRANSLATE_Y); + + groupModelStack.push(groupModel); + break; + case "clip-path": + clipPathModel = new ClipPathModel(); + + tempPosition = getAttrPosition(xpp, "name"); + clipPathModel.setName( + (tempPosition != -1) ? xpp.getAttributeValue(tempPosition) : null); + + tempPosition = getAttrPosition(xpp, "pathData"); + clipPathModel.setPathData( + (tempPosition != -1) ? xpp.getAttributeValue(tempPosition) : null); + + clipPathModel.buildPath(useLegacyParser); + break; + } + break; + + case XmlPullParser.END_TAG: + switch (name) { + case "path": + if (groupModelStack.size() == 0) { + vectorModel.addPathModel(pathModel); + } else { + groupModelStack.peek().addPathModel(pathModel); + } + vectorModel.getFullpath().addPath(pathModel.getPath()); + break; + case "clip-path": + if (groupModelStack.size() == 0) { + vectorModel.addClipPathModel(clipPathModel); + } else { + groupModelStack.peek().addClipPathModel(clipPathModel); + } + break; + case "group": + GroupModel topGroupModel = groupModelStack.pop(); + if (groupModelStack.size() == 0) { + topGroupModel.setParent(null); + vectorModel.addGroupModel(topGroupModel); + } else { + topGroupModel.setParent(groupModelStack.peek()); + groupModelStack.peek().addGroupModel(topGroupModel); + } + break; + case "vector": + vectorModel.buildTransformMatrices(); + break; + } + break; + } + event = xpp.next(); + } + } catch (XmlPullParserException | IOException e) { + e.printStackTrace(); + } + } + + private int getAttrPosition(@NonNull XmlPullParser xpp, String attrName) { + for (int i = 0; i < xpp.getAttributeCount(); i++) { + if (xpp.getAttributeName(i).equals(attrName)) { + return i; + } + } + return -1; + } + + private void scaleAllPaths() { + vectorModel.scaleAllPaths(scaleMatrix); + } + + private void scaleAllStrokes() { + strokeRatio = Math.min(width / vectorModel.getWidth(), height / vectorModel.getHeight()); + vectorModel.scaleAllStrokeWidth(strokeRatio); + } } From 2f9e1e3d9613630cc8fe17e1c6bad64ddfd70858 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 24 Jul 2026 15:08:30 -0700 Subject: [PATCH 4/9] ADFA-4649: Replace ClipboardUtils/KeyboardUtils/NetworkUtils with native equivalents Adds copyToClipboard/isNetworkConnected/isSoftInputVisible extensions to common's ContextUtils.kt (the latter using WindowInsetsCompat's IME visibility check instead of blankj's decor-view-height heuristic). Updates the CloneRepositoryViewModelTest mock to stub the new extension via mockkStatic on the facade class instead of blankj's NetworkUtils object. Fourth of several staged commits removing com.blankj:utilcodex. Co-Authored-By: Claude Sonnet 5 --- .../actions/filetree/CopyPathAction.kt | 23 +- .../androidide/actions/text/RedoAction.kt | 162 ++-- .../androidide/activities/AboutActivity.kt | 4 +- .../activities/editor/FullscreenManager.kt | 537 ++++++------- .../itsaky/androidide/ui/EditorBottomSheet.kt | 4 +- .../viewmodel/CloneRepositoryViewModel.kt | 447 +++++------ .../viewmodel/GitBottomSheetViewModel.kt | 730 +++++++++--------- .../viewmodel/CloneRepositoryViewModelTest.kt | 380 ++++----- .../itsaky/androidide/utils/ContextUtils.kt | 35 + 9 files changed, 1211 insertions(+), 1111 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/actions/filetree/CopyPathAction.kt b/app/src/main/java/com/itsaky/androidide/actions/filetree/CopyPathAction.kt index d5bb3dd5c3..a26ddf5310 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/filetree/CopyPathAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/filetree/CopyPathAction.kt @@ -18,11 +18,11 @@ package com.itsaky.androidide.actions.filetree import android.content.Context -import com.blankj.utilcode.util.ClipboardUtils import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.actions.requireFile import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.resources.R +import com.itsaky.androidide.utils.copyToClipboard import com.itsaky.androidide.utils.flashSuccess /** @@ -30,16 +30,17 @@ import com.itsaky.androidide.utils.flashSuccess * * @author Akash Yadav */ -class CopyPathAction(context: Context, override val order: Int) : - BaseFileTreeAction(context, labelRes = R.string.copy_path, iconRes = R.drawable.ic_copy) { +class CopyPathAction( + context: Context, + override val order: Int, +) : BaseFileTreeAction(context, labelRes = R.string.copy_path, iconRes = R.drawable.ic_copy) { + override val id: String = "ide.editor.fileTree.copyPath" - override val id: String = "ide.editor.fileTree.copyPath" - override fun retrieveTooltipTag(isAlternateContext: Boolean): String = - TooltipTag.PROJECT_ITEM_COPYPATH + override fun retrieveTooltipTag(isAlternateContext: Boolean): String = TooltipTag.PROJECT_ITEM_COPYPATH - override suspend fun execAction(data: ActionData) { - val file = data.requireFile() - ClipboardUtils.copyText("[AndroidIDE] Copied File Path", file.absolutePath) - flashSuccess(R.string.copied) - } + override suspend fun execAction(data: ActionData) { + val file = data.requireFile() + data[Context::class.java]!!.copyToClipboard(file.absolutePath, label = "[AndroidIDE] Copied File Path") + flashSuccess(R.string.copied) + } } diff --git a/app/src/main/java/com/itsaky/androidide/actions/text/RedoAction.kt b/app/src/main/java/com/itsaky/androidide/actions/text/RedoAction.kt index cc988f9567..ed4fd5a1b7 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/text/RedoAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/text/RedoAction.kt @@ -1,79 +1,83 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ - -package com.itsaky.androidide.actions.text - -import android.app.Activity -import android.content.Context -import android.view.MenuItem -import androidx.core.content.ContextCompat -import com.blankj.utilcode.util.KeyboardUtils -import com.itsaky.androidide.actions.ActionData -import com.itsaky.androidide.actions.EditorRelatedAction -import com.itsaky.androidide.actions.markInvisible -import com.itsaky.androidide.idetooltips.TooltipTag -import com.itsaky.androidide.resources.R - -/** @author Akash Yadav */ -class RedoAction(context: Context, override val order: Int) : EditorRelatedAction() { - - override fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = TooltipTag.EDITOR_TOOLBAR_REDO - override val id: String = ID - - companion object { - const val ID = "ide.editor.code.text.redo" - } - - init { - label = context.getString(R.string.redo) - icon = ContextCompat.getDrawable(context, R.drawable.ic_redo) - } - - override fun prepare(data: ActionData) { - super.prepare(data) - - if (!visible) { - return - } - - val editor = data.getEditor() ?: run { - markInvisible() - return - } - - enabled = editor.canRedo() - } - - override suspend fun execAction(data: ActionData): Boolean { - val editor = data.getEditor() ?: run { - markInvisible() - return false - } - - editor.redo() - data.getActivity()?.invalidateOptionsMenu() - return true - } - - override fun getShowAsActionFlags(data: ActionData): Int { - return if (KeyboardUtils.isSoftInputVisible(data.get(Context::class.java) as Activity)) { - MenuItem.SHOW_AS_ACTION_IF_ROOM - } else { - MenuItem.SHOW_AS_ACTION_NEVER - } - } -} +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.actions.text + +import android.app.Activity +import android.content.Context +import android.view.MenuItem +import androidx.core.content.ContextCompat +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.EditorRelatedAction +import com.itsaky.androidide.actions.markInvisible +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.resources.R +import com.itsaky.androidide.utils.isSoftInputVisible + +/** @author Akash Yadav */ +class RedoAction( + context: Context, + override val order: Int, +) : EditorRelatedAction() { + override fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = TooltipTag.EDITOR_TOOLBAR_REDO + + override val id: String = ID + + companion object { + const val ID = "ide.editor.code.text.redo" + } + + init { + label = context.getString(R.string.redo) + icon = ContextCompat.getDrawable(context, R.drawable.ic_redo) + } + + override fun prepare(data: ActionData) { + super.prepare(data) + + if (!visible) { + return + } + + val editor = + data.getEditor() ?: run { + markInvisible() + return + } + + enabled = editor.canRedo() + } + + override suspend fun execAction(data: ActionData): Boolean { + val editor = + data.getEditor() ?: run { + markInvisible() + return false + } + + editor.redo() + data.getActivity()?.invalidateOptionsMenu() + return true + } + + override fun getShowAsActionFlags(data: ActionData): Int = + if ((data.get(Context::class.java) as Activity).isSoftInputVisible()) { + MenuItem.SHOW_AS_ACTION_IF_ROOM + } else { + MenuItem.SHOW_AS_ACTION_NEVER + } +} diff --git a/app/src/main/java/com/itsaky/androidide/activities/AboutActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/AboutActivity.kt index c85b3ac2b4..0ccb37c0bf 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/AboutActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/AboutActivity.kt @@ -27,7 +27,6 @@ import androidx.annotation.StringRes import androidx.core.content.ContextCompat import androidx.core.graphics.Insets import androidx.core.view.updatePaddingRelative -import com.blankj.utilcode.util.ClipboardUtils import com.itsaky.androidide.BuildConfig import com.itsaky.androidide.FeedbackButtonManager import com.itsaky.androidide.R @@ -41,6 +40,7 @@ import com.itsaky.androidide.models.SimpleIconTitleDescriptionItem import com.itsaky.androidide.utils.BasicBuildInfo import com.itsaky.androidide.utils.BuildInfoUtils import com.itsaky.androidide.utils.UrlManager +import com.itsaky.androidide.utils.copyToClipboard import com.itsaky.androidide.utils.dpToPx import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.utils.resolveAttr @@ -86,7 +86,7 @@ class AboutActivity : EdgeToEdgeIDEActivity() { ideVersion.isFocusable = true ideVersion.setBackgroundResource(R.drawable.bg_ripple) ideVersion.setOnClickListener { - ClipboardUtils.copyText(BuildInfoUtils.getBuildInfoHeader()) + this@AboutActivity.copyToClipboard(BuildInfoUtils.getBuildInfoHeader()) flashSuccess(R.string.copied) } diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/FullscreenManager.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/FullscreenManager.kt index 874e90358f..20d2fff1e0 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/FullscreenManager.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/FullscreenManager.kt @@ -5,274 +5,287 @@ import android.content.ContextWrapper import android.view.View import android.view.ViewGroup import androidx.core.view.updateLayoutParams -import com.blankj.utilcode.util.KeyboardUtils import com.google.android.material.appbar.AppBarLayout import com.google.android.material.bottomsheet.BottomSheetBehavior import com.itsaky.androidide.R import com.itsaky.androidide.databinding.ContentEditorBinding +import com.itsaky.androidide.utils.isSoftInputVisible import kotlin.math.abs class FullscreenManager( - private val contentBinding: ContentEditorBinding, - private val bottomSheetBehavior: BottomSheetBehavior, - private val closeDrawerAction: () -> Unit, - private val onFullscreenToggleRequested: () -> Unit, + private val contentBinding: ContentEditorBinding, + private val bottomSheetBehavior: BottomSheetBehavior, + private val closeDrawerAction: () -> Unit, + private val onFullscreenToggleRequested: () -> Unit, ) { - private sealed interface FullscreenUiState { - val isFullscreen: Boolean - - data object Fullscreen : FullscreenUiState { - override val isFullscreen = true - } - - data object Windowed : FullscreenUiState { - override val isFullscreen = false - } - - companion object { - fun from(isFullscreen: Boolean): FullscreenUiState { - return if (isFullscreen) Fullscreen else Windowed - } - } - } - - private sealed interface FullscreenRenderCommand { - val targetState: FullscreenUiState - val animate: Boolean - - fun apply(manager: FullscreenManager) - - data class EnterFullscreen( - override val animate: Boolean, - ) : FullscreenRenderCommand { - override val targetState = FullscreenUiState.Fullscreen - - override fun apply(manager: FullscreenManager) { - manager.applyFullscreen(animate) - } - } - - data class ExitFullscreen( - override val animate: Boolean, - ) : FullscreenRenderCommand { - override val targetState = FullscreenUiState.Windowed - - override fun apply(manager: FullscreenManager) { - manager.applyNonFullscreen(animate) - } - } - - data class Refresh( - override val targetState: FullscreenUiState, - ) : FullscreenRenderCommand { - override val animate = false - - override fun apply(manager: FullscreenManager) { - if (targetState.isFullscreen) { - manager.applyFullscreen(animate = false) - } else { - manager.applyNonFullscreen(animate = false) - } - } - } - - companion object { - fun resolve( - currentState: FullscreenUiState, - targetState: FullscreenUiState, - animate: Boolean, - ): FullscreenRenderCommand { - val shouldAnimate = animate && currentState != targetState - - if (!shouldAnimate) { - return Refresh(targetState) - } - - return if (targetState.isFullscreen) { - EnterFullscreen(animate = true) - } else { - ExitFullscreen(animate = true) - } - } - } - } - - private val topBar = contentBinding.editorAppBarLayout - private val appBarContent = contentBinding.editorAppbarContent - private val editorContainer = contentBinding.editorContainer - private val fullscreenToggle = contentBinding.btnFullscreenToggle - - private var isBound = false - private var isTransitioning = false - private var currentState: FullscreenUiState = FullscreenUiState.Windowed - private var defaultSkipCollapsed = false - private var transitionToken = 0L - private var pendingTransitionToken = 0L - - private val transitionDurationMs = 350L - - private var isFullscreenState = false - - private val offsetListener = AppBarLayout.OnOffsetChangedListener { appBarLayout, verticalOffset -> - val totalScrollRange = appBarLayout.totalScrollRange - val effectiveScrollRange = if (totalScrollRange > 0) totalScrollRange else appBarLayout.height - - if (effectiveScrollRange > 0) { - val collapseFraction = abs(verticalOffset).toFloat() / effectiveScrollRange.toFloat() - appBarContent.alpha = 1f - collapseFraction - } - - if (!isFullscreenState && verticalOffset == 0) { - val params = appBarContent.layoutParams as AppBarLayout.LayoutParams - if (params.scrollFlags != 0) { - params.scrollFlags = 0 - appBarContent.layoutParams = params - } - } - } - - private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() { - override fun onStateChanged(bottomSheetView: View, newState: Int) { - handleBottomSheetStateChange(newState) - } - - override fun onSlide(bottomSheetView: View, slideOffset: Float) = Unit - } - - fun bind() { - if (isBound) return - - defaultSkipCollapsed = bottomSheetBehavior.skipCollapsed - bottomSheetBehavior.skipCollapsed = false - setupScrollFlags() - topBar.addOnOffsetChangedListener(offsetListener) - bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback) - fullscreenToggle.setOnClickListener { onFullscreenToggleRequested() } - - isBound = true - } - - fun destroy() { - if (!isBound) return - - fullscreenToggle.setOnClickListener(null) - topBar.removeOnOffsetChangedListener(offsetListener) - bottomSheetBehavior.removeBottomSheetCallback(bottomSheetCallback) - bottomSheetBehavior.skipCollapsed = defaultSkipCollapsed - fullscreenToggle.removeCallbacks(clearTransitioningRunnable) - isTransitioning = false - - isBound = false - } - - fun render(isFullscreen: Boolean, animate: Boolean) { - val targetState = FullscreenUiState.from(isFullscreen) - val command = - FullscreenRenderCommand.resolve( - currentState = currentState, - targetState = targetState, - animate = animate, - ) - - currentState = command.targetState - syncTransitionState(command) - command.apply(this) - syncToggleUi(command.targetState) - } - - private fun setupScrollFlags() { - appBarContent.updateLayoutParams { - scrollFlags = AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL or - AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS or - AppBarLayout.LayoutParams.SCROLL_FLAG_SNAP - } - } - - private fun handleBottomSheetStateChange(newState: Int) { - val isCollapsedInWindowedMode = - newState == BottomSheetBehavior.STATE_COLLAPSED && !currentState.isFullscreen - val isSheetRevealedWhileFullscreen = - (newState == BottomSheetBehavior.STATE_EXPANDED || - newState == BottomSheetBehavior.STATE_HALF_EXPANDED) && - currentState.isFullscreen && - !isTransitioning - - if (isCollapsedInWindowedMode) { - bottomSheetBehavior.isHideable = false - } - - if (isSheetRevealedWhileFullscreen) { - onFullscreenToggleRequested() - } - } - - private fun syncTransitionState(command: FullscreenRenderCommand) { - fullscreenToggle.removeCallbacks(clearTransitioningRunnable) - - if (!command.animate) { - isTransitioning = false - transitionToken++ - return - } - - isTransitioning = true - pendingTransitionToken = ++transitionToken - fullscreenToggle.postDelayed(clearTransitioningRunnable, transitionDurationMs) - } - - private fun applyFullscreen(animate: Boolean) { - closeDrawerAction() - - setupScrollFlags() - topBar.setExpanded(false, animate) - appBarContent.alpha = 0f - - bottomSheetBehavior.isHideable = true - - val activity = contentBinding.root.context.let { context -> - generateSequence(context) { (it as? ContextWrapper)?.baseContext } - .filterIsInstance() - .firstOrNull() - } - val isKeyboardOpen = activity?.let { KeyboardUtils.isSoftInputVisible(it) } ?: false - val targetState = if (isKeyboardOpen) BottomSheetBehavior.STATE_COLLAPSED else BottomSheetBehavior.STATE_HIDDEN - - if (bottomSheetBehavior.state != targetState) { - bottomSheetBehavior.state = targetState - } - - editorContainer.updateLayoutParams { - bottomMargin = 0 - } - } - - private fun applyNonFullscreen(animate: Boolean) { - topBar.setExpanded(true, animate) - appBarContent.alpha = 1f - - if (bottomSheetBehavior.state != BottomSheetBehavior.STATE_COLLAPSED) { - bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED - } else { - bottomSheetBehavior.isHideable = false - } - } - - private fun syncToggleUi(state: FullscreenUiState) { - isFullscreenState = state.isFullscreen - if (state.isFullscreen) { - fullscreenToggle.setImageResource(R.drawable.ic_fullscreen_exit) - fullscreenToggle.contentDescription = - contentBinding.root.context.getString(R.string.desc_exit_fullscreen) - } else { - fullscreenToggle.setImageResource(R.drawable.ic_fullscreen) - fullscreenToggle.contentDescription = - contentBinding.root.context.getString(R.string.desc_enter_fullscreen) - } - } - - private val clearTransitioningRunnable = Runnable { - if (pendingTransitionToken == transitionToken) { - isTransitioning = false - } - } + private sealed interface FullscreenUiState { + val isFullscreen: Boolean + + data object Fullscreen : FullscreenUiState { + override val isFullscreen = true + } + + data object Windowed : FullscreenUiState { + override val isFullscreen = false + } + + companion object { + fun from(isFullscreen: Boolean): FullscreenUiState = if (isFullscreen) Fullscreen else Windowed + } + } + + private sealed interface FullscreenRenderCommand { + val targetState: FullscreenUiState + val animate: Boolean + + fun apply(manager: FullscreenManager) + + data class EnterFullscreen( + override val animate: Boolean, + ) : FullscreenRenderCommand { + override val targetState = FullscreenUiState.Fullscreen + + override fun apply(manager: FullscreenManager) { + manager.applyFullscreen(animate) + } + } + + data class ExitFullscreen( + override val animate: Boolean, + ) : FullscreenRenderCommand { + override val targetState = FullscreenUiState.Windowed + + override fun apply(manager: FullscreenManager) { + manager.applyNonFullscreen(animate) + } + } + + data class Refresh( + override val targetState: FullscreenUiState, + ) : FullscreenRenderCommand { + override val animate = false + + override fun apply(manager: FullscreenManager) { + if (targetState.isFullscreen) { + manager.applyFullscreen(animate = false) + } else { + manager.applyNonFullscreen(animate = false) + } + } + } + + companion object { + fun resolve( + currentState: FullscreenUiState, + targetState: FullscreenUiState, + animate: Boolean, + ): FullscreenRenderCommand { + val shouldAnimate = animate && currentState != targetState + + if (!shouldAnimate) { + return Refresh(targetState) + } + + return if (targetState.isFullscreen) { + EnterFullscreen(animate = true) + } else { + ExitFullscreen(animate = true) + } + } + } + } + + private val topBar = contentBinding.editorAppBarLayout + private val appBarContent = contentBinding.editorAppbarContent + private val editorContainer = contentBinding.editorContainer + private val fullscreenToggle = contentBinding.btnFullscreenToggle + + private var isBound = false + private var isTransitioning = false + private var currentState: FullscreenUiState = FullscreenUiState.Windowed + private var defaultSkipCollapsed = false + private var transitionToken = 0L + private var pendingTransitionToken = 0L + + private val transitionDurationMs = 350L + + private var isFullscreenState = false + + private val offsetListener = + AppBarLayout.OnOffsetChangedListener { appBarLayout, verticalOffset -> + val totalScrollRange = appBarLayout.totalScrollRange + val effectiveScrollRange = if (totalScrollRange > 0) totalScrollRange else appBarLayout.height + + if (effectiveScrollRange > 0) { + val collapseFraction = abs(verticalOffset).toFloat() / effectiveScrollRange.toFloat() + appBarContent.alpha = 1f - collapseFraction + } + + if (!isFullscreenState && verticalOffset == 0) { + val params = appBarContent.layoutParams as AppBarLayout.LayoutParams + if (params.scrollFlags != 0) { + params.scrollFlags = 0 + appBarContent.layoutParams = params + } + } + } + + private val bottomSheetCallback = + object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged( + bottomSheetView: View, + newState: Int, + ) { + handleBottomSheetStateChange(newState) + } + + override fun onSlide( + bottomSheetView: View, + slideOffset: Float, + ) = Unit + } + + fun bind() { + if (isBound) return + + defaultSkipCollapsed = bottomSheetBehavior.skipCollapsed + bottomSheetBehavior.skipCollapsed = false + setupScrollFlags() + topBar.addOnOffsetChangedListener(offsetListener) + bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback) + fullscreenToggle.setOnClickListener { onFullscreenToggleRequested() } + + isBound = true + } + + fun destroy() { + if (!isBound) return + + fullscreenToggle.setOnClickListener(null) + topBar.removeOnOffsetChangedListener(offsetListener) + bottomSheetBehavior.removeBottomSheetCallback(bottomSheetCallback) + bottomSheetBehavior.skipCollapsed = defaultSkipCollapsed + fullscreenToggle.removeCallbacks(clearTransitioningRunnable) + isTransitioning = false + + isBound = false + } + + fun render( + isFullscreen: Boolean, + animate: Boolean, + ) { + val targetState = FullscreenUiState.from(isFullscreen) + val command = + FullscreenRenderCommand.resolve( + currentState = currentState, + targetState = targetState, + animate = animate, + ) + + currentState = command.targetState + syncTransitionState(command) + command.apply(this) + syncToggleUi(command.targetState) + } + + private fun setupScrollFlags() { + appBarContent.updateLayoutParams { + scrollFlags = AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL or + AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS or + AppBarLayout.LayoutParams.SCROLL_FLAG_SNAP + } + } + + private fun handleBottomSheetStateChange(newState: Int) { + val isCollapsedInWindowedMode = + newState == BottomSheetBehavior.STATE_COLLAPSED && !currentState.isFullscreen + val isSheetRevealedWhileFullscreen = + ( + newState == BottomSheetBehavior.STATE_EXPANDED || + newState == BottomSheetBehavior.STATE_HALF_EXPANDED + ) && + currentState.isFullscreen && + !isTransitioning + + if (isCollapsedInWindowedMode) { + bottomSheetBehavior.isHideable = false + } + + if (isSheetRevealedWhileFullscreen) { + onFullscreenToggleRequested() + } + } + + private fun syncTransitionState(command: FullscreenRenderCommand) { + fullscreenToggle.removeCallbacks(clearTransitioningRunnable) + + if (!command.animate) { + isTransitioning = false + transitionToken++ + return + } + + isTransitioning = true + pendingTransitionToken = ++transitionToken + fullscreenToggle.postDelayed(clearTransitioningRunnable, transitionDurationMs) + } + + private fun applyFullscreen(animate: Boolean) { + closeDrawerAction() + + setupScrollFlags() + topBar.setExpanded(false, animate) + appBarContent.alpha = 0f + + bottomSheetBehavior.isHideable = true + + val activity = + contentBinding.root.context.let { context -> + generateSequence(context) { (it as? ContextWrapper)?.baseContext } + .filterIsInstance() + .firstOrNull() + } + val isKeyboardOpen = activity?.let { it.isSoftInputVisible() } ?: false + val targetState = if (isKeyboardOpen) BottomSheetBehavior.STATE_COLLAPSED else BottomSheetBehavior.STATE_HIDDEN + + if (bottomSheetBehavior.state != targetState) { + bottomSheetBehavior.state = targetState + } + + editorContainer.updateLayoutParams { + bottomMargin = 0 + } + } + + private fun applyNonFullscreen(animate: Boolean) { + topBar.setExpanded(true, animate) + appBarContent.alpha = 1f + + if (bottomSheetBehavior.state != BottomSheetBehavior.STATE_COLLAPSED) { + bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + } else { + bottomSheetBehavior.isHideable = false + } + } + + private fun syncToggleUi(state: FullscreenUiState) { + isFullscreenState = state.isFullscreen + if (state.isFullscreen) { + fullscreenToggle.setImageResource(R.drawable.ic_fullscreen_exit) + fullscreenToggle.contentDescription = + contentBinding.root.context.getString(R.string.desc_exit_fullscreen) + } else { + fullscreenToggle.setImageResource(R.drawable.ic_fullscreen) + fullscreenToggle.contentDescription = + contentBinding.root.context.getString(R.string.desc_enter_fullscreen) + } + } + + private val clearTransitioningRunnable = + Runnable { + if (pendingTransitionToken == transitionToken) { + isTransitioning = false + } + } } diff --git a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt index 86d15aadba..46bf3dd006 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt @@ -42,7 +42,6 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.transition.TransitionManager -import com.blankj.utilcode.util.KeyboardUtils import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.tabs.TabLayout.OnTabSelectedListener import com.google.android.material.tabs.TabLayout.Tab @@ -67,6 +66,7 @@ import com.itsaky.androidide.utils.Symbols.forFile import com.itsaky.androidide.utils.dpToPx import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess +import com.itsaky.androidide.utils.isSoftInputVisible import com.itsaky.androidide.viewmodel.ApkInstallationViewModel import com.itsaky.androidide.viewmodel.BottomSheetViewModel import com.itsaky.androidide.viewmodel.BuildOutputViewModel @@ -539,7 +539,7 @@ class EditorBottomSheet ) val activity = context as Activity - if (KeyboardUtils.isSoftInputVisible(activity)) { + if (activity.isSoftInputVisible()) { binding.headerContainer.displayedChild = CHILD_SYMBOL_INPUT } else { binding.headerContainer.displayedChild = CHILD_HEADER diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/CloneRepositoryViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/CloneRepositoryViewModel.kt index 7883ee4d62..f141e262aa 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/CloneRepositoryViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/CloneRepositoryViewModel.kt @@ -4,9 +4,12 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.application import androidx.lifecycle.viewModelScope +import com.itsaky.androidide.git.core.GitCredentialsManager import com.itsaky.androidide.git.core.GitRepositoryManager -import com.itsaky.androidide.git.core.parseGitRepositoryUrl import com.itsaky.androidide.git.core.models.CloneRepoUiState +import com.itsaky.androidide.git.core.parseGitRepositoryUrl +import com.itsaky.androidide.resources.R +import com.itsaky.androidide.utils.isNetworkConnected import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -14,252 +17,260 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.eclipse.jgit.api.errors.TransportException import org.eclipse.jgit.lib.ProgressMonitor import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider -import org.eclipse.jgit.api.errors.TransportException -import java.net.UnknownHostException import java.io.EOFException import java.io.File -import com.blankj.utilcode.util.NetworkUtils -import com.itsaky.androidide.git.core.GitCredentialsManager -import com.itsaky.androidide.resources.R +import java.net.UnknownHostException class CloneRepositoryViewModel( - application: Application, - private val credentialsManager: GitCredentialsManager + application: Application, + private val credentialsManager: GitCredentialsManager, ) : AndroidViewModel(application) { + private val _uiState = MutableStateFlow(CloneRepoUiState.Idle()) + val uiState: StateFlow = _uiState.asStateFlow() + + @Volatile + private var isCloneCancelled = false + + fun onInputChanged( + url: String, + path: String, + ) { + val normalizedUrl = parseGitRepositoryUrl(url) + val currentState = _uiState.value + if (currentState is CloneRepoUiState.Idle) { + _uiState.update { + currentState.copy( + url = url, + localPath = path, + isCloneButtonEnabled = normalizedUrl != null && path.isNotBlank(), + ) + } + } else if (currentState is CloneRepoUiState.Error) { + _uiState.update { + CloneRepoUiState.Idle( + url = url, + localPath = path, + isCloneButtonEnabled = normalizedUrl != null && path.isNotBlank(), + ) + } + } + } + + fun resetState() { + _uiState.value = CloneRepoUiState.Idle() + } - private val _uiState = MutableStateFlow(CloneRepoUiState.Idle()) - val uiState: StateFlow = _uiState.asStateFlow() + fun cloneRepository( + url: String, + localPath: String, + username: String? = null, + token: String? = null, + ) { + val normalizedUrl = parseGitRepositoryUrl(url) + if (normalizedUrl == null) { + _uiState.update { + CloneRepoUiState.Error( + url = url, + localPath = localPath, + errorResId = R.string.msg_invalid_url, + ) + } + return + } - @Volatile - private var isCloneCancelled = false + isCloneCancelled = false + val destDir = File(localPath) + val isExistingDir = destDir.exists() + if (isExistingDir && destDir.listFiles()?.isNotEmpty() == true) { + _uiState.update { + CloneRepoUiState.Error( + url = normalizedUrl, + localPath = localPath, + errorResId = R.string.destination_directory_not_empty, + ) + } + return + } - fun onInputChanged(url: String, path: String) { - val normalizedUrl = parseGitRepositoryUrl(url) - val currentState = _uiState.value - if (currentState is CloneRepoUiState.Idle) { - _uiState.update { - currentState.copy( - url = url, - localPath = path, - isCloneButtonEnabled = normalizedUrl != null && path.isNotBlank() - ) - } - } else if (currentState is CloneRepoUiState.Error) { - _uiState.update { - CloneRepoUiState.Idle( - url = url, - localPath = path, - isCloneButtonEnabled = normalizedUrl != null && path.isNotBlank() - ) - } - } - } + if (!application.isNetworkConnected()) { + _uiState.update { + CloneRepoUiState.Error( + url = normalizedUrl, + localPath = localPath, + errorResId = R.string.no_internet_connection, + canRetry = true, + ) + } + return + } - fun resetState() { - _uiState.value = CloneRepoUiState.Idle() - } + viewModelScope.launch { + var hasCloned = false + _uiState.update { + CloneRepoUiState.Cloning( + url = normalizedUrl, + localPath = localPath, + statusTextResId = R.string.initialising_clone, + ) + } + try { + val credentials = + if (!username.isNullOrBlank() && !token.isNullOrBlank()) { + UsernamePasswordCredentialsProvider(username, token) + } else { + null + } - fun cloneRepository( - url: String, - localPath: String, - username: String? = null, - token: String? = null - ) { - val normalizedUrl = parseGitRepositoryUrl(url) - if (normalizedUrl == null) { - _uiState.update { - CloneRepoUiState.Error( - url = url, - localPath = localPath, - errorResId = R.string.msg_invalid_url - ) - } - return - } + val progressMonitor = + object : ProgressMonitor { + private var totalWork = 0 + private var completedWork = 0 + private var currentTaskTitle = "" - isCloneCancelled = false - val destDir = File(localPath) - val isExistingDir = destDir.exists() - if (isExistingDir && destDir.listFiles()?.isNotEmpty() == true) { - _uiState.update { - CloneRepoUiState.Error( - url = normalizedUrl, - localPath = localPath, - errorResId = R.string.destination_directory_not_empty - ) - } - return - } + override fun start(totalTasks: Int) {} - if (!NetworkUtils.isConnected()) { - _uiState.update { - CloneRepoUiState.Error( - url = normalizedUrl, - localPath = localPath, - errorResId = R.string.no_internet_connection, - canRetry = true - ) - } - return - } + override fun beginTask( + title: String, + totalWork: Int, + ) { + this.currentTaskTitle = title + this.totalWork = totalWork + this.completedWork = 0 + updateProgressUI() + } - viewModelScope.launch { - var hasCloned = false - _uiState.update { - CloneRepoUiState.Cloning( - url = normalizedUrl, - localPath = localPath, - statusTextResId = R.string.initialising_clone - ) - } - try { - val credentials = if (!username.isNullOrBlank() && !token.isNullOrBlank()) { - UsernamePasswordCredentialsProvider(username, token) - } else { - null - } + override fun update(completed: Int) { + this.completedWork += completed + updateProgressUI() + } - val progressMonitor = object : ProgressMonitor { - private var totalWork = 0 - private var completedWork = 0 - private var currentTaskTitle = "" + override fun endTask() {} - override fun start(totalTasks: Int) {} + override fun isCancelled(): Boolean = isCloneCancelled - override fun beginTask(title: String, totalWork: Int) { - this.currentTaskTitle = title - this.totalWork = totalWork - this.completedWork = 0 - updateProgressUI() - } + override fun showDuration(enabled: Boolean) {} - override fun update(completed: Int) { - this.completedWork += completed - updateProgressUI() - } + private fun updateProgressUI() { + val percentage = + if (totalWork > 0) { + ((completedWork.toFloat() / totalWork.toFloat()) * 100).toInt() + } else { + 0 + } - override fun endTask() {} + val progressMsg = + if (totalWork > 0) { + "$currentTaskTitle: $percentage% -- ($completedWork/$totalWork)" + } else { + currentTaskTitle + } - override fun isCancelled(): Boolean { - return isCloneCancelled - } + _uiState.update { currentState -> + if (currentState is CloneRepoUiState.Cloning) { + currentState.copy( + cloneProgress = progressMsg, + clonePercentage = percentage, + isCancellable = true, + statusTextResId = if (isCloneCancelled) R.string.cancelling_clone else R.string.cloning_repo, + ) + } else { + currentState + } + } + } + } - override fun showDuration(enabled: Boolean) {} + GitRepositoryManager.cloneRepository(normalizedUrl, destDir, credentials, progressMonitor) - private fun updateProgressUI() { - val percentage = if (totalWork > 0) { - ((completedWork.toFloat() / totalWork.toFloat()) * 100).toInt() - } else { - 0 - } + if (!destDir.exists()) { + throw Exception("Destination directory was not created.") + } - val progressMsg = if (totalWork > 0) { - "$currentTaskTitle: $percentage% -- ($completedWork/$totalWork)" - } else { - currentTaskTitle - } + hasCloned = true + _uiState.update { + CloneRepoUiState.Success(localPath = localPath) + } + credentialsManager.saveCredentialsIfNeeded(username, token) + } catch (e: Exception) { + // Error handling + if (isCloneCancelled) { + _uiState.update { + CloneRepoUiState.Idle( + url = normalizedUrl, + localPath = localPath, + isCloneButtonEnabled = true, + ) + } + return@launch + } - _uiState.update { currentState -> - if (currentState is CloneRepoUiState.Cloning) { - currentState.copy( - cloneProgress = progressMsg, - clonePercentage = percentage, - isCancellable = true, - statusTextResId = if (isCloneCancelled) R.string.cancelling_clone else R.string.cloning_repo - ) - } else { - currentState - } - } - } - } + val isNetworkError = e is TransportException && e.cause is UnknownHostException + val isConnectionDrop = + e.cause is EOFException || + e.message?.contains("Unexpected end of stream") == true || + e.message?.contains("Software caused connection abort") == true - GitRepositoryManager.cloneRepository(normalizedUrl, destDir, credentials, progressMonitor) - - if (!destDir.exists()) { - throw Exception("Destination directory was not created.") - } - - hasCloned = true - _uiState.update { - CloneRepoUiState.Success(localPath = localPath) - } - credentialsManager.saveCredentialsIfNeeded(username, token) - } catch (e: Exception) { - // Error handling - if (isCloneCancelled) { - _uiState.update { - CloneRepoUiState.Idle( - url = normalizedUrl, - localPath = localPath, - isCloneButtonEnabled = true - ) - } - return@launch - } + val errorResId = + when { + isNetworkError -> R.string.no_internet_connection + isConnectionDrop -> R.string.connection_lost + else -> null + } - val isNetworkError = e is TransportException && e.cause is UnknownHostException - val isConnectionDrop = e.cause is EOFException || - e.message?.contains("Unexpected end of stream") == true || - e.message?.contains("Software caused connection abort") == true - - val errorResId = when { - isNetworkError -> R.string.no_internet_connection - isConnectionDrop -> R.string.connection_lost - else -> null - } - - val errorMessage = if (errorResId == null) { - e.message ?: application.getString(R.string.unknown_error) - } else null - - _uiState.update { - CloneRepoUiState.Error( - url = normalizedUrl, - localPath = localPath, - errorResId = errorResId, - errorMessage = errorMessage?.let { application.getString(R.string.clone_failed, it) }, - canRetry = true - ) - } - } finally { - // Clean up partial clone directories - if (!hasCloned) { - withContext(Dispatchers.IO) { - if (!isExistingDir) { - destDir.deleteRecursively() - } else { - destDir.listFiles()?.forEach { it.deleteRecursively() } - } - } + val errorMessage = + if (errorResId == null) { + e.message ?: application.getString(R.string.unknown_error) + } else { + null + } - val currentState = _uiState.value - if (currentState is CloneRepoUiState.Cloning) { - _uiState.update { - CloneRepoUiState.Idle( - url = normalizedUrl, - localPath = localPath, - isCloneButtonEnabled = true - ) - } - } - } - } - } - } + _uiState.update { + CloneRepoUiState.Error( + url = normalizedUrl, + localPath = localPath, + errorResId = errorResId, + errorMessage = errorMessage?.let { application.getString(R.string.clone_failed, it) }, + canRetry = true, + ) + } + } finally { + // Clean up partial clone directories + if (!hasCloned) { + withContext(Dispatchers.IO) { + if (!isExistingDir) { + destDir.deleteRecursively() + } else { + destDir.listFiles()?.forEach { it.deleteRecursively() } + } + } - fun cancelClone() { - isCloneCancelled = true + val currentState = _uiState.value + if (currentState is CloneRepoUiState.Cloning) { + _uiState.update { + CloneRepoUiState.Idle( + url = normalizedUrl, + localPath = localPath, + isCloneButtonEnabled = true, + ) + } + } + } + } + } + } - _uiState.update { currentState -> - if (currentState is CloneRepoUiState.Cloning) { - currentState.copy(statusTextResId = R.string.cancelling_clone) - } else { - currentState - } - } - } + fun cancelClone() { + isCloneCancelled = true + _uiState.update { currentState -> + if (currentState is CloneRepoUiState.Cloning) { + currentState.copy(statusTextResId = R.string.cancelling_clone) + } else { + currentState + } + } + } } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt index 0c1612ccdb..1722511022 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt @@ -2,7 +2,7 @@ package com.itsaky.androidide.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.blankj.utilcode.util.NetworkUtils +import com.itsaky.androidide.app.BaseApplication import com.itsaky.androidide.eventbus.events.editor.DocumentSaveEvent import com.itsaky.androidide.eventbus.events.file.FileCreationEvent import com.itsaky.androidide.eventbus.events.file.FileDeletionEvent @@ -16,6 +16,7 @@ import com.itsaky.androidide.git.core.models.GitStatus import com.itsaky.androidide.preferences.internal.GitPreferences import com.itsaky.androidide.projects.IProjectManager import com.itsaky.androidide.resources.R +import com.itsaky.androidide.utils.isNetworkConnected import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -33,352 +34,383 @@ import org.greenrobot.eventbus.ThreadMode import org.slf4j.LoggerFactory import java.io.File -class GitBottomSheetViewModel(private val credentialsManager: GitCredentialsManager) : ViewModel() { - - private val log = LoggerFactory.getLogger(GitBottomSheetViewModel::class.java) - - private val _gitStatus = MutableStateFlow(GitStatus.EMPTY) - val gitStatus: StateFlow = _gitStatus.asStateFlow() - - private val _currentBranch = MutableStateFlow(null) - val currentBranch: StateFlow = _currentBranch.asStateFlow() - - private val _commitHistory = - MutableStateFlow(CommitHistoryUiState.Loading) - val commitHistory: StateFlow = _commitHistory.asStateFlow() - - private val _isGitRepository = MutableStateFlow(false) - val isGitRepository: StateFlow = _isGitRepository.asStateFlow() - - private val _localCommitsCount = MutableStateFlow(0) - val localCommitsCount: StateFlow = _localCommitsCount.asStateFlow() - - private val _pullState = MutableStateFlow(PullUiState.Idle) - val pullState: StateFlow = _pullState.asStateFlow() - - private val _pushState = MutableStateFlow(PushUiState.Idle) - val pushState: StateFlow = _pushState.asStateFlow() - - private var pullResetJob: Job? = null - private var pushResetJob: Job? = null - - var currentRepository: GitRepository? = null - private set - - init { - EventBus.getDefault().register(this) - initializeRepository() - } - - override fun onCleared() { - super.onCleared() - EventBus.getDefault().unregister(this) - currentRepository?.close() - } - - private fun initializeRepository() { - viewModelScope.launch { - try { - val projectDir = File(IProjectManager.getInstance().projectDirPath) - currentRepository = GitRepositoryManager.openRepository(projectDir) - _isGitRepository.value = currentRepository != null - refreshStatus() - } catch (e: Exception) { - log.error("Failed to initialize repository", e) - _isGitRepository.value = false - _gitStatus.value = GitStatus.EMPTY - } - } - } - - /** - * Refreshes the Git status of the project. - */ - fun refreshStatus() { - viewModelScope.launch { - try { - currentRepository?.let { repo -> - val status = repo.getStatus() - _gitStatus.value = status - _currentBranch.value = repo.getCurrentBranch()?.name - getLocalCommitsCount() - } ?: run { - _gitStatus.value = GitStatus.EMPTY - _currentBranch.value = null - _localCommitsCount.value = 0 - } - } catch (e: Exception) { - log.error("Failed to refresh git status", e) - _gitStatus.value = GitStatus.EMPTY - _currentBranch.value = null - _localCommitsCount.value = 0 - } - } - } - - suspend fun getLocalCommitsCount() { - _localCommitsCount.value = currentRepository?.getLocalCommitsCount() ?: 0 - } - - fun commitChanges( - summary: String, - description: String? = null, - selectedPaths: List, - onSuccess: () -> Unit - ) { - viewModelScope.launch { - try { - if (selectedPaths.isEmpty()) return@launch - - val repository = currentRepository ?: return@launch - - val projectDir = File(IProjectManager.getInstance().projectDirPath) - val filesToStage = selectedPaths.map { File(projectDir, it) } - - repository.stageFiles(filesToStage) - - val message = - if (!description.isNullOrBlank()) "$summary\n\n$description" else summary - repository.commit( - message = message, - authorName = GitPreferences.userName, - authorEmail = GitPreferences.userEmail - ) - - refreshStatus() - onSuccess() - } catch (e: Exception) { - log.error("Failed to commit changes", e) - } - - } - } - - fun getCommitHistoryList() { - viewModelScope.launch { - _commitHistory.value = CommitHistoryUiState.Loading - try { - val history = currentRepository?.getHistory() - if (history.isNullOrEmpty()) { - _commitHistory.value = CommitHistoryUiState.Empty - } else { - _commitHistory.value = CommitHistoryUiState.Success(history) - } - getLocalCommitsCount() - } catch (e: Exception) { - log.error("Failed to fetch commit history", e) - _commitHistory.value = CommitHistoryUiState.Error(e.message) - } - } - } - - fun push(username: String?, token: String?) { - pushResetJob?.cancel() - - viewModelScope.launch { - try { - if (!NetworkUtils.isConnected()){ - _pushState.value = PushUiState.Error(errorResId = R.string.no_internet_connection) - return@launch - } - - _pushState.value = PushUiState.Pushing - val repository = currentRepository ?: return@launch - val credentials = buildCredentials(username, token) - val results = repository.push(credentialsProvider = credentials) - val error = results.flatMap { it.remoteUpdates } - .firstOrNull { - it.status != RemoteRefUpdate.Status.OK && - it.status != RemoteRefUpdate.Status.UP_TO_DATE - } - - if (error != null) { - handlePushError(error) - return@launch - } - - handlePushSuccess(username, token) - } catch (e: Exception) { - if (e.message?.contains("not authorized", ignoreCase = true) == true) { - credentialsManager.clearCredentials() - _pushState.value = PushUiState.Error(errorResId = R.string.repo_authorization_error) - return@launch - } - _pushState.value = PushUiState.Error(e.message) - } finally { - pushResetJob = viewModelScope.launch { - delay(3000) - _pushState.value = PushUiState.Idle - } - } - } - } - - private fun buildCredentials(username: String?, token: String?) = - if (!username.isNullOrBlank() && !token.isNullOrBlank()) { - UsernamePasswordCredentialsProvider(username, token) - } else null - - private fun handlePushError(update: RemoteRefUpdate) { - val resId = if (update.status == RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD) { - R.string.push_rejected_nonfastforward - } else { - R.string.unknown_error - } - _pushState.value = PushUiState.Error( - message = update.message ?: update.status.name, - errorResId = resId - ) - } - - private suspend fun handlePushSuccess( - username: String?, - token: String?, - ) { - _pushState.value = PushUiState.Success - credentialsManager.saveCredentialsIfNeeded(username, token) - refreshStatus() - getLocalCommitsCount() - getCommitHistoryList() - } - - fun pull(username: String?, token: String?) { - pullResetJob?.cancel() - - viewModelScope.launch { - try { - if (!NetworkUtils.isConnected()){ - _pullState.value = PullUiState.Error(errorResId = R.string.no_internet_connection) - return@launch - } - - _pullState.value = PullUiState.Pulling - val repository = currentRepository ?: return@launch - val credentials = buildCredentials(username, token) - val result = repository.pull(credentialsProvider = credentials) - - if (!result.isSuccessful) { - handlePullError(result) - return@launch - } - - handlePullSuccess(username, token) - } catch (e: CheckoutConflictException) { - log.error("Pull failed with checkout conflict", e) - val paths = e.conflictingPaths?.joinToString("\n") ?: "" - _pullState.value = PullUiState.Error(errorResId = R.string.checkout_conflict_message, errorArgs = listOf(paths)) - } catch (e: Exception) { - log.error("Pull failed", e) - if (e.message?.contains("not authorized", ignoreCase = true) == true) { - credentialsManager.clearCredentials() - _pullState.value = PullUiState.Error(errorResId = R.string.repo_authorization_error) - return@launch - } - _pullState.value = PullUiState.Error(e.message) - } finally { - pullResetJob = viewModelScope.launch { - delay(3000) - _pullState.value = PullUiState.Idle - } - } - } - } - - private fun handlePullError(result: PullResult) { - val mergeStatus = result.mergeResult?.mergeStatus - val statusName = mergeStatus?.name ?: "Unknown error" - - if (mergeStatus == MergeStatus.CONFLICTING) { - _pullState.value = PullUiState.Conflicts() - refreshStatus() - } else { - _pullState.value = PullUiState.Error("Pull failed: $statusName") - } - } - - private fun handlePullSuccess( - username: String?, - token: String?, - ) { - _pullState.value = PullUiState.Success - credentialsManager.saveCredentialsIfNeeded(username, token) - refreshStatus() - getCommitHistoryList() - } - - fun resetPullState() { - pullResetJob?.cancel() - _pullState.value = PullUiState.Idle - } - - fun resetPushState() { - pushResetJob?.cancel() - _pushState.value = PushUiState.Idle - } - - sealed class PullUiState { - object Idle : PullUiState() - object Pulling : PullUiState() - object Success : PullUiState() - data class Conflicts(val message: String? = null) : PullUiState() - data class Error(val message: String? = null, val errorResId: Int? = R.string.unknown_error, val errorArgs: List? = null) : PullUiState() - } - - sealed class PushUiState { - object Idle : PushUiState() - object Pushing : PushUiState() - object Success : PushUiState() - data class Error(val message: String? = null, val errorResId: Int? = R.string.unknown_error) : PushUiState() - } - - @Subscribe(threadMode = ThreadMode.MAIN) - fun onDocumentSaved(event: DocumentSaveEvent) { - refreshStatus() - } - - @Subscribe(threadMode = ThreadMode.MAIN) - fun onProjectFilesChanged(event: ListProjectFilesRequestEvent) { - refreshStatus() - } - - @Subscribe(threadMode = ThreadMode.MAIN) - fun onFileCreated(event: FileCreationEvent) { - refreshStatus() - } - - @Subscribe(threadMode = ThreadMode.MAIN) - fun onFileDeleted(event: FileDeletionEvent) { - refreshStatus() - } - - @Subscribe(threadMode = ThreadMode.MAIN) - fun onFileRenamed(event: FileRenameEvent) { - refreshStatus() - } - - fun abortMerge(onSuccess: (() -> Unit)? = null) { - viewModelScope.launch { - try { - currentRepository?.abortMerge() - refreshStatus() - onSuccess?.invoke() - } catch (e: Exception) { - log.error("Failed to abort merge", e) - } - } - } - - fun resolveConflict(path: String) { - viewModelScope.launch { - try { - val repository = currentRepository ?: return@launch - val projectDir = File(IProjectManager.getInstance().projectDirPath) - repository.stageFiles(listOf(File(projectDir, path))) - refreshStatus() - } catch (e: Exception) { - log.error("Failed to resolve conflict for $path", e) - } - } - } - +class GitBottomSheetViewModel( + private val credentialsManager: GitCredentialsManager, +) : ViewModel() { + private val log = LoggerFactory.getLogger(GitBottomSheetViewModel::class.java) + + private val _gitStatus = MutableStateFlow(GitStatus.EMPTY) + val gitStatus: StateFlow = _gitStatus.asStateFlow() + + private val _currentBranch = MutableStateFlow(null) + val currentBranch: StateFlow = _currentBranch.asStateFlow() + + private val _commitHistory = + MutableStateFlow(CommitHistoryUiState.Loading) + val commitHistory: StateFlow = _commitHistory.asStateFlow() + + private val _isGitRepository = MutableStateFlow(false) + val isGitRepository: StateFlow = _isGitRepository.asStateFlow() + + private val _localCommitsCount = MutableStateFlow(0) + val localCommitsCount: StateFlow = _localCommitsCount.asStateFlow() + + private val _pullState = MutableStateFlow(PullUiState.Idle) + val pullState: StateFlow = _pullState.asStateFlow() + + private val _pushState = MutableStateFlow(PushUiState.Idle) + val pushState: StateFlow = _pushState.asStateFlow() + + private var pullResetJob: Job? = null + private var pushResetJob: Job? = null + + var currentRepository: GitRepository? = null + private set + + init { + EventBus.getDefault().register(this) + initializeRepository() + } + + override fun onCleared() { + super.onCleared() + EventBus.getDefault().unregister(this) + currentRepository?.close() + } + + private fun initializeRepository() { + viewModelScope.launch { + try { + val projectDir = File(IProjectManager.getInstance().projectDirPath) + currentRepository = GitRepositoryManager.openRepository(projectDir) + _isGitRepository.value = currentRepository != null + refreshStatus() + } catch (e: Exception) { + log.error("Failed to initialize repository", e) + _isGitRepository.value = false + _gitStatus.value = GitStatus.EMPTY + } + } + } + + /** + * Refreshes the Git status of the project. + */ + fun refreshStatus() { + viewModelScope.launch { + try { + currentRepository?.let { repo -> + val status = repo.getStatus() + _gitStatus.value = status + _currentBranch.value = repo.getCurrentBranch()?.name + getLocalCommitsCount() + } ?: run { + _gitStatus.value = GitStatus.EMPTY + _currentBranch.value = null + _localCommitsCount.value = 0 + } + } catch (e: Exception) { + log.error("Failed to refresh git status", e) + _gitStatus.value = GitStatus.EMPTY + _currentBranch.value = null + _localCommitsCount.value = 0 + } + } + } + + suspend fun getLocalCommitsCount() { + _localCommitsCount.value = currentRepository?.getLocalCommitsCount() ?: 0 + } + + fun commitChanges( + summary: String, + description: String? = null, + selectedPaths: List, + onSuccess: () -> Unit, + ) { + viewModelScope.launch { + try { + if (selectedPaths.isEmpty()) return@launch + + val repository = currentRepository ?: return@launch + + val projectDir = File(IProjectManager.getInstance().projectDirPath) + val filesToStage = selectedPaths.map { File(projectDir, it) } + + repository.stageFiles(filesToStage) + + val message = + if (!description.isNullOrBlank()) "$summary\n\n$description" else summary + repository.commit( + message = message, + authorName = GitPreferences.userName, + authorEmail = GitPreferences.userEmail, + ) + + refreshStatus() + onSuccess() + } catch (e: Exception) { + log.error("Failed to commit changes", e) + } + } + } + + fun getCommitHistoryList() { + viewModelScope.launch { + _commitHistory.value = CommitHistoryUiState.Loading + try { + val history = currentRepository?.getHistory() + if (history.isNullOrEmpty()) { + _commitHistory.value = CommitHistoryUiState.Empty + } else { + _commitHistory.value = CommitHistoryUiState.Success(history) + } + getLocalCommitsCount() + } catch (e: Exception) { + log.error("Failed to fetch commit history", e) + _commitHistory.value = CommitHistoryUiState.Error(e.message) + } + } + } + + fun push( + username: String?, + token: String?, + ) { + pushResetJob?.cancel() + + viewModelScope.launch { + try { + if (!BaseApplication.baseInstance.isNetworkConnected()) { + _pushState.value = PushUiState.Error(errorResId = R.string.no_internet_connection) + return@launch + } + + _pushState.value = PushUiState.Pushing + val repository = currentRepository ?: return@launch + val credentials = buildCredentials(username, token) + val results = repository.push(credentialsProvider = credentials) + val error = + results + .flatMap { it.remoteUpdates } + .firstOrNull { + it.status != RemoteRefUpdate.Status.OK && + it.status != RemoteRefUpdate.Status.UP_TO_DATE + } + + if (error != null) { + handlePushError(error) + return@launch + } + + handlePushSuccess(username, token) + } catch (e: Exception) { + if (e.message?.contains("not authorized", ignoreCase = true) == true) { + credentialsManager.clearCredentials() + _pushState.value = PushUiState.Error(errorResId = R.string.repo_authorization_error) + return@launch + } + _pushState.value = PushUiState.Error(e.message) + } finally { + pushResetJob = + viewModelScope.launch { + delay(3000) + _pushState.value = PushUiState.Idle + } + } + } + } + + private fun buildCredentials( + username: String?, + token: String?, + ) = if (!username.isNullOrBlank() && !token.isNullOrBlank()) { + UsernamePasswordCredentialsProvider(username, token) + } else { + null + } + + private fun handlePushError(update: RemoteRefUpdate) { + val resId = + if (update.status == RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD) { + R.string.push_rejected_nonfastforward + } else { + R.string.unknown_error + } + _pushState.value = + PushUiState.Error( + message = update.message ?: update.status.name, + errorResId = resId, + ) + } + + private suspend fun handlePushSuccess( + username: String?, + token: String?, + ) { + _pushState.value = PushUiState.Success + credentialsManager.saveCredentialsIfNeeded(username, token) + refreshStatus() + getLocalCommitsCount() + getCommitHistoryList() + } + + fun pull( + username: String?, + token: String?, + ) { + pullResetJob?.cancel() + + viewModelScope.launch { + try { + if (!BaseApplication.baseInstance.isNetworkConnected()) { + _pullState.value = PullUiState.Error(errorResId = R.string.no_internet_connection) + return@launch + } + + _pullState.value = PullUiState.Pulling + val repository = currentRepository ?: return@launch + val credentials = buildCredentials(username, token) + val result = repository.pull(credentialsProvider = credentials) + + if (!result.isSuccessful) { + handlePullError(result) + return@launch + } + + handlePullSuccess(username, token) + } catch (e: CheckoutConflictException) { + log.error("Pull failed with checkout conflict", e) + val paths = e.conflictingPaths?.joinToString("\n") ?: "" + _pullState.value = PullUiState.Error(errorResId = R.string.checkout_conflict_message, errorArgs = listOf(paths)) + } catch (e: Exception) { + log.error("Pull failed", e) + if (e.message?.contains("not authorized", ignoreCase = true) == true) { + credentialsManager.clearCredentials() + _pullState.value = PullUiState.Error(errorResId = R.string.repo_authorization_error) + return@launch + } + _pullState.value = PullUiState.Error(e.message) + } finally { + pullResetJob = + viewModelScope.launch { + delay(3000) + _pullState.value = PullUiState.Idle + } + } + } + } + + private fun handlePullError(result: PullResult) { + val mergeStatus = result.mergeResult?.mergeStatus + val statusName = mergeStatus?.name ?: "Unknown error" + + if (mergeStatus == MergeStatus.CONFLICTING) { + _pullState.value = PullUiState.Conflicts() + refreshStatus() + } else { + _pullState.value = PullUiState.Error("Pull failed: $statusName") + } + } + + private fun handlePullSuccess( + username: String?, + token: String?, + ) { + _pullState.value = PullUiState.Success + credentialsManager.saveCredentialsIfNeeded(username, token) + refreshStatus() + getCommitHistoryList() + } + + fun resetPullState() { + pullResetJob?.cancel() + _pullState.value = PullUiState.Idle + } + + fun resetPushState() { + pushResetJob?.cancel() + _pushState.value = PushUiState.Idle + } + + sealed class PullUiState { + object Idle : PullUiState() + + object Pulling : PullUiState() + + object Success : PullUiState() + + data class Conflicts( + val message: String? = null, + ) : PullUiState() + + data class Error( + val message: String? = null, + val errorResId: Int? = R.string.unknown_error, + val errorArgs: List? = null, + ) : PullUiState() + } + + sealed class PushUiState { + object Idle : PushUiState() + + object Pushing : PushUiState() + + object Success : PushUiState() + + data class Error( + val message: String? = null, + val errorResId: Int? = R.string.unknown_error, + ) : PushUiState() + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onDocumentSaved(event: DocumentSaveEvent) { + refreshStatus() + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onProjectFilesChanged(event: ListProjectFilesRequestEvent) { + refreshStatus() + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onFileCreated(event: FileCreationEvent) { + refreshStatus() + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onFileDeleted(event: FileDeletionEvent) { + refreshStatus() + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onFileRenamed(event: FileRenameEvent) { + refreshStatus() + } + + fun abortMerge(onSuccess: (() -> Unit)? = null) { + viewModelScope.launch { + try { + currentRepository?.abortMerge() + refreshStatus() + onSuccess?.invoke() + } catch (e: Exception) { + log.error("Failed to abort merge", e) + } + } + } + + fun resolveConflict(path: String) { + viewModelScope.launch { + try { + val repository = currentRepository ?: return@launch + val projectDir = File(IProjectManager.getInstance().projectDirPath) + repository.stageFiles(listOf(File(projectDir, path))) + refreshStatus() + } catch (e: Exception) { + log.error("Failed to resolve conflict for $path", e) + } + } + } } diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/CloneRepositoryViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/CloneRepositoryViewModelTest.kt index b3ce6de026..f6c42bb7eb 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodel/CloneRepositoryViewModelTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/CloneRepositoryViewModelTest.kt @@ -2,10 +2,11 @@ package com.itsaky.androidide.viewmodel import android.app.Application import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import com.blankj.utilcode.util.NetworkUtils import com.itsaky.androidide.R import com.itsaky.androidide.git.core.GitCredentialsManager import com.itsaky.androidide.git.core.GitRepositoryManager +import com.itsaky.androidide.git.core.models.CloneRepoUiState +import com.itsaky.androidide.utils.isNetworkConnected import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -29,195 +30,198 @@ import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import org.junit.runners.JUnit4 import java.io.File -import com.itsaky.androidide.git.core.models.CloneRepoUiState - @RunWith(JUnit4::class) @OptIn(ExperimentalCoroutinesApi::class) - class CloneRepositoryViewModelTest { - - @get:Rule - val instantExecutorRule = InstantTaskExecutorRule() - - @get:Rule - val mainDispatcherRule = MainDispatcherRule() - - @get:Rule - val tempFolder = TemporaryFolder() - - private lateinit var viewModel: CloneRepositoryViewModel - private val context = mockk(relaxed = true) - private val credentialsManager = mockk(relaxed = true) - - @Before - fun setup() { - // NetworkUtils.isConnected() transitively triggers blankj Utils.init() -> - // android.util.Log.e(), which throws "not mocked" under plain JVM tests. - // Stub the connectivity check so cloneRepository() proceeds to the - // GitRepositoryManager call these tests are actually exercising. - mockkStatic(NetworkUtils::class) - every { NetworkUtils.isConnected() } returns true - - mockkObject(GitRepositoryManager) - viewModel = CloneRepositoryViewModel(context, credentialsManager) - - // Mock the application string responses for error messages - every { context.getString(any(), *anyVararg()) } answers { - val varargs = it.invocation.args[1] as Array<*> - "Clone failed: ${varargs[0]}" - } - } - - @After - fun tearDown() { - unmockkAll() - } - - @Test - fun `initial state should be empty and clone button disabled`() { - val state = viewModel.uiState.value - assertTrue(state is CloneRepoUiState.Idle) - assertEquals("", (state as CloneRepoUiState.Idle).url) - assertEquals("", state.localPath) - assertFalse(state.isCloneButtonEnabled) - } - - @Test - fun `when state is reset, values are cleared`() { - viewModel.onInputChanged( - url = "https://github.com/user/repo.git", path = "/sdcard/path" - ) - - viewModel.resetState() - - val state = viewModel.uiState.value - assertTrue(state is CloneRepoUiState.Idle) - assertEquals("", (state as CloneRepoUiState.Idle).url) - assertEquals("", state.localPath) - assertFalse(state.isCloneButtonEnabled) - } - - @Test - fun `when input is valid, clone button is enabled`() { - viewModel.onInputChanged( - url = "https://github.com/user/repo.git", path = "/sdcard/path" - ) - - val state = viewModel.uiState.value - assertTrue(state is CloneRepoUiState.Idle) - assertTrue((state as CloneRepoUiState.Idle).isCloneButtonEnabled) - } - - - @Test - fun `when input is invalid, clone button is disabled`() { - viewModel.onInputChanged( - url = "", path = "" - ) - - val state = viewModel.uiState.value - assertTrue(state is CloneRepoUiState.Idle) - assertFalse((state as CloneRepoUiState.Idle).isCloneButtonEnabled) - } - - @Test - fun `cloning into non-empty directory sets error status`() { - val folder = tempFolder.newFolder("existing_repo") - File(folder, "keep.me").createNewFile() - - viewModel.cloneRepository("https://github.com/username/newproject.git", folder.absolutePath) - - val state = viewModel.uiState.value - assertTrue(state is CloneRepoUiState.Error) - assertEquals(R.string.destination_directory_not_empty, (state as CloneRepoUiState.Error).errorResId) - } - - @Test - fun `cloneRepository success updates UI state correctly`() = runTest { - val folder = tempFolder.newFolder("NewProject") - val url = "https://github.com/username/newproject.git" - - coEvery { - GitRepositoryManager.cloneRepository(any(), any(), any(), any()) - } returns mockk() - - viewModel.cloneRepository(url, folder.absolutePath) - advanceUntilIdle() - - val state = viewModel.uiState.value - assertTrue(state is CloneRepoUiState.Success) - assertEquals(folder.absolutePath, (state as CloneRepoUiState.Success).localPath) - - coVerify(exactly = 1) { - GitRepositoryManager.cloneRepository( - url = url, destDir = any(), credentialsProvider = null, progressMonitor = any() - ) - } - } - - @Test - fun `cloneRepository failure updates UI with error message`() = runTest { - val folder = tempFolder.newFolder("NewProject") - val errorMsg = "Network Timeout" - - coEvery { - GitRepositoryManager.cloneRepository(any(), any(), any(), any()) - } throws Exception(errorMsg) - - viewModel.cloneRepository("https://github.com/username/newproject.git", folder.absolutePath) - advanceUntilIdle() - - val state = viewModel.uiState.value - assertTrue(state is CloneRepoUiState.Error) - Assert.assertNotNull((state as CloneRepoUiState.Error).errorMessage) - assertTrue(state.errorMessage!!.contains(errorMsg)) - } - - @Test - fun `test clone repository with auth is successful`() = runTest { - val folder = tempFolder.newFolder("AuthProject") - val url = "https://github.com/username/newproject.git" - - coEvery { - GitRepositoryManager.cloneRepository(any(), any(), any(), any()) - } returns mockk() - - viewModel.cloneRepository( - url = url, - localPath = folder.absolutePath, - username = "username", - token = "token" - ) - advanceUntilIdle() - - val state = viewModel.uiState.value - assertTrue(state is CloneRepoUiState.Success) - - coVerify(exactly = 1) { - GitRepositoryManager.cloneRepository( - url = url, - destDir = folder, - credentialsProvider = ofType(UsernamePasswordCredentialsProvider::class), - progressMonitor = any() - ) - } - } - - @Test - fun `cloneRepository fails if destination directory does not exist after clone`() = runTest { - val folder = File(tempFolder.root, "MissingFolder") - - coEvery { - GitRepositoryManager.cloneRepository(any(), any(), any(), any()) - } returns mockk() - - viewModel.cloneRepository("https://github.com/username/newproject.git", folder.absolutePath) - advanceUntilIdle() - - val state = viewModel.uiState.value - assertTrue(state is CloneRepoUiState.Error) - Assert.assertNotNull((state as CloneRepoUiState.Error).errorMessage) - assertTrue(state.errorMessage!!.contains("Destination directory was not created")) - } + @get:Rule + val instantExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var viewModel: CloneRepositoryViewModel + private val context = mockk(relaxed = true) + private val credentialsManager = mockk(relaxed = true) + + @Before + fun setup() { + // Stub the connectivity check so cloneRepository() proceeds to the + // GitRepositoryManager call these tests are actually exercising. + mockkStatic("com.itsaky.androidide.utils.ContextUtilsKt") + every { context.isNetworkConnected() } returns true + + mockkObject(GitRepositoryManager) + viewModel = CloneRepositoryViewModel(context, credentialsManager) + + // Mock the application string responses for error messages + every { context.getString(any(), *anyVararg()) } answers { + val varargs = it.invocation.args[1] as Array<*> + "Clone failed: ${varargs[0]}" + } + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `initial state should be empty and clone button disabled`() { + val state = viewModel.uiState.value + assertTrue(state is CloneRepoUiState.Idle) + assertEquals("", (state as CloneRepoUiState.Idle).url) + assertEquals("", state.localPath) + assertFalse(state.isCloneButtonEnabled) + } + + @Test + fun `when state is reset, values are cleared`() { + viewModel.onInputChanged( + url = "https://github.com/user/repo.git", + path = "/sdcard/path", + ) + + viewModel.resetState() + + val state = viewModel.uiState.value + assertTrue(state is CloneRepoUiState.Idle) + assertEquals("", (state as CloneRepoUiState.Idle).url) + assertEquals("", state.localPath) + assertFalse(state.isCloneButtonEnabled) + } + + @Test + fun `when input is valid, clone button is enabled`() { + viewModel.onInputChanged( + url = "https://github.com/user/repo.git", + path = "/sdcard/path", + ) + + val state = viewModel.uiState.value + assertTrue(state is CloneRepoUiState.Idle) + assertTrue((state as CloneRepoUiState.Idle).isCloneButtonEnabled) + } + + @Test + fun `when input is invalid, clone button is disabled`() { + viewModel.onInputChanged( + url = "", + path = "", + ) + + val state = viewModel.uiState.value + assertTrue(state is CloneRepoUiState.Idle) + assertFalse((state as CloneRepoUiState.Idle).isCloneButtonEnabled) + } + + @Test + fun `cloning into non-empty directory sets error status`() { + val folder = tempFolder.newFolder("existing_repo") + File(folder, "keep.me").createNewFile() + + viewModel.cloneRepository("https://github.com/username/newproject.git", folder.absolutePath) + + val state = viewModel.uiState.value + assertTrue(state is CloneRepoUiState.Error) + assertEquals(R.string.destination_directory_not_empty, (state as CloneRepoUiState.Error).errorResId) + } + + @Test + fun `cloneRepository success updates UI state correctly`() = + runTest { + val folder = tempFolder.newFolder("NewProject") + val url = "https://github.com/username/newproject.git" + + coEvery { + GitRepositoryManager.cloneRepository(any(), any(), any(), any()) + } returns mockk() + + viewModel.cloneRepository(url, folder.absolutePath) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertTrue(state is CloneRepoUiState.Success) + assertEquals(folder.absolutePath, (state as CloneRepoUiState.Success).localPath) + + coVerify(exactly = 1) { + GitRepositoryManager.cloneRepository( + url = url, + destDir = any(), + credentialsProvider = null, + progressMonitor = any(), + ) + } + } + + @Test + fun `cloneRepository failure updates UI with error message`() = + runTest { + val folder = tempFolder.newFolder("NewProject") + val errorMsg = "Network Timeout" + + coEvery { + GitRepositoryManager.cloneRepository(any(), any(), any(), any()) + } throws Exception(errorMsg) + + viewModel.cloneRepository("https://github.com/username/newproject.git", folder.absolutePath) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertTrue(state is CloneRepoUiState.Error) + Assert.assertNotNull((state as CloneRepoUiState.Error).errorMessage) + assertTrue(state.errorMessage!!.contains(errorMsg)) + } + + @Test + fun `test clone repository with auth is successful`() = + runTest { + val folder = tempFolder.newFolder("AuthProject") + val url = "https://github.com/username/newproject.git" + + coEvery { + GitRepositoryManager.cloneRepository(any(), any(), any(), any()) + } returns mockk() + + viewModel.cloneRepository( + url = url, + localPath = folder.absolutePath, + username = "username", + token = "token", + ) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertTrue(state is CloneRepoUiState.Success) + + coVerify(exactly = 1) { + GitRepositoryManager.cloneRepository( + url = url, + destDir = folder, + credentialsProvider = ofType(UsernamePasswordCredentialsProvider::class), + progressMonitor = any(), + ) + } + } + + @Test + fun `cloneRepository fails if destination directory does not exist after clone`() = + runTest { + val folder = File(tempFolder.root, "MissingFolder") + + coEvery { + GitRepositoryManager.cloneRepository(any(), any(), any(), any()) + } returns mockk() + + viewModel.cloneRepository("https://github.com/username/newproject.git", folder.absolutePath) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertTrue(state is CloneRepoUiState.Error) + Assert.assertNotNull((state as CloneRepoUiState.Error).errorMessage) + assertTrue(state.errorMessage!!.contains("Destination directory was not created")) + } } diff --git a/common/src/main/java/com/itsaky/androidide/utils/ContextUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/ContextUtils.kt index 0dad50bff7..97462f67d1 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/ContextUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/ContextUtils.kt @@ -20,13 +20,19 @@ package com.itsaky.androidide.utils import android.app.Activity import android.app.AlarmManager import android.app.PendingIntent +import android.content.ClipData +import android.content.ClipboardManager import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.res.Configuration import android.content.res.Resources.Theme +import android.net.ConnectivityManager +import android.net.NetworkCapabilities import android.provider.Settings import android.util.TypedValue +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import kotlin.system.exitProcess /** @@ -83,3 +89,32 @@ fun Context.dpToPx(dp: Float): Int = (dp * resources.displayMetrics.density + 0. * Converts an sp value to pixels, using this context's display metrics. */ fun Context.spToPx(sp: Float): Int = (sp * resources.displayMetrics.scaledDensity + 0.5f).toInt() + +/** + * Copies [text] to the system clipboard, under the given [label]. + */ +fun Context.copyToClipboard( + text: CharSequence, + label: CharSequence = "", +) { + val cm = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(ClipData.newPlainText(label, text)) +} + +/** + * Checks whether this device currently has network connectivity with internet access. + */ +fun Context.isNetworkConnected(): Boolean { + val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false + val network = cm.activeNetwork ?: return false + val capabilities = cm.getNetworkCapabilities(network) ?: return false + return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) +} + +/** + * Checks whether the soft (on-screen) keyboard is currently visible in this activity's window. + */ +fun Activity.isSoftInputVisible(): Boolean { + val insets = ViewCompat.getRootWindowInsets(window.decorView) ?: return false + return insets.isVisible(WindowInsetsCompat.Type.ime()) +} From 29c438ba1cb485975fa04954ed74bcdf0c608364 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 24 Jul 2026 15:17:42 -0700 Subject: [PATCH 5/9] ADFA-4649: Replace remaining misc blankj utilities with native equivalents - ThrowableUtils.getFullStackTrace -> Throwable.stackTraceToString() (Kotlin stdlib) - ConvertUtils.byte2MemorySize/MemoryConstants -> inline division - AppUtils.getAppVersionCode -> new Context.getAppVersionCode() extension - DeviceUtils.getManufacturer/getModel/isEmulator/isDeviceRooted -> ported onto the project's own (same-name, same-package) DeviceUtils object - CloseUtils.closeIO -> direct try/close - ArrayUtils.contains -> IntStream.anyMatch - StringUtils.isTrimEmpty -> inline null/trim check - Utils.getApp() -> BaseApplication.baseInstance - ActivityUtils.startActivity -> IDEApplication.instance.startActivity - ActivityUtils.getTopActivity() -> new BaseApplication.foregroundActivity, tracked via a lifecycle-callbacks registration in BaseApplication.onCreate() (common module can't depend on IDEApplication in app); IDEApplication's own more precise Pre/Post-based tracker now overrides it Fifth of several staged commits removing com.blankj:utilcodex. Co-Authored-By: Claude Sonnet 5 --- .../activities/editor/BaseEditorActivity.kt | 4 +- .../CredentialProtectedApplicationLoader.kt | 135 +-- .../itsaky/androidide/app/IDEApplication.kt | 2 +- .../handlers/CrashEventSubscriber.kt | 7 +- .../handlers/GlitchTipDiagnosticsContext.kt | 6 +- .../itsaky/androidide/utils/BuildInfoUtils.kt | 4 +- .../itsaky/androidide/app/BaseApplication.kt | 38 + .../itsaky/androidide/utils/ContextUtils.kt | 12 + .../itsaky/androidide/utils/DeviceUtils.kt | 39 +- .../itsaky/androidide/utils/FlashbarUtils.kt | 4 +- .../com/itsaky/androidide/utils/urlManager.kt | 116 +-- .../language/groovy/GroovyAutoComplete.java | 692 ++++++++------- .../BaseIncrementalAnalyzeManager.java | 817 +++++++++--------- .../lsp/java/compiler/SourceFileManager.java | 15 +- 14 files changed, 995 insertions(+), 896 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index c70063f772..029cc41438 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -65,8 +65,6 @@ import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import com.blankj.utilcode.constant.MemoryConstants -import com.blankj.utilcode.util.ConvertUtils.byte2MemorySize import com.github.mikephil.charting.components.AxisBase import com.github.mikephil.charting.data.Entry import com.github.mikephil.charting.data.LineData @@ -281,7 +279,7 @@ abstract class BaseEditorActivity : dataset.entries.mapIndexed { index, entry -> entry.y = - byte2MemorySize(proc.usageHistory[index], MemoryConstants.MB).toFloat() + (proc.usageHistory[index] / (1024.0 * 1024.0)).toFloat() } dataset.label = "%s - %.2fMB".format(proc.pname, dataset.entries.last().y) 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 6afb08d8ff..bc245aec84 100644 --- a/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt +++ b/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt @@ -1,13 +1,17 @@ package com.itsaky.androidide.app import android.content.Intent +import android.os.Handler +import android.os.Looper +import android.os.UserManager import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat -import com.blankj.utilcode.util.ThrowableUtils +import androidx.work.WorkManager import com.google.android.material.color.DynamicColors import com.itsaky.androidide.activities.CrashHandlerActivity import com.itsaky.androidide.activities.editor.IDELogcatReader import com.itsaky.androidide.editor.schemes.IDEColorSchemeProvider +import com.itsaky.androidide.eventbus.events.plugin.PluginCrashedEvent import com.itsaky.androidide.eventbus.events.preferences.PreferenceChangeEvent import com.itsaky.androidide.lookup.Lookup import com.itsaky.androidide.managers.ToolsManager @@ -24,7 +28,6 @@ import com.itsaky.androidide.utils.Environment import com.itsaky.androidide.utils.FeatureFlags import com.itsaky.androidide.utils.FileUtil import com.itsaky.androidide.utils.VMUtils -import com.itsaky.androidide.eventbus.events.plugin.PluginCrashedEvent import io.sentry.Sentry import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers @@ -35,10 +38,6 @@ import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import org.slf4j.LoggerFactory -import android.os.Handler -import android.os.Looper -import android.os.UserManager -import androidx.work.WorkManager import java.io.File import java.util.concurrent.atomic.AtomicBoolean import kotlin.system.exitProcess @@ -79,11 +78,11 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { application = app if (!isCredentialStorageReady(app)) { - logger.error("Credential protected storage is not ready. Skipping credential protected initialization.") - return - } + logger.error("Credential protected storage is not ready. Skipping credential protected initialization.") + return + } - initializeWorkManagerSafely(app) + initializeWorkManagerSafely(app) Environment.init(app) @@ -121,45 +120,46 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { } } - private fun isCredentialStorageReady(app: IDEApplication): Boolean { - val userManager = app.getSystemService(UserManager::class.java) + private fun isCredentialStorageReady(app: IDEApplication): Boolean { + val userManager = app.getSystemService(UserManager::class.java) - if (!userManager.isUserUnlocked) return false + if (!userManager.isUserUnlocked) return false - val filesDir = app.filesDir - val noBackupDir = app.noBackupFilesDir + val filesDir = app.filesDir + val noBackupDir = app.noBackupFilesDir - if (!filesDir.exists()) filesDir.mkdirs() - if (!noBackupDir.exists()) noBackupDir.mkdirs() + if (!filesDir.exists()) filesDir.mkdirs() + if (!noBackupDir.exists()) noBackupDir.mkdirs() - return filesDir.exists() && - filesDir.isDirectory && - noBackupDir.exists() && - noBackupDir.isDirectory - } + return filesDir.exists() && + filesDir.isDirectory && + noBackupDir.exists() && + noBackupDir.isDirectory + } - private fun initializeWorkManagerSafely(app: IDEApplication) { - runCatching { - WorkManager.getInstance(app) - }.onFailure { error -> - logger.error("Failed to get WorkManager instance after storage validation", error) - Sentry.captureException(error) - } - } + private fun initializeWorkManagerSafely(app: IDEApplication) { + runCatching { + WorkManager.getInstance(app) + }.onFailure { error -> + logger.error("Failed to get WorkManager instance after storage validation", error) + Sentry.captureException(error) + } + } fun handleUncaughtException( thread: Thread, exception: Throwable, ) { val pluginManager = PluginManager.getInstance() - val pluginId = runCatching { - pluginManager?.let { pm -> - pm.crashTracker.findPluginForStackTrace( - exception, - pm.getLoadedPluginIds() - ) { pm.getClassLoaderForPluginId(it) } - } - }.getOrNull() + val pluginId = + runCatching { + pluginManager?.let { pm -> + pm.crashTracker.findPluginForStackTrace( + exception, + pm.getLoadedPluginIds(), + ) { pm.getClassLoaderForPluginId(it) } + } + }.getOrNull() if (pluginId != null) { handlePluginCrash(pluginId, exception) @@ -174,7 +174,7 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { intent.action = CrashHandlerActivity.REPORT_ACTION intent.putExtra( CrashHandlerActivity.TRACE_KEY, - ThrowableUtils.getFullStackTrace(exception), + exception.stackTraceToString(), ) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) IDEApplication.instance.startActivity(intent) @@ -188,7 +188,10 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { exitProcess(EXIT_CODE_CRASH) } - private fun handlePluginCrash(pluginId: String, exception: Throwable) { + private fun handlePluginCrash( + pluginId: String, + exception: Throwable, + ) { runCatching { writeException(exception) @@ -202,13 +205,14 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { val result = pluginManager.recordPluginCrash(pluginId) val wasDisabled = result is PluginManager.CrashResult.Disabled - val crashCount = when (result) { - is PluginManager.CrashResult.Recorded -> result.crashCount - is PluginManager.CrashResult.Disabled -> pluginManager.crashTracker.getCrashCount(pluginId) - } + val crashCount = + when (result) { + is PluginManager.CrashResult.Recorded -> result.crashCount + is PluginManager.CrashResult.Disabled -> pluginManager.crashTracker.getCrashCount(pluginId) + } EventBus.getDefault().post( - PluginCrashedEvent(pluginId, result.pluginName, crashCount, wasDisabled, ThrowableUtils.getFullStackTrace(exception)) + PluginCrashedEvent(pluginId, result.pluginName, crashCount, wasDisabled, exception.stackTraceToString()), ) logger.warn("Plugin crash handled without killing process: {} (disabled={})", pluginId, wasDisabled) }.onFailure { e -> @@ -225,13 +229,15 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { Looper.loop() break } catch (e: Throwable) { - val pluginId = runCatching { - PluginManager.getInstance()?.let { pm -> - pm.crashTracker.findPluginForStackTrace( - e, pm.getLoadedPluginIds() - ) { pm.getClassLoaderForPluginId(it) } - } - }.getOrNull() + val pluginId = + runCatching { + PluginManager.getInstance()?.let { pm -> + pm.crashTracker.findPluginForStackTrace( + e, + pm.getLoadedPluginIds(), + ) { pm.getClassLoaderForPluginId(it) } + } + }.getOrNull() if (pluginId != null) { lastPluginCrashTime = System.currentTimeMillis() @@ -257,7 +263,7 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { .writer() .buffered() .use { outputStream -> - outputStream.write(ThrowableUtils.getFullStackTrace(throwable)) + outputStream.write(throwable?.stackTraceToString() ?: "") } } @@ -357,7 +363,9 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { @OptIn(DelicateCoroutinesApi::class) private fun setupBuildServiceProviders() { - val buildServiceImpl = com.itsaky.androidide.plugins.manager.services.IdeBuildServiceImpl.getInstance() + val buildServiceImpl = + com.itsaky.androidide.plugins.manager.services.IdeBuildServiceImpl + .getInstance() // Provide runApp functionality buildServiceImpl.setRunAppProvider { callback -> @@ -371,7 +379,9 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { return@launch } - val projectManager = com.itsaky.androidide.projects.IProjectManager.getInstance() + val projectManager = + com.itsaky.androidide.projects.IProjectManager + .getInstance() val appModules = projectManager.getAndroidAppModules() if (appModules.isEmpty()) { @@ -450,7 +460,9 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { } private fun setupProjectManipulationProviders() { - val manipulationServiceImpl = com.itsaky.androidide.plugins.manager.services.IdeProjectManipulationServiceImpl.getInstance() + val manipulationServiceImpl = + com.itsaky.androidide.plugins.manager.services.IdeProjectManipulationServiceImpl + .getInstance() // Provide dependency addition manipulationServiceImpl.setAddDependencyProvider { dependencyString, buildFilePath -> @@ -534,12 +546,13 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { val pm = PluginManager.getInstance() ?: return@runCatching val result = pm.recordPluginCrash(pluginId) val wasDisabled = result is PluginManager.CrashResult.Disabled - val crashCount = when (result) { - is PluginManager.CrashResult.Recorded -> result.crashCount - is PluginManager.CrashResult.Disabled -> pm.crashTracker.getCrashCount(pluginId) - } + val crashCount = + when (result) { + is PluginManager.CrashResult.Recorded -> result.crashCount + is PluginManager.CrashResult.Disabled -> pm.crashTracker.getCrashCount(pluginId) + } EventBus.getDefault().post( - PluginCrashedEvent(pluginId, result.pluginName, crashCount, wasDisabled, ThrowableUtils.getFullStackTrace(error)) + PluginCrashedEvent(pluginId, result.pluginName, crashCount, wasDisabled, error.stackTraceToString()), ) } } 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 e196ba4506..a4364353cb 100755 --- a/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt +++ b/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt @@ -74,7 +74,7 @@ class IDEApplication : /** * The currently visible (foreground) activity. */ - val foregroundActivity: Activity? + override val foregroundActivity: Activity? get() = foregroundActivityState.value private val deviceUnlockReceiver = diff --git a/app/src/main/java/com/itsaky/androidide/handlers/CrashEventSubscriber.kt b/app/src/main/java/com/itsaky/androidide/handlers/CrashEventSubscriber.kt index 501597daa4..16851126bf 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/CrashEventSubscriber.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/CrashEventSubscriber.kt @@ -1,9 +1,8 @@ package com.itsaky.androidide.handlers import android.content.Intent -import com.blankj.utilcode.util.ActivityUtils.startActivity -import com.blankj.utilcode.util.ThrowableUtils.getFullStackTrace import com.itsaky.androidide.activities.CrashHandlerActivity +import com.itsaky.androidide.app.IDEApplication import com.itsaky.androidide.eventbus.events.editor.ReportCaughtExceptionEvent import io.sentry.Sentry import org.greenrobot.eventbus.Subscribe @@ -29,9 +28,9 @@ class CrashEventSubscriber { try { val intent = Intent() intent.action = CrashHandlerActivity.REPORT_ACTION - intent.putExtra(CrashHandlerActivity.TRACE_KEY, getFullStackTrace(ev.throwable)) + intent.putExtra(CrashHandlerActivity.TRACE_KEY, ev.throwable.stackTraceToString()) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(intent) + IDEApplication.instance.startActivity(intent) exitProcess(EXIT_CODE_CRASH) } catch (error: Throwable) { diff --git a/app/src/main/java/com/itsaky/androidide/handlers/GlitchTipDiagnosticsContext.kt b/app/src/main/java/com/itsaky/androidide/handlers/GlitchTipDiagnosticsContext.kt index fa889602ce..09e151b921 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/GlitchTipDiagnosticsContext.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/GlitchTipDiagnosticsContext.kt @@ -20,13 +20,13 @@ package com.itsaky.androidide.handlers import android.content.pm.ApplicationInfo import android.os.SystemClock -import com.blankj.utilcode.util.AppUtils -import com.blankj.utilcode.util.DeviceUtils import com.itsaky.androidide.app.IDEApplication import com.itsaky.androidide.app.configuration.IDEBuildConfigProvider import com.itsaky.androidide.buildinfo.BuildInfo import com.itsaky.androidide.plugins.manager.core.PluginManager import com.itsaky.androidide.utils.BuildInfoUtils +import com.itsaky.androidide.utils.DeviceUtils +import com.itsaky.androidide.utils.getAppVersionCode import com.termux.shared.android.PackageUtils import com.termux.shared.android.SELinuxUtils import io.sentry.EventProcessor @@ -182,7 +182,7 @@ object GlitchTipDiagnosticsContext { // A — release identifier + version code. tag(event, "app_version_name") { BuildInfo.VERSION_NAME_SIMPLE } - tag(event, "app_version_code") { AppUtils.getAppVersionCode().toString() } + tag(event, "app_version_code") { IDEApplication.instance.getAppVersionCode().toString() } tag(event, "app_git_commit") { BuildInfo.CI_GIT_COMMIT_HASH } tag(event, "app_ci_build") { BuildInfo.CI_BUILD.toString() } diff --git a/app/src/main/java/com/itsaky/androidide/utils/BuildInfoUtils.kt b/app/src/main/java/com/itsaky/androidide/utils/BuildInfoUtils.kt index c1ec8a402b..7b17c93371 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/BuildInfoUtils.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/BuildInfoUtils.kt @@ -19,8 +19,6 @@ package com.itsaky.androidide.utils import android.content.Context import android.os.Build -import com.blankj.utilcode.util.AppUtils -import com.blankj.utilcode.util.DeviceUtils import com.itsaky.androidide.BuildConfig import com.itsaky.androidide.app.IDEApplication import com.itsaky.androidide.app.configuration.IDEBuildConfigProvider @@ -38,7 +36,7 @@ object BuildInfoUtils { private val BUILD_INFO_HEADER by lazy { val map = mapOf( - "Version" to "v${BasicBuildInfo.formatVersion()} (${AppUtils.getAppVersionCode()})", + "Version" to "v${BasicBuildInfo.formatVersion()} (${IDEApplication.instance.getAppVersionCode()})", "CI Build" to BuildInfo.CI_BUILD, "Branch" to BuildInfo.CI_GIT_BRANCH, "Commit" to BuildInfo.CI_GIT_COMMIT_HASH, diff --git a/common/src/main/java/com/itsaky/androidide/app/BaseApplication.kt b/common/src/main/java/com/itsaky/androidide/app/BaseApplication.kt index bb39d17425..bfead96b58 100644 --- a/common/src/main/java/com/itsaky/androidide/app/BaseApplication.kt +++ b/common/src/main/java/com/itsaky/androidide/app/BaseApplication.kt @@ -16,10 +16,12 @@ */ package com.itsaky.androidide.app +import android.app.Activity import android.app.Application import android.content.Context import android.content.ContextWrapper import android.content.SharedPreferences +import android.os.Bundle import androidx.core.os.UserManagerCompat import com.itsaky.androidide.managers.NoopSharedPreferencesImpl import com.itsaky.androidide.managers.PreferenceManager @@ -28,6 +30,7 @@ import org.slf4j.LoggerFactory open class BaseApplication : Application() { private var _prefManager: PreferenceManager? = null + private var _foregroundActivity: Activity? = null val isUserUnlocked: Boolean get() = UserManagerCompat.isUserUnlocked(this) @@ -38,6 +41,12 @@ open class BaseApplication : Application() { "PreferenceManager not initialized" } + /** + * The currently visible (resumed) activity, if any. + */ + open val foregroundActivity: Activity? + get() = _foregroundActivity + init { _baseInstance = this } @@ -46,6 +55,35 @@ open class BaseApplication : Application() { super.onCreate() _prefManager = PreferenceManager(getSafeContext()) JavaCharacter.initMap() + registerActivityLifecycleCallbacks( + object : Application.ActivityLifecycleCallbacks { + override fun onActivityCreated( + activity: Activity, + savedInstanceState: Bundle?, + ) = Unit + + override fun onActivityStarted(activity: Activity) = Unit + + override fun onActivityResumed(activity: Activity) { + _foregroundActivity = activity + } + + override fun onActivityPaused(activity: Activity) { + if (_foregroundActivity === activity) { + _foregroundActivity = null + } + } + + override fun onActivityStopped(activity: Activity) = Unit + + override fun onActivitySaveInstanceState( + activity: Activity, + outState: Bundle, + ) = Unit + + override fun onActivityDestroyed(activity: Activity) = Unit + }, + ) } @JvmOverloads diff --git a/common/src/main/java/com/itsaky/androidide/utils/ContextUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/ContextUtils.kt index 97462f67d1..e43553d795 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/ContextUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/ContextUtils.kt @@ -25,6 +25,7 @@ import android.content.ClipboardManager import android.content.ComponentName import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.content.res.Configuration import android.content.res.Resources.Theme import android.net.ConnectivityManager @@ -111,6 +112,17 @@ fun Context.isNetworkConnected(): Boolean { return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } +/** + * Returns this app's version code, or -1 if it could not be determined. + */ +@Suppress("DEPRECATION") +fun Context.getAppVersionCode(): Int = + try { + packageManager.getPackageInfo(packageName, 0).versionCode + } catch (e: PackageManager.NameNotFoundException) { + -1 + } + /** * Checks whether the soft (on-screen) keyboard is currently visible in this activity's window. */ diff --git a/common/src/main/java/com/itsaky/androidide/utils/DeviceUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/DeviceUtils.kt index d8716b2c35..40f3d0c137 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/DeviceUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/DeviceUtils.kt @@ -1,13 +1,26 @@ package com.itsaky.androidide.utils import android.annotation.SuppressLint +import android.os.Build import android.text.TextUtils import org.slf4j.LoggerFactory +import java.io.File object DeviceUtils { - private val logger = LoggerFactory.getLogger(DeviceUtils::class.java) + private val ROOT_INDICATOR_PATHS = + arrayOf( + "/system/bin/", + "/system/xbin/", + "/sbin/", + "/system/sd/xbin/", + "/system/bin/failsafe/", + "/data/local/xbin/", + "/data/local/bin/", + "/data/local/", + ) + fun isMiui(): Boolean = !TextUtils.isEmpty(getSystemProperty("ro.miui.ui.version.name")) @SuppressLint("PrivateApi") @@ -21,4 +34,28 @@ object DeviceUtils { logger.warn("Unable to use SystemProperties.get", e) null } + + fun getManufacturer(): String = Build.MANUFACTURER + + fun getModel(): String = Build.MODEL?.trim()?.replace("\\s*".toRegex(), "") ?: "" + + /** + * Heuristic check for whether the app is running on an emulator, based on common + * build-fingerprint indicators used by AVD/Genymotion images. + */ + fun isEmulator(): Boolean = + Build.FINGERPRINT.startsWith("generic") || + Build.FINGERPRINT.startsWith("unknown") || + Build.MODEL.contains("google_sdk") || + Build.MODEL.contains("Emulator") || + Build.MODEL.contains("Android SDK built for") || + Build.MANUFACTURER.contains("Genymotion") || + (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) || + Build.PRODUCT == "google_sdk" + + /** + * Heuristic check for whether the device is rooted, based on the presence of an `su` + * binary in common locations. + */ + fun isDeviceRooted(): Boolean = ROOT_INDICATOR_PATHS.any { File(it, "su").exists() } } diff --git a/common/src/main/java/com/itsaky/androidide/utils/FlashbarUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/FlashbarUtils.kt index 5c49b603e6..6028135fa9 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/FlashbarUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/FlashbarUtils.kt @@ -19,7 +19,7 @@ package com.itsaky.androidide.utils import android.app.Activity import androidx.annotation.StringRes -import com.blankj.utilcode.util.ActivityUtils +import com.itsaky.androidide.app.BaseApplication import com.itsaky.androidide.flashbar.Flashbar fun flashbarBuilder(): Flashbar.Builder? = withActivity { flashbarBuilder() } @@ -72,7 +72,7 @@ fun flashInfo( suspend fun flashProgress(configure: (Flashbar.Builder.() -> Unit)? = null): Flashbar? = withActivity { flashProgress(configure) } private inline fun withActivity(action: Activity.() -> T?): T? = - ActivityUtils.getTopActivity()?.action() + BaseApplication.baseInstance.foregroundActivity?.action() ?: run { ILogger.ROOT.warn("Cannot show flashbar message. Cannot get top activity.") null diff --git a/common/src/main/java/com/itsaky/androidide/utils/urlManager.kt b/common/src/main/java/com/itsaky/androidide/utils/urlManager.kt index aa492da992..7a3f375cde 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/urlManager.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/urlManager.kt @@ -6,70 +6,70 @@ import android.content.Context import android.content.Intent import android.net.Uri import androidx.preference.PreferenceManager -import com.blankj.utilcode.util.Utils +import com.itsaky.androidide.app.BaseApplication import com.itsaky.androidide.common.R object UrlManager { - private const val PREF_EXTERNAL_URL_WHITELIST = "external_url_whitelist" + private const val PREF_EXTERNAL_URL_WHITELIST = "external_url_whitelist" - fun openUrl(url: String, pkg: String? = null, context: Context? = null) { - val ctx = context ?: Utils.getApp().applicationContext + fun openUrl(url: String, pkg: String? = null, context: Context? = null) { + val ctx = context ?: BaseApplication.baseInstance.applicationContext - if (needsConsent(url, ctx) && ctx is Activity) { - showConsentDialog(url, pkg, ctx) - } else { - openUrlInternal(url, pkg, ctx) - } - } + if (needsConsent(url, ctx) && ctx is Activity) { + showConsentDialog(url, pkg, ctx) + } else { + openUrlInternal(url, pkg, ctx) + } + } - private fun needsConsent(url: String, context: Context): Boolean { - val prefs = PreferenceManager.getDefaultSharedPreferences(context) - val whitelist = prefs.getStringSet(PREF_EXTERNAL_URL_WHITELIST, emptySet()) ?: emptySet() - return !whitelist.contains(url) - } + private fun needsConsent(url: String, context: Context): Boolean { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val whitelist = prefs.getStringSet(PREF_EXTERNAL_URL_WHITELIST, emptySet()) ?: emptySet() + return !whitelist.contains(url) + } - private fun showConsentDialog(url: String, pkg: String?, context: Activity) { - DialogUtils.newMaterialDialogBuilder(context) - .setTitle(R.string.url_consent_title) - .setMessage(context.getString(R.string.url_consent_message, url)) - .setPositiveButton(R.string.url_consent_proceed) { dialog, _ -> - dialog.dismiss() - openUrlInternal(url, pkg, context) - } - .setNegativeButton(R.string.url_consent_cancel) { dialog, _ -> - dialog.dismiss() - } - .setNeutralButton(R.string.url_consent_dont_ask) { dialog, _ -> - dialog.dismiss() - addToWhitelist(url, context) - openUrlInternal(url, pkg, context) - } - .setCancelable(true) - .show() - } + private fun showConsentDialog(url: String, pkg: String?, context: Activity) { + DialogUtils.newMaterialDialogBuilder(context) + .setTitle(R.string.url_consent_title) + .setMessage(context.getString(R.string.url_consent_message, url)) + .setPositiveButton(R.string.url_consent_proceed) { dialog, _ -> + dialog.dismiss() + openUrlInternal(url, pkg, context) + } + .setNegativeButton(R.string.url_consent_cancel) { dialog, _ -> + dialog.dismiss() + } + .setNeutralButton(R.string.url_consent_dont_ask) { dialog, _ -> + dialog.dismiss() + addToWhitelist(url, context) + openUrlInternal(url, pkg, context) + } + .setCancelable(true) + .show() + } - private fun addToWhitelist(url: String, context: Context) { - val prefs = PreferenceManager.getDefaultSharedPreferences(context) - val whitelist = prefs.getStringSet(PREF_EXTERNAL_URL_WHITELIST, emptySet())?.toMutableSet() ?: mutableSetOf() - whitelist.add(url) - prefs.edit().putStringSet(PREF_EXTERNAL_URL_WHITELIST, whitelist).apply() - } + private fun addToWhitelist(url: String, context: Context) { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val whitelist = prefs.getStringSet(PREF_EXTERNAL_URL_WHITELIST, emptySet())?.toMutableSet() ?: mutableSetOf() + whitelist.add(url) + prefs.edit().putStringSet(PREF_EXTERNAL_URL_WHITELIST, whitelist).apply() + } - private fun openUrlInternal(url: String, pkg: String?, context: Context) { - try { - Intent().apply { - action = Intent.ACTION_VIEW - data = Uri.parse(url) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - pkg?.let { setPackage(it) } - context.startActivity(this) - } - } catch (th: Throwable) { - when { - pkg != null -> openUrlInternal(url, null, context) - th is ActivityNotFoundException -> flashError(R.string.msg_app_unavailable_for_intent) - else -> flashError(th.message) - } - } - } -} \ No newline at end of file + private fun openUrlInternal(url: String, pkg: String?, context: Context) { + try { + Intent().apply { + action = Intent.ACTION_VIEW + data = Uri.parse(url) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + pkg?.let { setPackage(it) } + context.startActivity(this) + } + } catch (th: Throwable) { + when { + pkg != null -> openUrlInternal(url, null, context) + th is ActivityNotFoundException -> flashError(R.string.msg_app_unavailable_for_intent) + else -> flashError(th.message) + } + } + } +} diff --git a/editor/src/main/java/com/itsaky/androidide/editor/language/groovy/GroovyAutoComplete.java b/editor/src/main/java/com/itsaky/androidide/editor/language/groovy/GroovyAutoComplete.java index e15119ae75..94ddffe09e 100755 --- a/editor/src/main/java/com/itsaky/androidide/editor/language/groovy/GroovyAutoComplete.java +++ b/editor/src/main/java/com/itsaky/androidide/editor/language/groovy/GroovyAutoComplete.java @@ -17,7 +17,6 @@ package com.itsaky.androidide.editor.language.groovy; import android.os.Bundle; -import com.blankj.utilcode.util.StringUtils; import com.itsaky.androidide.lsp.models.CompletionItem; import com.itsaky.androidide.lsp.models.CompletionItemKind; import com.itsaky.androidide.lsp.models.InsertTextFormat; @@ -32,363 +31,362 @@ public class GroovyAutoComplete { - private static final List ANDROIDX_ARTIFACTS = createArtifacts(); - private static final List CONFIGURATIONS = createConfigs(); - private static final List OTHERS = createOtherCompletions(); + private static final List ANDROIDX_ARTIFACTS = createArtifacts(); + private static final List CONFIGURATIONS = createConfigs(); + private static final List OTHERS = createOtherCompletions(); - private static List createArtifacts() { - var artifacts = new ArrayList(); - artifacts.add("androidx.arch.core:core-common"); - artifacts.add("androidx.arch.core:core"); - artifacts.add("androidx.arch.core:core-testing"); - artifacts.add("androidx.arch.core:core-runtime"); - artifacts.add("androidx.lifecycle:lifecycle-common"); - artifacts.add("androidx.lifecycle:lifecycle-common-java8"); - artifacts.add("androidx.lifecycle:lifecycle-compiler"); - artifacts.add("androidx.lifecycle:lifecycle-extensions"); - artifacts.add("androidx.lifecycle:lifecycle-livedata"); - artifacts.add("androidx.lifecycle:lifecycle-livedata-core"); - artifacts.add("androidx.lifecycle:lifecycle-reactivestreams"); - artifacts.add("androidx.lifecycle:lifecycle-runtime"); - artifacts.add("androidx.lifecycle:lifecycle-viewmodel"); - artifacts.add("androidx.paging:paging-common"); - artifacts.add("androidx.paging:paging-runtime"); - artifacts.add("androidx.paging:paging-rxjava2"); - artifacts.add("androidx.room:room-common"); - artifacts.add("androidx.room:room-compiler"); - artifacts.add("androidx.room:room-guava"); - artifacts.add("androidx.room:room-migration"); - artifacts.add("androidx.room:room-runtime"); - artifacts.add("androidx.room:room-rxjava2"); - artifacts.add("androidx.room:room-testing"); - artifacts.add("androidx.sqlite:sqlite"); - artifacts.add("androidx.sqlite:sqlite-framework"); - artifacts.add("androidx.constraintlayout:constraintlayout"); - artifacts.add("androidx.constraintlayout:constraintlayout-solver"); - artifacts.add("androidx.test.espresso.idling:idling-concurrent"); - artifacts.add("androidx.test.espresso.idling:idling-net"); - artifacts.add("androidx.test.espresso:espresso-accessibility"); - artifacts.add("androidx.test.espresso:espresso-contrib"); - artifacts.add("androidx.test.espresso:espresso-core"); - artifacts.add("androidx.test.espresso:espresso-idling-resource"); - artifacts.add("androidx.test.espresso:espresso-intents"); - artifacts.add("androidx.test.espresso:espresso-remote"); - artifacts.add("androidx.test.espresso:espresso-web"); - artifacts.add("androidx.test.jank:janktesthelper"); - artifacts.add("androidx.test:test-services"); - artifacts.add("androidx.test.uiautomator:uiautomator"); - artifacts.add("androidx.test:monitor"); - artifacts.add("androidx.test:orchestrator"); - artifacts.add("androidx.test:rules"); - artifacts.add("androidx.test:runner"); - artifacts.add("androidx.vectordrawable:vectordrawable-animated"); - artifacts.add("androidx.appcompat:appcompat"); - artifacts.add("androidx.asynclayoutinflater:asynclayoutinflater"); - artifacts.add("androidx.car:car"); - artifacts.add("androidx.cardview:cardview"); - artifacts.add("androidx.collection:collection"); - artifacts.add("androidx.coordinatorlayout:coordinatorlayout"); - artifacts.add("androidx.cursoradapter:cursoradapter"); - artifacts.add("androidx.browser:browser"); - artifacts.add("androidx.customview:customview"); - artifacts.add("com.google.android.material:material"); - artifacts.add("androidx.documentfile:documentfile"); - artifacts.add("androidx.drawerlayout:drawerlayout"); - artifacts.add("androidx.exifinterface:exifinterface"); - artifacts.add("androidx.gridlayout:gridlayout"); - artifacts.add("androidx.heifwriter:heifwriter"); - artifacts.add("androidx.interpolator:interpolator"); - artifacts.add("androidx.leanback:leanback"); - artifacts.add("androidx.loader:loader"); - artifacts.add("androidx.localbroadcastmanager:localbroadcastmanager"); - artifacts.add("androidx.media2:media2"); - artifacts.add("androidx.media2:media2-exoplayer"); - artifacts.add("androidx.mediarouter:mediarouter"); - artifacts.add("androidx.multidex:multidex"); - artifacts.add("androidx.multidex:multidex-instrumentation"); - artifacts.add("androidx.palette:palette"); - artifacts.add("androidx.percentlayout:percentlayout"); - artifacts.add("androidx.leanback:leanback-preference"); - artifacts.add("androidx.legacy:legacy-preference-v14"); - artifacts.add("androidx.preference:preference"); - artifacts.add("androidx.print:print"); - artifacts.add("androidx.recommendation:recommendation"); - artifacts.add("androidx.recyclerview:recyclerview-selection"); - artifacts.add("androidx.recyclerview:recyclerview"); - artifacts.add("androidx.slice:slice-builders"); - artifacts.add("androidx.slice:slice-core"); - artifacts.add("androidx.slice:slice-view"); - artifacts.add("androidx.slidingpanelayout:slidingpanelayout"); - artifacts.add("androidx.annotation:annotation"); - artifacts.add("androidx.core:core"); - artifacts.add("androidx.contentpager:contentpager"); - artifacts.add("androidx.legacy:legacy-support-core-ui"); - artifacts.add("androidx.legacy:legacy-support-core-utils"); - artifacts.add("androidx.dynamicanimation:dynamicanimation"); - artifacts.add("androidx.emoji:emoji"); - artifacts.add("androidx.emoji:emoji-appcompat"); - artifacts.add("androidx.emoji:emoji-bundled"); - artifacts.add("androidx.fragment:fragment"); - artifacts.add("androidx.media:media"); - artifacts.add("androidx.tvprovider:tvprovider"); - artifacts.add("androidx.legacy:legacy-support-v13"); - artifacts.add("androidx.legacy:legacy-support-v4"); - artifacts.add("androidx.vectordrawable:vectordrawable"); - artifacts.add("androidx.swiperefreshlayout:swiperefreshlayout"); - artifacts.add("androidx.textclassifier:textclassifier"); - artifacts.add("androidx.transition:transition"); - artifacts.add("androidx.versionedparcelable:versionedparcelable"); - artifacts.add("androidx.viewpager:viewpager"); - artifacts.add("androidx.wear:wear"); - artifacts.add("androidx.webkit:webkit"); + private static List createArtifacts() { + var artifacts = new ArrayList(); + artifacts.add("androidx.arch.core:core-common"); + artifacts.add("androidx.arch.core:core"); + artifacts.add("androidx.arch.core:core-testing"); + artifacts.add("androidx.arch.core:core-runtime"); + artifacts.add("androidx.lifecycle:lifecycle-common"); + artifacts.add("androidx.lifecycle:lifecycle-common-java8"); + artifacts.add("androidx.lifecycle:lifecycle-compiler"); + artifacts.add("androidx.lifecycle:lifecycle-extensions"); + artifacts.add("androidx.lifecycle:lifecycle-livedata"); + artifacts.add("androidx.lifecycle:lifecycle-livedata-core"); + artifacts.add("androidx.lifecycle:lifecycle-reactivestreams"); + artifacts.add("androidx.lifecycle:lifecycle-runtime"); + artifacts.add("androidx.lifecycle:lifecycle-viewmodel"); + artifacts.add("androidx.paging:paging-common"); + artifacts.add("androidx.paging:paging-runtime"); + artifacts.add("androidx.paging:paging-rxjava2"); + artifacts.add("androidx.room:room-common"); + artifacts.add("androidx.room:room-compiler"); + artifacts.add("androidx.room:room-guava"); + artifacts.add("androidx.room:room-migration"); + artifacts.add("androidx.room:room-runtime"); + artifacts.add("androidx.room:room-rxjava2"); + artifacts.add("androidx.room:room-testing"); + artifacts.add("androidx.sqlite:sqlite"); + artifacts.add("androidx.sqlite:sqlite-framework"); + artifacts.add("androidx.constraintlayout:constraintlayout"); + artifacts.add("androidx.constraintlayout:constraintlayout-solver"); + artifacts.add("androidx.test.espresso.idling:idling-concurrent"); + artifacts.add("androidx.test.espresso.idling:idling-net"); + artifacts.add("androidx.test.espresso:espresso-accessibility"); + artifacts.add("androidx.test.espresso:espresso-contrib"); + artifacts.add("androidx.test.espresso:espresso-core"); + artifacts.add("androidx.test.espresso:espresso-idling-resource"); + artifacts.add("androidx.test.espresso:espresso-intents"); + artifacts.add("androidx.test.espresso:espresso-remote"); + artifacts.add("androidx.test.espresso:espresso-web"); + artifacts.add("androidx.test.jank:janktesthelper"); + artifacts.add("androidx.test:test-services"); + artifacts.add("androidx.test.uiautomator:uiautomator"); + artifacts.add("androidx.test:monitor"); + artifacts.add("androidx.test:orchestrator"); + artifacts.add("androidx.test:rules"); + artifacts.add("androidx.test:runner"); + artifacts.add("androidx.vectordrawable:vectordrawable-animated"); + artifacts.add("androidx.appcompat:appcompat"); + artifacts.add("androidx.asynclayoutinflater:asynclayoutinflater"); + artifacts.add("androidx.car:car"); + artifacts.add("androidx.cardview:cardview"); + artifacts.add("androidx.collection:collection"); + artifacts.add("androidx.coordinatorlayout:coordinatorlayout"); + artifacts.add("androidx.cursoradapter:cursoradapter"); + artifacts.add("androidx.browser:browser"); + artifacts.add("androidx.customview:customview"); + artifacts.add("com.google.android.material:material"); + artifacts.add("androidx.documentfile:documentfile"); + artifacts.add("androidx.drawerlayout:drawerlayout"); + artifacts.add("androidx.exifinterface:exifinterface"); + artifacts.add("androidx.gridlayout:gridlayout"); + artifacts.add("androidx.heifwriter:heifwriter"); + artifacts.add("androidx.interpolator:interpolator"); + artifacts.add("androidx.leanback:leanback"); + artifacts.add("androidx.loader:loader"); + artifacts.add("androidx.localbroadcastmanager:localbroadcastmanager"); + artifacts.add("androidx.media2:media2"); + artifacts.add("androidx.media2:media2-exoplayer"); + artifacts.add("androidx.mediarouter:mediarouter"); + artifacts.add("androidx.multidex:multidex"); + artifacts.add("androidx.multidex:multidex-instrumentation"); + artifacts.add("androidx.palette:palette"); + artifacts.add("androidx.percentlayout:percentlayout"); + artifacts.add("androidx.leanback:leanback-preference"); + artifacts.add("androidx.legacy:legacy-preference-v14"); + artifacts.add("androidx.preference:preference"); + artifacts.add("androidx.print:print"); + artifacts.add("androidx.recommendation:recommendation"); + artifacts.add("androidx.recyclerview:recyclerview-selection"); + artifacts.add("androidx.recyclerview:recyclerview"); + artifacts.add("androidx.slice:slice-builders"); + artifacts.add("androidx.slice:slice-core"); + artifacts.add("androidx.slice:slice-view"); + artifacts.add("androidx.slidingpanelayout:slidingpanelayout"); + artifacts.add("androidx.annotation:annotation"); + artifacts.add("androidx.core:core"); + artifacts.add("androidx.contentpager:contentpager"); + artifacts.add("androidx.legacy:legacy-support-core-ui"); + artifacts.add("androidx.legacy:legacy-support-core-utils"); + artifacts.add("androidx.dynamicanimation:dynamicanimation"); + artifacts.add("androidx.emoji:emoji"); + artifacts.add("androidx.emoji:emoji-appcompat"); + artifacts.add("androidx.emoji:emoji-bundled"); + artifacts.add("androidx.fragment:fragment"); + artifacts.add("androidx.media:media"); + artifacts.add("androidx.tvprovider:tvprovider"); + artifacts.add("androidx.legacy:legacy-support-v13"); + artifacts.add("androidx.legacy:legacy-support-v4"); + artifacts.add("androidx.vectordrawable:vectordrawable"); + artifacts.add("androidx.swiperefreshlayout:swiperefreshlayout"); + artifacts.add("androidx.textclassifier:textclassifier"); + artifacts.add("androidx.transition:transition"); + artifacts.add("androidx.versionedparcelable:versionedparcelable"); + artifacts.add("androidx.viewpager:viewpager"); + artifacts.add("androidx.wear:wear"); + artifacts.add("androidx.webkit:webkit"); - artifacts.add("fileTree(dir: 'libs', include: ['*.jar'])"); - return artifacts; - } + artifacts.add("fileTree(dir: 'libs', include: ['*.jar'])"); + return artifacts; + } - private static List createConfigs() { - var configs = new ArrayList(); - configs.add("androidApis"); - configs.add("androidTestAnnotationProcessor"); - configs.add("androidTestApi"); - configs.add("androidTestApk"); - configs.add("androidTestCompile"); - configs.add("androidTestCompileOnly"); - configs.add("androidTestDebugAnnotationProcessor"); - configs.add("androidTestDebugApi"); - configs.add("androidTestDebugApk"); - configs.add("androidTestDebugCompile"); - configs.add("androidTestDebugCompileOnly"); - configs.add("androidTestDebugImplementation"); - configs.add("androidTestDebugProvided"); - configs.add("androidTestDebugRuntimeOnly"); - configs.add("androidTestDebugWearApp"); - configs.add("androidTestImplementation"); - configs.add("androidTestProvided"); - configs.add("androidTestRuntimeOnly"); - configs.add("androidTestUtil"); - configs.add("androidTestWearApp"); - configs.add("annotationProcessor"); - configs.add("api"); - configs.add("apk"); - configs.add("archives"); - configs.add("compile"); - configs.add("compileOnly"); - configs.add("coreLibraryDesugaring"); - configs.add("debugAnnotationProcessor"); - configs.add("debugApi"); - configs.add("debugApk"); - configs.add("debugCompile"); - configs.add("debugCompileOnly"); - configs.add("debugImplementation"); - configs.add("debugProvided"); - configs.add("debugRuntimeOnly"); - configs.add("debugWearApp"); - configs.add("default"); - configs.add("implementation"); - configs.add("implementation platform()"); - configs.add("lintChecks"); - configs.add("lintClassPath"); - configs.add("lintPublish"); - configs.add("provided"); - configs.add("releaseAnnotationProcessor"); - configs.add("releaseApi"); - configs.add("releaseApk"); - configs.add("releaseCompile"); - configs.add("releaseCompileOnly"); - configs.add("releaseImplementation"); - configs.add("releaseProvided"); - configs.add("releaseRuntimeOnly"); - configs.add("releaseWearApp"); - configs.add("runtimeOnly"); - configs.add("testAnnotationProcessor"); - configs.add("testApi"); - configs.add("testApk"); - configs.add("testCompile"); - configs.add("testCompileOnly"); - configs.add("testDebugAnnotationProcessor"); - configs.add("testDebugApi"); - configs.add("testDebugApk"); - configs.add("testDebugCompile"); - configs.add("testDebugCompileOnly"); - configs.add("testDebugImplementation"); - configs.add("testDebugProvided"); - configs.add("testDebugRuntimeOnly"); - configs.add("testDebugWearApp"); - configs.add("testImplementation"); - configs.add("testProvided"); - configs.add("testReleaseAnnotationProcessor"); - configs.add("testReleaseApi"); - configs.add("testReleaseApk"); - configs.add("testReleaseCompile"); - configs.add("testReleaseCompileOnly"); - configs.add("testReleaseImplementation"); - configs.add("testReleaseProvided"); - configs.add("testReleaseRuntimeOnly"); - configs.add("testReleaseWearApp"); - configs.add("testRuntimeOnly"); - configs.add("testWearApp"); - configs.add("wearApp"); - return configs; - } + private static List createConfigs() { + var configs = new ArrayList(); + configs.add("androidApis"); + configs.add("androidTestAnnotationProcessor"); + configs.add("androidTestApi"); + configs.add("androidTestApk"); + configs.add("androidTestCompile"); + configs.add("androidTestCompileOnly"); + configs.add("androidTestDebugAnnotationProcessor"); + configs.add("androidTestDebugApi"); + configs.add("androidTestDebugApk"); + configs.add("androidTestDebugCompile"); + configs.add("androidTestDebugCompileOnly"); + configs.add("androidTestDebugImplementation"); + configs.add("androidTestDebugProvided"); + configs.add("androidTestDebugRuntimeOnly"); + configs.add("androidTestDebugWearApp"); + configs.add("androidTestImplementation"); + configs.add("androidTestProvided"); + configs.add("androidTestRuntimeOnly"); + configs.add("androidTestUtil"); + configs.add("androidTestWearApp"); + configs.add("annotationProcessor"); + configs.add("api"); + configs.add("apk"); + configs.add("archives"); + configs.add("compile"); + configs.add("compileOnly"); + configs.add("coreLibraryDesugaring"); + configs.add("debugAnnotationProcessor"); + configs.add("debugApi"); + configs.add("debugApk"); + configs.add("debugCompile"); + configs.add("debugCompileOnly"); + configs.add("debugImplementation"); + configs.add("debugProvided"); + configs.add("debugRuntimeOnly"); + configs.add("debugWearApp"); + configs.add("default"); + configs.add("implementation"); + configs.add("implementation platform()"); + configs.add("lintChecks"); + configs.add("lintClassPath"); + configs.add("lintPublish"); + configs.add("provided"); + configs.add("releaseAnnotationProcessor"); + configs.add("releaseApi"); + configs.add("releaseApk"); + configs.add("releaseCompile"); + configs.add("releaseCompileOnly"); + configs.add("releaseImplementation"); + configs.add("releaseProvided"); + configs.add("releaseRuntimeOnly"); + configs.add("releaseWearApp"); + configs.add("runtimeOnly"); + configs.add("testAnnotationProcessor"); + configs.add("testApi"); + configs.add("testApk"); + configs.add("testCompile"); + configs.add("testCompileOnly"); + configs.add("testDebugAnnotationProcessor"); + configs.add("testDebugApi"); + configs.add("testDebugApk"); + configs.add("testDebugCompile"); + configs.add("testDebugCompileOnly"); + configs.add("testDebugImplementation"); + configs.add("testDebugProvided"); + configs.add("testDebugRuntimeOnly"); + configs.add("testDebugWearApp"); + configs.add("testImplementation"); + configs.add("testProvided"); + configs.add("testReleaseAnnotationProcessor"); + configs.add("testReleaseApi"); + configs.add("testReleaseApk"); + configs.add("testReleaseCompile"); + configs.add("testReleaseCompileOnly"); + configs.add("testReleaseImplementation"); + configs.add("testReleaseProvided"); + configs.add("testReleaseRuntimeOnly"); + configs.add("testReleaseWearApp"); + configs.add("testRuntimeOnly"); + configs.add("testWearApp"); + configs.add("wearApp"); + return configs; + } - private static List createOtherCompletions() { - var others = new ArrayList(); + private static List createOtherCompletions() { + var others = new ArrayList(); - // Common words that user may use - others.add("android"); - others.add("applicationVariants"); - others.add("compileSdkVersion"); - others.add("buildToolsVersion"); - others.add("defaultConfig"); - others.add("applicationId"); - others.add("minSdkVersion"); - others.add("targetSdkVersion"); - others.add("versionCode"); - others.add("versionName"); - others.add("multiDexEnabled"); - others.add("vectorDrawables.useSupportLibrary"); - others.add("compileOptions"); - others.add("coreLibraryDesugaringEnabled"); - others.add("sourceCompatibility"); - others.add("targetCompatibility"); - others.add("JavaVersion.VERSION_1_8"); - others.add("JavaVersion.VERSION_1_7"); + // Common words that user may use + others.add("android"); + others.add("applicationVariants"); + others.add("compileSdkVersion"); + others.add("buildToolsVersion"); + others.add("defaultConfig"); + others.add("applicationId"); + others.add("minSdkVersion"); + others.add("targetSdkVersion"); + others.add("versionCode"); + others.add("versionName"); + others.add("multiDexEnabled"); + others.add("vectorDrawables.useSupportLibrary"); + others.add("compileOptions"); + others.add("coreLibraryDesugaringEnabled"); + others.add("sourceCompatibility"); + others.add("targetCompatibility"); + others.add("JavaVersion.VERSION_1_8"); + others.add("JavaVersion.VERSION_1_7"); - // Lint options - others.add("lintOptions"); - others.add("quiet"); - others.add("abortOnError"); - others.add("checkReleaseBuilds"); - others.add("ignoreWarnings"); - others.add("checkAllWarnings"); - others.add("warningsAsErrors"); - others.add("disable"); - others.add("enable"); - others.add("check"); - others.add("noLines"); - others.add("showAll"); - others.add("explainIssues"); - others.add("lintConfig"); - others.add("textReport"); - others.add("textOutput"); - others.add("xmlReport"); - others.add("xmlOutput"); - others.add("htmlReport"); - others.add("htmlOutput"); - others.add("fatal"); - others.add("error"); - others.add("warning"); - others.add("ignore"); - others.add("informational"); - others.add("baseline"); - others.add("checkTestSources"); - others.add("ignoreTestSources"); - others.add("checkGeneratedSources"); - others.add("checkDependencies"); + // Lint options + others.add("lintOptions"); + others.add("quiet"); + others.add("abortOnError"); + others.add("checkReleaseBuilds"); + others.add("ignoreWarnings"); + others.add("checkAllWarnings"); + others.add("warningsAsErrors"); + others.add("disable"); + others.add("enable"); + others.add("check"); + others.add("noLines"); + others.add("showAll"); + others.add("explainIssues"); + others.add("lintConfig"); + others.add("textReport"); + others.add("textOutput"); + others.add("xmlReport"); + others.add("xmlOutput"); + others.add("htmlReport"); + others.add("htmlOutput"); + others.add("fatal"); + others.add("error"); + others.add("warning"); + others.add("ignore"); + others.add("informational"); + others.add("baseline"); + others.add("checkTestSources"); + others.add("ignoreTestSources"); + others.add("checkGeneratedSources"); + others.add("checkDependencies"); - // buildFeatures options - others.add("buildFeatures"); - others.add("aidl"); - others.add("buildConfig"); - others.add("compose"); - others.add("prefab"); - others.add("renderScript"); - others.add("resValues"); - others.add("shaders"); - others.add("viewBinding"); + // buildFeatures options + others.add("buildFeatures"); + others.add("aidl"); + others.add("buildConfig"); + others.add("compose"); + others.add("prefab"); + others.add("renderScript"); + others.add("resValues"); + others.add("shaders"); + others.add("viewBinding"); - // Signing configs and all - others.add("signingConfigs"); - others.add("debug"); - others.add("storeFile"); - others.add("storePassword"); - others.add("keyAlias"); - others.add("keyPassword"); - others.add("release"); - others.add("storeFile"); - others.add("storePassword"); - others.add("keyAlias"); - others.add("keyPassword"); - others.add("buildTypes"); - others.add("debug"); - others.add("minifyEnabled"); - others.add("proguardFiles"); - others.add("signingConfig"); - others.add("stringfog.enable"); - others.add("release"); - others.add("minifyEnabled"); - others.add("proguardFiles"); - others.add("signingConfig"); - others.add("stringfog.enable"); - others.add("packagingOptions"); - others.add("exclude"); + // Signing configs and all + others.add("signingConfigs"); + others.add("debug"); + others.add("storeFile"); + others.add("storePassword"); + others.add("keyAlias"); + others.add("keyPassword"); + others.add("release"); + others.add("storeFile"); + others.add("storePassword"); + others.add("keyAlias"); + others.add("keyPassword"); + others.add("buildTypes"); + others.add("debug"); + others.add("minifyEnabled"); + others.add("proguardFiles"); + others.add("signingConfig"); + others.add("stringfog.enable"); + others.add("release"); + others.add("minifyEnabled"); + others.add("proguardFiles"); + others.add("signingConfig"); + others.add("stringfog.enable"); + others.add("packagingOptions"); + others.add("exclude"); - // Maybe used in root project's build.gradle - others.add("buildscript"); - others.add("ext"); - others.add("project.ext"); - others.add("repositories"); - others.add("maven"); - others.add("url"); - others.add("google()"); - others.add("mavenLocal()"); - others.add("mavenCentral()"); - others.add("jcenter()"); - others.add("classpath"); - others.add("allprojects"); - others.add("subprojects"); - return others; - } + // Maybe used in root project's build.gradle + others.add("buildscript"); + others.add("ext"); + others.add("project.ext"); + others.add("repositories"); + others.add("maven"); + others.add("url"); + others.add("google()"); + others.add("mavenLocal()"); + others.add("mavenCentral()"); + others.add("jcenter()"); + others.add("classpath"); + others.add("allprojects"); + others.add("subprojects"); + return others; + } - public void complete( - ContentReference content, - CharPosition position, - CompletionPublisher publisher, - Bundle extraArguments) { - publisher.setUpdateThreshold(0); - final var prefix = - CompletionHelper.computePrefix(content, position, - c -> MyCharacter.isJavaIdentifierPart(c) || c == '.'); - if (StringUtils.isTrimEmpty(prefix)) { - return; - } - for (String artifact : ANDROIDX_ARTIFACTS) { - if (!artifact.toLowerCase(Locale.ROOT).startsWith(prefix.toLowerCase(Locale.ROOT))) { - continue; - } - final var completionItem = createCompletionItem(artifact); - completionItem.setMatchLevel(CompletionItem.matchLevel(artifact, prefix)); - publisher.addItem(completionItem); - } + public void complete( + ContentReference content, + CharPosition position, + CompletionPublisher publisher, + Bundle extraArguments) { + publisher.setUpdateThreshold(0); + final var prefix = CompletionHelper.computePrefix(content, position, + c -> MyCharacter.isJavaIdentifierPart(c) || c == '.'); + if (prefix == null || prefix.toString().trim().isEmpty()) { + return; + } + for (String artifact : ANDROIDX_ARTIFACTS) { + if (!artifact.toLowerCase(Locale.ROOT).startsWith(prefix.toLowerCase(Locale.ROOT))) { + continue; + } + final var completionItem = createCompletionItem(artifact); + completionItem.setMatchLevel(CompletionItem.matchLevel(artifact, prefix)); + publisher.addItem(completionItem); + } - for (String config : CONFIGURATIONS) { - if (!config.toLowerCase(Locale.ROOT).startsWith(prefix.toLowerCase(Locale.ROOT))) { - continue; - } - final var completionItem = createCompletionItem(config); - completionItem.setMatchLevel(CompletionItem.matchLevel(config, prefix)); - publisher.addItem(completionItem); - } + for (String config : CONFIGURATIONS) { + if (!config.toLowerCase(Locale.ROOT).startsWith(prefix.toLowerCase(Locale.ROOT))) { + continue; + } + final var completionItem = createCompletionItem(config); + completionItem.setMatchLevel(CompletionItem.matchLevel(config, prefix)); + publisher.addItem(completionItem); + } - for (String other : OTHERS) { - if (!other.toLowerCase(Locale.ROOT).startsWith(prefix.toLowerCase(Locale.ROOT))) { - continue; - } - final var completionItem = createCompletionItem(other); - completionItem.setMatchLevel(CompletionItem.matchLevel(other, prefix)); - publisher.addItem(completionItem); - } - } + for (String other : OTHERS) { + if (!other.toLowerCase(Locale.ROOT).startsWith(prefix.toLowerCase(Locale.ROOT))) { + continue; + } + final var completionItem = createCompletionItem(other); + completionItem.setMatchLevel(CompletionItem.matchLevel(other, prefix)); + publisher.addItem(completionItem); + } + } - private CompletionItem createCompletionItem(String itemLabel) { - CompletionItem item = new CompletionItem(); - item.setIdeLabel(itemLabel); - item.setDetail(""); - item.setInsertText(itemLabel); - item.setIdeSortText("0" + itemLabel); - item.setInsertTextFormat(InsertTextFormat.PLAIN_TEXT); - item.setCompletionKind(CompletionItemKind.NONE); - return item; - } + private CompletionItem createCompletionItem(String itemLabel) { + CompletionItem item = new CompletionItem(); + item.setIdeLabel(itemLabel); + item.setDetail(""); + item.setInsertText(itemLabel); + item.setIdeSortText("0" + itemLabel); + item.setInsertTextFormat(InsertTextFormat.PLAIN_TEXT); + item.setCompletionKind(CompletionItemKind.NONE); + return item; + } } diff --git a/editor/src/main/java/com/itsaky/androidide/editor/language/incremental/BaseIncrementalAnalyzeManager.java b/editor/src/main/java/com/itsaky/androidide/editor/language/incremental/BaseIncrementalAnalyzeManager.java index bf28601596..b893f3af8c 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/language/incremental/BaseIncrementalAnalyzeManager.java +++ b/editor/src/main/java/com/itsaky/androidide/editor/language/incremental/BaseIncrementalAnalyzeManager.java @@ -22,7 +22,6 @@ import static com.itsaky.androidide.syntax.colorschemes.SchemeAndroidIDE.withoutCompletion; import androidx.annotation.NonNull; -import com.blankj.utilcode.util.ArrayUtils; import com.google.common.base.Preconditions; import com.google.common.collect.EvictingQueue; import com.itsaky.androidide.syntax.colorschemes.SchemeAndroidIDE; @@ -39,6 +38,7 @@ import java.util.Objects; import java.util.Stack; import java.util.stream.Collectors; +import java.util.stream.IntStream; import kotlin.Pair; import org.antlr.v4.runtime.CharStream; import org.antlr.v4.runtime.CharStreams; @@ -51,411 +51,412 @@ * @author Akash Yadav */ public abstract class BaseIncrementalAnalyzeManager extends - AsyncIncrementalAnalyzeManager { - - protected final Lexer lexer; - private final int[] multilineStartTypes; - private final int[] multilineEndTypes; - private final int[] blockTokens; - - public BaseIncrementalAnalyzeManager(final Class lexer) { - Objects.requireNonNull(lexer, "Cannot create analyzer manager for null lexer"); - this.lexer = createLexerInstance(lexer); - - var multilineTokenTypes = getMultilineTokenStartEndTypes(); - verifyMultilineTypes(multilineTokenTypes); - - this.multilineStartTypes = multilineTokenTypes[0]; - this.multilineEndTypes = multilineTokenTypes[1]; - - var tokens = getCodeBlockTokens(); - if (tokens == null) { - tokens = new int[0]; - } - - this.blockTokens = tokens; - } - - @NonNull - private Lexer createLexerInstance(final Class lexer) { - try { - final var constructor = lexer.getConstructor(CharStream.class); - if (!constructor.isAccessible()) { - constructor.setAccessible(true); - } - return constructor.newInstance(createStream("")); - } catch (Throwable err) { - throw new RuntimeException("Unable to create Lexer instance", err); - } - } - - @NonNull - protected CharStream createStream(@NonNull CharSequence source) { - Objects.requireNonNull(source); - try { - return CharStreams.fromReader(new CharSequenceReader(source)); - } catch (IOException e) { - throw new RuntimeException("Cannot create CharStream for source", e); - } - } - - private void verifyMultilineTypes(@NonNull final int[][] types) { - Preconditions.checkState(types.length == 2, - "There must be exact two inner int[] in multiline token types"); - - final var start = types[0]; - final var end = types[1]; - Preconditions.checkState(start.length > 0, "Invalid start token types"); - Preconditions.checkState(end.length > 0, "Invalid end token types"); - } - - /** - * Returns the token types which start and end a multiline token. - * - * @return A 2xn matrix where int[] at index 0 specifies token types which start a - * multiline token and int[] 1 specifies tokens which end the multiline token. For example, - *

[['/', '*'], ['*', '/']]. - *

But instead of characters, there must be token types. - */ - protected abstract int[][] getMultilineTokenStartEndTypes(); - - /** - * Get the token types for left and right braces. - * - * @return An array of left and right brace token types. - */ - protected abstract int[] getCodeBlockTokens(); - - @Override - public LineState getInitialState() { - return new LineState(); - } - - @Override - public boolean stateEquals(final LineState state, final LineState another) { - return state.equals(another); - } - - @Override - public LineTokenizeResult tokenizeLine(final CharSequence lineText, - final LineState state, - final int line - ) { - final var tokens = new ArrayList(); - var newState = 0; - var stateObj = new LineState(); - if (state.state == LineState.NORMAL) { - newState = tokenizeNormal(lineText, 0, tokens, stateObj, state.lexerMode); - } else if (state.state == LineState.INCOMPLETE) { - final var result = fillIncomplete(lineText, tokens, state.lexerMode); - newState = IntPair.getFirst(result); - if (newState == LineState.NORMAL) { - newState = tokenizeNormal(lineText, IntPair.getSecond(result), tokens, stateObj, - state.lexerMode); - } else { - newState = LineState.INCOMPLETE; - } - } - stateObj.state = newState; - stateObj.lexerMode = lexer._mode; - return new LineTokenizeResult<>(stateObj, tokens); - } - - @Override - public List generateSpansForLine( - final LineTokenizeResult tokens - ) { - var result = generateSpans(tokens); - - Objects.requireNonNull(result); - - if (result.isEmpty()) { - result.add(Span.obtain(0, TextStyle.makeStyle(SchemeAndroidIDE.TEXT_NORMAL))); - } - - return result; - } - - /** - * Generate spans for the given {@link LineTokenizeResult}. - * - * @param tokens The tokenization result. - * @return The spans for the tokens. - */ - protected abstract List generateSpans( - final LineTokenizeResult tokens - ); - - @Override - public List computeBlocks(final Content text, - final AsyncIncrementalAnalyzeManager.CodeBlockAnalyzeDelegate delegate - ) { - final var stack = new Stack(); - final var blocks = new ArrayList(); - var line = 0; - var maxSwitch = 0; - var currSwitch = 0; - while (delegate.isNotCancelled() && line < text.getLineCount()) { - final var tokens = getState(line); - final var checkForIdentifiers = tokens.state.state == LineState.NORMAL || - (tokens.state.state == LineState.INCOMPLETE && tokens.tokens.size() > 1); - if (!tokens.state.hasBraces && !checkForIdentifiers) { - line++; - continue; - } - - for (int i = 0; i < tokens.tokens.size(); i++) { - final var token = tokens.tokens.get(i); - var offset = token.getStartIndex(); - if (isCodeBlockStart(token)) { - if (stack.isEmpty()) { - if (currSwitch > maxSwitch) { - maxSwitch = currSwitch; - } - currSwitch = 0; - } - currSwitch++; - CodeBlock block = new CodeBlock(); - block.startLine = line; - block.startColumn = offset; - stack.push(block); - } else if (isCodeBlockEnd(token)) { - if (!stack.isEmpty()) { - CodeBlock block = stack.pop(); - block.endLine = line; - block.endColumn = offset; - if (block.startLine != block.endLine) { - blocks.add(block); - } - } - } - } - - line++; - } - blocks.sort(CodeBlock.COMPARATOR_END); - return blocks; - } - - protected boolean isCodeBlockStart(IncrementalToken token) { - return blockTokens.length == 2 && token.getType() == blockTokens[0]; - } - - protected boolean isCodeBlockEnd(IncrementalToken token) { - return blockTokens.length == 2 && token.getType() == blockTokens[1]; - } - - @SuppressWarnings("UnstableApiUsage") - protected boolean isIncompleteTokenStart(EvictingQueue q) { - return matchTokenTypes(this.multilineStartTypes, q); - } - - @SuppressWarnings("UnstableApiUsage") - protected boolean isIncompleteTokenEnd(EvictingQueue q) { - return matchTokenTypes(this.multilineEndTypes, q); - } - - /** - * Called when the analyzer finds an incomplete token in a lne. - * - * @param token The incomplete token. - */ - protected abstract void handleIncompleteToken(IncrementalToken token); - - /** - * Gets the next token from lexer and return an {@link IncrementalToken}. - * - * @return The next {@link IncrementalToken}. - */ - protected IncrementalToken nextToken() { - return new IncrementalToken(lexer.nextToken()); - } - - /** - * Pop (remove) required number of tokens after an incomplete token has been encountered.
- * - *

For example:
- * When ['/', '*'] tokens are encountered, the '*' token must be removed. In this case, - * incompleteToken parameter will point to '/' token. - * - *

By default, this method removes only the last token. - * - * @param incompleteToken The token which is the start of the incomplete token. - * @param tokens The list of tokens from which extra tokens must be removed. - */ - protected void popTokensAfterIncomplete(@NonNull IncrementalToken incompleteToken, - @NonNull List tokens - ) { - tokens.remove(tokens.size() - 1); - } - - protected void handleLineCommentSpan(@NonNull IncrementalToken token, @NonNull List spans, - int offset - ) { - var commentType = SchemeAndroidIDE.COMMENT; - - // highlight special line comments - var commentText = token.getText(); - if (commentText.length() > 2) { - commentText = commentText.substring(2); - commentText = commentText.trim(); - if ("todo".equalsIgnoreCase(commentText.substring(0, 4))) { - commentType = TODO_COMMENT; - } else if ("fixme".equalsIgnoreCase(commentText.substring(0, 5))) { - commentType = FIXME_COMMENT; - } - } - spans.add(Span.obtain(offset, withoutCompletion(commentType))); - } - - /** - * Called when the state in {@link #tokenizeLine(CharSequence, LineState, int)} is - * {@link LineState#NORMAL}. - * - * @param line The line source. - * @param column The column in line. - * @param tokens The list of tokens that must be updated as the line is scanned. - * @param st The state object whose state must be after after the line has been - * scanned. - * @return The new state. - */ - @SuppressWarnings("UnstableApiUsage") - protected int tokenizeNormal(final CharSequence line, final int column, - final List tokens, final LineState st, - final int lexerMode - ) { - lexer.setInputStream(createStream(line)); - if (lexer._mode != lexerMode) { - lexer.pushMode(lexerMode); - } - final var queues = createEvictingQueueForTokens(); - final var start = queues.getFirst(); - final var end = queues.getSecond(); - var isInIncompleteToken = false; - var state = LineState.NORMAL; - IncrementalToken token; - IncrementalToken incompleteToken = null; - - while ((token = nextToken()) != null) { - if (token.getType() == IncrementalToken.EOF) { - break; - } - - // Skip to the token just after 'column' - if (token.getStartIndex() < column) { - continue; - } - - if (!isInIncompleteToken) { - if (token.getStartIndex() == column && !tokens.isEmpty()) { - token.type = tokens.get(tokens.size() - 1).getType(); - } - - tokens.add(token); - } - start.add(token); - end.add(token); - final var type = token.getType(); - if (ArrayUtils.contains(getCodeBlockTokens(), type)) { - st.hasBraces = true; - } - - if (start.remainingCapacity() == 0 && isIncompleteTokenStart(start)) { - isInIncompleteToken = true; - incompleteToken = start.poll(); - - // Pop extra tokens from the list. - popTokensAfterIncomplete(Objects.requireNonNull(incompleteToken), tokens); - } else if (end.remainingCapacity() == 0 && isIncompleteTokenEnd(end)) { - // This should most probably not happen because, if a comment starts and ends on the same - // line, the lexer will create a token for the whole comment - // But still we handle this case... - isInIncompleteToken = false; - incompleteToken = null; - } - - if (isInIncompleteToken) { - state = LineState.INCOMPLETE; - } - } - - if (incompleteToken != null) { - incompleteToken.incomplete = true; - handleIncompleteToken(incompleteToken); - } - - return state; - } - - /** - * Called when the state in {@link #tokenizeLine(CharSequence, LineState, int)} is - * {@link LineState#INCOMPLETE}. - * - * @param line The line source. - * @param tokens The list of tokens that must be updated as the line is scanned. - * @return The state and offset. - */ - @SuppressWarnings("UnstableApiUsage") - protected long fillIncomplete(CharSequence line, final List tokens, - final int lexerMode - ) { - lexer.setInputStream(createStream(line)); - if (lexer._mode != lexerMode) { - lexer.pushMode(lexerMode); - } - final var queue = createEvictingQueueForTokens(); - final var end = queue.getSecond(); - final var allTokens = lexer.getAllTokens() - .stream() - .map(IncrementalToken::new) - .collect(Collectors.toList()); - if (allTokens.isEmpty()) { - return IntPair.pack(LineState.INCOMPLETE, 0); - } - var completed = false; - var index = 0; - for (index = 0; index < allTokens.size(); index++) { - final IncrementalToken token = allTokens.get(index); - if (token.getType() == Token.EOF) { - break; - } - - end.add(token); - if (end.remainingCapacity() == 0 && isIncompleteTokenEnd(end)) { - completed = true; - break; - } - } - - final var first = allTokens.get(0); - final int offset = allTokens.get(completed ? index : index - 1).getStartIndex(); - first.startIndex = 0; - handleIncompleteToken(first); - tokens.add(first); - if (completed) { - return IntPair.pack(LineState.NORMAL, offset); - } else { - return IntPair.pack(LineState.INCOMPLETE, offset); - } - } - - @SuppressWarnings("UnstableApiUsage") - @NonNull - private Pair, EvictingQueue> createEvictingQueueForTokens() { - return new Pair<>(EvictingQueue.create(multilineStartTypes.length), - EvictingQueue.create(multilineEndTypes.length)); - } - - @SuppressWarnings("UnstableApiUsage") - private boolean matchTokenTypes(@NonNull int[] types, - @NonNull EvictingQueue tokens - ) { - final var arr = tokens.toArray(new IncrementalToken[0]); - for (int i = 0; i < types.length; i++) { - if (types[i] != arr[i].getType()) { - return false; - } - } - return true; - } + AsyncIncrementalAnalyzeManager { + + protected final Lexer lexer; + private final int[] multilineStartTypes; + private final int[] multilineEndTypes; + private final int[] blockTokens; + + public BaseIncrementalAnalyzeManager(final Class lexer) { + Objects.requireNonNull(lexer, "Cannot create analyzer manager for null lexer"); + this.lexer = createLexerInstance(lexer); + + var multilineTokenTypes = getMultilineTokenStartEndTypes(); + verifyMultilineTypes(multilineTokenTypes); + + this.multilineStartTypes = multilineTokenTypes[0]; + this.multilineEndTypes = multilineTokenTypes[1]; + + var tokens = getCodeBlockTokens(); + if (tokens == null) { + tokens = new int[0]; + } + + this.blockTokens = tokens; + } + + @Override + public List computeBlocks(final Content text, + final AsyncIncrementalAnalyzeManager.CodeBlockAnalyzeDelegate delegate) { + final var stack = new Stack(); + final var blocks = new ArrayList(); + var line = 0; + var maxSwitch = 0; + var currSwitch = 0; + while (delegate.isNotCancelled() && line < text.getLineCount()) { + final var tokens = getState(line); + final var checkForIdentifiers = tokens.state.state == LineState.NORMAL || + (tokens.state.state == LineState.INCOMPLETE && tokens.tokens.size() > 1); + if (!tokens.state.hasBraces && !checkForIdentifiers) { + line++; + continue; + } + + for (int i = 0; i < tokens.tokens.size(); i++) { + final var token = tokens.tokens.get(i); + var offset = token.getStartIndex(); + if (isCodeBlockStart(token)) { + if (stack.isEmpty()) { + if (currSwitch > maxSwitch) { + maxSwitch = currSwitch; + } + currSwitch = 0; + } + currSwitch++; + CodeBlock block = new CodeBlock(); + block.startLine = line; + block.startColumn = offset; + stack.push(block); + } else if (isCodeBlockEnd(token)) { + if (!stack.isEmpty()) { + CodeBlock block = stack.pop(); + block.endLine = line; + block.endColumn = offset; + if (block.startLine != block.endLine) { + blocks.add(block); + } + } + } + } + + line++; + } + blocks.sort(CodeBlock.COMPARATOR_END); + return blocks; + } + + @Override + public List generateSpansForLine( + final LineTokenizeResult tokens) { + var result = generateSpans(tokens); + + Objects.requireNonNull(result); + + if (result.isEmpty()) { + result.add(Span.obtain(0, TextStyle.makeStyle(SchemeAndroidIDE.TEXT_NORMAL))); + } + + return result; + } + + @Override + public LineState getInitialState() { + return new LineState(); + } + + @Override + public boolean stateEquals(final LineState state, final LineState another) { + return state.equals(another); + } + + @Override + public LineTokenizeResult tokenizeLine(final CharSequence lineText, + final LineState state, + final int line) { + final var tokens = new ArrayList(); + var newState = 0; + var stateObj = new LineState(); + if (state.state == LineState.NORMAL) { + newState = tokenizeNormal(lineText, 0, tokens, stateObj, state.lexerMode); + } else if (state.state == LineState.INCOMPLETE) { + final var result = fillIncomplete(lineText, tokens, state.lexerMode); + newState = IntPair.getFirst(result); + if (newState == LineState.NORMAL) { + newState = tokenizeNormal(lineText, IntPair.getSecond(result), tokens, stateObj, + state.lexerMode); + } else { + newState = LineState.INCOMPLETE; + } + } + stateObj.state = newState; + stateObj.lexerMode = lexer._mode; + return new LineTokenizeResult<>(stateObj, tokens); + } + + @NonNull + protected CharStream createStream(@NonNull CharSequence source) { + Objects.requireNonNull(source); + try { + return CharStreams.fromReader(new CharSequenceReader(source)); + } catch (IOException e) { + throw new RuntimeException("Cannot create CharStream for source", e); + } + } + + /** + * Called when the state in {@link #tokenizeLine(CharSequence, LineState, int)} is {@link LineState#INCOMPLETE}. + * + * @param line + * The line source. + * @param tokens + * The list of tokens that must be updated as the line is scanned. + * @return The state and offset. + */ + @SuppressWarnings("UnstableApiUsage") + protected long fillIncomplete(CharSequence line, final List tokens, + final int lexerMode) { + lexer.setInputStream(createStream(line)); + if (lexer._mode != lexerMode) { + lexer.pushMode(lexerMode); + } + final var queue = createEvictingQueueForTokens(); + final var end = queue.getSecond(); + final var allTokens = lexer.getAllTokens() + .stream() + .map(IncrementalToken::new) + .collect(Collectors.toList()); + if (allTokens.isEmpty()) { + return IntPair.pack(LineState.INCOMPLETE, 0); + } + var completed = false; + var index = 0; + for (index = 0; index < allTokens.size(); index++) { + final IncrementalToken token = allTokens.get(index); + if (token.getType() == Token.EOF) { + break; + } + + end.add(token); + if (end.remainingCapacity() == 0 && isIncompleteTokenEnd(end)) { + completed = true; + break; + } + } + + final var first = allTokens.get(0); + final int offset = allTokens.get(completed ? index : index - 1).getStartIndex(); + first.startIndex = 0; + handleIncompleteToken(first); + tokens.add(first); + if (completed) { + return IntPair.pack(LineState.NORMAL, offset); + } else { + return IntPair.pack(LineState.INCOMPLETE, offset); + } + } + + /** + * Generate spans for the given {@link LineTokenizeResult}. + * + * @param tokens + * The tokenization result. + * @return The spans for the tokens. + */ + protected abstract List generateSpans( + final LineTokenizeResult tokens); + + /** + * Get the token types for left and right braces. + * + * @return An array of left and right brace token types. + */ + protected abstract int[] getCodeBlockTokens(); + + /** + * Returns the token types which start and end a multiline token. + * + * @return A 2xn matrix where int[] at index 0 specifies token types which start a multiline token and int[] 1 specifies tokens which end the multiline token. For example, + *

+ * [['/', '*'], ['*', '/']]. + *

+ * But instead of characters, there must be token types. + */ + protected abstract int[][] getMultilineTokenStartEndTypes(); + + /** + * Called when the analyzer finds an incomplete token in a lne. + * + * @param token + * The incomplete token. + */ + protected abstract void handleIncompleteToken(IncrementalToken token); + + protected void handleLineCommentSpan(@NonNull IncrementalToken token, @NonNull List spans, + int offset) { + var commentType = SchemeAndroidIDE.COMMENT; + + // highlight special line comments + var commentText = token.getText(); + if (commentText.length() > 2) { + commentText = commentText.substring(2); + commentText = commentText.trim(); + if ("todo".equalsIgnoreCase(commentText.substring(0, 4))) { + commentType = TODO_COMMENT; + } else if ("fixme".equalsIgnoreCase(commentText.substring(0, 5))) { + commentType = FIXME_COMMENT; + } + } + spans.add(Span.obtain(offset, withoutCompletion(commentType))); + } + + protected boolean isCodeBlockEnd(IncrementalToken token) { + return blockTokens.length == 2 && token.getType() == blockTokens[1]; + } + + protected boolean isCodeBlockStart(IncrementalToken token) { + return blockTokens.length == 2 && token.getType() == blockTokens[0]; + } + + @SuppressWarnings("UnstableApiUsage") + protected boolean isIncompleteTokenEnd(EvictingQueue q) { + return matchTokenTypes(this.multilineEndTypes, q); + } + + @SuppressWarnings("UnstableApiUsage") + protected boolean isIncompleteTokenStart(EvictingQueue q) { + return matchTokenTypes(this.multilineStartTypes, q); + } + + /** + * Gets the next token from lexer and return an {@link IncrementalToken}. + * + * @return The next {@link IncrementalToken}. + */ + protected IncrementalToken nextToken() { + return new IncrementalToken(lexer.nextToken()); + } + + /** + * Pop (remove) required number of tokens after an incomplete token has been encountered.
+ * + *

+ * For example:
+ * When ['/', '*'] tokens are encountered, the '*' token must be removed. In this case, + * incompleteToken parameter will point to '/' token. + * + *

+ * By default, this method removes only the last token. + * + * @param incompleteToken + * The token which is the start of the incomplete token. + * @param tokens + * The list of tokens from which extra tokens must be removed. + */ + protected void popTokensAfterIncomplete(@NonNull IncrementalToken incompleteToken, + @NonNull List tokens) { + tokens.remove(tokens.size() - 1); + } + + /** + * Called when the state in {@link #tokenizeLine(CharSequence, LineState, int)} is {@link LineState#NORMAL}. + * + * @param line + * The line source. + * @param column + * The column in line. + * @param tokens + * The list of tokens that must be updated as the line is scanned. + * @param st + * The state object whose state must be after after the line has been scanned. + * @return The new state. + */ + @SuppressWarnings("UnstableApiUsage") + protected int tokenizeNormal(final CharSequence line, final int column, + final List tokens, final LineState st, + final int lexerMode) { + lexer.setInputStream(createStream(line)); + if (lexer._mode != lexerMode) { + lexer.pushMode(lexerMode); + } + final var queues = createEvictingQueueForTokens(); + final var start = queues.getFirst(); + final var end = queues.getSecond(); + var isInIncompleteToken = false; + var state = LineState.NORMAL; + IncrementalToken token; + IncrementalToken incompleteToken = null; + + while ((token = nextToken()) != null) { + if (token.getType() == IncrementalToken.EOF) { + break; + } + + // Skip to the token just after 'column' + if (token.getStartIndex() < column) { + continue; + } + + if (!isInIncompleteToken) { + if (token.getStartIndex() == column && !tokens.isEmpty()) { + token.type = tokens.get(tokens.size() - 1).getType(); + } + + tokens.add(token); + } + start.add(token); + end.add(token); + final var type = token.getType(); + if (IntStream.of(getCodeBlockTokens()).anyMatch(t -> t == type)) { + st.hasBraces = true; + } + + if (start.remainingCapacity() == 0 && isIncompleteTokenStart(start)) { + isInIncompleteToken = true; + incompleteToken = start.poll(); + + // Pop extra tokens from the list. + popTokensAfterIncomplete(Objects.requireNonNull(incompleteToken), tokens); + } else if (end.remainingCapacity() == 0 && isIncompleteTokenEnd(end)) { + // This should most probably not happen because, if a comment starts and ends on the same + // line, the lexer will create a token for the whole comment + // But still we handle this case... + isInIncompleteToken = false; + incompleteToken = null; + } + + if (isInIncompleteToken) { + state = LineState.INCOMPLETE; + } + } + + if (incompleteToken != null) { + incompleteToken.incomplete = true; + handleIncompleteToken(incompleteToken); + } + + return state; + } + + @SuppressWarnings("UnstableApiUsage") + @NonNull + private Pair, EvictingQueue> createEvictingQueueForTokens() { + return new Pair<>(EvictingQueue.create(multilineStartTypes.length), + EvictingQueue.create(multilineEndTypes.length)); + } + + @NonNull + private Lexer createLexerInstance(final Class lexer) { + try { + final var constructor = lexer.getConstructor(CharStream.class); + if (!constructor.isAccessible()) { + constructor.setAccessible(true); + } + return constructor.newInstance(createStream("")); + } catch (Throwable err) { + throw new RuntimeException("Unable to create Lexer instance", err); + } + } + + @SuppressWarnings("UnstableApiUsage") + private boolean matchTokenTypes(@NonNull int[] types, + @NonNull EvictingQueue tokens) { + final var arr = tokens.toArray(new IncrementalToken[0]); + for (int i = 0; i < types.length; i++) { + if (types[i] != arr[i].getType()) { + return false; + } + } + return true; + } + + private void verifyMultilineTypes(@NonNull final int[][] types) { + Preconditions.checkState(types.length == 2, + "There must be exact two inner int[] in multiline token types"); + + final var start = types[0]; + final var end = types[1]; + Preconditions.checkState(start.length > 0, "Invalid start token types"); + Preconditions.checkState(end.length > 0, "Invalid end token types"); + } } diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/compiler/SourceFileManager.java b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/compiler/SourceFileManager.java index 2d5ccb999b..886645d8c4 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/compiler/SourceFileManager.java +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/compiler/SourceFileManager.java @@ -21,7 +21,6 @@ import static java.util.Collections.emptySet; import androidx.annotation.NonNull; -import com.blankj.utilcode.util.CloseUtils; import com.itsaky.androidide.javac.config.JavacConfigProvider; import com.itsaky.androidide.javac.services.fs.AndroidFsProviderImpl; import com.itsaky.androidide.projects.api.AndroidModule; @@ -68,7 +67,11 @@ public class SourceFileManager extends ForwardingJavaFileManager searchPath) { } private JavaFileObject asJavaFileObject(SourceClassTrie.SourceNode node) { - final Path path = node.getFile(); - if (!Files.exists(path)) return null; + final Path path = node.getFile(); + if (!Files.exists(path)) + return null; // TODO erase method bodies of files that are not open return new SourceFileObject(node.getFile()); From d96caddcc935b16f6728e36cd64508419eea0ca6 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 24 Jul 2026 15:28:26 -0700 Subject: [PATCH 6/9] ADFA-4649: Replace ReflectUtils/ImageUtils/ResourceUtils/ZipUtils with native equivalents Adds four new common-module utility objects, each a same-name drop-in so call sites only needed an import swap: - ReflectUtils: a compact fluent reflection helper (reflect/field/ method/newInstance/get) covering exactly the subset used across lsp/java, javac-services, and xml-inflater - field lookup uses the field's declared type when wrapping, matching blankj's behavior bug-for-bug (verified via javap against the actual blankj bytecode) so existing call chains keep working identically. - ImageUtils: isImage (BitmapFactory decode-bounds check), getBitmap, and getImageType (magic-number header sniffing for JPG/PNG/GIF/TIFF/ BMP/ICO/WEBP). - ResourceUtils: copyFileFromAssets (recursive directory-aware asset copy, matching blankj) and readAssets2String, both via BaseApplication.baseInstance.assets. - ZipUtils: unzipFile using java.util.zip.ZipFile, with zip-slip path-traversal protection added as a safe hardening. Sixth of several staged commits removing com.blankj:utilcodex. Co-Authored-By: Claude Sonnet 5 --- .../editor/EditorHandlerActivity.kt | 313 ++++---- .../services/builder/GradleBuildService.kt | 7 +- .../tasks/callables/UnzipCallable.java | 22 +- .../itsaky/androidide/utils/IntentUtils.kt | 3 +- .../androidide/utils/ProjectWriter.java | 216 +++--- .../utils/TemplateRecipeExecutor.kt | 73 +- .../androidide/managers/ToolsManager.java | 2 +- .../com/itsaky/androidide/utils/ImageUtils.kt | 96 +++ .../itsaky/androidide/utils/ReflectUtils.kt | 164 +++++ .../itsaky/androidide/utils/ResourceUtils.kt | 72 ++ .../com/itsaky/androidide/utils/ZipUtils.kt | 65 ++ .../java/providers/CompletionProvider.java | 694 +++++++++--------- .../javac/services/visitors/UnEnter.kt | 27 +- .../drawable/DrawableParserFactory.java | 2 +- .../adapters/GestureOverlayViewAdapter.kt | 63 +- .../internal/adapters/ToggleButtonAdapter.kt | 32 +- 16 files changed, 1154 insertions(+), 697 deletions(-) create mode 100644 common/src/main/java/com/itsaky/androidide/utils/ImageUtils.kt create mode 100644 common/src/main/java/com/itsaky/androidide/utils/ReflectUtils.kt create mode 100644 common/src/main/java/com/itsaky/androidide/utils/ResourceUtils.kt create mode 100644 common/src/main/java/com/itsaky/androidide/utils/ZipUtils.kt diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index 32924c576d..3d95e1d085 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -36,7 +36,6 @@ import androidx.core.view.doOnNextLayout import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.lifecycleScope -import com.blankj.utilcode.util.ImageUtils import com.google.android.material.tabs.TabLayout import com.google.gson.Gson import com.itsaky.androidide.R @@ -47,6 +46,7 @@ import com.itsaky.androidide.actions.ActionItem.Location.EDITOR_TOOLBAR import com.itsaky.androidide.actions.ActionsRegistry.Companion.getInstance import com.itsaky.androidide.actions.build.QuickRunAction import com.itsaky.androidide.actions.internal.DefaultActionsRegistry +import com.itsaky.androidide.activities.PluginManagerActivity import com.itsaky.androidide.api.ActionContextProvider import com.itsaky.androidide.app.BaseApplication import com.itsaky.androidide.app.EditorEvents @@ -64,9 +64,9 @@ import com.itsaky.androidide.editor.schemes.IDEColorSchemeProvider import com.itsaky.androidide.editor.ui.IDEEditor import com.itsaky.androidide.eventbus.events.editor.DocumentChangeEvent import com.itsaky.androidide.eventbus.events.file.FileRenameEvent -import com.itsaky.androidide.activities.PluginManagerActivity import com.itsaky.androidide.eventbus.events.plugin.PluginCrashedEvent import com.itsaky.androidide.eventbus.events.preferences.PreferenceChangeEvent +import com.itsaky.androidide.fragments.sidebar.EditorSidebarFragment import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.interfaces.IEditorHandler @@ -77,11 +77,11 @@ import com.itsaky.androidide.models.Range import com.itsaky.androidide.models.SaveResult import com.itsaky.androidide.plugins.manager.build.PluginBuildActionManager import com.itsaky.androidide.plugins.manager.fragment.PluginFragmentFactory -import com.itsaky.androidide.preferences.internal.EditorPreferences import com.itsaky.androidide.plugins.manager.ui.PluginDrawableResolver import com.itsaky.androidide.plugins.manager.ui.PluginEditorTabManager import com.itsaky.androidide.plugins.manager.ui.PluginToolbarHost import com.itsaky.androidide.plugins.manager.ui.PluginUiActionManager +import com.itsaky.androidide.preferences.internal.EditorPreferences import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.projects.builder.BuildResult import com.itsaky.androidide.shortcuts.IdeShortcutActions @@ -90,15 +90,16 @@ import com.itsaky.androidide.shortcuts.ShortcutExecutionContext import com.itsaky.androidide.shortcuts.ShortcutManager import com.itsaky.androidide.tasks.executeAsync import com.itsaky.androidide.ui.CodeEditorView -import com.itsaky.androidide.fragments.sidebar.EditorSidebarFragment import com.itsaky.androidide.utils.DialogUtils.newMaterialDialogBuilder import com.itsaky.androidide.utils.DialogUtils.showConfirmationDialog import com.itsaky.androidide.utils.EditorActivityActions import com.itsaky.androidide.utils.EditorSidebarActions +import com.itsaky.androidide.utils.ImageUtils import com.itsaky.androidide.utils.IntentUtils.openImage import com.itsaky.androidide.utils.UniqueNameBuilder import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.utils.forEachViewRecursively +import com.itsaky.androidide.utils.hasVisibleDialog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch @@ -112,7 +113,6 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.atomic.AtomicBoolean import java.util.function.Consumer -import com.itsaky.androidide.utils.hasVisibleDialog /** * Base class for EditorActivity. Handles logic for working with file editors. @@ -139,19 +139,20 @@ open class EditorHandlerActivity : private var lastAppliedPluginFontScale = EditorPreferences.editorFontScale private val pluginTextBaseSizes = WeakHashMap() - private val pluginFontScalingListener = object : FragmentManager.FragmentLifecycleCallbacks() { - override fun onFragmentViewCreated( - mFragmentManager: FragmentManager, - mFragment: Fragment, - view: View, - savedInstanceState: Bundle? - ) { - val scale = EditorPreferences.editorFontScale - if (scale != 1f && isPluginFragment(mFragment)) { - applyPluginFontScale(view, scale) + private val pluginFontScalingListener = + object : FragmentManager.FragmentLifecycleCallbacks() { + override fun onFragmentViewCreated( + mFragmentManager: FragmentManager, + mFragment: Fragment, + view: View, + savedInstanceState: Bundle?, + ) { + val scale = EditorPreferences.editorFontScale + if (scale != 1f && isPluginFragment(mFragment)) { + applyPluginFontScale(view, scale) + } } } - } private val shortcutManager by lazy { ShortcutManager(applicationContext) } private var pluginEditorProvider: EditorProviderImpl? = null @@ -173,23 +174,22 @@ open class EditorHandlerActivity : return -1 } - override fun dispatchKeyEvent(event: KeyEvent): Boolean { - return shortcutManager.dispatch( + override fun dispatchKeyEvent(event: KeyEvent): Boolean = + shortcutManager.dispatch( event = event, context = ShortcutContext.EDITOR, focusView = currentFocus, hasModal = supportFragmentManager.hasVisibleDialog(), executionContext = editorShortcutExecutionContext(), ) || super.dispatchKeyEvent(event) - } - private fun editorShortcutExecutionContext(): ShortcutExecutionContext { - return ShortcutExecutionContext( - ideShortcutActions = IdeShortcutActions { - createToolbarActionData() - }, + private fun editorShortcutExecutionContext(): ShortcutExecutionContext = + ShortcutExecutionContext( + ideShortcutActions = + IdeShortcutActions { + createToolbarActionData() + }, ) - } override fun doOpenFile( file: File, @@ -217,7 +217,8 @@ open class EditorHandlerActivity : } private val floatingTabController by lazy { - com.itsaky.androidide.editor.floating.IdeFloatingTabController(this) + com.itsaky.androidide.editor.floating + .IdeFloatingTabController(this) } override fun onCreate(savedInstanceState: Bundle?) { @@ -238,9 +239,10 @@ open class EditorHandlerActivity : EditorEvents.notifyFileChanged(editorViewModel.getCurrentFile()) } - pluginEditorProvider = EditorProviderImpl(this).also { provider -> - IDEApplication.getPluginManager()?.setEditorProvider(provider) - } + pluginEditorProvider = + EditorProviderImpl(this).also { provider -> + IDEApplication.getPluginManager()?.setEditorProvider(provider) + } editorViewModel._startDrawerOpened.observe(this) { opened -> this.binding.editorDrawerLayout.apply { if (opened) openDrawer(GravityCompat.START) else closeDrawer(GravityCompat.START) @@ -260,11 +262,12 @@ open class EditorHandlerActivity : return@observeFiles } getOpenedFiles().also { - val cache = OpenedFilesCache( - projectPath = ProjectManagerImpl.getInstance().projectDirPath, - selectedFile = currentFile, - allFiles = it, - ) + val cache = + OpenedFilesCache( + projectPath = ProjectManagerImpl.getInstance().projectDirPath, + selectedFile = currentFile, + allFiles = it, + ) editorViewModel.writeOpenedFiles(cache) editorViewModel.openedFilesCache = cache } @@ -406,9 +409,10 @@ open class EditorHandlerActivity : lifecycleScope.launch { try { val prefs = (application as BaseApplication).prefManager - val jsonCache = withContext(Dispatchers.IO) { - prefs.getString(PREF_KEY_OPEN_FILES_CACHE, null) - } ?: return@launch + val jsonCache = + withContext(Dispatchers.IO) { + prefs.getString(PREF_KEY_OPEN_FILES_CACHE, null) + } ?: return@launch if (editorViewModel.getOpenedFileCount() > 0) { // Returning to an in-memory session (e.g. after onPause/onStop). Replaying the @@ -417,9 +421,10 @@ open class EditorHandlerActivity : return@launch } - val cache = withContext(Dispatchers.Default) { - Gson().fromJson(jsonCache, OpenedFilesCache::class.java) - } + val cache = + withContext(Dispatchers.Default) { + Gson().fromJson(jsonCache, OpenedFilesCache::class.java) + } onReadOpenedFilesCache(cache) // Clear the preference so it's only loaded once per cold restore @@ -441,14 +446,16 @@ open class EditorHandlerActivity : lifecycleScope.launch { try { val prefs = (application as BaseApplication).prefManager - val json = withContext(Dispatchers.IO) { - prefs.getString(PREF_KEY_OPEN_PLUGIN_TABS, null) - } ?: return@launch + val json = + withContext(Dispatchers.IO) { + prefs.getString(PREF_KEY_OPEN_PLUGIN_TABS, null) + } ?: return@launch // Decoding the cached JSON off the main thread avoids a UI stall on startup. - val tabIds = withContext(Dispatchers.Default) { - Gson().fromJson(json, Array::class.java)?.toList() - } ?: return@launch + val tabIds = + withContext(Dispatchers.Default) { + Gson().fromJson(json, Array::class.java)?.toList() + } ?: return@launch Log.d("EditorHandlerActivity", "Restoring plugin tabs: $tabIds") // Tab selection touches UI state, so keep it on the main thread. @@ -510,10 +517,14 @@ open class EditorHandlerActivity : // Sort by (order, id) so a plugin's ToolbarAction.order positions its icon among the // built-in actions. The 13 built-ins are registered with contiguous order 0..12, so // this is a visual no-op for them. - val actions = getInstance().getActions(EDITOR_TOOLBAR).values - .sortedWith(compareBy({ it.order }, { it.id })) - val hiddenIds = PluginBuildActionManager.getInstance().getHiddenActionIds() + - PluginUiActionManager.getHiddenActionIds() + val actions = + getInstance() + .getActions(EDITOR_TOOLBAR) + .values + .sortedWith(compareBy({ it.order }, { it.id })) + val hiddenIds = + PluginBuildActionManager.getInstance().getHiddenActionIds() + + PluginUiActionManager.getHiddenActionIds() actions.forEachIndexed { index, action -> val isLast = index == actions.size - 1 @@ -573,7 +584,10 @@ open class EditorHandlerActivity : return data } - private fun getToolbarContentDescription(action: ActionItem, data: ActionData): String { + private fun getToolbarContentDescription( + action: ActionItem, + data: ActionData, + ): String { val buildInProgress = with(com.itsaky.androidide.actions.build.AbstractCancellableRunAction) { this@EditorHandlerActivity.isBuildInProgress() @@ -583,22 +597,65 @@ open class EditorHandlerActivity : } val resId = when (action.id) { - QuickRunAction.ID -> string.cd_toolbar_quick_run - "ide.editor.syncProject" -> string.cd_toolbar_sync_project - "ide.editor.build.debug" -> string.cd_toolbar_start_debugger - "ide.editor.build.runTasks" -> string.cd_toolbar_run_gradle_tasks - "ide.editor.code.text.undo" -> string.cd_toolbar_undo - "ide.editor.code.text.redo" -> string.cd_toolbar_redo - "ide.editor.files.saveAll" -> string.cd_toolbar_save - "ide.editor.previewLayout" -> string.cd_toolbar_preview_layout - "ide.editor.find" -> string.cd_toolbar_find - "ide.editor.find.inFile" -> string.cd_toolbar_find_in_file - "ide.editor.find.inProject" -> string.cd_toolbar_find_in_project - "ide.editor.launchInstalledApp" -> string.cd_toolbar_launch_app - "ide.editor.service.logreceiver.disconnectSenders" -> + QuickRunAction.ID -> { + string.cd_toolbar_quick_run + } + + "ide.editor.syncProject" -> { + string.cd_toolbar_sync_project + } + + "ide.editor.build.debug" -> { + string.cd_toolbar_start_debugger + } + + "ide.editor.build.runTasks" -> { + string.cd_toolbar_run_gradle_tasks + } + + "ide.editor.code.text.undo" -> { + string.cd_toolbar_undo + } + + "ide.editor.code.text.redo" -> { + string.cd_toolbar_redo + } + + "ide.editor.files.saveAll" -> { + string.cd_toolbar_save + } + + "ide.editor.previewLayout" -> { + string.cd_toolbar_preview_layout + } + + "ide.editor.find" -> { + string.cd_toolbar_find + } + + "ide.editor.find.inFile" -> { + string.cd_toolbar_find_in_file + } + + "ide.editor.find.inProject" -> { + string.cd_toolbar_find_in_project + } + + "ide.editor.launchInstalledApp" -> { + string.cd_toolbar_launch_app + } + + "ide.editor.service.logreceiver.disconnectSenders" -> { string.cd_toolbar_disconnect_log_senders - "ide.editor.generatexml" -> string.cd_toolbar_image_to_layout - else -> null + } + + "ide.editor.generatexml" -> { + string.cd_toolbar_image_to_layout + } + + else -> { + null + } } return if (resId != null) getString(resId) else action.label } @@ -623,7 +680,10 @@ open class EditorHandlerActivity : } /** Undock the plugin tab [tabId] (at [position]) into a floating window over other apps. */ - fun undockPluginTab(tabId: String, position: Int) { + fun undockPluginTab( + tabId: String, + position: Int, + ) { val title = PluginEditorTabManager .getInstance() @@ -655,43 +715,44 @@ open class EditorHandlerActivity : override suspend fun openFile( file: File, selection: Range?, - ): CodeEditorView? = withContext(Dispatchers.Main) { - val range = selection ?: Range.NONE - val isImage = withContext(Dispatchers.IO) { ImageUtils.isImage(file) } - if (isImage) { - openImage(this@EditorHandlerActivity, file) - return@withContext null - } + ): CodeEditorView? = + withContext(Dispatchers.Main) { + val range = selection ?: Range.NONE + val isImage = withContext(Dispatchers.IO) { ImageUtils.isImage(file) } + if (isImage) { + openImage(this@EditorHandlerActivity, file) + return@withContext null + } - val pluginHandled = IDEApplication.getPluginManager()?.delegateFileOpen(file) ?: false - if (pluginHandled) { - return@withContext null - } + val pluginHandled = IDEApplication.getPluginManager()?.delegateFileOpen(file) ?: false + if (pluginHandled) { + return@withContext null + } - val fileIndex = openFileAndGetIndex(file, range) - if (fileIndex < 0) return@withContext null + val fileIndex = openFileAndGetIndex(file, range) + if (fileIndex < 0) return@withContext null - editorViewModel.startDrawerOpened = false - editorViewModel.displayedFileIndex = fileIndex + editorViewModel.startDrawerOpened = false + editorViewModel.displayedFileIndex = fileIndex - val tabPosition = getTabPositionForFileIndex(fileIndex) - val tab = content.tabs.getTabAt(tabPosition) - if (tab != null && !tab.isSelected) { - tab.select() - } + val tabPosition = getTabPositionForFileIndex(fileIndex) + val tab = content.tabs.getTabAt(tabPosition) + if (tab != null && !tab.isSelected) { + tab.select() + } - return@withContext try { - getEditorAtIndex(fileIndex) - } catch (th: Throwable) { - log.error("Unable to get editor at file index {}", fileIndex, th) - null + return@withContext try { + getEditorAtIndex(fileIndex) + } catch (th: Throwable) { + log.error("Unable to get editor at file index {}", fileIndex, th) + null + } } - } fun openFileAsync( file: File, selection: Range? = null, - onResult: (CodeEditorView?) -> Unit + onResult: (CodeEditorView?) -> Unit, ) { lifecycleScope.launch { onResult(openFile(file, selection)) @@ -1223,10 +1284,12 @@ open class EditorHandlerActivity : } } - private fun isPluginFragment(fragment: Fragment): Boolean = - fragment.javaClass.classLoader !== javaClass.classLoader + private fun isPluginFragment(fragment: Fragment): Boolean = fragment.javaClass.classLoader !== javaClass.classLoader - private fun applyPluginFontScale(root: View, scale: Float) { + private fun applyPluginFontScale( + root: View, + scale: Float, + ) { root.forEachViewRecursively { view -> if (view is TextView) { val baseSize = pluginTextBaseSizes.getOrPut(view) { view.textSize } @@ -1235,7 +1298,10 @@ open class EditorHandlerActivity : } } - private fun collectPluginFragments(manager: FragmentManager, into: MutableList) { + private fun collectPluginFragments( + manager: FragmentManager, + into: MutableList, + ) { manager.fragments.forEach { fragment -> if (isPluginFragment(fragment)) { into.add(fragment) @@ -1254,10 +1320,11 @@ open class EditorHandlerActivity : getString(string.msg_plugin_crash, event.pluginName, event.crashCount) } - val builder = newMaterialDialogBuilder(this) - .setTitle(string.title_plugin_crashed) - .setView(dialogView) - .setPositiveButton(string.dismiss, null) + val builder = + newMaterialDialogBuilder(this) + .setTitle(string.title_plugin_crashed) + .setView(dialogView) + .setPositiveButton(string.dismiss, null) if (event.wasDisabled) { builder.setNegativeButton(string.plugin_manager) { _, _ -> @@ -1282,12 +1349,11 @@ open class EditorHandlerActivity : clipboard?.setPrimaryClip( ClipData.newPlainText( getString(string.title_plugin_crash_log, event.pluginName), - event.stackTrace - ) + event.stackTrace, + ), ) flashSuccess(string.msg_crash_log_copied) - } - .show() + }.show() } private fun tearDownDisabledPluginContributions(pluginId: String) { @@ -1295,9 +1361,10 @@ open class EditorHandlerActivity : val pluginManager = IDEApplication.getPluginManager() ?: return val tabManager = PluginEditorTabManager.getInstance() - val tabsToClose = pluginTabIndices.keys.toList().filter { tabId -> - tabManager.getPluginIdForTab(tabId) == pluginId - } + val tabsToClose = + pluginTabIndices.keys.toList().filter { tabId -> + tabManager.getPluginIdForTab(tabId) == pluginId + } tabsToClose.forEach { tabId -> val index = pluginTabIndices[tabId] ?: return@forEach closePluginTab(index) @@ -1393,7 +1460,6 @@ open class EditorHandlerActivity : } fun selectPluginTabById(tabId: String): Boolean { - // Check if the tab already exists val existingTabIndex = pluginTabIndices[tabId] if (existingTabIndex != null) { @@ -1424,7 +1490,6 @@ open class EditorHandlerActivity : return false } - runOnUiThread { val content = contentOrNull ?: return@runOnUiThread @@ -1452,7 +1517,8 @@ open class EditorHandlerActivity : val fragment = tabManager.getOrCreateTabFragment(pluginTab.id) if (fragment != null) { - supportFragmentManager.beginTransaction() + supportFragmentManager + .beginTransaction() .add(containerView.id, fragment, "plugin_tab_${pluginTab.id}") .commitNowAllowingStateLoss() Log.d("EditorHandlerActivity", "Plugin fragment added to container for tab: ${pluginTab.id}") @@ -1468,17 +1534,17 @@ open class EditorHandlerActivity : editorViewModel.displayedFileIndex = -1 updateTabVisibility() - pluginTabIndices.forEach { - val tab = content.tabs.getTabAt(it.value) ?: return@forEach - tab.view.setOnLongClickListener { - TooltipManager.showIdeCategoryTooltip( - context = this@EditorHandlerActivity, - anchorView = tab.view, - tag = TooltipTag.PROJECT_PLUGIN_TAB, - ) - true - } - } + pluginTabIndices.forEach { + val tab = content.tabs.getTabAt(it.value) ?: return@forEach + tab.view.setOnLongClickListener { + TooltipManager.showIdeCategoryTooltip( + context = this@EditorHandlerActivity, + anchorView = tab.view, + tag = TooltipTag.PROJECT_PLUGIN_TAB, + ) + true + } + } } return true @@ -1711,7 +1777,6 @@ open class EditorHandlerActivity : dialog.dismiss() saveAllAsync(notify = false) { - runOnUiThread { if (contentOrNull == null) return@runOnUiThread performCloseAllFiles(manualFinish = true) diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt index a7040a6e6f..ca74c79bb6 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt @@ -25,8 +25,6 @@ import android.content.Intent import android.os.IBinder import android.text.TextUtils import androidx.core.app.NotificationManagerCompat -import com.blankj.utilcode.util.ResourceUtils -import com.blankj.utilcode.util.ZipUtils import com.itsaky.androidide.BuildConfig import com.itsaky.androidide.analytics.IAnalyticsManager import com.itsaky.androidide.analytics.gradle.BuildCompletedMetric @@ -70,6 +68,8 @@ import com.itsaky.androidide.tooling.api.models.ToolingServerMetadata import com.itsaky.androidide.tooling.events.ProgressEvent import com.itsaky.androidide.utils.Environment import com.itsaky.androidide.utils.FeatureFlags +import com.itsaky.androidide.utils.ResourceUtils +import com.itsaky.androidide.utils.ZipUtils import com.termux.shared.termux.shell.command.environment.TermuxShellEnvironment import io.sentry.Sentry import kotlinx.coroutines.CoroutineName @@ -344,7 +344,6 @@ class GradleBuildService : 'W' -> logger.warn(params.message) 'E' -> logger.error(params.message) 'I' -> logger.info(params.message) - else -> logger.trace(params.message) } } @@ -552,7 +551,7 @@ class GradleBuildService : try { val projectDir = ProjectManagerImpl.getInstance().projectDir val files = ZipUtils.unzipFile(extracted, projectDir) - if (files != null && files.isNotEmpty()) { + if (files.isNotEmpty()) { return GradleWrapperCheckResult(true) } } catch (e: IOException) { diff --git a/app/src/main/java/com/itsaky/androidide/tasks/callables/UnzipCallable.java b/app/src/main/java/com/itsaky/androidide/tasks/callables/UnzipCallable.java index 915ebe482b..06bcdb840a 100755 --- a/app/src/main/java/com/itsaky/androidide/tasks/callables/UnzipCallable.java +++ b/app/src/main/java/com/itsaky/androidide/tasks/callables/UnzipCallable.java @@ -20,23 +20,23 @@ package com.itsaky.androidide.tasks.callables; -import com.blankj.utilcode.util.ZipUtils; +import com.itsaky.androidide.utils.ZipUtils; import java.io.File; import java.util.List; import java.util.concurrent.Callable; public final class UnzipCallable implements Callable> { - private File src; - private File dest; + private File src; + private File dest; - public UnzipCallable(File src, File dest) { - this.src = src; - this.dest = dest; - } + public UnzipCallable(File src, File dest) { + this.src = src; + this.dest = dest; + } - @Override - public List call() throws Exception { - return ZipUtils.unzipFile(src, dest); - } + @Override + public List call() throws Exception { + return ZipUtils.unzipFile(src, dest); + } } diff --git a/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt b/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt index eb383c03a2..3655a19784 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt @@ -23,9 +23,8 @@ import android.os.Build import androidx.annotation.RequiresApi import androidx.core.app.ShareCompat import androidx.core.content.FileProvider -import com.blankj.utilcode.util.ImageUtils -import com.blankj.utilcode.util.ImageUtils.ImageType.TYPE_UNKNOWN import com.itsaky.androidide.R +import com.itsaky.androidide.utils.ImageUtils.ImageType.TYPE_UNKNOWN import org.slf4j.LoggerFactory import rikka.shizuku.Shizuku import java.io.File diff --git a/app/src/main/java/com/itsaky/androidide/utils/ProjectWriter.java b/app/src/main/java/com/itsaky/androidide/utils/ProjectWriter.java index df1357d2fc..f5f4bfbea6 100755 --- a/app/src/main/java/com/itsaky/androidide/utils/ProjectWriter.java +++ b/app/src/main/java/com/itsaky/androidide/utils/ProjectWriter.java @@ -21,7 +21,6 @@ import static java.lang.Character.toLowerCase; import androidx.annotation.NonNull; -import com.blankj.utilcode.util.ResourceUtils; import com.itsaky.androidide.utils.ClassBuilder.SourceLanguage; import java.io.File; import java.util.regex.Matcher; @@ -30,111 +29,112 @@ public class ProjectWriter { - private static final String XML_TEMPLATE_PATH = "templates/xml"; - private static final String SOURCE_PATH_REGEX = "/.*/src/.*/java|kt"; - - @NonNull - public static String createMenu() { - return ResourceUtils.readAssets2String(XML_TEMPLATE_PATH + "/menu.xml"); - } - - @NonNull - public static String createDrawable() { - return ResourceUtils.readAssets2String(XML_TEMPLATE_PATH + "/drawable.xml"); - } - - @NonNull - public static String createLayout() { - return ResourceUtils.readAssets2String(XML_TEMPLATE_PATH + "/layout.xml"); - } - - @NonNull - public static String createLayoutName(String name) { - final var nameWithoutExtension = StringsKt.substringBeforeLast(name, '.', name); - var baseName = nameWithoutExtension; - if (baseName.endsWith("Activity")) { - baseName = StringsKt.substringBeforeLast(baseName, "Activity", baseName); - baseName = "activity" + baseName; - } else if (baseName.endsWith("Fragment")) { - baseName = StringsKt.substringBeforeLast(baseName, "Fragment", baseName); - baseName = "fragment" + baseName; - } else { - baseName = "layout" + baseName; - } - - final var sb = new StringBuilder(); - var hasUpper = false; - for (int i = 0; i < baseName.length(); i++) { - final char c = baseName.charAt(i); - if (isUpperCase(c)) { - hasUpper = true; - sb.append("_"); - sb.append(toLowerCase(c)); - continue; - } - - sb.append(c); - } - - if (!hasUpper) { - sb.delete(0, sb.length()); - sb.append("layout_"); - sb.append(nameWithoutExtension); - } - - sb.append(".xml"); - - return sb.toString(); - } - - public static String getPackageName(File parentPath) { - // Returns the package name or the closest internal and if none is found, returns null - Matcher pkgMatcher = Pattern.compile(SOURCE_PATH_REGEX).matcher(parentPath.getAbsolutePath()); - - if (pkgMatcher.find()) { - int end = pkgMatcher.end(); - if (end <= 0) return ""; - - String name = parentPath.getAbsolutePath().substring(end); - if (name.startsWith(File.separator)) { - name = name.substring(1); - } - - if (!name.isEmpty()) { - return name.replace(File.separator, "."); - } - - File[] files = parentPath.listFiles(); - if (files != null) { - for (File file : files) { - if (file.isDirectory() && isValidPackageName(file.getName())) { - return file.getName(); - } - } - } - return ""; - } - - return null; - } - - private static boolean isValidPackageName(String name) { - return name.matches("^[a-zA-Z_][a-zA-Z0-9_]*$"); - } - - public static String createClass(String packageName, String className, SourceLanguage language) { - return ClassBuilder.createClass(packageName, className, language); - } - - public static String createInterface(String packageName, String className, SourceLanguage language) { - return ClassBuilder.createInterface(packageName, className, language); - } - - public static String createEnum(String packageName, String className, SourceLanguage language) { - return ClassBuilder.createEnum(packageName, className, language); - } - - public static String createActivity(String packageName, String className, boolean appCompatActivity, SourceLanguage language) { - return ClassBuilder.createActivity(packageName, className, appCompatActivity, language); - } + private static final String XML_TEMPLATE_PATH = "templates/xml"; + private static final String SOURCE_PATH_REGEX = "/.*/src/.*/java|kt"; + + public static String createActivity(String packageName, String className, boolean appCompatActivity, SourceLanguage language) { + return ClassBuilder.createActivity(packageName, className, appCompatActivity, language); + } + + public static String createClass(String packageName, String className, SourceLanguage language) { + return ClassBuilder.createClass(packageName, className, language); + } + + @NonNull + public static String createDrawable() { + return ResourceUtils.readAssets2String(XML_TEMPLATE_PATH + "/drawable.xml"); + } + + public static String createEnum(String packageName, String className, SourceLanguage language) { + return ClassBuilder.createEnum(packageName, className, language); + } + + public static String createInterface(String packageName, String className, SourceLanguage language) { + return ClassBuilder.createInterface(packageName, className, language); + } + + @NonNull + public static String createLayout() { + return ResourceUtils.readAssets2String(XML_TEMPLATE_PATH + "/layout.xml"); + } + + @NonNull + public static String createLayoutName(String name) { + final var nameWithoutExtension = StringsKt.substringBeforeLast(name, '.', name); + var baseName = nameWithoutExtension; + if (baseName.endsWith("Activity")) { + baseName = StringsKt.substringBeforeLast(baseName, "Activity", baseName); + baseName = "activity" + baseName; + } else if (baseName.endsWith("Fragment")) { + baseName = StringsKt.substringBeforeLast(baseName, "Fragment", baseName); + baseName = "fragment" + baseName; + } else { + baseName = "layout" + baseName; + } + + final var sb = new StringBuilder(); + var hasUpper = false; + for (int i = 0; i < baseName.length(); i++) { + final char c = baseName.charAt(i); + if (isUpperCase(c)) { + hasUpper = true; + sb.append("_"); + sb.append(toLowerCase(c)); + continue; + } + + sb.append(c); + } + + if (!hasUpper) { + sb.delete(0, sb.length()); + sb.append("layout_"); + sb.append(nameWithoutExtension); + } + + sb.append(".xml"); + + return sb.toString(); + } + + @NonNull + public static String createMenu() { + return ResourceUtils.readAssets2String(XML_TEMPLATE_PATH + "/menu.xml"); + } + + public static String getPackageName(File parentPath) { + // Returns the package name or the closest internal and if none is found, returns null + Matcher pkgMatcher = Pattern.compile(SOURCE_PATH_REGEX).matcher(parentPath.getAbsolutePath()); + + if (pkgMatcher.find()) { + int end = pkgMatcher.end(); + if (end <= 0) + return ""; + + String name = parentPath.getAbsolutePath().substring(end); + if (name.startsWith(File.separator)) { + name = name.substring(1); + } + + if (!name.isEmpty()) { + return name.replace(File.separator, "."); + } + + File[] files = parentPath.listFiles(); + if (files != null) { + for (File file : files) { + if (file.isDirectory() && isValidPackageName(file.getName())) { + return file.getName(); + } + } + } + return ""; + } + + return null; + } + + private static boolean isValidPackageName(String name) { + return name.matches("^[a-zA-Z_][a-zA-Z0-9_]*$"); + } } diff --git a/app/src/main/java/com/itsaky/androidide/utils/TemplateRecipeExecutor.kt b/app/src/main/java/com/itsaky/androidide/utils/TemplateRecipeExecutor.kt index 77bb84f4cd..8efbe6682d 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/TemplateRecipeExecutor.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/TemplateRecipeExecutor.kt @@ -18,13 +18,11 @@ package com.itsaky.androidide.utils import android.content.Context -import org.adfa.constants.LOCAL_MAVEN_CACHES_DEST -import org.adfa.constants.LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME -import com.blankj.utilcode.util.ResourceUtils -import com.blankj.utilcode.util.ZipUtils import com.itsaky.androidide.app.IDEApplication import com.itsaky.androidide.managers.ToolsManager import com.itsaky.androidide.templates.RecipeExecutor +import org.adfa.constants.LOCAL_MAVEN_CACHES_DEST +import org.adfa.constants.LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME import java.io.File import java.io.IOException import java.io.InputStream @@ -34,37 +32,48 @@ import java.io.InputStream * * @author Akash Yadav */ -class TemplateRecipeExecutor ( - override val context: Context +class TemplateRecipeExecutor( + override val context: Context, ) : RecipeExecutor { + private val application: IDEApplication + get() = IDEApplication.instance - private val application: IDEApplication - get() = IDEApplication.instance - - override fun copy(source: File, dest: File) { - source.copyTo(dest) - } + override fun copy( + source: File, + dest: File, + ) { + source.copyTo(dest) + } - override fun save(source: String, dest: File) { - dest.parentFile?.mkdirs() - dest.writeText(source) - } + override fun save( + source: String, + dest: File, + ) { + dest.parentFile?.mkdirs() + dest.writeText(source) + } - override fun openAsset(path: String): InputStream { - try { - return application.assets.open(path) - } catch (e: Exception) { - throw RuntimeException(e) - } - } + override fun openAsset(path: String): InputStream { + try { + return application.assets.open(path) + } catch (e: Exception) { + throw RuntimeException(e) + } + } - override fun copyAsset(path: String, dest: File) { - openAsset(path).use { - it.copyTo(dest.outputStream()) - } - } + override fun copyAsset( + path: String, + dest: File, + ) { + openAsset(path).use { + it.copyTo(dest.outputStream()) + } + } - override fun copyAssetsRecursively(path: String, destDir: File) { - ResourceUtils.copyFileFromAssets(path, destDir.absolutePath) - } -} \ No newline at end of file + override fun copyAssetsRecursively( + path: String, + destDir: File, + ) { + ResourceUtils.copyFileFromAssets(path, destDir.absolutePath) + } +} diff --git a/common/src/main/java/com/itsaky/androidide/managers/ToolsManager.java b/common/src/main/java/com/itsaky/androidide/managers/ToolsManager.java index 52b29e2b98..363ad47aff 100755 --- a/common/src/main/java/com/itsaky/androidide/managers/ToolsManager.java +++ b/common/src/main/java/com/itsaky/androidide/managers/ToolsManager.java @@ -26,7 +26,6 @@ import androidx.annotation.WorkerThread; import com.aayushatharva.brotli4j.Brotli4jLoader; import com.aayushatharva.brotli4j.decoder.BrotliInputStream; -import com.blankj.utilcode.util.ResourceUtils; import com.itsaky.androidide.app.BaseApplication; import com.itsaky.androidide.app.configuration.IDEBuildConfigProvider; import com.itsaky.androidide.app.configuration.IJdkDistributionProvider; @@ -34,6 +33,7 @@ import com.itsaky.androidide.utils.FileIOUtils; import com.itsaky.androidide.utils.FileUtils; import com.itsaky.androidide.utils.IoUtilsKt; +import com.itsaky.androidide.utils.ResourceUtils; import com.itsaky.androidide.utils.SelfSignedCertUtils; import java.io.File; import java.io.FileOutputStream; diff --git a/common/src/main/java/com/itsaky/androidide/utils/ImageUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/ImageUtils.kt new file mode 100644 index 0000000000..463c615eea --- /dev/null +++ b/common/src/main/java/com/itsaky/androidide/utils/ImageUtils.kt @@ -0,0 +1,96 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import java.io.File +import java.io.RandomAccessFile + +object ImageUtils { + enum class ImageType( + val value: String, + ) { + TYPE_JPG("jpg"), + TYPE_PNG("png"), + TYPE_GIF("gif"), + TYPE_TIFF("tiff"), + TYPE_BMP("bmp"), + TYPE_WEBP("webp"), + TYPE_ICO("ico"), + TYPE_UNKNOWN(""), + } + + /** + * Checks whether [file] can be decoded as a bitmap image. + */ + @JvmStatic + fun isImage(file: File): Boolean { + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(file.absolutePath, options) + return options.outWidth > 0 && options.outHeight > 0 + } + + /** + * Decodes [file] as a [Bitmap], or `null` if it isn't a valid/supported image. + */ + @JvmStatic + fun getBitmap(file: File): Bitmap? = BitmapFactory.decodeFile(file.absolutePath) + + /** + * Determines [file]'s image type by sniffing its magic-number header bytes, falling back to + * [ImageType.TYPE_UNKNOWN] if the file is missing, unreadable, or doesn't match a known format. + */ + @JvmStatic + fun getImageType(file: File): ImageType { + if (!file.isFile) return ImageType.TYPE_UNKNOWN + + val header = ByteArray(12) + val read = + try { + RandomAccessFile(file, "r").use { it.read(header) } + } catch (e: Exception) { + return ImageType.TYPE_UNKNOWN + } + if (read < 4) return ImageType.TYPE_UNKNOWN + + fun byteAt(i: Int): Int = header[i].toInt() and 0xFF + + return when { + byteAt(0) == 0xFF && byteAt(1) == 0xD8 && byteAt(2) == 0xFF -> ImageType.TYPE_JPG + + byteAt(0) == 0x89 && byteAt(1) == 0x50 && byteAt(2) == 0x4E && byteAt(3) == 0x47 -> ImageType.TYPE_PNG + + byteAt(0) == 0x47 && byteAt(1) == 0x49 && byteAt(2) == 0x46 -> ImageType.TYPE_GIF + + (byteAt(0) == 0x49 && byteAt(1) == 0x49) || (byteAt(0) == 0x4D && byteAt(1) == 0x4D) -> ImageType.TYPE_TIFF + + byteAt(0) == 0x42 && byteAt(1) == 0x4D -> ImageType.TYPE_BMP + + byteAt(0) == 0x00 && byteAt(1) == 0x00 && byteAt(2) == 0x01 && byteAt(3) == 0x00 -> ImageType.TYPE_ICO + + read >= 12 && + header[0] == 'R'.code.toByte() && header[1] == 'I'.code.toByte() && + header[2] == 'F'.code.toByte() && header[3] == 'F'.code.toByte() && + header[8] == 'W'.code.toByte() && header[9] == 'E'.code.toByte() && + header[10] == 'B'.code.toByte() && header[11] == 'P'.code.toByte() -> ImageType.TYPE_WEBP + + else -> ImageType.TYPE_UNKNOWN + } + } +} diff --git a/common/src/main/java/com/itsaky/androidide/utils/ReflectUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/ReflectUtils.kt new file mode 100644 index 0000000000..b46a9cd442 --- /dev/null +++ b/common/src/main/java/com/itsaky/androidide/utils/ReflectUtils.kt @@ -0,0 +1,164 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import java.lang.reflect.Constructor +import java.lang.reflect.Field +import java.lang.reflect.Method + +/** + * A small fluent reflection helper, covering the subset of behavior this codebase relies on: + * wrapping an object or class, getting/setting a field by name, invoking a method by name, and + * instantiating via a matching constructor. Field/method/constructor resolution walks up the + * class hierarchy and accepts any assignable argument type (auto-boxing primitives). + */ +class ReflectUtils private constructor( + private val type: Class<*>, + private val target: Any?, +) { + class ReflectException( + cause: Throwable, + ) : RuntimeException(cause) + + companion object { + @JvmStatic + fun reflect(clazz: Class<*>): ReflectUtils = ReflectUtils(clazz, null) + + @JvmStatic + fun reflect(obj: Any): ReflectUtils = ReflectUtils(obj.javaClass, obj) + + private fun wrapper(cls: Class<*>): Class<*> = + when (cls) { + Int::class.javaPrimitiveType -> Int::class.javaObjectType + Long::class.javaPrimitiveType -> Long::class.javaObjectType + Double::class.javaPrimitiveType -> Double::class.javaObjectType + Float::class.javaPrimitiveType -> Float::class.javaObjectType + Boolean::class.javaPrimitiveType -> Boolean::class.javaObjectType + Short::class.javaPrimitiveType -> Short::class.javaObjectType + Byte::class.javaPrimitiveType -> Byte::class.javaObjectType + Char::class.javaPrimitiveType -> Char::class.javaObjectType + else -> cls + } + + private fun paramsMatch( + paramTypes: Array>, + argTypes: List?>, + ): Boolean { + if (paramTypes.size != argTypes.size) return false + for (i in paramTypes.indices) { + val argType = argTypes[i] ?: continue + if (!wrapper(paramTypes[i]).isAssignableFrom(wrapper(argType))) return false + } + return true + } + } + + private fun findField(name: String): Field { + var current: Class<*>? = type + while (current != null) { + try { + return current.getDeclaredField(name) + } catch (e: NoSuchFieldException) { + current = current.superclass + } + } + throw ReflectException(NoSuchFieldException("No field '$name' found on $type or its superclasses")) + } + + private fun findMethod( + name: String, + argTypes: List?>, + ): Method { + var current: Class<*>? = type + while (current != null) { + for (m in current.declaredMethods) { + if (m.name == name && paramsMatch(m.parameterTypes, argTypes)) { + return m + } + } + current = current.superclass + } + throw ReflectException(NoSuchMethodException("No method '$name' found on $type matching $argTypes")) + } + + private fun findConstructor(argTypes: List?>): Constructor<*> { + for (c in type.declaredConstructors) { + if (paramsMatch(c.parameterTypes, argTypes)) return c + } + throw ReflectException(NoSuchMethodException("No constructor found on $type matching $argTypes")) + } + + fun newInstance(vararg args: Any?): ReflectUtils = + try { + val constructor = findConstructor(args.map { it?.javaClass }) + constructor.isAccessible = true + reflect(constructor.newInstance(*args) ?: return ReflectUtils(Any::class.java, null)) + } catch (e: ReflectException) { + throw e + } catch (e: Exception) { + throw ReflectException(e) + } + + fun field(name: String): ReflectUtils = + try { + val f = findField(name) + f.isAccessible = true + ReflectUtils(f.type, f.get(target)) + } catch (e: ReflectException) { + throw e + } catch (e: Exception) { + throw ReflectException(e) + } + + fun field( + name: String, + value: Any?, + ): ReflectUtils = + try { + val f = findField(name) + f.isAccessible = true + f.set(target, value) + this + } catch (e: ReflectException) { + throw e + } catch (e: Exception) { + throw ReflectException(e) + } + + fun method( + name: String, + vararg args: Any?, + ): ReflectUtils = + try { + val m = findMethod(name, args.map { it?.javaClass }) + m.isAccessible = true + val result = m.invoke(target, *args) + if (m.returnType == Void.TYPE || result == null) { + ReflectUtils(type, target) + } else { + reflect(result) + } + } catch (e: ReflectException) { + throw e + } catch (e: Exception) { + throw ReflectException(e) + } + + @Suppress("UNCHECKED_CAST") + fun get(): T = target as T +} diff --git a/common/src/main/java/com/itsaky/androidide/utils/ResourceUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/ResourceUtils.kt new file mode 100644 index 0000000000..9166f72170 --- /dev/null +++ b/common/src/main/java/com/itsaky/androidide/utils/ResourceUtils.kt @@ -0,0 +1,72 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import com.itsaky.androidide.app.BaseApplication +import org.slf4j.LoggerFactory +import java.io.File + +object ResourceUtils { + private val logger = LoggerFactory.getLogger(ResourceUtils::class.java) + + /** + * Copies the asset at [assetPath] to [destPath]. If [assetPath] names a directory (i.e. it has + * listable children), copies it recursively. + */ + @JvmStatic + fun copyFileFromAssets( + assetPath: String, + destPath: String, + ): Boolean { + val assets = BaseApplication.baseInstance.assets + return try { + val children = assets.list(assetPath) + if (!children.isNullOrEmpty()) { + var result = true + for (child in children) { + result = copyFileFromAssets("$assetPath/$child", "$destPath/$child") && result + } + result + } else { + val destFile = File(destPath) + destFile.parentFile?.mkdirs() + assets.open(assetPath).use { input -> + destFile.outputStream().use { output -> input.copyTo(output) } + } + true + } + } catch (e: Exception) { + logger.warn("Failed to copy asset '{}' to '{}'", assetPath, destPath, e) + false + } + } + + /** + * Reads the asset at [assetPath] fully as a UTF-8 string, or an empty string if it can't be read. + */ + @JvmStatic + fun readAssets2String(assetPath: String): String = + try { + BaseApplication.baseInstance.assets + .open(assetPath) + .use { it.readBytes().toString(Charsets.UTF_8) } + } catch (e: Exception) { + logger.warn("Failed to read asset '{}'", assetPath, e) + "" + } +} diff --git a/common/src/main/java/com/itsaky/androidide/utils/ZipUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/ZipUtils.kt new file mode 100644 index 0000000000..a21c2ab5f0 --- /dev/null +++ b/common/src/main/java/com/itsaky/androidide/utils/ZipUtils.kt @@ -0,0 +1,65 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import java.io.File +import java.io.IOException +import java.util.zip.ZipFile + +object ZipUtils { + /** + * Extracts every entry of [zipFile] into [destDir], preserving directory structure, and + * returns the list of extracted files. Rejects entries that would extract outside [destDir] + * (zip-slip). + */ + @JvmStatic + @Throws(IOException::class) + fun unzipFile( + zipFile: File, + destDir: File, + ): List { + destDir.mkdirs() + val destDirPath = destDir.canonicalPath + File.separator + val result = mutableListOf() + + ZipFile(zipFile).use { zip -> + val entries = zip.entries() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + val outFile = File(destDir, entry.name) + + if (!outFile.canonicalPath.startsWith(destDirPath)) { + throw IOException("Zip entry is outside of the target directory: ${entry.name}") + } + + if (entry.isDirectory) { + outFile.mkdirs() + } else { + outFile.parentFile?.mkdirs() + zip.getInputStream(entry).use { input -> + outFile.outputStream().use { output -> input.copyTo(output) } + } + } + + result.add(outFile) + } + } + + return result + } +} diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/providers/CompletionProvider.java b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/providers/CompletionProvider.java index 12abf195c2..2f5890742f 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/providers/CompletionProvider.java +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/providers/CompletionProvider.java @@ -1,354 +1,340 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ - -package com.itsaky.androidide.lsp.java.providers; - -import static com.itsaky.androidide.lsp.api.HelpersKt.describeSnippet; - -import androidx.annotation.NonNull; - -import com.blankj.utilcode.util.ReflectUtils; -import com.itsaky.androidide.lsp.api.AbstractServiceProvider; -import com.itsaky.androidide.lsp.api.ICompletionProvider; -import com.itsaky.androidide.lsp.api.IServerSettings; -import com.itsaky.androidide.lsp.internal.model.CachedCompletion; -import com.itsaky.androidide.lsp.java.compiler.CompileTask; -import com.itsaky.androidide.lsp.java.compiler.CompletionInfo; -import com.itsaky.androidide.lsp.java.compiler.JavaCompilerConfig; -import com.itsaky.androidide.lsp.java.compiler.JavaCompilerService; -import com.itsaky.androidide.lsp.java.compiler.SourceFileObject; -import com.itsaky.androidide.lsp.java.compiler.SynchronizedTask; -import com.itsaky.androidide.lsp.java.models.CompilationRequest; -import com.itsaky.androidide.lsp.java.models.PartialReparseRequest; -import com.itsaky.androidide.lsp.java.providers.completion.IJavaCompletionProvider; -import com.itsaky.androidide.lsp.java.providers.completion.IdentifierCompletionProvider; -import com.itsaky.androidide.lsp.java.providers.completion.ImportCompletionProvider; -import com.itsaky.androidide.lsp.java.providers.completion.KeywordCompletionProvider; -import com.itsaky.androidide.lsp.java.providers.completion.MemberReferenceCompletionProvider; -import com.itsaky.androidide.lsp.java.providers.completion.MemberSelectCompletionProvider; -import com.itsaky.androidide.lsp.java.providers.completion.SwitchConstantCompletionProvider; -import com.itsaky.androidide.lsp.java.utils.ASTFixer; -import com.itsaky.androidide.lsp.java.utils.CancelChecker; -import com.itsaky.androidide.lsp.java.visitors.FindCompletionsAt; -import com.itsaky.androidide.lsp.models.CompletionParams; -import com.itsaky.androidide.lsp.models.CompletionResult; -import com.itsaky.androidide.utils.DocumentUtils; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.nio.file.Path; -import java.time.Duration; -import java.time.Instant; -import java.util.Collections; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Consumer; - -import io.github.rosemoe.sora.lang.completion.snippet.CodeSnippet; -import openjdk.source.tree.Tree; -import openjdk.source.util.TreePath; - -public class CompletionProvider extends AbstractServiceProvider implements ICompletionProvider { - - public static final int MAX_COMPLETION_ITEMS = CompletionResult.MAX_ITEMS; - private static final Logger LOG = LoggerFactory.getLogger(CompletionProvider.class); - private final AtomicBoolean completing = new AtomicBoolean(false); - private JavaCompilerService compiler; - private CachedCompletion cache; - private Consumer nextCacheConsumer; - - public CompletionProvider() { - super(); - } - - public synchronized CompletionProvider reset(JavaCompilerService compiler, - IServerSettings settings, CachedCompletion cache, - Consumer nextCacheConsumer - ) { - this.compiler = compiler; - this.cache = cache; - this.nextCacheConsumer = nextCacheConsumer; - - super.applySettings(settings); - return this; - } - - @Override - public boolean canComplete(Path file) { - return ICompletionProvider.super.canComplete(file) && DocumentUtils.isJavaFile(file); - } - - @NonNull - @Override - public CompletionResult complete(@NonNull CompletionParams params) { - final var synchronizedTask = compiler.getSynchronizedTask(); - if (synchronizedTask.isBusy()) { - LOG.error("Cannot complete, a compilation task is already in progress"); - synchronizedTask.logStats(); - return CompletionResult.EMPTY; - } - - completing.set(true); - try { - - abortCompletionIfCancelled(); - return completeInternal(params); - } catch (Throwable err) { - if (CancelChecker.isCancelled(err)) { - LOG.info("Completion request cancelled"); - } else { - LOG.error("An error occurred while computing completions", err); - } - throw err; - } finally { - completing.set(false); - } - } - - @NonNull - private CompletionResult completeInternal(final @NonNull CompletionParams params) { - Path file = params.getFile(); - int line = params.getPosition().getLine(); - int column = params.getPosition().getColumn(); - LOG.info("Complete at {}({},{})...", file.getFileName(), line, column); - - Instant started = Instant.now(); - - if (this.cache != null && this.cache.canUseCache(params)) { - final String prefix = params.requirePrefix(); - final String partial = partialIdentifier(prefix, prefix.length()); - final CompletionResult result = CompletionResult.mapAndFilter(this.cache.getResult(), partial, - item -> { - final var description = item.getSnippetDescription(); - var deleteSelected = true; - var allowCommands = false; - CodeSnippet snippet = null; - - if (description != null) { - deleteSelected = description.getDeleteSelected(); - allowCommands = description.getAllowCommandExecution(); - snippet = description.getSnippet(); - } - - item.setSnippetDescription( - describeSnippet(partial, deleteSelected, snippet, allowCommands)); - }); - - result.markCached(); - - if (!result.isIncomplete() && !result.getItems().isEmpty()) { - LOG.info("...using cached completion"); - logCompletionDuration(started, result); - return result; - } else { - LOG.info("...cached completions are empty"); - } - } else { - LOG.info("...cannot use cached completions"); - } - - - abortCompletionIfCancelled(); - final long cursor = params.getPosition().requireIndex(); - final var sourceObject = new SourceFileObject(file); - final var contentBuilder = new StringBuilder(sourceObject.getCharContent(true)); - - int endOfLine = endOfLine(contentBuilder, (int) cursor); - contentBuilder.insert(endOfLine, ';'); - - final StringBuilder contents; - final var context = compiler.compiler.currentContext; - if (context != null) { - - abortCompletionIfCancelled(); - contents = new ASTFixer(context).fix(contentBuilder); - } else { - contents = contentBuilder; - } - - final String contentString = contents.toString(); - final PartialReparseRequest partialRequest = new PartialReparseRequest( - cursor - params.requirePrefix().length(), contentString); - - abortCompletionIfCancelled(); - - CompletionResult result = compileAndComplete(contentString, params, partialRequest); - if (result == null) { - result = CompletionResult.EMPTY; - } - - - abortCompletionIfCancelled(); - logCompletionDuration(started, result); - - - abortCompletionIfCancelled(); - if (this.nextCacheConsumer != null) { - this.nextCacheConsumer.accept(CachedCompletion.cache(params, result)); - } - - return result; - } - - @NonNull - private String partialIdentifier(String contents, int end) { - int start = end; - while (start > 0 && Character.isJavaIdentifierPart(contents.charAt(start - 1))) { - start--; - } - return contents.substring(start, end); - } - - private void logCompletionDuration(Instant started, @NonNull CompletionResult result) { - long elapsedMs = Duration.between(started, Instant.now()).toMillis(); - LOG.info("Found {} items{}{}in {} ms", - result.getItems().size(), - result.isIncomplete() ? " (incomplete) " : "", - result.isCached() ? " (cached) " : " ", - elapsedMs - ); - } - - private int endOfLine(@NonNull CharSequence contents, int cursor) { - while (cursor < contents.length()) { - char c = contents.charAt(cursor); - if (c == '\r' || c == '\n') { - break; - } - cursor++; - } - return cursor; - } - - private CompletionResult compileAndComplete(String contents, CompletionParams params, - PartialReparseRequest partialRequest - ) { - final long cursor = params.getPosition().requireIndex(); - final var file = params.getFile(); - final var started = Instant.now(); - final var source = new SourceFileObject(file, contents, Instant.now()); - final var partial = partialIdentifier(contents, (int) cursor); - final var endsWithParen = endsWithParen(contents, (int) cursor); - - - abortCompletionIfCancelled(); - - final CompilationRequest request = new CompilationRequest(Collections.singletonList(source), - partialRequest); - request.configureContext = ctx -> { - final var config = JavaCompilerConfig.instance(ctx); - config.setCompletionInfo(new CompletionInfo(params.getPosition())); - }; - - SynchronizedTask synchronizedTask = compiler.compile(request); - return synchronizedTask.get(task -> { - if (task == null || task.task == null || task.task.getContext() == null) { - LOG.warn("Compilation resulted in an invalid JavacTask"); - return CompletionResult.EMPTY; - } - - abortCompletionIfCancelled(); - LOG.info("...compiled in {}ms", Duration.between(started, Instant.now()).toMillis()); - TreePath path = new FindCompletionsAt(task.task).scan(task.root(), cursor); - - - abortCompletionIfCancelled(); - String newPartial = partial; - if (path.getLeaf().getKind() == Tree.Kind.IMPORT) { - newPartial = qualifiedPartialIdentifier(contents, (int) cursor); - if (newPartial.endsWith(ASTFixer.IDENT)) { - newPartial = newPartial.substring(0, newPartial.length() - ASTFixer.IDENT.length()); - } - } - - final var result = doComplete(file, contents, cursor, newPartial, endsWithParen, task, path); - - // IMPORTANT: Unregister the completion info from the compiler configuration - if (task.task.getContext() != null) { - final var compilerConfig = JavaCompilerConfig.instance(task.task.getContext()); - compilerConfig.setCompletionInfo(null); - } - - return result; - }); - } - - @NonNull - private CompletionResult doComplete(final Path file, final String contents, final long cursor, - final String partial, final boolean endsWithParen, - final CompileTask task, final TreePath path - ) { - final Class klass; - - abortCompletionIfCancelled(); - switch (path.getLeaf().getKind()) { - case IDENTIFIER: - klass = IdentifierCompletionProvider.class; - break; - case MEMBER_SELECT: - klass = MemberSelectCompletionProvider.class; - break; - case MEMBER_REFERENCE: - klass = MemberReferenceCompletionProvider.class; - break; - case SWITCH: - klass = SwitchConstantCompletionProvider.class; - break; - case IMPORT: - klass = ImportCompletionProvider.class; - break; - default: - klass = KeywordCompletionProvider.class; - break; - } - - final IJavaCompletionProvider provider = ReflectUtils.reflect(klass) - .newInstance(file, cursor, compiler, getSettings()) - .get(); - - if (provider instanceof ImportCompletionProvider) { - ((ImportCompletionProvider) provider).setImportPath( - qualifiedPartialIdentifier(contents, (int) cursor)); - } - - - abortCompletionIfCancelled(); - return provider.complete(task, path, partial, endsWithParen); - } - - private boolean endsWithParen(@NonNull String contents, int cursor) { - for (int i = cursor; i < contents.length(); i++) { - if (!Character.isJavaIdentifierPart(contents.charAt(i))) { - return contents.charAt(i) == '('; - } - } - return false; - } - - @NonNull - private String qualifiedPartialIdentifier(String contents, int end) { - int start = end; - while (start > 0 && isQualifiedIdentifierChar(contents.charAt(start - 1))) { - start--; - } - return contents.substring(start, end); - } - - private boolean isQualifiedIdentifierChar(char c) { - return c == '.' || Character.isJavaIdentifierPart(c); - } -} +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.lsp.java.providers; + +import static com.itsaky.androidide.lsp.api.HelpersKt.describeSnippet; + +import androidx.annotation.NonNull; +import com.itsaky.androidide.lsp.api.AbstractServiceProvider; +import com.itsaky.androidide.lsp.api.ICompletionProvider; +import com.itsaky.androidide.lsp.api.IServerSettings; +import com.itsaky.androidide.lsp.internal.model.CachedCompletion; +import com.itsaky.androidide.lsp.java.compiler.CompileTask; +import com.itsaky.androidide.lsp.java.compiler.CompletionInfo; +import com.itsaky.androidide.lsp.java.compiler.JavaCompilerConfig; +import com.itsaky.androidide.lsp.java.compiler.JavaCompilerService; +import com.itsaky.androidide.lsp.java.compiler.SourceFileObject; +import com.itsaky.androidide.lsp.java.compiler.SynchronizedTask; +import com.itsaky.androidide.lsp.java.models.CompilationRequest; +import com.itsaky.androidide.lsp.java.models.PartialReparseRequest; +import com.itsaky.androidide.lsp.java.providers.completion.IJavaCompletionProvider; +import com.itsaky.androidide.lsp.java.providers.completion.IdentifierCompletionProvider; +import com.itsaky.androidide.lsp.java.providers.completion.ImportCompletionProvider; +import com.itsaky.androidide.lsp.java.providers.completion.KeywordCompletionProvider; +import com.itsaky.androidide.lsp.java.providers.completion.MemberReferenceCompletionProvider; +import com.itsaky.androidide.lsp.java.providers.completion.MemberSelectCompletionProvider; +import com.itsaky.androidide.lsp.java.providers.completion.SwitchConstantCompletionProvider; +import com.itsaky.androidide.lsp.java.utils.ASTFixer; +import com.itsaky.androidide.lsp.java.utils.CancelChecker; +import com.itsaky.androidide.lsp.java.visitors.FindCompletionsAt; +import com.itsaky.androidide.lsp.models.CompletionParams; +import com.itsaky.androidide.lsp.models.CompletionResult; +import com.itsaky.androidide.utils.DocumentUtils; +import com.itsaky.androidide.utils.ReflectUtils; +import io.github.rosemoe.sora.lang.completion.snippet.CodeSnippet; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import openjdk.source.tree.Tree; +import openjdk.source.util.TreePath; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class CompletionProvider extends AbstractServiceProvider implements ICompletionProvider { + + public static final int MAX_COMPLETION_ITEMS = CompletionResult.MAX_ITEMS; + private static final Logger LOG = LoggerFactory.getLogger(CompletionProvider.class); + private final AtomicBoolean completing = new AtomicBoolean(false); + private JavaCompilerService compiler; + private CachedCompletion cache; + private Consumer nextCacheConsumer; + + public CompletionProvider() { + super(); + } + + @Override + public boolean canComplete(Path file) { + return ICompletionProvider.super.canComplete(file) && DocumentUtils.isJavaFile(file); + } + + @NonNull + @Override + public CompletionResult complete(@NonNull CompletionParams params) { + final var synchronizedTask = compiler.getSynchronizedTask(); + if (synchronizedTask.isBusy()) { + LOG.error("Cannot complete, a compilation task is already in progress"); + synchronizedTask.logStats(); + return CompletionResult.EMPTY; + } + + completing.set(true); + try { + + abortCompletionIfCancelled(); + return completeInternal(params); + } catch (Throwable err) { + if (CancelChecker.isCancelled(err)) { + LOG.info("Completion request cancelled"); + } else { + LOG.error("An error occurred while computing completions", err); + } + throw err; + } finally { + completing.set(false); + } + } + + public synchronized CompletionProvider reset(JavaCompilerService compiler, + IServerSettings settings, CachedCompletion cache, + Consumer nextCacheConsumer) { + this.compiler = compiler; + this.cache = cache; + this.nextCacheConsumer = nextCacheConsumer; + + super.applySettings(settings); + return this; + } + + private CompletionResult compileAndComplete(String contents, CompletionParams params, + PartialReparseRequest partialRequest) { + final long cursor = params.getPosition().requireIndex(); + final var file = params.getFile(); + final var started = Instant.now(); + final var source = new SourceFileObject(file, contents, Instant.now()); + final var partial = partialIdentifier(contents, (int) cursor); + final var endsWithParen = endsWithParen(contents, (int) cursor); + + abortCompletionIfCancelled(); + + final CompilationRequest request = new CompilationRequest(Collections.singletonList(source), + partialRequest); + request.configureContext = ctx -> { + final var config = JavaCompilerConfig.instance(ctx); + config.setCompletionInfo(new CompletionInfo(params.getPosition())); + }; + + SynchronizedTask synchronizedTask = compiler.compile(request); + return synchronizedTask.get(task -> { + if (task == null || task.task == null || task.task.getContext() == null) { + LOG.warn("Compilation resulted in an invalid JavacTask"); + return CompletionResult.EMPTY; + } + + abortCompletionIfCancelled(); + LOG.info("...compiled in {}ms", Duration.between(started, Instant.now()).toMillis()); + TreePath path = new FindCompletionsAt(task.task).scan(task.root(), cursor); + + abortCompletionIfCancelled(); + String newPartial = partial; + if (path.getLeaf().getKind() == Tree.Kind.IMPORT) { + newPartial = qualifiedPartialIdentifier(contents, (int) cursor); + if (newPartial.endsWith(ASTFixer.IDENT)) { + newPartial = newPartial.substring(0, newPartial.length() - ASTFixer.IDENT.length()); + } + } + + final var result = doComplete(file, contents, cursor, newPartial, endsWithParen, task, path); + + // IMPORTANT: Unregister the completion info from the compiler configuration + if (task.task.getContext() != null) { + final var compilerConfig = JavaCompilerConfig.instance(task.task.getContext()); + compilerConfig.setCompletionInfo(null); + } + + return result; + }); + } + + @NonNull + private CompletionResult completeInternal(final @NonNull CompletionParams params) { + Path file = params.getFile(); + int line = params.getPosition().getLine(); + int column = params.getPosition().getColumn(); + LOG.info("Complete at {}({},{})...", file.getFileName(), line, column); + + Instant started = Instant.now(); + + if (this.cache != null && this.cache.canUseCache(params)) { + final String prefix = params.requirePrefix(); + final String partial = partialIdentifier(prefix, prefix.length()); + final CompletionResult result = CompletionResult.mapAndFilter(this.cache.getResult(), partial, + item -> { + final var description = item.getSnippetDescription(); + var deleteSelected = true; + var allowCommands = false; + CodeSnippet snippet = null; + + if (description != null) { + deleteSelected = description.getDeleteSelected(); + allowCommands = description.getAllowCommandExecution(); + snippet = description.getSnippet(); + } + + item.setSnippetDescription( + describeSnippet(partial, deleteSelected, snippet, allowCommands)); + }); + + result.markCached(); + + if (!result.isIncomplete() && !result.getItems().isEmpty()) { + LOG.info("...using cached completion"); + logCompletionDuration(started, result); + return result; + } else { + LOG.info("...cached completions are empty"); + } + } else { + LOG.info("...cannot use cached completions"); + } + + abortCompletionIfCancelled(); + final long cursor = params.getPosition().requireIndex(); + final var sourceObject = new SourceFileObject(file); + final var contentBuilder = new StringBuilder(sourceObject.getCharContent(true)); + + int endOfLine = endOfLine(contentBuilder, (int) cursor); + contentBuilder.insert(endOfLine, ';'); + + final StringBuilder contents; + final var context = compiler.compiler.currentContext; + if (context != null) { + + abortCompletionIfCancelled(); + contents = new ASTFixer(context).fix(contentBuilder); + } else { + contents = contentBuilder; + } + + final String contentString = contents.toString(); + final PartialReparseRequest partialRequest = new PartialReparseRequest( + cursor - params.requirePrefix().length(), contentString); + + abortCompletionIfCancelled(); + + CompletionResult result = compileAndComplete(contentString, params, partialRequest); + if (result == null) { + result = CompletionResult.EMPTY; + } + + abortCompletionIfCancelled(); + logCompletionDuration(started, result); + + abortCompletionIfCancelled(); + if (this.nextCacheConsumer != null) { + this.nextCacheConsumer.accept(CachedCompletion.cache(params, result)); + } + + return result; + } + + @NonNull + private CompletionResult doComplete(final Path file, final String contents, final long cursor, + final String partial, final boolean endsWithParen, + final CompileTask task, final TreePath path) { + final Class klass; + + abortCompletionIfCancelled(); + switch (path.getLeaf().getKind()) { + case IDENTIFIER: + klass = IdentifierCompletionProvider.class; + break; + case MEMBER_SELECT: + klass = MemberSelectCompletionProvider.class; + break; + case MEMBER_REFERENCE: + klass = MemberReferenceCompletionProvider.class; + break; + case SWITCH: + klass = SwitchConstantCompletionProvider.class; + break; + case IMPORT: + klass = ImportCompletionProvider.class; + break; + default: + klass = KeywordCompletionProvider.class; + break; + } + + final IJavaCompletionProvider provider = ReflectUtils.reflect(klass) + .newInstance(file, cursor, compiler, getSettings()) + .get(); + + if (provider instanceof ImportCompletionProvider) { + ((ImportCompletionProvider) provider).setImportPath( + qualifiedPartialIdentifier(contents, (int) cursor)); + } + + abortCompletionIfCancelled(); + return provider.complete(task, path, partial, endsWithParen); + } + + private int endOfLine(@NonNull CharSequence contents, int cursor) { + while (cursor < contents.length()) { + char c = contents.charAt(cursor); + if (c == '\r' || c == '\n') { + break; + } + cursor++; + } + return cursor; + } + + private boolean endsWithParen(@NonNull String contents, int cursor) { + for (int i = cursor; i < contents.length(); i++) { + if (!Character.isJavaIdentifierPart(contents.charAt(i))) { + return contents.charAt(i) == '('; + } + } + return false; + } + + private boolean isQualifiedIdentifierChar(char c) { + return c == '.' || Character.isJavaIdentifierPart(c); + } + + private void logCompletionDuration(Instant started, @NonNull CompletionResult result) { + long elapsedMs = Duration.between(started, Instant.now()).toMillis(); + LOG.info("Found {} items{}{}in {} ms", + result.getItems().size(), + result.isIncomplete() ? " (incomplete) " : "", + result.isCached() ? " (cached) " : " ", + elapsedMs); + } + + @NonNull + private String partialIdentifier(String contents, int end) { + int start = end; + while (start > 0 && Character.isJavaIdentifierPart(contents.charAt(start - 1))) { + start--; + } + return contents.substring(start, end); + } + + @NonNull + private String qualifiedPartialIdentifier(String contents, int end) { + int start = end; + while (start > 0 && isQualifiedIdentifierChar(contents.charAt(start - 1))) { + start--; + } + return contents.substring(start, end); + } +} diff --git a/subprojects/javac-services/src/main/java/com/itsaky/androidide/javac/services/visitors/UnEnter.kt b/subprojects/javac-services/src/main/java/com/itsaky/androidide/javac/services/visitors/UnEnter.kt index ab60bc0cf0..c0f851f051 100644 --- a/subprojects/javac-services/src/main/java/com/itsaky/androidide/javac/services/visitors/UnEnter.kt +++ b/subprojects/javac-services/src/main/java/com/itsaky/androidide/javac/services/visitors/UnEnter.kt @@ -16,7 +16,7 @@ */ package com.itsaky.androidide.javac.services.visitors -import com.blankj.utilcode.util.ReflectUtils +import com.itsaky.androidide.utils.ReflectUtils import openjdk.tools.javac.code.Symbol.ModuleSymbol import openjdk.tools.javac.comp.Enter import openjdk.tools.javac.tree.JCTree.JCClassDecl @@ -30,18 +30,21 @@ import openjdk.tools.javac.tree.TreeScanner * * @author Akash Yadav */ -class UnEnter(private val enter: Enter, private val msym: ModuleSymbol) : TreeScanner() { - override fun visitClassDef(tree: JCClassDecl) { - val csym = tree.sym ?: return +class UnEnter( + private val enter: Enter, + private val msym: ModuleSymbol, +) : TreeScanner() { + override fun visitClassDef(tree: JCClassDecl) { + val csym = tree.sym ?: return - val etr = ReflectUtils.reflect(enter) - ReflectUtils.reflect(etr.field("typeEnvs")).method("remove", csym) + val etr = ReflectUtils.reflect(enter) + ReflectUtils.reflect(etr.field("typeEnvs")).method("remove", csym) - val chk = etr.field("chk") - ReflectUtils.reflect(chk).method("removeCompiled", csym) - ReflectUtils.reflect(chk).method("clearLocalClassNameIndexes", csym) + val chk = etr.field("chk") + ReflectUtils.reflect(chk).method("removeCompiled", csym) + ReflectUtils.reflect(chk).method("clearLocalClassNameIndexes", csym) - ReflectUtils.reflect(etr.field("syms")).method("removeClass", msym, csym.flatname) - super.visitClassDef(tree) - } + ReflectUtils.reflect(etr.field("syms")).method("removeClass", msym, csym.flatname) + super.visitClassDef(tree) + } } diff --git a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/drawable/DrawableParserFactory.java b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/drawable/DrawableParserFactory.java index 3e341bdfae..985ade1a7b 100644 --- a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/drawable/DrawableParserFactory.java +++ b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/drawable/DrawableParserFactory.java @@ -24,9 +24,9 @@ import android.graphics.drawable.NinePatchDrawable; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.blankj.utilcode.util.ImageUtils; import com.itsaky.androidide.inflater.vectormaster.VectorMasterDrawable; import com.itsaky.androidide.utils.FileIOUtils; +import com.itsaky.androidide.utils.ImageUtils; import java.io.File; import java.io.IOException; import java.io.Reader; diff --git a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/adapters/GestureOverlayViewAdapter.kt b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/adapters/GestureOverlayViewAdapter.kt index f87d49e303..4b1ef69884 100644 --- a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/adapters/GestureOverlayViewAdapter.kt +++ b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/adapters/GestureOverlayViewAdapter.kt @@ -18,9 +18,9 @@ package com.itsaky.androidide.inflater.internal.adapters import android.gesture.GestureOverlayView -import com.blankj.utilcode.util.ReflectUtils import com.itsaky.androidide.annotations.inflater.ViewAdapter import com.itsaky.androidide.inflater.AttributeHandlerScope +import com.itsaky.androidide.utils.ReflectUtils /** * Attribute adapter for [GestureOverlayView]. @@ -29,36 +29,37 @@ import com.itsaky.androidide.inflater.AttributeHandlerScope */ @ViewAdapter(GestureOverlayView::class) open class GestureOverlayViewAdapter : FrameLayoutAdapter() { + override fun createAttrHandlers(create: (String, AttributeHandlerScope.() -> Unit) -> Unit) { + super.createAttrHandlers(create) + create("eventsInterceptionEnabled") { view.isEventsInterceptionEnabled = parseBoolean(value) } + create("fadeDuration") { + ReflectUtils.reflect(view).field("mFadeDuration", parseLong(value, 150)) + } + create("fadeEnabled") { view.isFadeEnabled = parseBoolean(value) } + create("fadeOffset") { view.fadeOffset = parseLong(value, 420) } + create("gestureColor") { view.gestureColor = parseColor(context, value) } + create("gestureStrokeAngleThreshold") { view.gestureStrokeAngleThreshold = parseFloat(value) } + create("gestureStrokeLengthThreshold") { view.gestureStrokeLengthThreshold = parseFloat(value) } + create("gestureStrokeSquarenessThreshold") { + view.gestureStrokeSquarenessTreshold = parseFloat(value) + } + create("gestureStrokeType") { view.gestureStrokeType = parseGestureStrokeType(value) } + create("gestureStrokeWidth") { view.gestureStrokeWidth = parseFloat(value) } + create("orientation") { view.orientation = parseOrientation(value) } + create("uncertainGestureColor") { view.uncertainGestureColor = parseColor(context, value) } + } - override fun createAttrHandlers(create: (String, AttributeHandlerScope.() -> Unit) -> Unit) { - super.createAttrHandlers(create) - create("eventsInterceptionEnabled") { view.isEventsInterceptionEnabled = parseBoolean(value) } - create("fadeDuration") { - ReflectUtils.reflect(view).field("mFadeDuration", parseLong(value, 150)) - } - create("fadeEnabled") { view.isFadeEnabled = parseBoolean(value) } - create("fadeOffset") { view.fadeOffset = parseLong(value, 420) } - create("gestureColor") { view.gestureColor = parseColor(context, value) } - create("gestureStrokeAngleThreshold") { view.gestureStrokeAngleThreshold = parseFloat(value) } - create("gestureStrokeLengthThreshold") { view.gestureStrokeLengthThreshold = parseFloat(value) } - create("gestureStrokeSquarenessThreshold") { - view.gestureStrokeSquarenessTreshold = parseFloat(value) - } - create("gestureStrokeType") { view.gestureStrokeType = parseGestureStrokeType(value) } - create("gestureStrokeWidth") { view.gestureStrokeWidth = parseFloat(value) } - create("orientation") { view.orientation = parseOrientation(value) } - create("uncertainGestureColor") { view.uncertainGestureColor = parseColor(context, value) } - } + protected open fun parseOrientation(value: String): Int = + if ("horizontal" == value) { + GestureOverlayView.ORIENTATION_HORIZONTAL + } else { + GestureOverlayView.ORIENTATION_VERTICAL + } - protected open fun parseOrientation(value: String): Int { - return if ("horizontal" == value) { - GestureOverlayView.ORIENTATION_HORIZONTAL - } else GestureOverlayView.ORIENTATION_VERTICAL - } - - protected open fun parseGestureStrokeType(value: String): Int { - return if ("multiple" == value) { - GestureOverlayView.GESTURE_STROKE_TYPE_MULTIPLE - } else GestureOverlayView.GESTURE_STROKE_TYPE_SINGLE - } + protected open fun parseGestureStrokeType(value: String): Int = + if ("multiple" == value) { + GestureOverlayView.GESTURE_STROKE_TYPE_MULTIPLE + } else { + GestureOverlayView.GESTURE_STROKE_TYPE_SINGLE + } } diff --git a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/adapters/ToggleButtonAdapter.kt b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/adapters/ToggleButtonAdapter.kt index 7bd60affbe..21b0cc5b70 100644 --- a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/adapters/ToggleButtonAdapter.kt +++ b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/adapters/ToggleButtonAdapter.kt @@ -18,12 +18,12 @@ package com.itsaky.androidide.inflater.internal.adapters import android.widget.ToggleButton -import com.blankj.utilcode.util.ReflectUtils.reflect import com.itsaky.androidide.annotations.inflater.ViewAdapter import com.itsaky.androidide.inflater.AttributeHandlerScope import com.itsaky.androidide.inflater.models.UiWidget import com.itsaky.androidide.resources.R.drawable import com.itsaky.androidide.resources.R.string +import com.itsaky.androidide.utils.ReflectUtils.Companion.reflect /** * Attribute adapter for [ToggleButton]. @@ -32,21 +32,19 @@ import com.itsaky.androidide.resources.R.string */ @ViewAdapter(ToggleButton::class) open class ToggleButtonAdapter : CompoundButtonAdapter() { + override fun createAttrHandlers(create: (String, AttributeHandlerScope.() -> Unit) -> Unit) { + super.createAttrHandlers(create) + create("disabledAlpha") { reflect(view).field("mDisabledAlpha", parseFloat(value, 0.5f)) } + create("textOff") { view.textOff = parseString(value) } + create("textOn") { view.textOn = parseString(value) } + } - override fun createAttrHandlers(create: (String, AttributeHandlerScope.() -> Unit) -> Unit) { - super.createAttrHandlers(create) - create("disabledAlpha") { reflect(view).field("mDisabledAlpha", parseFloat(value, 0.5f)) } - create("textOff") { view.textOff = parseString(value) } - create("textOn") { view.textOn = parseString(value) } - } - - override fun createUiWidgets(): List { - return listOf( - UiWidget( - ToggleButton::class.java, - string.widget_togglebutton, - drawable.ic_widget_toggle_button - ) - ) - } + override fun createUiWidgets(): List = + listOf( + UiWidget( + ToggleButton::class.java, + string.widget_togglebutton, + drawable.ic_widget_toggle_button, + ), + ) } From 200e074da287ca5a0412d5cd6299d0f33cf1bee7 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 24 Jul 2026 15:39:46 -0700 Subject: [PATCH 7/9] ADFA-4649: Remove com.blankj:utilcodex dependency Drops the utilcodex dependency declaration from all 12 modules that had it (app, common, editor, uidesigner, xml-inflater, actions, lsp/api, lsp/java, lsp/models, lsp/xml, subprojects/javac-services, subprojects/flashbar) and its now-unused entries from gradle/libs.versions.toml. Verified: zero com.blankj.utilcode source references remain, :app:compileV8DebugKotlin/JavaWithJavac builds clean, unit tests pass (190 tests, 0 failures) across app/common/ editor/xml-inflater/lsp-java, a full :app:assembleV8Debug succeeds, and the resulting debug APK contains no blankj/utilcode classes. Final commit removing com.blankj:utilcodex (AndroidUtilCode/blankj) - duplicated AndroidX core, Kotlin stdlib, and coroutines functionality that this app now covers with small native equivalents, shrinking the APK. Co-Authored-By: Claude Sonnet 5 --- actions/build.gradle.kts | 1 - app/build.gradle.kts | 1 - common/build.gradle.kts | 1 - editor/build.gradle.kts | 2 -- gradle/libs.versions.toml | 3 --- lsp/api/build.gradle.kts | 1 - lsp/java/build.gradle.kts | 1 - lsp/models/build.gradle.kts | 1 - lsp/xml/build.gradle.kts | 1 - subprojects/flashbar/build.gradle.kts | 2 -- subprojects/javac-services/build.gradle.kts | 1 - uidesigner/build.gradle.kts | 1 - xml-inflater/build.gradle.kts | 1 - 13 files changed, 17 deletions(-) diff --git a/actions/build.gradle.kts b/actions/build.gradle.kts index 3b319d6ba2..981e779eb1 100644 --- a/actions/build.gradle.kts +++ b/actions/build.gradle.kts @@ -40,7 +40,6 @@ dependencies { implementation(libs.common.editor) implementation(libs.common.kotlin) implementation(libs.common.kotlin.coroutines.android) - implementation(libs.common.utilcode) implementation(libs.google.auto.service.annotations) implementation(libs.androidx.core.ktx) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f227db508a..8c0c208706 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -199,7 +199,6 @@ dependencies { implementation(platform(libs.sora.bom)) implementation(libs.common.editor) - implementation(libs.common.utilcode) implementation(libs.common.glide) implementation(libs.common.jsoup) implementation(libs.common.kotlin.coroutines.android) diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 9fbc503015..89bb798ee3 100755 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -15,7 +15,6 @@ dependencies { api(platform(libs.sora.bom)) api(libs.common.editor) api(libs.common.lang3) - api(libs.common.utilcode) api(libs.composite.constants) api(libs.google.guava) api(libs.google.material) diff --git a/editor/build.gradle.kts b/editor/build.gradle.kts index 31dd1fac87..c58ee88d42 100644 --- a/editor/build.gradle.kts +++ b/editor/build.gradle.kts @@ -40,8 +40,6 @@ dependencies { implementation(libs.androidx.tracing) implementation(libs.androidx.tracing.ktx) - implementation(libs.common.utilcode) - implementation(libs.google.material) implementation(projects.actions) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c8a6aab368..9c4e15649b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,7 +41,6 @@ retrofit = "2.11.0" markwon = "4.6.2" maven-publish-plugin = "0.27.0" room = "2.8.4" -utilcodex = "1.31.1" viewpager2 = "1.1.0-beta02" zoomage = "1.3.1" monitor = "1.6.1" @@ -157,14 +156,12 @@ androidide-ts-log = { module = "com.itsaky.androidide.treesitter:tree-sitter-log androidide-ts-xml = { module = "com.itsaky.androidide.treesitter:tree-sitter-xml", version.ref = "tree-sitter" } #Layout Editor -utilcodex = { module = "com.blankj:utilcodex", version.ref = "utilcodex" } zoomage = { module = "com.jsibbold:zoomage", version.ref = "zoomage" } sora-bom = { module = "io.github.Rosemoe.sora-editor:bom", version.ref = "editor" } sora-language-textmate = { module = "io.github.Rosemoe.sora-editor:language-textmate" } # Common common-editor = { module = "io.github.Rosemoe.sora-editor:editor" } -common-utilcode = { module = "com.blankj:utilcodex", version = "1.31.1" } common-glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } common-glide_ap = { module = "com.github.bumptech.glide:compiler", version.ref = "glide" } common-jsoup = { module = "org.jsoup:jsoup", version = "1.19.1" } diff --git a/lsp/api/build.gradle.kts b/lsp/api/build.gradle.kts index e8be75f082..8ba434b9e5 100644 --- a/lsp/api/build.gradle.kts +++ b/lsp/api/build.gradle.kts @@ -46,7 +46,6 @@ dependencies { implementation(libs.androidx.appcompat) implementation(libs.androidx.core.ktx) implementation(libs.common.kotlin) - implementation(libs.common.utilcode) implementation(libs.google.material) api(projects.subprojects.projects) diff --git a/lsp/java/build.gradle.kts b/lsp/java/build.gradle.kts index 73322f24be..70b545f02d 100644 --- a/lsp/java/build.gradle.kts +++ b/lsp/java/build.gradle.kts @@ -42,7 +42,6 @@ dependencies { implementation(platform(libs.sora.bom)) implementation(libs.common.editor) implementation(libs.common.javaparser) - implementation(libs.common.utilcode) implementation(libs.androidx.annotation) implementation(libs.google.guava) implementation(libs.google.gson) diff --git a/lsp/models/build.gradle.kts b/lsp/models/build.gradle.kts index e29979a605..eabded43c4 100644 --- a/lsp/models/build.gradle.kts +++ b/lsp/models/build.gradle.kts @@ -19,5 +19,4 @@ dependencies { implementation(libs.androidx.appcompat) implementation(libs.androidx.core.ktx) implementation(libs.common.kotlin) - implementation(libs.common.utilcode) } diff --git a/lsp/xml/build.gradle.kts b/lsp/xml/build.gradle.kts index fd6468a572..b7d944882e 100644 --- a/lsp/xml/build.gradle.kts +++ b/lsp/xml/build.gradle.kts @@ -39,7 +39,6 @@ dependencies { implementation(platform(libs.sora.bom)) implementation(libs.common.editor) - implementation(libs.common.utilcode) implementation(libs.androidide.ts) implementation(libs.androidide.ts.xml) diff --git a/subprojects/flashbar/build.gradle.kts b/subprojects/flashbar/build.gradle.kts index 9932eefe4e..6411065b17 100644 --- a/subprojects/flashbar/build.gradle.kts +++ b/subprojects/flashbar/build.gradle.kts @@ -27,8 +27,6 @@ android { } dependencies { - implementation(libs.common.utilcode) - implementation(libs.androidx.appcompat) implementation(libs.androidx.annotation) implementation(libs.androidx.core.ktx) diff --git a/subprojects/javac-services/build.gradle.kts b/subprojects/javac-services/build.gradle.kts index 9038cec70f..e4660205e8 100644 --- a/subprojects/javac-services/build.gradle.kts +++ b/subprojects/javac-services/build.gradle.kts @@ -17,7 +17,6 @@ android { dependencies { implementation(libs.common.kotlin) - implementation(libs.common.utilcode) implementation(libs.google.guava) implementation(projects.common) implementation(projects.logger) diff --git a/uidesigner/build.gradle.kts b/uidesigner/build.gradle.kts index ff674aa9ba..9e409ba16e 100644 --- a/uidesigner/build.gradle.kts +++ b/uidesigner/build.gradle.kts @@ -21,7 +21,6 @@ dependencies { implementation(platform(libs.sora.bom)) implementation(libs.common.editor) implementation(libs.common.kotlin) - implementation(libs.common.utilcode) implementation(libs.google.material) implementation(projects.actions) diff --git a/xml-inflater/build.gradle.kts b/xml-inflater/build.gradle.kts index 237f1cdc98..52ba0273af 100644 --- a/xml-inflater/build.gradle.kts +++ b/xml-inflater/build.gradle.kts @@ -31,7 +31,6 @@ dependencies { implementation(libs.androidx.appcompat) implementation(libs.common.kotlin) - implementation(libs.common.utilcode) implementation(projects.annotations) implementation(projects.common) From da37d47f380c2720528e816dd29665fe3b990678 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 24 Jul 2026 16:19:10 -0700 Subject: [PATCH 8/9] ADFA-4649: Document why BaseApplication has its own foregroundActivity tracker Per architecture review: common can't depend on app (IDEApplication), so this base tracker exists for common-module consumers (FlashbarUtils); IDEApplication's own pre-existing Pre/Post-callback tracker overrides it and wins at runtime in the real app. Co-Authored-By: Claude Sonnet 5 --- .../main/java/com/itsaky/androidide/app/BaseApplication.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/common/src/main/java/com/itsaky/androidide/app/BaseApplication.kt b/common/src/main/java/com/itsaky/androidide/app/BaseApplication.kt index bfead96b58..f4d4092ba1 100644 --- a/common/src/main/java/com/itsaky/androidide/app/BaseApplication.kt +++ b/common/src/main/java/com/itsaky/androidide/app/BaseApplication.kt @@ -43,6 +43,11 @@ open class BaseApplication : Application() { /** * The currently visible (resumed) activity, if any. + * + * `IDEApplication` (in the `app` module) overrides this with its own pre-existing + * Pre/Post-callback-based tracker, which wins at runtime. This base implementation only + * exists so `common`-module code (which can't depend on `app`) has a foreground-activity + * source to call - e.g. FlashbarUtils' `withActivity`. */ open val foregroundActivity: Activity? get() = _foregroundActivity From 1baf30d5e3529c8a92410b6cdc264bafe1e708d7 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 24 Jul 2026 16:52:57 -0700 Subject: [PATCH 9/9] docs: retro for LeakCanary/JAXP/PDF.js/blankj-utilcodex session Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 3 ++- docs/process/learnings.md | 12 ++++++++++ docs/process/retrospective.md | 42 +++++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 docs/process/retrospective.md diff --git a/CLAUDE.md b/CLAUDE.md index c7a6cd31fb..22b9a794b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,7 @@ flox activate -d flox/local -- ./gradlew - **Debug APK (arm64, the usual target):** `flox activate -d flox/local -- ./gradlew :app:assembleV8Debug --parallel --max-workers=6` - **Single unit test:** `flox activate -d flox/local -- ./gradlew :module:test --tests "com.itsaky.androidide.SomeTest"` - **Module unit tests:** `flox activate -d flox/local -- ./gradlew :testing:unit:test` +- **Fast iteration:** during multi-file/multi-module changes, verify with targeted `:module:compileV8DebugKotlin`/`compileV8DebugJavaWithJavac` invocations (batch several modules into one Gradle call) rather than a full assemble. Reserve `:app:assembleV8Debug` for final end-to-end verification — it's slow (multi-minute) in this multi-module project, and running it after every small change adds up. When the user names a CI/CD job ("the sonar job", "the analyze workflow"), read `.github/workflows/*.yml` — the YAML is the authoritative gradle/shell invocation. Don't reverse-engineer it from gradle tasks or build files. @@ -37,7 +38,7 @@ See **[ARCHITECTURE.md](ARCHITECTURE.md)** — the single source of truth for th - **Avoid new dependencies** — the build almost certainly already has what's needed. Check `gradle/libs.versions.toml` and `build.gradle.kts` first. - **Persistence:** prefer **Room** for relational data and the filesystem/preferences for settings; raw SQLite only for justified exceptions — see [ADR 0001](docs/adr/0001-prefer-room-for-persistence.md). - **Protect the two Android system bars** in any UI work: the top status bar (clock, notifications, status icons) and the bottom navigation bar (home, back, recents). Don't draw over or intercept them. -- **Plan and size before building.** Prefer **one PR per ticket/use case** — don't force-split a coherent change (splitting has its own overhead when later edits span the pieces). When a change is large, break it into **reviewable commits** — mechanical/refactor commits separate from behavioral ones — and offer review-by-commit. Treat ~500 LOC / ~10 files as a signal to reach for that commit structure, not a hard cap; the ceiling rises as LLM-assisted review matures. +- **Plan and size before building.** Prefer **one PR per ticket/use case** — don't force-split a coherent change (splitting has its own overhead when later edits span the pieces). When a change is large, break it into **reviewable commits** — mechanical/refactor commits separate from behavioral ones — and offer review-by-commit. Treat ~500 LOC / ~10 files as a signal to reach for that commit structure, not a hard cap; the ceiling rises as LLM-assisted review matures. For staged multi-commit refactors (e.g. removing a dependency across many files/modules), order stages easiest-to-hardest and independently compile/test each stage (see Build & test's fast-iteration guidance) before moving to the next, so a failure is isolated to the stage that caused it. - **Keep docs in step with code.** When you change code, update the docs that describe it in the same change — a module's `README.md`, `ARCHITECTURE.md`, or an ADR — so a doc never outlives the API it documents (see REVIEW.md, Code quality). If the doc fix is out of scope, file a ticket rather than let it drift. - `.androidide_root` is a sentinel file tests use to locate the project root — don't delete it. - Avoid http or https links which go off-device. When such links are unavoidable, warn the user beforehand and offer to cancel the action. diff --git a/docs/process/learnings.md b/docs/process/learnings.md index 6ba031074b..852a02c8b3 100644 --- a/docs/process/learnings.md +++ b/docs/process/learnings.md @@ -7,6 +7,18 @@ ## Git / GitHub - Before pushing a follow-up commit to a community PR, check `gh pr view --json headRepositoryOwner` — the PR head is usually on the contributor's **fork**, so a same-named push to `origin` doesn't touch the PR and just creates a confusing dead branch that has to be deleted. +## Android / Kotlin +- `Handler.removeCallbacks(Runnable)` only removes callbacks posted by that *exact* `Handler` instance, not just the same `Looper` — `Handler(Looper.getMainLooper()).removeCallbacks(x)` won't cancel something posted via a *different* `Handler` bound to the same looper. Any post/cancel pair needs to share one `Handler` instance (see `TaskExecutor.mainThreadHandler`, added when replacing blankj's `ThreadUtils.getMainHandler()`). + +## Reverse-engineering a library before porting it +- When writing a same-name drop-in for a third-party utility (to remove the dependency without changing call-site behavior), don't guess its semantics from memory/docs — extract the AAR's `classes.jar` and run `javap -c` against the actual bytecode to confirm exact chaining/wrapping behavior, especially for fluent/reflection-style APIs where a subtle mismatch (e.g., wrapping a field's *declared* type vs. its *runtime* class) changes behavior at existing call sites. + +## MockK +- Migrating a mocked call from a Java static method (`mockkStatic(SomeClass::class)`) to a Kotlin top-level extension function requires `mockkStatic("com.package.FileNameKt")` (the compiled JVM facade class name) instead — `mockkStatic(ExtensionReceiver::class)` doesn't work for extension functions. + +## Measuring a real before/after delta +- To measure an actual size/perf delta for a change (not just estimate it), use `git worktree add `, build there, and diff the artifacts — avoids disturbing the current working tree or stashing. + ## Kotlin LSP test harness - Disposing the `KtLspTestEnvironment` in a unit test (`env.close()`, or `Disposer.dispose(env.project)`) throws `AssertionError: Write access is allowed inside write-action only`. IntelliJ requires model teardown to run inside a write action. This is why `KtLspTestRule`'s teardown has `env.close()` commented out as "fails in test cases". To dispose deterministically in a test, wrap it: `ApplicationManager.getApplication().runWriteAction { env.close() }`. - The index/compilation environment lifecycle is racy: background `IndexWorker` coroutines call `PsiManager.findFile(project)` and will crash with `Project is already disposed` if the project is disposed before the workers are stopped. Always stop & join `KtSymbolIndex.close()` (and cancel related scopes) before `Disposer.dispose(...)`. diff --git a/docs/process/retrospective.md b/docs/process/retrospective.md new file mode 100644 index 0000000000..4285d3c04a --- /dev/null +++ b/docs/process/retrospective.md @@ -0,0 +1,42 @@ +# Retrospective Log + +## 2026-07-24 - LeakCanary icon shrink (ADFA-4843), JAXP/PDF.js investigations (ADFA-1491/ADFA-3304), and full blankj:utilcodex removal (ADFA-4649) + +### Time Breakdown +| Started | Phase | 👤 Hands-On Time | 🤖 Agent Time | Problems | +|---------|-------|-----------------|---------------|----------| +| Jul 24 7:52pm | LeakCanary (ADFA-4843): investigate → decide → build → PR | ██ 4m | ██ 18m | | +| Jul 24 8:29pm | Retro attempt #1 (interrupted before analysis ran) | | | ⚠ Interrupted, no work saved | +| Jul 24 8:33pm | Ticket triage: ADFA-1491 + ADFA-3304 deep investigation (no code shipped) + PR #1572 review-bot polling loop | █ 2m | █████ 42m | ⚠ ~40m of research produced zero shipped code (correctly — both tickets' real scope was smaller/harder than assumed) | +| Jul 24 9:31pm | ADFA-4649: full 7-stage blankj-utilcode removal (71 files, 20 util classes) → PR | █ 1m | █████████ 96m | ⚠ One same-turn fix: `BaseApplication`/`IDEApplication.foregroundActivity` override collision, caught by compiler immediately | +| Jul 24 11:13pm | APK size measurement, push, PR, architecture review + doc fix | █ 2m | ██ 8m | | +| Jul 24 11:44pm | Jira progress, retro resume | █ 1m | | | + +### Metrics +| Metric | Duration | +|--------|----------| +| Total wall-clock | ~3h 52m | +| Hands-on | ~58 min (25%) | +| Automated agent time | ~126 min (54%) | +| Idle/testing/away | ~48 min (21%) | +| Retro analysis time | ~2 min | + +### Key Observations +- The ADFA-4649 removal (96 min, one continuous turn) was the standout: 7 staged commits, each independently compiled/spotless'd/tested, zero user intervention needed mid-stream — one instruction ("do this in stages") ran to completion. +- The user consistently gave short, high-trust directives ("push and open a PR", "add the comment and post the review") rather than step-by-step guidance — low-friction collaboration. +- Two tickets (ADFA-1491 JAXP, ADFA-3304 PDF.js/docdb) got real investigation but shipped no code — in both cases the actual scope was smaller or more infrastructure-entangled than the ticket implied, and findings were posted instead of forcing a low-value change. Good triage, but ~40 min of agent research for zero shipped code is worth naming. +- Unresolved: the `@claude review` bot on PR #1572 never responded in this session (no result, no overage message either) — the polling loop just stopped when other work took over, not because it resolved. +- Only two full `:app:assembleV8Debug` builds ran in the whole session (final verification + before/after size comparison); all per-stage verification used targeted, batched `:module:compileV8DebugKotlin` calls — already the efficient pattern, but build wait time was still the user's named friction point. + +### Feedback +**What worked:** Staged, verified commits for the big refactor — breaking ADFA-4649 into 7 easiest-to-hardest commits, each compiled/tested/spotless'd before moving on. +**What didn't:** Waiting for the build system to create an APK — inherent friction in this multi-module Android project, not a request to change approach. + +### Actions Taken +| Issue | Action Type | Change | +|-------|-------------|--------| +| No standing guidance to prefer targeted compiles over full assembles during iteration | CLAUDE.md | Added a "Fast iteration" bullet to Build & test: batch targeted `:module:compileV8DebugKotlin` calls during iteration, reserve `:app:assembleV8Debug` for final verification | +| Staged-refactor pattern that worked well wasn't captured as standing guidance | CLAUDE.md | Extended "Plan and size before building": staged multi-commit refactors should order easiest→hardest and verify each stage before advancing | +| `Handler.removeCallbacks` same-instance requirement, javap-verification technique, MockK extension-function static mocking, git-worktree before/after comparison | docs/process/learnings.md | Added 4 new learnings entries | +| Two zero-code ticket investigations (ADFA-1491, ADFA-3304) | No action | Already covered by existing "post progress / comment findings" guidance; no gap | +| Stalled `@claude review` bot polling (~45 min, no resolution) | No action | Single inconclusive data point — too thin to generalize into a standing timeout/give-up rule yet |