From 7f0a50043f824b625b0273f4c69b4ab792353a56 Mon Sep 17 00:00:00 2001 From: Pouria Amini <64161548+PouriaAmini@users.noreply.github.com> Date: Wed, 12 Jun 2024 09:58:15 -0700 Subject: [PATCH] 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 --- .github/workflows/pull-request-test.yml | 6 +- android/build.gradle | 11 +- android/src/main/AndroidManifest.xml | 1 - .../android/DefaultTrackingOptions.kt | 47 ++- .../android/ExperimentalAmplitudeFeature.kt | 9 + .../android/internal/ViewHierarchyScanner.kt | 95 ++++++ .../amplitude/android/internal/ViewTarget.kt | 25 ++ .../gestures/AutocaptureGestureListener.kt | 78 +++++ .../gestures/AutocaptureWindowCallback.kt | 41 +++ .../gestures/NoCaptureWindowCallback.kt | 109 ++++++ .../gestures/WindowCallbackAdapter.kt | 128 +++++++ .../locators/AndroidViewTargetLocator.kt | 51 +++ .../ComposeLayoutNodeBoundsHelper.java | 50 +++ .../locators/ComposeViewTargetLocator.java | 132 ++++++++ .../internal/locators/ViewTargetLocator.kt | 17 + .../internal/locators/ViewTargetLocators.kt | 33 ++ .../android/plugins/AndroidLifecyclePlugin.kt | 12 + .../android/utilities/DefaultEventUtils.kt | 60 +++- .../amplitude/android/utilities/LoadClass.kt | 36 ++ .../AutocaptureGestureListenerClickTest.kt | 311 ++++++++++++++++++ .../gestures/AutocaptureWindowCallbackTest.kt | 65 ++++ .../internal/gestures/ViewMockHelper.kt | 74 +++++ .../plugins/AndroidLifecyclePluginTest.kt | 37 +-- build.gradle | 2 +- common-android/build.gradle | 8 +- gradle/wrapper/gradle-wrapper.properties | 3 +- gradlew | 257 +++++++++------ samples/java-android-app/build.gradle | 6 +- samples/kotlin-android-app/build.gradle | 6 +- 29 files changed, 1524 insertions(+), 186 deletions(-) create mode 100644 android/src/main/java/com/amplitude/android/ExperimentalAmplitudeFeature.kt create mode 100644 android/src/main/java/com/amplitude/android/internal/ViewHierarchyScanner.kt create mode 100644 android/src/main/java/com/amplitude/android/internal/ViewTarget.kt create mode 100644 android/src/main/java/com/amplitude/android/internal/gestures/AutocaptureGestureListener.kt create mode 100644 android/src/main/java/com/amplitude/android/internal/gestures/AutocaptureWindowCallback.kt create mode 100644 android/src/main/java/com/amplitude/android/internal/gestures/NoCaptureWindowCallback.kt create mode 100644 android/src/main/java/com/amplitude/android/internal/gestures/WindowCallbackAdapter.kt create mode 100644 android/src/main/java/com/amplitude/android/internal/locators/AndroidViewTargetLocator.kt create mode 100644 android/src/main/java/com/amplitude/android/internal/locators/ComposeLayoutNodeBoundsHelper.java create mode 100644 android/src/main/java/com/amplitude/android/internal/locators/ComposeViewTargetLocator.java create mode 100644 android/src/main/java/com/amplitude/android/internal/locators/ViewTargetLocator.kt create mode 100644 android/src/main/java/com/amplitude/android/internal/locators/ViewTargetLocators.kt create mode 100644 android/src/main/java/com/amplitude/android/utilities/LoadClass.kt create mode 100644 android/src/test/java/com/amplitude/android/internal/gestures/AutocaptureGestureListenerClickTest.kt create mode 100644 android/src/test/java/com/amplitude/android/internal/gestures/AutocaptureWindowCallbackTest.kt create mode 100644 android/src/test/java/com/amplitude/android/internal/gestures/ViewMockHelper.kt diff --git a/.github/workflows/pull-request-test.yml b/.github/workflows/pull-request-test.yml index ed1ec62c..dcbfa306 100644 --- a/.github/workflows/pull-request-test.yml +++ b/.github/workflows/pull-request-test.yml @@ -5,7 +5,7 @@ on: [pull_request] jobs: build: name: Build with JDK ${{ matrix.java-version }} - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest strategy: matrix: java-version: [11, 17] @@ -20,7 +20,7 @@ jobs: cache: 'gradle' - name: Build - run: ./gradlew build + run: ./gradlew build -x test - name: Upload build results if: always() @@ -35,7 +35,7 @@ jobs: test-and-lint: name: Test and Lint with JDK 17 - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest needs: build steps: - uses: actions/checkout@v4 diff --git a/android/build.gradle b/android/build.gradle index ca39fceb..b7af1603 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -16,13 +16,13 @@ apply from: "${rootDir}/gradle/publish-module.gradle" version = PUBLISH_VERSION android { - compileSdk 31 + compileSdk 34 defaultConfig { multiDexEnabled true - minSdk 16 - targetSdk 31 + minSdk 19 + targetSdk 34 versionName PUBLISH_VERSION buildConfigField "String", "AMPLITUDE_VERSION", "\"${version}\"" @@ -47,6 +47,7 @@ android { testOptions { unitTests { includeAndroidResources = true + returnDefaultValues = true } } } @@ -61,6 +62,8 @@ dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2' implementation 'com.amplitude:analytics-connector:1.0.0' + implementation 'androidx.core:core-ktx:1.7.0' + compileOnly 'androidx.compose.ui:ui:1.6.7' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' @@ -74,7 +77,7 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter-params:5.7.2' testImplementation("junit:junit:4.13.2") testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.7.2") - testImplementation 'org.robolectric:robolectric:4.7.3' + testImplementation 'org.robolectric:robolectric:4.12.1' testImplementation 'androidx.test:core:1.4.0' testImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.ext:junit:1.1.3' diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index b8490463..13d69f96 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - \ No newline at end of file diff --git a/android/src/main/java/com/amplitude/android/DefaultTrackingOptions.kt b/android/src/main/java/com/amplitude/android/DefaultTrackingOptions.kt index da565b16..297b2209 100644 --- a/android/src/main/java/com/amplitude/android/DefaultTrackingOptions.kt +++ b/android/src/main/java/com/amplitude/android/DefaultTrackingOptions.kt @@ -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 } } diff --git a/android/src/main/java/com/amplitude/android/ExperimentalAmplitudeFeature.kt b/android/src/main/java/com/amplitude/android/ExperimentalAmplitudeFeature.kt new file mode 100644 index 00000000..5c4333ac --- /dev/null +++ b/android/src/main/java/com/amplitude/android/ExperimentalAmplitudeFeature.kt @@ -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 diff --git a/android/src/main/java/com/amplitude/android/internal/ViewHierarchyScanner.kt b/android/src/main/java/com/amplitude/android/internal/ViewHierarchyScanner.kt new file mode 100644 index 00000000..bbb46455 --- /dev/null +++ b/android/src/main/java/com/amplitude/android/internal/ViewHierarchyScanner.kt @@ -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, + viewTargetLocators: List, + 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, + targetType: ViewTarget.Type, + viewTargetLocators: List, + logger: Logger, + ): ViewTarget? { + val queue = ArrayDeque().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 + } +} diff --git a/android/src/main/java/com/amplitude/android/internal/ViewTarget.kt b/android/src/main/java/com/amplitude/android/internal/ViewTarget.kt new file mode 100644 index 00000000..6235fbcd --- /dev/null +++ b/android/src/main/java/com/amplitude/android/internal/ViewTarget.kt @@ -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 = WeakReference(_view) + + val view: Any? + get() = viewRef.get() + + enum class Type { Clickable } +} diff --git a/android/src/main/java/com/amplitude/android/internal/gestures/AutocaptureGestureListener.kt b/android/src/main/java/com/amplitude/android/internal/gestures/AutocaptureGestureListener.kt new file mode 100644 index 00000000..8858c0a0 --- /dev/null +++ b/android/src/main/java/com/amplitude/android/internal/gestures/AutocaptureGestureListener.kt @@ -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) -> Unit, + private val logger: Logger, + private val viewTargetLocators: List, +) : GestureDetector.OnGestureListener { + private val activityRef: WeakReference = 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 + } +} diff --git a/android/src/main/java/com/amplitude/android/internal/gestures/AutocaptureWindowCallback.kt b/android/src/main/java/com/amplitude/android/internal/gestures/AutocaptureWindowCallback.kt new file mode 100644 index 00000000..efd683d8 --- /dev/null +++ b/android/src/main/java/com/amplitude/android/internal/gestures/AutocaptureWindowCallback.kt @@ -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) -> Unit, + viewTargetLocators: List, + 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) + } + } +} diff --git a/android/src/main/java/com/amplitude/android/internal/gestures/NoCaptureWindowCallback.kt b/android/src/main/java/com/amplitude/android/internal/gestures/NoCaptureWindowCallback.kt new file mode 100644 index 00000000..02509066 --- /dev/null +++ b/android/src/main/java/com/amplitude/android/internal/gestures/NoCaptureWindowCallback.kt @@ -0,0 +1,109 @@ +package com.amplitude.android.internal.gestures + +import android.view.ActionMode +import android.view.KeyEvent +import android.view.Menu +import android.view.MenuItem +import android.view.MotionEvent +import android.view.SearchEvent +import android.view.View +import android.view.Window +import android.view.WindowManager +import android.view.accessibility.AccessibilityEvent + +internal class NoCaptureWindowCallback : Window.Callback { + override fun dispatchKeyEvent(event: KeyEvent?): Boolean { + return false + } + + override fun dispatchKeyShortcutEvent(event: KeyEvent?): Boolean { + return false + } + + override fun dispatchTouchEvent(event: MotionEvent?): Boolean { + return false + } + + override fun dispatchTrackballEvent(event: MotionEvent?): Boolean { + return false + } + + override fun dispatchGenericMotionEvent(event: MotionEvent?): Boolean { + return false + } + + override fun dispatchPopulateAccessibilityEvent(event: AccessibilityEvent?): Boolean { + return false + } + + override fun onCreatePanelView(featureId: Int): View? { + return null + } + + override fun onCreatePanelMenu( + featureId: Int, + menu: Menu, + ): Boolean { + return false + } + + override fun onPreparePanel( + featureId: Int, + view: View?, + menu: Menu, + ): Boolean { + return false + } + + override fun onMenuOpened( + featureId: Int, + menu: Menu, + ): Boolean { + return false + } + + override fun onMenuItemSelected( + featureId: Int, + item: MenuItem, + ): Boolean { + return false + } + + override fun onWindowAttributesChanged(attrs: WindowManager.LayoutParams?) {} + + override fun onContentChanged() {} + + override fun onWindowFocusChanged(hasFocus: Boolean) {} + + override fun onAttachedToWindow() {} + + override fun onDetachedFromWindow() {} + + override fun onPanelClosed( + featureId: Int, + menu: Menu, + ) {} + + override fun onSearchRequested(): Boolean { + return false + } + + override fun onSearchRequested(searchEvent: SearchEvent?): Boolean { + return false + } + + override fun onWindowStartingActionMode(callback: ActionMode.Callback?): ActionMode? { + return null + } + + override fun onWindowStartingActionMode( + callback: ActionMode.Callback?, + type: Int, + ): ActionMode? { + return null + } + + override fun onActionModeStarted(mode: ActionMode?) {} + + override fun onActionModeFinished(mode: ActionMode?) {} +} diff --git a/android/src/main/java/com/amplitude/android/internal/gestures/WindowCallbackAdapter.kt b/android/src/main/java/com/amplitude/android/internal/gestures/WindowCallbackAdapter.kt new file mode 100644 index 00000000..20e674dc --- /dev/null +++ b/android/src/main/java/com/amplitude/android/internal/gestures/WindowCallbackAdapter.kt @@ -0,0 +1,128 @@ +package com.amplitude.android.internal.gestures + +import android.annotation.SuppressLint +import android.view.ActionMode +import android.view.KeyEvent +import android.view.Menu +import android.view.MenuItem +import android.view.MotionEvent +import android.view.SearchEvent +import android.view.View +import android.view.Window +import android.view.WindowManager +import android.view.accessibility.AccessibilityEvent + +internal open class WindowCallbackAdapter(val delegate: Window.Callback) : Window.Callback { + override fun dispatchKeyEvent(event: KeyEvent?): Boolean { + return delegate.dispatchKeyEvent(event) + } + + override fun dispatchKeyShortcutEvent(event: KeyEvent?): Boolean { + return delegate.dispatchKeyShortcutEvent(event) + } + + override fun dispatchTouchEvent(event: MotionEvent?): Boolean { + return delegate.dispatchTouchEvent(event) + } + + override fun dispatchTrackballEvent(event: MotionEvent?): Boolean { + return delegate.dispatchTrackballEvent(event) + } + + override fun dispatchGenericMotionEvent(event: MotionEvent?): Boolean { + return delegate.dispatchGenericMotionEvent(event) + } + + override fun dispatchPopulateAccessibilityEvent(event: AccessibilityEvent?): Boolean { + return delegate.dispatchPopulateAccessibilityEvent(event) + } + + override fun onCreatePanelView(featureId: Int): View? { + return delegate.onCreatePanelView(featureId) + } + + override fun onCreatePanelMenu( + featureId: Int, + menu: Menu, + ): Boolean { + return delegate.onCreatePanelMenu(featureId, menu) + } + + override fun onPreparePanel( + featureId: Int, + view: View?, + menu: Menu, + ): Boolean { + return delegate.onPreparePanel(featureId, view, menu) + } + + override fun onMenuOpened( + featureId: Int, + menu: Menu, + ): Boolean { + return delegate.onMenuOpened(featureId, menu) + } + + override fun onMenuItemSelected( + featureId: Int, + item: MenuItem, + ): Boolean { + return delegate.onMenuItemSelected(featureId, item) + } + + override fun onWindowAttributesChanged(attrs: WindowManager.LayoutParams?) { + return delegate.onWindowAttributesChanged(attrs) + } + + override fun onContentChanged() { + return delegate.onContentChanged() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + return delegate.onWindowFocusChanged(hasFocus) + } + + override fun onAttachedToWindow() { + return delegate.onAttachedToWindow() + } + + override fun onDetachedFromWindow() { + return delegate.onDetachedFromWindow() + } + + override fun onPanelClosed( + featureId: Int, + menu: Menu, + ) { + return delegate.onPanelClosed(featureId, menu) + } + + override fun onSearchRequested(): Boolean { + return delegate.onSearchRequested() + } + + @SuppressLint("NewApi") + override fun onSearchRequested(searchEvent: SearchEvent?): Boolean { + return delegate.onSearchRequested(searchEvent) + } + + override fun onWindowStartingActionMode(callback: ActionMode.Callback?): ActionMode? { + return delegate.onWindowStartingActionMode(callback) + } + + @SuppressLint("NewApi") + override fun onWindowStartingActionMode( + callback: ActionMode.Callback?, + type: Int, + ): ActionMode? { + return delegate.onWindowStartingActionMode(callback, type) + } + + override fun onActionModeStarted(mode: ActionMode?) { + return delegate.onActionModeStarted(mode) + } + + override fun onActionModeFinished(mode: ActionMode?) { + return delegate.onActionModeFinished(mode) + } +} diff --git a/android/src/main/java/com/amplitude/android/internal/locators/AndroidViewTargetLocator.kt b/android/src/main/java/com/amplitude/android/internal/locators/AndroidViewTargetLocator.kt new file mode 100644 index 00000000..29df17fd --- /dev/null +++ b/android/src/main/java/com/amplitude/android/internal/locators/AndroidViewTargetLocator.kt @@ -0,0 +1,51 @@ +package com.amplitude.android.internal.locators + +import android.content.res.Resources +import android.view.View +import com.amplitude.android.internal.ViewTarget +import com.amplitude.android.internal.ViewTarget.Type + +internal class AndroidViewTargetLocator : ViewTargetLocator { + private val coordinates = IntArray(2) + + companion object { + private const val SOURCE = "android_view" + } + + override fun Any.locate( + position: Pair, + targetType: Type, + ): ViewTarget? { + return (this as? View) + ?.takeIf { touchWithinBounds(position) && targetType === Type.Clickable && isViewTappable() } + ?.let { createViewTarget() } + } + + private fun View.createViewTarget(): ViewTarget { + val className = javaClass.canonicalName ?: javaClass.simpleName ?: null + val resourceName: String? = + try { + context.resources.getResourceEntryName(id) + } catch (ignored: Resources.NotFoundException) { + null + } + return ViewTarget(this, className, resourceName, null, SOURCE) + } + + private fun View.touchWithinBounds(position: Pair): Boolean { + val (x, y) = position + + getLocationOnScreen(coordinates) + val vx = coordinates[0] + val vy = coordinates[1] + + val w = width + val h = height + + return !(x < vx || x > vx + w || y < vy || y > vy + h) + } + + private fun View.isViewTappable(): Boolean { + return isClickable && visibility == View.VISIBLE + } +} diff --git a/android/src/main/java/com/amplitude/android/internal/locators/ComposeLayoutNodeBoundsHelper.java b/android/src/main/java/com/amplitude/android/internal/locators/ComposeLayoutNodeBoundsHelper.java new file mode 100644 index 00000000..054d436a --- /dev/null +++ b/android/src/main/java/com/amplitude/android/internal/locators/ComposeLayoutNodeBoundsHelper.java @@ -0,0 +1,50 @@ +package com.amplitude.android.internal.locators; + +import androidx.annotation.OptIn; +import androidx.compose.ui.InternalComposeUiApi; +import androidx.compose.ui.geometry.Rect; +import androidx.compose.ui.layout.LayoutCoordinatesKt; +import androidx.compose.ui.node.LayoutNode; +import androidx.compose.ui.node.LayoutNodeLayoutDelegate; + +import com.amplitude.common.Logger; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.Field; + +@SuppressWarnings("KotlinInternalInJava") +@OptIn(markerClass = InternalComposeUiApi.class) +public class ComposeLayoutNodeBoundsHelper { + private Field layoutDelegateField = null; + + private final @NotNull Logger logger; + + public ComposeLayoutNodeBoundsHelper(@NotNull Logger logger) { + this.logger = logger; + try { + final Class clazz = Class.forName("androidx.compose.ui.node.LayoutNode"); + layoutDelegateField = clazz.getDeclaredField("layoutDelegate"); + layoutDelegateField.setAccessible(true); + } catch (Exception e) { + logger.info("Could not find LayoutNode.layoutDelegate field"); + } + } + + public @Nullable Rect getLayoutNodeWindowBounds(@NotNull final LayoutNode node) { + if (layoutDelegateField == null) { + return null; + } + try { + final LayoutNodeLayoutDelegate delegate = (LayoutNodeLayoutDelegate) layoutDelegateField.get(node); + if (delegate == null) { + return null; + } + return LayoutCoordinatesKt.boundsInWindow(delegate.getOuterCoordinator().getCoordinates()); + } catch (Exception e) { + logger.info("Could not fetch position for LayoutNode"); + } + return null; + } +} diff --git a/android/src/main/java/com/amplitude/android/internal/locators/ComposeViewTargetLocator.java b/android/src/main/java/com/amplitude/android/internal/locators/ComposeViewTargetLocator.java new file mode 100644 index 00000000..6287b975 --- /dev/null +++ b/android/src/main/java/com/amplitude/android/internal/locators/ComposeViewTargetLocator.java @@ -0,0 +1,132 @@ +package com.amplitude.android.internal.locators; + +import androidx.annotation.OptIn; +import androidx.compose.ui.InternalComposeUiApi; +import androidx.compose.ui.geometry.Rect; +import androidx.compose.ui.layout.ModifierInfo; +import androidx.compose.ui.node.LayoutNode; +import androidx.compose.ui.node.Owner; +import androidx.compose.ui.semantics.SemanticsConfiguration; +import androidx.compose.ui.semantics.SemanticsModifier; +import androidx.compose.ui.semantics.SemanticsPropertyKey; + +import com.amplitude.android.internal.ViewTarget; +import com.amplitude.common.Logger; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayDeque; +import java.util.List; +import java.util.Map; +import java.util.Queue; + +import kotlin.Pair; + +@SuppressWarnings("KotlinInternalInJava") +@OptIn(markerClass = InternalComposeUiApi.class) +public class ComposeViewTargetLocator implements ViewTargetLocator { + private volatile @Nullable ComposeLayoutNodeBoundsHelper composeLayoutNodeBoundsHelper; + + private static final String SOURCE = "jetpack_compose"; + + private final @NotNull Logger logger; + + public ComposeViewTargetLocator(@NotNull Logger logger) { + this.logger = logger; + } + + @Nullable + @Override + public ViewTarget locate( + @NotNull Object root, + @NotNull Pair position, + @NotNull ViewTarget.Type targetType) { + + // lazy init composeHelper as it's using some reflection under the hood + if (composeLayoutNodeBoundsHelper == null) { + synchronized (this) { + if (composeLayoutNodeBoundsHelper == null) { + composeLayoutNodeBoundsHelper = new ComposeLayoutNodeBoundsHelper(logger); + } + } + } + + if (!(root instanceof Owner)) { + return null; + } + + final @NotNull Queue queue = new ArrayDeque<>(); + queue.add(((Owner) root).getRoot()); + + // the final tag to return + @Nullable String targetTag = null; + + // the last known tag when iterating the node tree + @Nullable String lastKnownTag = null; + while (!queue.isEmpty()) { + final @Nullable LayoutNode node = queue.poll(); + if (node == null) { + continue; + } + + if (node.isPlaced() && layoutNodeBoundsContain(composeLayoutNodeBoundsHelper, node, position.component1(), position.component2())) { + boolean isClickable = false; + final List modifiers = node.getModifierInfo(); + for (ModifierInfo modifierInfo : modifiers) { + if (modifierInfo.getModifier() instanceof SemanticsModifier) { + final SemanticsModifier semanticsModifierCore = + (SemanticsModifier) modifierInfo.getModifier(); + final SemanticsConfiguration semanticsConfiguration = + semanticsModifierCore.getSemanticsConfiguration(); + for (Map.Entry, ?> entry : semanticsConfiguration) { + final @Nullable String key = entry.getKey().getName(); + if ("OnClick".equals(key)) { + isClickable = true; + } else if ("TestTag".equals(key)) { + if (entry.getValue() instanceof String) { + lastKnownTag = (String) entry.getValue(); + } + } + } + } else { + // Newer Jetpack Compose 1.5 uses Node modifiers for clicks/scrolls + final @Nullable String type = modifierInfo.getModifier().getClass().getCanonicalName(); + if ("androidx.compose.foundation.ClickableElement".equals(type) + || "androidx.compose.foundation.CombinedClickableElement".equals(type)) { + isClickable = true; + } + } + } + + if (isClickable && targetType == ViewTarget.Type.Clickable) { + targetTag = lastKnownTag; + } + } + queue.addAll(node.getZSortedChildren().asMutableList()); + } + + if (targetTag == null) { + return null; + } else { + return new ViewTarget(null, null, null, targetTag, SOURCE); + } + } + + private static boolean layoutNodeBoundsContain( + @NotNull ComposeLayoutNodeBoundsHelper composeLayoutNodeBoundsHelper, + @NotNull LayoutNode node, + final float x, + final float y) { + + final @Nullable Rect bounds = composeLayoutNodeBoundsHelper.getLayoutNodeWindowBounds(node); + if (bounds == null) { + return false; + } else { + return x >= bounds.getLeft() + && x <= bounds.getRight() + && y >= bounds.getTop() + && y <= bounds.getBottom(); + } + } +} diff --git a/android/src/main/java/com/amplitude/android/internal/locators/ViewTargetLocator.kt b/android/src/main/java/com/amplitude/android/internal/locators/ViewTargetLocator.kt new file mode 100644 index 00000000..2386023a --- /dev/null +++ b/android/src/main/java/com/amplitude/android/internal/locators/ViewTargetLocator.kt @@ -0,0 +1,17 @@ +package com.amplitude.android.internal.locators + +import com.amplitude.android.internal.ViewTarget + +fun interface ViewTargetLocator { + /** + * Locates a [ViewTarget] at the given position based on the [View] type. + * + * @param position the position to locate the view target at. + * @param targetType the type of the view target to locate. + * @return the [ViewTarget] at the given position, or null if none was found. + */ + fun Any.locate( + position: Pair, + targetType: ViewTarget.Type, + ): ViewTarget? +} diff --git a/android/src/main/java/com/amplitude/android/internal/locators/ViewTargetLocators.kt b/android/src/main/java/com/amplitude/android/internal/locators/ViewTargetLocators.kt new file mode 100644 index 00000000..0feca3ae --- /dev/null +++ b/android/src/main/java/com/amplitude/android/internal/locators/ViewTargetLocators.kt @@ -0,0 +1,33 @@ +package com.amplitude.android.internal.locators + +import com.amplitude.android.utilities.LoadClass +import com.amplitude.common.Logger + +internal object ViewTargetLocators { + private const val COMPOSE_CLASS_NAME = "androidx.compose.ui.node.Owner" + private const val COMPOSE_GESTURE_LOCATOR_CLASS_NAME = + "com.amplitude.android.internal.locators.ComposeViewTargetLocator" + + /** + * A list [ViewTargetLocator]s for classic Android [View][android.view.View]s and Jetpack + * Compose. + * + * @param logger the logger to use for logging. + * @return a list of [ViewTargetLocator]s. + */ + @JvmField + val ALL: (Logger) -> List = { logger -> + mutableListOf().apply { + val loadClass = LoadClass() + val isComposeUpstreamAvailable = loadClass.isClassAvailable(COMPOSE_CLASS_NAME, logger) + val isComposeAvailable = + isComposeUpstreamAvailable && + loadClass.isClassAvailable(COMPOSE_GESTURE_LOCATOR_CLASS_NAME, logger) + + if (isComposeAvailable) { + add(ComposeViewTargetLocator(logger)) + } + add(AndroidViewTargetLocator()) + } + } +} diff --git a/android/src/main/java/com/amplitude/android/plugins/AndroidLifecyclePlugin.kt b/android/src/main/java/com/amplitude/android/plugins/AndroidLifecyclePlugin.kt index 18335d5a..ea8e6125 100644 --- a/android/src/main/java/com/amplitude/android/plugins/AndroidLifecyclePlugin.kt +++ b/android/src/main/java/com/amplitude/android/plugins/AndroidLifecyclePlugin.kt @@ -65,10 +65,16 @@ class AndroidLifecyclePlugin : Application.ActivityLifecycleCallbacks, Plugin { val isFromBackground = !isFirstLaunch.getAndSet(false) DefaultEventUtils(androidAmplitude).trackAppOpenedEvent(packageInfo, isFromBackground) } + if (androidConfiguration.defaultTracking.userInteractions) { + DefaultEventUtils(androidAmplitude).startUserInteractionEventTracking(activity) + } } override fun onActivityPaused(activity: Activity) { androidAmplitude.onExitForeground(getCurrentTimeMillis()) + if (androidConfiguration.defaultTracking.userInteractions) { + DefaultEventUtils(androidAmplitude).stopUserInteractionEventTracking(activity) + } } override fun onActivityStopped(activity: Activity) { @@ -84,7 +90,13 @@ class AndroidLifecyclePlugin : Application.ActivityLifecycleCallbacks, Plugin { override fun onActivityDestroyed(activity: Activity) { } + override fun teardown() { + super.teardown() + (androidConfiguration.context as Application).unregisterActivityLifecycleCallbacks(this) + } + companion object { + @JvmStatic fun getCurrentTimeMillis(): Long { return System.currentTimeMillis() } diff --git a/android/src/main/java/com/amplitude/android/utilities/DefaultEventUtils.kt b/android/src/main/java/com/amplitude/android/utilities/DefaultEventUtils.kt index b5611da0..459e136b 100644 --- a/android/src/main/java/com/amplitude/android/utilities/DefaultEventUtils.kt +++ b/android/src/main/java/com/amplitude/android/utilities/DefaultEventUtils.kt @@ -8,6 +8,9 @@ import android.net.ParseException import android.net.Uri import android.os.Build import com.amplitude.android.Amplitude +import com.amplitude.android.internal.gestures.AutocaptureWindowCallback +import com.amplitude.android.internal.gestures.NoCaptureWindowCallback +import com.amplitude.android.internal.locators.ViewTargetLocators.ALL import com.amplitude.core.Storage import kotlinx.coroutines.launch @@ -19,6 +22,7 @@ class DefaultEventUtils(private val amplitude: Amplitude) { const val APPLICATION_BACKGROUNDED = "[Amplitude] Application Backgrounded" const val DEEP_LINK_OPENED = "[Amplitude] Deep Link Opened" const val SCREEN_VIEWED = "[Amplitude] Screen Viewed" + const val ELEMENT_CLICKED = "[Amplitude] Element Clicked" } object EventProperties { @@ -30,6 +34,10 @@ class DefaultEventUtils(private val amplitude: Amplitude) { const val LINK_URL = "[Amplitude] Link URL" const val LINK_REFERRER = "[Amplitude] Link Referrer" const val SCREEN_NAME = "[Amplitude] Screen Name" + const val ELEMENT_CLASS = "[Amplitude] Element Class" + const val ELEMENT_RESOURCE = "[Amplitude] Element Resource" + const val ELEMENT_TAG = "[Amplitude] Element Tag" + const val ELEMENT_SOURCE = "[Amplitude] Element Source" } fun trackAppUpdatedInstalledEvent(packageInfo: PackageInfo) { @@ -69,7 +77,10 @@ class DefaultEventUtils(private val amplitude: Amplitude) { } } - fun trackAppOpenedEvent(packageInfo: PackageInfo, isFromBackground: Boolean) { + fun trackAppOpenedEvent( + packageInfo: PackageInfo, + isFromBackground: Boolean, + ) { val currentVersion = packageInfo.versionName val currentBuild = packageInfo.getVersionCode().toString() @@ -107,10 +118,11 @@ class DefaultEventUtils(private val amplitude: Amplitude) { fun trackScreenViewedEvent(activity: Activity) { try { val packageManager = activity.packageManager - val info = packageManager?.getActivityInfo( - activity.componentName, - PackageManager.GET_META_DATA, - ) + val info = + packageManager?.getActivityInfo( + activity.componentName, + PackageManager.GET_META_DATA, + ) /* Get the label metadata in following order 1. activity label 2. if 1 is missing, fallback to parent application label @@ -125,6 +137,29 @@ class DefaultEventUtils(private val amplitude: Amplitude) { } } + fun startUserInteractionEventTracking(activity: Activity) { + activity.window?.let { window -> + val delegate = window.callback ?: NoCaptureWindowCallback() + window.callback = + AutocaptureWindowCallback( + delegate, + activity, + amplitude::track, + ALL(amplitude.logger), + amplitude.logger, + ) + } ?: amplitude.logger.error("Failed to track user interaction event: Activity window is null") + } + + fun stopUserInteractionEventTracking(activity: Activity) { + activity.window?.let { window -> + (window.callback as? AutocaptureWindowCallback)?.let { windowCallback -> + window.callback = windowCallback.delegate.takeUnless { it is NoCaptureWindowCallback } + } + true + } ?: amplitude.logger.error("Failed to stop user interaction event tracking: Activity window is null") + } + private fun getReferrer(activity: Activity): Uri? { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { return activity.referrer @@ -135,14 +170,15 @@ class DefaultEventUtils(private val amplitude: Amplitude) { referrerUri = intent.getParcelableExtra(Intent.EXTRA_REFERRER) if (referrerUri == null) { - referrerUri = intent.getStringExtra("android.intent.extra.REFERRER_NAME")?.let { - try { - Uri.parse(it) - } catch (e: ParseException) { - amplitude.logger.error("Failed to parse the referrer uri: $it") - null + referrerUri = + intent.getStringExtra("android.intent.extra.REFERRER_NAME")?.let { + try { + Uri.parse(it) + } catch (e: ParseException) { + amplitude.logger.error("Failed to parse the referrer uri: $it") + null + } } - } } } return referrerUri diff --git a/android/src/main/java/com/amplitude/android/utilities/LoadClass.kt b/android/src/main/java/com/amplitude/android/utilities/LoadClass.kt new file mode 100644 index 00000000..1e9f7981 --- /dev/null +++ b/android/src/main/java/com/amplitude/android/utilities/LoadClass.kt @@ -0,0 +1,36 @@ +package com.amplitude.android.utilities + +import com.amplitude.common.Logger + +/** An Adapter for making Class.forName testable */ +class LoadClass { + /** + * Try to load a class via reflection + * + * @param clazz the full class name + * @param logger an instance of ILogger + * @return a Class if it's available, or null + */ + private fun loadClass( + clazz: String, + logger: Logger?, + ): Class<*>? { + try { + return Class.forName(clazz) + } catch (e: ClassNotFoundException) { + logger?.debug("Class not available:$clazz: $e") + } catch (e: UnsatisfiedLinkError) { + logger?.error("Failed to load (UnsatisfiedLinkError) $clazz: $e") + } catch (e: Throwable) { + logger?.error("Failed to initialize $clazz: $e") + } + return null + } + + fun isClassAvailable( + clazz: String, + logger: Logger?, + ): Boolean { + return loadClass(clazz, logger) != null + } +} diff --git a/android/src/test/java/com/amplitude/android/internal/gestures/AutocaptureGestureListenerClickTest.kt b/android/src/test/java/com/amplitude/android/internal/gestures/AutocaptureGestureListenerClickTest.kt new file mode 100644 index 00000000..e508bd81 --- /dev/null +++ b/android/src/test/java/com/amplitude/android/internal/gestures/AutocaptureGestureListenerClickTest.kt @@ -0,0 +1,311 @@ +package com.amplitude.android.internal.gestures + +import android.app.Activity +import android.content.Context +import android.content.res.Resources +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.Window +import android.widget.CheckBox +import android.widget.RadioButton +import com.amplitude.android.internal.locators.AndroidViewTargetLocator +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 io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.reflect.KClass + +@OptIn(ExperimentalCoroutinesApi::class) +class AutocaptureGestureListenerClickTest { + class Fixture { + val activity = mockk() + val resources = mockk() + val logger = mockk(relaxed = true) + val context = mockk() + val window = mockk(relaxed = true) + val track = mockk<(String, Map) -> Unit>(relaxed = true) + + lateinit var target: View + lateinit var invalidTarget: View + + internal fun getSut( + type: KClass, + event: MotionEvent, + resourceName: String = "test_button", + isInvalidTargetVisible: Boolean = true, + isInvalidTargetClickable: Boolean = true, + attachViewsToRoot: Boolean = true, + targetOverride: View? = null, + ): AutocaptureGestureListener { + invalidTarget = + mockView( + type = View::class, + event = event, + visible = isInvalidTargetVisible, + clickable = isInvalidTargetClickable, + context = context, + ) + + if (targetOverride == null) { + this.target = + mockView( + type = type, + event = event, + clickable = true, + context = context, + ) + } else { + this.target = targetOverride + } + + if (attachViewsToRoot) { + window.mockDecorView( + type = ViewGroup::class, + event = event, + context = context, + ) { + every { it.childCount } returns 2 + every { it.getChildAt(0) } returns invalidTarget + every { it.getChildAt(1) } returns target + } + } + + resources.mockForTarget(this.target, resourceName) + every { context.resources } returns resources + every { this@Fixture.target.context } returns context + every { activity.window } returns window + return AutocaptureGestureListener( + activity, + track, + logger, + listOf(AndroidViewTargetLocator()), + ) + } + } + + private val fixture = Fixture() + + @BeforeEach + fun setup() { + Dispatchers.setMain(Dispatchers.Unconfined) + } + + @AfterEach + fun cleanup() { + Dispatchers.resetMain() + } + + @Test + fun `when target and its ViewGroup are clickable, captures an event for target`() { + val event = mockk(relaxed = true) + val sut = + fixture.getSut( + type = View::class, + event = event, + isInvalidTargetVisible = false, + attachViewsToRoot = false, + ) + + val container1 = + mockView(type = ViewGroup::class, event = event, touchWithinBounds = false, context = fixture.context) + val notClickableInvalidTarget = + mockView(type = View::class, event = event) + val container2 = + mockView(type = ViewGroup::class, event = event, clickable = true, context = fixture.context) { + every { it.childCount } returns 3 + every { it.getChildAt(0) } returns notClickableInvalidTarget + every { it.getChildAt(1) } returns fixture.invalidTarget + every { it.getChildAt(2) } returns fixture.target + } + + fixture.window.mockDecorView( + type = ViewGroup::class, + event = event, + context = fixture.context, + ) { + every { it.childCount } returns 2 + every { it.getChildAt(0) } returns container1 + every { it.getChildAt(1) } returns container2 + } + + sut.onSingleTapUp(event) + + verify(exactly = 1) { + fixture.track( + ELEMENT_CLICKED, + mapOf( + ELEMENT_CLASS to "android.view.View", + ELEMENT_RESOURCE to "test_button", + ELEMENT_TAG to null, + ELEMENT_SOURCE to "Android View", + ), + ) + } + } + + @Test + fun `ignores invisible or gone views`() { + val event = mockk(relaxed = true) + val sut = + fixture.getSut( + type = RadioButton::class, + event = event, + resourceName = "radio_button", + isInvalidTargetVisible = false, + ) + + sut.onSingleTapUp(event) + + verify { + fixture.track( + ELEMENT_CLICKED, + mapOf( + ELEMENT_CLASS to "android.widget.RadioButton", + ELEMENT_RESOURCE to "radio_button", + ELEMENT_TAG to null, + ELEMENT_SOURCE to "Android View", + ), + ) + } + } + + @Test + fun `ignores not clickable targets`() { + val event = mockk(relaxed = true) + val sut = + fixture.getSut( + type = CheckBox::class, + event = event, + resourceName = "check_box", + isInvalidTargetVisible = false, + ) + + sut.onSingleTapUp(event) + + verify { + fixture.track( + ELEMENT_CLICKED, + mapOf( + ELEMENT_CLASS to "android.widget.CheckBox", + ELEMENT_RESOURCE to "check_box", + ELEMENT_TAG to null, + ELEMENT_SOURCE to "Android View", + ), + ) + } + } + + @Test + fun `when no children present and decor view not clickable, does not capture an event`() { + val event = mockk(relaxed = true) + val sut = + fixture.getSut( + type = View::class, + event = event, + attachViewsToRoot = false, + ) + + fixture.window.mockDecorView(type = ViewGroup::class, event = event) { + every { it.childCount } returns 0 + } + + sut.onSingleTapUp(event) + + verify(exactly = 0) { + fixture.track(any(), any()) + } + } + + @Test + fun `when target is decorView, captures an event for decorView`() { + val event = mockk(relaxed = true) + val decorView = + fixture.window.mockDecorView(type = ViewGroup::class, event = event, clickable = true) { + every { it.childCount } returns 0 + } + + val sut = + fixture.getSut( + type = ViewGroup::class, + event = event, + resourceName = "decor_view", + targetOverride = decorView, + ) + + sut.onSingleTapUp(event) + + verify { + fixture.track( + ELEMENT_CLICKED, + mapOf( + ELEMENT_CLASS to decorView.javaClass.canonicalName, + ELEMENT_RESOURCE to "decor_view", + ELEMENT_TAG to null, + ELEMENT_SOURCE to "Android View", + ), + ) + } + } + + @Test + fun `does not capture events when view reference is null`() { + val event = mockk(relaxed = true) + val sut = + fixture.getSut( + type = View::class, + event = event, + attachViewsToRoot = false, + ) + + sut.onSingleTapUp(event) + + verify(exactly = 0) { + fixture.track(any(), any()) + } + } + + @Test + fun `uses simple class name if canonical name isn't available`() { + class LocalView(context: Context) : View(context) + + val event = mockk(relaxed = true) + val sut = + fixture.getSut( + type = LocalView::class, + event = event, + attachViewsToRoot = false, + ) + + fixture.window.mockDecorView(type = ViewGroup::class, event = event, touchWithinBounds = false) { + every { it.childCount } returns 1 + every { it.getChildAt(0) } returns fixture.target + } + + sut.onSingleTapUp(event) + + verify { + fixture.track( + ELEMENT_CLICKED, + mapOf( + ELEMENT_CLASS to fixture.target.javaClass.simpleName, + ELEMENT_RESOURCE to "test_button", + ELEMENT_TAG to null, + ELEMENT_SOURCE to "Android View", + ), + ) + } + } +} diff --git a/android/src/test/java/com/amplitude/android/internal/gestures/AutocaptureWindowCallbackTest.kt b/android/src/test/java/com/amplitude/android/internal/gestures/AutocaptureWindowCallbackTest.kt new file mode 100644 index 00000000..c9a20a4f --- /dev/null +++ b/android/src/test/java/com/amplitude/android/internal/gestures/AutocaptureWindowCallbackTest.kt @@ -0,0 +1,65 @@ +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.AndroidViewTargetLocator +import com.amplitude.common.Logger +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test + +class AutocaptureWindowCallbackTest { + class Fixture { + val activity = mockk() + val delegate = mockk(relaxed = true) + val track = mockk<(String, Map) -> Unit>() + val gestureDetector = mockk() + val gestureListener = mockk() + val motionEventCopy = mockk(relaxed = true) + val logger = mockk(relaxed = true) + + internal fun getSut(): AutocaptureWindowCallback { + return AutocaptureWindowCallback( + delegate, + activity, + track, + listOf(AndroidViewTargetLocator()), + logger, + object : AutocaptureWindowCallback.MotionEventObtainer { + override fun obtain(origin: MotionEvent): MotionEvent { + val actionMasked = origin.actionMasked + every { motionEventCopy.actionMasked } returns actionMasked + return motionEventCopy + } + }, + gestureListener, + gestureDetector, + ) + } + } + + private val fixture = Fixture() + + @Test + fun `delegates the events to the gesture detector`() { + val event = mockk(relaxed = true) + val sut = fixture.getSut() + + sut.dispatchTouchEvent(event) + + verify { fixture.gestureDetector.onTouchEvent(fixture.motionEventCopy) } + verify { fixture.motionEventCopy.recycle() } + } + + @Test + fun `nullable event is ignored`() { + val sut = fixture.getSut() + + sut.dispatchTouchEvent(null) + + verify(exactly = 0) { fixture.gestureDetector.onTouchEvent(any()) } + } +} diff --git a/android/src/test/java/com/amplitude/android/internal/gestures/ViewMockHelper.kt b/android/src/test/java/com/amplitude/android/internal/gestures/ViewMockHelper.kt new file mode 100644 index 00000000..effb4b22 --- /dev/null +++ b/android/src/test/java/com/amplitude/android/internal/gestures/ViewMockHelper.kt @@ -0,0 +1,74 @@ +package com.amplitude.android.internal.gestures + +import android.content.Context +import android.content.res.Resources +import android.view.MotionEvent +import android.view.View +import android.view.Window +import io.mockk.every +import io.mockk.mockkClass +import kotlin.math.abs +import kotlin.reflect.KClass + +internal fun Window.mockDecorView( + type: KClass, + id: Int = View.generateViewId(), + event: MotionEvent, + touchWithinBounds: Boolean = true, + clickable: Boolean = false, + visible: Boolean = true, + context: Context? = null, + finalize: (T) -> Unit = {}, +): T { + val view = mockView(type, id, event, touchWithinBounds, clickable, visible, context, finalize) + every { decorView } returns view + return view +} + +internal fun mockView( + type: KClass, + id: Int = View.generateViewId(), + event: MotionEvent, + touchWithinBounds: Boolean = true, + clickable: Boolean = false, + visible: Boolean = true, + context: Context? = null, + finalize: (T) -> Unit = {}, +): T { + val coordinates = IntArray(2) + if (!touchWithinBounds) { + coordinates[0] = (event.x).toInt() + 10 + coordinates[1] = (event.y).toInt() + 10 + } else { + coordinates[0] = (event.x).toInt() - 10 + coordinates[1] = (event.y).toInt() - 10 + } + val mockView = mockkClass(type, relaxed = true) + + every { mockView.id } returns id + every { mockView.context } returns context + every { mockView.isClickable } returns clickable + every { mockView.visibility } returns if (visible) View.VISIBLE else View.GONE + + every { mockView.getLocationOnScreen(any()) } answers { + val array = invocation.args[0] as IntArray + array[0] = coordinates[0] + array[1] = coordinates[1] + } + + val diffPosX = abs(event.x - coordinates[0]).toInt() + val diffPosY = abs(event.y - coordinates[1]).toInt() + every { mockView.width } returns diffPosX + 10 + every { mockView.height } returns diffPosY + 10 + + finalize(mockView) + + return mockView +} + +internal fun Resources.mockForTarget( + target: View, + expectedResourceName: String, +) { + every { getResourceEntryName(target.id) } returns expectedResourceName +} diff --git a/android/src/test/java/com/amplitude/android/plugins/AndroidLifecyclePluginTest.kt b/android/src/test/java/com/amplitude/android/plugins/AndroidLifecyclePluginTest.kt index 4cdb4e19..052ebbf3 100644 --- a/android/src/test/java/com/amplitude/android/plugins/AndroidLifecyclePluginTest.kt +++ b/android/src/test/java/com/amplitude/android/plugins/AndroidLifecyclePluginTest.kt @@ -332,7 +332,7 @@ class AndroidLifecyclePluginTest { every { mockedActivity.packageManager } returns mockedPackageManager every { mockedActivity.componentName } returns mockk() val mockedActivityInfo = mockk() - every { mockedPackageManager.getActivityInfo(any(), any()) } returns mockedActivityInfo + every { mockedPackageManager.getActivityInfo(any(), PackageManager.GET_META_DATA) } returns mockedActivityInfo every { mockedActivityInfo.loadLabel(mockedPackageManager) } returns "test-label" val mockedBundle = mockk() androidLifecyclePlugin.onActivityCreated(mockedActivity, mockedBundle) @@ -365,7 +365,7 @@ class AndroidLifecyclePluginTest { every { mockedActivity.packageManager } returns mockedPackageManager every { mockedActivity.componentName } returns mockk() val mockedActivityInfo = mockk() - every { mockedPackageManager.getActivityInfo(any(), any()) } returns mockedActivityInfo + every { mockedPackageManager.getActivityInfo(any(), PackageManager.GET_META_DATA) } returns mockedActivityInfo every { mockedActivityInfo.loadLabel(mockedPackageManager) } returns "test-label" val mockedBundle = mockk() androidLifecyclePlugin.onActivityCreated(mockedActivity, mockedBundle) @@ -444,39 +444,6 @@ class AndroidLifecyclePluginTest { } } - @Config(sdk = [16]) - @Test - fun `test deep link opened event is tracked when using sdk is lower than 17`() = runTest { - setDispatcher(testScheduler) - configuration.defaultTracking.deepLinks = true - amplitude.add(androidLifecyclePlugin) - - val mockedPlugin = spyk(StubPlugin()) - amplitude.add(mockedPlugin) - amplitude.isBuilt.await() - - val mockedIntent = mockk() - every { mockedIntent.data } returns Uri.parse("app://url.com/open") - every { mockedIntent.getParcelableExtra(any()) } returns Uri.parse("android-app://com.android.chrome") - val mockedActivity = mockk() - every { mockedActivity.intent } returns mockedIntent - val mockedBundle = mockk() - androidLifecyclePlugin.onActivityCreated(mockedActivity, mockedBundle) - - advanceUntilIdle() - Thread.sleep(100) - - val tracks = mutableListOf() - verify { mockedPlugin.track(capture(tracks)) } - Assertions.assertEquals(1, tracks.count()) - - with(tracks[0]) { - Assertions.assertEquals(DefaultEventUtils.EventTypes.DEEP_LINK_OPENED, eventType) - Assertions.assertEquals(eventProperties?.get(DefaultEventUtils.EventProperties.LINK_URL), "app://url.com/open") - Assertions.assertEquals(eventProperties?.get(DefaultEventUtils.EventProperties.LINK_REFERRER), null) - } - } - @Test fun `test deep link opened event is not tracked when disabled`() = runTest { setDispatcher(testScheduler) diff --git a/build.gradle b/build.gradle index 04bbf1f2..477d0b5e 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ buildscript { - ext.kotlin_version = "1.5.10" + ext.kotlin_version = "1.8.0" ext.dokka_version = '1.6.10' repositories { maven { url "https://plugins.gradle.org/m2/" } diff --git a/common-android/build.gradle b/common-android/build.gradle index 4866b442..3c24aade 100644 --- a/common-android/build.gradle +++ b/common-android/build.gradle @@ -5,11 +5,11 @@ plugins { } android { - compileSdk 31 + compileSdk 34 defaultConfig { - minSdk 16 - targetSdk 31 + minSdk 19 + targetSdk 34 versionCode 1 versionName "1.0" @@ -49,7 +49,7 @@ dependencies { testImplementation 'com.google.android.gms:play-services-base:18.0.1' testImplementation 'com.google.android.gms:play-services-ads-identifier:18.0.1' testImplementation 'io.mockk:mockk:1.12.3' - testImplementation 'org.robolectric:robolectric:4.7.3' + testImplementation 'org.robolectric:robolectric:4.12.1' testImplementation 'androidx.test:core:1.4.0' testImplementation "org.junit.jupiter:junit-jupiter-api:5.7.2" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.7.2" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c29b43c0..8049c684 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Tue Jun 11 16:11:46 PDT 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 744e882e..1b6c7873 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,67 +17,101 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` +APP_BASE_NAME=${0##*/} # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MSYS* | MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +121,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the @@ -106,80 +140,95 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/samples/java-android-app/build.gradle b/samples/java-android-app/build.gradle index 80857de5..9193451b 100644 --- a/samples/java-android-app/build.gradle +++ b/samples/java-android-app/build.gradle @@ -7,12 +7,12 @@ ext { } android { - compileSdk 31 + compileSdk 34 defaultConfig { applicationId "com.amplitude.android.sample" - minSdk 16 - targetSdk 31 + minSdk 19 + targetSdk 34 versionCode 1 versionName "1.0" multiDexEnabled true diff --git a/samples/kotlin-android-app/build.gradle b/samples/kotlin-android-app/build.gradle index 9465c774..69d44659 100644 --- a/samples/kotlin-android-app/build.gradle +++ b/samples/kotlin-android-app/build.gradle @@ -8,12 +8,12 @@ ext { } android { - compileSdk 31 + compileSdk 34 defaultConfig { applicationId "com.amplitude.android.sample" - minSdk 16 - targetSdk 31 + minSdk 19 + targetSdk 34 versionCode 1 versionName "1.0" multiDexEnabled true