Как работает SystemUI в Android



    В этой статье я разберу архитектуру и принцип работы основного приложения Android — SystemUI. Меня заинтересовала эта тема, потому что мне интересно, как устроена система, которой пользуется такое огромное количество пользователей и для которой ежедневно выкатываются тысячи приложений в Google Play или просто на просторы интернета. Помимо этого меня интересует вопрос информационной безопасности Android и создаваемых под него приложений.

    В системе Android, SystemUI — это приложение, путь к исходному коду которого находится в platform_frameworks_base/packages/SystemUI/, на девайсе оно находится в system/priv-app/-SystemUI.

    priv-app — это каталог, где хранятся привилегированные приложения. К слову, по пути system/app лежат предустановленные приложения, а обычные приложения, которые мы устанавливаем на свой девайс самостоятельно, хранятся в data/app.

    Тут сразу возникает вопрос: почему нельзя засунуть все предустановленные и привилегированные приложения в один каталог, зачем нужно это разделение?

    Дело в том, что некоторые приложения более системные, чем другие:) И это разделение необходимо для того чтобы уменьшить покрытие эксплойтами системных приложений, для получения доступа к защищенным операциям. Можно создавать приложение, которое будет иметь специальный ApplicationInfo.FLAG_SYSTEM и в системе получит больше прав, однако apk файл с таким разрешением будет помещен в раздел system.

    Итак, SystemUI — это apk-файл, который по сути своей обычное приложение. Однако, если посмотреть на сложное устройство SystemUI, перестает казаться, что это всего лишь простое приложение, верно?

    Данное приложение выполняет весьма важные функции:


    • Навигация
    • Недавние приложения
    • Быстрые настройки
    • Панель уведомлений
    • Экран блокировки
    • Регулятор громкости
    • Главный экран
    • ...

    Запуск SystemUI


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

        <application
            android:name=".SystemUIApplication"
            android:persistent="true"
            android:allowClearUserData="false"
            android:allowBackup="false"
            android:hardwareAccelerated="true"
            android:label="@string/app_label"
            android:icon="@drawable/icon"
            android:process="com.android.systemui"
            android:supportsRtl="true"
            android:theme="@style/Theme.SystemUI"
            android:defaultToDeviceProtectedStorage="true"
            android:directBootAware="true"
            android:appComponentFactory="androidx.core.app.CoreComponentFactory">

    Если мы залезем в SystemServer, который является одним из двух столпов в мире Android (второй — Zygote, но об этом я расскажу как-нибудь в другой раз), то мы можешь найти место, где стартует SystemUI при загрузке системы.

     static final void startSystemUi(Context context, WindowManagerService windowManager) {
            Intent intent = new Intent();
            intent.setComponent(new ComponentName("com.android.systemui",
                        "com.android.systemui.SystemUIService"));
            intent.addFlags(Intent.FLAG_DEBUG_TRIAGED_MISSING);
            //Slog.d(TAG, "Starting service: " + intent);
            context.startServiceAsUser(intent, UserHandle.SYSTEM);
            windowManager.onSystemUiStarted();
        }

    Тут мы видим как запускается сервис SystemUI с помощью непубличного API startServiceAsUser. Если бы вы захотели использовать это, то вам пришлось бы обратиться к рефлексии. Но если вы решите использовать reflection API в Android — подумайте несколько раз, стоит ли это того. Подумайте раз сто:)

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

    public abstract class SystemUI implements SysUiServiceProvider {
        public Context mContext;
        public Map<Class<?>, Object> mComponents;
    
        public abstract void start();
    
        protected void onConfigurationChanged(Configuration newConfig) {
        }
    
        public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
        }
    
        protected void onBootCompleted() {
        }
    
        @SuppressWarnings("unchecked")
        public <T> T getComponent(Class<T> interfaceType) {
            return (T) (mComponents != null ? mComponents.get(interfaceType) : null);
        }
    
        public <T, C extends T> void putComponent(Class<T> interfaceType, C component) {
            if (mComponents != null) {
                mComponents.put(interfaceType, component);
            }
        }
    
        public static void overrideNotificationAppName(Context context, Notification.Builder n,
                boolean system) {
            final Bundle extras = new Bundle();
            String appName = system
                    ? context.getString(com.android.internal.R.string.notification_app_name_system)
                    : context.getString(com.android.internal.R.string.notification_app_name_settings);
            extras.putString(Notification.EXTRA_SUBSTITUTE_APP_NAME, appName);
    
            n.addExtras(extras);
        }
    }
    

    Метод start() вызывается для запуска каждой службы, которые перечислены ниже.

    <string-array name="config_systemUIServiceComponents" translatable="false">
            <item>com.android.systemui.Dependency</item>
            <item>com.android.systemui.util.NotificationChannels</item>
            <item>com.android.systemui.statusbar.CommandQueue$CommandQueueStart</item>
            <item>com.android.systemui.keyguard.KeyguardViewMediator</item>
            <item>com.android.systemui.recents.Recents</item>
            <item>com.android.systemui.volume.VolumeUI</item>
            <item>com.android.systemui.stackdivider.Divider</item>
            <item>com.android.systemui.SystemBars</item>
            <item>com.android.systemui.usb.StorageNotification</item>
            <item>com.android.systemui.power.PowerUI</item>
            <item>com.android.systemui.media.RingtonePlayer</item>
            <item>com.android.systemui.keyboard.KeyboardUI</item>
            <item>com.android.systemui.pip.PipUI</item>
            <item>com.android.systemui.shortcut.ShortcutKeyDispatcher</item>
            <item>@string/config_systemUIVendorServiceComponent</item>
            <item>com.android.systemui.util.leak.GarbageMonitor$Service</item>
            <item>com.android.systemui.LatencyTester</item>
            <item>com.android.systemui.globalactions.GlobalActionsComponent</item>
            <item>com.android.systemui.ScreenDecorations</item>
            <item>com.android.systemui.fingerprint.FingerprintDialogImpl</item>
            <item>com.android.systemui.SliceBroadcastRelayHandler</item>
        </string-array>

    Регулирование громкости


    Мы регулярно пользуемся кнопками громкости на своих устройствах, но не задумываемся какие процессы должны произойти в системе для того чтобы мы могли прибавить или убавить звук. Операция кажется довольно простой на словах, но если заглянуть в VolumeUI, который находится в подпапке SystenUI/volume, в разных режимах интерфейс имеет свою вариацию.


    Я уже говорила о том, что сервисы SystemUI запускаются методом start(). Если мы посмотрим на класс VolumeUI, то он тоже наследуется от SystemUI.

    public class VolumeUI extends SystemUI {
        private static final String TAG = "VolumeUI";
        private static boolean LOGD = Log.isLoggable(TAG, Log.DEBUG);
    
        private final Handler mHandler = new Handler();
    
        private boolean mEnabled;
        private VolumeDialogComponent mVolumeComponent;
    
        @Override
        public void start() {
            boolean enableVolumeUi = mContext.getResources().getBoolean(R.bool.enable_volume_ui);
            boolean enableSafetyWarning =
                mContext.getResources().getBoolean(R.bool.enable_safety_warning);
            mEnabled = enableVolumeUi || enableSafetyWarning;
            if (!mEnabled) return;
            mVolumeComponent = new VolumeDialogComponent(this, mContext, null);
            mVolumeComponent.setEnableDialogs(enableVolumeUi, enableSafetyWarning);
            putComponent(VolumeComponent.class, getVolumeComponent());
            setDefaultVolumeController();
        }
    …

    Тут мы видим что с помощью mEnabled мы определяем, следует ли нам показывать панель с настройкой звука. И судя по VolumeDialogComponent, VolumeUI отображает звуковую панель в виде диалога. Но все действия относительно нажатия на клавиши громкости обрабатываются в PhoneWindow.

     protected boolean onKeyDown(int featureId, int keyCode, KeyEvent event) {
         ...
            switch (keyCode) {
                case KeyEvent.KEYCODE_VOLUME_UP:
                case KeyEvent.KEYCODE_VOLUME_DOWN:
                case KeyEvent.KEYCODE_VOLUME_MUTE: {
                    // If we have a session send it the volume command, otherwise
                    // use the suggested stream.
                    if (mMediaController != null) {
                        mMediaController.dispatchVolumeButtonEventAsSystemService(event);
                    } else {
                        getMediaSessionManager().dispatchVolumeKeyEventAsSystemService(event,
                                mVolumeControlStreamType);
                    }
                    return true;
                }
                ...
    
       protected boolean onKeyUp(int featureId, int keyCode, KeyEvent event) {
            final KeyEvent.DispatcherState dispatcher =
                    mDecor != null ? mDecor.getKeyDispatcherState() : null;
            if (dispatcher != null) {
                dispatcher.handleUpEvent(event);
            }
            //Log.i(TAG, "Key up: repeat=" + event.getRepeatCount()
            //        + " flags=0x" + Integer.toHexString(event.getFlags()));
    
            switch (keyCode) {
                case KeyEvent.KEYCODE_VOLUME_UP:
                case KeyEvent.KEYCODE_VOLUME_DOWN: {
                    // If we have a session send it the volume command, otherwise
                    // use the suggested stream.
                    if (mMediaController != null) {
                        mMediaController.dispatchVolumeButtonEventAsSystemService(event);
                    } else {
                        getMediaSessionManager().dispatchVolumeKeyEventAsSystemService(
                                event, mVolumeControlStreamType);
                    }
                    return true;
                }
    …

    Насколько мы видим, KEYCODE_VOLUME_UP (+) не обрабатывается и перейдет в обработку KEYCODE_VOLUME_DOWN (-). И в обоих событиях, как в onKeyDown, так и в onKeyUp вызывается метод dispatchVolumeButtonEventAsSystemService.

     public void dispatchVolumeButtonEventAsSystemService(@NonNull KeyEvent keyEvent) {
            switch (keyEvent.getAction()) {
                case KeyEvent.ACTION_DOWN: {
                    int direction = 0;
                    switch (keyEvent.getKeyCode()) {
                        case KeyEvent.KEYCODE_VOLUME_UP:
                            direction = AudioManager.ADJUST_RAISE;
                            break;
                            ...
                        mSessionBinder.adjustVolume(mContext.getPackageName(), mCbStub, true, direction,
                   ...
                }
    

    Итак, тут у нас вызывается метод adjustVolume, для того чтобы мы могли проверить наш direction, которому будет присвоен параметр события.

    В итоге когда мы доберемся до AudioService, где будет вызван sendVolumeUpdate, где помимо вызова метода postVolumeChanged, будет установлен интерфейс HDMI.

     // UI update and Broadcast Intent
     protected void sendVolumeUpdate(int streamType, int oldIndex, int index, int flags) {
            ...
            mVolumeController.postVolumeChanged(streamType, flags);
        }
    
        private int updateFlagsForSystemAudio(int flags) {
           ...
                    if (mHdmiSystemAudioSupported &&
                            ((flags & AudioManager.FLAG_HDMI_SYSTEM_AUDIO_VOLUME) == 0)) {
                        flags &= ~AudioManager.FLAG_SHOW_UI;
                    }
               ...
            }
            return flags;
        }
    
         public void postVolumeChanged(int streamType, int flags) {
             ...
                    mController.volumeChanged(streamType, flags);
            ...
            }

    RingtonePlayer


    RingtonePlayer в Android выполняет роль проигрывателя. Он так же наследуется от SystemUI и в методе start() мы видим:

     @Override
        public void start() {
           ...
                mAudioService.setRingtonePlayer(mCallback);
          ...
        }

    Здесь у нас устанавливается mCallback, который по сути является экземпляром IRingtonePlayer.

    private IRingtonePlayer mCallback = new IRingtonePlayer.Stub() {
            @Override
            public void play(IBinder token, Uri uri, AudioAttributes aa, float volume, boolean looping)
                    throws RemoteException {
                ...
            }
    
            @Override
            public void stop(IBinder token) {
               ...
            }
    
            @Override
            public boolean isPlaying(IBinder token) {
                ...
            }
    
            @Override
            public void setPlaybackProperties(IBinder token, float volume, boolean looping) {
               ...
            }
    
            @Override
            public void playAsync(Uri uri, UserHandle user, boolean looping, AudioAttributes aa) {
               ...
            }
    
            @Override
            public void stopAsync() {
               ...
            }
    
            @Override
            public String getTitle(Uri uri) {
                ...
            }
    
            @Override
            public ParcelFileDescriptor openRingtone(Uri uri) {
                ...
            }
        };

    В итоге можно управлять RingtonePlayerService с помощью Binder для воспроизведения звуковых файлов.

    PowerUI


    PowerUI отвечает за управление питанием и уведомлениями. Аналогично наследуется от SystemUI и имеет метод start().

    public void start() {
            mPowerManager = (PowerManager) mContext.getSystemService(Context.POWER_SERVICE);
            mHardwarePropertiesManager = (HardwarePropertiesManager)
                    mContext.getSystemService(Context.HARDWARE_PROPERTIES_SERVICE);
            mScreenOffTime = mPowerManager.isScreenOn() ? -1 : SystemClock.elapsedRealtime();
            mWarnings = Dependency.get(WarningsUI.class);
            mEnhancedEstimates = Dependency.get(EnhancedEstimates.class);
            mLastConfiguration.setTo(mContext.getResources().getConfiguration());
    
            ContentObserver obs = new ContentObserver(mHandler) {
                @Override
                public void onChange(boolean selfChange) {
                    updateBatteryWarningLevels();
                }
            };
            final ContentResolver resolver = mContext.getContentResolver();
            resolver.registerContentObserver(Settings.Global.getUriFor(
                    Settings.Global.LOW_POWER_MODE_TRIGGER_LEVEL),
                    false, obs, UserHandle.USER_ALL);
            updateBatteryWarningLevels();
            mReceiver.init();
    
            showThermalShutdownDialog();
    
            initTemperatureWarning();
        }

    Как мы видим из приведенного выше кода, происодит подписка на изменения Settings.Global.LOW_POWER_MODE_TRIGGER_LEVEL, а после — вызов mReceiver.init().

     public void init() {
                // Register for Intent broadcasts for...
                IntentFilter filter = new IntentFilter();
                filter.addAction(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED);
                filter.addAction(Intent.ACTION_BATTERY_CHANGED);
                filter.addAction(Intent.ACTION_SCREEN_OFF);
                filter.addAction(Intent.ACTION_SCREEN_ON);
                filter.addAction(Intent.ACTION_USER_SWITCHED);
                mContext.registerReceiver(this, filter, null, mHandler);
            }

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

    Задачи


    Recents — это основная и часто используемая функция в мобильных устройствах на базе Android.

    Главные функции:


    • Отображение всех задач
    • Переключение между задачами
    • Удаление задач


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


    А в с помощью RecentTaskInfo мы можем получить информацию о конкретной задаче.

    public static class RecentTaskInfo implements Parcelable {
          
            public int id;
            public int persistentId;
            public Intent baseIntent;
            public ComponentName origActivity;
            public ComponentName realActivity;
            public CharSequence description;
            public int stackId;
            ...

    Вообще, запущенные задачи можно вынести в отдельную тему. Я изучила ее со всех сторон, так как хотела размывать экран приложения перед переходом приложения в background, чтобы в RecentsTask отображалась нечитаемая версия снапшота. Однако, проблема заключается в том, что снапшот приложения берется раньше, чем вызывается onPause(). Эту проблему можно решить несколькими способами. Либо выставлять флаг, чтобы система просто скрывала содержимое экрана с помощью

    getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE);

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

    Можно вообще сделать так, чтобы конкретная activity приложения не отображалось в задачах, проставив в манифесте

    android:excludeFromRecents = "true"

    Либо можно воспользоваться хитростью с помощью

    Intent.FLAG_ACTIVITY_MULTIPLE_TASK

    Можно задать основной активности выше приведенный флаг excludeFromRecents = true, для того чтобы ее экран отсутствовал в запущенных задачах, но во время загрузки приложения запустить отдельную задачу, которая будет показывать либо размытый скриншот с основной активности, либо любое другое изображение. Более подробно, как это можно сделать описано в официальной документации на примере Google Drive.

    Экран блокировки


    Keyguard уже посложнее всех вышеприведенных модулей. Он представляет из себя сервис, который запускается в SystemUI, а управляется при помощи KeyguardViewMediator.

    private void setupLocked() {
            ...
    
            // Assume keyguard is showing (unless it's disabled) until we know for sure, unless Keyguard
            // is disabled.
            if (mContext.getResources().getBoolean(
                    com.android.keyguard.R.bool.config_enableKeyguardService)) {
                setShowingLocked(!shouldWaitForProvisioning()
                        && !mLockPatternUtils.isLockScreenDisabled(
                                KeyguardUpdateMonitor.getCurrentUser()),
                        mAodShowing, mSecondaryDisplayShowing, true /* forceCallbacks */);
            } else {
                // The system's keyguard is disabled or missing.
                setShowingLocked(false, mAodShowing, mSecondaryDisplayShowing, true);
            }
    
           ...
    
            mLockSounds = new SoundPool(1, AudioManager.STREAM_SYSTEM, 0);
            String soundPath = Settings.Global.getString(cr, Settings.Global.LOCK_SOUND);
            if (soundPath != null) {
                mLockSoundId = mLockSounds.load(soundPath, 1);
            }
           ...
           
            int lockSoundDefaultAttenuation = mContext.getResources().getInteger(
                    com.android.internal.R.integer.config_lockSoundVolumeDb);
            mLockSoundVolume = (float)Math.pow(10, (float)lockSoundDefaultAttenuation/20);
    
            ...
        }

    Однако на самом деле KeyguardService самостоятельно не работает с интерфейсом экрана блокировки, он лишь передает информацию в модуль StatusBar, где уже и производятся действия относительно визуального вида экрана и отображения информации.

    Панель уведомлений


    SystemBars имеет довольно сложное устройство и структуру. Его работа разделяется на два этапа:
    1. Инициализация SystemBars
    2. Отображение уведомлений

    Если посмотреть на запуск SystemBars

    private void createStatusBarFromConfig() {
            ...
            final String clsName = mContext.getString(R.string.config_statusBarComponent);
            ...
                cls = mContext.getClassLoader().loadClass(clsName);
            ...
                mStatusBar = (SystemUI) cls.newInstance();
            ...
        }

    То мы видим ссылку на ресурс из которого читается имя класса и создается его экземпляр.

    <string name="config_statusBarComponent" translatable="false">com.android.systemui.statusbar.phone.StatusBar</string>

    Таким образом мы видим что тут вызывается StatusBar, который будет работать с выводом уведомлений и UI.

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

    P.S. Подбор материала и более короткие статьи я всегда выставляю на @miproblema в телеграм.
    Поделиться публикацией

    Комментарии 10

      +2
      Спасибо, хорошая статья! Продолжайте описывать больше внутренностей этой ОС
        +1
        Спасибо! Интересная статья. Сам тоже копал SystemUI в целях модификации но не настолько глубоко- нужна была прошивка-киоск.

        Я правильно понял, что здесь описывается AOSP?
        Хотелось бы добавить, что вендоры часто модифицируют SystemUI. Даже если прошивка внешне кажется чистым андроидом. В случае оболочек иногда он переработан более чем на половину. И тогда, если нет исходников, понять как он работает поможет только реверс-инжиниринг
          +1
          Да, AOSP. На скринах вообще samsung experience. Я покопалась и в кастомных прошивках, по факту они очень похожи, но тема требует более подробного изучения, чтобы я могла говорить что-то наверняка:)

          Очень сильно перерабатываются и облегчаются как правило прошивки от всяких дешевых брендов или вообще китайских ноунеймов.
            0
            На скринах вообще samsung experience
            Потому и спросил. Первая мысль- «откуда сырцы?»)

            как правило прошивки от всяких дешевых брендов
            Не соглашусь. Какой смысл ноунеймам тратить на это ресурсы сегодня? Добавят 1-2 кнопки в навбар и готово. SystemUI сам по себе не тяжелый.

            Другое дело бренды первого и второго эшелона- всякие защиты, темы, жесты, аналитика, дополнительные кнопки в шторке и навбаре… Что внутри SystemUI Flyme OS или MiUI даже представить страшно
              0
              Что внутри SystemUI Flyme OS или MiUI даже представить страшно
              Очевидно, баги и отсутствие совместимости с обычной версией)

              miproblema спасибо за статьи, очень интересно читать
          +1
          Автор, делай больше статей по этой теме!
            0
            Круто. Даже интересно. Получается освновные обновления андроида — это обновления SystemUI?
              0
              Здорово. С удовольствием почитал бы и про то, как работают фоновые процессы. Например, что такое Running services в Developer options. Или как у приложения Facebook получалось отжирать батарейку не будучи запущенным. Что вообще приложениям можно делать в фоне, как часто и как много. Был бы прям мега благодарен за такой экскурс.
                0
                Отключал фоновые процессы на 8.1, но так и не нашёл, что каждый час отсылает в сеть 100-3000 Кб.
                0

                Спасибо вам за статью!
                Небольшой вопрос: не знаете, как Google умеет в рантайме модифицировать Recents UI?
                Судя по картинке с моего планшета, оно как-то инжектит виджет из google quick search box (см. на надпись "Не удалось добавить виджет"), но каким образом?

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

                Самое читаемое