# SDK Gravity Field (Android)

Gravity Field SDK - это клиентская библиотека для интеграции Android-приложений с платформой персонализации и A/B-тестирования Gravity Field.

SDK работает как "тонкий клиент": логика выбора кампаний и контента остается на стороне Gravity Field, а приложение:

  • передает контекст текущего экрана;
  • отправляет события пользователя;
  • получает контент в виде встроенных UI-форматов или JSON;
  • отображает inline- и in-app-кампании;
  • отправляет события взаимодействия для аналитики.

Эта документация предназначена для мобильных разработчиков и инженеров, которые интегрируют Android SDK в приложение.

Репозиторий Gravity Field SDK для Android

# Добавление библиотеки в проект

  1. Скопируйте модуль gravity_sdk из репозитория SDK в корень Android-проекта.

  2. Подключите модуль в settings.gradle.kts:

include(":gravity_sdk")
  1. Добавьте зависимость в build.gradle.kts вашего app-модуля:
dependencies {
    implementation(project(":gravity_sdk"))
}

# Быстрый старт

Этот раздел показывает минимальную интеграцию: инициализация SDK, идентификация пользователя, отправка контекста, событий и отображение inline-кампаний.

# Шаг 1: Инициализация SDK

Инициализируйте SDK один раз при старте приложения. Обычно это делается в классе Application.

import android.app.Application
import android.content.Intent
import android.net.Uri
import ai.gravityfield.gravity_sdk.GravitySDK
import ai.gravityfield.gravity_sdk.models.FollowDeeplinkEvent
import ai.gravityfield.gravity_sdk.models.FollowUrlEvent
import ai.gravityfield.gravity_sdk.models.RequestPushEvent
import ai.gravityfield.gravity_sdk.models.TrackingEvent
import ai.gravityfield.gravity_sdk.models.UISettings
import ai.gravityfield.gravity_sdk.utils.LogLevel

class App : Application() {
    override fun onCreate() {
        super.onCreate()

        GravitySDK.initialize(
            context = this,
            apiKey = "YOUR_API_KEY",
            section = "YOUR_SECTION_ID",
            gravityEventCallback = ::handleGravityEvent,
            uiSettings = UISettings(fontResId = R.font.your_custom_font),
            logLevel = LogLevel.DEBUG,
        )
    }

    private fun handleGravityEvent(event: TrackingEvent) {
        when (event) {
            is FollowUrlEvent -> {
                val intent = Intent(Intent.ACTION_VIEW, Uri.parse(event.url))
                intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
                startActivity(intent)
            }
            is FollowDeeplinkEvent -> {
                // Реализуйте навигацию по deeplink
            }
            is RequestPushEvent -> {
                // При необходимости добавьте аналитику или дополнительную логику
            }
            else -> Unit
        }
    }
}

# Почему важен gravityEventCallback?

SDK не выполняет навигацию по URL и deeplink самостоятельно. Когда пользователь нажимает на кнопку, ссылку или действие внутри кампании, SDK передает событие в gravityEventCallback, а приложение должно выполнить нужное действие.

Без gravityEventCallback интерактивные элементы кампаний не смогут корректно открыть URL и deeplink. Для RequestPushEvent SDK сам открывает системный экран настроек уведомлений и дополнительно отправляет callback.

Подробнее см. раздел Обработка обратных вызовов (Callbacks).

# Шаг 2: Идентификация пользователя

После авторизации пользователя отправьте LoginEvent, чтобы связать анонимную активность с авторизованным профилем.

fun onUserLoggedIn(context: Context, rawPhoneNumber: String) {
    val normalizedPhone = normalizePhone(rawPhoneNumber)
    val hashedPhone = sha256Hex(normalizedPhone)

    val loginEvent = LoginEvent(
        cuid = hashedPhone,
        cuidType = "phone_hash",
    )

    GravitySDK.instance.triggerEvent(
        events = listOf(loginEvent),
        pageContext = PageContext(
            type = ContextType.OTHER,
            data = emptyList(),
            location = "app://login",
        ),
        activityContext = context,
    )
}

Отправка LoginEvent после успешного входа - рекомендуемый сценарий идентификации для Android SDK.

# Шаг 3: Отслеживание просмотров экранов

Используйте trackView(...), чтобы передать SDK факт просмотра экрана. Этот вызов нужен и для трекинга пользовательского поведения, и для сценариев, в которых показ кампании зависит от контекста страницы.

fun trackHomepageView(context: Context) {
    GravitySDK.instance.trackView(
        pageContext = PageContext(
            type = ContextType.HOMEPAGE,
            data = emptyList(),
            location = "app://homepage",
        ),
        activityContext = context,
    )
}

Важно: если PageContext содержит значения из товарного фида, передавайте их без изменений относительно фида. Это относится к SKU, иерархии категорий, lng и другим feed-derived значениям. Регистр является частью значения: не приводите его к upper/lower case.

Если для этого контекста настроена in-app-кампания, SDK может загрузить и показать ее автоматически.

# Почему trackView(...) принимает Context?

Android SDK использует activityContext для показа in-app-контента. Если SDK не сможет определить Activity из переданного контекста, in-app-кампания не будет показана.

Для экранов Activity и Fragment рекомендуется передавать контекст текущего экрана, а не applicationContext.

# Шаг 4: Отслеживание событий

Используйте triggerEvent(...), чтобы передать в SDK действие пользователя. Этот вызов нужен и для трекинга событий в аналитике, и для сценариев, в которых событие может активировать кампанию.

fun trackAddToCart(context: Context, productId: String) {
    val event = AddToCartEvent(
        value = 99.99,
        productId = productId,
        quantity = 1,
        currency = "RUB",
    )

    GravitySDK.instance.triggerEvent(
        events = listOf(event),
        pageContext = PageContext(
            type = ContextType.PRODUCT,
            data = listOf(productId),
            location = "app://product/$productId",
        ),
        activityContext = context,
    )
}

Важно: если в событии или PageContext передаются значения, которые должны матчиться с товарным фидом, используйте их в точности как в фиде. Это относится к productId, cart[*].productId, SKU в data, категориям, lng и другим feed-derived значениям. Регистр должен совпадать с фидом.

# Шаг 5: Отображение inline-кампаний

Для отображения inline-кампаний используйте GravityInlineView, GravityInlineCompose или GravityInlineListView.

Пример для Jetpack Compose:

GravityInlineCompose(
    modifier = Modifier.height(250.dp),
    selector = "homepage-recommendations",
    pageContext = PageContext(
        type = ContextType.HOMEPAGE,
        data = emptyList(),
        location = "app://homepage",
    ),
    loader = { CircularProgressIndicator() },
)

Пример для XML:

<ai.gravityfield.gravity_sdk.ui.GravityInlineView
    android:id="@+id/recommendationsView"
    android:layout_width="match_parent"
    android:layout_height="250dp"
    app:selector="homepage-recommendations" />
val inlineView = findViewById<GravityInlineView>(R.id.recommendationsView)
inlineView.init(
    PageContext(
        type = ContextType.HOMEPAGE,
        data = emptyList(),
        location = "app://homepage",
    )
)

# Инициализация и конфигурация

# initialize()

Основной метод настройки SDK. Вызывается один раз при старте приложения.

fun initialize(
    context: Context,
    apiKey: String,
    section: String,
    gravityEventCallback: GravityEventCallback,
    productViewBuilder: ProductViewBuilder? = null,
    productFilter: ProductFilter? = null,
    uiSettings: UISettings? = null,
    logLevel: LogLevel = LogLevel.NONE,
)
Параметр Тип Описание
context Context Контекст приложения.
apiKey String Ваш API key.
section String Идентификатор секции проекта.
gravityEventCallback (TrackingEvent) -> Unit Обязательный callback для действий SDK.
productViewBuilder ProductViewBuilder? Кастомный рендер карточек товара.
productFilter ((Slot) -> Boolean)? Фильтр товаров на клиенте.
uiSettings UISettings? UI-настройки встроенных форматов SDK.
logLevel LogLevel Уровень логирования внутреннего SDK-логгера. По умолчанию LogLevel.NONE.

Если GravitySDK.instance вызывается до initialize(...), SDK выбросит исключение GravitySDK has not been initialized.

# Логирование SDK

Android SDK поддерживает параметр logLevel в initialize(...). Он управляет только внутренними логами SDK и не влияет на gravityEventCallback, отправку событий или логи приложения.

Логи пишутся через Android Log с тегом GravitySDK.

  • LogLevel.DEBUG включает HTTP debug-логи SDK и сообщения об ошибках.
  • LogLevel.INFO доступен в публичном API, но в текущей реализации SDK отдельных info-логов нет, поэтому практический эффект сейчас такой же, как у ERROR.
  • LogLevel.ERROR оставляет только ошибки SDK.
  • LogLevel.NONE полностью отключает внутренний вывод SDK.

Для отладки удобно явно включать LogLevel.DEBUG, а в production оставлять значение по умолчанию LogLevel.NONE.

# setOptions()

Метод задает глобальные настройки, которые применяются ко всем последующим вызовам trackView(...), triggerEvent(...) и getContentBySelector(...).

fun setOptions(
    options: Options?,
    contentSettings: ContentSettings?,
    proxyUrl: String?,
)

Пример:

GravitySDK.instance.setOptions(
    options = Options(
        isReturnUserInfo = true,
        isReturnAnalyticsMetadata = true,
    ),
    contentSettings = ContentSettings(
        skusOnly = false,
        fields = listOf("name", "price", "imageUrl"),
    ),
    proxyUrl = "https://my-proxy.example.com",
)

# Что настраивается через Options

data class Options(
    val isReturnCounter: Boolean = false,
    val isReturnUserInfo: Boolean = false,
    val isReturnAnalyticsMetadata: Boolean = false,
    val isImplicitPageview: Boolean = false,
    val isImplicitImpression: Boolean = true,
)
Поле Значение по умолчанию Назначение
isReturnCounter false Возвращать счетчики в ответе.
isReturnUserInfo false Возвращать информацию о пользователе.
isReturnAnalyticsMetadata false Возвращать дополнительные аналитические метаданные.
isImplicitPageview false Использовать неявный pageview-режим.
isImplicitImpression true Разрешить неявные impression для соответствующих сценариев.

# Что настраивается через ContentSettings

data class ContentSettings(
    val skusOnly: Boolean = false,
    val fields: List<String>? = null,
)
Поле Значение по умолчанию Назначение
skusOnly false Возвращать только SKU вместо расширенных товарных данных.
fields null Ограничить список товарных полей в ответе.

Практически это означает следующее:

  • если skusOnly = true, приложение получает облегченный ответ и само решает, как догружать данные о товаре;
  • если fields задан, SDK запрашивает только указанные товарные поля;
  • если setOptions(...) не вызывается, используются стандартные значения Options() и ContentSettings().

proxyUrl позволяет отправлять запросы SDK через ваш прокси. Если значение не задано, используется стандартный endpoint SDK.

# Идентификация пользователя

Android SDK поддерживает два режима идентификации: автоматический и управляемый приложением.

# Стандарт CUID на основе телефона (phone_hash)

Рекомендуемый идентификатор пользователя для LoginEvent и SignUpEvent - SHA-256 хеш нормализованного мобильного телефона.

Правила формирования:

  • перед хешированием номер очищается от всех символов, кроме цифр;
  • для РФ и КЗ номер приводится к формату 7XXXXXXXXXX;
  • для других стран используется международный формат без + и разделителей;
  • хеш считается по строке в UTF-8;
  • результат передается в lowercase hex;
  • в cuidType передается строка "phone_hash".

Один и тот же идентификатор рекомендуется использовать во всех каналах: web, server-side API, offline import и mobile SDK.

# Автоматическая идентификация

Это поведение SDK по умолчанию. При первом запросе сервер возвращает uid и ses, а SDK сохраняет их и переиспользует в следующих запросах.

Это означает:

  • неавторизованный пользователь идентифицируется SDK автоматически;
  • uid и ses не нужно передавать вручную, если вы используете SDK-managed режим;
  • после логина рекомендуется отправить LoginEvent, чтобы связать анонимный и авторизованный профили.

# Ручная идентификация

Если приложение уже управляет идентификатором пользователя и сессии, можно задать их явно.

# setUser()

fun setUser(userId: String, sessionId: String)

Пример:

GravitySDK.instance.setUser(
    userId = "user-from-my-system-42",
    sessionId = "session-from-my-system-xyz",
)

После этого SDK будет использовать ваш userId и sessionId во всех последующих запросах.

# PageContext

PageContext описывает, где находится пользователь в приложении и в каком бизнес-контексте выполняется запрос.

Этот объект используется во всех основных интеграционных вызовах:

  • trackView(...) для передачи просмотров экранов;
  • triggerEvent(...) для передачи пользовательских действий;
  • getContentBySelector(...) для ручного получения контента.

Корректное заполнение PageContext влияет и на аналитику, и на подбор контента, и на активацию кампаний.

# Структура PageContext

data class PageContext(
    val type: ContextType,
    val data: List<String>,
    val location: String,
    val lng: String? = null,
    val pageNumber: Int? = null,
    val referrer: String? = null,
    val utm: Map<String, String>? = null,
    val attributes: Map<String, String> = emptyMap(),
)
Поле Обязательность Описание
type Обязательно Тип экрана.
data Обязательно Контекстные данные для выбранного типа экрана.
location Обязательно Уникальный идентификатор экрана или deeplink.
lng Опционально Региональный код для мультирегиональности.
pageNumber Опционально Номер страницы в пагинации.
referrer Опционально Источник перехода.
utm Опционально UTM-метки.
attributes Опционально Дополнительные атрибуты таргетинга.

Бизнес-логика заполнения PageContext соответствует Page context.

Рекомендуемая схема:

  • HOMEPAGE: data = emptyList().
  • PRODUCT: в data передается SKU товара ровно как в товарном фиде, без нормализации и с тем же регистром.
  • CART: в data передаются SKU всех товаров, находящихся в корзине, ровно как в товарном фиде, без нормализации и с тем же регистром.
  • CATEGORY: в data передается полная иерархия категорий от самой широкой до самой узкой ровно как в товарном фиде, без нормализации и с тем же регистром.
  • SEARCH: в data передается поисковый запрос одной строкой. Для пустого поиска передается пустой список.
  • OTHER: используется только для экранов, которые не подходят под остальные типы, например для статических страниц или категорий, не включенных в товарный фид.

Для любых значений в PageContext, которые должны матчиться с товарным фидом, действует единое правило: передавайте их идентично фиду. Нельзя менять написание, переименовывать значения или приводить их к upper/lower case.

lng используется в первую очередь для мультирегиональности. Это позволяет отдавать пользователю корректные региональные данные по товарам: цены, доступность и остатки.

Например, если пользователь находится в Новосибирске, значение lng может использоваться для того, чтобы в рекомендациях участвовали только товары, реально доступные для Новосибирска.

Важно:

  • значения lng в контексте и в товарном фиде должны совпадать полностью, включая регистр;
  • lng, как и любые другие feed-derived значения, нужно передавать без преобразования к upper/lower case;
  • lng нужно передавать только если проект использует региональные варианты данных в фиде.

utm и attributes технически не обязательны. Передавайте их, если эти поля используются в правилах таргетинга или аналитике вашего проекта.

Дополнительно:

  • SDK автоматически дополняет attributes служебными значениями app_version, sdk_version и app_platform;
  • блок device формируется самим SDK, вручную его заполнять не нужно.

См. также:

  • trackView(...)
  • triggerEvent(...)
  • getContentBySelector(...)

# Отслеживание просмотров экранов

# trackView(...)

Метод отправляет событие просмотра экрана. В Android SDK этот вызов асинхронный, но сам метод не является suspend.

fun trackView(
    pageContext: PageContext,
    activityContext: Context,
)

Для trackView(...) требуется корректно заполненный PageContext.

Пример:

GravitySDK.instance.trackView(
    pageContext = PageContext(
        type = ContextType.PRODUCT,
        data = listOf("product-sku-123"),
        location = "app://product/123",
    ),
    activityContext = this,
)

trackView(...) выполняет две задачи:

  • передает в Gravity Field информацию о просмотре экрана;
  • может инициировать загрузку и показ кампании, если для этого контекста настроен соответствующий сценарий.

Если для переданного контекста настроена in-app-кампания, SDK может сразу после запроса загрузить и показать ее.

# Трекинг событий

События описывают действия пользователя: покупку, добавление в корзину, авторизацию и другие сценарии, которые должны попадать в аналитику или запускать кампании.

Бизнес-логика заполнения событий соответствует Настройке передачи событий, а Android SDK предоставляет типы и методы для их отправки.

Для e-commerce сценариев события PurchaseEvent и AddToCartEvent обязательны. Дополнительно рекомендуется внедрять остальные релевантные преднастроенные события, которые поддерживаются вашим сценарием интеграции.

Для всех полей событий, которые должны матчиться с товарным фидом, действует то же правило, что и для PageContext: передавайте значения идентично фиду. Это относится к productId, cart[*].productId и другим feed-derived значениям. Регистр является частью значения.

# triggerEvent(...)

В Android SDK этот вызов асинхронный, но сам метод не является suspend.

fun triggerEvent(
    events: List<TriggerEvent>,
    pageContext: PageContext,
    activityContext: Context,
)

Для triggerEvent(...) требуется корректно заполненный PageContext.

triggerEvent(...) выполняет две задачи:

  • передает в Gravity Field пользовательские действия для аналитики, сегментации и построения профиля;
  • может инициировать загрузку и показ кампании, если событие используется как триггер в настройках платформы.

Пример:

GravitySDK.instance.triggerEvent(
    events = listOf(
        AddToCartEvent(
            value = 1500.0,
            productId = "sku-abc-1",
            quantity = 1,
            currency = "RUB",
        )
    ),
    pageContext = PageContext(
        type = ContextType.PRODUCT,
        data = listOf("sku-abc-1"),
        location = "app://product/sku-abc-1",
    ),
    activityContext = this,
)

# Покупка (PurchaseEvent)

Отправляется после успешного завершения заказа.

val purchaseEvent = PurchaseEvent(
    uniqueTransactionId = "ORDER-12345",
    value = 2550.75,
    currency = "RUB",
    cart = listOf(
        CartItem(productId = "sku-123", quantity = 1, itemPrice = 100.50),
        CartItem(productId = "sku-456", quantity = 2, itemPrice = 1225.125),
    ),
)

Правила заполнения:

  • uniqueTransactionId должен быть уникальным для каждой покупки.
  • value - полная сумма заказа.
  • currency опциональна, но обязательна для мультивалютных проектов.
  • cart содержит фактический состав заказа.
  • каждый cart[*].productId должен совпадать со SKU в товарном фиде полностью, включая регистр.
  • товары в cart рекомендуется передавать в порядке добавления: от самых старых к самым новым.
  • каждый CartItem.itemPrice - стоимость одной единицы товара после применения скидок.

# Добавление в корзину (AddToCartEvent)

Отправляйте событие в момент фактического добавления товара в корзину.

val addToCartEvent = AddToCartEvent(
    value = 1500.0,
    productId = "sku-abc-1",
    quantity = 1,
    currency = "RUB",
)

Правила заполнения:

  • value - сумма, добавляемая в корзину этим действием. Если добавляется несколько единиц одного товара, передается quantity * itemPrice.
  • quantity - количество единиц, добавленных именно этим действием, а не итоговое количество товара в корзине.
  • productId должен совпадать со SKU в товарном фиде полностью, включая регистр.
  • currency опциональна, но обязательна для мультивалютных проектов.
  • cart, если передается, должен содержать актуальное состояние корзины, включая только что добавленный товар. Все cart[*].productId должны совпадать со SKU в товарном фиде полностью, включая регистр. Товары рекомендуется передавать в порядке добавления: от самых старых к самым новым.

# Удаление из корзины (RemoveFromCartEvent) и синхронизация корзины (SyncCartEvent)

  • RemoveFromCartEvent отправляйте в момент удаления товара из корзины или уменьшения количества.
  • value в RemoveFromCartEvent - сумма удаляемых единиц товара.
  • quantity в RemoveFromCartEvent - количество единиц, удаленных этим действием.
  • productId в RemoveFromCartEvent должен совпадать со SKU в товарном фиде полностью, включая регистр.
  • SyncCartEvent используйте, когда нужно передать актуальное состояние корзины целиком: например, при пакетном изменении корзины, очистке корзины, объединении корзин после логина или серверном обновлении состава корзины.
  • в Android SDK SyncCartEvent также требует value, поэтому передавайте общую стоимость актуального состава корзины.
  • cart в SyncCartEvent должен содержать полное текущее состояние корзины. Все cart[*].productId должны совпадать со SKU в товарном фиде полностью, включая регистр.
  • товары в cart для RemoveFromCartEvent и SyncCartEvent рекомендуется передавать в порядке добавления: от самых старых к самым новым.
  • currency для RemoveFromCartEvent и SyncCartEvent опциональна, но обязательна для мультивалютных проектов.

# Вход в систему (LoginEvent)

val loginEvent = LoginEvent(
    cuid = sha256Hex(normalizePhone(rawPhoneNumber)),
    cuidType = "phone_hash",
)

Поле hashedEmail опционально. Если email не является обязательным в вашем процессе регистрации или логина, его можно не передавать.

Для LoginEvent рекомендуется использовать единый идентификатор phone_hash. Если проект использует другой cuid, он должен быть согласован и одинаково использоваться во всех каналах интеграции.

# Добавление в избранное (AddToWishlistEvent)

val addToWishlistEvent = AddToWishlistEvent(
    value = 1500.0,
    productId = "sku-abc-1",
)

productId должен совпадать со SKU в товарном фиде полностью, включая регистр.

# Кастомное событие (CustomEvent)

val customEvent = CustomEvent(
    type = "survey-completed-v1",
    name = "Survey completed",
    customProps = mapOf(
        "surveyId" to "summer-2025-feedback",
        "rating" to "5",
    ),
)

# Передача статуса Push-уведомлений

Если таргетинг кампаний зависит от статуса push-разрешения, передавайте его в SDK.

# setNotificationPermissionStatus()

fun setNotificationPermissionStatus(status: NotificationPermissionStatus)

Допустимые значения:

  • NotificationPermissionStatus.GRANTED
  • NotificationPermissionStatus.DENIED
  • NotificationPermissionStatus.UNKNOWN

Пример:

import androidx.core.app.NotificationManagerCompat

val areNotificationsEnabled =
    NotificationManagerCompat.from(context).areNotificationsEnabled()

val status = if (areNotificationsEnabled) {
    NotificationPermissionStatus.GRANTED
} else {
    NotificationPermissionStatus.DENIED
}

GravitySDK.instance.setNotificationPermissionStatus(status)

SDK добавляет это значение в device.permission последующих запросов. Для базовой интеграции этот шаг не обязателен, но он необходим, если push-статус участвует в таргетинге.

# Работа с контентом

Android SDK поддерживает три основных режима работы с контентом:

  • in-app-кампании, которые SDK показывает самостоятельно;
  • inline-кампании, которые встраиваются в экран приложения;
  • JSON-кампании для полностью ручного рендеринга.

# In-App кампании

SDK автоматически отображает in-app-кампании, если они приходят в ответ на trackView(...) или triggerEvent(...).

Приложение отвечает за:

  • корректный PageContext;
  • корректный activityContext;
  • обработку действий пользователя через gravityEventCallback.

# GravityInlineView

Используйте GravityInlineView, если экран построен на XML.

<ai.gravityfield.gravity_sdk.ui.GravityInlineView
    android:id="@+id/recommendationsView"
    android:layout_width="match_parent"
    android:layout_height="250dp"
    app:selector="homepage-recs"
    app:color="#FFF1F1F1"
    app:cornerRadius="20dp" />

После инфлейта обязательно передайте PageContext:

val inlineView = findViewById<GravityInlineView>(R.id.recommendationsView)
inlineView.init(
    PageContext(
        type = ContextType.HOMEPAGE,
        data = emptyList(),
        location = "app://homepage",
    )
)

Поддерживаемые XML-атрибуты:

Атрибут Описание
app:selector Селектор кампании.
app:color Цвет фона контейнера.
app:cornerRadius Единый радиус скругления.
app:cornerRadiusTopStart Радиус верхнего левого угла.
app:cornerRadiusTopEnd Радиус верхнего правого угла.
app:cornerRadiusBottomStart Радиус нижнего левого угла.
app:cornerRadiusBottomEnd Радиус нижнего правого угла.
app:loaderLayout Кастомный layout для состояния загрузки.

# GravityInlineCompose

Используйте GravityInlineCompose, если экран написан на Jetpack Compose.

GravityInlineCompose(
    modifier = Modifier
        .fillMaxWidth()
        .height(250.dp),
    selector = "homepage-recs",
    pageContext = PageContext(
        type = ContextType.HOMEPAGE,
        data = emptyList(),
        location = "app://homepage",
    ),
    loader = { CircularProgressIndicator() },
)

loader - это composable для состояния загрузки. Если placeholder не нужен, можно передать loader = null.

# GravityInlineListView

GravityInlineListView используется для отображения нескольких inline-элементов из одной группы.

<ai.gravityfield.gravity_sdk.ui.GravityInlineListView
    android:id="@+id/inlineListView"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:groupSelector="homepage-group" />
val inlineListView = findViewById<GravityInlineListView>(R.id.inlineListView)
inlineListView.init(
    PageContext(
        type = ContextType.HOMEPAGE,
        data = emptyList(),
        location = "app://homepage",
    )
)

Этот компонент полезен для сценариев, где несколько блоков должны быть загружены одной группой кампаний.

# ProductViewBuilder и кастомный рендеринг товаров

Если нужно полностью контролировать отображение карточек товара, реализуйте ProductViewBuilder или LegacyProductViewBuilder.

Важно:

  • SDK автоматически отслеживает показ товара;
  • клик по товару в кастомной карточке нужно отправлять вручную через sendProductEngagement(...).

Пример для Jetpack Compose:

class MyComposeProductViewBuilder : ProductViewBuilder {
    @Composable
    override fun Build(slot: Slot, content: CampaignContent, campaign: Campaign) {
        Box(
            modifier = Modifier.clickable {
                GravitySDK.instance.sendProductEngagement(
                    ProductClickEngagement(slot, content, campaign)
                )
                // Откройте экран товара
            }
        ) {
            Text(text = slot.item["name"] as? String ?: "Unknown Product")
        }
    }
}

# JSON-кампании

Используйте JSON-режим, если приложение должно само интерпретировать ответ и строить UI без встроенных компонентов SDK.

# getContentBySelector(...)

suspend fun getContentBySelector(
    selector: String,
    pageContext: PageContext,
): ContentResponse

Для getContentBySelector(...) требуется корректно заполненный PageContext.

Пример:

lifecycleScope.launch {
    val response = GravitySDK.instance.getContentBySelector(
        selector = "homepage-banner-json",
        pageContext = PageContext(
            type = ContextType.HOMEPAGE,
            data = emptyList(),
            location = "app://home",
        )
    )

    if (response.data.isNotEmpty()) {
        val campaign = response.data.first()
        val variation = campaign.payload.firstOrNull()
        val content = variation?.contents?.firstOrNull()
        // Отрисуйте content в своем UI
    }
}

В Android SDK это публичный метод для manual rendering. Внутренние методы getContentByCampaignId(...) и getContentByGroupSelector(...) используются самим SDK и не являются частью публичного интеграционного API.

# Какие параметры нужны для получения контента

Для вызова getContentBySelector(...) обязательны:

  • selector;
  • pageContext.type;
  • pageContext.data;
  • pageContext.location.

Опциональны:

  • lng;
  • pageNumber;
  • referrer;
  • utm;
  • attributes.

skusOnly и fields не передаются в метод напрямую. Они задаются один раз через setOptions(...) в ContentSettings и затем влияют на все следующие вызовы choose.

# Как устроен ответ ContentResponse

Упрощенно структура ответа выглядит так:

ContentResponse
  -> data: List<Campaign>
      -> payload: List<CampaignVariation>
          -> contents: List<CampaignContent>
              -> products?.slots: List<Slot>

Это важно для manual rendering и engagement:

  • campaign берите из response.data;
  • content берите из campaign.payload[*].contents[*];
  • slot берите из content.products?.slots[*].

decisionId присутствует в CampaignVariation, но публичные методы sendContentEngagement(...) и sendProductEngagement(...) не требуют передавать его отдельным параметром.

# Пример: server-driven recommendation widget

Этот сценарий полезен, когда приложению нужен полный контроль над native UI, но структура recommendation widget, заголовок, подборка товаров и layout по-прежнему должны приходить с backend. На Android это альтернатива GravityInlineView и GravityInlineCompose, а не их замена на уровне SDK API.

Разделение ответственности в этом flow:

  • клиент вызывает getContentBySelector(...) с нужными selector и PageContext;
  • backend возвращает готовую конфигурацию виджета;
  • клиент не выбирает сам заголовок, не перестраивает порядок товаров и не решает локально row или grid;
  • клиент интерпретирует ответ SDK, строит native UI, обрабатывает onClick и вручную отправляет engagement.

# Что именно управляется с сервера

  • заголовок и дополнительные текстовые блоки приходят как Element(type = text) внутри content.variables.elements;
  • изображения, кнопки, spacer и другие UI-элементы тоже приходят в elements;
  • подборка товаров, стратегия, fallback и ранжирование приходят в content.products;
  • тип товарного блока задается через products-container и style.productContainerType;
  • внешний контейнер виджета приходит в content.variables.frameUI?.container;
  • визуальная кастомизация приходит через frameUI.container.style и elements[*].style.

# Как читать ответ SDK

Поле Что означает Как использовать
response.data[*] campaign Верхний уровень ответа SDK. Используется для engagement и доступа к variation.
campaign.payload[*] variation / decision Содержит набор contents для выбранной кампании.
payload.contents[*] CampaignContent Конкретный блок, который нужно рендерить.
content.variables.frameUI?.container Внешний контейнер виджета Фон, скругление, padding, margin, size и опциональный onClick для всего блока.
content.variables.elements Ordered server-driven UI schema Рендерите элементы строго в том порядке, в котором они пришли с backend.
Element(type = text) Текстовый блок Если это первый text до products-container, его можно трактовать как заголовок виджета.
Element(type = products-container) Место вставки товарного блока В этой точке UI нужно отрисовать content.products?.slots.
element.style.productContainerType Тип recommendation widget Значение row или grid приходит с backend и не должно хардкодиться в приложении.
element.style.gridColumns Число колонок для grid Если поле отсутствует, используйте дефолт клиента, например 3.
content.products?.slots[*] Отранжированные товары Используйте порядок слотов как есть, без локальной перестановки.
slot.item[...] Данные товара Источник name, price, imageUrl, url и любых кастомных полей из фида.

products.strategyId, products.name и products.fallback показывают, какая рекомендательная стратегия была выбрана backend и был ли использован fallback.

# Что клиент не должен делать

  • не хардкодить заголовок, если он уже приходит в elements;
  • не перестраивать порядок товаров, если backend уже вернул готовое ранжирование;
  • не игнорировать productContainerType, если backend переключил layout с row на grid или обратно;
  • не передавать вручную decisionId или engagement URL: используйте campaign, content и slot из ответа SDK.

# Полный пример на Jetpack Compose

Ниже пример server-driven виджета. Он:

  • загружает CampaignContent через getContentBySelector(...);
  • рендерит elements в том порядке, который вернул backend;
  • берет товары из content.products?.slots;
  • выбирает row или grid по products-container.style.productContainerType;
  • вручную отправляет ContentImpressionEngagement, ContentVisibleImpressionEngagement, ProductVisibleImpressionEngagement и ProductClickEngagement.

Для краткости RemoteImage ниже — это placeholder. Подмените его на ваш image loader, например Coil или Glide.

import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import ai.gravityfield.gravity_sdk.GravitySDK
import ai.gravityfield.gravity_sdk.models.Action
import ai.gravityfield.gravity_sdk.models.CampaignContent
import ai.gravityfield.gravity_sdk.models.ContentImpressionEngagement
import ai.gravityfield.gravity_sdk.models.ContentVisibleImpressionEngagement
import ai.gravityfield.gravity_sdk.models.Element
import ai.gravityfield.gravity_sdk.models.ElementType
import ai.gravityfield.gravity_sdk.models.GravityContentAlignment
import ai.gravityfield.gravity_sdk.models.GravityMargin
import ai.gravityfield.gravity_sdk.models.GravityPadding
import ai.gravityfield.gravity_sdk.models.OnClickModel
import ai.gravityfield.gravity_sdk.models.PageContext
import ai.gravityfield.gravity_sdk.models.ProductClickEngagement
import ai.gravityfield.gravity_sdk.models.ProductContainerType
import ai.gravityfield.gravity_sdk.models.ProductVisibleImpressionEngagement
import ai.gravityfield.gravity_sdk.models.Slot
import ai.gravityfield.gravity_sdk.models.Style
import ai.gravityfield.gravity_sdk.network.Campaign
import kotlinx.coroutines.delay

@Composable
fun ServerDrivenRecommendationWidget(
    selector: String,
    pageContext: PageContext,
    onOpenUrl: (String) -> Unit,
    onOpenDeeplink: (String) -> Unit,
    onOpenProduct: (Map<String, Any?>) -> Unit,
) {
    var campaign by remember { mutableStateOf<Campaign?>(null) }
    var content by remember { mutableStateOf<CampaignContent?>(null) }
    var isLoading by remember { mutableStateOf(true) }
    var hasSentContentImpression by remember { mutableStateOf(false) }
    var hasSentContentVisibleImpression by remember { mutableStateOf(false) }

    LaunchedEffect(selector, pageContext) {
        isLoading = true

        val response = runCatching {
            GravitySDK.instance.getContentBySelector(
                selector = selector,
                pageContext = pageContext,
            )
        }.getOrNull()

        campaign = response?.data?.firstOrNull()
        content = campaign?.payload?.firstOrNull()?.contents?.firstOrNull()
        hasSentContentImpression = false
        hasSentContentVisibleImpression = false
        isLoading = false
    }

    if (isLoading) {
        CircularProgressIndicator()
        return
    }

    val resolvedCampaign = campaign ?: return
    val resolvedContent = content ?: return
    val containerStyle = resolvedContent.variables.frameUI?.container?.style ?: Style.empty
    val containerAlignment = containerStyle.contentAlignment?.toHorizontalAlignment()
        ?: Alignment.Start

    LaunchedEffect(resolvedContent.contentId) {
        if (!hasSentContentImpression) {
            hasSentContentImpression = true
            GravitySDK.instance.sendContentEngagement(
                ContentImpressionEngagement(resolvedContent, resolvedCampaign)
            )
        }
    }

    VisibleImpressionBox(
        modifier = Modifier.fillMaxWidth(),
        onVisible = {
            if (!hasSentContentVisibleImpression) {
                hasSentContentVisibleImpression = true
                GravitySDK.instance.sendContentEngagement(
                    ContentVisibleImpressionEngagement(resolvedContent, resolvedCampaign)
                )
            }
        }
    ) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .applyGravitySize(containerStyle)
                .clip(RoundedCornerShape((containerStyle.cornerRadius ?: 0.0).dp))
                .background(
                    color = containerStyle.backgroundColor ?: Color.Transparent,
                    shape = RoundedCornerShape((containerStyle.cornerRadius ?: 0.0).dp)
                )
                .padding(containerStyle.padding.toPaddingValues())
                .padding(containerStyle.margin.toPaddingValues()),
            horizontalAlignment = containerAlignment,
        ) {
            resolvedContent.variables.elements.orEmpty().forEach { element ->
                RenderElement(
                    element = element,
                    content = resolvedContent,
                    campaign = resolvedCampaign,
                    onOpenUrl = onOpenUrl,
                    onOpenDeeplink = onOpenDeeplink,
                    onOpenProduct = onOpenProduct,
                )
            }
        }
    }
}

@Composable
private fun RenderElement(
    element: Element,
    content: CampaignContent,
    campaign: Campaign,
    onOpenUrl: (String) -> Unit,
    onOpenDeeplink: (String) -> Unit,
    onOpenProduct: (Map<String, Any?>) -> Unit,
) {
    val context = LocalContext.current
    val style = element.style

    when (element.type) {
        ElementType.TEXT -> {
            Text(
                text = element.text.orEmpty(),
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(style.margin.toPaddingValues())
                    .then(
                        if (element.onClick != null) {
                            Modifier.clickable {
                                handleOnClick(
                                    context = context,
                                    onClick = element.onClick,
                                    onOpenUrl = onOpenUrl,
                                    onOpenDeeplink = onOpenDeeplink,
                                )
                            }
                        } else {
                            Modifier
                        }
                    ),
                color = style.textColor ?: Color.Unspecified,
                fontSize = style.fontSize ?: TextUnit.Unspecified,
                fontWeight = style.fontWeight ?: FontWeight.Normal,
                textAlign = style.contentAlignment.toTextAlign(),
            )
        }

        ElementType.IMAGE -> {
            val src = element.src ?: return
            RemoteImage(
                url = src,
                modifier = Modifier
                    .fillMaxWidth()
                    .height((style.size?.height ?: 120.0).dp)
                    .padding(style.margin.toPaddingValues())
                    .then(
                        if (element.onClick != null) {
                            Modifier.clickable {
                                handleOnClick(
                                    context = context,
                                    onClick = element.onClick,
                                    onOpenUrl = onOpenUrl,
                                    onOpenDeeplink = onOpenDeeplink,
                                )
                            }
                        } else {
                            Modifier
                        }
                    )
            )
        }

        ElementType.BUTTON -> {
            Button(
                onClick = {
                    handleOnClick(
                        context = context,
                        onClick = element.onClick,
                        onOpenUrl = onOpenUrl,
                        onOpenDeeplink = onOpenDeeplink,
                    )
                },
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(style.margin.toPaddingValues()),
                colors = ButtonDefaults.buttonColors(
                    containerColor = style.backgroundColor ?: Color(0xFF111827),
                    contentColor = style.textColor ?: Color.White,
                )
            ) {
                Text(
                    text = element.text.orEmpty(),
                    fontSize = style.fontSize ?: TextUnit.Unspecified,
                    fontWeight = style.fontWeight ?: FontWeight.Normal,
                )
            }
        }

        ElementType.SPACER -> {
            if (style.weight != null) {
                Spacer(modifier = Modifier.weight(style.weight))
            } else {
                Spacer(modifier = Modifier.height((style.size?.height ?: 12.0).dp))
            }
        }

        ElementType.PRODUCTS_CONTAINER -> {
            val slots = content.products?.slots.orEmpty()

            when (style.productContainerType ?: ProductContainerType.ROW) {
                ProductContainerType.ROW -> {
                    LazyRow(
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(style.margin.toPaddingValues()),
                        contentPadding = style.padding.toPaddingValues(),
                        horizontalArrangement = Arrangement.spacedBy((style.rowSpacing ?: 12).dp),
                    ) {
                        itemsIndexed(
                            items = slots,
                            key = { itemIndex, slot -> productKey(slot, itemIndex) }
                        ) { itemIndex, slot ->
                            ProductCard(
                                slot = slot,
                                content = content,
                                campaign = campaign,
                                onOpenProduct = onOpenProduct,
                            )
                        }
                    }
                }

                ProductContainerType.GRID -> {
                    LazyVerticalGrid(
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(style.margin.toPaddingValues())
                            .then(
                                if (style.size?.height != null) {
                                    Modifier.height(style.size.height.dp)
                                } else {
                                    Modifier
                                }
                            ),
                        columns = GridCells.Fixed(max(style.gridColumns ?: 3, 1)),
                        contentPadding = style.padding.toPaddingValues(),
                        horizontalArrangement = Arrangement.spacedBy(12.dp),
                        verticalArrangement = Arrangement.spacedBy(12.dp),
                        userScrollEnabled = false,
                    ) {
                        itemsIndexed(
                            items = slots,
                            key = { itemIndex, slot -> productKey(slot, itemIndex) }
                        ) { itemIndex, slot ->
                            ProductCard(
                                slot = slot,
                                content = content,
                                campaign = campaign,
                                onOpenProduct = onOpenProduct,
                            )
                        }
                    }
                }
            }
        }

        else -> Unit
    }
}

@Composable
private fun ProductCard(
    slot: Slot,
    content: CampaignContent,
    campaign: Campaign,
    onOpenProduct: (Map<String, Any?>) -> Unit,
) {
    VisibleImpressionBox(
        modifier = Modifier.width(180.dp),
        onVisible = {
            GravitySDK.instance.sendProductEngagement(
                ProductVisibleImpressionEngagement(slot, content, campaign)
            )
        }
    ) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .background(Color.White, RoundedCornerShape(16.dp))
                .clickable {
                    GravitySDK.instance.sendProductEngagement(
                        ProductClickEngagement(slot, content, campaign)
                    )
                    onOpenProduct(slot.item)
                }
                .padding(12.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            stringValue("imageUrl", slot.item)?.let { imageUrl ->
                RemoteImage(
                    url = imageUrl,
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(160.dp)
                )
            }

            Text(
                text = stringValue("name", slot.item) ?: "Unknown product",
                style = TextStyle(fontWeight = FontWeight.SemiBold),
            )

            stringValue("price", slot.item)?.let { price ->
                Text(text = price, color = Color(0xFF667085))
            }
        }
    }
}

@Composable
private fun RemoteImage(
    url: String,
    modifier: Modifier = Modifier,
) {
    Box(
        modifier = modifier.background(Color(0xFFF2F4F7), RoundedCornerShape(12.dp)),
        contentAlignment = Alignment.Center,
    ) {
        Text(
            text = "Load with your image loader:\n$url",
            textAlign = TextAlign.Center,
            color = Color(0xFF475467),
        )
    }
}

@Composable
private fun VisibleImpressionBox(
    modifier: Modifier = Modifier,
    onVisible: () -> Unit,
    content: @Composable () -> Unit,
) {
    val view = LocalView.current
    var isVisible by remember { mutableStateOf(false) }
    var hasFired by remember { mutableStateOf(false) }

    LaunchedEffect(isVisible, hasFired) {
        if (!isVisible || hasFired) return@LaunchedEffect
        delay(1000)
        if (isVisible && !hasFired) {
            hasFired = true
            onVisible()
        }
    }

    Box(
        modifier = modifier.onGloballyPositioned { coordinates ->
            val windowRect = Rect(0f, 0f, view.width.toFloat(), view.height.toFloat())
            val widgetRect = coordinates.boundsInWindow()
            val widgetSize = coordinates.size

            val intersection = widgetRect.intersect(windowRect)
            val visibleArea = intersection.width * intersection.height
            val fullArea = widgetSize.width * widgetSize.height

            isVisible = fullArea > 0 && visibleArea >= fullArea * 0.5f
        }
    ) {
        content()
    }
}

private fun handleOnClick(
    context: Context,
    onClick: OnClickModel?,
    onOpenUrl: (String) -> Unit,
    onOpenDeeplink: (String) -> Unit,
) {
    onClick ?: return

    when (onClick.action) {
        Action.FOLLOW_URL -> onClick.url?.let(onOpenUrl)
        Action.FOLLOW_DEEPLINK -> onClick.deeplink?.let(onOpenDeeplink)
        Action.COPY -> {
            val text = onClick.copyData ?: return
            val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
            clipboard.setPrimaryClip(ClipData.newPlainText("", text))
        }
        else -> Unit
    }
}

private fun productKey(slot: Slot, index: Int): String {
    return slot.slotId
        ?: stringValue("sku", slot.item)
        ?: stringValue("id", slot.item)
        ?: "slot-$index"
}

private fun stringValue(key: String, item: Map<String, Any?>): String? {
    val value = item[key] ?: return null
    return when (value) {
        is String -> value
        is Number -> value.toString()
        else -> null
    }
}

private fun GravityPadding?.toPaddingValues(): PaddingValues {
    return PaddingValues(
        start = ((this?.left ?: 0.0)).dp,
        top = ((this?.top ?: 0.0)).dp,
        end = ((this?.right ?: 0.0)).dp,
        bottom = ((this?.bottom ?: 0.0)).dp,
    )
}

private fun GravityMargin?.toPaddingValues(): PaddingValues {
    return PaddingValues(
        start = ((this?.left ?: 0.0)).dp,
        top = ((this?.top ?: 0.0)).dp,
        end = ((this?.right ?: 0.0)).dp,
        bottom = ((this?.bottom ?: 0.0)).dp,
    )
}

private fun Modifier.applyGravitySize(style: Style): Modifier {
    var result = this
    if (style.size?.width != null) {
        result = result.width(style.size.width.dp)
    }
    if (style.size?.height != null) {
        result = result.height(style.size.height.dp)
    }
    return result
}

private fun GravityContentAlignment?.toTextAlign(): TextAlign {
    return when (this) {
        GravityContentAlignment.CENTER -> TextAlign.Center
        GravityContentAlignment.END -> TextAlign.End
        else -> TextAlign.Start
    }
}

Если backend передает frameUI.container.onClick, его можно повесить на wrapper виджета тем же способом, что и element.onClick в примере выше.

# Engagement для manual widget

При использовании публичного getContentBySelector(...) SDK только получает контент. Engagement для такого manual widget приложение отправляет само:

  • ContentImpressionEngagement — один раз, когда виджет впервые отрисован;
  • ContentVisibleImpressionEngagement — один раз, когда минимум 50% виджета находится в видимой области не менее 1 секунды;
  • ProductVisibleImpressionEngagement — один раз на карточку товара по тому же правилу;
  • ProductClickEngagement — на тап по карточке товара до навигации;
  • публичного ContentClickEngagement в текущем Android SDK нет.

Для manual rendering это означает, что campaign, content и slot нужно сохранить из ответа SDK и использовать повторно при отправке engagement.

# Дополнительная кастомизация из backend

Нативный widget можно стилизовать по полям, которые уже приходят в моделях SDK:

  • container: backgroundColor, cornerRadius, padding, margin, size;
  • text: fontSize, fontWeight, textColor, contentAlignment;
  • image: src, fit, size;
  • layout: gridColumns, productContainerType;
  • элементы: button, image, text, spacer, products-container.

# Кастомизация UI

# Настройка шрифта

Встроенные UI-компоненты SDK можно стилизовать через UISettings.

val uiSettings = UISettings(fontResId = R.font.my_custom_font)

GravitySDK.initialize(
    context = this,
    apiKey = "YOUR_API_KEY",
    section = "YOUR_SECTION_ID",
    gravityEventCallback = ::handleGravityEvent,
    uiSettings = uiSettings,
)

После этого встроенные текстовые элементы SDK будут использовать указанный шрифт.

# Трекинг взаимодействий (engagement)

Engagement-события позволяют фиксировать показы и клики по контенту и товарам.

# Какие события SDK отправляет автоматически

Для встроенных UI-компонентов SDK сам отправляет базовые события показа.

Для кастомного UI вручную нужно отправлять только те взаимодействия, которые SDK не может определить сам.

# sendContentEngagement()

fun sendContentEngagement(engagement: ContentEngagement)

Поддерживаемые типы:

  • ContentImpressionEngagement(content, campaign)
  • ContentVisibleImpressionEngagement(content, campaign)
  • ContentCloseEngagement(content, campaign)

Пример:

GravitySDK.instance.sendContentEngagement(
    ContentVisibleImpressionEngagement(content, campaign)
)

# sendProductEngagement()

fun sendProductEngagement(engagement: ProductEngagement)

Поддерживаемые типы:

  • ProductClickEngagement(slot, content, campaign)
  • ProductVisibleImpressionEngagement(slot, content, campaign)

Пример:

GravitySDK.instance.sendProductEngagement(
    ProductClickEngagement(slot, content, campaign)
)

# Когда нужно отправлять engagement вручную

  • при полностью ручном JSON-рендеринге;
  • при кастомном рендеринге карточек товара через ProductViewBuilder;
  • в других сценариях, где вы сами контролируете жизненный цикл видимости и клика.

# Какие объекты передавать в engagement

Для ручной отправки используйте объекты из ответа SDK, а не отдельные ID:

  • в ProductClickEngagement(...) и ProductVisibleImpressionEngagement(...) передаются slot, content, campaign;
  • в ContentVisibleImpressionEngagement(...) и других content engagement передаются content, campaign.

На практике это означает:

  • slot берется из content.products?.slots;
  • content берется из campaign.payload[*].contents[*];
  • campaign берется из ContentResponse.data.

# Откуда брать slot, content и campaign

Чаще всего эти объекты берутся из ответа getContentBySelector(...).

val response = GravitySDK.instance.getContentBySelector(
    selector = "homepage-banner-json",
    pageContext = pageContext,
)

val campaign = response.data.firstOrNull() ?: return
val variation = campaign.payload.firstOrNull() ?: return
val content = variation.contents.firstOrNull() ?: return
val slot = content.products?.slots?.firstOrNull() ?: return

После этого можно отправлять engagement:

GravitySDK.instance.sendContentEngagement(
    ContentVisibleImpressionEngagement(content, campaign)
)

GravitySDK.instance.sendProductEngagement(
    ProductClickEngagement(slot, content, campaign)
)

Если вы используете кастомный ProductViewBuilder, SDK сам передает вам slot, content и campaign в метод Build(...) или createView(...), и их можно использовать напрямую без дополнительного поиска в ответе.

# Когда считается visible impression

Во встроенных UI-компонентах Android SDK фиксирует visible impression, когда элемент находится в видимой области не менее чем на 50% и сохраняет видимость не менее 1 секунды.

# Справочник по событиям (TriggerEvent)

Событие Описание Основные параметры
AddToCartEvent Добавление товара в корзину value, productId, quantity, currency?, cart?
PurchaseEvent Успешная покупка uniqueTransactionId, value, cart, currency?
RemoveFromCartEvent Удаление товара из корзины value, productId, quantity, currency?, cart?
SyncCartEvent Передача актуального состава корзины value, currency?, cart?
AddToWishlistEvent Добавление в избранное value, productId
SignUpEvent Регистрация пользователя hashedEmail?, cuid?, cuidType?
LoginEvent Авторизация пользователя hashedEmail?, cuid?, cuidType?
CustomEvent Кастомное событие type, name, customProps?

Для LoginEvent и SignUpEvent рекомендуется передавать cuid и cuidType, даже если на уровне класса эти поля не обязательны.

# Обработка обратных вызовов (Callbacks)

Подпишитесь на события SDK через gravityEventCallback в initialize(...).

# Справочник по событиям TrackingEvent

Событие Описание Требует обработки приложением
ContentLoadEvent Контент загружен. Нет
ContentImpressionEvent Контент показан. Нет
ContentVisibleImpressionEvent Контент стал видимым. Нет
ContentCloseEvent Контент закрыт. Нет
CopyEvent Пользователь скопировал значение. Нет
CancelEvent Пользователь отменил действие. Нет
FollowUrlEvent Пользователь нажал на URL. Да
FollowDeeplinkEvent Пользователь нажал на deeplink. Да
RequestPushEvent Пользователь инициировал сценарий push-permission. Опционально
ProductImpressionEvent Карточка товара стала видимой. Нет

Пример обработки:

private fun handleGravityEvent(event: TrackingEvent) {
    when (event) {
        is FollowUrlEvent -> {
            val intent = Intent(Intent.ACTION_VIEW, Uri.parse(event.url))
            intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
            startActivity(intent)
        }
        is FollowDeeplinkEvent -> {
            // Выполните навигацию по deeplink
        }
        is RequestPushEvent -> {
            // При необходимости обработайте событие в аналитике
        }
        else -> Unit
    }
}

# FAQ и Troubleshooting

Почему GravityInlineView не отображается?
Проверьте два условия: в XML задан app:selector, а после инфлейта вызван init(pageContext). Без init(...) SDK не получит контекст для загрузки кампании.

Почему GravityInlineCompose не показывает контент?
Проверьте selector, pageContext и то, что composable действительно получает место на экране. Для отладки удобно временно передать loader, чтобы различать состояние загрузки и отсутствие кампании.

Почему кампания не показывается после trackView(...) или triggerEvent(...)?
Проверьте PageContext, актуальность section, наличие подходящей кампании и то, что в activityContext передается контекст текущего экрана. Если SDK не сможет определить Activity, in-app-показ не произойдет.

Почему приложение не падает, даже если сеть недоступна?
Android SDK обрабатывает сетевые ошибки внутри себя. В этом случае контент просто не будет показан или не будет загружен.

Нужно ли передавать lng, utm и attributes всегда?
Нет. lng нужен только если проект использует мультирегиональность и региональные варианты данных в фиде. utm и attributes нужны только если на них завязаны правила таргетинга или аналитика.

Нужно ли отдельно передавать decisionId в engagement?
Нет. В публичных Android-методах engagement он не передается отдельным параметром. Используйте объекты slot, content и campaign, полученные из ответа SDK.