commit 51b2c91d2419c59e1040b3800d7f28c5fdc11854 Author: Daniel Weschke Date: Sun Apr 18 19:37:00 2021 +0200 inital commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07cd930 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# built application files +*.apk +*.ap_ + +# Mac files +.DS_Store + +# files for the dex VM +*.dex + +# Java class files +*.class + +# generated files +bin/ +gen/ + +# Ignore gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ +proguard-project.txt + +# Eclipse files +.project +.classpath +.settings/ + +# Android Studio/IDEA +*.iml +.idea diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f0a2666 --- /dev/null +++ b/LICENSE @@ -0,0 +1,191 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + 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 + + 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. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..d4098e4 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ + +Android CameraLive +================== + +This app is using the Camera2 API to display the camera preview with a JPEG, DEPTH +or RAW frame. + +Introduction +------------ + +The [Camera2 API][1] allows users to capture RAW images, i.e. unprocessed pixel data +directly from the camera sensor that has not yet been converted into a format and +colorspace typically used for displaying and storing images viewed by humans. + +This app displays a live camera preview in a TextureView and is based on the +[Camera2Basic][2] example. + +[1]: https://developer.android.com/reference/android/hardware/camera2/package-summary.html +[2]: https://github.com/android/camera-samples/tree/main/Camera2Basic + +Pre-requisites +-------------- + +- Android SDK 29+ +- Android Studio 3.5+ + +Screenshots +----------- + +Screenshot + +Getting Started +--------------- + +This sample uses the Gradle build system. To build this project, use the +"gradlew build" command or use "Import Project" in Android Studio. diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..1eb40c1 --- /dev/null +++ b/app/build.gradle @@ -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' +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..638f04d --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/de/weseng/camera/basic/CameraActivity.kt b/app/src/main/java/de/weseng/camera/basic/CameraActivity.kt new file mode 100644 index 0000000..1193532 --- /dev/null +++ b/app/src/main/java/de/weseng/camera/basic/CameraActivity.kt @@ -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 + } +} diff --git a/app/src/main/java/de/weseng/camera/basic/fragments/CameraFragment.kt b/app/src/main/java/de/weseng/camera/basic/fragments/CameraFragment.kt new file mode 100644 index 0000000..1a5d911 --- /dev/null +++ b/app/src/main/java/de/weseng/camera/basic/fragments/CameraFragment.kt @@ -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, + 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_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") + } + } +} diff --git a/app/src/main/java/de/weseng/camera/basic/fragments/ImageViewerFragment.kt b/app/src/main/java/de/weseng/camera/basic/fragments/ImageViewerFragment.kt new file mode 100644 index 0000000..fd23b2c --- /dev/null +++ b/app/src/main/java/de/weseng/camera/basic/fragments/ImageViewerFragment.kt @@ -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 = 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})") + } + } +} diff --git a/app/src/main/java/de/weseng/camera/basic/fragments/PermissionsFragment.kt b/app/src/main/java/de/weseng/camera/basic/fragments/PermissionsFragment.kt new file mode 100644 index 0000000..8d85c02 --- /dev/null +++ b/app/src/main/java/de/weseng/camera/basic/fragments/PermissionsFragment.kt @@ -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, 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 + } + } +} diff --git a/app/src/main/java/de/weseng/camera/basic/fragments/SelectorFragment.kt b/app/src/main/java/de/weseng/camera/basic/fragments/SelectorFragment.kt new file mode 100644 index 0000000..dbe2299 --- /dev/null +++ b/app/src/main/java/de/weseng/camera/basic/fragments/SelectorFragment.kt @@ -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(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 { + val availableCameras: MutableList = 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 + } + } +} diff --git a/app/src/main/res/drawable-hdpi/ic_launcher.png b/app/src/main/res/drawable-hdpi/ic_launcher.png new file mode 100644 index 0000000..bba1165 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_launcher.png b/app/src/main/res/drawable-mdpi/ic_launcher.png new file mode 100644 index 0000000..4304591 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_launcher.png b/app/src/main/res/drawable-xhdpi/ic_launcher.png new file mode 100644 index 0000000..80c5eba Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_launcher.png b/app/src/main/res/drawable-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..9baac9b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/drawable/ic_photo.xml b/app/src/main/res/drawable/ic_photo.xml new file mode 100644 index 0000000..7224d03 --- /dev/null +++ b/app/src/main/res/drawable/ic_photo.xml @@ -0,0 +1,25 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-land/fragment_camera.xml b/app/src/main/res/layout-land/fragment_camera.xml new file mode 100644 index 0000000..bfa336c --- /dev/null +++ b/app/src/main/res/layout-land/fragment_camera.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_camera.xml b/app/src/main/res/layout/activity_camera.xml new file mode 100644 index 0000000..f1fdd64 --- /dev/null +++ b/app/src/main/res/layout/activity_camera.xml @@ -0,0 +1,28 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_camera.xml b/app/src/main/res/layout/fragment_camera.xml new file mode 100644 index 0000000..6108a4d --- /dev/null +++ b/app/src/main/res/layout/fragment_camera.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml new file mode 100644 index 0000000..287fa42 --- /dev/null +++ b/app/src/main/res/navigation/nav_graph.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..5663566 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,19 @@ + + + CameraLive + Capture + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..d939e7a --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/app/src/test/java/de/weseng/camera/basic/InstrumentedTests.kt b/app/src/test/java/de/weseng/camera/basic/InstrumentedTests.kt new file mode 100644 index 0000000..82ec326 --- /dev/null +++ b/app/src/test/java/de/weseng/camera/basic/InstrumentedTests.kt @@ -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 = + 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) + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..f737dd9 --- /dev/null +++ b/build.gradle @@ -0,0 +1,48 @@ +/* + * 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. + */ + +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + // Top-level variables used for versioning + ext.kotlin_version = '1.4.31' + ext.java_version = JavaVersion.VERSION_1_8 + + repositories { + google() + jcenter() + } + dependencies { + classpath 'com.android.tools.build:gradle:4.1.3' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.3.4" + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +allprojects { + repositories { + mavenLocal() + google() + jcenter() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..59169ec --- /dev/null +++ b/gradle.properties @@ -0,0 +1,22 @@ + +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Settings specified in this file will override any Gradle settings +# configured through the IDE. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx10248m -XX:MaxPermSize=256m +# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +android.enableJetifier=true +android.useAndroidX=true \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..94336fc Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..335e9da --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed Mar 31 20:44:51 PDT 2021 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-bin.zip diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..cccdd3d --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..e95643d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/screenshots/icon-web.png b/screenshots/icon-web.png new file mode 100644 index 0000000..d9bd4c4 Binary files /dev/null and b/screenshots/icon-web.png differ diff --git a/screenshots/main.png b/screenshots/main.png new file mode 100644 index 0000000..76e44e9 Binary files /dev/null and b/screenshots/main.png differ diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..663c048 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,18 @@ +/* + * 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. + */ + +include 'app' +include 'utils' diff --git a/utils/.gitignore b/utils/.gitignore new file mode 100644 index 0000000..38c5875 --- /dev/null +++ b/utils/.gitignore @@ -0,0 +1,2 @@ +/build +.idea \ No newline at end of file diff --git a/utils/build.gradle b/utils/build.gradle new file mode 100644 index 0000000..d9cdcc9 --- /dev/null +++ b/utils/build.gradle @@ -0,0 +1,75 @@ +/* + * 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.library' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' +android { + compileSdkVersion 29 + + defaultConfig { + minSdkVersion 21 + targetSdkVersion 29 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles 'consumer-rules.pro' + } + + 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-optimize.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + + // Kotlin lang + 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.recyclerview:recyclerview:1.1.0' + + // EXIF Interface + implementation 'androidx.exifinterface:exifinterface:1.2.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' +} diff --git a/utils/src/main/AndroidManifest.xml b/utils/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6523623 --- /dev/null +++ b/utils/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + diff --git a/utils/src/main/java/de/weseng/camera/utils/AutoFitSurfaceView.kt b/utils/src/main/java/de/weseng/camera/utils/AutoFitSurfaceView.kt new file mode 100644 index 0000000..8f1ea94 --- /dev/null +++ b/utils/src/main/java/de/weseng/camera/utils/AutoFitSurfaceView.kt @@ -0,0 +1,79 @@ +/* + * 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.utils + +import android.content.Context +import android.util.AttributeSet +import android.util.Log +import android.view.SurfaceView +import kotlin.math.roundToInt + +/** + * A [SurfaceView] that can be adjusted to a specified aspect ratio and + * performs center-crop transformation of input frames. + */ +class AutoFitSurfaceView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0 +) : SurfaceView(context, attrs, defStyle) { + + private var aspectRatio = 0f + + /** + * Sets the aspect ratio for this view. The size of the view will be + * measured based on the ratio calculated from the parameters. + * + * @param width Camera resolution horizontal size + * @param height Camera resolution vertical size + */ + fun setAspectRatio(width: Int, height: Int) { + require(width > 0 && height > 0) { "Size cannot be negative" } + aspectRatio = width.toFloat() / height.toFloat() + holder.setFixedSize(width, height) + requestLayout() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + val width = MeasureSpec.getSize(widthMeasureSpec) + val height = MeasureSpec.getSize(heightMeasureSpec) + if (aspectRatio == 0f) { + setMeasuredDimension(width, height) + } else { + + // Performs center-crop transformation of the camera frames + val newWidth: Int + val newHeight: Int + val actualRatio = if (width > height) aspectRatio else 1f / aspectRatio + if (width < height * actualRatio) { + newHeight = height + newWidth = (height * actualRatio).roundToInt() + } else { + newWidth = width + newHeight = (width / actualRatio).roundToInt() + } + + Log.d(TAG, "Measured dimensions set: $newWidth x $newHeight") + setMeasuredDimension(newWidth, newHeight) + } + } + + companion object { + private val TAG = AutoFitSurfaceView::class.java.simpleName + } +} diff --git a/utils/src/main/java/de/weseng/camera/utils/CameraSizes.kt b/utils/src/main/java/de/weseng/camera/utils/CameraSizes.kt new file mode 100644 index 0000000..a56cd6e --- /dev/null +++ b/utils/src/main/java/de/weseng/camera/utils/CameraSizes.kt @@ -0,0 +1,79 @@ +/* + * 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.utils + +import android.graphics.Point +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.params.StreamConfigurationMap +import android.util.Size +import android.view.Display +import kotlin.math.max +import kotlin.math.min + +/** Helper class used to pre-compute shortest and longest sides of a [Size] */ +class SmartSize(width: Int, height: Int) { + var size = Size(width, height) + var long = max(size.width, size.height) + var short = min(size.width, size.height) + override fun toString() = "SmartSize(${long}x${short})" +} + +/** Standard High Definition size for pictures and video */ +val SIZE_1080P: SmartSize = SmartSize(1920, 1080) + +/** Returns a [SmartSize] object for the given [Display] */ +fun getDisplaySmartSize(display: Display): SmartSize { + val outPoint = Point() + display.getRealSize(outPoint) + return SmartSize(outPoint.x, outPoint.y) +} + +/** + * Returns the largest available PREVIEW size. For more information, see: + * https://d.android.com/reference/android/hardware/camera2/CameraDevice and + * https://developer.android.com/reference/android/hardware/camera2/params/StreamConfigurationMap + */ +fun getPreviewOutputSize( + display: Display, + characteristics: CameraCharacteristics, + targetClass: Class, + format: Int? = null +): Size { + + // Find which is smaller: screen or 1080p + val screenSize = getDisplaySmartSize(display) + val hdScreen = screenSize.long >= SIZE_1080P.long || screenSize.short >= SIZE_1080P.short + val maxSize = if (hdScreen) SIZE_1080P else screenSize + + // If image format is provided, use it to determine supported sizes; else use target class + val config = characteristics.get( + CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)!! + if (format == null) + assert(StreamConfigurationMap.isOutputSupportedFor(targetClass)) + else + assert(config.isOutputSupportedFor(format)) + val allSizes = if (format == null) + config.getOutputSizes(targetClass) else config.getOutputSizes(format) + + // Get available sizes and sort them by area from largest to smallest + val validSizes = allSizes + .sortedWith(compareBy { it.height * it.width }) + .map { SmartSize(it.width, it.height) }.reversed() + + // Then, get the largest output size that is smaller or equal than our max size + return validSizes.first { it.long <= maxSize.long && it.short <= maxSize.short }.size +} \ No newline at end of file diff --git a/utils/src/main/java/de/weseng/camera/utils/ExifUtils.kt b/utils/src/main/java/de/weseng/camera/utils/ExifUtils.kt new file mode 100644 index 0000000..15e6267 --- /dev/null +++ b/utils/src/main/java/de/weseng/camera/utils/ExifUtils.kt @@ -0,0 +1,73 @@ +/* + * 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.utils + +import android.graphics.Bitmap +import android.graphics.Matrix +import android.util.Log +import androidx.exifinterface.media.ExifInterface + +private const val TAG: String = "ExifUtils" + +/** Transforms rotation and mirroring information into one of the [ExifInterface] constants */ +fun computeExifOrientation(rotationDegrees: Int, mirrored: Boolean) = when { + rotationDegrees == 0 && !mirrored -> ExifInterface.ORIENTATION_NORMAL + rotationDegrees == 0 && mirrored -> ExifInterface.ORIENTATION_FLIP_HORIZONTAL + rotationDegrees == 180 && !mirrored -> ExifInterface.ORIENTATION_ROTATE_180 + rotationDegrees == 180 && mirrored -> ExifInterface.ORIENTATION_FLIP_VERTICAL + rotationDegrees == 270 && mirrored -> ExifInterface.ORIENTATION_TRANSVERSE + rotationDegrees == 90 && !mirrored -> ExifInterface.ORIENTATION_ROTATE_90 + rotationDegrees == 90 && mirrored -> ExifInterface.ORIENTATION_TRANSPOSE + rotationDegrees == 270 && mirrored -> ExifInterface.ORIENTATION_ROTATE_270 + rotationDegrees == 270 && !mirrored -> ExifInterface.ORIENTATION_TRANSVERSE + else -> ExifInterface.ORIENTATION_UNDEFINED +} + +/** + * Helper function used to convert an EXIF orientation enum into a transformation matrix + * that can be applied to a bitmap. + * + * @return matrix - Transformation required to properly display [Bitmap] + */ +fun decodeExifOrientation(exifOrientation: Int): Matrix { + val matrix = Matrix() + + // Apply transformation corresponding to declared EXIF orientation + when (exifOrientation) { + ExifInterface.ORIENTATION_NORMAL -> Unit + ExifInterface.ORIENTATION_UNDEFINED -> Unit + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90F) + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180F) + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270F) + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1F, 1F) + ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1F, -1F) + ExifInterface.ORIENTATION_TRANSPOSE -> { + matrix.postScale(-1F, 1F) + matrix.postRotate(270F) + } + ExifInterface.ORIENTATION_TRANSVERSE -> { + matrix.postScale(-1F, 1F) + matrix.postRotate(90F) + } + + // Error out if the EXIF orientation is invalid + else -> Log.e(TAG, "Invalid orientation: $exifOrientation") + } + + // Return the resulting matrix + return matrix +} diff --git a/utils/src/main/java/de/weseng/camera/utils/GenericListAdapter.kt b/utils/src/main/java/de/weseng/camera/utils/GenericListAdapter.kt new file mode 100644 index 0000000..e821886 --- /dev/null +++ b/utils/src/main/java/de/weseng/camera/utils/GenericListAdapter.kt @@ -0,0 +1,55 @@ +/* + * 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.utils + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView + +/** Type helper used for the callback triggered once our view has been bound */ +typealias BindCallback = (view: View, data: T, position: Int) -> Unit + +/** List adapter for generic types, intended used for small-medium lists of data */ +class GenericListAdapter( + private val dataset: List, + private val itemLayoutId: Int? = null, + private val itemViewFactory: (() -> View)? = null, + private val onBind: BindCallback +) : RecyclerView.Adapter() { + + class GenericListViewHolder(val view: View) : RecyclerView.ViewHolder(view) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = GenericListViewHolder(when { + itemViewFactory != null -> itemViewFactory.invoke() + itemLayoutId != null -> { + LayoutInflater.from(parent.context) + .inflate(itemLayoutId, parent, false) + } + else -> { + throw IllegalStateException( + "Either the layout ID or the view factory need to be non-null") + } + }) + + override fun onBindViewHolder(holder: GenericListViewHolder, position: Int) { + if (position < 0 || position > dataset.size) return + onBind(holder.view, dataset[position], position) + } + + override fun getItemCount() = dataset.size +} \ No newline at end of file diff --git a/utils/src/main/java/de/weseng/camera/utils/OrientationLiveData.kt b/utils/src/main/java/de/weseng/camera/utils/OrientationLiveData.kt new file mode 100644 index 0000000..d8f80e9 --- /dev/null +++ b/utils/src/main/java/de/weseng/camera/utils/OrientationLiveData.kt @@ -0,0 +1,95 @@ +/* + * 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.utils + +import android.content.Context +import android.hardware.camera2.CameraCharacteristics +import android.view.OrientationEventListener +import android.view.Surface +import androidx.lifecycle.LiveData + + +/** + * Calculates closest 90-degree orientation to compensate for the device + * rotation relative to sensor orientation, i.e., allows user to see camera + * frames with the expected orientation. + */ +class OrientationLiveData( + context: Context, + characteristics: CameraCharacteristics +): LiveData() { + + private val listener = object : OrientationEventListener(context.applicationContext) { + override fun onOrientationChanged(orientation: Int) { + val rotation = when { + orientation <= 45 -> Surface.ROTATION_0 + orientation <= 135 -> Surface.ROTATION_90 + orientation <= 225 -> Surface.ROTATION_180 + orientation <= 315 -> Surface.ROTATION_270 + else -> Surface.ROTATION_0 + } + val relative = computeRelativeRotation(characteristics, rotation) + if (relative != value) postValue(relative) + } + } + + override fun onActive() { + super.onActive() + listener.enable() + } + + override fun onInactive() { + super.onInactive() + listener.disable() + } + + companion object { + + /** + * Computes rotation required to transform from the camera sensor orientation to the + * device's current orientation in degrees. + * + * @param characteristics the [CameraCharacteristics] to query for the sensor orientation. + * @param surfaceRotation the current device orientation as a Surface constant + * @return the relative rotation from the camera sensor to the current device orientation. + */ + @JvmStatic + private fun computeRelativeRotation( + characteristics: CameraCharacteristics, + surfaceRotation: Int + ): Int { + val sensorOrientationDegrees = + characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION)!! + + val deviceOrientationDegrees = when (surfaceRotation) { + Surface.ROTATION_0 -> 0 + Surface.ROTATION_90 -> 90 + Surface.ROTATION_180 -> 180 + Surface.ROTATION_270 -> 270 + else -> 0 + } + + // Reverse device orientation for front-facing cameras + val sign = if (characteristics.get(CameraCharacteristics.LENS_FACING) == + CameraCharacteristics.LENS_FACING_FRONT) 1 else -1 + + // Calculate desired JPEG orientation relative to camera orientation to make + // the image upright relative to the device orientation + return (sensorOrientationDegrees - (deviceOrientationDegrees * sign) + 360) % 360 + } + } +} diff --git a/utils/src/main/java/de/weseng/camera/utils/YuvToRgbConverter.kt b/utils/src/main/java/de/weseng/camera/utils/YuvToRgbConverter.kt new file mode 100644 index 0000000..4b24138 --- /dev/null +++ b/utils/src/main/java/de/weseng/camera/utils/YuvToRgbConverter.kt @@ -0,0 +1,198 @@ +/* + * 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.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.ImageFormat +import android.graphics.Rect +import android.media.Image +import android.renderscript.Allocation +import android.renderscript.Element +import android.renderscript.RenderScript +import android.renderscript.ScriptIntrinsicYuvToRGB +import android.renderscript.Type + +/** + * Helper class used to efficiently convert a [Media.Image] object from + * [ImageFormat.YUV_420_888] format to an RGB [Bitmap] object. + * + * The [yuvToRgb] method is able to achieve the same FPS as the CameraX image + * analysis use case on a Pixel 3 XL device at the default analyzer resolution, + * which is 30 FPS with 640x480. + * + * NOTE: This has been tested in a limited number of devices and is not + * considered production-ready code. It was created for illustration purposes, + * since this is not an efficient camera pipeline due to the multiple copies + * required to convert each frame. + */ +class YuvToRgbConverter(context: Context) { + private val rs = RenderScript.create(context) + private val scriptYuvToRgb = ScriptIntrinsicYuvToRGB.create(rs, Element.U8_4(rs)) + + private var pixelCount: Int = -1 + private lateinit var yuvBuffer: ByteArray + private lateinit var inputAllocation: Allocation + private lateinit var outputAllocation: Allocation + + @Synchronized + fun yuvToRgb(image: Image, output: Bitmap) { + + // Ensure that the intermediate output byte buffer is allocated + if (!::yuvBuffer.isInitialized) { + pixelCount = image.cropRect.width() * image.cropRect.height() + // Bits per pixel is an average for the whole image, so it's useful to compute the size + // of the full buffer but should not be used to determine pixel offsets + val pixelSizeBits = ImageFormat.getBitsPerPixel(ImageFormat.YUV_420_888) + yuvBuffer = ByteArray(pixelCount * pixelSizeBits / 8) + } + + // Get the YUV data in byte array form using NV21 format + imageToByteArray(image, yuvBuffer) + + // Ensure that the RenderScript inputs and outputs are allocated + if (!::inputAllocation.isInitialized) { + // Explicitly create an element with type NV21, since that's the pixel format we use + val elemType = Type.Builder(rs, Element.YUV(rs)).setYuvFormat(ImageFormat.NV21).create() + inputAllocation = Allocation.createSized(rs, elemType.element, yuvBuffer.size) + } + if (!::outputAllocation.isInitialized) { + outputAllocation = Allocation.createFromBitmap(rs, output) + } + + // Convert NV21 format YUV to RGB + inputAllocation.copyFrom(yuvBuffer) + scriptYuvToRgb.setInput(inputAllocation) + scriptYuvToRgb.forEach(outputAllocation) + outputAllocation.copyTo(output) + } + + private fun imageToByteArray(image: Image, outputBuffer: ByteArray) { + assert(image.format == ImageFormat.YUV_420_888) + + val imageCrop = image.cropRect + val imagePlanes = image.planes + + imagePlanes.forEachIndexed { planeIndex, plane -> + // How many values are read in input for each output value written + // Only the Y plane has a value for every pixel, U and V have half the resolution i.e. + // + // Y Plane U Plane V Plane + // =============== ======= ======= + // Y Y Y Y Y Y Y Y U U U U V V V V + // Y Y Y Y Y Y Y Y U U U U V V V V + // Y Y Y Y Y Y Y Y U U U U V V V V + // Y Y Y Y Y Y Y Y U U U U V V V V + // Y Y Y Y Y Y Y Y + // Y Y Y Y Y Y Y Y + // Y Y Y Y Y Y Y Y + val outputStride: Int + + // The index in the output buffer the next value will be written at + // For Y it's zero, for U and V we start at the end of Y and interleave them i.e. + // + // First chunk Second chunk + // =============== =============== + // Y Y Y Y Y Y Y Y V U V U V U V U + // Y Y Y Y Y Y Y Y V U V U V U V U + // Y Y Y Y Y Y Y Y V U V U V U V U + // Y Y Y Y Y Y Y Y V U V U V U V U + // Y Y Y Y Y Y Y Y + // Y Y Y Y Y Y Y Y + // Y Y Y Y Y Y Y Y + var outputOffset: Int + + when (planeIndex) { + 0 -> { + outputStride = 1 + outputOffset = 0 + } + 1 -> { + outputStride = 2 + // For NV21 format, U is in odd-numbered indices + outputOffset = pixelCount + 1 + } + 2 -> { + outputStride = 2 + // For NV21 format, V is in even-numbered indices + outputOffset = pixelCount + } + else -> { + // Image contains more than 3 planes, something strange is going on + return@forEachIndexed + } + } + + val planeBuffer = plane.buffer + val rowStride = plane.rowStride + val pixelStride = plane.pixelStride + + // We have to divide the width and height by two if it's not the Y plane + val planeCrop = if (planeIndex == 0) { + imageCrop + } else { + Rect( + imageCrop.left / 2, + imageCrop.top / 2, + imageCrop.right / 2, + imageCrop.bottom / 2 + ) + } + + val planeWidth = planeCrop.width() + val planeHeight = planeCrop.height() + + // Intermediate buffer used to store the bytes of each row + val rowBuffer = ByteArray(plane.rowStride) + + // Size of each row in bytes + val rowLength = if (pixelStride == 1 && outputStride == 1) { + planeWidth + } else { + // Take into account that the stride may include data from pixels other than this + // particular plane and row, and that could be between pixels and not after every + // pixel: + // + // |---- Pixel stride ----| Row ends here --> | + // | Pixel 1 | Other Data | Pixel 2 | Other Data | ... | Pixel N | + // + // We need to get (N-1) * (pixel stride bytes) per row + 1 byte for the last pixel + (planeWidth - 1) * pixelStride + 1 + } + + for (row in 0 until planeHeight) { + // Move buffer position to the beginning of this row + planeBuffer.position( + (row + planeCrop.top) * rowStride + planeCrop.left * pixelStride) + + if (pixelStride == 1 && outputStride == 1) { + // When there is a single stride value for pixel and output, we can just copy + // the entire row in a single step + planeBuffer.get(outputBuffer, outputOffset, rowLength) + outputOffset += rowLength + } else { + // When either pixel or output have a stride > 1 we must copy pixel by pixel + planeBuffer.get(rowBuffer, 0, rowLength) + for (col in 0 until planeWidth) { + outputBuffer[outputOffset] = rowBuffer[col * pixelStride] + outputOffset += outputStride + } + } + } + } + } +} diff --git a/utils/src/main/res/drawable/ic_shutter.xml b/utils/src/main/res/drawable/ic_shutter.xml new file mode 100644 index 0000000..9bb91ab --- /dev/null +++ b/utils/src/main/res/drawable/ic_shutter.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/utils/src/main/res/drawable/ic_shutter_focused.xml b/utils/src/main/res/drawable/ic_shutter_focused.xml new file mode 100644 index 0000000..9bf521d --- /dev/null +++ b/utils/src/main/res/drawable/ic_shutter_focused.xml @@ -0,0 +1,28 @@ + + + + + + diff --git a/utils/src/main/res/drawable/ic_shutter_normal.xml b/utils/src/main/res/drawable/ic_shutter_normal.xml new file mode 100644 index 0000000..cb50026 --- /dev/null +++ b/utils/src/main/res/drawable/ic_shutter_normal.xml @@ -0,0 +1,28 @@ + + + + + + diff --git a/utils/src/main/res/drawable/ic_shutter_pressed.xml b/utils/src/main/res/drawable/ic_shutter_pressed.xml new file mode 100644 index 0000000..9bf521d --- /dev/null +++ b/utils/src/main/res/drawable/ic_shutter_pressed.xml @@ -0,0 +1,28 @@ + + + + + +