We need to go deeper: диплинки и кодогенерация

Привет! Мы написали свою систему диплинков на основе кодогенерации. В этой статье поговорим, как мы упростили работу с диплинками и смогли отловить устаревшие, добавили мониторинг и как собрали все диплинки в одной статье в конфлюенсе.

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

d7a1067a67a3b3b300148f3728f286ed.jpg

Самая большая проблема — это проблема безопасности. Объясню на примере активити, которая открывает веб-страницы. Активити открывается по диплинку, в нём указывается 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 = [],
   val addToDoc: Boolean = true
)

@Retention(AnnotationRetention.SOURCE)
annotation class Uri(
   val scheme: String,
   val hostPath: String,
   val description: Description = Description(),
   val parameters: Array = [],
   val examples: Array = [],
   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 = []
)

Сейчас в системе у нас пять аннотаций. 

  1. Deeplink — используется для описания одно или нескольких uri, также есть возможность добавить описание, общие параметры для всех uri и можно указать, попадёт ли данный диплинк в документацию.

  2. Uri — позволяет установить диплинк (scheme://hostPath), дополнительно можно оставить описание, параметры, с которыми используется диплинк, и, если необходимо, добавить примеры.

  3. Description— используется для описания. Можно указать группу, в которой содержится диплинк, это необходимо для сортировки в итоговой документации. 

  4. Parameter— аннотация задаёт query-параметры в формате key-value, в value описываем возможные значения. 

  5. 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, который должен возвращать ту активити, которую нужно открыть.

Архитектура диплинков

36a4782223d9daf38e9500b1121a2315.png

Под капотом всё работает немного сложнее. Точкой входа у нас является SplashActivity. Она с помощью DeepLinkDelegateи остальных компонентов достаёт из реестра нужный обработчик для того диплинка, который пришёл в систему. 

Манифест для SplashActivity выглядит следующим образом.


   
       

       
       

       
   
   
       

       
       

       
       

       
       

       
       ...
       
       
   

В манифесте есть два интент-фильтра для диплинков. Первый для диплинков — со схемой qiwi, что позволяет открывать любые диплинки с этой схемой. Во втором интент-фильтр используется для url-диплинков. Мы редко добавляем url-диплинки, поэтому манифест практически не редактируем.

Кодогенерация

Во время кодогенерации происходит поиск всех классов, помеченных аннотацией Deeplink, потом с помощью Kotlin Poet создаются несколько DeeplinkRegistry и json-файл с описанием диплинков для документации.

DeeplinkRegistry — это интерфейс с методом registry(), возвращающий мапу, где ключ — диплинк, а значение — класс обработчика.

Пример сгенерированного DeeplinkRegistry для диплинков uri:

public class OuterDeepLinkRegistryGenerated : DeepLinkRegistry {
   public override fun registry(): Map> {
       val map = mutableMapOf>()
       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-файла.

36dbf1942890fd37d4d71c6767e44b6c.png

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

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

В системе диплинков мы добавили мониторинг и аналитику. И получаем события о необработанных диплинках. Алерты настроены на почту и в телеграм-канал, что позволяет быстро реагировать на инциденты.

Проблему с безопасностью в WebView, о которой писал выше, мы решили следующим образом: в обработчике для активити с WebView проверяем по белому списку урл, который приходит с диплинком, если урл содержится в списке, то открываем веб-страницу.

Итоги

  1. Упростили работу с диплинками, например, в обработчике проверяем фича флаг, проводим валидацию параметров.

  2. Повысили безопасность активити с WebView, завели whitelist, по которому определяется, будет ли открыта веб-страничка или нет, если приходит диплинк с урлом не из белого списка, то кидаем событие в мониторинг.

  3. Добавили мониторинг в систему диплинков.

  4. Улучшили жизнь специалистам из маркетинга, они получили всегда актуальную таблицу с диплинками.

  5. Отловили все устаревшие и кривые диплинки, которые использовались в промо-материалах.

© Habrahabr.ru