НТЦ „Вулкан“
Компания
12,77
рейтинг
13 марта 2014 в 17:00

Разработка → «Шпионская» камера в 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.
Спасибо за внимание!
Автор: @bejibx
НТЦ „Вулкан“
рейтинг 12,77
Компания прекратила активность на сайте

Комментарии (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.
    Работает, звука «затвора» нет, срабатывает на разблокировке.

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

Самое читаемое Разработка