inital commit

This commit is contained in:
2021-04-18 19:37:00 +02:00
commit 51b2c91d24
44 changed files with 2782 additions and 0 deletions

89
app/build.gradle Normal file
View File

@@ -0,0 +1,89 @@
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-android-extensions'
apply plugin: "androidx.navigation.safeargs"
android {
compileSdkVersion 29
defaultConfig {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
applicationId "de.weseng.camera.basic"
minSdkVersion 21
targetSdkVersion 29
versionCode 1
versionName "1.0.0"
}
compileOptions {
sourceCompatibility rootProject.ext.java_version
targetCompatibility rootProject.ext.java_version
}
kotlinOptions {
jvmTarget = "$rootProject.ext.java_version"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
dependencies {
implementation project(':utils')
// Kotlin lang
implementation 'androidx.core:core-ktx:1.2.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.4'
// App compat and UI things
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.2.0'
implementation "androidx.viewpager2:viewpager2:1.0.0"
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
// Navigation library
def nav_version = '2.2.2'
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
// EXIF Interface
implementation 'androidx.exifinterface:exifinterface:1.2.0'
// Glide
implementation 'com.github.bumptech.glide:glide:4.11.0'
kapt 'com.github.bumptech.glide:compiler:4.11.0'
// Unit testing
testImplementation 'androidx.test.ext:junit:1.1.1'
testImplementation 'androidx.test:rules:1.2.0'
testImplementation 'androidx.test:runner:1.2.0'
testImplementation 'androidx.test.espresso:espresso-core:3.2.0'
testImplementation 'org.robolectric:robolectric:4.3.1'
// Instrumented testing
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test:rules:1.2.0'
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
}

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright 2020 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ https://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="de.weseng.camera.basic">
<!-- Permission declarations -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- A camera with (optional) RAW capability is required to use this application -->
<uses-feature android:name="android.hardware.camera.any" />
<uses-feature android:name="android.hardware.camera.raw" android:required="false" />
<application
android:allowBackup="true"
android:fullBackupContent="true"
android:label="@string/app_name"
android:icon="@drawable/ic_launcher"
tools:ignore="GoogleAppIndexingWarning">
<activity
android:name="de.weseng.camera.basic.CameraActivity"
android:clearTaskOnLaunch="true"
android:theme="@style/AppTheme">
<!-- Main app intent filter -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,58 @@
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package de.weseng.camera.basic
import android.os.Bundle
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageButton
import androidx.appcompat.app.AppCompatActivity
class CameraActivity : AppCompatActivity() {
private lateinit var container: FrameLayout
private lateinit var captureButton: ImageButton
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_camera)
container = findViewById(R.id.fragment_container)
}
override fun onResume() {
super.onResume()
// Before setting full screen flags, we must wait a bit to let UI settle; otherwise, we may
// be trying to set app to immersive mode before it's ready and the flags do not stick
container.postDelayed({
container.systemUiVisibility = FLAGS_FULLSCREEN
}, IMMERSIVE_FLAG_TIMEOUT)
}
companion object {
/** Combination of all flags required to put activity into immersive mode */
const val FLAGS_FULLSCREEN =
View.SYSTEM_UI_FLAG_LOW_PROFILE or
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
/** Milliseconds used for UI animations */
const val ANIMATION_FAST_MILLIS = 50L
const val ANIMATION_SLOW_MILLIS = 100L
private const val IMMERSIVE_FLAG_TIMEOUT = 500L
}
}

View File

@@ -0,0 +1,487 @@
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package de.weseng.camera.basic.fragments
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Color
import android.graphics.ImageFormat
import android.hardware.camera2.CameraCaptureSession
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraDevice
import android.hardware.camera2.CameraManager
import android.hardware.camera2.CaptureRequest
import android.hardware.camera2.CaptureResult
import android.hardware.camera2.DngCreator
import android.hardware.camera2.TotalCaptureResult
import android.media.Image
import android.media.ImageReader
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import android.view.LayoutInflater
import android.view.Surface
import android.view.SurfaceHolder
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.drawable.toDrawable
import androidx.exifinterface.media.ExifInterface
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController
import androidx.navigation.Navigation
import androidx.navigation.fragment.navArgs
import de.weseng.camera.utils.computeExifOrientation
import de.weseng.camera.utils.getPreviewOutputSize
import de.weseng.camera.utils.AutoFitSurfaceView
import de.weseng.camera.utils.OrientationLiveData
import de.weseng.camera.basic.R
import de.weseng.camera.basic.CameraActivity
import kotlinx.android.synthetic.main.fragment_camera.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.Closeable
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.concurrent.ArrayBlockingQueue
import java.util.concurrent.TimeoutException
import java.util.Date
import java.util.Locale
import kotlin.RuntimeException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
class CameraFragment : Fragment() {
/** AndroidX navigation arguments */
private val args: CameraFragmentArgs by navArgs()
/** Host's navigation controller */
private val navController: NavController by lazy {
Navigation.findNavController(requireActivity(), R.id.fragment_container)
}
/** Detects, characterizes, and connects to a CameraDevice (used for all camera operations) */
private val cameraManager: CameraManager by lazy {
val context = requireContext().applicationContext
context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
}
/** [CameraCharacteristics] corresponding to the provided Camera ID */
private val characteristics: CameraCharacteristics by lazy {
cameraManager.getCameraCharacteristics(args.cameraId)
}
/** Readers used as buffers for camera still shots */
private lateinit var imageReader: ImageReader
/** [HandlerThread] where all camera operations run */
private val cameraThread = HandlerThread("CameraThread").apply { start() }
/** [Handler] corresponding to [cameraThread] */
private val cameraHandler = Handler(cameraThread.looper)
/** Performs recording animation of flashing screen */
private val animationTask: Runnable by lazy {
Runnable {
// Flash white animation
overlay.background = Color.argb(150, 255, 255, 255).toDrawable()
// Wait for ANIMATION_FAST_MILLIS
overlay.postDelayed({
// Remove white flash animation
overlay.background = null
}, CameraActivity.ANIMATION_FAST_MILLIS)
}
}
/** [HandlerThread] where all buffer reading operations run */
private val imageReaderThread = HandlerThread("imageReaderThread").apply { start() }
/** [Handler] corresponding to [imageReaderThread] */
private val imageReaderHandler = Handler(imageReaderThread.looper)
/** Where the camera preview is displayed */
private lateinit var viewFinder: AutoFitSurfaceView
/** Overlay on top of the camera preview */
private lateinit var overlay: View
/** The [CameraDevice] that will be opened in this fragment */
private lateinit var camera: CameraDevice
/** Internal reference to the ongoing [CameraCaptureSession] configured with our parameters */
private lateinit var session: CameraCaptureSession
/** Live data listener for changes in the device orientation relative to the camera */
private lateinit var relativeOrientation: OrientationLiveData
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = inflater.inflate(R.layout.fragment_camera, container, false)
@SuppressLint("MissingPermission")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
overlay = view.findViewById(R.id.overlay)
viewFinder = view.findViewById(R.id.view_finder)
capture_button.setOnApplyWindowInsetsListener { v, insets ->
v.translationX = (-insets.systemWindowInsetRight).toFloat()
v.translationY = (-insets.systemWindowInsetBottom).toFloat()
insets.consumeSystemWindowInsets()
}
capture_button.setVisibility(View.GONE)
//captureButton = findViewById(R.id.capture_button)
//captureButton.setVisibility(View.GONE))
viewFinder.holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceDestroyed(holder: SurfaceHolder) = Unit
override fun surfaceChanged(
holder: SurfaceHolder,
format: Int,
width: Int,
height: Int) = Unit
override fun surfaceCreated(holder: SurfaceHolder) {
// Selects appropriate preview size and configures view finder
val previewSize = getPreviewOutputSize(
viewFinder.display, characteristics, SurfaceHolder::class.java)
Log.d(TAG, "View finder size: ${viewFinder.width} x ${viewFinder.height}")
Log.d(TAG, "Selected preview size: $previewSize")
viewFinder.setAspectRatio(previewSize.width, previewSize.height)
// To ensure that size is set, initialize camera in the view's thread
view.post { initializeCamera() }
}
})
// Used to rotate the output media to match device orientation
relativeOrientation = OrientationLiveData(requireContext(), characteristics).apply {
observe(viewLifecycleOwner, Observer {
orientation -> Log.d(TAG, "Orientation changed: $orientation")
})
}
}
/**
* Begin all camera operations in a coroutine in the main thread. This function:
* - Opens the camera
* - Configures the camera session
* - Starts the preview by dispatching a repeating capture request
* - Sets up the still image capture listeners
*/
private fun initializeCamera() = lifecycleScope.launch(Dispatchers.Main) {
// Open the selected camera
camera = openCamera(cameraManager, args.cameraId, cameraHandler)
// Initialize an image reader which will be used to capture still photos
val size = characteristics.get(
CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)!!
.getOutputSizes(args.pixelFormat).maxBy { it.height * it.width }!!
imageReader = ImageReader.newInstance(
size.width, size.height, args.pixelFormat, IMAGE_BUFFER_SIZE)
// Creates list of Surfaces where the camera will output frames
val targets = listOf(viewFinder.holder.surface, imageReader.surface)
// Start a capture session using our open camera and list of Surfaces where frames will go
session = createCaptureSession(camera, targets, cameraHandler)
val captureRequest = camera.createCaptureRequest(
CameraDevice.TEMPLATE_PREVIEW).apply { addTarget(viewFinder.holder.surface) }
// This will keep sending the capture request as frequently as possible until the
// session is torn down or session.stopRepeating() is called
session.setRepeatingRequest(captureRequest.build(), null, cameraHandler)
// Listen to the capture button
capture_button.setOnClickListener {
// Disable click listener to prevent multiple requests simultaneously in flight
it.isEnabled = false
// Perform I/O heavy operations in a different scope
lifecycleScope.launch(Dispatchers.IO) {
takePhoto().use { result ->
Log.d(TAG, "Result received: $result")
// Save the result to disk
val output = saveResult(result)
Log.d(TAG, "Image saved: ${output.absolutePath}")
// If the result is a JPEG file, update EXIF metadata with orientation info
if (output.extension == "jpg") {
val exif = ExifInterface(output.absolutePath)
exif.setAttribute(
ExifInterface.TAG_ORIENTATION, result.orientation.toString())
exif.saveAttributes()
Log.d(TAG, "EXIF metadata saved: ${output.absolutePath}")
}
// Display the photo taken to user
lifecycleScope.launch(Dispatchers.Main) {
navController.navigate(CameraFragmentDirections.actionCameraToJpegViewer(output.absolutePath)
.setOrientation(result.orientation)
.setDepth(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
result.format == ImageFormat.DEPTH_JPEG))
}
}
// Re-enable click listener after photo is taken
it.post { it.isEnabled = true }
}
}
}
/** Opens the camera and returns the opened device (as the result of the suspend coroutine) */
@SuppressLint("MissingPermission")
private suspend fun openCamera(
manager: CameraManager,
cameraId: String,
handler: Handler? = null
): CameraDevice = suspendCancellableCoroutine { cont ->
manager.openCamera(cameraId, object : CameraDevice.StateCallback() {
override fun onOpened(device: CameraDevice) = cont.resume(device)
override fun onDisconnected(device: CameraDevice) {
Log.w(TAG, "Camera $cameraId has been disconnected")
requireActivity().finish()
}
override fun onError(device: CameraDevice, error: Int) {
val msg = when(error) {
ERROR_CAMERA_DEVICE -> "Fatal (device)"
ERROR_CAMERA_DISABLED -> "Device policy"
ERROR_CAMERA_IN_USE -> "Camera in use"
ERROR_CAMERA_SERVICE -> "Fatal (service)"
ERROR_MAX_CAMERAS_IN_USE -> "Maximum cameras in use"
else -> "Unknown"
}
val exc = RuntimeException("Camera $cameraId error: ($error) $msg")
Log.e(TAG, exc.message, exc)
if (cont.isActive) cont.resumeWithException(exc)
}
}, handler)
}
/**
* Starts a [CameraCaptureSession] and returns the configured session (as the result of the
* suspend coroutine
*/
private suspend fun createCaptureSession(
device: CameraDevice,
targets: List<Surface>,
handler: Handler? = null
): CameraCaptureSession = suspendCoroutine { cont ->
// Create a capture session using the predefined targets; this also involves defining the
// session state callback to be notified of when the session is ready
device.createCaptureSession(targets, object: CameraCaptureSession.StateCallback() {
override fun onConfigured(session: CameraCaptureSession) = cont.resume(session)
override fun onConfigureFailed(session: CameraCaptureSession) {
val exc = RuntimeException("Camera ${device.id} session configuration failed")
Log.e(TAG, exc.message, exc)
cont.resumeWithException(exc)
}
}, handler)
}
/**
* Helper function used to capture a still image using the [CameraDevice.TEMPLATE_STILL_CAPTURE]
* template. It performs synchronization between the [CaptureResult] and the [Image] resulting
* from the single capture, and outputs a [CombinedCaptureResult] object.
*/
private suspend fun takePhoto():
CombinedCaptureResult = suspendCoroutine { cont ->
// Flush any images left in the image reader
@Suppress("ControlFlowWithEmptyBody")
while (imageReader.acquireNextImage() != null) {}
// Start a new image queue
val imageQueue = ArrayBlockingQueue<Image>(IMAGE_BUFFER_SIZE)
imageReader.setOnImageAvailableListener({ reader ->
val image = reader.acquireNextImage()
Log.d(TAG, "Image available in queue: ${image.timestamp}")
imageQueue.add(image)
}, imageReaderHandler)
val captureRequest = session.device.createCaptureRequest(
CameraDevice.TEMPLATE_STILL_CAPTURE).apply { addTarget(imageReader.surface) }
session.capture(captureRequest.build(), object : CameraCaptureSession.CaptureCallback() {
override fun onCaptureStarted(
session: CameraCaptureSession,
request: CaptureRequest,
timestamp: Long,
frameNumber: Long) {
super.onCaptureStarted(session, request, timestamp, frameNumber)
viewFinder.post(animationTask)
}
override fun onCaptureCompleted(
session: CameraCaptureSession,
request: CaptureRequest,
result: TotalCaptureResult) {
super.onCaptureCompleted(session, request, result)
val resultTimestamp = result.get(CaptureResult.SENSOR_TIMESTAMP)
Log.d(TAG, "Capture result received: $resultTimestamp")
// Set a timeout in case image captured is dropped from the pipeline
val exc = TimeoutException("Image dequeuing took too long")
val timeoutRunnable = Runnable { cont.resumeWithException(exc) }
imageReaderHandler.postDelayed(timeoutRunnable, IMAGE_CAPTURE_TIMEOUT_MILLIS)
// Loop in the coroutine's context until an image with matching timestamp comes
// We need to launch the coroutine context again because the callback is done in
// the handler provided to the `capture` method, not in our coroutine context
@Suppress("BlockingMethodInNonBlockingContext")
lifecycleScope.launch(cont.context) {
while (true) {
// Dequeue images while timestamps don't match
val image = imageQueue.take()
// TODO(owahltinez): b/142011420
// if (image.timestamp != resultTimestamp) continue
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
image.format != ImageFormat.DEPTH_JPEG &&
image.timestamp != resultTimestamp) continue
Log.d(TAG, "Matching image dequeued: ${image.timestamp}")
// Unset the image reader listener
imageReaderHandler.removeCallbacks(timeoutRunnable)
imageReader.setOnImageAvailableListener(null, null)
// Clear the queue of images, if there are left
while (imageQueue.size > 0) {
imageQueue.take().close()
}
// Compute EXIF orientation metadata
val rotation = relativeOrientation.value ?: 0
val mirrored = characteristics.get(CameraCharacteristics.LENS_FACING) ==
CameraCharacteristics.LENS_FACING_FRONT
val exifOrientation = computeExifOrientation(rotation, mirrored)
// Build the result and resume progress
cont.resume(CombinedCaptureResult(
image, result, exifOrientation, imageReader.imageFormat))
// There is no need to break out of the loop, this coroutine will suspend
}
}
}
}, cameraHandler)
}
/** Helper function used to save a [CombinedCaptureResult] into a [File] */
private suspend fun saveResult(result: CombinedCaptureResult): File = suspendCoroutine { cont ->
when (result.format) {
// When the format is JPEG or DEPTH JPEG we can simply save the bytes as-is
ImageFormat.JPEG, ImageFormat.DEPTH_JPEG -> {
val buffer = result.image.planes[0].buffer
val bytes = ByteArray(buffer.remaining()).apply { buffer.get(this) }
try {
val output = createFile(requireContext(), "jpg")
FileOutputStream(output).use { it.write(bytes) }
cont.resume(output)
} catch (exc: IOException) {
Log.e(TAG, "Unable to write JPEG image to file", exc)
cont.resumeWithException(exc)
}
}
// When the format is RAW we use the DngCreator utility library
ImageFormat.RAW_SENSOR -> {
val dngCreator = DngCreator(characteristics, result.metadata)
try {
val output = createFile(requireContext(), "dng")
FileOutputStream(output).use { dngCreator.writeImage(it, result.image) }
cont.resume(output)
} catch (exc: IOException) {
Log.e(TAG, "Unable to write DNG image to file", exc)
cont.resumeWithException(exc)
}
}
// No other formats are supported by this sample
else -> {
val exc = RuntimeException("Unknown image format: ${result.image.format}")
Log.e(TAG, exc.message, exc)
cont.resumeWithException(exc)
}
}
}
override fun onStop() {
super.onStop()
try {
camera.close()
} catch (exc: Throwable) {
Log.e(TAG, "Error closing camera", exc)
}
}
override fun onDestroy() {
super.onDestroy()
cameraThread.quitSafely()
imageReaderThread.quitSafely()
}
companion object {
private val TAG = CameraFragment::class.java.simpleName
/** Maximum number of images that will be held in the reader's buffer */
private const val IMAGE_BUFFER_SIZE: Int = 3
/** Maximum time allowed to wait for the result of an image capture */
private const val IMAGE_CAPTURE_TIMEOUT_MILLIS: Long = 5000
/** Helper data class used to hold capture metadata with their associated image */
data class CombinedCaptureResult(
val image: Image,
val metadata: CaptureResult,
val orientation: Int,
val format: Int
) : Closeable {
override fun close() = image.close()
}
/**
* Create a [File] named a using formatted timestamp with the current date and time.
*
* @return [File] created.
*/
private fun createFile(context: Context, extension: String): File {
val sdf = SimpleDateFormat("yyyy_MM_dd_HH_mm_ss_SSS", Locale.US)
return File(context.filesDir, "IMG_${sdf.format(Date())}.$extension")
}
}
}

View File

@@ -0,0 +1,176 @@
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package de.weseng.camera.basic.fragments
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.util.Log
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.ImageView
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.navArgs
import androidx.viewpager2.widget.ViewPager2
import com.bumptech.glide.Glide
import de.weseng.camera.utils.GenericListAdapter
import de.weseng.camera.utils.decodeExifOrientation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.BufferedInputStream
import java.io.File
import kotlin.math.max
class ImageViewerFragment : Fragment() {
/** AndroidX navigation arguments */
private val args: ImageViewerFragmentArgs by navArgs()
/** Default Bitmap decoding options */
private val bitmapOptions = BitmapFactory.Options().apply {
inJustDecodeBounds = false
// Keep Bitmaps at less than 1 MP
if (max(outHeight, outWidth) > DOWNSAMPLE_SIZE) {
val scaleFactorX = outWidth / DOWNSAMPLE_SIZE + 1
val scaleFactorY = outHeight / DOWNSAMPLE_SIZE + 1
inSampleSize = max(scaleFactorX, scaleFactorY)
}
}
/** Bitmap transformation derived from passed arguments */
private val bitmapTransformation: Matrix by lazy { decodeExifOrientation(args.orientation) }
/** Flag indicating that there is depth data available for this image */
private val isDepth: Boolean by lazy { args.depth }
/** Data backing our Bitmap viewpager */
private val bitmapList: MutableList<Bitmap> = mutableListOf()
private fun imageViewFactory() = ImageView(requireContext()).apply {
layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = ViewPager2(requireContext()).apply {
// Populate the ViewPager and implement a cache of two media items
offscreenPageLimit = 2
adapter = GenericListAdapter(
bitmapList,
itemViewFactory = { imageViewFactory() }) { view, item, _ ->
view as ImageView
Glide.with(view).load(item).into(view)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view as ViewPager2
lifecycleScope.launch(Dispatchers.IO) {
// Load input image file
val inputBuffer = loadInputBuffer()
// Load the main JPEG image
addItemToViewPager(view, decodeBitmap(inputBuffer, 0, inputBuffer.size))
// If we have depth data attached, attempt to load it
if (isDepth) {
try {
val depthStart = findNextJpegEndMarker(inputBuffer, 2)
addItemToViewPager(view, decodeBitmap(
inputBuffer, depthStart, inputBuffer.size - depthStart))
val confidenceStart = findNextJpegEndMarker(inputBuffer, depthStart)
addItemToViewPager(view, decodeBitmap(
inputBuffer, confidenceStart, inputBuffer.size - confidenceStart))
} catch (exc: RuntimeException) {
Log.e(TAG, "Invalid start marker for depth or confidence data")
}
}
}
}
/** Utility function used to read input file into a byte array */
private fun loadInputBuffer(): ByteArray {
val inputFile = File(args.filePath)
return BufferedInputStream(inputFile.inputStream()).let { stream ->
ByteArray(stream.available()).also {
stream.read(it)
stream.close()
}
}
}
/** Utility function used to add an item to the viewpager and notify it, in the main thread */
private fun addItemToViewPager(view: ViewPager2, item: Bitmap) = view.post {
bitmapList.add(item)
view.adapter!!.notifyDataSetChanged()
}
/** Utility function used to decode a [Bitmap] from a byte array */
private fun decodeBitmap(buffer: ByteArray, start: Int, length: Int): Bitmap {
// Load bitmap from given buffer
val bitmap = BitmapFactory.decodeByteArray(buffer, start, length, bitmapOptions)
// Transform bitmap orientation using provided metadata
return Bitmap.createBitmap(
bitmap, 0, 0, bitmap.width, bitmap.height, bitmapTransformation, true)
}
companion object {
private val TAG = ImageViewerFragment::class.java.simpleName
/** Maximum size of [Bitmap] decoded */
private const val DOWNSAMPLE_SIZE: Int = 1024 // 1MP
/** These are the magic numbers used to separate the different JPG data chunks */
private val JPEG_DELIMITER_BYTES = arrayOf(-1, -39)
/**
* Utility function used to find the markers indicating separation between JPEG data chunks
*/
private fun findNextJpegEndMarker(jpegBuffer: ByteArray, start: Int): Int {
// Sanitize input arguments
assert(start >= 0) { "Invalid start marker: $start" }
assert(jpegBuffer.size > start) {
"Buffer size (${jpegBuffer.size}) smaller than start marker ($start)" }
// Perform a linear search until the delimiter is found
for (i in start until jpegBuffer.size - 1) {
if (jpegBuffer[i].toInt() == JPEG_DELIMITER_BYTES[0] &&
jpegBuffer[i + 1].toInt() == JPEG_DELIMITER_BYTES[1]) {
return i + 2
}
}
// If we reach this, it means that no marker was found
throw RuntimeException("Separator marker not found in buffer (${jpegBuffer.size})")
}
}
}

View File

@@ -0,0 +1,71 @@
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package de.weseng.camera.basic.fragments
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.navigation.Navigation
import de.weseng.camera.basic.R
private const val PERMISSIONS_REQUEST_CODE = 10
private val PERMISSIONS_REQUIRED = arrayOf(Manifest.permission.CAMERA)
/**
* This [Fragment] requests permissions and, once granted, it will navigate to the next fragment
*/
class PermissionsFragment : Fragment() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (hasPermissions(requireContext())) {
// If permissions have already been granted, proceed
Navigation.findNavController(requireActivity(), R.id.fragment_container).navigate(
PermissionsFragmentDirections.actionPermissionsToSelector())
} else {
// Request camera-related permissions
requestPermissions(PERMISSIONS_REQUIRED, PERMISSIONS_REQUEST_CODE)
}
}
override fun onRequestPermissionsResult(
requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == PERMISSIONS_REQUEST_CODE) {
if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// Takes the user to the success fragment when permission is granted
Navigation.findNavController(requireActivity(), R.id.fragment_container).navigate(
PermissionsFragmentDirections.actionPermissionsToSelector())
} else {
Toast.makeText(context, "Permission request denied", Toast.LENGTH_LONG).show()
}
}
}
companion object {
/** Convenience method used to check if all permissions required by this app are granted */
fun hasPermissions(context: Context) = PERMISSIONS_REQUIRED.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
}
}

View File

@@ -0,0 +1,134 @@
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package de.weseng.camera.basic.fragments
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.ImageFormat
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraManager
import android.hardware.camera2.CameraMetadata
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.navigation.Navigation
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import de.weseng.camera.basic.R
import de.weseng.camera.utils.GenericListAdapter
class SelectorFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = RecyclerView(requireContext())
@SuppressLint("MissingPermission")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view as RecyclerView
view.apply {
layoutManager = LinearLayoutManager(requireContext())
val cameraManager =
requireContext().getSystemService(Context.CAMERA_SERVICE) as CameraManager
val cameraList = enumerateCameras(cameraManager)
val layoutId = android.R.layout.simple_list_item_1
adapter = GenericListAdapter(cameraList, itemLayoutId = layoutId) { view, item, _ ->
view.findViewById<TextView>(android.R.id.text1).text = item.title
view.setOnClickListener {
Navigation.findNavController(requireActivity(), R.id.fragment_container)
.navigate(SelectorFragmentDirections.actionSelectorToCamera(
item.cameraId, item.format))
}
}
}
}
companion object {
/** Helper class used as a data holder for each selectable camera format item */
private data class FormatItem(val title: String, val cameraId: String, val format: Int)
/** Helper function used to convert a lens orientation enum into a human-readable string */
private fun lensOrientationString(value: Int) = when(value) {
CameraCharacteristics.LENS_FACING_BACK -> "Back"
CameraCharacteristics.LENS_FACING_FRONT -> "Front"
CameraCharacteristics.LENS_FACING_EXTERNAL -> "External"
else -> "Unknown"
}
/** Helper function used to list all compatible cameras and supported pixel formats */
@SuppressLint("InlinedApi")
private fun enumerateCameras(cameraManager: CameraManager): List<FormatItem> {
val availableCameras: MutableList<FormatItem> = mutableListOf()
// Get list of all compatible cameras
val cameraIds = cameraManager.cameraIdList.filter {
val characteristics = cameraManager.getCameraCharacteristics(it)
val capabilities = characteristics.get(
CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES)
capabilities?.contains(
CameraMetadata.REQUEST_AVAILABLE_CAPABILITIES_BACKWARD_COMPATIBLE) ?: false
}
// Iterate over the list of cameras and return all the compatible ones
cameraIds.forEach { id ->
val characteristics = cameraManager.getCameraCharacteristics(id)
val orientation = lensOrientationString(
characteristics.get(CameraCharacteristics.LENS_FACING)!!)
// Query the available capabilities and output formats
val capabilities = characteristics.get(
CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES)!!
val outputFormats = characteristics.get(
CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)!!.outputFormats
// All cameras *must* support JPEG output so we don't need to check characteristics
availableCameras.add(FormatItem(
"$orientation JPEG ($id)", id, ImageFormat.JPEG))
// Return cameras that support RAW capability
if (capabilities.contains(
CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_RAW) &&
outputFormats.contains(ImageFormat.RAW_SENSOR)) {
availableCameras.add(FormatItem(
"$orientation RAW ($id)", id, ImageFormat.RAW_SENSOR))
}
// Return cameras that support JPEG DEPTH capability
if (capabilities.contains(
CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_DEPTH_OUTPUT) &&
outputFormats.contains(ImageFormat.DEPTH_JPEG)) {
availableCameras.add(FormatItem(
"$orientation DEPTH ($id)", id, ImageFormat.DEPTH_JPEG))
}
}
return availableCameras
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2020 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ https://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#000000"
android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01L14.5,12l4.5,6H5l3.5,-4.5z"/>
</vector>

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2020 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ https://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<de.weseng.camera.utils.AutoFitSurfaceView
android:id="@+id/view_finder"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<View
android:id="@+id/overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:foreground="@android:color/transparent" />
<ImageButton
android:id="@+id/capture_button"
android:layout_width="96dp"
android:layout_height="96dp"
android:layout_gravity="center|right"
android:scaleType="fitCenter"
android:background="@drawable/ic_shutter"
android:contentDescription="@string/capture" />
</FrameLayout>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2020 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ https://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<androidx.fragment.app.FragmentContainerView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/fragment_container"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:keepScreenOn="true"
app:defaultNavHost="true"
app:navGraph="@navigation/nav_graph"
tools:context="de.weseng.camera.basic.CameraActivity" />

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2020 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ https://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<de.weseng.camera.utils.AutoFitSurfaceView
android:id="@+id/view_finder"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<View
android:id="@+id/overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:foreground="@android:color/transparent" />
<ImageButton
android:id="@+id/capture_button"
android:layout_width="96dp"
android:layout_height="96dp"
android:layout_gravity="bottom|center"
android:scaleType="fitCenter"
android:background="@drawable/ic_shutter"
android:contentDescription="@string/capture" />
</FrameLayout>

View File

@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2020 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ https://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<navigation
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/nav_graph"
app:startDestination="@id/permissions_fragment">
<fragment
android:id="@+id/permissions_fragment"
android:name="de.weseng.camera.basic.fragments.PermissionsFragment"
android:label="Permissions" >
<action
android:id="@+id/action_permissions_to_selector"
app:destination="@id/selector_fragment"
app:popUpTo="@id/permissions_fragment"
app:popUpToInclusive="true" />
</fragment>
<fragment
android:id="@+id/selector_fragment"
android:name="de.weseng.camera.basic.fragments.SelectorFragment"
android:label="Selector" >
<action
android:id="@+id/action_selector_to_camera"
app:launchSingleTop="true"
app:destination="@id/camera_fragment" />
</fragment>
<fragment
android:id="@+id/camera_fragment"
android:name="de.weseng.camera.basic.fragments.CameraFragment"
android:label="Camera" >
<argument
android:name="camera_id"
app:argType="string"
app:nullable="false"/>
<argument
android:name="pixel_format"
app:argType="integer"
app:nullable="false"/>
<action
android:id="@+id/action_camera_to_permissions"
app:destination="@id/permissions_fragment"
app:popUpTo="@id/camera_fragment"
app:popUpToInclusive="true"/>
<action
android:id="@+id/action_camera_to_jpeg_viewer"
app:launchSingleTop="true"
app:destination="@id/image_viewer_fragment" />
</fragment>
<fragment
android:id="@+id/image_viewer_fragment"
android:name="de.weseng.camera.basic.fragments.ImageViewerFragment"
android:label="Image Viewer" >
<argument
android:name="file_path"
app:argType="string"
app:nullable="false"/>
<argument
android:name="orientation"
app:argType="integer"
android:defaultValue="0" />
<argument
android:name="depth"
app:argType="boolean"
android:defaultValue="false" />
</fragment>
</navigation>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Copyright 2015 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<resources>
<string name="app_name">CameraLive</string>
<string name="capture">Capture</string>
</resources>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2020 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ https://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:immersive">true</item>
<item name="android:windowFullscreen">true</item>
<item name="android:windowTranslucentStatus">true</item>
<item name="android:windowTranslucentNavigation">true</item>
</style>
</resources>

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package de.weseng.camera.basic
import android.Manifest
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.rule.ActivityTestRule
import androidx.test.rule.GrantPermissionRule
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MainInstrumentedTest {
@get:Rule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.CAMERA)
@get:Rule
val activityRule: ActivityTestRule<CameraActivity> =
ActivityTestRule(CameraActivity::class.java)
@Test
fun useAppContext() {
// Context of the app under test
val context = ApplicationProvider.getApplicationContext() as Context
assertEquals("de.weseng.camera.basic", context.packageName)
}
}