Android 12: история поддержки +6


Всем привет! Меня зовут Максим Новиков, я Android-разработчик в команде мобильного оператора Yota.


Совсем недавно вышла новая версия всеми нами любимой OS. Вот и пришло время нашему приложению поддержать её. Было очень много обзоров на новые возможности Android 12, а также изменений для разработчиков. Сегодня я расскажу про наш тернистый путь. Наливайте чай/кофе и готовьтесь к увлекательному погружению в мир Android.



До поддержки


Главный вопрос: сможет ли приложение работать без какой-либо адаптации? Берём приложение без какой-либо поддержки и устанавливаем на девайс с Android 12. В нашем случае это Pixel 3a XL.


Конкретно у нас появилась ошибка, связанная с получением параметров экрана через ApplicationContext. Кому интересно небольшое расследование, прошу под спойлер.


Разбор ошибки

Смотрим логи. Появилась новая ошибка:


E/ContextImpl: Tried to access visual service WindowManager from a non-visual Context:
ru.yota.android.appModule.application.YotaApplication@6cd68c3 WindowManager should be accessed from Activity or other visual Context.
Use an Activity or a Context created with Context#createWindowContext(int, Bundle), which are adjusted to the configuration and visual bounds of an area on screen.
    java.lang.IllegalAccessException: Tried to access visual service WindowManager from a non-visual Context:ru.yota.android.appModule.application.YotaApplication@6cd68c3
        at android.app.ContextImpl.getSystemService(ContextImpl.java:2059)
        at android.content.ContextWrapper.getSystemService(ContextWrapper.java:857)
        at com.splunk.mint.Utils.getScreenOrientation(Utils.java:63)
        at com.splunk.mint.Properties.initialize(Properties.java:121)
        at com.splunk.mint.Mint$1.run(Mint.java:103)
        at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
        at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
        at java.lang.Thread.run(Thread.java:920)

Смотрим на эту строчку 


com.splunk.mint.Utils.getScreenOrientation(Utils.java:63)

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


Проведя исследование кода библиотеки, указанного в логе, видим, что проблема в следующем:


Display display = ((WindowManager)gContext.getSystemService("window")).getDefaultDisplay()

Естественно, поскольку это выглядит как краш, было ощущение, что часть функционала библиотеки теперь не будет работать. Однако, если посмотреть сам код функци getSystemService в ContextImpl.java.



@Override
public Object getSystemService(String name) {
  if (vmIncorrectContextUseEnabled()) {
    // Check incorrect Context usage.
    if (WINDOW_SERVICE.equals(name) && !isUiContext()) {
      final String errorMessage = "Tried to access visual service "
      + SystemServiceRegistry.getSystemServiceClassName(name)
      + " from a non-visual Context:" + getOuterContext();
      final String message = "WindowManager should be accessed from Activity or other "
      + "visual Context. Use an Activity or a Context created with "
      + "Context#createWindowContext(int, Bundle), which are adjusted to "
      + "the configuration and visual bounds of an area on screen.";
      final Exception exception = new IllegalAccessException(errorMessage);
      StrictMode.onIncorrectContextUsed(message, exception);
      Log.e(TAG, errorMessage + " " + message, exception);
    }
  }
  return SystemServiceRegistry.getSystemService(this, name);
}

Увидим, что ошибка создаётся, отправляется в StrictMode и логируется, но при этом никак не влияет на создание WindowManager. Также сами протестируем работоспособность кода из библиотеки


val windowManager = applicationContext.getSystemService(Context.WINDOW_SERVICE) as WindowManager
val rotation = windowManager.defaultDisplay.rotation
Log.d("rotation = $rotation")

Всё работает.


Немного посмотрев документацию, видим, что начиная с Android 12 следует использовать Context#getDisplay(). А уже он приводит к падению, если мы используем его не из нужного контекста. 


Из всего этого делаем вывод, что теперь получить Display можно только из Visual Context, которым у нас является Activity.


Предварительный разбор закончен, давайте перейдём к самой сути.


Начало


Первый шаг для поддержки новой версии OS — это  поднятие compileSdkVersion и targetSdkVersion. В нашем случае до 31.


СompileSdkVersion отвечает за то, под какую версию компилируется ваш проект. От этого, например, зависят разные lint rules и inspections.

От targetSdkVersion зависит то, как сама OS будет реагировать на ваше приложение, делать какие-то обратные совместимости и так далее.

При компиляцие проекта видим ошибку, которая говорит нам, что необходимо явно проставить android:exported у Activity.


Manifest merger failed: android:exported needs to be explicitly specified for activity. Apps targeting Android 12 and higher are required to specify an explicit value for android:exported when the corresponding component has an intent filter defined. See https://developer.android.com/guide/topics/manifest/activity-element#exported for details.

Кроме activity такая же логика теперь работает для service и broadcast receiver у которых есть intent-filter.


Итак, собираем и устанавливаем приложение.


Получаем краш при старте приложения.


java.lang.IllegalArgumentException: ru.yota.android: Targeting S+ (version 31 and above) requires that one of FLAG_IMMUTABLE or FLAG_MUTABLE be specified when creating a PendingIntent.

Strongly consider using FLAG_IMMUTABLE, only use FLAG_MUTABLE if some functionality depends on the PendingIntent being mutable, e.g. if it needs to be used with inline replies or bubbles.

Причём PendingIntent не используется на старте приложения. Однако, запускаются отложенные действия при помощи WorkManager версии 2.5.0, в котором как раз используется PendingIntent, но новое изменение с флагами не поддержано.


Заходя в документацию видим следующее:



Обновляемся до 2.7.0. Запускаем приложение.


Оно запустилось, но теперь начинают сыпаться ANR.


Смотрим в логи, видим:


java.lang.RuntimeException: Unable to create service com.yandex.metrica.ConfigurationJobService: java.lang.IllegalArgumentException: ru.yota.android: Targeting S+ (version 31 and above) requires that one of FLAG_IMMUTABLE or FLAG_MUTABLE be specified when creating a PendingIntent.

Strongly consider using FLAG_IMMUTABLE, only use FLAG_MUTABLE if some functionality depends on the PendingIntent being mutable, e.g. if it needs to be used with inline replies or bubbles.

Делаем вывод, что Yandex Metrica создаёт PendingIntent без указания флагов мутабильности. Это связано с тем, что в версии 3.13.1 не было поддержки этих флагов.


Обновляемся до последней версии  — 4.0.0. 


Хотелось бы отметить, что эта библиотека была блокером на пути поддержки Android 12. Поскольку это обновление вышло не за долго до официального релиза OS. 


Заодно, проходимся текстовым поиском по “PendingIntent” и добавляем флаг PendingIntent.FLAG_IMMUTABLE. Из-за того, что у нашего приложения minsdk 22 необходимо прописывать:


if (VERSION.SDK_INT >= VERSION_CODES.M) {
 intent.flags = intent.flags.or(PendingIntent.FLAG_IMMUTABLE)
}

Ура! всё наконец-то заработало!



Теперь пройдёмся по списку изменений и посмотрим, что ещё нужно сделать в нашем приложении.


Deep links


Наткнулся на безобидный абзац не говорящий ничего конкретного:


Android App Links verification changes

On apps that target Android 12 or higher, the system makes several changes to how Android App Links are verified. These changes improve the reliability of the app-linking experience and provide more control to app developers and end users.

If you rely on Android App Link verification to open web links in your app, check that you use the correct format when you add intent filters for Android App Link verification. In particular, make sure that these intent filters include the BROWSABLE category and support the https scheme.

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


Теперь, если у вашего приложения не настроен auto verify ваших диплинков, при переходе по ним будет открываться ВСЕГДА браузер, без показа диалога выбора: приложение или браузер. 


Чтобы это изменить, пользователю необходимо идти в настройки


apps/Default Apps/Opening Links/выбор приложения.

Либо в информации о приложении открывать Open by default. И уже там добавить вашу схему диплинка в verified нажав Add Link и выбрав её:



После этого, по нажатию на диплинк вашего приложения будет открываться ВСЕГДА приложение, без какого-либо диалога.


Чтобы пользователю не приходилось проделывать все эти манипуляции (которые он, конечно же, скорее всего делать не будет), существует настройка auto verify.


В Android Studio встроен хороший инструмент, который очень упрощает нужную настройку. Находиться он в Tools -> App Links Assistant.



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


https://${host вашей deeplink}/.well-known/assetlinks.json

[
  {
    "relation": [
      "delegate_permission/common.handle_all_urls"
    ],
    "target": {
      "namespace": "android_app",
      "package_name": "${application id приложения}",
      "sha256_cert_fingerprints": [%${список  подписей вашего приложения во всех билдах} ]
    }
  }
]

И не забыть добавить параметр в ваш intent-filter:


<intent-filter
          android:autoVerify="true"
          tools:targetApi="m">

Когда ваше приложение будет установлено на девайс, через некоторое время запуститься процесс верификации. Но на практике она проходила сама и хватало просто проверки статуса приложения командой adb:


adb shell pm get-app-links ${ваш package name}

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


Splash screen


Когда мы запускаем приложение, происходит процесс загрузки ресурсов и классов. В этот момент ещё не произошла отрисовка контента первого Activity. Вместо него мы видим окно, которое раскрашено в тему Activity. Оно называется Splash Screen.


Раньше было 2 основных решения для splash screen:


  1. Делаем отдельное splash activity с темой и устанавливаем туда android:windowBackground;
  2. У стартовой activity устанавливаем также тему с android:windowBackground в manifest и меняем её до вызова super.onCreate в activity. Особенно популярно в Single Activity подходе, когда во всём приложении у нас только одно Activity.

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


У Activity появился getter, который возвращает интерфейс для настройки SplashView:


public final SplashScreen getSplashScreen()
public interface SplashScreen {
  void setOnExitAnimationListener(@NonNull SplashScreen.OnExitAnimationListener listener);
  void clearOnExitAnimationListener();
  void setSplashScreenTheme(int themeId);
  public interface OnExitAnimationListener {
    void onSplashScreenExit(@NonNull SplashScreenView view);
  }
}

Итак пытаемся запустить splash screen.


В целом всё нормально. Однако, при длительном запуске activity, происходит следующее:


drawing

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

Чтобы продлить показ Splash Screen Google рекомендует следующее решение и не забываем делать это только для Android 12 и выше.


решение от Google
// Create a new event for the activity.
override fun onCreate(savedInstanceState: Bundle?) {
  super.onCreate(savedInstanceState)
  // Set the layout for the content view.
  setContentView(R.layout.main_activity)

  if (VERSION.SDK_INT >= VERSION_CODES.S) {
    // Set up an OnPreDrawListener to the root view.
    val content: View = findViewById(android.R.id.content)
    content.viewTreeObserver.addOnPreDrawListener(
      object : ViewTreeObserver.OnPreDrawListener {
        override fun onPreDraw(): Boolean {
          // Check if the initial data is ready.
          return if (viewModel.isReady) {
            // The content is ready; start drawing.
            content.viewTreeObserver.removeOnPreDrawListener(this)
            true
          } else {
            // The content is not ready; suspend.
            false
          }
        }
      }
    )
  }
}

Или же чуть-чуть элегантнее:
// Create a new event for the activity.
override fun onCreate(savedInstanceState: Bundle?) {
  super.onCreate(savedInstanceState)
  // Set the layout for the content view.
  setContentView(R.layout.main_activity)

  if (VERSION.SDK_INT >= VERSION_CODES.S) {
    // Set up an OnPreDrawListener to the root view.
    val content: View = findViewById(android.R.id.content)
    val onPreDrawListener = OnPreDrawListener { false }
    content.viewTreeObserver.addOnPreDrawListener(onPreDrawListener)
    viewModel.isReadyState.observe(this) { isReady ->
      if (isReady) {
        content.viewTreeObserver.removeOnPreDrawListener(onPreDrawListener)
      }
    }
  }
}

Из минусов этого решения: при открытии приложения не с иконки, платформенный splash screen остаётся без лого.


Слева Android 11, справа Android 12


drawingdrawing


С временем отображения лого разобрались.


Увеличение лого в целом не критично, но перфекционист во мне стал негодовать и решил с этим разобраться.


Для этого версионируем нашу тему с помощью папки value-v31:


<style name="SplashAppTheme" parent="AppTheme">
    <item name="android:windowSplashScreenBackground">@color/white</item>
    <item name="android:windowSplashScreenAnimatedIcon">@drawable/bg_splash</item>
    <item name="android:windowSplashScreenAnimationDuration">1000</item>
  </style>

Теперь с помощью свойства windowSplashScreenAnimatedIcon можем менять размер лого. Соответственно, для этого добавляем версионирование для лого в drawable-v31.


Подробнее по свойствам можно почитать здесь.


Ровно в день, когда хотели публиковать статью, вышло обновление на Samsung. И увидели мы следующее:


drawing

Лого стало заметно меньше. Причём, если убрать фикс для размера, то всё возвращается в норму.

В итоге было выдвинуто предположение, что баг на стороне Google и Samsung при подготовке обновления поправили его. Поскольку количество пользователей с телефонами Samsung у нас гораздо больше, было принято решение убрать фикс размера для Pixel. 

С фиксами всё. Что ещё нового?

Для обратной совместимости анимаций появилась специальная библиотека. На момент написания статьи она находилась ещё в альфе

implementation 'androidx.core:core-splashscreen:1.0.0-alpha02'

Хотелось бы отметить, что добавили lint правило CustomSplashScreen, которое смотрит на ваши классы. Если в названии класса присутствует SplashActivity, то появляется ошибка:




Новая политика GEO разрешений


По Android 11 включительно запрос разрешения ACCESS_FINE_LOCATION выдавал только его одно:



Начиная с Android 12, пользователю будет выдан диалог на два разрешения: точное и приблизительно, даже если мы запрашивали только точное. 



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


Далее у нас открываются следующие пути действий:


  1. Запрашивать только приблизительное местоположение.
  2. Обрабатывать оба разрешения и подстраивать наш функционал в зависимости от того, какое разрешение дал пользователь;
  3. Запрашивать оба разрешения, если пользователь дал только приблизительное, запрашивать ешё раз.

Путь 1 будет достаточен, если, к примеру, вам необходимо определить город пользователя. Если же вам нужно показать какие-то точки в городе, можно использовать путь 2. Путь 3 нужен только узкоспециализированным приложениям, в которых важна точность. Например для такси или приложениям для безопасности детей.


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


В нашем проекте для работы с геопозицией используется FusedLocationProvider. Без особых деталей имплементация выглядела следующим образом:


имплементация
Раскрывающийся спойлер
 fun locationUpdates() {
    val locationProvider: FusedLocationProviderClient =
      LocationServices.getFusedLocationProviderClient(context)
    val locationRequest = LocationRequest.create().apply {
      priority = LocationRequest.PRIORITY_HIGH_ACCURACY
      interval = 5L * 1000L
      fastestInterval = 2000L
    }
    val locationCallback = object : LocationCallback() {
      override fun onLocationResult(locationResult: LocationResult?) {
        // handle new location here locationResult?.lastLocation
        super.onLocationResult(locationResult)
      }
    }
    locationProvider.requestLocationUpdates(
        locationRequest, locationCallback,
        Looper.getMainLooper()
    )
  }

В ходе проб и ошибок выяснил, что необходимо будет по разному конфигурировать создание LocationRequest в зависимости от того, какое разрешение было получено. Если было получено приблизительное местоположение, и ранее не была получена никакая локация, то при priority = LocationRequest.PRIORITY_HIGH_ACCURACY, ваше приложение не получит локацию пользователя. В этом случае нам необходимо либо
PRIORITY_BALANCED_POWER_ACCURACY(уровень квартала) либо PRIORITY_LOW_POWER (уровень города). Также желательно увеличивать интервалы получения координат. Более подробную информацию можно посмотреть здесь.


Что же нужно было для поддержки


  • обновить Work Manger до 2.7.0;
  • обновить App Metrica до 4.0;
  • добавить всем PendingIntent флаг мутабильности;
  • поддержать auto verify, добавить файл конфигурации на сайт и докрутить конфигурацию intent-filter;
  • добавить версионированную тему для сплеша;
  • выбрать свой путь работы с geo и следовать ему.

Заключение


По моим ощущениям Android 12 вышла интересной и приносящей много нового, как для пользователей, так и для разработчиков. Не обошлось без сложностей, как например со Splash screen. Также хотелось бы отметить, что наше приложение покрывает не абсолютно все кейсы. Например, нам не пришлось обрабатывать новый permission для точных будильников. Надеюсь эта статья поможет вам быстрее разобраться с обновлением и поддержкой изменений Android 12!




К сожалению, не доступен сервер mySQL