«Шпионская» камера в 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.
Спасибо за внимание!

ссылка на оригинал статьи http://habrahabr.ru/company/ntc-vulkan/blog/215693/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *