Added basic layout inflation logic

This commit is contained in:
Akash Yadav
2022-11-03 18:58:33 +05:30
parent 42eca3e5d9
commit 070fa83601
13 changed files with 314 additions and 54 deletions
Binary file not shown.
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<View xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
+11
View File
@@ -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
@@ -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)
}
@@ -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.
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
@@ -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<in ViewGroup> = 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"
}
}
@@ -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<IView>()
override val childCount: Int
@@ -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<IAttribute>()
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?
}
}
@@ -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")
}
}
@@ -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")
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<AppCompatActivity>
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")
}
}