-
Notifications
You must be signed in to change notification settings - Fork 12
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add autocapture for element clicks (#199)
* Implement Autocapture for the Kotlin SDK * Support Android API levels down to 16 for autocapture * Revert linter changes * Upgrade minSDK to 19 with targetSDK set to 34 * Add unit tests for Autocapture * Revert linter changes and fix typo * Add tracking start and stop for user interactions * Find ViewTarget even if Resource.NotFoundException is thrown * Change event name to [Amplitude] Element Tapped * Add checks for Compose * Fix failing tests for autocapture * Fix typo * Change element clicked event name * Move compose to the android module * Remove compose gradle plugin * Add experimental opt-in annotation for user interactions tracking * Fix lint issues * Change build JDK version to 17 * Update gradle version * Add Java 11 to the CI flow * Cleanup the CI workflow * Add name for workflow jobs * Exclude tests for builds * Opt-in for internalComposeUiApi usage
- Loading branch information
1 parent
9432ef8
commit 7f0a500
Showing
29 changed files
with
1,524 additions
and
186 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,4 @@ | ||
<?xml version="1.0" encoding="utf-8"?> | ||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" | ||
package="com.amplitude.android"> | ||
|
||
</manifest> |
47 changes: 33 additions & 14 deletions
47
android/src/main/java/com/amplitude/android/DefaultTrackingOptions.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,27 +1,46 @@ | ||
package com.amplitude.android | ||
|
||
open class DefaultTrackingOptions @JvmOverloads constructor( | ||
open class DefaultTrackingOptions | ||
@JvmOverloads | ||
constructor( | ||
var sessions: Boolean = true, | ||
var appLifecycles: Boolean = false, | ||
var deepLinks: Boolean = false, | ||
var screenViews: Boolean = false | ||
var screenViews: Boolean = false, | ||
) { | ||
var userInteractions = false | ||
@ExperimentalAmplitudeFeature | ||
set | ||
|
||
// Prebuilt options for easier usage | ||
companion object { | ||
@JvmField | ||
val ALL = DefaultTrackingOptions( | ||
sessions = true, | ||
appLifecycles = true, | ||
deepLinks = true, | ||
screenViews = true | ||
) | ||
val ALL = | ||
DefaultTrackingOptions( | ||
sessions = true, | ||
appLifecycles = true, | ||
deepLinks = true, | ||
screenViews = true, | ||
) | ||
|
||
@JvmField | ||
val NONE = DefaultTrackingOptions( | ||
sessions = false, | ||
appLifecycles = false, | ||
deepLinks = false, | ||
screenViews = false | ||
) | ||
val NONE = | ||
DefaultTrackingOptions( | ||
sessions = false, | ||
appLifecycles = false, | ||
deepLinks = false, | ||
screenViews = false, | ||
) | ||
} | ||
|
||
@ExperimentalAmplitudeFeature | ||
constructor( | ||
sessions: Boolean = true, | ||
appLifecycles: Boolean = false, | ||
deepLinks: Boolean = false, | ||
screenViews: Boolean = false, | ||
userInteractions: Boolean = false, | ||
) : this(sessions, appLifecycles, deepLinks, screenViews) { | ||
this.userInteractions = userInteractions | ||
} | ||
} |
9 changes: 9 additions & 0 deletions
9
android/src/main/java/com/amplitude/android/ExperimentalAmplitudeFeature.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
package com.amplitude.android | ||
|
||
@RequiresOptIn( | ||
message = | ||
"This feature is experimental, and may change or break at any time. Use with caution.", | ||
) | ||
@Retention(AnnotationRetention.BINARY) | ||
@Target(AnnotationTarget.PROPERTY_SETTER, AnnotationTarget.CONSTRUCTOR) | ||
annotation class ExperimentalAmplitudeFeature |
95 changes: 95 additions & 0 deletions
95
android/src/main/java/com/amplitude/android/internal/ViewHierarchyScanner.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,95 @@ | ||
package com.amplitude.android.internal | ||
|
||
import android.os.Looper | ||
import android.view.View | ||
import android.view.ViewGroup | ||
import androidx.core.view.children | ||
import com.amplitude.android.internal.locators.ViewTargetLocator | ||
import com.amplitude.common.Logger | ||
import kotlinx.coroutines.Dispatchers | ||
import kotlinx.coroutines.runBlocking | ||
import kotlinx.coroutines.withContext | ||
|
||
/** | ||
* Utility class for scanning the view hierarchy and finding a target at a given position. | ||
*/ | ||
internal object ViewHierarchyScanner { | ||
/** | ||
* Finds a target in the view hierarchy at the given position and returns a [ViewTarget]. | ||
* | ||
* If the found target is clickable and its [View] contains another clickable direct child in the | ||
* target position, the child will be returned. | ||
* | ||
* @param position the position (x, y) to find the target at | ||
* @param targetType the type of the target to find | ||
* @param viewTargetLocators the locators to use to find the target | ||
* @return the [ViewTarget] at the given position, or null if none was found | ||
*/ | ||
@JvmStatic | ||
fun View.findTarget( | ||
position: Pair<Float, Float>, | ||
viewTargetLocators: List<ViewTargetLocator>, | ||
targetType: ViewTarget.Type, | ||
logger: Logger, | ||
): ViewTarget? = | ||
runBlocking { | ||
val viewLooper = | ||
handler?.looper | ||
?: Looper.getMainLooper() | ||
?: logger.error("Unable to get main looper") | ||
.let { return@runBlocking null } | ||
|
||
// The entire view tree is single threaded, and that's typically the main thread, but | ||
// it doesn't have to be, and we don't know where the passed in view is coming from. | ||
if (viewLooper.thread == Thread.currentThread()) { | ||
findTargetWithLocators(position, targetType, viewTargetLocators, logger) | ||
} else { | ||
withContext(Dispatchers.Main) { | ||
findTargetWithLocators(position, targetType, viewTargetLocators, logger) | ||
} | ||
} | ||
} | ||
|
||
/** Applies the locators to the view hierarchy to find the target */ | ||
private fun View.findTargetWithLocators( | ||
position: Pair<Float, Float>, | ||
targetType: ViewTarget.Type, | ||
viewTargetLocators: List<ViewTargetLocator>, | ||
logger: Logger, | ||
): ViewTarget? { | ||
val queue = ArrayDeque<View>().apply { add(this@findTargetWithLocators) } | ||
|
||
var target: ViewTarget? = null | ||
while (queue.isNotEmpty()) { | ||
val view = | ||
try { | ||
queue.removeFirst() | ||
} catch (e: NoSuchElementException) { | ||
logger.error("Unable to get view from queue") | ||
continue | ||
} | ||
|
||
if (view is ViewGroup) { | ||
queue.addAll(view.children) | ||
} | ||
|
||
// Applies the locators until a target is found. If the target type is clickable, check | ||
// the children in case the target is a child which is also clickable. | ||
viewTargetLocators.any { locator -> | ||
with(locator) { | ||
view.locate(position, targetType)?.let { newTarget -> | ||
if (targetType == ViewTarget.Type.Clickable) { | ||
target = newTarget | ||
return@any true | ||
} else { | ||
return newTarget | ||
} | ||
} | ||
false | ||
} | ||
} | ||
} | ||
|
||
return target | ||
} | ||
} |
25 changes: 25 additions & 0 deletions
25
android/src/main/java/com/amplitude/android/internal/ViewTarget.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
package com.amplitude.android.internal | ||
|
||
import java.lang.ref.WeakReference | ||
|
||
/** | ||
* Represents a UI element in the view hierarchy from [ViewHierarchyScanner]. | ||
* | ||
* @property className the class name of the view. | ||
* @property recourseName the resource name of the view. | ||
* @property tag the tag of the view. | ||
*/ | ||
data class ViewTarget( | ||
private val _view: Any?, | ||
val className: String?, | ||
val recourseName: String?, | ||
val tag: String?, | ||
val source: String, | ||
) { | ||
private val viewRef: WeakReference<Any> = WeakReference(_view) | ||
|
||
val view: Any? | ||
get() = viewRef.get() | ||
|
||
enum class Type { Clickable } | ||
} |
78 changes: 78 additions & 0 deletions
78
android/src/main/java/com/amplitude/android/internal/gestures/AutocaptureGestureListener.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,78 @@ | ||
package com.amplitude.android.internal.gestures | ||
|
||
import android.app.Activity | ||
import android.view.GestureDetector | ||
import android.view.MotionEvent | ||
import androidx.annotation.VisibleForTesting | ||
import com.amplitude.android.internal.ViewHierarchyScanner.findTarget | ||
import com.amplitude.android.internal.ViewTarget | ||
import com.amplitude.android.internal.locators.ViewTargetLocator | ||
import com.amplitude.android.utilities.DefaultEventUtils.EventProperties.ELEMENT_CLASS | ||
import com.amplitude.android.utilities.DefaultEventUtils.EventProperties.ELEMENT_RESOURCE | ||
import com.amplitude.android.utilities.DefaultEventUtils.EventProperties.ELEMENT_SOURCE | ||
import com.amplitude.android.utilities.DefaultEventUtils.EventProperties.ELEMENT_TAG | ||
import com.amplitude.android.utilities.DefaultEventUtils.EventTypes.ELEMENT_CLICKED | ||
import com.amplitude.common.Logger | ||
import java.lang.ref.WeakReference | ||
|
||
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) | ||
class AutocaptureGestureListener( | ||
activity: Activity, | ||
private val track: (String, Map<String, Any?>) -> Unit, | ||
private val logger: Logger, | ||
private val viewTargetLocators: List<ViewTargetLocator>, | ||
) : GestureDetector.OnGestureListener { | ||
private val activityRef: WeakReference<Activity> = WeakReference(activity) | ||
|
||
override fun onDown(e: MotionEvent): Boolean { | ||
return false | ||
} | ||
|
||
override fun onShowPress(e: MotionEvent) {} | ||
|
||
override fun onSingleTapUp(e: MotionEvent): Boolean { | ||
val decorView = | ||
activityRef.get()?.window?.decorView | ||
?: logger.error("DecorView is null in onSingleTapUp()").let { return false } | ||
val target: ViewTarget = | ||
decorView.findTarget( | ||
Pair(e.x, e.y), | ||
viewTargetLocators, | ||
ViewTarget.Type.Clickable, | ||
logger, | ||
) ?: logger.error("Unable to find click target").let { return false } | ||
|
||
mapOf( | ||
ELEMENT_CLASS to target.className, | ||
ELEMENT_RESOURCE to target.recourseName, | ||
ELEMENT_TAG to target.tag, | ||
ELEMENT_SOURCE to | ||
target.source | ||
.replace("_", " ") | ||
.split(" ") | ||
.joinToString(" ") { it.replaceFirstChar { c -> c.uppercase() } }, | ||
).let { track(ELEMENT_CLICKED, it) } | ||
|
||
return false | ||
} | ||
|
||
override fun onScroll( | ||
e1: MotionEvent?, | ||
e2: MotionEvent, | ||
distanceX: Float, | ||
distanceY: Float, | ||
): Boolean { | ||
return false | ||
} | ||
|
||
override fun onLongPress(e: MotionEvent) {} | ||
|
||
override fun onFling( | ||
e1: MotionEvent?, | ||
e2: MotionEvent, | ||
velocityX: Float, | ||
velocityY: Float, | ||
): Boolean { | ||
return false | ||
} | ||
} |
41 changes: 41 additions & 0 deletions
41
android/src/main/java/com/amplitude/android/internal/gestures/AutocaptureWindowCallback.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
package com.amplitude.android.internal.gestures | ||
|
||
import android.app.Activity | ||
import android.view.GestureDetector | ||
import android.view.MotionEvent | ||
import android.view.Window | ||
import com.amplitude.android.internal.locators.ViewTargetLocator | ||
import com.amplitude.common.Logger | ||
|
||
internal class AutocaptureWindowCallback( | ||
delegate: Window.Callback, | ||
activity: Activity, | ||
track: (String, Map<String, Any?>) -> Unit, | ||
viewTargetLocators: List<ViewTargetLocator>, | ||
private val logger: Logger, | ||
private val motionEventObtainer: MotionEventObtainer = object : MotionEventObtainer {}, | ||
private val gestureListener: AutocaptureGestureListener = | ||
AutocaptureGestureListener(activity, track, logger, viewTargetLocators), | ||
private val gestureDetector: GestureDetector = GestureDetector(activity, gestureListener), | ||
) : WindowCallbackAdapter(delegate) { | ||
override fun dispatchTouchEvent(event: MotionEvent?): Boolean { | ||
event?.let { | ||
motionEventObtainer.obtain(event).let { | ||
try { | ||
gestureDetector.onTouchEvent(it) | ||
} catch (e: Exception) { | ||
logger.error("Error handling touch event: $e") | ||
} finally { | ||
it.recycle() | ||
} | ||
} | ||
} | ||
return super.dispatchTouchEvent(event) | ||
} | ||
|
||
interface MotionEventObtainer { | ||
fun obtain(origin: MotionEvent): MotionEvent { | ||
return MotionEvent.obtain(origin) | ||
} | ||
} | ||
} |
Oops, something went wrong.