Kotlin
March 4

Настраиваем кросс-обновления Android-приложений между сторами

Меня зовут Тимофей, я Android-разработчик в Сравни. Давайте поговорим о кросс-обновлении Android-приложений без привязки к конкретному стору – так, чтобы пользователи могли устанавливать из одного источника, а обновлять – из другого, без необходимости удалять и ставить заново.

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

Но первые реальные практические шаги в этом направлении мы сделали в формате
“А что, так можно было?”: пошли выкладывать приложение в RuStore и попутно обнаружили возможности использовать аналогичные механизмы для настройки кросс-обновления.

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

Спойлер: чтобы всё это завелось, понадобится версия Android API level не ниже версии 28. Но давайте по порядку.

Что не так с подписью приложения

Центральный персонаж моей истории – подпись. Та самая, из криптографии, которая позволяет идентифицировать авторство программного кода. В случае Android-приложений нужна для публикации и верификации сборки (на всякий случай вспомним её адрес по прописке: AndroidStudio → Build → Generate Signed Bundle / APK).

С главным героем познакомились, теперь перейдём к квесту. Исходная постановка задачи звучала так.

Дано:

  • minSdk = 28;
  • приложение опубликовано в Google Play и App Gallery с разными подписями, но одинаковыми id (packageName).

Найти:

  • удобный способ выложить приложение в RuStore.

Таким образом, на тот момент у нас не было цели на настройку кросс-обновления между сторами (чтобы пользователи могли обновить скачанное ранее из Google Play приложение свеже выложенным из RuStore и наоборот). Но мечта была, в процессе решения задачи это сыграло свою роль; запишем для протокола.

Для распространения в App Gallery используется универсальный Android Package Kit (APK); функция подписания приложения не включена. Для распространения в Google Play используется Android App Bundle (AAB) с включенной функцией Play App Signing. То есть Google берёт наш AAB-файл и нарезает его для всех устройств на оптимизированные для них APK; при этом каждый такой APK подписывает неким ключом.

На первый взгляд всё выглядело просто: достаём из Google Play ключ, которым подписываются все итоговые APK; подписывем им релизный AAB; загружаем в RuStore; .…; profit.

Пробуем, не работает.

Оказывается, в RuStore в то время не было возможности загрузить AAB-файл. Позже такая опция появилась.
* на момент написания этого текста фича находится в бете, но для успеха всё равно нужна будет заветная подпись (об этом ниже).

Вчитываемся глубже в документацию: можно загрузить до 10 APK-файлов, чтобы работать с разными подписями и сервисами пушей. Звучит интересно! Пользуясь случаем – спасибо команде RuStore, что всё время были на связи и помогали разбираться!

Продолжаем вчитываться. Для успешного обновления нашего приложения без ошибок, необходимы два условия:

  1. одинаковый id приложения – подтверждение того, что приложение то же самое;
  2. одинаковая подпись – свидетельство того, что приложение создал тот же разработчик.

До этого момента подпись была для нас штукой, которую ты однажды сгенерировал в студии и потом благополучно забывал о её существовании. Что ж, похоже, настал момент познакомиться с ней поближе.

Открываем матчасть: существует четыре схемы подписей, каждая из которых является деревом Меркела (подробнее тут)… Закрываем матчасть, идём в собачьи парикмахеры думать, как всё сделать проще.

Разговор по душам с Google Play Console (мысленный)

В Google Play Console мы загружаем AAB-файл, подписанный нашим ключом. Читаем, что есть ключ подписи и загрузочный ключ. Как бы нам достать этот ключ подписи? На первый взгляд выглядит, что всё устроено так, чтобы его нельзя было вот так просто взять и заполучить.

При создании приложения и далее (например, для запуска тестирования) Google довольно навязчиво подталкивает разработчиков к тому, чтобы использовать сравнительно новую опцию подписания приложения. Оправдывают это возможностью уменьшить размер скачиваемых APK-файлов за счёт того, что APK оптимизированы под каждое устройство.

Ох уж эти ⚠️ – раздражают, ей-богу!

Все обычно соглашаются, ключ генерируется за пару секунд, и никто о нём не вспоминает до момента начала распространения приложения в других магазинах.

Мысленно у меня в голове состоялся примерно следующий разговор с Google Play Console (GPC):
Я: Но ведь ключ – наш! Почему мы не можем его достать?
GPC: Вам самим так будет лучше.
Я: Хотя бы пользоваться можно?
GPC: Да. Загрузите к нам AAB-файл, мы его подпишем вашим ключом и отдадим вам – универсальный APK или пачку нарезанных APK для всех конфигураций устройств, тут выбирайте сами.

Ок, новый план на выкладку в RuStore получается таким:

  1. собрать релизный AAB, подписанный загрузочным ключом;
  2. отправить в Google Play;
  3. скачать универсальный APK;
  4. отправить APK в RuStore.

На всякий случай вспоминаем про риски: а что, если Google забанит наше приложение у себя в сторе (из-за санкций или чего-то такого), они дадут нам возможность пользоваться нашим ключом, сохранённым у них на серверах?

GPC: Нет🤷

Тут нужно сделать оговорку: в явном виде мы не нашли прямого указания, что ключ использовать не получится. Поэтому на всякий случай ориентировались на худший сценарий.
Если вам доводилось сталкиваться с баном в сторе и вы из своего опыта знаете, как в этом случае обстоят дела с ключами – расскажите в комментариях?

Как бы там ни было, именно на этом этапе мы и поставили себе задачу со звёздочкой: завладеть ключами подписи, чтобы мы могли ими пользоваться и хранить самостоятельно.

Я: Эй, GPC, а можно поменять подпись?
GPC: Да, правда всего раз в год.

В разделе для подписи приложения может увидеть такую кнопку:

Но подождите! До этого мы читали в документации, что можно обновить приложение, только если у него совпадают подпись и айдишник с прошлой версией. Оказывается, начиная с Android 9, можно менять подпись один раз в год. Это возможно благодаря новой схеме подписания, которую не то, чтобы активно афишируют, но которая работает – для Android API level 24 и выше можно сменить подпись!

GPC: Мы можем сгенерировать для вас новый ключ и снова сохранить его у себя
Я: В чём профит?
GPC: Можете использовать другой ключ из этого аккаунта разработчика.
Я: Ага, но ведь все ключи в этом и других аккаунтах хранятся у тебя.
GPC: Можете загрузить свой, anyway.

Только посмотрите, что творится-то: всё, что нам нужно сделать для решения задачи со звёздочкой – заменить ключ в Google Play Console, при этом сохранив его у себя.

Ограничения: если мы заменим подпись, новая автоматически не раскатится по всем пользователям – у старых пользователей останется старая подпись. Неприятно, но не критично.

Чеклист: настройка кросс-обновления между сторами

С учётом всего сказанного выше, получаем уточнённую постановку задачи:

  • Хотим дать пользователям возможность – установить приложение из Google Play, а обновлять из другого стора; и наоборот.
  • Храним ключ подписи в Google Play и только там; ключ для нас сгенерировал Google.
  • Смирились, что в рамках данной схемы пока не можем предложить вариантов для пользователей с API 27 и ниже.

Решение:

  1. Записать актуальную версию кода приложения, выложенного в Google Play.
  2. Сгенерировать новые ключи для подписи всех версий всех наших приложений.
  3. Загрузить и заменить ключи в Google Play Console через функцию подписания приложения.
  4. Для каждого релиза в другой стор в Google Play Console загружать AAB-файл, подписанный ключом загрузки, и скачивать оттуда универсальный APK, подписанный специальным образом двумя ключами (старым ключом подписи и новым ключом подписи).
  5. Подождать, пока достаточное число пользователей перейдет на версию приложения с новой подписью.
  6. Загрузить новую подпись во все магазины и включить там подписание приложений.
  7. Перестать прогонять сборки через Google Play, напрямую загружать AAB во все альтернативные сторы.

А всё-таки что там с выкладкой в RuStore: нюансы и чеклист

Давайте восстановим историческую справедливость: к схеме кросс-обновления нас привела именно задача выкладки приложения в RuStore. Так что давайте пристальнее вглядимся в применение чеклиста кросс-обновления там. А заодно посмотрим на нюансы, которые могут возникнуть в процессе – проверим, где чеклисту могут понадобиться уточнения.

Можно было бы предположить, что RuStore сам подберёт нужную подпись для вашего приложения, но на деле всё работает не так (во всяком случае пока, ибо сейчас фича aab в RuStore находится в бэте). Если вы загрузите туда AAB с подписью X и APK с подписью Y, то при скачивании пользователем обновления стор не обратит внимание на подпись установленного приложения на устройстве – отдаст юзеру сконфигурированный для его процессора APK, подписанный подписью X.

Если не учитывать эту особенность, есть большая вероятность получить ошибку обновления (! запомним).

Таким образом, в случае RuStore чеклист для настройки кросс-обновления следует уточнить:

  1. Записать актуальную версию кода приложения, выложенного в Google Play (для примера, пусть будет 10.3.17).
  2. Сгенерировать новые ключи для подписи всех версий всех наших приложений – просто через Android Studio; важно: надёжно сохранить ключи, чтобы никто не смог их скомпрометировать.
  3. Загрузить и заменить ключи в Google Play через функцию подписания приложения: “Загрузить новый ключ подписи приложения из Java KeyStore”.
  4. При каждом следующем релизе в RuStore (начиная с 10.3.18), в Google Play Console загружать AAB-файл, подписанный ключом загрузки (то есть как обычно) и скачивать оттуда универсальный подписанный APK-файл.

Этот APK будет подписан специальным образом двумя ключами: вашим старым ключом, который хранится в Google Play Console и который использовался ранее при подписании, и вашим новым ключом (свеже созданным в Android Studio на шаге 2) с подтверждением смены подписи (proof-of-rotate). Для удобства деплоя мы сделали скрипт, который позволяет автоматизировать этот шаг – пишите в комментариях, если любопытно было бы взглянуть на него поближе.

Тут стоит учесть, что Google Play Console не позволит загрузить два разных файла с одинаковой версией кода. Так что если вы используете в RuStore адаптированную под него сборку, то придётся выбрать, у какой из сборок будет выше версия кода. Последствия выбора: если для сборки в RuStore версия выше, чем для Google Play, то пользователи RuStore всегда смогут мигрировать из Google Play.

  1. Подождать, пока достаточное количество пользователей перейдет на версию приложения 10.3.18 (с новой подписью) и выше.
  2. Загрузить в RuStore новую подпись (созданную на шаге 2), включить функцию подписания приложений.
  3. Перестать выполнять шаг 4 для каждой выкладки в RuStore и Google Play. Вместо этого при каждом релизе в RuStore загружать AAB-файл с подписью, созданной на шаге 2.

А что, так можно было?

Вот так и получилось – пошли решать задачу о публикации в RuStore, а наткнулись на схему для кросс-обновления приложений между сторами (Тут мог быть мем про “маршрут перестроен”). Этот сценарий пока что больше похож на workaround, чем на проторенный мейнстримный путь по публикации и дистрибуции приложений. Тем любопытнее разобраться, за счёт чего удалось обнаружить лазейку – как бы нам и в следующий раз увидеть новые возможности и воспользоваться ими.

Тут было много факторов: и исследовательский кураж (чтобы не останавливаться и продолжать перебирать варианты решения после неудач); и взгляд на пару шагов вперёд, на перспективу (чтобы попробовать выжать больше из свеже обнаруженных возможностей).

Главный же на мой взгляд вывод:

Не принимать за данность привычный расклад вещей.

Долгое время мы жили с мыслью, что обновить приложение можно, только если его подпись и айдишник совпадают с прошлой версией. И что подпись менять нельзя (да и зачем вообще о ней думать). При этом тогда уже существовала возможность подпись менять (пусть и раз в год).

Не всё работает так, как хотелось бы, но по-другому не бывает – на практике оказалось, что интересное случается в тот момент, когда перестаёшь так думать.

P.S. В статье указана версия API 28 и выше. Исходя из разных источников (в консоли написано API 24, в introduce к Android 9 (API 28) написано, что на API 27 и ниже это работать не будет) выбрана просто максимальная версия.

Источник