«Шпионская» камера в Android

    Привет, %username%! Сегодня я хочу поделиться опытом разработки одного приложения для Android и трудностями, с которыми пришлось столкнуться при не совсем честном использовании камеры.
    Идея приложения «Страж» жила внутри отдела разработки достаточно давно, но первая реализация появилась на платформе Symbian 2 года назад. Сама идея незамысловата – делать фотографии человека, взявшего телефон в руки. В первой реализации приложение было разделено на сигнальные модули и модули обратных вызовов. Сигнальные модули отвечали за регистрацию изменений определённого состояния телефона. Например: извлечение или установка SIM-карты или карты памяти, входящий или исходящий звонок, или совсем хитрые – главным сенсором был сенсор акселерометра, который определял момент поднятия телефона со стола. Модули обратных вызовов – это действия, которые выполняются по сигналам сенсоров. Были реализованы фотография и запись звука.
    При портировании приложения на платформу Android подход заметно поменялся. Да и вообще от старого приложения осталась только идея, оно перестало быть модульным, а из всего функционала остался только функционал фотографирования. О реализации этого функционала и хочется рассказать.

    Делаем фотографию


    Сначала приведу вольный перевод официальной документации, касающейся вопроса пользования камерой.
    • За фотографии в Android отвечает класс Camera.

    <uses-permission android:name="android.permission.CAMERA" />
    <uses-feature android:name="android.hardware.camera" />
    <uses-feature android:name="android.hardware.camera.autofocus" />
    

    Чтобы получить картинку нужно:
    1. Найти Id нужной камеры, используя методы getNumberOfCameras и getCameraInfo );
    2. Получить ссылку на объект камеры методом open .
    3. Получить текущие (по-умолчанию) настройки методом getParameters .
    4. При необходимости изменить параметры и установить их заново методом setParameters ;
    5. При необходимости установить ориентацию камеры методом setDisplayOrientation (НЕТ вертикальному видео!);
    6. ВАЖНО: Передать в метод setPreviewDisplay правильно инициализированный объект SurfaceHolder. Если этого не сделать, то камера не сможет начать превью.
    7. ВАЖНО: Вызвать метод startPreview ), который начнет обновлять SurfaceHolder. Вы ОБЯЗАНЫ начать превью перед тем как сделать снимок.
    8. Наконец-то вызвать метод takePicture и дождаться когда данные вернуться в onPictureTaken ;
    9. После вызова метода takePicture превью будет остановлено. Если нужно сделать еще фото, то придется вызвать startPreview снова;
    10. Если же камера больше не нужна, то сначала нужно остановить превью методом stopPreview;
    11. ВАЖНО: Вызвать метод release() чтобы освободить ресурсы камеры для других приложений. Приложение должно немедленно освобождать ресурсы камеры в методе onPause (и получать их обратно в методе onResume ).

    Данный класс не потокобезопасный. Большинство операций (превью, фокусировка, получение фото) асинхронны и возвращают результат через коллбэки, которые будут вызваны в том же потоке, в котором был вызван метод open. Методы данного класса ни в коем случае не должны вызываться сразу из нескольких потоков.
    Предупреждение: Разные устройства на ОС Android могут иметь разные возможности камеры (например, разрешение, возможность автофокусировки и т.п.).

    Здесь перевод заканчивается и начинается самое интересное.
    Из всего вышеперечисленного в глаза бросаются следующие проблемы:
    1. Надо показывать превью.
    2. На разных устройствах камера может работать по-разному.

    С ними-то мы и будем бороться.
    Когда возникает проблема из разряда «в доках написано, что так сделать нельзя», перво-наперво нужно заглянуть в исходники. Из них стало понятно, что прорисовка превью вынесена на уровень нативного кода setPreviewDisplay(Surface). Была принята попытка быстро разобраться в том, как вообще система определяет, стартовали мы превью или нет. Быстро пробраться через тернии C++ кода не получилось, поэтому я пошёл по пути наименьшего сопротивления — создал превью, но отобразил его незаметно для пользователя. Если поискать на stackoverflow, то можно найти другой способ – передавать в setPreviewDisplay SurfaceHolder, созданный динамически. А раз объект не добавлен в разметку Activity, то и отображаться он не будет. К сожалению, данный метод работает только для старых версий Android (до 3.0, если не ошибаюсь). В новых версиях разработчики исправили данное недоразумение.
    Таким образом, приходим к единственному выводу – мы должны так или иначе отобразить превью на экране, вопрос теперь только в том, можно ли сделать это незаметно? К счастью, ответ – «да, можно». И вот что для этого нужно:
    1. Прозрачная Activity.
    2. FrameLayout размером 1 на 1 пиксель в левом верхнем углу нашей Activity.

    Прозрачное Activity делается одной строчкой манифеста, для этого определим её так:
    <activity
    	android:name=".activities.CameraActivity"
    	android:exported="false"
    	android:launchMode="singleTask"
    	android:excludeFromRecents="true"
    	android:theme="@android:style/Theme.Translucent.NoTitleBar" />
    

    и создадим для нее следующую несложную разметку:
    <?xml version="1.0" encoding="utf-8"?>
    <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:id="@+id/surfaceHolder"
        android:layout_width="1.0px"
        android:layout_height="1.0px" />
    

    Объект SurfaceHolder создается и добавляется в разметку динамически. В принципе можно было добавить его сразу в разметку, данный момент был вынесен в код, чтобы не лезть в разметку при необходимости переопределить поведение объекта.
    Итак, прозрачное Activity есть, SurfaceHolder создаем динамически, что дальше? Дальше дело за главным – инициализировать камеру и сделать фото. Идея здесь в том, чтобы сделать фото сразу на старте Activity и закрыть её как можно быстрее. Определим нашу Activity так:
    public class CameraActivity extends Activity implements Camera.PictureCallback, SurfaceHolder.Callback
    {
        private static final int NO_FRONT_CAMERA = -1;
    
        private Camera mCamera;
        private boolean mPreviewIsRunning = false;
        private boolean mIsTakingPicture = false;
    
        public class CameraPreview extends SurfaceView
        {
            public CameraPreview(Context context)
            {
                super(context);
    
            }
        }
    	...
    


    Таким образом, в неё будут сыпаться события от SurfaceHolder’а (surfaceCreated, surfaceChanged, surfaceDestroyed) и Camera (onPictureTaken). Внутренний класс CameraPreview нужен исключительно для того, чтобы, как я отмечал выше, быстро и безболезненно внести правки в поведение нашего SurfaceView в случае необходимости. Далее приведу скопом методы Activity

    Немного кода
    @Override
        public void onCreate(Bundle savedInstanceState)
        {
            super.onCreate(savedInstanceState);
    
            setContentView(R.layout.surface_holder);
    
            SurfaceView surfaceView = new CameraPreview(this);
            ((FrameLayout) findViewById(R.id.surfaceHolder)).addView(surfaceView);
            SurfaceHolder holder = surfaceView.getHolder();
            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB)
                holder.setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS);
            holder.addCallback(this);
        }
    
    @Override
        protected void onResume()
        {
            startPreview();
            super.onResume();
        }
    
        @Override
        protected void onPause()
        {
            stopPreview();
            super.onPause();
        }
    
        @Override
        public void surfaceCreated(SurfaceHolder surfaceHolder)
        {
            final int cameraId = getFrontCameraId();
            if (cameraId != NO_FRONT_CAMERA)
            {
                try
                {
                    mCamera = Camera.open(cameraId);
    
                    Camera.Parameters parameters = mCamera.getParameters();
                    if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT)
                        parameters.setRotation(270);
    
                    List<String> flashModes = parameters.getSupportedFlashModes();
                    if (flashModes != null && flashModes.contains(Camera.Parameters.FLASH_MODE_OFF))
                        parameters.setFlashMode(Camera.Parameters.FLASH_MODE_OFF);
    
                    List<String> whiteBalance = parameters.getSupportedWhiteBalance();
                    if (whiteBalance != null && whiteBalance.contains(Camera.Parameters.WHITE_BALANCE_AUTO))
                        parameters.setWhiteBalance(Camera.Parameters.WHITE_BALANCE_AUTO);
    
                    List<String> focusModes = parameters.getSupportedFocusModes();
                    if (focusModes != null && focusModes.contains(Camera.Parameters.FOCUS_MODE_AUTO))
                        parameters.setFocusMode(Camera.Parameters.FOCUS_MODE_AUTO);
    
                    List<Camera.Size> sizes = parameters.getSupportedPictureSizes();
                    if (sizes != null && sizes.size() > 0)
                    {
                        Camera.Size size = sizes.get(0);
                        parameters.setPictureSize(size.width, size.height);
                    }
    
                    List<Camera.Size> previewSizes = parameters.getSupportedPreviewSizes();
                    if (previewSizes != null)
                    {
                        Camera.Size previewSize = previewSizes.get(previewSizes.size() - 1);
                        parameters.setPreviewSize(previewSize.width, previewSize.height);
                    }
    
                    mCamera.setParameters(parameters);
    
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1)
                        mCamera.enableShutterSound(false);
                }
                catch (RuntimeException e)
                {
                    A.handleException(e, true);
                    finish();
                    return;
                }
            }
            else
            {
                Log.e(Value.LOG_TAG, "Could not find front-facing camera");
                finish();
                return;
            }
    
            try
            {
                mCamera.setPreviewDisplay(surfaceHolder);
            }
            catch (IOException ioe)
            {
                A.handleException(ioe, true);
                finish();
            }
        }
    
        @Override
        public void surfaceChanged(SurfaceHolder surfaceHolder, int format, int width, int height)
        {
            startPreview();
        }
    
        @Override
        public void surfaceDestroyed(SurfaceHolder surfaceHolder)
        {
            releaseCamera();
        }
    
        @Override
        public void onPictureTaken(byte[] bytes, Camera camera)
        {
            mIsTakingPicture = false;
            releaseCamera();
            //noinspection PrimitiveArrayArgumentToVariableArgMethod
            new SaveImageTask().execute(bytes);
            finish();
        }
    
        private int getFrontCameraId()
        {
            final int numberOfCameras = Camera.getNumberOfCameras();
            for (int i = 0; i < numberOfCameras; i++)
            {
                Camera.CameraInfo info = new Camera.CameraInfo();
                Camera.getCameraInfo(i, info);
                if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) return i;
            }
            return NO_FRONT_CAMERA;
        }
    
        private void startPreview()
        {
            if (!mPreviewIsRunning && mCamera != null)
            {
                try
                {
                    mCamera.startPreview();
                    mCamera.autoFocus(new Camera.AutoFocusCallback()
                    {
                        @Override
                        public void onAutoFocus(boolean b, Camera camera)
                        {
                            if (!mIsTakingPicture)
                            {
                                try
                                {
                                    mIsTakingPicture = true;
                                    mCamera.setPreviewCallback(null);
                                    mCamera.takePicture(null, null, CameraActivity.this);
                                }
                                catch (RuntimeException e)
                                {
                                    A.handleException(e, true);
                                    finish();
                                }
                            }
                        }
                    });
                    mPreviewIsRunning = true;
                }
                catch (Exception e)
                {
                    A.handleException(e, true);
                    finish();
                }
            }
        }
    
        private void stopPreview()
        {
            if (!mIsTakingPicture && mPreviewIsRunning && mCamera != null) {
                mCamera.stopPreview();
                mPreviewIsRunning = false;
            }
        }
    
        private void releaseCamera()
        {
            if (mCamera != null)
            {
                mCamera.setPreviewCallback(null);
                mCamera.stopPreview();
                mCamera.release();
                mCamera = null;
            }
        }
    


    Что интересного в данном коде? Распишу по пунктам.
    1. Самое важное – порядок вызова методов. В документации говорится, что нужно вызвать и в каком порядке, но не указывается когда именно. Например, метод setPreviewDisplay. Если инициализировать камеру и вызвать этот метод сразу в onCreate или в onResume, то фото сделать не получится. Тогда откуда узнать, когда нужно вызывать этот метод? Правильный ответ – из комментариев к методу setPreviewDisplay в исходниках. Вот небольшая выдержка оттуда:
      The android.view.SurfaceHolder must already contain a surface when this method is called. If you are usingandroid.view.SurfaceView, you will need to register a android.view.SurfaceHolder.Callback withandroid.view.SurfaceHolder.addCallback(android.view.SurfaceHolder.Callback) and wait forandroid.view.SurfaceHolder.Callback.surfaceCreated(android.view.SurfaceHolder) before calling setPreviewDisplay() or starting preview.
      This method must be called before startPreview().
    2. Второй момент связан с жизненным циклом объекта SurfaceHolder относительно Activity. Жизненный цикл Activity можно найти в документации, а вот с SurfaceHolder’ом всё непонятно, поэтому пришлось выяснять это опытным путём:

      onCreate(Bundle savedInstanceState)
      onResume()
      onPause()
      surfaceCreated(SurfaceHolder surfaceHolder)
      surfaceChanged(SurfaceHolder surfaceHolder, int format, int width, int height)
      onStop()
      surfaceDestroyed(SurfaceHolder surfaceHolder)
      

    3. Следующий интересный момент связан с порядком вызовов методов жизненного цикла Activity. Вы можете спросить: «Зачем нужны все эти проверки в духе if (mCamera != null) и переменные mPreviewIsRunning, mIsTakingPicture?». К сожалению, единственный ответ, который я могу дать в данном случае – так надо. И дело тут в том, что в некоторых ситуациях порядок вызовов методов жизненного цикла Activity может отличаться от указанного в официальных доках (от вот этой диаграммы, например ). В основном казусы происходят, когда на телефоне включена блокировка экрана. У меня бывали случаи, когда метод onStop вызывался два раза подряд, а после этого, минуя onStart, как ни в чём не бывало, вызывался onResume. При этом порядок вызова методов может отличаться на разных аппаратах, даже не смотря на одну и ту же версию Android на борту. Я долго пытался в этом разобраться, понять, почему это происходит. В результате только потратил на это кучу времени и написал текущую реализацию.

    Итак, настало время немного обобщить происходящее. Вот что происходит в приложении:
    1. Стартуем Activity на нужное событие (в моем случае — на включение экрана).
    2. В onCreate создаем SurfaceHolder и регистрируем Activity для получения коллбэков.
    3. Ждем вызова surfaceCreated и в нём инициализируем камеру.
    4. После того, как камера инициализирована, пытаемся вызвать takePicture. Поскольку порядок вызова методов сильно зависит от аппарата, версии ОС и типа блокировки экрана, пытаемся в методах onResume| surfaceChanged стартовать превью, а в onPause останавливать её. При этом onResume| onPause могут случиться как до, так и после surfaceCreated, поэтому везде проверяем камеру на «инициализированность».
    5. Метод surfaceChanged, согласно документации, гарантированно вызывается хотя бы раз после surfaceCreated, но теоретически может быть вызван еще сколько угодно раз в процессе получения фотографии. Добавляем переменную mPreviewIsRunning для того, чтобы ненароком не стартануть превью несколько раз. Стартуем превью, вызываем takePicture, ждём.
    6. Ловим фотографию в onPictureTaken. Освобождаем камеру, создаем AsyncTask для сохранения картинки, закрываем Activity.

    Таким образом, общий порядок вызовов получается следующий:

    onCreate(Bundle savedInstanceState)
    onResume()
    onPause()
    surfaceCreated(SurfaceHolder surfaceHolder)
    surfaceChanged(SurfaceHolder surfaceHolder, int format, int width, int height)
    onPictureTaken(byte[] bytes, Camera camera)
    onStop()
    surfaceDestroyed(SurfaceHolder surfaceHolder)
    

    Заключение


    Приложение работает и стабильно делает фотки на моём телефоне (Nexus 4). Кроме него тестировал и на других моделях, в том числе Motorola Droid RAZR и HTС Sensation. Как я уже упоминал выше – на разных телефонах камеры работают по-разному. На некоторых телефонах, когда делается фото, слышен звук затвора. На других – фотография повернута не в ту сторону и исправляется это только редактированием EXIF’а. На некоторых телефонах и вовсе (я полагаю, из-за особенностей оболочки) порядок вызова методов жизненного цикла Activity может заметно отличаться. Связано всё это не только с огромным количеством производителей устройств на Android’е, но и с невероятной фрагментацией самой ОС (интересную заметку по этому поводу можно найти на 57 странице 1 номера журнала «Хакер» за 2014 год). Поэтому очень сильно хотелось бы:
    1. Добавить профили для разных моделей телефонов и делать фотографию с учетом этого профиля. Например, для телефонов, издающих звук затвора при фотографировании добавить мьют непосредственно перед фотографированием.
    2. Хорошенько погонять приложение на большом наборе тестовых моделек и попытаться понять причину различия в вызове методов Activity.
    3. Поглубже закопаться в исходники Android’а. Залезть, наконец, в нативную часть и разобраться, почему takePicture можно вызывать только после инициализации превью. Подумать, как еще можно с этим бороться.


    Это все вопрос развития в недалеком будущем.
    Сейчас же приложение доступно на Google.Play в текущей версии. Оно бесплатно, поскольку главной целью при его создании было исследование глубин Андроида. Для интересующихся ссылка на google.play.
    Спасибо за внимание!
    НТЦ „Вулкан“ 12,91
    Компания
    Поделиться публикацией
    Комментарии 37
    • +1
      Скрытую камеру милый котик спалил?
      • +1
        Да это же мой аватар!
      • +3
        А почему приложение не совместимо с Nexus 7 (который без задней камеры)? android 4.4.2
        • 0
          Да и на том, который с задней, тоже не поддерживается.
          • +1
            Просто совсем не тестировал приложение на планшетах, поэтому ограничился только мелкими и средними экранами в манифесте. Могу конечно убрать ограничение из манифеста и выложить новую версию, но за результат не ручаюсь.
            • 0
              Конечно уберите. Так хоть можно будет выявить проблемные устройства.
              У меня ни на одно из устройств не ставиться (Nexus 4/7).
              • +1
                Вы уверены? На мой Nexus 4 отлично ставится.
                В Google Play выводится «устройство не поддерживается»?

                Извините, не могу удержаться.
                • 0
                  Да. Выводится «устройство не поддерживается».
                • 0
                  Убрал, как сообщает google.play «появится в течении нескольких часов». Можно поподробнее про Nexus 4? Потому что основная часть тестирования проходила именно на нём, так что именно с этой моделью проблем быть не должно ни с установкой, ни с работой.
                  • 0
                    Полез глубже. Там выдает, что ограничено по стране регистрации.
                    • 0
                      Если не секрет, какая именно страна? В вашем профиле не нашел. Я ограничил список стран по языкам, на которые переведено приложение (русский, английский), но, кажется Вашу страну упустил.
                      • +1
                        Зачем такие странные ограничения по странам? Ведь на том же английском говорят практически во всех странах.
                        • 0
                          Потому, что тогда придется ставить английский языком по-умолчанию. В таком случае, у пользователей из Белоруссии или Украины, при условии что язык системы родной (то-есть Белорусский или Украинский), всё будет по-английски а не по-русски. Поскольку большинство пользователей всё таки из русскоговорящих стран я решил сделать языком по-умолчанию именно русский.
                          • 0
                            К сожалению, из-за таких ограничений приложение не доступно из Словакии (что актуально для меня) и из многих других стран…
                            • 0
                              Коллега предложил интересное решение. Поставлю язык по-умолчанию английский, а вместо переводов на украинский, белорусский и т.д. пихну русские ресурсы. И уберу ограничение.
                              • 0
                                Спасибо, решение действительно интересное, стоит взять на заметку. Будет интересно пощупать программу — надеюсь мой старичок (HTC EVO 3D) ее потянет.
            • +1
              Отличное приложение, спасибо! Уже пользуюсь :)
              Nexus 4 — без проблем.

              Можно попросить о паре плюх:
              1. Указывать папку хранения фотографий.
              1.1. Сделать возможность складировать фотки в папку дропбокса (если он установлен) — он будет автоматически синхронизировать папки и, если что, всегда будешь в курсе событий :). Вроде как у него есть апи для работы с файловой системой.
              • 0
                Дропбокс может и сам синхронизировать фотки в папку Camera uploads, вроде как со всех папок телефона
                • 0
                  Хм, действительно. Но он загружает все фото, которые есть :(
                • 0
                  Сделаю в следующей версии. К сожалению, не могу обещать что скоро.
                • +1
                  У вас в манифесте написано <uses-feature android:name="android.hardware.camera.autofocus" />. Я бы советовал исправить на <uses-feature android:name="android.hardware.camera.autofocus" android:required="false" /> иначе google play не даст поставить ваше приложение на телефоны с камерой без автофокуса.
                  • 0
                    Исправлю, спасибо за замечание.
                  • +1
                    Похоже, у вас на кнопке включения ON и OFF перепутаны.
                    • –1
                      Нет, на самом деле так и задумано. В данном случае надпись означает что будет сделано при нажатии на кнопку, а не текущее состояние. После Вашего замечания не уверен что это было оправданное решение (Вас ввело в замешательство), но когда делал мне такой вариант показался логичней.
                      • 0
                        В данном случае логичнее так, как привычней. То есть, как у всех, включая системные настройки:

                        image
                    • 0
                      Я подобное спомощью Tasker реализовал для себя, но с логикой следующего вида:
                      Если связь с блютус устройством утеряна (в моём случае это часы Sony SW2), телефон блокируется паролем.
                      Если в этом состоянии включается экран, то телефон делает два снимка один с передней камеры, а другой с задней.
                      По-скольку телефон заблокирован, то галерея (просмотр) не открывается.
                      Как только связь с часами восстанавливается, блокировка телефона снимается, а в галереи у меня есть снимки людей кто пытался разблокировать телефон.
                      • 0
                        В данном случае смысл был сделать фото именно незаметно. Более того, сами фотографии тоже хранятся незаметно, использую скрытую папку + файл .nomedia в ней, так что во встроенной галерее сделанные фотографии не посмотришь.
                      • 0
                        Есть более хитрый способ — можно попробовать показать всплывающее окно поверх интерфейса системы и задвинуть сам SurfaceView размером 1х1 за его пределы. Ваше решение с Activity имеет один минус — во время нахождения этой самой Activity на экране все нажатия на экран будут приходить в неё, а не в то, что под ней. Для пользователя это будет выглядеть так, будто всё зависло.
                        • 0
                          Немного не понял Вас, «всплывающее окно» — это Dialog? Касательно минуса Вы правы, на медленных телефонах действительно наблюдается такое.
                          • 0
                            Скорее всего имелось виду добавление View через windowmanager поверх любой запущенной acitvity
                            • 0
                              Попробовал, понравилось. Исчезла проблема с аппаратами, которые долго делают фото — touch events не улетают в прозрачную Activity, нет ощущения что телефон завис. Пока вижу одну проблему — Service, в отличие от Activity, может быть убит системой без предупреждения в любой момент, в таком случае могут случатся пролёты с некоторыми фотографиями. Почитаю внимательней про жизненный цикл сервисов. Если все устроит — перепишу под такой подход и обязательно обновлю статью.
                        • 0
                          THL W5 — работает, звука вроде не слышу.
                          правда снимает, похоже, не на включении, а на разблокировке.
                          • 0
                            С включенной разблокировкой могут быть проблемы. Дело в том, что на одних телефонах событие включения экрана прилетает сразу после нажатия на кнопку включения, тогда как на других после разблокировки. Также на моем Nexus 4 моя прозрачная Activity отображается поверх окна разблокировки, а на Motorola Droid RAZR коллеги нет. В общем, как я указал в статье, очень много нюансов, связанных с тем, что система ведет себя по-разному на разных устройствах. Надеюсь в будущем смогу найти универсальный вариант.
                            • 0
                              еще иногда действия и правда activity перехватывает, так как выглядит как новый лаг. обычно длительностью на старт GPS.
                              он что, опрашивается не ПОСЛЕ снимка?
                              • 0
                                Координаты GPS вообще не связаны с Activity, которая делает снимки. Она делает фото и кидает интент сервису, который уже ловит GPS.
                                • 0
                                  значит просто подлаг съёмки.
                          • 0
                            Xiaomi MI2A, родная оболочка MIUI.
                            Работает, звука «затвора» нет, срабатывает на разблокировке.

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

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