11.3 Fotos und Video
Mit Intents können Komponenten unterschiedlicher Apps kombiniert werden. Die Nutzung vorhandener Bausteine hat für Sie als Entwickler den Vorteil, das Rad nicht neu erfinden zu müssen. Und der Anwender findet sich schneller zurecht, weil er die Bedienung einer wiederverwendeten Komponente bereits kennt.
11.3.1 Vorhandene Funktionen nutzen
Wie leicht Sie die in Android eingebaute Kamera-App nutzen können, zeige ich Ihnen anhand des Programms KameraDemo1. Nach dem Start sehen Sie die Benutzeroberfläche aus Abbildung 11.7.
Klicken Sie auf FOTO AUFNEHMEN, um die Kamera-App im Still-Image-Modus zu betreiben. Diesen sehen Sie in Abbildung 11.8. Mit VIDEO AUFNEHMEN drehen Sie Filme. Zwischen dem Videomodus, der in Abbildung 11.9 dargestellt ist, und dem Fotomodus kann der Anwender jederzeit umschalten. Beachten Sie in diesem Zusammenhang, dass die Benutzeroberfläche auf unterschiedlichen Geräten zum Teil stark variiert.
Die Klasse KameraDemo1Activity ist sehr kurz. Das Starten der Kamera-App findet in zwei Implementierungen von onClick() statt (der Methodenname ist durch die Verwendung eines Lambdas nicht zu sehen). Wie Sie aus vielen anderen Beispielen in diesem Buch bereits wissen, werden android.view.View.OnClickListener-Instanzen verwendet, um auf das Anklicken von Buttons zu reagieren.
Die Klasse android.provider.MediaStore gestattet den Zugriff auf die Mediendatenbank von Android-Geräten. Für uns sind im Moment vor allem die beiden Konstanten INTENT_ACTION_STILL_IMAGE_CAMERA und INTENT_ACTION_VIDEO_CAMERA interessant, denn sie werden als Actions für Intents verwendet, um die Kamera-App mit startActivity() zu starten.
package com.thomaskuenneth.androidbuch.kamerademo1
import android.content.Intent
import android.os.Bundle
import android.provider.MediaStore
import androidx.appcompat.app.AppCompatActivity
import kotlinx.android.synthetic.main.activity_main.*
class KameraDemo1Activity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
foto.setOnClickListener {
val intent = Intent(
MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA)
startActivity(intent)
}
video.setOnClickListener {
val intent = Intent(
MediaStore.INTENT_ACTION_VIDEO_CAMERA)
startActivity(intent)
}
}
}
Eine auf diese Weise gestartete Kamera-App sendet allerdings keine Daten an den Aufrufer. Die beiden Intents INTENT_ACTION_STILL_IMAGE_CAMERA und INTENT_ACTION_VIDEO_CAMERA eignen sich deshalb vor allem für Fire-and-forget-Szenarien. Damit ist gemeint, dass Sie dem Anwender Ihrer App zwar den Komfort bieten, ein Foto oder Video aufzunehmen, aber die auf diese Weise entstandenen Dateien nicht unmittelbar integrieren oder weiterverarbeiten. Wie Sie das bewerkstelligen, möchte ich Ihnen nun zeigen.
Aufgenommene Fotos weiterverarbeiten
Um Daten von der Kamera-App in Ihrem Programm zu verwenden, rufen Sie startActivityForResult() auf. Das gesendete Intent enthält die Action ACTION_IMAGE_CAPTURE. Außerdem müssen Sie einen Uniform Resource Identifier (URI) übergeben, der das aufgenommene Foto in der Mediendatenbank repräsentiert. Wie das funktioniert, zeigt das Projekt KameraDemo2. Nach dem Auslösen in der Kamera-App erscheinen mehrere Buttons (Abbildung 11.10). Mit verwerfen Sie die Aufnahme und beginnen von vorne. bringt Sie zu KameraDemo2 zurück. Dort wird das aufgenommene Foto angezeigt (Abbildung 11.11). Mit brechen Sie die Aufnahme ab.
KameraDemo2Activity überschreibt die vier Methoden onCreate(), onStart(), onRequestPermissionsResult() und onActivityResult(). In onCreate() wird nur die Benutzeroberfläche angezeigt und ein OnClickListener registriert. Ein Klick auf Foto aufnehmen ruft startCamera() auf. Diese erzeugt mit contentResolver.insert() einen neuen Eintrag in der systemweiten Mediendatenbank und feuert anschließend ein Intent mit der Action ACTION_IMAGE_CAPTURE. Titel, Beschreibung und der MIME-Type werden als ContentValues-Objekt übergeben. Dieses wird durch entsprechende put()-Aufrufe gefüllt. Den URI des angelegten Datensatzes erhält das Intent mittels putExtra().
Damit KameraDemo2 funktioniert, muss mit WRITE_EXTERNAL_STORAGE der Zugriff auf externe Medien angefordert werden. Da es sich hierbei um eine gefährliche Berechtigung handelt, ist neben dem obligatorischen Eintrag in der Manifestdatei auch die Behandlung zur Laufzeit nötig. In onStart() wird mit checkSelfPermission() geprüft, ob die App auf externe Medien schreiben darf. Falls nicht, wird die Berechtigung angefordert. Bis sie erteilt wurde, kann Foto aufnehmen nicht angeklickt werden.
package com.thomaskuenneth.androidbuch.kamerademo2
import android.Manifest
import android.app.Activity
import android.content.*
import android.content.pm.PackageManager
import android.graphics.*
import android.net.Uri
import android.os.Bundle
import android.provider.MediaStore
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import kotlinx.android.synthetic.main.activity_main.*
private const val REQUEST_WRITE_EXTERNAL_STORAGE = 123
private const val REQUEST_IMAGE_CAPTURE = 1
private val TAG = KameraDemo2Activity::class.simpleName
class KameraDemo2Activity : AppCompatActivity() {
private lateinit var imageUri: Uri
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
button.setOnClickListener { startCamera() }
}
override fun onStart() {
super.onStart()
if (checkSelfPermission(
Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(
Manifest.permission.WRITE_EXTERNAL_STORAGE),
REQUEST_WRITE_EXTERNAL_STORAGE)
button.isEnabled = false
} else {
button.isEnabled = true
}
}
override fun onRequestPermissionsResult(requestCode: Int,
permissions: Array<String>,
grantResults: IntArray) {
if (requestCode ==
REQUEST_WRITE_EXTERNAL_STORAGE &&
grantResults.isNotEmpty() && grantResults[0] ==
PackageManager.PERMISSION_GRANTED) {
button.isEnabled = true
}
}
override fun onActivityResult(requestCode: Int,
resultCode: Int,
data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_IMAGE_CAPTURE) {
if (resultCode == Activity.RESULT_OK) {
val source = ImageDecoder.createSource(this.contentResolver,
imageUri)
val bitmapSource = ImageDecoder.decodeBitmap(source)
// Größe des aufgenommenen Bildes
val wSource = bitmapSource.width
val hSource = bitmapSource.height
// auf eine Höhe von maximal 300 Pixel skalieren
val hDesti = if (hSource > 300) 300 else hSource
val wDesti = (wSource.toFloat() / hSource.toFloat()
* hDesti.toFloat()).toInt()
val bitmapDesti = Bitmap.createScaledBitmap(bitmapSource,
wDesti, hDesti, false)
imageView.setImageBitmap(bitmapDesti)
} else {
val rowsDeleted = contentResolver.delete(imageUri,
null, null)
Log.d(TAG, "$rowsDeleted rows deleted")
}
}
}
private fun startCamera() {
val values = ContentValues()
values.put(MediaStore.Images.Media.TITLE,
getString(R.string.app_name))
values.put(MediaStore.Images.Media.DESCRIPTION,
getString(R.string.descr))
values.put(MediaStore.Images.Media.MIME_TYPE,
"image/jpeg")
contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
values)?.let {
imageUri = it
}
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
intent.putExtra(MediaStore.EXTRA_OUTPUT, imageUri)
startActivityForResult(intent, REQUEST_IMAGE_CAPTURE)
}
}
Nach einer Aufnahme wird die Methode onActivityResult() aufgerufen. Hat die Kamera-App als resultCode den Wert RESULT_OK gemeldet (der Benutzer hat ein Foto geschossen), ist alles in Ordnung und das Foto kann angezeigt werden. Andernfalls muss der Eintrag mit delete() wieder aus der Mediendatenbank entfernt werden. Der Anwender hat die Aufnahme ja abgebrochen.
Mit den beiden Methoden createSource() und decodeBitmap() der Klasse ImageDecoder lässt sich ein Bild, das in der Mediendatenbank gespeichert wurde, sehr einfach als Bitmap zur Verfügung stellen. Sie wiederum kann bequem in ImageViews angezeigt werden. Rufen Sie einfach setImageBitmap() einer android.widget.ImageView-Instanz auf. Allerdings können die von der Kamera gelieferten Fotos zu groß sein. Um eine Ausnahme zur Laufzeit zu vermeiden, sollten Sie das Bild, wie im Beispiel gezeigt, mit createScaledBitmap() skalieren.
Mit der Galerie arbeiten
Die App Fotos oder Galerie zeigt alle Fotos und Videos der systemweiten Mediendatenbank an. Sie ist auf praktisch allen Geräten vorhanden. Wie Sie sie in Ihre eigenen Programme integrieren, zeige ich Ihnen anhand des Beispiels GalleryDemo. Unmittelbar nach dem Start ruft die App die Auswahlseite der Galerie auf (Abbildung 11.12).
Tippt der Benutzer ein Bild an, startet GalleryDemo abermals die Galerie. Diese zeigt die ausgewählte Datei in einer Art Detailansicht an, die in Abbildung 11.13 zu sehen ist.
Die Klasse GalleryDemoActivity erzeugt in onCreate() ein Intent mit der Aktion ACTION_PICK und dem URI EXTERNAL_CONTENT_URI und übergibt es an die Methode startActivityForResult().
package com.thomaskuenneth.androidbuch.gallerydemo
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.provider.MediaStore
import androidx.appcompat.app.AppCompatActivity
private const val REQUEST_GALLERY_PICK = 1
class GalleryDemoActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val intent = Intent(Intent.ACTION_PICK,
MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
startActivityForResult(intent, REQUEST_GALLERY_PICK)
}
override fun onActivityResult(requestCode: Int,
resultCode: Int,
data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_GALLERY_PICK) {
if (resultCode == Activity.RESULT_OK) {
data?.let {
val intentView = Intent(Intent.ACTION_VIEW, it.data)
startActivity(intentView)
}
} else {
finish()
}
}
}
}
In onActivityResult() wird mit resultCode == Activity.RESULT_OK geprüft, ob der Benutzer ein Bild ausgewählt hat. Falls ja, wurde der URI der Datei in den Extras eines Intents (data) übermittelt. Er kann dann mit it.data abgefragt werden. Um keine Fehler zur Laufzeit zu riskieren, sollten Sie auf jeden Fall mit data?.let { sicherstellen, dass nicht anstelle eines Intents null übergeben wurde. Um ein Bild anzuzeigen, packen Sie einfach dessen URI in ein Intent mit der Action ACTION_VIEW und rufen anschließend startActivity() auf.
11.3.2 Die eigene Kamera-App
Sie haben gesehen, wie schnell Sie Ihre Apps mit einer Aufnahmefunktion für Bilder und Videos versehen können. Bislang haben wir hierfür Teile der mitgelieferten Kamera-App verwendet. In diesem Abschnitt zeige ich Ihnen, wie Sie diese in einer sehr einfachen Version nachbauen. Schritt für Schritt lernen Sie anhand des in Abbildung 11.14 dargestellten Beispiels KameraDemo3, wie eine Live-Vorschau programmiert wird, wie Sie aus den unterschiedlichen Kameras eines Android-Geräts die gewünschte auswählen und wie die eigentliche Aufnahme funktioniert.
Die Layoutdatei von KameraDemo3 (Listing 11.10) enthält nur ein RelativeLayout mit einer in beiden Richtungen zentrierten android.view.SurfaceView als einziges Kind. Eine SurfaceView stellt einen dedizierten Zeichenbereich zur Verfügung, der zwar innerhalb einer View-Hierarchie angeordnet, aber von einem anderen Thread gezeichnet wird. Das hat den Vorteil, dass der Zeichen-Thread nicht auf die App warten muss, wenn diese mit anderen Aktionen beschäftigt ist. Der Zugriff auf die Oberfläche (engl. Surface) geschieht mithilfe von android.view.SurfaceHolder-Objekten. Diese Klasse enthält unter anderem die Methoden addCallback() und removeCallback(), mit denen Sie ein SurfaceHolder.Callback-Objekt registrieren oder entfernen.
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<SurfaceView
android:id="@+id/surfaceview"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true" />
</RelativeLayout>
Die Methoden des Interface SurfaceHolder.Callback (Listing 11.11) werden bei Änderungen an der Oberfläche aufgerufen, surfaceCreated() beispielsweise unmittelbar nach dem Erzeugen. surfaceChanged() informiert über strukturelle Änderungen. surfaceDestroyed() kündigt die unmittelbar bevorstehende Zerstörung einer Oberfläche an.
private val surfaceHolderCallback: SurfaceHolder.Callback =
object : SurfaceHolder.Callback {
override fun surfaceDestroyed(holder: SurfaceHolder) {
Log.d(TAG, "surfaceDestroyed()")
}
override fun surfaceCreated(holder: SurfaceHolder) {
Log.d(TAG, "surfaceCreated()")
try {
openCamera()
} catch (e: Exception) {
// SecurityException, CameraAccessException
Log.e(TAG, "openCamera()", e)
}
}
override fun surfaceChanged(
holder: SurfaceHolder,
format: Int, width: Int,
height: Int
) {
Log.d(TAG, "surfaceChanged()")
}
}
Die Klasse KameraDemo3Activity überschreibt onCreate(), onPause(), onResume() und onRequestPermissionsResult(). onCreate() lädt die Benutzeroberfläche und zeigt sie an. Um später auf die Kamera zugreifen zu können, wird mit getSystemService(CameraManager::class.java) ein Objekt des Typs CameraManager ermittelt und der Variable manager zugewiesen. Außerdem muss die App in der Manifestdatei die gefährliche Berechtigung android.permission.CAMERA anfordern und im Code entsprechend behandeln. Die Prüfung findet in onResume() statt. Hat der Anwender zugestimmt, wird configureHolder() aufgerufen.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
manager = getSystemService(CameraManager::class.java)
setContentView(R.layout.activity_main)
camera = null
}
override fun onPause() {
super.onPause()
surfaceview.visibility = View.GONE
activeSession?.close()
activeSession = null
camera?.close()
camera = null
surfaceview.holder.removeCallback(surfaceHolderCallback)
}
override fun onResume() {
super.onResume()
if (checkSelfPermission(Manifest.permission.CAMERA)
!= PackageManager.PERMISSION_GRANTED
) {
requestPermissions(
arrayOf(Manifest.permission.CAMERA),
requestCamera
)
} else {
configureHolder()
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
if (requestCode == requestCamera &&
grantResults.isNotEmpty() && grantResults[0] ==
PackageManager.PERMISSION_GRANTED
) {
configureHolder()
}
}
private fun configureHolder() {
surfaceview.holder.addCallback(surfaceHolderCallback)
val sizes = findCameraFacingBack()
if (sizes.isEmpty()) {
Log.d(TAG, "keine passende Kamera gefunden")
finish()
}
val metrics = resources.displayMetrics
for (size in sizes) {
if (size.width > metrics.widthPixels
|| size.height > metrics.heightPixels
) {
continue
}
surfaceview.setOnClickListener { takePicture() }
surfaceview.holder.setFixedSize(size.width, size.height)
surfaceview.visibility = View.VISIBLE
imageReader = ImageReader.newInstance(
size.width, size.height,
ImageFormat.JPEG, 2
)
imageReader.setOnImageAvailableListener(
{
Log.d(TAG, "setOnImageAvailableListener()")
val image = imageReader.acquireLatestImage()
val planes = image.planes
val buffer = planes[0].buffer
saveJPG(buffer)
image.close()
}, null
)
return
}
Log.d(TAG, "Zu groß")
finish()
}
In onPause() mache ich mit surfaceview.visibility die SurfaceView unsichtbar und blende sie in configureHolder() wieder ein. Das ist erforderlich, damit die Methoden meiner SurfaceHolder.Callback-Implementierung zuverlässig aufgerufen werden. Haben Sie die Aufrufe von addCallback() und removeCallback() bemerkt? Mit ihnen wird eine Instanz der Klasse SurfaceHolder.Callback registriert oder entfernt.
configureHolder() verzweigt nach findCameraFacingBack(). Dort wird nach einer vom Benutzer weg zeigenden Kamera gesucht und ihre Kennung der Variablen cameraId zugewiesen. findCameraFacingBack() liefert eine Liste von Ausgabegrößen. Sie wird in configureHolder() verwendet, um die Vorschau mit setFixedSize() zu konfigurieren. Steht keine passende Größe zur Verfügung, beendet sich die Activity mit finish(). Bestimmt fragen Sie sich, wann ich endlich etwas über Kameras schreibe. Bislang haben Sie zwar einiges über SurfaceView gelernt, aber das Ziel dieses Abschnitts ist doch die Implementierung einer Live-Vorschau, oder?
Kameras auswählen und verwenden
Der Zugriff auf eine Kamera erfolgt über Instanzen des Typs CameraDevice. In findCameraFacingBack() (Listing 11.13) wird mit cameraIdList über eine Liste der zur Verfügung stehenden Kameras iteriert. Die Methode getCameraCharacteristics() hilft Ihnen bei der Auswahl. Hierzu fragen Sie mit get() gewünschte Eigenschaften ab. Liefert beispielsweise get(CameraCharacteristics.LENS_FACING) den Wert LENS_FACING_BACK, haben Sie die Kamera an der Rückseite des Geräts gefunden. Wichtig ist, als Nächstes mithilfe einer StreamConfigurationMap die möglichen Ausgabegrößen des Geräts zu ermitteln. Die Klasse stellt hierfür die Methode getOutputSizes() zur Verfügung. Um eine StreamConfigurationMap zu ermitteln, rufen Sie cc.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) auf.
private fun findCameraFacingBack(): Array<Size> {
cameraId = ""
try {
val ids = manager.cameraIdList
for (id in ids) {
val cc = manager.getCameraCharacteristics(id)
Log.d(TAG, "$id: $cc")
val lensFacing = cc.get(CameraCharacteristics.LENS_FACING)
if (lensFacing != null &&
lensFacing ==
CameraCharacteristics.LENS_FACING_BACK
) {
cameraId = id
cc.get(
CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP
)?.run {
return getOutputSizes(SurfaceHolder::class.java)
}
}
}
} catch (e: CameraAccessException) {
Log.e(TAG, "findCameraFacingBack()", e)
} catch (e: NullPointerException) {
Log.e(TAG, "findCameraFacingBack()", e)
}
return emptyArray()
}
Meine Implementierung in configureHolder() sucht das Element, dessen Breite und Höhe am besten zur Größe des einer Activity zur Verfügung stehenden Bereichs passt. Das muss vor der Inbetriebnahme der Kamera erfolgen. Eigentlich müssen Sie dazu nur die Methode openCamera() der Klasse CameraManager aufrufen, allerdings ist eine ganze Reihe von Callback-Objekten beteiligt. Diese sehen wir uns nun etwas genauer an (Listing 11.14). Die abstrakte Klasse CameraDevice.StateCallback erfordert die Implementierung von drei Methoden. onOpened() wird aufgerufen, nachdem das Öffnen (Hochfahren) der Kamera abgeschlossen ist. Ich verwende sie, um die nachfolgenden Schritte der Kamerakonfiguration zu beginnen. Die Methode onDisconnected() signalisiert, dass die Kamera nicht mehr verfügbar ist. Es bietet sich an, in dieser Methode Aufräumarbeiten durchzuführen. onError() wird bei schwerwiegenden Fehlern aufgerufen.
private fun openCamera() {
manager.openCamera(
cameraId,
object : CameraDevice.StateCallback() {
override fun onOpened(_camera: CameraDevice) {
Log.d(TAG, "onOpened()")
camera = _camera
createPreviewCaptureSession()
}
override fun onDisconnected(camera: CameraDevice) {
Log.d(TAG, "onDisconnected()")
}
override fun onError(
camera: CameraDevice,
error: Int
) {
Log.d(TAG, "onError()")
}
}, null
)
}
Darüber hinaus müssen Sie zwei weitere Aktionen durchführen, damit eine Kamera verwendet werden kann. In createPreviewCaptureSession() (Listing 11.15) erzeuge ich mit createCaptureRequest() ein Objekt des Typs CaptureRequest.Builder und weise es der Instanzvariablen builderPreview zu. Dieser Builder erhält mit addTarget() eine Referenz auf das Surface, das die Vorschau repräsentiert.
private fun createPreviewCaptureSession() {
val outputs = mutableListOf<OutputConfiguration>()
outputs.add(OutputConfiguration(surfaceview.holder.surface))
outputs.add(OutputConfiguration(imageReader.surface))
val sessionConfiguration = SessionConfiguration(
SessionConfiguration.SESSION_REGULAR,
outputs, mainExecutor, captureSessionCallback
)
try {
camera?.createCaptureRequest(
CameraDevice.TEMPLATE_PREVIEW
)?.let {
builderPreview = it
it.addTarget(surfaceview.holder.surface)
camera?.createCaptureSession(sessionConfiguration)
}
} catch (e: Exception) {
Log.e(TAG, "createPreviewCaptureSession()", e)
}
}
Nun folgt das letzte Puzzleteil: Ich erzeuge eine CameraCaptureSession, indem ich die Methode createCaptureSession() eines CameraDevice-Objekts aufrufe. Dabei wird ein – Sie ahnen es wahrscheinlich – Callback-Objekt übergeben. In meiner Beispielimplementierung (Listing 11.16) ist dies die Instanzvariable captureSessionCallback. Das ebenfalls übergebene SessionConfiguration-Objekt konfiguriert die Session. Vielleicht wundern Sie sich in diesem Zusammenhang über das Array outputs. Es enthält nämlich zwei OutputConfiguration-Objekte. Eines (dessen Konstruktor hatte ich surfaceview.holder.surface übergeben) sorgt dafür, dass ein kontinuierlicher Datenstrom in der SurfaceView angezeigt wird. Das ist die Live-Vorschau. Das zweite brauchen wir etwas später für die eigentliche Aufnahme.
private val captureSessionCallback =
object : CameraCaptureSession.StateCallback() {
override fun onConfigured(session: CameraCaptureSession) {
try {
session.setRepeatingRequest(builderPreview.build(), null, null)
activeSession = session
} catch (e: CameraAccessException) {
Log.e(TAG, "onConfigured()", e)
}
}
override fun onConfigureFailed(session: CameraCaptureSession) {
Log.e(TAG, "onConfigureFailed()")
}
}
Die abstrakte Klasse CameraCaptureSession.StateCallback erwartet die Implementierung der beiden Methoden onConfigured() und onConfigureFailed(). Letztere wird aufgerufen, wenn die Session aufgrund eines Fehlers nicht genutzt werden kann. onConfigured() signalisiert, dass die Konfiguration der Session erfolgreich war und diese nun verwendet werden kann. In meiner Beispielimplementierung sorgt session.setRepeatingRequest() dafür, dass die Live-Vorschau aktiviert wird. Hierfür wird ein CaptureRequest-Objekt benötigt, das mit builderPreview.build() erzeugt wird.
[+] Tipp
Wenn Sie Ihre App nicht nur für den Eigengebrauch entwickeln, sondern über Google Play vertreiben möchten, ist es wichtig, in der Manifestdatei zu vermerken, wenn Ihr Programm zwingend eine Kamera voraussetzt. Fügen Sie einfach die Zeile
<uses-feature android:name="android.hardware.camera"
android:required="true" />
hinzu. Ihre App wird dann nur auf Geräten mit eingebauter Kamera zum Download angeboten. Dies bewahrt Anwender vor Frust und schützt Sie vor unnötigen schlechten Kommentaren. Wenn Ihre App auch ohne Kamera funktioniert, brauchen Sie den Eintrag natürlich nicht. In diesem Fall ist es aber wichtig, sauber auf das nicht Vorhandensein der Hardware zu reagieren, also beispielsweise einen entsprechenden Hinweis anzuzeigen.
Sicherlich fragen Sie sich, wie man eigentlich ein Foto aufnimmt. Eine Live-Vorschau ist zweifellos eine feine Sache, aber irgendwann möchte man schließlich den Auslöser drücken. Was Sie dazu tun müssen, zeige ich Ihnen im folgenden Abschnitt.
Fotos aufnehmen
Um eine Aufnahme auszulösen, soll der Anwender die Live-Vorschau antippen. In der Methode configureHolder() (Listing 11.12) wird mit setOnClickListener() ein Listener gesetzt, der die folgende Methode takePicture() aufruft.
private fun takePicture() {
try {
val builder = camera?.createCaptureRequest(
CameraDevice.TEMPLATE_STILL_CAPTURE
)
builder?.addTarget(imageReader.surface)
builder?.build()?.let {
activeSession?.capture(it, null, null)
}
} catch (e: CameraAccessException) {
Log.e(TAG, "takePicture()", e)
}
}
activeSession verweist auf ein CameraCaptureSession-Objekt. Wir haben es in der Implementierung der Methode onConfigured() von CameraCaptureSession.StateCallback verwendet, um mit setRepeatingRequest() die Live-Vorschau zu aktivieren. Nun rufen wir capture() auf, um das Foto zu schießen. Dafür ist ein CaptureRequest.Builder nötig, den wir mit createCaptureRequest() erzeugen. Das Ziel der Aufnahme (addTarget()) ist jetzt nicht der SurfaceHolder der Live-Vorschau, sondern ein Objekt, das ich über eine Instanzvariable namens imageReader referenziere. Das folgende Codefragment finden Sie in der Methode configureHolder().
imageReader = ImageReader.newInstance(
size.width, size.height,
ImageFormat.JPEG, 2
)
imageReader.setOnImageAvailableListener(
{
Log.d(TAG, "setOnImageAvailableListener()")
val image = imageReader.acquireLatestImage()
val planes = image.planes
val buffer = planes[0].buffer
saveJPG(buffer)
image.close()
}, null
)
Nachdem der ImageReader mit newInstance() erzeugt wurde, muss mit setOnImageAvailableListener() ein OnImageAvailableListener gesetzt werden. Dessen Methode onImageAvailable() ermittelt mit acquireLatestImage() das zuletzt aufgenommene Foto. Objekte des Typs android.media.Image können im Prinzip mehrere Farbebenen haben. Bei ImageReader-Objekten mit dem Format ImageFormat.JPEG liefert getPlanes() aber immer ein Feld der Länge 1. Das Speichern der Bilddaten in eine Datei findet in der Methode saveJPG() statt. Ihr wird als einziges Argument ein ByteBuffer übergeben. Diesen liefert buffer des Objekts Image.Plane (planes[0]).
private fun saveJPG(data: ByteBuffer) {
getExternalFilesDir(Environment.DIRECTORY_PICTURES)?.let {
if (it.mkdirs()) {
Log.d(TAG, "dirs created")
}
val f = File(it, "${TAG}_${System.currentTimeMillis()}.jpg")
try {
FileOutputStream(f).use { fos ->
BufferedOutputStream(fos).use { bos ->
while (data.hasRemaining()) {
bos.write(data.get().toInt())
}
Toast.makeText(
this, R.string.click,
Toast.LENGTH_SHORT
).show()
addToMediaProvider(f)
}
}
} catch (e: IOException) {
Log.e(TAG, "saveJPG()", e)
}
}
}
saveJPG() speichert Fotos im anwendungsspezifischen Verzeichnis für Bilder auf dem primären externen Medium. Dies geschieht byteweise, solange die ByteBuffer-Methode hasRemaining() den Wert true liefert. Dies ist dennoch effizient, da der Filterstrom BufferedOutputStream vor dem Schreiben genügend große Häppchen ansammelt. Die zum Schluss aufgerufene Methode addToMediaProvider() in Listing 11.20 fügt die Datei der systemweiten Mediendatenbank hinzu und zeigt das Bild an.
private fun addToMediaProvider(f: File) {
MediaScannerConnection.scanFile(
this,
arrayOf(f.toString()),
arrayOf("image/jpeg")
) { _, uri ->
val i = Intent(
Intent.ACTION_VIEW,
uri
)
startActivity(i)
}
}
Mit zugegebenermaßen nicht ganz wenig Programmcode haben Sie eine einfache Kamera-App erhalten, die Bilder im JPEG-Format speichert. Zu tun gäbe es aber noch eine ganze Menge. Zum Beispiel ist es schade, dass das Foto keine Informationen bezüglich der Ausrichtung des Sensors speichert. Für Aufnahmen bei ungünstigen Lichtverhältnissen wäre die Ansteuerbarkeit des Blitzes sehr wichtig. Und wie stellt die Kamera eigentlich scharf? Der Weg zu diesen weiter fortgeschrittenen Techniken führt über CameraCaptureSession.CaptureCallback-Objekte.
CaptureCallback implementiert die Methoden onCaptureStarted(), onCapturePartial(), onCaptureProgressed(), onCaptureCompleted() sowie onCaptureFailed(). Mit ihnen wird ein komplexer Lebenszyklus beschrieben, der verschiedene Phasen eines Capture Requests repräsentiert. In den Methodenimplementierungen wird der aktuelle Zustand des Capture Requests geprüft und durch Umkonfigurieren des korrespondierenden Builders und Absetzen eines neuen Capture Requests modifiziert.
11.3.3 Videos drehen
In diesem Abschnitt zeige ich Ihnen anhand des Projekts KameraDemo4, wie Sie Videoclips aufzeichnen können. Die Dateien werden im Cacheverzeichnis der App gespeichert und mit dem zentralen Teilen-Dialog an einen beliebigen Empfänger weitergereicht. Dieses Mal verwende ich aber nicht die Klassen der Android-Plattform, sondern die Jetpack-Komponente CameraX. Um sie in Apps verwenden zu können, müssen Sie in der modulspezifischen build.gradle-Datei die folgenden Zeilen eintragen:
def camerax_version = "1.0.0-beta08"
implementation "androidx.camera:camera-core:${camerax_version}"
implementation "androidx.camera:camera-camera2:${camerax_version}"
implementation "androidx.camera:camera-view:1.0.0-alpha15"
Die Namensbestandteile alpha und beta weisen darauf hin, dass sich die Komponente noch sehr schnell weiterentwickelt. Den jeweils aktuellen Stand finden Sie auf der Seite https://developer.android.com/jetpack/androidx/releases/camera. Leider ist auch die Programmierschnittstelle noch nicht fertig. Sie müssen deshalb damit rechnen, dass Versionswechsel brechende Änderungen enthalten, sie also Anpassungen an Ihrem Code vornehmen müssen, wenn Sie auf neuere Versionen aktualisieren. Allerdings gilt dies auch für andere Jetpack-Komponenten.
CameraX bringt ein einfaches UI-Element mit, das sich nicht nur um das Anzeigen der Vorschau kümmert, sondern auch gleich die ganze Logik mitbringt, um Fotos zu schießen und Videos zu drehen. Listing 11.22 zeigt, wie Sie die Komponente androidx.camera.view.CameraView verwenden.
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_centerHorizontal="true"
android:text="@string/start" />
<androidx.camera.view.CameraView
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/preview"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_above="@+id/button"
android:layout_alignParentTop="true"
app:captureMode="video"
app:flash="auto"
app:lensFacing="back"
app:pinchToZoomEnabled="false"
app:scaleType="fitCenter"
tools:context=".KameraDemo4Activity" />
</RelativeLayout>
captureMode legt den Aufnahmemodus fest. flash konfiguriert den Blitz. lensFacing wählt die zu verwendende Kamera aus. Und mit scaleType beeinflussen Sie, ob die Vorschau skaliert und ggf. beschnitten wird. Es ist praktisch, die View schon in der Layoutdatei zu konfigurieren. Natürlich können Sie die Werte aber auch im Code setzen. Um die Kamera verwenden und Ton aufzeichnen zu können, müssen Sie in der Manifestdatei die Berechtigungen android.permission.RECORD_AUDIO und android.permission.CAMERA anfordern und zur Laufzeit prüfen. Die Klasse KameraDemo4Activity (Listing 11.23) überschreibt hierzu wie üblich onRequestPermissionsResult(). Die Prüfung habe ich in die private Methode checkPermissions() ausgelagert.
package com.thomaskuenneth.androidbuch.kamerademo4
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.VideoCapture.*
import androidx.core.content.FileProvider
import kotlinx.android.synthetic.main.activity_main.*
import java.io.File
import android.util.Log
private const val REQUEST_PERMISSIONS = 123
private val TAG = KameraDemo4Activity::class.simpleName
class KameraDemo4Activity : AppCompatActivity() {
private val requestCameraRecordAudio =
arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)
private var isRecording = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
button.isEnabled = false
button.setOnClickListener { toggleRecord() }
updateButton()
if (checkPermissions()) {
preview.post { startCamera() }
} else {
requestPermissions(
requestCameraRecordAudio, REQUEST_PERMISSIONS
)
}
}
override fun onRequestPermissionsResult(
requestCode: Int, permissions: Array<String>,
grantResults: IntArray
) {
if (requestCode == REQUEST_PERMISSIONS) {
if (checkPermissions()) {
preview.post { startCamera() }
} else {
Toast.makeText(
this,
"Permissions not granted by the user.",
Toast.LENGTH_SHORT
).show()
finish()
}
}
}
private fun checkPermissions(): Boolean {
for (permission in requestCameraRecordAudio) {
if (checkSelfPermission(
permission
) != PackageManager.PERMISSION_GRANTED
) {
return false
}
}
return true
}
@Throws(SecurityException::class)
private fun startCamera() {
button.isEnabled = true
preview.bindToLifecycle(this)
}
private fun toggleRecord() {
isRecording = !isRecording
updateButton()
if (isRecording) {
val dir = File(cacheDir, "videos")
dir.mkdirs()
val file = File(dir, "${System.currentTimeMillis()}.mp4")
preview.startRecording(file, mainExecutor,
object : OnVideoSavedCallback {
override fun onVideoSaved(outputFileResults:
OutputFileResults) {
Toast.makeText(
this@KameraDemo4Activity,
file.absolutePath,
Toast.LENGTH_LONG
).show()
val uri = FileProvider.getUriForFile(
this@KameraDemo4Activity,
"com.thomaskuenneth.androidbuch.kamerademo4.fileprovider",
file
)
val intent = Intent(Intent.ACTION_SEND)
intent.type = "video/*"
intent.putExtra(Intent.EXTRA_STREAM, uri)
val chooser = Intent.createChooser(intent,
getString(R.string.share))
val l = packageManager.queryIntentActivities(
chooser,
PackageManager.MATCH_DEFAULT_ONLY
)
for (resolveInfo in l) {
val packageName = resolveInfo.activityInfo.packageName
grantUriPermission(
packageName,
uri,
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
startActivity(chooser)
}
override fun onError(
videoCaptureError: Int,
message: String,
cause: Throwable?
) {
Log.e(TAG, message, cause)
}
})
} else
preview.stopRecording()
}
private fun updateButton() {
button.text = getString(if (isRecording) R.string.stop
else R.string.start)
}
}
Das Hochfahren der Kamera findet in startCamera() statt. Hierzu ist nur der Aufruf der Methode bindToLifecycle() nötig. Um das Entfernen kümmert sich die Komponente selbstständig; Sie müssen nichts weiter tun. Um die Aufnahme zu starten oder zu beenden, rufen Sie startRecording() bzw. stopRecording() auf. Beides geschieht in toggleRecord(). Das Interface VideoCapture.OnVideoSavedCallback definiert die zwei Methoden onVideoSaved() und onError(). Sie werden im Erfolgs- bzw. im Fehlerfall aufgerufen. Letzteren ignoriert mein Beispiel geflissentlich. Konnte der Clip hingegen gespeichert werden, wird zunächst mit FileProvider.getUriForFile() ein Uri ermittelt und dieser in ein Intent mit der Aktion ACTION_SEND verpackt. startActivity() übergibt es an den Teilen-Dialog des Systems.