From dde4bf279710839d474d1df5f1d18abf8a8bc331 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 24 Jul 2026 14:50:11 -0700 Subject: [PATCH 01/14] 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 02/14] 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 03/14] 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 04/14] 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 05/14] 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 06/14] 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 07/14] 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 08/14] 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 09/14] 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 | From 90ea7f40510b4b1046e30f6812225377653faceb Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 27 Jul 2026 11:46:50 -0700 Subject: [PATCH 10/14] ADFA-4649: Fix two regressions in FileUtils blankj replacement rename() silently overwrote an existing file at the destination since File.renameTo invokes rename(2), which atomically replaces the target. blankj's original guarded against this; restore the check. isUtf8() read and decoded the entire file instead of sampling a small header like blankj's original 24-byte check, turning a cheap classifier call into a full read+decode on hot paths (RecursiveFileSearcher, BaseEditorActivity tab restore, IDELanguageClientImpl). Sample only the header again, and return false for empty files to match prior behavior. Adds FileUtilsTest covering both fixes plus the header-sampling and empty-file edge cases. Co-Authored-By: Claude Sonnet 5 --- .../com/itsaky/androidide/utils/FileUtils.kt | 14 ++++- .../itsaky/androidide/utils/FileUtilsTest.kt | 61 +++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 common/src/test/java/com/itsaky/androidide/utils/FileUtilsTest.kt diff --git a/common/src/main/java/com/itsaky/androidide/utils/FileUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/FileUtils.kt index d830850be3..1a19cc73a2 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/FileUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/FileUtils.kt @@ -22,6 +22,8 @@ import java.io.FileFilter import java.nio.ByteBuffer import java.nio.charset.CodingErrorAction +private const val UTF8_SNIFF_LENGTH = 24 + object FileUtils { @JvmStatic fun isUtf8(file: File): Boolean { @@ -34,7 +36,12 @@ object FileUtils { decoder.onUnmappableCharacter(CodingErrorAction.REPORT) return try { - file.inputStream().use { input -> decoder.decode(ByteBuffer.wrap(input.readBytes())) } + val header = ByteArray(UTF8_SNIFF_LENGTH) + val read = file.inputStream().use { it.read(header) } + if (read <= 0) { + return false + } + decoder.decode(ByteBuffer.wrap(header, 0, read)) true } catch (e: Exception) { false @@ -74,7 +81,10 @@ object FileUtils { fun rename( file: File, newName: String, - ): Boolean = file.renameTo(File(file.parentFile, newName)) + ): Boolean { + val newFile = File(file.parentFile, newName) + return !newFile.exists() && file.renameTo(newFile) + } @JvmStatic fun getFileExtension(file: File): String = file.extension diff --git a/common/src/test/java/com/itsaky/androidide/utils/FileUtilsTest.kt b/common/src/test/java/com/itsaky/androidide/utils/FileUtilsTest.kt new file mode 100644 index 0000000000..cc1476ff2f --- /dev/null +++ b/common/src/test/java/com/itsaky/androidide/utils/FileUtilsTest.kt @@ -0,0 +1,61 @@ +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** + * Regression tests for behavior differences introduced when replacing + * com.blankj:utilcodex's FileUtils with an in-house implementation (ADFA-4649). + */ +class FileUtilsTest { + @get:Rule + val tempFolder = TemporaryFolder() + + @Test + fun `rename does not overwrite an existing file at the destination`() { + val source = tempFolder.newFile("a.txt").apply { writeText("source") } + val destination = tempFolder.newFile("b.txt").apply { writeText("destination") } + + val result = FileUtils.rename(source, destination.name) + + assertThat(result).isFalse() + assertThat(destination.readText()).isEqualTo("destination") + assertThat(source.exists()).isTrue() + } + + @Test + fun `rename succeeds when the destination does not exist`() { + val source = tempFolder.newFile("a.txt").apply { writeText("source") } + val destination = tempFolder.root.resolve("c.txt") + + val result = FileUtils.rename(source, destination.name) + + assertThat(result).isTrue() + assertThat(destination.readText()).isEqualTo("source") + } + + @Test + fun `isUtf8 ignores invalid bytes beyond the sampled header`() { + val file = tempFolder.newFile("valid-header.bin") + file.writeBytes(ByteArray(24) { 'a'.code.toByte() } + byteArrayOf(0xFF.toByte())) + + assertThat(FileUtils.isUtf8(file)).isTrue() + } + + @Test + fun `isUtf8 rejects a file with invalid bytes in its header`() { + val file = tempFolder.newFile("invalid-header.bin") + file.writeBytes(byteArrayOf(0xFF.toByte(), 0xFE.toByte())) + + assertThat(FileUtils.isUtf8(file)).isFalse() + } + + @Test + fun `isUtf8 returns false for an empty file`() { + val file = tempFolder.newFile("empty.txt") + + assertThat(FileUtils.isUtf8(file)).isFalse() + } +} From 5be744e3248602fde7ed0897bc2399120554c3bc Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 27 Jul 2026 13:10:43 -0700 Subject: [PATCH 11/14] ADFA-4649: Address code review feedback on blankj replacement Correctness fixes: - CredentialProtectedApplicationLoader: don't mark initialization as loaded until credential storage readiness succeeds, so a failed attempt can retry after user unlock (compareAndSet-based claim). - RecentProjectsAdapter: move project rename off the main thread (executeAsyncProvideError, matching the existing delete flow). - TemplateRecipeExecutor: close the destination stream in copyAsset (was leaking a file descriptor per copy). - FileManagerViewModel: roll back a case-only rename to its original name if the second rename step fails, instead of stranding the file under its temporary name. - ImageUtils: fix TIFF detection to require the full 4-byte magic number, not just the 2-byte byte-order marker (was false-positiving on any file starting with "II"/"MM"). - ImageUtils.isUtf8-adjacent FileUtils.isUtf8: decode the UTF-8 sample with endOfInput=false so a multi-byte sequence truncated at the 24-byte sample boundary reports underflow instead of a spurious malformed-input error. - ReflectUtils: remove a dead Elvis fallback in newInstance, and restore blankj's final-field-modifier stripping in the field setter (best-effort; falls back to the normal IllegalAccessException where the runtime blocks it). - CompletionProvider: replace reflective provider construction with direct instantiation per Tree.Kind (drops ReflectUtils from the completion hot path). - VectorMasterDrawable.fromXMLFile: throw a clear IOException instead of passing a null read result into the XML parser. - BaseIncrementalAnalyzeManager: use the already-cached blockTokens field instead of rebuilding an IntStream per token in the analysis loop. Exception handling: - FileActionManager, NewFileAction, EditorHandlerActivity: rethrow CancellationException before narrower catches, so cancellation isn't swallowed by structured concurrency. - FileUtils, ImageUtils, ResourceUtils: narrow broad Exception catches to IOException; FileUtils/ResourceUtils now log on failure. Other fixes: - RunTasksDialogFragment, BaseEditorActivity: retain debounce/ onboarding Runnables as stable fields and remove their callbacks in onDestroyView()/preDestroy(), since Handler.removeCallbacks needs the exact same Runnable instance to actually cancel anything. - GitBottomSheetViewModel: make the connectivity check injectable (default arg preserves production behavior) for testability. - CodeEditorView, IDEEditor, GroovyAutoComplete: locale-independent formatting for archive listing/logging, and hoist a loop-invariant toLowerCase call out of a per-candidate autocomplete loop. - ContextUtils: log the exceptions isAccessibilityEnabled and getAppVersionCode were silently swallowing. Test coverage: - Add regression tests for all of the above where practical. - Add ReflectUtilsTest, ImageUtilsTest, ResourceUtilsTest (new mockk test dependency in :common) covering previously-untested public API surface, and expand ZipUtils/FileUtils test coverage. Co-Authored-By: Claude Sonnet 5 --- .../androidide/actions/FileActionManager.kt | 6 +- .../actions/filetree/NewFileAction.kt | 7 + .../activities/editor/BaseEditorActivity.kt | 14 +- .../editor/EditorHandlerActivity.kt | 10 +- .../adapters/RecentProjectsAdapter.kt | 19 ++- .../CredentialProtectedApplicationLoader.kt | 18 ++- .../fragments/RunTasksDialogFragment.kt | 19 ++- .../itsaky/androidide/ui/CodeEditorView.kt | 4 + .../utils/TemplateRecipeExecutor.kt | 6 +- .../viewmodel/FileManagerViewModel.kt | 10 +- .../viewmodel/GitBottomSheetViewModel.kt | 5 +- .../viewmodel/CloneRepositoryViewModelTest.kt | 15 ++- common/build.gradle.kts | 1 + .../itsaky/androidide/utils/ContextUtils.kt | 6 + .../com/itsaky/androidide/utils/FileUtils.kt | 40 +++++- .../com/itsaky/androidide/utils/ImageUtils.kt | 9 +- .../itsaky/androidide/utils/ReflectUtils.kt | 33 ++++- .../itsaky/androidide/utils/ResourceUtils.kt | 5 +- .../itsaky/androidide/utils/FileUtilsTest.kt | 26 ++++ .../itsaky/androidide/utils/ImageUtilsTest.kt | 123 ++++++++++++++++++ .../androidide/utils/ReflectUtilsTest.kt | 116 +++++++++++++++++ .../androidide/utils/ResourceUtilsTest.kt | 94 +++++++++++++ .../itsaky/androidide/utils/ZipUtilsTest.kt | 61 +++++++++ .../language/groovy/GroovyAutoComplete.java | 8 +- .../BaseIncrementalAnalyzeManager.java | 8 +- .../itsaky/androidide/editor/ui/IDEEditor.kt | 2 +- .../java/providers/CompletionProvider.java | 19 +-- .../vectormaster/VectorMasterDrawable.java | 6 +- 28 files changed, 624 insertions(+), 66 deletions(-) create mode 100644 common/src/test/java/com/itsaky/androidide/utils/ImageUtilsTest.kt create mode 100644 common/src/test/java/com/itsaky/androidide/utils/ReflectUtilsTest.kt create mode 100644 common/src/test/java/com/itsaky/androidide/utils/ResourceUtilsTest.kt create mode 100644 common/src/test/java/com/itsaky/androidide/utils/ZipUtilsTest.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 f6fa2c1908..e5e941a9bb 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/FileActionManager.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/FileActionManager.kt @@ -3,6 +3,7 @@ package com.itsaky.androidide.actions import com.itsaky.androidide.actions.observers.FileActionObserver import com.itsaky.androidide.eventbus.events.file.FileCreationEvent import com.itsaky.androidide.utils.FileIOUtils +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -32,7 +33,10 @@ class FileActionManager { EventBus.getDefault().post(FileCreationEvent(targetFile)) Result.success(targetFile) } - } catch (e: Exception) { + } catch (e: CancellationException) { + throw e + } catch (e: IllegalArgumentException) { + // StringEscapeUtils.unescapeJava rejects malformed escape sequences this way. Result.failure(e) } 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 24450b6288..7beba57b3f 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 @@ -44,6 +44,7 @@ import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.utils.showWithLongPressTooltip import com.unnamed.b.atv.model.TreeNode import jdkx.lang.model.SourceVersion +import kotlinx.coroutines.CancellationException import org.greenrobot.eventbus.EventBus import org.koin.core.component.KoinComponent import org.koin.core.component.get @@ -91,6 +92,8 @@ class NewFileAction( val node = data.getTreeNode() try { createNewFile(context, node, file, false) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log.error("Failed to create new file", e) flashError(e.cause?.message ?: e.message) @@ -192,6 +195,8 @@ class NewFileAction( dialogInterface.dismiss() try { doCreateSourceFile(binding, file, context, node) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log.error("Failed to create source file", e) flashError(e.cause?.message ?: e.message) @@ -460,6 +465,8 @@ class NewFileAction( try { createFile(node, folder, name, content) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log.error("Failed to create file", e) flashError(e.cause?.message ?: e.message) 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 029cc41438..2d8f88dc70 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 @@ -405,6 +405,13 @@ abstract class BaseEditorActivity : protected var optionsMenuInvalidator: Runnable? = null + // Collapses the bottom sheet after its initial onboarding "peek" on first launch. + private val bottomSheetOnboardingCollapseRunnable = + Runnable { + bottomSheetViewModel.setSheetState(STATE_COLLAPSED) + app.prefManager.putBoolean(KEY_BOTTOM_SHEET_SHOWN, true) + } + private var gestureDetector: GestureDetector? = null private val flingDistanceThreshold by lazy { dpToPx(100f) } private val flingVelocityThreshold by lazy { dpToPx(100f) } @@ -471,6 +478,7 @@ abstract class BaseEditorActivity : runCatching { debuggerServiceStopHandler.removeCallbacks(debuggerServiceStopRunnable) } optionsMenuInvalidator?.also { mainThreadHandler.removeCallbacks(it) } optionsMenuInvalidator = null + mainThreadHandler.removeCallbacks(bottomSheetOnboardingCollapseRunnable) apkInstallationViewModel.destroy(this) @@ -1438,10 +1446,8 @@ abstract class BaseEditorActivity : bottomSheetViewModel.sheetBehaviorState != BottomSheetBehavior.STATE_EXPANDED ) { bottomSheetViewModel.setSheetState(BottomSheetBehavior.STATE_EXPANDED) - mainThreadHandler.postDelayed({ - bottomSheetViewModel.setSheetState(STATE_COLLAPSED) - app.prefManager.putBoolean(KEY_BOTTOM_SHEET_SHOWN, true) - }, 1500) + mainThreadHandler.removeCallbacks(bottomSheetOnboardingCollapseRunnable) + mainThreadHandler.postDelayed(bottomSheetOnboardingCollapseRunnable, 1500) } binding.contentCard.progress = 0f 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 3d95e1d085..ecd7ff984f 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 @@ -38,6 +38,7 @@ import androidx.fragment.app.FragmentManager import androidx.lifecycle.lifecycleScope import com.google.android.material.tabs.TabLayout import com.google.gson.Gson +import com.google.gson.JsonSyntaxException import com.itsaky.androidide.R import com.itsaky.androidide.R.string import com.itsaky.androidide.actions.ActionData @@ -100,6 +101,7 @@ 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.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch @@ -429,7 +431,9 @@ open class EditorHandlerActivity : // Clear the preference so it's only loaded once per cold restore withContext(Dispatchers.IO) { prefs.putString(PREF_KEY_OPEN_FILES_CACHE, null) } - } catch (err: Throwable) { + } catch (err: CancellationException) { + throw err + } catch (err: JsonSyntaxException) { log.error("Failed to reopen recently opened files", err) } } @@ -466,7 +470,9 @@ open class EditorHandlerActivity : } withContext(Dispatchers.IO) { prefs.putString(PREF_KEY_OPEN_PLUGIN_TABS, null) } - } catch (e: Exception) { + } catch (e: CancellationException) { + throw e + } catch (e: JsonSyntaxException) { Log.e("EditorHandlerActivity", "Failed to restore plugin tabs", e) } } 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 41af7f4c48..82d6cac293 100644 --- a/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt @@ -26,6 +26,7 @@ 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.tasks.executeAsyncProvideError import com.itsaky.androidide.utils.FileUtils import com.itsaky.androidide.utils.applyLongPressRecursively import com.itsaky.androidide.utils.flashError @@ -208,12 +209,7 @@ class RecentProjectsAdapter( .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 - } - } + executeAsync { FileUtils.delete(project.path) } }.create() val contentView = dialog.window?.decorView @@ -255,14 +251,15 @@ class RecentProjectsAdapter( .trim() val oldPath = project.path val newPath = oldPath.substringBeforeLast("/") + "/" + newName - try { - project.rename(newPath) + executeAsyncProvideError({ project.rename(newPath) }) { _, error -> + if (error != null) { + logger.error("Failed to rename project", error) + flashError(R.string.rename_failed) + return@executeAsyncProvideError + } 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) } } 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 bc245aec84..6c65959387 100644 --- a/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt +++ b/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt @@ -72,16 +72,22 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { return } - _isLoaded.set(true) - logger.info("Loading credential protected storage context components...") - application = app if (!isCredentialStorageReady(app)) { logger.error("Credential protected storage is not ready. Skipping credential protected initialization.") return } + if (!_isLoaded.compareAndSet(false, true)) { + // Another call already claimed initialization (e.g. a concurrent retry after + // user unlock); avoid running the rest of this method twice. + logger.warn("Attempt to perform multiple loads of the application. Ignoring.") + return + } + + application = app + initializeWorkManagerSafely(app) Environment.init(app) @@ -138,9 +144,11 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { } private fun initializeWorkManagerSafely(app: IDEApplication) { - runCatching { + try { WorkManager.getInstance(app) - }.onFailure { error -> + } catch (error: IllegalStateException) { + // WorkManager.getInstance throws IllegalStateException if WorkManager is not + // initialized properly (e.g. WorkManagerInitializer disabled/misconfigured). logger.error("Failed to get WorkManager instance after storage validation", error) Sentry.captureException(error) } 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 f7c5052cc8..f6fbf2fffe 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/RunTasksDialogFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/RunTasksDialogFragment.kt @@ -71,6 +71,13 @@ class RunTasksDialogFragment : BottomSheetDialogFragment() { private lateinit var run: LayoutRunTaskBinding private val viewModel: RunTasksViewModel by viewModels() + private val searchRunner = + Runnable { + viewModel.query = run.searchInput.editText + ?.text + ?.toString() ?: "" + } + companion object { private val log = LoggerFactory.getLogger(RunTasksDialogFragment::class.java) @@ -149,13 +156,6 @@ class RunTasksDialogFragment : BottomSheetDialogFragment() { run.searchInput.editText?.addTextChangedListener( object : SingleTextWatcher() { - val searchRunner = - Runnable { - viewModel.query = run.searchInput.editText - ?.text - ?.toString() ?: "" - } - override fun afterTextChanged(s: Editable?) { mainThreadHandler.removeCallbacks(searchRunner) mainThreadHandler.postDelayed(searchRunner, SEARCH_DELAY) @@ -245,6 +245,11 @@ class RunTasksDialogFragment : BottomSheetDialogFragment() { } } + override fun onDestroyView() { + mainThreadHandler.removeCallbacks(searchRunner) + super.onDestroyView() + } + private fun getWindowHeight(): Int { val height = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 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 eeb2f573e9..5a219cfedd 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt @@ -82,6 +82,7 @@ import org.greenrobot.eventbus.ThreadMode import org.slf4j.LoggerFactory import java.io.Closeable import java.io.File +import java.util.Locale import kotlin.math.abs private const val MIN_FONT_SIZE = EditorPreferences.FONT_SIZE_MIN @@ -514,6 +515,7 @@ class CodeEditorView( builder.appendLine() builder.appendLine( String.format( + Locale.ROOT, "%-10s %-10s %-6s %-8s %-20s %s", "Length", "Compressed", @@ -547,6 +549,7 @@ class CodeEditorView( } builder.appendLine( String.format( + Locale.ROOT, "%-10s %-10s %-6s %-8s %-20s %s", sizeStr, compressedStr, @@ -567,6 +570,7 @@ class CodeEditorView( } builder.appendLine( String.format( + Locale.ROOT, "%-10d %-10d %-6s %s", totalSize, totalCompressed, 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 8efbe6682d..40defdbe65 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/TemplateRecipeExecutor.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/TemplateRecipeExecutor.kt @@ -65,8 +65,10 @@ class TemplateRecipeExecutor( path: String, dest: File, ) { - openAsset(path).use { - it.copyTo(dest.outputStream()) + openAsset(path).use { input -> + dest.outputStream().use { output -> + input.copyTo(output) + } } } 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 cfbb230a31..cd4977f770 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/FileManagerViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/FileManagerViewModel.kt @@ -44,7 +44,15 @@ class FileManagerViewModel : ViewModel() { 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) + if (file.renameTo(tempFile)) { + tempFile.renameTo(destFile).also { success -> + if (!success) { + tempFile.renameTo(file) + } + } + } else { + false + } } else { FileUtils.rename(file, newName) } 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 1722511022..988bd198ad 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt @@ -36,6 +36,7 @@ import java.io.File class GitBottomSheetViewModel( private val credentialsManager: GitCredentialsManager, + private val isNetworkConnected: () -> Boolean = { BaseApplication.baseInstance.isNetworkConnected() }, ) : ViewModel() { private val log = LoggerFactory.getLogger(GitBottomSheetViewModel::class.java) @@ -181,7 +182,7 @@ class GitBottomSheetViewModel( viewModelScope.launch { try { - if (!BaseApplication.baseInstance.isNetworkConnected()) { + if (!isNetworkConnected()) { _pushState.value = PushUiState.Error(errorResId = R.string.no_internet_connection) return@launch } @@ -263,7 +264,7 @@ class GitBottomSheetViewModel( viewModelScope.launch { try { - if (!BaseApplication.baseInstance.isNetworkConnected()) { + if (!isNetworkConnected()) { _pullState.value = PullUiState.Error(errorResId = R.string.no_internet_connection) return@launch } 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 f6c42bb7eb..59a93a284b 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodel/CloneRepositoryViewModelTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/CloneRepositoryViewModelTest.kt @@ -1,6 +1,7 @@ package com.itsaky.androidide.viewmodel import android.app.Application +import android.content.Context import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.itsaky.androidide.R import com.itsaky.androidide.git.core.GitCredentialsManager @@ -51,7 +52,7 @@ class CloneRepositoryViewModelTest { fun setup() { // Stub the connectivity check so cloneRepository() proceeds to the // GitRepositoryManager call these tests are actually exercising. - mockkStatic("com.itsaky.androidide.utils.ContextUtilsKt") + mockkStatic(Context::isNetworkConnected) every { context.isNetworkConnected() } returns true mockkObject(GitRepositoryManager) @@ -207,6 +208,18 @@ class CloneRepositoryViewModelTest { } } + @Test + fun `cloneRepository when offline sets error state with retry`() { + every { context.isNetworkConnected() } returns false + + viewModel.cloneRepository("https://github.com/username/newproject.git", tempFolder.newFolder("OfflineProject").absolutePath) + + val state = viewModel.uiState.value + assertTrue(state is CloneRepoUiState.Error) + assertEquals(R.string.no_internet_connection, (state as CloneRepoUiState.Error).errorResId) + assertTrue(state.canRetry) + } + @Test fun `cloneRepository fails if destination directory does not exist after clone`() = runTest { diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 89bb798ee3..212db5f4f8 100755 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -45,6 +45,7 @@ dependencies { testImplementation(projects.testing.common) testImplementation(libs.tests.kotlinx.coroutines) testImplementation(libs.tests.google.truth) + testImplementation(libs.tests.mockk) androidTestImplementation(projects.testing.android) // brotli4j 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 e43553d795..79082beb37 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/ContextUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/ContextUtils.kt @@ -34,8 +34,12 @@ import android.provider.Settings import android.util.TypedValue import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import org.slf4j.LoggerFactory import kotlin.system.exitProcess +@PublishedApi +internal val logger = LoggerFactory.getLogger("ContextUtils") + /** * Check if the given accessibility service is enabled. */ @@ -48,6 +52,7 @@ inline fun Context.isAccessibilityEnabled(): Boolean { val services = Settings.Secure.getString(contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES) return services?.contains(name.flattenToString()) ?: false } catch (e: Settings.SettingNotFoundException) { + logger.warn("Failed to check if accessibility service is enabled", e) return false } } @@ -120,6 +125,7 @@ fun Context.getAppVersionCode(): Int = try { packageManager.getPackageInfo(packageName, 0).versionCode } catch (e: PackageManager.NameNotFoundException) { + logger.warn("Failed to get app version code", e) -1 } diff --git a/common/src/main/java/com/itsaky/androidide/utils/FileUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/FileUtils.kt index 1a19cc73a2..0d5f467073 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/FileUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/FileUtils.kt @@ -17,14 +17,27 @@ package com.itsaky.androidide.utils +import org.slf4j.LoggerFactory import java.io.File import java.io.FileFilter +import java.io.IOException import java.nio.ByteBuffer +import java.nio.CharBuffer import java.nio.charset.CodingErrorAction private const val UTF8_SNIFF_LENGTH = 24 +/** File helpers not tied to reading/writing content (see [FileIOUtils] for that). */ object FileUtils { + /** + * Sniffs whether [file] looks like valid UTF-8 by decoding only its first + * [UTF8_SNIFF_LENGTH] bytes; a large file with invalid bytes past that sample still passes. + * + * Decodes with `endOfInput = false` so a multi-byte sequence truncated at the sample + * boundary reports underflow (needs more bytes) rather than a malformed-input error - + * otherwise a genuinely valid UTF-8 file could fail this check purely because a + * multi-byte character happened to straddle byte [UTF8_SNIFF_LENGTH]. + */ @JvmStatic fun isUtf8(file: File): Boolean { if (!file.isFile) { @@ -41,13 +54,15 @@ object FileUtils { if (read <= 0) { return false } - decoder.decode(ByteBuffer.wrap(header, 0, read)) - true - } catch (e: Exception) { + val input = ByteBuffer.wrap(header, 0, read) + val output = CharBuffer.allocate(read) + !decoder.decode(input, output, false).isError + } catch (e: IOException) { false } } + /** Lists children of [dir] matching [filter], recursing into subdirectories if [recursive]. */ @JvmStatic fun listFilesInDirWithFilter( dir: File, @@ -71,12 +86,15 @@ object FileUtils { return result } + /** Deletes [file], recursing into directories. Returns whether everything was removed. */ @JvmStatic fun delete(file: File): Boolean = file.deleteRecursively() + /** @see delete */ @JvmStatic fun delete(path: String): Boolean = delete(File(path)) + /** Renames [file] to [newName] in the same directory; fails (returns `false`) if that name is already taken. */ @JvmStatic fun rename( file: File, @@ -89,14 +107,25 @@ object FileUtils { @JvmStatic fun getFileExtension(file: File): String = file.extension + /** Ensures [file] exists as a directory, creating it (and parents) if needed. */ @JvmStatic fun createOrExistsDir(file: File): Boolean = file.isDirectory || file.mkdirs() } +/** Whole-file read/write helpers that return a failure value instead of throwing. */ object FileIOUtils { + private val logger = LoggerFactory.getLogger(FileIOUtils::class.java) + + /** Reads all of [file] as UTF-8, or `null` if it doesn't exist or can't be read. */ @JvmStatic - fun readFile2String(file: File): String = file.readText(Charsets.UTF_8) + fun readFile2String(file: File): String? = + try { + file.readText(Charsets.UTF_8) + } catch (e: IOException) { + null + } + /** Writes [content] to [file] (creating parent directories as needed). Returns whether it succeeded. */ @JvmStatic fun writeFileFromString( file: File, @@ -106,7 +135,8 @@ object FileIOUtils { file.parentFile?.mkdirs() file.writeText(content) true - } catch (e: Exception) { + } catch (e: IOException) { + logger.warn("Failed to write file: {}", file, e) false } } diff --git a/common/src/main/java/com/itsaky/androidide/utils/ImageUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/ImageUtils.kt index 463c615eea..017aed85be 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/ImageUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/ImageUtils.kt @@ -20,9 +20,12 @@ package com.itsaky.androidide.utils import android.graphics.Bitmap import android.graphics.BitmapFactory import java.io.File +import java.io.IOException import java.io.RandomAccessFile +/** Bitmap/image helpers, including magic-number based image type detection. */ object ImageUtils { + /** Image formats recognized by [getImageType]'s header sniffing. */ enum class ImageType( val value: String, ) { @@ -64,7 +67,7 @@ object ImageUtils { val read = try { RandomAccessFile(file, "r").use { it.read(header) } - } catch (e: Exception) { + } catch (e: IOException) { return ImageType.TYPE_UNKNOWN } if (read < 4) return ImageType.TYPE_UNKNOWN @@ -78,7 +81,9 @@ object ImageUtils { 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 + // TIFF magic is the byte-order marker (II/MM) followed by 42 encoded in that order. + (byteAt(0) == 0x49 && byteAt(1) == 0x49 && byteAt(2) == 0x2A && byteAt(3) == 0x00) || + (byteAt(0) == 0x4D && byteAt(1) == 0x4D && byteAt(2) == 0x00 && byteAt(3) == 0x2A) -> ImageType.TYPE_TIFF byteAt(0) == 0x42 && byteAt(1) == 0x4D -> ImageType.TYPE_BMP diff --git a/common/src/main/java/com/itsaky/androidide/utils/ReflectUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/ReflectUtils.kt index b46a9cd442..459e76f69c 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/ReflectUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/ReflectUtils.kt @@ -20,6 +20,7 @@ package com.itsaky.androidide.utils import java.lang.reflect.Constructor import java.lang.reflect.Field import java.lang.reflect.Method +import java.lang.reflect.Modifier /** * A small fluent reflection helper, covering the subset of behavior this codebase relies on: @@ -80,6 +81,25 @@ class ReflectUtils private constructor( throw ReflectException(NoSuchFieldException("No field '$name' found on $type or its superclasses")) } + /** + * Best-effort mirror of blankj's final-field handling: clears the `FINAL` bit on [field] so + * [Field.set] can write to it. Silently gives up if the runtime blocks this (e.g. Android's + * hidden-API restrictions on newer API levels) - [Field.set] then fails with its normal + * [IllegalAccessException] on a final field, same as if this method didn't exist. + */ + private fun stripFinalModifier(field: Field) { + if (field.modifiers and Modifier.FINAL == 0) { + return + } + try { + val modifiersField = Field::class.java.getDeclaredField("modifiers") + modifiersField.isAccessible = true + modifiersField.setInt(field, field.modifiers and Modifier.FINAL.inv()) + } catch (e: Exception) { + // Not available on this runtime - fall through and let Field.set fail normally. + } + } + private fun findMethod( name: String, argTypes: List?>, @@ -103,17 +123,19 @@ class ReflectUtils private constructor( throw ReflectException(NoSuchMethodException("No constructor found on $type matching $argTypes")) } + /** Instantiates via a matching constructor, wrapping the new instance. Throws [ReflectException] on failure. */ 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)) + reflect(constructor.newInstance(*args)) } catch (e: ReflectException) { throw e } catch (e: Exception) { throw ReflectException(e) } + /** Reads a field by name (searching up the class hierarchy), wrapping its value. Throws [ReflectException] if not found. */ fun field(name: String): ReflectUtils = try { val f = findField(name) @@ -125,6 +147,7 @@ class ReflectUtils private constructor( throw ReflectException(e) } + /** Sets a field by name and returns this wrapper for chaining. Throws [ReflectException] if not found. */ fun field( name: String, value: Any?, @@ -132,6 +155,7 @@ class ReflectUtils private constructor( try { val f = findField(name) f.isAccessible = true + stripFinalModifier(f) f.set(target, value) this } catch (e: ReflectException) { @@ -140,6 +164,12 @@ class ReflectUtils private constructor( throw ReflectException(e) } + /** + * Invokes a method by name with the given arguments. Returns a wrapper around the result, or + * this same wrapper (unchanged) when the method is void or returns null, so calls can be + * chained regardless of return type. Throws [ReflectException] if no matching method is found + * or invocation fails. + */ fun method( name: String, vararg args: Any?, @@ -159,6 +189,7 @@ class ReflectUtils private constructor( throw ReflectException(e) } + /** Returns the wrapped target, unchecked-cast to [T]; throws [ClassCastException] on a mismatched type parameter. */ @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 index 9166f72170..85c8cfd467 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/ResourceUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/ResourceUtils.kt @@ -20,6 +20,7 @@ package com.itsaky.androidide.utils import com.itsaky.androidide.app.BaseApplication import org.slf4j.LoggerFactory import java.io.File +import java.io.IOException object ResourceUtils { private val logger = LoggerFactory.getLogger(ResourceUtils::class.java) @@ -50,7 +51,7 @@ object ResourceUtils { } true } - } catch (e: Exception) { + } catch (e: IOException) { logger.warn("Failed to copy asset '{}' to '{}'", assetPath, destPath, e) false } @@ -65,7 +66,7 @@ object ResourceUtils { BaseApplication.baseInstance.assets .open(assetPath) .use { it.readBytes().toString(Charsets.UTF_8) } - } catch (e: Exception) { + } catch (e: IOException) { logger.warn("Failed to read asset '{}'", assetPath, e) "" } diff --git a/common/src/test/java/com/itsaky/androidide/utils/FileUtilsTest.kt b/common/src/test/java/com/itsaky/androidide/utils/FileUtilsTest.kt index cc1476ff2f..c888db5e04 100644 --- a/common/src/test/java/com/itsaky/androidide/utils/FileUtilsTest.kt +++ b/common/src/test/java/com/itsaky/androidide/utils/FileUtilsTest.kt @@ -44,6 +44,18 @@ class FileUtilsTest { assertThat(FileUtils.isUtf8(file)).isTrue() } + @Test + fun `isUtf8 accepts a file whose header ends mid multi-byte sequence`() { + val file = tempFolder.newFile("truncated-multibyte.bin") + val asciiPrefix = ByteArray(22) { 'a'.code.toByte() } + // Euro sign (E2 82 AC) straddles the 24-byte sample boundary: only its first + // two bytes fall inside the header, so bytes 0-23 end mid-sequence. + val euroSign = byteArrayOf(0xE2.toByte(), 0x82.toByte(), 0xAC.toByte()) + file.writeBytes(asciiPrefix + euroSign) + + assertThat(FileUtils.isUtf8(file)).isTrue() + } + @Test fun `isUtf8 rejects a file with invalid bytes in its header`() { val file = tempFolder.newFile("invalid-header.bin") @@ -58,4 +70,18 @@ class FileUtilsTest { assertThat(FileUtils.isUtf8(file)).isFalse() } + + @Test + fun `readFile2String returns file contents`() { + val file = tempFolder.newFile("readable.txt").apply { writeText("hello") } + + assertThat(FileIOUtils.readFile2String(file)).isEqualTo("hello") + } + + @Test + fun `readFile2String returns null instead of throwing when the read fails`() { + val directory = tempFolder.newFolder("not-a-file") + + assertThat(FileIOUtils.readFile2String(directory)).isNull() + } } diff --git a/common/src/test/java/com/itsaky/androidide/utils/ImageUtilsTest.kt b/common/src/test/java/com/itsaky/androidide/utils/ImageUtilsTest.kt new file mode 100644 index 0000000000..b652ed53df --- /dev/null +++ b/common/src/test/java/com/itsaky/androidide/utils/ImageUtilsTest.kt @@ -0,0 +1,123 @@ +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** + * Regression tests for [ImageUtils.getImageType]'s magic-number sniffing (ADFA-4649). + */ +class ImageUtilsTest { + @get:Rule + val tempFolder = TemporaryFolder() + + @Test + fun `getImageType recognizes a valid little-endian TIFF header`() { + val file = tempFolder.newFile("valid.tiff") + file.writeBytes(byteArrayOf(0x49, 0x49, 0x2A, 0x00)) + + assertThat(ImageUtils.getImageType(file)).isEqualTo(ImageUtils.ImageType.TYPE_TIFF) + } + + @Test + fun `getImageType recognizes a valid big-endian TIFF header`() { + val file = tempFolder.newFile("valid-be.tiff") + file.writeBytes(byteArrayOf(0x4D, 0x4D, 0x00, 0x2A)) + + assertThat(ImageUtils.getImageType(file)).isEqualTo(ImageUtils.ImageType.TYPE_TIFF) + } + + @Test + fun `getImageType rejects an II-prefixed header with the wrong signature`() { + val file = tempFolder.newFile("not-tiff.bin") + file.writeBytes(byteArrayOf(0x49, 0x49, 0x00, 0x00)) + + assertThat(ImageUtils.getImageType(file)).isEqualTo(ImageUtils.ImageType.TYPE_UNKNOWN) + } + + @Test + fun `getImageType recognizes a JPEG header`() { + val file = tempFolder.newFile("valid.jpg") + file.writeBytes(byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xE0.toByte())) + + assertThat(ImageUtils.getImageType(file)).isEqualTo(ImageUtils.ImageType.TYPE_JPG) + } + + @Test + fun `getImageType recognizes a PNG header`() { + val file = tempFolder.newFile("valid.png") + file.writeBytes(byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47)) + + assertThat(ImageUtils.getImageType(file)).isEqualTo(ImageUtils.ImageType.TYPE_PNG) + } + + @Test + fun `getImageType recognizes a GIF header`() { + val file = tempFolder.newFile("valid.gif") + file.writeBytes(byteArrayOf(0x47, 0x49, 0x46, 0x38)) + + assertThat(ImageUtils.getImageType(file)).isEqualTo(ImageUtils.ImageType.TYPE_GIF) + } + + @Test + fun `getImageType recognizes a BMP header`() { + val file = tempFolder.newFile("valid.bmp") + file.writeBytes(byteArrayOf(0x42, 0x4D, 0x00, 0x00)) + + assertThat(ImageUtils.getImageType(file)).isEqualTo(ImageUtils.ImageType.TYPE_BMP) + } + + @Test + fun `getImageType recognizes an ICO header`() { + val file = tempFolder.newFile("valid.ico") + file.writeBytes(byteArrayOf(0x00, 0x00, 0x01, 0x00)) + + assertThat(ImageUtils.getImageType(file)).isEqualTo(ImageUtils.ImageType.TYPE_ICO) + } + + @Test + fun `getImageType recognizes a WEBP header`() { + val file = tempFolder.newFile("valid.webp") + file.writeBytes( + byteArrayOf( + 'R'.code.toByte(), + 'I'.code.toByte(), + 'F'.code.toByte(), + 'F'.code.toByte(), + 0x00, + 0x00, + 0x00, + 0x00, + 'W'.code.toByte(), + 'E'.code.toByte(), + 'B'.code.toByte(), + 'P'.code.toByte(), + ), + ) + + assertThat(ImageUtils.getImageType(file)).isEqualTo(ImageUtils.ImageType.TYPE_WEBP) + } + + @Test + fun `getImageType returns unknown for a header shorter than 4 bytes`() { + val file = tempFolder.newFile("too-short.bin") + file.writeBytes(byteArrayOf(0x00, 0x01)) + + assertThat(ImageUtils.getImageType(file)).isEqualTo(ImageUtils.ImageType.TYPE_UNKNOWN) + } + + @Test + fun `getImageType returns unknown for a directory`() { + val dir = tempFolder.newFolder("a-directory") + + assertThat(ImageUtils.getImageType(dir)).isEqualTo(ImageUtils.ImageType.TYPE_UNKNOWN) + } + + @Test + fun `getImageType returns unknown for a missing file`() { + val missing = tempFolder.root.resolve("does-not-exist.png") + + assertThat(ImageUtils.getImageType(missing)).isEqualTo(ImageUtils.ImageType.TYPE_UNKNOWN) + } +} diff --git a/common/src/test/java/com/itsaky/androidide/utils/ReflectUtilsTest.kt b/common/src/test/java/com/itsaky/androidide/utils/ReflectUtilsTest.kt new file mode 100644 index 0000000000..a4799abccf --- /dev/null +++ b/common/src/test/java/com/itsaky/androidide/utils/ReflectUtilsTest.kt @@ -0,0 +1,116 @@ +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.assertThrows +import org.junit.Test + +/** + * Regression tests for ReflectUtils, added when replacing com.blankj:utilcodex's ReflectUtils + * with an in-house implementation (ADFA-4649), including a fix for a final-field-modifier gap + * (blankj clears a field's FINAL bit before setting it; the initial port omitted this). + */ +class ReflectUtilsTest { + private open class Base { + @Suppress("unused") + private val baseField: String = "base-value" + } + + private class Target( + private var count: Int, + ) : Base() { + @Suppress("unused") + private fun add(delta: Int): Int { + count += delta + return count + } + + @Suppress("unused") + private fun reset() { + count = 0 + } + } + + private class HasFinalField { + @Suppress("unused") + private val locked: String = "original" + } + + private class Widget( + val label: String, + ) + + @Test + fun `field reads a private field declared on the class itself`() { + val value = ReflectUtils.reflect(Target(5)).field("count").get() + + assertThat(value).isEqualTo(5) + } + + @Test + fun `field reads a private field declared on a superclass`() { + val value = ReflectUtils.reflect(Target(0)).field("baseField").get() + + assertThat(value).isEqualTo("base-value") + } + + @Test + fun `field sets a private field`() { + val target = Target(0) + + ReflectUtils.reflect(target).field("count", 42) + + assertThat(ReflectUtils.reflect(target).field("count").get()).isEqualTo(42) + } + + @Test + fun `field sets a final instance field`() { + val instance = HasFinalField() + + ReflectUtils.reflect(instance).field("locked", "changed") + + val value = ReflectUtils.reflect(instance).field("locked").get() + assertThat(value).isEqualTo("changed") + } + + @Test + fun `field throws ReflectException for a field that doesn't exist`() { + assertThrows(ReflectUtils.ReflectException::class.java) { + ReflectUtils.reflect(Target(0)).field("doesNotExist") + } + } + + @Test + fun `method invokes a private method and unwraps its return value`() { + val result = ReflectUtils.reflect(Target(10)).method("add", 5).get() + + assertThat(result).isEqualTo(15) + } + + @Test + fun `method returns the same wrapper for a void method, allowing chaining`() { + val target = Target(10) + + val count = + ReflectUtils + .reflect(target) + .method("reset") + .field("count") + .get() + + assertThat(count).isEqualTo(0) + } + + @Test + fun `method throws ReflectException for a method that doesn't exist`() { + assertThrows(ReflectUtils.ReflectException::class.java) { + ReflectUtils.reflect(Target(0)).method("doesNotExist") + } + } + + @Test + fun `newInstance constructs via a matching constructor`() { + val widget = ReflectUtils.reflect(Widget::class.java).newInstance("hello").get() + + assertThat(widget.label).isEqualTo("hello") + } +} diff --git a/common/src/test/java/com/itsaky/androidide/utils/ResourceUtilsTest.kt b/common/src/test/java/com/itsaky/androidide/utils/ResourceUtilsTest.kt new file mode 100644 index 0000000000..00d8c2913f --- /dev/null +++ b/common/src/test/java/com/itsaky/androidide/utils/ResourceUtilsTest.kt @@ -0,0 +1,94 @@ +package com.itsaky.androidide.utils + +import android.content.res.AssetManager +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.app.BaseApplication +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.IOException + +/** + * Regression tests for ResourceUtils, added when replacing com.blankj:utilcodex's ResourceUtils + * with an in-house implementation (ADFA-4649). [BaseApplication.baseInstance] and its + * [AssetManager] are mocked since this file's only external dependency is asset access. + */ +class ResourceUtilsTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private val assets = mockk() + + @Before + fun setup() { + val app = mockk { every { assets } returns this@ResourceUtilsTest.assets } + mockkObject(BaseApplication.Companion) + every { BaseApplication.baseInstance } returns app + } + + @After + fun tearDown() { + unmockkObject(BaseApplication.Companion) + } + + @Test + fun `copyFileFromAssets copies a single file`() { + every { assets.list("icon.png") } returns emptyArray() + every { assets.open("icon.png") } returns "png bytes".byteInputStream() + + val dest = tempFolder.root.resolve("copied/icon.png") + val result = ResourceUtils.copyFileFromAssets("icon.png", dest.absolutePath) + + assertThat(result).isTrue() + assertThat(dest.readText()).isEqualTo("png bytes") + } + + @Test + fun `copyFileFromAssets copies a directory recursively`() { + every { assets.list("template") } returns arrayOf("a.txt", "nested") + every { assets.list("template/a.txt") } returns emptyArray() + every { assets.list("template/nested") } returns arrayOf("b.txt") + every { assets.list("template/nested/b.txt") } returns emptyArray() + every { assets.open("template/a.txt") } returns "a content".byteInputStream() + every { assets.open("template/nested/b.txt") } returns "b content".byteInputStream() + + val destDir = tempFolder.newFolder("dest") + val result = ResourceUtils.copyFileFromAssets("template", destDir.absolutePath) + + assertThat(result).isTrue() + assertThat(File(destDir, "a.txt").readText()).isEqualTo("a content") + assertThat(File(destDir, "nested/b.txt").readText()).isEqualTo("b content") + } + + @Test + fun `copyFileFromAssets returns false without throwing when the asset can't be opened`() { + every { assets.list("missing.png") } returns emptyArray() + every { assets.open("missing.png") } throws IOException("no such asset") + + val dest = tempFolder.root.resolve("copied/missing.png") + val result = ResourceUtils.copyFileFromAssets("missing.png", dest.absolutePath) + + assertThat(result).isFalse() + } + + @Test + fun `readAssets2String returns the asset's content`() { + every { assets.open("recipe.json.ftl") } returns "{\"key\":\"value\"}".byteInputStream() + + assertThat(ResourceUtils.readAssets2String("recipe.json.ftl")).isEqualTo("{\"key\":\"value\"}") + } + + @Test + fun `readAssets2String returns an empty string without throwing when the asset can't be read`() { + every { assets.open("missing.ftl") } throws IOException("no such asset") + + assertThat(ResourceUtils.readAssets2String("missing.ftl")).isEmpty() + } +} diff --git a/common/src/test/java/com/itsaky/androidide/utils/ZipUtilsTest.kt b/common/src/test/java/com/itsaky/androidide/utils/ZipUtilsTest.kt new file mode 100644 index 0000000000..a8c2acc349 --- /dev/null +++ b/common/src/test/java/com/itsaky/androidide/utils/ZipUtilsTest.kt @@ -0,0 +1,61 @@ +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.assertThrows +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.IOException +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** + * Regression tests for ZipUtils.unzipFile, including its zip-slip (path traversal) protection + * introduced when replacing com.blankj:utilcodex's ZipUtils with an in-house implementation + * (ADFA-4649). + */ +class ZipUtilsTest { + @get:Rule + val tempFolder = TemporaryFolder() + + @Test + fun `unzipFile extracts nested directories and files`() { + val zipFile = tempFolder.newFile("archive.zip") + ZipOutputStream(zipFile.outputStream()).use { zip -> + zip.putNextEntry(ZipEntry("dir/")) + zip.closeEntry() + + zip.putNextEntry(ZipEntry("dir/nested.txt")) + zip.write("nested content".toByteArray()) + zip.closeEntry() + + zip.putNextEntry(ZipEntry("root.txt")) + zip.write("root content".toByteArray()) + zip.closeEntry() + } + + val destDir = tempFolder.newFolder("dest") + ZipUtils.unzipFile(zipFile, destDir) + + assertThat(File(destDir, "dir/nested.txt").readText()).isEqualTo("nested content") + assertThat(File(destDir, "root.txt").readText()).isEqualTo("root content") + } + + @Test + fun `unzipFile rejects entries that traverse outside the destination directory`() { + val zipFile = tempFolder.newFile("evil.zip") + ZipOutputStream(zipFile.outputStream()).use { zip -> + zip.putNextEntry(ZipEntry("../evil.txt")) + zip.write("evil content".toByteArray()) + zip.closeEntry() + } + + val destDir = tempFolder.newFolder("dest") + + assertThrows(IOException::class.java) { ZipUtils.unzipFile(zipFile, destDir) } + + val escapedFile = File(destDir.parentFile, "evil.txt") + assertThat(escapedFile.exists()).isFalse() + } +} 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 94ddffe09e..96690f2637 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 @@ -351,8 +351,10 @@ public void complete( if (prefix == null || prefix.toString().trim().isEmpty()) { return; } + final var lowerPrefix = prefix.toLowerCase(Locale.ROOT); + for (String artifact : ANDROIDX_ARTIFACTS) { - if (!artifact.toLowerCase(Locale.ROOT).startsWith(prefix.toLowerCase(Locale.ROOT))) { + if (!artifact.toLowerCase(Locale.ROOT).startsWith(lowerPrefix)) { continue; } final var completionItem = createCompletionItem(artifact); @@ -361,7 +363,7 @@ public void complete( } for (String config : CONFIGURATIONS) { - if (!config.toLowerCase(Locale.ROOT).startsWith(prefix.toLowerCase(Locale.ROOT))) { + if (!config.toLowerCase(Locale.ROOT).startsWith(lowerPrefix)) { continue; } final var completionItem = createCompletionItem(config); @@ -370,7 +372,7 @@ public void complete( } for (String other : OTHERS) { - if (!other.toLowerCase(Locale.ROOT).startsWith(prefix.toLowerCase(Locale.ROOT))) { + if (!other.toLowerCase(Locale.ROOT).startsWith(lowerPrefix)) { continue; } final var completionItem = createCompletionItem(other); 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 b893f3af8c..8ed4e59a48 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 @@ -38,7 +38,6 @@ 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; @@ -387,8 +386,11 @@ protected int tokenizeNormal(final CharSequence line, final int column, start.add(token); end.add(token); final var type = token.getType(); - if (IntStream.of(getCodeBlockTokens()).anyMatch(t -> t == type)) { - st.hasBraces = true; + for (final var blockToken : blockTokens) { + if (blockToken == type) { + st.hasBraces = true; + break; + } } if (start.remainingCapacity() == 0 && isIncompleteTokenStart(start)) { 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 ccac57b655..73a17de50e 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 @@ -375,7 +375,7 @@ open class IDEEditor return } - log.info(String.format("Executing command '%s' for completion item.", command.title)) + log.info("Executing command '${command.title}' for completion item.") when (command.command) { Command.TRIGGER_COMPLETION -> { val completion = getComponent(EditorAutoCompletion::class.java) 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 2f5890742f..6e1bb5110a 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 @@ -45,7 +45,6 @@ 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; @@ -250,34 +249,30 @@ private CompletionResult completeInternal(final @NonNull CompletionParams params 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; + final IJavaCompletionProvider provider; abortCompletionIfCancelled(); switch (path.getLeaf().getKind()) { case IDENTIFIER: - klass = IdentifierCompletionProvider.class; + provider = new IdentifierCompletionProvider(file, cursor, compiler, getSettings()); break; case MEMBER_SELECT: - klass = MemberSelectCompletionProvider.class; + provider = new MemberSelectCompletionProvider(file, cursor, compiler, getSettings()); break; case MEMBER_REFERENCE: - klass = MemberReferenceCompletionProvider.class; + provider = new MemberReferenceCompletionProvider(file, cursor, compiler, getSettings()); break; case SWITCH: - klass = SwitchConstantCompletionProvider.class; + provider = new SwitchConstantCompletionProvider(file, cursor, compiler, getSettings()); break; case IMPORT: - klass = ImportCompletionProvider.class; + provider = new ImportCompletionProvider(file, cursor, compiler, getSettings()); break; default: - klass = KeywordCompletionProvider.class; + provider = new KeywordCompletionProvider(file, cursor, compiler, getSettings()); break; } - final IJavaCompletionProvider provider = ReflectUtils.reflect(klass) - .newInstance(file, cursor, compiler, getSettings()) - .get(); - if (provider instanceof ImportCompletionProvider) { ((ImportCompletionProvider) provider).setImportPath( qualifiedPartialIdentifier(contents, (int) cursor)); 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 e19a858962..18cb3345cb 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 @@ -56,8 +56,12 @@ public static VectorMasterDrawable fromXML(@NonNull String vectorXML) } @NonNull - public static VectorMasterDrawable fromXMLFile(File file) throws XmlPullParserException { + public static VectorMasterDrawable fromXMLFile(File file) + throws XmlPullParserException, IOException { final var source = FileIOUtils.readFile2String(file); + if (source == null) { + throw new IOException("Failed to read vector file: " + file); + } return VectorMasterDrawable.fromXML(source); } From fd02074c0aab587d027db986131c7563c13b56fa Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 27 Jul 2026 16:45:57 -0700 Subject: [PATCH 12/14] ADFA-4649: Fix two more code review findings CredentialProtectedApplicationLoader: _isLoaded was claimed via compareAndSet before ~10 subsequent init steps (WorkManager, Environment, FeatureFlags, EventBus, Termux, theme, plugin system, ToolsManager), several of which had no error handling and would leave _isLoaded stuck true on failure/cancellation, permanently blocking any retry. Wrap the init body in try/catch that un-claims _isLoaded before rethrowing, and make EventBus registration idempotent so a retry after a partial failure doesn't immediately fail on double-registration. FileUtils.writeFileFromString: the failure log included the file's full path and the raw exception (whose message often re-embeds the path). Log only the exception's class name instead. Co-Authored-By: Claude Sonnet 5 --- .../CredentialProtectedApplicationLoader.kt | 58 +++++++++++-------- .../com/itsaky/androidide/utils/FileUtils.kt | 2 +- 2 files changed, 35 insertions(+), 25 deletions(-) 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 6c65959387..f9bef8288b 100644 --- a/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt +++ b/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt @@ -88,41 +88,51 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { application = app - initializeWorkManagerSafely(app) + try { + initializeWorkManagerSafely(app) - Environment.init(app) + Environment.init(app) - FeatureFlags.initialize() - LeakCanaryConfig.applyFromFeatureFlags() + FeatureFlags.initialize() + LeakCanaryConfig.applyFromFeatureFlags() - EventBus.getDefault().register(this) + if (!EventBus.getDefault().isRegistered(this)) { + EventBus.getDefault().register(this) + } - // Load termux application - TermuxApplicationLoader.load(app) + // Load termux application + TermuxApplicationLoader.load(app) - if (DevOpsPreferences.dumpLogs) { - startLogcatReader() - } + if (DevOpsPreferences.dumpLogs) { + startLogcatReader() + } - withContext(Dispatchers.Main) { - AppCompatDelegate.setDefaultNightMode(GeneralPreferences.uiMode) + withContext(Dispatchers.Main) { + AppCompatDelegate.setDefaultNightMode(GeneralPreferences.uiMode) - if (IThemeManager.getInstance().getCurrentTheme() == IDETheme.MATERIAL_YOU) { - DynamicColors.applyToActivitiesIfAvailable(app) + if (IThemeManager.getInstance().getCurrentTheme() == IDETheme.MATERIAL_YOU) { + DynamicColors.applyToActivitiesIfAvailable(app) + } } - } - initializePluginSystem() - installPluginCrashLooperGuard() + initializePluginSystem() + installPluginCrashLooperGuard() - app.coroutineScope.launch(Dispatchers.IO) { - // color schemes are stored in files - // initialize scheme provider on the IO dispatcher - IDEColorSchemeProvider.init() - } + app.coroutineScope.launch(Dispatchers.IO) { + // color schemes are stored in files + // initialize scheme provider on the IO dispatcher + IDEColorSchemeProvider.init() + } - if (!VMUtils.isJvm || VMUtils.isInstrumentedTest) { - ToolsManager.init(app, null) + if (!VMUtils.isJvm || VMUtils.isInstrumentedTest) { + ToolsManager.init(app, null) + } + } catch (e: Throwable) { + // Un-claim the load on failure/cancellation so a later retry (e.g. after user + // unlock) can attempt initialization again instead of being stuck "loaded" with + // some components never actually initialized. + _isLoaded.set(false) + throw e } } diff --git a/common/src/main/java/com/itsaky/androidide/utils/FileUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/FileUtils.kt index 0d5f467073..d163778a98 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/FileUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/FileUtils.kt @@ -136,7 +136,7 @@ object FileIOUtils { file.writeText(content) true } catch (e: IOException) { - logger.warn("Failed to write file: {}", file, e) + logger.warn("Failed to write file (error: {})", e.javaClass.simpleName) false } } From f83d36395fced7220868d7a8f157ea9345ed188b Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 28 Jul 2026 14:01:18 -0700 Subject: [PATCH 13/14] ADFA-4649: Fix unsafe !! in CopyPathAction Per review feedback, use the existing ActionData.requireContext() helper instead of data[Context::class.java]!! - same behavior (throws if absent) but matches the idiom already used by every other action in this package. Co-Authored-By: Claude Sonnet 5 --- .../com/itsaky/androidide/actions/filetree/CopyPathAction.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 a26ddf5310..7e0a126e81 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 @@ -19,6 +19,7 @@ package com.itsaky.androidide.actions.filetree import android.content.Context import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.requireContext import com.itsaky.androidide.actions.requireFile import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.resources.R @@ -40,7 +41,7 @@ class CopyPathAction( override suspend fun execAction(data: ActionData) { val file = data.requireFile() - data[Context::class.java]!!.copyToClipboard(file.absolutePath, label = "[AndroidIDE] Copied File Path") + data.requireContext().copyToClipboard(file.absolutePath, label = "[AndroidIDE] Copied File Path") flashSuccess(R.string.copied) } } From ae090657964bcc6c58182e773b273f0b52cb301b Mon Sep 17 00:00:00 2001 From: David Schachter Date: Tue, 28 Jul 2026 18:10:01 -0700 Subject: [PATCH 14/14] ADFA-4649: Address remaining code review findings on blankj replacement Fixes Hal Eisen's 5-point review of PR #1576: - isSoftInputVisible() now gates the WindowInsetsCompat IME check on API 30+ and falls back to a decor visible-frame heuristic below that, since the inset bit misreports on API 28-29 (MIN_SDK = 28), same as our own termux KeyboardUtils.isSoftKeyboardVisible() documents. - BaseApplication drops its dead foregroundActivity tracker: IDEApplication is the only subclass that populates it, so the base getter now just returns null instead of registering an unused ActivityLifecycleCallbacks. - ReflectUtils' four public methods now share one wrapErrors() helper instead of repeating the same try/catch wrapping block. - DeviceUtils.getModel() drops a redundant .trim() before a regex that already strips all whitespace. - FileUtils.kt gets a doc pointer to the pre-existing FileUtil.java, noting the differing failure contracts, so the next maintainer knows which to reach for. Co-Authored-By: Claude Sonnet 5 --- .../itsaky/androidide/app/BaseApplication.kt | 41 +++---------------- .../itsaky/androidide/utils/ContextUtils.kt | 23 ++++++++++- .../itsaky/androidide/utils/DeviceUtils.kt | 2 +- .../com/itsaky/androidide/utils/FileUtils.kt | 11 ++++- .../itsaky/androidide/utils/ReflectUtils.kt | 34 +++++++-------- 5 files changed, 51 insertions(+), 60 deletions(-) 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 f4d4092ba1..5adefa731b 100644 --- a/common/src/main/java/com/itsaky/androidide/app/BaseApplication.kt +++ b/common/src/main/java/com/itsaky/androidide/app/BaseApplication.kt @@ -21,7 +21,6 @@ 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 @@ -30,7 +29,6 @@ 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) @@ -44,13 +42,13 @@ 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`. + * `IDEApplication` (in the `app` module) overrides this with its own tracker. 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` - and defaults to + * `null` since the base class has no subclass that needs it populated. */ open val foregroundActivity: Activity? - get() = _foregroundActivity + get() = null init { _baseInstance = this @@ -60,35 +58,6 @@ 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 79082beb37..b0d2f216c7 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/ContextUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/ContextUtils.kt @@ -28,8 +28,10 @@ import android.content.Intent import android.content.pm.PackageManager import android.content.res.Configuration import android.content.res.Resources.Theme +import android.graphics.Rect import android.net.ConnectivityManager import android.net.NetworkCapabilities +import android.os.Build import android.provider.Settings import android.util.TypedValue import androidx.core.view.ViewCompat @@ -131,8 +133,25 @@ fun Context.getAppVersionCode(): Int = /** * Checks whether the soft (on-screen) keyboard is currently visible in this activity's window. + * + * `WindowInsetsCompat`'s IME visibility bit is only reliable from API 30 onward; below that + * (down to `MIN_SDK = 28`) it can misreport, so we fall back to a decor visible-frame heuristic + * that works regardless of API level or the window's soft-input-adjust mode. */ fun Activity.isSoftInputVisible(): Boolean { - val insets = ViewCompat.getRootWindowInsets(window.decorView) ?: return false - return insets.isVisible(WindowInsetsCompat.Type.ime()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val insets = ViewCompat.getRootWindowInsets(window.decorView) ?: return false + return insets.isVisible(WindowInsetsCompat.Type.ime()) + } + return isSoftInputVisibleByDecorFrame() +} + +private fun Activity.isSoftInputVisibleByDecorFrame(): Boolean { + val decorView = window.decorView + val visibleFrame = Rect() + decorView.getWindowVisibleDisplayFrame(visibleFrame) + val heightDiff = decorView.height - visibleFrame.height() + // A keyboard eats a large chunk of the screen; smaller diffs come from status/nav bar + // insets rather than the IME, so require the gap to be a meaningful fraction of the screen. + return heightDiff > decorView.height / 4 } 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 40f3d0c137..d2adb4d89b 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/DeviceUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/DeviceUtils.kt @@ -37,7 +37,7 @@ object DeviceUtils { fun getManufacturer(): String = Build.MANUFACTURER - fun getModel(): String = Build.MODEL?.trim()?.replace("\\s*".toRegex(), "") ?: "" + fun getModel(): String = Build.MODEL?.replace("\\s*".toRegex(), "") ?: "" /** * Heuristic check for whether the app is running on an emulator, based on common diff --git a/common/src/main/java/com/itsaky/androidide/utils/FileUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/FileUtils.kt index d163778a98..2e4d033215 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/FileUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/FileUtils.kt @@ -27,7 +27,16 @@ import java.nio.charset.CodingErrorAction private const val UTF8_SNIFF_LENGTH = 24 -/** File helpers not tied to reading/writing content (see [FileIOUtils] for that). */ +/** + * File helpers not tied to reading/writing content (see [FileIOUtils] for that). + * + * Overlaps in purpose with the pre-existing [FileUtil] (singular) - that class predates this + * blankj-utilcode replacement and has its own `readFile`/`writeFile`/`makeDir`/`moveFile`/ + * `deleteFile`, with different failure contracts (e.g. [FileUtil.readFile] returns `""` on a + * missing file, where [FileIOUtils.readFile2String] returns `null`). Prefer this + * `FileUtils`/[FileIOUtils] pair for new code; [FileUtil] remains for its existing call sites and + * bitmap-decoding helpers ([FileUtil.decodeSampleBitmapFromPath], [FileUtil.getScaledBitmap]). + */ object FileUtils { /** * Sniffs whether [file] looks like valid UTF-8 by decoding only its first diff --git a/common/src/main/java/com/itsaky/androidide/utils/ReflectUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/ReflectUtils.kt index 459e76f69c..9ccec80420 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/ReflectUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/ReflectUtils.kt @@ -123,28 +123,30 @@ class ReflectUtils private constructor( throw ReflectException(NoSuchMethodException("No constructor found on $type matching $argTypes")) } - /** Instantiates via a matching constructor, wrapping the new instance. Throws [ReflectException] on failure. */ - fun newInstance(vararg args: Any?): ReflectUtils = + /** Runs [block], passing through any [ReflectException] and wrapping every other exception in one. */ + private inline fun wrapErrors(block: () -> T): T = try { - val constructor = findConstructor(args.map { it?.javaClass }) - constructor.isAccessible = true - reflect(constructor.newInstance(*args)) + block() } catch (e: ReflectException) { throw e } catch (e: Exception) { throw ReflectException(e) } + /** Instantiates via a matching constructor, wrapping the new instance. Throws [ReflectException] on failure. */ + fun newInstance(vararg args: Any?): ReflectUtils = + wrapErrors { + val constructor = findConstructor(args.map { it?.javaClass }) + constructor.isAccessible = true + reflect(constructor.newInstance(*args)) + } + /** Reads a field by name (searching up the class hierarchy), wrapping its value. Throws [ReflectException] if not found. */ fun field(name: String): ReflectUtils = - try { + wrapErrors { val f = findField(name) f.isAccessible = true ReflectUtils(f.type, f.get(target)) - } catch (e: ReflectException) { - throw e - } catch (e: Exception) { - throw ReflectException(e) } /** Sets a field by name and returns this wrapper for chaining. Throws [ReflectException] if not found. */ @@ -152,16 +154,12 @@ class ReflectUtils private constructor( name: String, value: Any?, ): ReflectUtils = - try { + wrapErrors { val f = findField(name) f.isAccessible = true stripFinalModifier(f) f.set(target, value) this - } catch (e: ReflectException) { - throw e - } catch (e: Exception) { - throw ReflectException(e) } /** @@ -174,7 +172,7 @@ class ReflectUtils private constructor( name: String, vararg args: Any?, ): ReflectUtils = - try { + wrapErrors { val m = findMethod(name, args.map { it?.javaClass }) m.isAccessible = true val result = m.invoke(target, *args) @@ -183,10 +181,6 @@ class ReflectUtils private constructor( } else { reflect(result) } - } catch (e: ReflectException) { - throw e - } catch (e: Exception) { - throw ReflectException(e) } /** Returns the wrapped target, unchecked-cast to [T]; throws [ClassCastException] on a mismatched type parameter. */