diff --git a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/UIDebuggerFlipperPlugin.kt b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/UIDebuggerFlipperPlugin.kt new file mode 100644 index 000000000..0d71e148c --- /dev/null +++ b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/UIDebuggerFlipperPlugin.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.flipper.plugins.uidebugger + +import com.facebook.flipper.core.FlipperConnection +import com.facebook.flipper.core.FlipperPlugin +import com.facebook.flipper.plugins.uidebugger.commands.CommandRegister +import com.facebook.flipper.plugins.uidebugger.commands.Context +import com.facebook.flipper.plugins.uidebugger.commands.GetRoot + +class UIDebuggerFlipperPlugin : FlipperPlugin { + private var context: Context = Context() + private var connection: FlipperConnection? = null + + override fun getId(): String { + return "UIDebugger" + } + + @Throws(Exception::class) + override fun onConnect(connection: FlipperConnection) { + this.connection = connection + registerCommands(connection) + } + + @Throws(Exception::class) + override fun onDisconnect() { + this.connection = null + } + + override fun runInBackground(): Boolean { + return true + } + + fun registerCommands(connection: FlipperConnection) { + CommandRegister.register(connection, GetRoot(context)) + } +} diff --git a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/commands/Command.kt b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/commands/Command.kt new file mode 100644 index 000000000..9e7d7c27b --- /dev/null +++ b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/commands/Command.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.flipper.plugins.uidebugger.commands + +import com.facebook.flipper.core.FlipperObject +import com.facebook.flipper.core.FlipperReceiver +import com.facebook.flipper.core.FlipperResponder +import com.facebook.flipper.plugins.common.MainThreadFlipperReceiver + +/** An interface for extensions to the UIDebugger plugin */ +abstract class Command(val context: Context) { + /** The command identifier to respond to */ + abstract fun identifier(): String + /** Execute the command */ + abstract fun execute(params: FlipperObject, response: FlipperResponder) + /** Receiver which is the low-level handler for the incoming request */ + open fun receiver(): FlipperReceiver { + return object : MainThreadFlipperReceiver() { + @kotlin.Throws(java.lang.Exception::class) + override fun onReceiveOnMainThread(params: FlipperObject, responder: FlipperResponder) { + execute(params, responder) + } + } + } +} diff --git a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/commands/CommandRegister.kt b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/commands/CommandRegister.kt new file mode 100644 index 000000000..054977382 --- /dev/null +++ b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/commands/CommandRegister.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.flipper.plugins.uidebugger.commands + +import com.facebook.flipper.core.FlipperConnection + +sealed class CommandRegister { + companion object { + fun register(connection: FlipperConnection, cmd: T) where T : Command { + connection.receive(cmd.identifier(), cmd.receiver()) + } + } +} diff --git a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/commands/Context.kt b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/commands/Context.kt new file mode 100644 index 000000000..dfef7b373 --- /dev/null +++ b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/commands/Context.kt @@ -0,0 +1,10 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.flipper.plugins.uidebugger.commands + +class Context() {} diff --git a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/commands/GetRoot.kt b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/commands/GetRoot.kt new file mode 100644 index 000000000..d3e17206d --- /dev/null +++ b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/commands/GetRoot.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.flipper.plugins.uidebugger.commands + +import com.facebook.flipper.core.FlipperObject +import com.facebook.flipper.core.FlipperResponder + +class GetRoot(context: Context) : Command(context) { + override fun identifier(): String { + return "getRoot" + } + + override fun execute(params: FlipperObject, response: FlipperResponder) {} +} diff --git a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/ApplicationObserver.kt b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/ApplicationObserver.kt new file mode 100644 index 000000000..650e0459d --- /dev/null +++ b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/ApplicationObserver.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.flipper.plugins.uidebugger.core + +import android.app.Activity +import android.app.Application +import android.os.Bundle +import android.view.View +import java.lang.ref.WeakReference + +class ApplicationObserver(val application: Application) : Application.ActivityLifecycleCallbacks { + interface ActivityStackChangedListener { + fun onActivityStackChanged(stack: List) + } + + interface ActivityDestroyedListener { + fun onActivityDestroyed(activity: Activity) + } + + private val rootsResolver: RootViewResolver + private val activities: MutableList> + private var activityStackChangedlistener: ActivityStackChangedListener? = null + private var activityDestroyedListener: ActivityDestroyedListener? = null + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + activities.add(WeakReference(activity)) + activityStackChangedlistener?.let { listener -> + listener.onActivityStackChanged(this.activitiesStack) + } + } + override fun onActivityStarted(activity: Activity) {} + override fun onActivityResumed(activity: Activity) {} + override fun onActivityPaused(activity: Activity) {} + override fun onActivityStopped(activity: Activity) {} + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + override fun onActivityDestroyed(activity: Activity) { + val activityIterator: MutableIterator> = activities.iterator() + + while (activityIterator.hasNext()) { + if (activityIterator.next().get() === activity) { + activityIterator.remove() + } + } + + activityDestroyedListener?.let { listener -> listener.onActivityDestroyed(activity) } + + activityStackChangedlistener?.let { listener -> + listener.onActivityStackChanged(this.activitiesStack) + } + } + + fun setActivityStackChangedListener(listener: ActivityStackChangedListener?) { + activityStackChangedlistener = listener + } + + fun setActivityDestroyedListener(listener: ActivityDestroyedListener?) { + activityDestroyedListener = listener + } + + val activitiesStack: List + get() { + val stack: MutableList = ArrayList(activities.size) + val activityIterator: MutableIterator> = activities.iterator() + while (activityIterator.hasNext()) { + val activity: Activity? = activityIterator.next().get() + if (activity == null) { + activityIterator.remove() + } else { + stack.add(activity) + } + } + return stack + } + + val rootViews: List + get() { + val roots = rootsResolver.listActiveRootViews() + roots?.let { roots -> + val viewRoots: MutableList = ArrayList(roots.size) + for (root in roots) { + viewRoots.add(root.view) + } + return viewRoots + } + + return emptyList() + } + + init { + rootsResolver = RootViewResolver() + application.registerActivityLifecycleCallbacks(this) + activities = ArrayList>() + } +} diff --git a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/LayoutVisitor.kt b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/LayoutVisitor.kt new file mode 100644 index 000000000..6e58fbadc --- /dev/null +++ b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/LayoutVisitor.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.flipper.plugins.uidebugger.core + +import android.view.View +import android.view.ViewGroup + +/** Layout Visitor traverses the entire view hierarchy from a given root. */ +class LayoutVisitor(val visitor: Visitor) { + interface Visitor { + fun visit(view: View) + } + + fun traverse(view: View) { + visitor.visit(view) + + if (view is ViewGroup) { + val viewGroup = view as ViewGroup + val childCount = viewGroup.childCount - 1 + for (i in 0 until childCount) { + val child = viewGroup.getChildAt(i) + traverse(child) + } + } + } + + companion object { + fun create(visitor: Visitor): LayoutVisitor { + return LayoutVisitor(visitor) + } + } +} diff --git a/android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/RootViewResolver.kt b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/RootViewResolver.kt new file mode 100644 index 000000000..7929033b1 --- /dev/null +++ b/android/src/main/java/com/facebook/flipper/plugins/uidebugger/core/RootViewResolver.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.flipper.plugins.uidebugger.core + +import android.os.Build +import android.view.View +import android.view.WindowManager +import java.lang.reflect.Field +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Modifier +import java.util.ArrayList +import java.util.List + +/** + * Provides access to all root views in an application. + * + * 95% of the time this is unnecessary and we can operate solely on current Activity's root view as + * indicated by getWindow().getDecorView(). However in the case of popup windows, menus, and dialogs + * the actual view hierarchy we should be operating on is not accessible thru public apis. + * + * In the spirit of degrading gracefully when new api levels break compatibility, callers should + * handle a list of size 0 by assuming getWindow().getDecorView() on the currently resumed activity + * is the sole root - this assumption will be correct often enough. + * + * Obviously, you need to be on the main thread to use this. + */ +class RootViewResolver { + private var initialized = false + private var windowManagerObj: Any? = null + private var viewsField: Field? = null + private var paramsField: Field? = null + + class RootView(val view: View, val param: WindowManager.LayoutParams) + interface Listener { + fun onRootViewAdded(rootView: View) + fun onRootViewRemoved(rootView: View) + fun onRootViewsChanged(rootView: List) + } + + class ObservableArrayList() : ArrayList() { + private var listener: Listener? = null + fun setListener(listener: Listener?) { + this.listener = listener + } + + override fun add(value: View): Boolean { + val ret = super.add(value) + listener?.let { l -> + l.onRootViewAdded(value) + l.onRootViewsChanged(this as List) + } + return ret + } + + override fun remove(value: View): Boolean { + val ret = super.remove(value) + listener?.let { l -> + l.onRootViewRemoved(value) + l.onRootViewsChanged(this as List) + } + + return ret + } + + override fun removeAt(index: Int): View { + val view = super.removeAt(index) + listener?.let { l -> + l.onRootViewRemoved(view) + l.onRootViewsChanged(this as List) + } + return view + } + } + + fun attachListener(listener: Listener?) { + if (Build.VERSION.SDK_INT < 19 || listener == null) { + // We don't have a use for this on older APIs. If you do then modify accordingly :) + return + } + if (!initialized) { + initialize() + } + try { + viewsField?.let { vf -> + // Forgive me father for I have sinned... + val modifiers = Field::class.java.getDeclaredField("accessFlags") + modifiers.isAccessible = true + modifiers.setInt(vf, vf.modifiers and Modifier.FINAL.inv()) + + val views = vf[windowManagerObj] as List + + val observableViews = ObservableArrayList() + observableViews.setListener(listener) + observableViews.addAll(views) + + vf[windowManagerObj] = observableViews + } + } catch (e: Throwable) {} + } + + /** + * Lists the active root views in an application at this moment. + * + * @return a list of all the active root views in the application. + * @throws IllegalStateException if invoked from a thread besides the main thread. + */ + fun listActiveRootViews(): List? { + if (!initialized) { + initialize() + } + if (null == windowManagerObj) { + return null + } + if (null == viewsField) { + return null + } + if (null == paramsField) { + return null + } + var views: List? = null + var params: List? = null + try { + viewsField?.let { field -> + if (Build.VERSION.SDK_INT < 19) { + val arr = field[windowManagerObj] as Array + views = arr.toList() as List + } else { + views = field[windowManagerObj] as List + } + } + + paramsField?.let { field -> + if (Build.VERSION.SDK_INT < 19) { + val arr = field[windowManagerObj] as Array + params = arr.toList() as List + } else { + params = field[windowManagerObj] as List + } + } + } catch (re: RuntimeException) { + return null + } catch (iae: IllegalAccessException) { + return null + } + + val roots: ArrayList = ArrayList() + views?.let { views -> + params?.let { params -> + for (i in views.indices) { + roots.add(RootView(views[i], params[i])) + } + } + } + + return roots as List + } + + private fun initialize() { + initialized = true + val accessClass = + if (Build.VERSION.SDK_INT > 16) WINDOW_MANAGER_GLOBAL_CLAZZ else WINDOW_MANAGER_IMPL_CLAZZ + val instanceMethod = if (Build.VERSION.SDK_INT > 16) GET_GLOBAL_INSTANCE else GET_DEFAULT_IMPL + try { + val clazz = Class.forName(accessClass) + val getMethod = clazz.getMethod(instanceMethod) + windowManagerObj = getMethod.invoke(null) + viewsField = clazz.getDeclaredField(VIEWS_FIELD) + viewsField?.let { vf -> vf.setAccessible(true) } + paramsField = clazz.getDeclaredField(WINDOW_PARAMS_FIELD) + paramsField?.let { pf -> pf.setAccessible(true) } + } catch (ite: InvocationTargetException) {} catch (cnfe: ClassNotFoundException) {} catch ( + nsfe: NoSuchFieldException) {} catch (nsme: NoSuchMethodException) {} catch ( + re: RuntimeException) {} catch (iae: IllegalAccessException) {} + } + + companion object { + private val TAG = RootViewResolver::class.java.simpleName + private const val WINDOW_MANAGER_IMPL_CLAZZ = "android.view.WindowManagerImpl" + private const val WINDOW_MANAGER_GLOBAL_CLAZZ = "android.view.WindowManagerGlobal" + private const val VIEWS_FIELD = "mViews" + private const val WINDOW_PARAMS_FIELD = "mParams" + private const val GET_DEFAULT_IMPL = "getDefault" + private const val GET_GLOBAL_INSTANCE = "getInstance" + } +} diff --git a/android/src/test/java/com/facebook/flipper/plugins/uidebugger/UIDebuggerFlipperPluginTest.kt b/android/src/test/java/com/facebook/flipper/plugins/uidebugger/UIDebuggerFlipperPluginTest.kt new file mode 100644 index 000000000..575dc6772 --- /dev/null +++ b/android/src/test/java/com/facebook/flipper/plugins/uidebugger/UIDebuggerFlipperPluginTest.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.flipper.plugins.uidebugger + +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class UIDebuggerFlipperPluginTest { + @Throws(Exception::class) + @Test + fun emptyTest() { + var plugin = UIDebuggerFlipperPlugin() + Assert.assertNotNull(plugin) + } +}