diff --git a/tests/test-home/.androidide/init/model.jar b/tests/test-home/.androidide/init/model.jar index 237f0e5de..f625b502b 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/singleView.xml b/tests/test-project/app/src/main/res/layout/singleView.xml new file mode 100644 index 000000000..ebd55776d --- /dev/null +++ b/tests/test-project/app/src/main/res/layout/singleView.xml @@ -0,0 +1,4 @@ + + diff --git a/xml-inflater/build.gradle b/xml-inflater/build.gradle index 5af3c249b..0a9c36c19 100644 --- a/xml-inflater/build.gradle +++ b/xml-inflater/build.gradle @@ -34,6 +34,14 @@ android { includeAndroidResources = true } } + + sourceSets { + main { + java { + srcDir "${buildDir.absolutePath}/generated/source/kaptKotlin/" + } + } + } } dependencies { @@ -44,9 +52,12 @@ dependencies { implementation project(path: ':build-tools:aaptcompiler') implementation project(path: ':build-tools:projects') implementation project(path: ':build-tools:xml-utils') + implementation project(path: ':resources') kapt project(path: ':annotation-processors') + testImplementation project (path: ':build-tools:projects') + testImplementation project (path: ':build-tools:tooling-api-testing') testImplementation tests.junit testImplementation tests.google_truth testImplementation tests.robolectric diff --git a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/IAttributeAdapter.kt b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/IAttributeAdapter.kt index f59f30498..35872f0b6 100644 --- a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/IAttributeAdapter.kt +++ b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/IAttributeAdapter.kt @@ -31,4 +31,11 @@ abstract class IAttributeAdapter { * @param attribute The attribute to apply. */ abstract fun apply(view: IView, attribute: IAttribute) + + /** + * Apply the basic attributes to a view so that it could be rendered. + * + * @param view The view to apply basic attributes to. + */ + abstract fun applyBasic(view: IView) } diff --git a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/IView.kt b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/IView.kt index 76d2461af..b973ec738 100644 --- a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/IView.kt +++ b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/IView.kt @@ -39,7 +39,7 @@ interface IView { val view: View /** The parent view. */ - val parent: IViewGroup + var parent: IViewGroup? /** * Add and apply the given attribute to this view. 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 new file mode 100644 index 000000000..9c486a803 --- /dev/null +++ b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/ErrorView.kt @@ -0,0 +1,47 @@ +/* + * 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.internal + +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.view.View +import android.widget.TextView +import androidx.core.content.ContextCompat +import com.itsaky.androidide.inflater.R + +/** + * View used to show views that cannot be inflated. + * + * @author Akash Yadav + */ +class ErrorView(context: Context, message: String) : + ViewImpl(NAME, createErrView(context, message)) { + companion object { + private val NAME = View::class.java.name + } +} + +private fun createErrView(context: Context, message: String): View { + return TextView(context).apply { + text = message + background = ColorDrawable(Color.WHITE) + setTextColor(ContextCompat.getColor(context, R.color.secondaryColor)) + setTextIsSelectable(true) + } +} 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 57e6b9797..e8f848166 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 @@ -17,11 +17,13 @@ package com.itsaky.androidide.inflater.internal +import android.content.Context import android.view.InflateException +import android.view.View import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams import com.android.aapt.Resources.SourcePosition import com.android.aapt.Resources.XmlElement -import com.android.aapt.Resources.XmlNode.NodeCase import com.android.aapt.Resources.XmlNode.NodeCase.ELEMENT import com.android.aaptcompiler.AaptResourceType.LAYOUT import com.android.aaptcompiler.BlameLogger @@ -33,29 +35,35 @@ import com.itsaky.androidide.aapt.logging.IDELogger import com.itsaky.androidide.inflater.IInflateEventsListener import com.itsaky.androidide.inflater.ILayoutInflater import com.itsaky.androidide.inflater.IView +import com.itsaky.androidide.inflater.IViewGroup import com.itsaky.androidide.inflater.InflationFinishEvent import com.itsaky.androidide.inflater.InflationStartEvent import com.itsaky.androidide.projects.ProjectManager import com.itsaky.androidide.projects.api.AndroidModule +import com.itsaky.androidide.utils.ILogger +import com.itsaky.androidide.xml.widgets.Widget +import com.itsaky.androidide.xml.widgets.WidgetTable import java.io.File +import java.lang.reflect.Method /** * Default implementation of [ILayoutInflater]. * * @author Akash Yadav */ -internal class LayoutInflaterImpl : ILayoutInflater() { +open class LayoutInflaterImpl : ILayoutInflater() { override var inflationEventListener: IInflateEventsListener? = null + private val log = ILogger.newInstance("LayoutInflaterImpl") override fun inflate(file: File, parent: ViewGroup): IView? { inflationEventListener?.onEvent(InflationStartEvent()) - return doInflate(file, parent).apply { + return doInflate(file, wrap(parent)).apply { inflationEventListener?.onEvent(InflationFinishEvent(this)) } } - private fun doInflate(file: File, parent: ViewGroup): IView? { + protected open fun doInflate(file: File, parent: IViewGroup): IView? { val pathData = extractPathData(file) if (pathData.type != LAYOUT) { throw InflateException("File is not a layout file.") @@ -77,30 +85,156 @@ internal class LayoutInflaterImpl : ILayoutInflater() { val processor = XmlProcessor(pathData.source, BlameLogger(IDELogger)) processor.process(resFile, file.inputStream()) - return doInflate(processor, parent) + return doInflate(processor, parent, module) } - private fun doInflate(processor: XmlProcessor, parent: ViewGroup): IView? { + protected open fun doInflate( + processor: XmlProcessor, + parent: IViewGroup, + module: AndroidModule, + ): IView? { // TODO(itsaky) : Add test for multiple view as root layout // The inflater should fail in such cases - val (file, node) = + val (_, node) = processor.xmlResources.find { it.file == processor.primaryFile } ?: throw InflateException("Unable to find primary XML resource from XmlProcessor") - + if (node.nodeCase != ELEMENT) { - throw InflateException("Found ${node.nodeCase} but $ELEMENT was expected at ${node.source.lineCol()}") + throw InflateException( + "Found ${node.nodeCase} but $ELEMENT was expected at ${node.source.lineCol()}" + ) } - + val element = node.element - val view = onCreateView(element) + return onCreateView(element, parent, module) + } + + protected open fun onCreateView( + element: XmlElement, + parent: IViewGroup, + module: AndroidModule, + widgets: WidgetTable = + module.getWidgetTable() + ?: throw IllegalStateException("No widget table found for module $module"), + ): IView { + + val parentView = parent.view as ViewGroup + + if ("include" == element.name) { + return onCreateIncludedView(element, parentView, module, widgets) + } + + if ("merge" == element.name) { + return onCreateMergedView(element, parentView, module, widgets) + } + + val widget = + if (element.name.contains('.')) widgets.getWidget(element.name) + else widgets.findWidgetWithSimpleName(element.name) + + // TODO(itsaky): Handle views from libraries + val view = + if (widget == null) { + onCreateUnsupportedView("View with name '${element.name}' not found", parentView) + } else { + onCreatePlatformView(widget, parentView, module, widgets) + } + + val adapter = + AttributeAdapterIndex.getAdapter(view.name) + ?: throw InflateException("No attribute adapter found for view ${view.name}") + + adapter.applyBasic(view) + + view.view.layoutParams = generateLayoutParams(parentView) + parent.addChild(view) + + if (element.childCount > 0 && view is IViewGroup) { + for (child in element.childList) { + if (child.nodeCase != ELEMENT) { + throw InflateException("Unexpected node at ${child.source.lineCol()}") + } + val childView = + onCreateView(element = child.element, parent = view, module = module, widgets = widgets) + view.addChild(childView) + } + } + + return view + } + + private fun onCreateMergedView( + element: XmlElement, + parent: ViewGroup, + module: AndroidModule, + widgets: WidgetTable, + ): IView { TODO("Not yet implemented") } - - private fun onCreateView(element: XmlElement): IView { + + private fun onCreateIncludedView( + element: XmlElement, + parent: ViewGroup, + module: AndroidModule, + widgets: WidgetTable, + ): IView { TODO("Not yet implemented") } + + protected open fun onCreatePlatformView( + widget: Widget, + parent: ViewGroup, + module: AndroidModule, + widgets: WidgetTable, + ): IView { + return try { + val v = createViewInstance(widget, parent) + return ViewImpl(widget.qualifiedName, v) + } catch (err: Throwable) { + onCreateUnsupportedView("Unable to create view for widget ${widget.qualifiedName}", parent) + } + } + + protected open fun generateLayoutParams(parent: ViewGroup): LayoutParams { + return try { + var clazz: Class = parent.javaClass + var method: Method? + while (true) { + try { + method = clazz.getDeclaredMethod("generateDefaultLayoutParams") + break + } catch (e: Throwable) { + /* ignored */ + } + + clazz = clazz.superclass + } + if (method != null) { + method.isAccessible = true + return method.invoke(parent) as LayoutParams + } + log.error("Unable to create default params for view parent:", parent) + LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + } catch (th: Throwable) { + throw InflateException("Unable to create layout params for parent: $parent", th) + } + } + + private fun createViewInstance(widget: Widget, parent: ViewGroup): View { + val klass = javaClass.classLoader!!.loadClass(widget.qualifiedName) + val constructor = klass.getConstructor(Context::class.java) + return constructor.newInstance(parent.context) as View + } + + private fun onCreateUnsupportedView(message: String, parent: ViewGroup): IView { + return ErrorView(parent.context, message) + } - private fun SourcePosition.lineCol() : String { + private fun wrap(parent: ViewGroup) : IViewGroup { + return ViewGroupImpl(parent.javaClass.name, parent) + } + + private fun SourcePosition.lineCol(): String { return "line $lineNumber and column $columnNumber" } } diff --git a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/AbstractViewGroup.kt b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/ViewGroupImpl.kt similarity index 93% rename from xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/AbstractViewGroup.kt rename to xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/ViewGroupImpl.kt index bdb76ed2a..3feed025b 100644 --- a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/AbstractViewGroup.kt +++ b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/ViewGroupImpl.kt @@ -21,8 +21,8 @@ import android.view.ViewGroup import com.itsaky.androidide.inflater.IView import com.itsaky.androidide.inflater.IViewGroup -abstract class AbstractViewGroup(name: String, view: ViewGroup) : - AbstractView(name = name, view = view), IViewGroup { +open class ViewGroupImpl(name: String, view: ViewGroup) : + ViewImpl(name = name, view = view), IViewGroup { protected val children = mutableListOf() override val childCount: Int diff --git a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/AbstractView.kt b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/ViewImpl.kt similarity index 66% rename from xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/AbstractView.kt rename to xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/ViewImpl.kt index ed0441d58..a95ab484d 100644 --- a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/AbstractView.kt +++ b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/ViewImpl.kt @@ -20,18 +20,36 @@ package com.itsaky.androidide.inflater.internal import android.view.View import com.itsaky.androidide.inflater.IAttribute import com.itsaky.androidide.inflater.IView +import com.itsaky.androidide.inflater.IViewGroup import com.itsaky.androidide.inflater.internal.utils.simpleName import com.itsaky.androidide.inflater.internal.utils.tagName +import com.itsaky.androidide.utils.ILogger -abstract class AbstractView( +open class ViewImpl +@JvmOverloads +constructor( override val name: String, override val view: View, override val simpleName: String = name.simpleName(), override val tag: String = name.tagName() ) : IView { + private val log = ILogger.newInstance(javaClass.simpleName) protected val attributes = mutableListOf() + override var parent: IViewGroup? = null + override fun addAttribute(attribute: IAttribute) { - + this.attributes.add(attribute) + val adapter = AttributeAdapterIndex.getAdapter(name) + if (adapter == null) { + log.warn("No attribute adapter found for view $name") + return + } + adapter.apply(this, attribute) + } + + override fun removeAttribute(attribute: IAttribute) { + this.attributes.remove(attribute) + // TODO(itsaky): Should attribute adapters handle this as well? } } diff --git a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/adapters/ViewAttrAdapter.kt b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/adapters/ViewAttrAdapter.kt index c9a94611f..72d4ab653 100644 --- a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/adapters/ViewAttrAdapter.kt +++ b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/adapters/ViewAttrAdapter.kt @@ -34,4 +34,8 @@ open class ViewAttrAdapter : IAttributeAdapter() { override fun apply(view: IView, attribute: IAttribute) { TODO("Not yet implemented") } + + override fun applyBasic(view: IView) { + TODO("Not yet implemented") + } } diff --git a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/adapters/ViewGroupAttrAdapter.kt b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/adapters/ViewGroupAttrAdapter.kt index aaeec5775..03a69f171 100644 --- a/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/adapters/ViewGroupAttrAdapter.kt +++ b/xml-inflater/src/main/java/com/itsaky/androidide/inflater/internal/adapters/ViewGroupAttrAdapter.kt @@ -25,7 +25,7 @@ import com.itsaky.androidide.inflater.IView /** @author Akash Yadav */ @AttributeAdapter(ViewGroup::class) -class ViewGroupAttrAdapter : IAttributeAdapter() { +class ViewGroupAttrAdapter : ViewAttrAdapter() { override fun apply(view: IView, attribute: IAttribute) { TODO("Not yet implemented") diff --git a/xml-inflater/src/test/java/com/itsaky/androidide/inflater/ExampleUnitTest.kt b/xml-inflater/src/test/java/com/itsaky/androidide/inflater/ExampleUnitTest.kt deleted file mode 100644 index bcb187e38..000000000 --- a/xml-inflater/src/test/java/com/itsaky/androidide/inflater/ExampleUnitTest.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * 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 - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file 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 new file mode 100644 index 000000000..72cf6e733 --- /dev/null +++ b/xml-inflater/src/test/java/com/itsaky/androidide/inflater/LayoutInflaterTest.kt @@ -0,0 +1,69 @@ +/* + * 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 + +import android.widget.LinearLayout +import androidx.appcompat.app.AppCompatActivity +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.lookup.Lookup +import com.itsaky.androidide.projects.ProjectManager +import com.itsaky.androidide.projects.builder.BuildService +import com.itsaky.androidide.tooling.api.messages.InitializeProjectMessage +import com.itsaky.androidide.tooling.testing.ToolingApiTestLauncher +import java.io.File +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.android.controller.ActivityController + +/** @author Akash Yadav */ +@RunWith(RobolectricTestRunner::class) +class LayoutInflaterTest { + + private lateinit var activityController: ActivityController + private lateinit var activity: AppCompatActivity + + @Before + fun setup() { + + val (server, project) = ToolingApiTestLauncher().launchServer(implDir = "../build-tools/tooling-api-impl") + server.initialize(InitializeProjectMessage(File("../tests/test-project").absolutePath)).get() + + Lookup.DEFAULT.register(BuildService.KEY_PROJECT_PROXY, project) + ProjectManager.setupProject() + + this.activityController = Robolectric.buildActivity(AppCompatActivity::class.java) + this.activity = this.activityController.get() + } + + @Test + fun `test functionality`() { + val file = layoutFile("singleView") + val parent = LinearLayout(activity) + val inflater = ILayoutInflater.newInflater() + val inflated = inflater.inflate(file, parent) + assertThat(inflated).isNotNull() + } + + 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") + } +}