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

в 13:00, , рубрики: android, camera, Блог компании НТЦ „Вулкан“, Разработка под android, метки: ,

«Шпионская» камера в 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 можно вызывать только после инициализации превью. Подумать, как еще можно с этим бороться.

«Шпионская» камера в Android
Это все вопрос развития в недалеком будущем.
Сейчас же приложение доступно на Google.Play в текущей версии. Оно бесплатно, поскольку главной целью при его создании было исследование глубин Андроида. Для интересующихся ссылка на google.play.
Спасибо за внимание!

Автор: bejibx

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js