Привет! Мы написали свою систему диплинков на основе кодогенерации. В этой статье поговорим, как мы упростили работу с диплинками и смогли отловить устаревшие, добавили мониторинг и как собрали все диплинки в одной статье в конфлюенсе.
Диплинк — это uri на конкретный ресурс в приложении. Они нужны бизнесу, чтобы упрощать пользовательский опыт. Так вместо нескольких переходов внутри приложения диплинки позволяют направить пользователя на определённый экран в один клик (польза для пользователя) и снимать статистику (польза для бизнеса). Например: на внешней площадке компания разместила баннер, в котором предлагается заказать виртуальную карту, пользователь может кликнуть по баннеру и сразу попасть на экран заказа карты, а бизнес сможет оценить, какая из площадок более эффективна.

Самая большая проблема — это проблема безопасности. Объясню на примере активити, которая открывает веб-страницы. Активити открывается по диплинку, в нём указывается URL в качестве параметра. Один из вариантов атаки — когда злоумышленник может заставить пользователя пройти по диплинку с URL на вредоносный сайт и таким образом провести атаку. Ещё одна из возможных проблем — на некоторых экранах нам нужно валидировать параметры, а это иногда занимает значительную часть активити. Было бы хорошо вынести валидацию в отдельное место.
Ещё у нас бывали случаи, когда маркетинг запускал промо-кампании либо с диплинками, в которых содержались ошибки, либо с устаревшими диплинками, которые уже не поддерживались, и мы могли об этом даже и не узнать. А при заведении новой кампании маркетинг обращался к разработчиками за диплинками и поиск занимал некоторое время. Если разработчик помнил название экрана и диплинк без параметров, то его можно было быстро найти, а если название экрана сразу не вспомнилось, то алгоритм поиска примерно такой: сбилдить проект -> пройти на нужный экран -> посмотреть в логах, какая активити открылась -> пойти в манифест для получения диплинка -> открыть исходных код активити для сбора входных параметров.
Посмотрев на это, мы поняли, что нам нужна единая точка обработки, анализа, мониторинга, и решили выбрать инструмент аннотаций и кодогенерации. Сейчас объявление диплинка у нас выглядит следующим образом.
Пример объявления диплинка для экрана бонуса.
@DeepLink( Uri("qiwi", "bonus"), description = Description("открыть раздел бонус(кэшбек)") ) class BonusHandler : ProxyDeepLinkHandler() { override val activityClass: Class<*> = BonusShowcaseActivity::class.java }
С помощью аннотации @Deeplink помечаем обработчик и задаем диплинк, в данном примере диплинк qiwi://bonus откроет BonusShowcaseActivity.
Во втором примере показано, как производим проверки перед открытием диплинка.
@DeepLink( Uri("qiwi", "bonus"), description = Description("открыть раздел бонус(кэшбек)") ) class BonusHandler : DeepLinkHandler() { @Inject lateinit var bonusShowcaseFeature: BonusShowcaseFeature override fun deeplinkData(context: Context, intent: Intent): DeepLinkData { QiwiApplication.get(context) .appComponent.inject(this) val clazz = BonusShowcaseActivity::class.java val startIntent = Intent(context, claszz).copyIntent(intent) return bonusShowcaseFeature.deepLinkData(DeepLinkData(startIntent, claszz)) } }
В обработчике проверяем фича флаг BonusShowcaseFeature и открываем экран, если флаг включен. Про наши фича флаги можно почитать в этом посте.
Аннотации
@Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.SOURCE) annotation class DeepLink( vararg val deepLinks: Uri, val description: Description = Description(), val commonParams: Array<Parameter> = [], val addToDoc: Boolean = true ) @Retention(AnnotationRetention.SOURCE) annotation class Uri( val scheme: String, val hostPath: String, val description: Description = Description(), val parameters: Array<Parameter> = [], val examples: Array<Example> = [], val addToDoc: Boolean = true ) @Retention(AnnotationRetention.SOURCE) annotation class Description( val description: String = "", val group: String = Groups.OTHER ) @Retention(AnnotationRetention.SOURCE) annotation class Parameter( val key: String = "", val value: String = "", val description: String = "" ) @Retention(AnnotationRetention.SOURCE) annotation class Example( val description: String, val parameters: Array<Parameter> = [] )
Сейчас в системе у нас пять аннотаций.
Deeplink — используется для описания одно или нескольких uri, также есть возможность добавить описание, общие параметры для всех uri и можно указать, попадёт ли данный диплинк в документацию.
Uri — позволяет установить диплинк(scheme://hostPath), дополнительно можно оставить описание, параметры, с которыми используется диплинк, и, если необходимо, добавить примеры.
Description— используется для описания. Можно указать группу, в которой содержится диплинк, это необходимо для сортировки в итоговой документации.
Parameter— аннотация задаёт query-параметры в формате key-value, в value описываем возможные значения.
Example — используется для примеров.
Аннотация Deeplink указывает, какой обработчик будет обрабатывать диплинк из аннотации Uri. Все обработчики являются наследниками класса DeepLinkHandler.
class DeepLinkData( val startIntent: Intent?, val activityClazz: Class<*>? ) abstract class DeepLinkHandler { var analytics: DeepLinkHandlerAnalytics? = null abstract fun deeplinkData(context: Context, intent: Intent): DeepLinkData fun process(context: Context, intent: Intent) { val deepLinkData = deeplinkData(context, intent) deepLinkData.startIntent?.let { startIntent -> context.startActivity(startIntent) } } }
Абстрактный класс, который умеет открывать активити по интенту и отправлять аналитику. При наследовании от этого класса нужно реализовать метод deeplinkData и в нём можно проводить обработку диплинка.
В случае, когда не нужно проводить проверку диплинка, мы используем ProxyDeeplinkHandler.
abstract class ProxyDeepLinkHandler : DeepLinkHandler() { protected abstract val activityClass: Class<*> override fun deeplinkData(context: Context, intent: Intent): DeepLinkData { val startIntent = Intent(context, activityClass).copyIntent(intent) return DeepLinkData(startIntent, activityClass) } }
При наследовании от этого класса в дочернем классе достаточно определить поле activityClass, который должен возвращать ту активити, которую нужно открыть.
Архитектура диплинков

Под капотом всё работает немного сложнее. Точкой входа у нас является SplashActivity. Она с помощью DeepLinkDelegate и остальных компонентов достаёт из реестра нужный обработчик для того диплинка, который пришёл в систему.
Манифест для SplashActivity выглядит следующим образом.
<activity android:name=".splashScreen.view.SplashScreenActivity" android:theme="@style/QiwiSplashScreenTheme"> <intent-filter> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <data android:scheme="qiwi" /> </intent-filter> <intent-filter> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <data android:scheme="http" /> <data android:scheme="https" /> <data android:host="qiwi.com" /> <data android:host="qiwi.ru" /> <data android:path="/main.action" /> ... <data android:pathPattern="/p/.*" /> <data android:path="/feed" /> </intent-filter> </activity>
В манифесте есть два интент-фильтра для диплинков. Первый для диплинков — со схемой qiwi, что позволяет открывать любые диплинки с этой схемой. Во втором интент-фильтр используется для url-диплинков. Мы редко добавляем url-диплинки, поэтому манифест практически не редактируем.
Кодогенерация
Во время кодогенерации происходит поиск всех классов, помеченных аннотацией Deeplink, потом с помощью Kotlin Poet создаются несколько DeeplinkRegistry и json-файл с описанием диплинков для документации.
DeeplinkRegistry — это интерфейс с методом registry(), возвращающий мапу, где ключ - диплинк, а значение — класс обработчика.
Пример сгенерированного DeeplinkRegistry для диплинков uri:
public class OuterDeepLinkRegistryGenerated : DeepLinkRegistry { public override fun registry(): Map<String, Class<out Any>> { val map = mutableMapOf<String, Class<out Any>>() map["qiwi://bonus"] = BonusHandler::class.java map["qiwi://main.action"] = MainHandler::class.java map["qiwi://cards/detail"] = CardDetailActivityHandler::class.java ... map["qiwi://help"] = ProfileActivityHandler::class.java return map } }
Json-файл с описанием диплинков выглядит так:
{ "qiwi://bonus": { "description": "открыть раздел бонус(кэшбек)" }, "qiwi://qvc/order": { "group": "QIWI-карты", "description": "заказ qvc или qvc_mir карты", "parameters": [ { "name": "alias", "value": "qvc | qvc-mir" } ] }, "qiwi://cards/detail": { "group": "QIWI-карты", "description": "детали карты", "parameters": [ { "name": "id", "value": "*", "description": "id карты" } ] } }
Из этого файла создается документация.
Документация — это большая таблица, в которой перечислены все диплинки с описанием и параметрами из json-файла.

Таблица всегда актуальная, так как выгрузка json-файла встроена в релизный пайплайн. С помощью таски файл загружается на статик-сервер. А в конфлюенсе настроена таблица, которая подгружает и парсит json-файл.
Таблица в первую очередь полезна отделу маркетинга, так как позволяет быстро получить инфу по диплинкам.
В системе диплинков мы добавили мониторинг и аналитику. И получаем события о необработанных диплинках. Алерты настроены на почту и в телеграм-канал, что позволяет быстро реагировать на инциденты.
Проблему с безопасностью в WebView, о которой писал выше, мы решили следующим образом: в обработчике для активити с WebView проверяем по белому списку урл, который приходит с диплинком, если урл содержится в списке, то открываем веб-страницу.
Итоги
Упростили работу с диплинками, например, в обработчике проверяем фича флаг, проводим валидацию параметров.
Повысили безопасность активити с WebView, завели whitelist, по которому определяется, будет ли открыта веб-страничка или нет, если приходит диплинк с урлом не из белого списка, то кидаем событие в мониторинг.
Добавили мониторинг в систему диплинков.
Улучшили жизнь специалистам из маркетинга, они получили всегда актуальную таблицу с диплинками.
Отловили все устаревшие и кривые диплинки, которые использовались в промо-материалах.
