diff --git a/annotation-processors/src/main/java/com/itsaky/androidide/annotations/processor/ViewAdapterIndexGenerator.kt b/annotation-processors/src/main/java/com/itsaky/androidide/annotations/processor/ViewAdapterIndexGenerator.kt index 89a6632f4..0aef09d54 100644 --- a/annotation-processors/src/main/java/com/itsaky/androidide/annotations/processor/ViewAdapterIndexGenerator.kt +++ b/annotation-processors/src/main/java/com/itsaky/androidide/annotations/processor/ViewAdapterIndexGenerator.kt @@ -81,6 +81,20 @@ class ViewAdapterIndexGenerator { indexClassBuilder.addMethod( MethodSpec.methodBuilder("getAdapter") + .addAnnotation(ClassName.get("androidx.annotation", "Nullable")) + .addModifiers(PUBLIC, STATIC) + .addParameter(String::class.java, "view", FINAL) + .returns(adapterType) + .addStatement("var adapter = \$L.get(\$L)", INDEX_MAP_FIELD, "view") + .beginControlFlow("if (adapter == null)") + .addStatement("adapter = \$L.get(\$S)", INDEX_MAP_FIELD, env.viewElement().qualifiedName) + .endControlFlow() + .addStatement("return adapter") + .build() + ) + + indexClassBuilder.addMethod( + MethodSpec.methodBuilder("getViewAdapter") .addAnnotation(ClassName.get("androidx.annotation", "Nullable")) .addModifiers(PUBLIC, STATIC) .addParameter(String::class.java, "view", FINAL) diff --git a/tests/test-home/.androidide/init/model.jar b/tests/test-home/.androidide/init/model.jar index be6127240..8b3912702 100644 Binary files a/tests/test-home/.androidide/init/model.jar and b/tests/test-home/.androidide/init/model.jar differ diff --git a/tests/test-project/app/src/main/res/layout/unsupported_views.xml b/tests/test-project/app/src/main/res/layout/unsupported_views.xml new file mode 100644 index 000000000..c391c603a --- /dev/null +++ b/tests/test-project/app/src/main/res/layout/unsupported_views.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/models/UiViewGroup.kt b/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/models/UiViewGroup.kt index 05511ddb9..9c18fac3b 100644 --- a/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/models/UiViewGroup.kt +++ b/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/models/UiViewGroup.kt @@ -48,8 +48,12 @@ internal class UiViewGroup(file: LayoutFile, name: String, view: ViewGroup) : return 0 } val adapter = - ViewAdapterIndex.getAdapter(name) as? IViewGroupAdapter - ?: throw IllegalStateException("No view adapter for '$name'") + ViewAdapterIndex.getAdapter(name) + + if (adapter !is IViewGroupAdapter) { + return childCount + } + return when (adapter.getLayoutBehavior(this)) { TOP_LEFT -> childCount VERTICAL -> computeViewIndexVertically(x, y) diff --git a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/ErrorView.kt b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/ErrorView.kt index 726db92af..ffe45f14b 100644 --- a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/ErrorView.kt +++ b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/ErrorView.kt @@ -21,6 +21,8 @@ import android.content.Context import android.graphics.Color import android.graphics.drawable.ColorDrawable import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout import android.widget.TextView import com.itsaky.androidide.inflater.R import com.itsaky.androidide.utils.resolveAttr @@ -30,11 +32,14 @@ import com.itsaky.androidide.utils.resolveAttr * * @author Akash Yadav */ -class ErrorView(file: LayoutFile, context: Context, message: String) : - ViewImpl(file = file, name = NAME, view = createErrView(context, message)) { - companion object { - private val NAME = View::class.java.name - } +class ErrorView(file: LayoutFile, name: String, context: Context, message: String) : + ViewImpl(file = file, name = name, view = createErrView(context, message)) { + override val tag: String = name +} + +class ErrorLayout(file: LayoutFile, name: String, context: Context) : + ViewGroupImpl(file = file, name = name, view = createErrLayout(context)) { + override val tag: String = name } private fun createErrView(context: Context, message: String): View { @@ -45,3 +50,7 @@ private fun createErrView(context: Context, message: String): View { setTextIsSelectable(true) } } + +private fun createErrLayout(context: Context): ViewGroup { + return FrameLayout(context) +} diff --git a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/LayoutInflaterImpl.kt b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/LayoutInflaterImpl.kt index 3c48c7740..ceda79c1c 100644 --- a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/LayoutInflaterImpl.kt +++ b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/LayoutInflaterImpl.kt @@ -50,15 +50,16 @@ import com.itsaky.androidide.inflater.events.OnInflateViewEvent import com.itsaky.androidide.inflater.internal.utils.IDTable import com.itsaky.androidide.inflater.internal.utils.ViewFactory.createViewInstance import com.itsaky.androidide.inflater.internal.utils.ViewFactory.generateLayoutParams -import com.itsaky.androidide.inflater.utils.isParsing import com.itsaky.androidide.inflater.internal.utils.parseLayoutReference import com.itsaky.androidide.inflater.utils.endParse +import com.itsaky.androidide.inflater.utils.isParsing import com.itsaky.androidide.inflater.utils.startParse import com.itsaky.androidide.lookup.Lookup import com.itsaky.androidide.projects.ProjectManager import com.itsaky.androidide.projects.api.AndroidModule import com.itsaky.androidide.xml.widgets.Widget import com.itsaky.androidide.xml.widgets.WidgetTable +import com.itsaky.androidide.xml.widgets.WidgetType import java.io.File /** @@ -87,18 +88,14 @@ open class LayoutInflaterImpl : ILayoutInflater { override fun inflate(file: File, parent: ViewGroup): List { startInflation(file) - return doInflate(file, parent).apply { - finishInflation() - } + return doInflate(file, parent).apply { finishInflation() } } - - override fun inflate(file: File, parent: IViewGroup) : List { + + override fun inflate(file: File, parent: IViewGroup): List { startInflation(file) - return doInflate(file, parent).apply { - finishInflation() - } + return doInflate(file, parent).apply { finishInflation() } } - + override fun close() { this.module = null this.inflationEventListener = null @@ -111,7 +108,7 @@ open class LayoutInflaterImpl : ILayoutInflater { val (processor, module) = processXmlFile(file) return doInflate(processor, parent, module) } - + protected open fun doInflate(file: File, parent: IViewGroup): List { val (processor, module) = processXmlFile(file) return doInflate(processor, parent, module) @@ -124,7 +121,7 @@ open class LayoutInflaterImpl : ILayoutInflater { ): List { return doInflate(processor, module) { wrap(parent) } } - + protected open fun doInflate( processor: XmlProcessor, parent: IViewGroup, @@ -190,7 +187,12 @@ open class LayoutInflaterImpl : ILayoutInflater { // TODO(itsaky): Handle views from libraries val view: ViewImpl = (if (widget == null) { - onCreateUnsupportedView("View with name '${element.name}' not found", parentView) + onCreateUnsupportedView( + element.name, + element.childCount > 0, + "View with name '${element.name}' not found", + parentView + ) } else { onCreatePlatformView(widget, parentView, module, widgets) }) @@ -224,7 +226,7 @@ open class LayoutInflaterImpl : ILayoutInflater { val parentView = parent.view as ViewGroup val adapter = ViewAdapterIndex.getAdapter(view.name) - ?: throw InflateException("No attribute adapter found for view ${view.name}") + ?: throw InflateException("No attribute adapter found for view ${view.name}") view.view.layoutParams = generateLayoutParams(parentView) @@ -308,7 +310,12 @@ open class LayoutInflaterImpl : ILayoutInflater { val v = createViewInstance(widget.qualifiedName, parent.context) return componentFactory.createView(currentLayoutFile, widget.qualifiedName, v) } catch (err: Throwable) { - onCreateUnsupportedView("Unable to create view for widget ${widget.qualifiedName}", parent) + onCreateUnsupportedView( + widget.qualifiedName, + widget.type == WidgetType.LAYOUT, + "Unable to create view for widget ${widget.qualifiedName}", + parent + ) } } @@ -360,15 +367,24 @@ open class LayoutInflaterImpl : ILayoutInflater { return processor to module } - private fun onCreateUnsupportedView(message: String, parent: ViewGroup): IView { - return ErrorView(currentLayoutFile, parent.context, message) + private fun onCreateUnsupportedView( + name: String, + isLayout: Boolean, + message: String, + parent: ViewGroup + ): IView { + return if (isLayout) { + ErrorLayout(file = currentLayoutFile, name = name, context = parent.context) + } else { + ErrorView(file = currentLayoutFile, name = name, context = parent.context, message = message) + } } - + private fun List.finishInflation() { inflationEventListener?.onEvent(InflationFinishEvent(this)) _primaryInflatingFile = null } - + private fun startInflation(file: File) { this._primaryInflatingFile = file IDTable.newRound() diff --git a/xml-inflater/src/test/java/com/itsaky/androidide/inflater/LayoutInflaterTest.kt b/xml-inflater/src/test/java/com/itsaky/androidide/inflater/LayoutInflaterTest.kt index 8723e462b..c2ae9fc34 100644 --- a/xml-inflater/src/test/java/com/itsaky/androidide/inflater/LayoutInflaterTest.kt +++ b/xml-inflater/src/test/java/com/itsaky/androidide/inflater/LayoutInflaterTest.kt @@ -18,7 +18,10 @@ package com.itsaky.androidide.inflater import android.view.View +import android.widget.FrameLayout +import android.widget.ImageView import android.widget.LinearLayout +import android.widget.TextView import com.google.common.truth.Truth.assertThat import com.itsaky.androidide.inflater.internal.LayoutInflaterImpl import com.itsaky.androidide.inflater.internal.ViewGroupImpl @@ -39,9 +42,9 @@ class LayoutInflaterTest { @Test fun `test functionality`() { inflaterTest { - requiresActivity { activity -> + requiresActivity { val file = layoutFile("singleView") - val parent = LinearLayout(activity) + val parent = LinearLayout(this) val inflater = ILayoutInflater.newInflater() val inflated = inflater.inflate(file, parent) inflated.apply { @@ -56,8 +59,8 @@ class LayoutInflaterTest { @Test fun `test inflated view is of expected type`() { inflaterTest { module -> - requiresActivity { activity -> - val parent = LinearLayout(activity) + requiresActivity { + val parent = LinearLayout(this) val inflater = ILayoutInflater.newInflater() as LayoutInflaterImpl viewToAdapter.keys.forEach { view -> println("Testing layout inflater and adapter for ${view.qualifiedName}") @@ -72,9 +75,13 @@ class LayoutInflaterTest { assertThat(this) .contains(newAttribute(inflated, INamespace.ANDROID, "id", "@+id/template_view")) assertThat(this) - .contains(newAttribute(inflated, INamespace.ANDROID, "layout_height", "match_parent")) + .contains( + newAttribute(inflated, INamespace.ANDROID, "layout_height", "match_parent") + ) assertThat(this) - .contains(newAttribute(inflated, INamespace.ANDROID, "layout_width", "match_parent")) + .contains( + newAttribute(inflated, INamespace.ANDROID, "layout_width", "match_parent") + ) } } } @@ -85,8 +92,8 @@ class LayoutInflaterTest { @Test fun `verify included view hierarchy`() { inflaterTest { module -> - requiresActivity { activity -> - val parent = LinearLayout(activity) + requiresActivity { + val parent = LinearLayout(this) val inflater = ILayoutInflater.newInflater() val inflated = inflater.inflate(layoutFile("include"), parent) assertThat(inflated).hasSize(1) @@ -117,9 +124,9 @@ class LayoutInflaterTest { @Test fun `verify merged view hierarchy`() { inflaterTest { module -> - requiresActivity { activity -> - val parent = LinearLayout(activity) - val inflater = ILayoutInflater.newInflater() + requiresActivity { + val parent = LinearLayout(this) + val inflater = ILayoutInflater.newInflater(module) val inflated = inflater.inflate(layoutFile("merge"), parent) assertThat(inflated).hasSize(1) @@ -141,6 +148,54 @@ class LayoutInflaterTest { } } + @Test + fun `test unsupported views`() { + inflaterTest { + requiresActivity { + val parent = LinearLayout(this) + val inflater = ILayoutInflater.newInflater(it) + val inflated = inflater.inflate(layoutFile("unsupported_views"), parent) + + assertThat(inflated).hasSize(1) + + val root = inflated[0] as ViewGroupImpl + + + // verity the inflated layout hierarchy + // this takes care of verifying that the generated XML elements will have proper XML tagsl + assertThat(root.printHierarchy()) + .isEqualTo( + "android.widget.LinearLayout\n" + + " com.itsaky.androidide.inflater.unsupported.UnsupportedView\n" + + " com.itsaky.androidide.inflater.unsupported.UnsupportedLayout\n" + + " com.itsaky.androidide.inflater.unsupported.UnsupportedLayout\n" + + " android.widget.ImageView\n" + + " android.widget.ImageView\n" + + " com.itsaky.androidide.inflater.unsupported.UnsupportedLayout\n" + + " com.itsaky.androidide.inflater.unsupported.UnsupportedView\n" + + " com.itsaky.androidide.inflater.unsupported.UnsupportedView\n" + ) + + // TextView is used to inflate unsupported views + assertThat(root[0].view).isInstanceOf(TextView::class.java) + + // TextView is also used for layouts which do not have any child viewsl + assertThat(root[1].view).isInstanceOf(TextView::class.java) + + // FrameLayout is used to inflate unsupported layouts with child views + assertThat(root[2].view).isInstanceOf(FrameLayout::class.java) + assertThat(root[3].view).isInstanceOf(FrameLayout::class.java) + + assertThat((root[2] as ViewGroupImpl)[0].view).isInstanceOf(ImageView::class.java) + assertThat((root[2] as ViewGroupImpl)[1].view).isInstanceOf(ImageView::class.java) + + // Unsupported child views + assertThat((root[3] as ViewGroupImpl)[0].view).isInstanceOf(TextView::class.java) + assertThat((root[3] as ViewGroupImpl)[1].view).isInstanceOf(TextView::class.java) + } + } + } + private fun layoutFile(name: String): File { val app = ProjectManager.app ?: throw IllegalStateException("Project is not initialized") return File(app.projectDir, "src/main/res/layout/$name.xml") diff --git a/xml-inflater/src/test/java/com/itsaky/androidide/inflater/ValueParsersTest.kt b/xml-inflater/src/test/java/com/itsaky/androidide/inflater/ValueParsersTest.kt index 31a783e46..304db36a0 100644 --- a/xml-inflater/src/test/java/com/itsaky/androidide/inflater/ValueParsersTest.kt +++ b/xml-inflater/src/test/java/com/itsaky/androidide/inflater/ValueParsersTest.kt @@ -41,26 +41,24 @@ class ValueParsersTest { @Test fun `hardcoded dimension parser test`() { inflaterTest { module -> - requiresActivity { activity -> + requiresActivity { // Hardcoded dimensions val units = arrayOf("dp", "sp", "pt", "px", "in", "mm") val results = intArrayOf(1, 1, 2, 1, 160, 6) for (i in units.indices) { - assertThat(parseDimension(activity, "1" + units[i], -1f).roundToInt()) - .isEqualTo(results[i]) + assertThat(parseDimension(this, "1" + units[i], -1f).roundToInt()).isEqualTo(results[i]) } // Dimensions from platform resources - assertThat(parseDimension(activity, "@android:dimen/app_icon_size")).isEqualTo(48) - assertThat(parseDimension(activity, "@android:dimen/status_bar_height_portrait")) - .isEqualTo(24) + assertThat(parseDimension(this, "@android:dimen/app_icon_size")).isEqualTo(48) + assertThat(parseDimension(this, "@android:dimen/status_bar_height_portrait")).isEqualTo(24) val exists = try { // The app module here targets android 31 and the dimension resource below was added in // android 31. So, this should throw an exception - assertThat(parseDimension(activity, "@android:dimen/status_bar_height_default")) + assertThat(parseDimension(this, "@android:dimen/status_bar_height_default")) .isEqualTo(-2) true } catch (err: IllegalArgumentException) { @@ -69,15 +67,15 @@ class ValueParsersTest { assertThat(exists).isFalse() - assertThat(parseDimension(activity, "@dimen/test_dimen_0dp", def = 1f)).isEqualTo(0) - assertThat(parseDimension(activity, "@dimen/test_dimen_1dp", def = 0f)).isEqualTo(1) - assertThat(parseDimension(activity, "@dimen/test_dimen_1dp_ref", def = 0f)).isEqualTo(1) - assertThat(parseDimension(activity, "@dimen/test_dimen_0dp_ref", def = 0f)).isEqualTo(0) + assertThat(parseDimension(this, "@dimen/test_dimen_0dp", def = 1f)).isEqualTo(0) + assertThat(parseDimension(this, "@dimen/test_dimen_1dp", def = 0f)).isEqualTo(1) + assertThat(parseDimension(this, "@dimen/test_dimen_1dp_ref", def = 0f)).isEqualTo(1) + assertThat(parseDimension(this, "@dimen/test_dimen_0dp_ref", def = 0f)).isEqualTo(0) - assertThat(parseDimension(activity, "@dimen/test_dimen_0pt", def = 1f)).isEqualTo(0) - assertThat(parseDimension(activity, "@dimen/test_dimen_1pt", def = 0f)).isEqualTo(1) - assertThat(parseDimension(activity, "@dimen/test_dimen_1pt_ref", def = 0f)).isEqualTo(1) - assertThat(parseDimension(activity, "@dimen/test_dimen_0pt_ref", def = 0f)).isEqualTo(0) + assertThat(parseDimension(this, "@dimen/test_dimen_0pt", def = 1f)).isEqualTo(0) + assertThat(parseDimension(this, "@dimen/test_dimen_1pt", def = 0f)).isEqualTo(1) + assertThat(parseDimension(this, "@dimen/test_dimen_1pt_ref", def = 0f)).isEqualTo(1) + assertThat(parseDimension(this, "@dimen/test_dimen_0pt_ref", def = 0f)).isEqualTo(0) } } } @@ -85,17 +83,17 @@ class ValueParsersTest { @Test fun `drawable parse test`() { inflaterTest { module -> - requiresActivity { activity -> - parseDrawable(activity, "#ff0000").apply { + requiresActivity { + parseDrawable(this, "#ff0000").apply { assertThat(this).isNotNull() assertThat(this).isInstanceOf(ColorDrawable::class.java) assertThat((this as ColorDrawable).color).isEqualTo(Color.RED) } - parseDrawable(activity, "@android:drawable/ab_share_pack_material").apply { + parseDrawable(this, "@android:drawable/ab_share_pack_material").apply { assertThat(this).isNotNull() assertThat(this).isInstanceOf(BitmapDrawable::class.java) } - parseDrawable(activity, "@android:drawable/action_bar_background").apply { + parseDrawable(this, "@android:drawable/action_bar_background").apply { assertThat(this).isNotNull() assertThat(this).isInstanceOf(GradientDrawable::class.java) } @@ -178,17 +176,16 @@ class ValueParsersTest { @Test fun `color parser test`() { inflaterTest { - requiresActivity { activity -> - assertThat(parseColor(activity, "@android:color/red")).isEqualTo(Color.RED) - assertThat(parseColor(activity, "@android:color/black")).isEqualTo(Color.BLACK) - assertThat(parseColor(activity, "@android:color/white")).isEqualTo(Color.WHITE) - assertThat(parseColor(activity, "@android:color/transparent")).isEqualTo(Color.TRANSPARENT) - assertThat(parseColor(activity, "@color/test_color")).isEqualTo(Color.parseColor("#f44336")) - assertThat(parseColor(activity, "@color/test_color_ref")) - .isEqualTo(Color.parseColor("#f44336")) + requiresActivity { + assertThat(parseColor(this, "@android:color/red")).isEqualTo(Color.RED) + assertThat(parseColor(this, "@android:color/black")).isEqualTo(Color.BLACK) + assertThat(parseColor(this, "@android:color/white")).isEqualTo(Color.WHITE) + assertThat(parseColor(this, "@android:color/transparent")).isEqualTo(Color.TRANSPARENT) + assertThat(parseColor(this, "@color/test_color")).isEqualTo(Color.parseColor("#f44336")) + assertThat(parseColor(this, "@color/test_color_ref")).isEqualTo(Color.parseColor("#f44336")) // TODO Implement color state list parser - assertThat(parseColor(activity, "@color/test_selector")).isEqualTo(Color.TRANSPARENT) + assertThat(parseColor(this, "@color/test_selector")).isEqualTo(Color.TRANSPARENT) } } } diff --git a/xml-inflater/src/test/java/com/itsaky/androidide/inflater/XmlInflaterTest.kt b/xml-inflater/src/test/java/com/itsaky/androidide/inflater/XmlInflaterTest.kt index 3c8a4fc6e..4650a62d6 100644 --- a/xml-inflater/src/test/java/com/itsaky/androidide/inflater/XmlInflaterTest.kt +++ b/xml-inflater/src/test/java/com/itsaky/androidide/inflater/XmlInflaterTest.kt @@ -58,6 +58,6 @@ fun inflaterTest(block: (AndroidModule) -> Unit) { endParse() } -fun requiresActivity(block: (AppCompatActivity) -> Unit) { - block(XmlInflaterTest.activity) +fun requiresActivity(block: AppCompatActivity.() -> Unit) { + XmlInflaterTest.activity.block() }