commit 59044e0554ee72791611bf2a247e4026a2dee380 Author: raphael Date: Sun Dec 12 15:44:30 2021 +0100 initial android app setup, adds fragments, camera - scans in camera view for qr code - camera view inside fragment - fragment 1 passed to camera fragment - prints qr code to log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a68e5b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Log/OS Files +*.log + +# Android Studio generated files and folders +captures/ +.externalNativeBuild/ +.cxx/ +*.apk +output.json + +# IntelliJ +*.iml +.idea/ + +# Keystore files +*.jks +*.keystore + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Android Profiling +*.hprof diff --git a/README.md b/README.md new file mode 100644 index 0000000..f544d54 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +## Android Client for Bump + +uses camerax framework inside an activity fragment to run camera. +Heavily uses requireActivity() inside that to get the appropriate +camera functionality. Not sure, if this is the way to go with fragments. + +MLKit QR scanner is used to scan QR codes explitily from the view frame. + diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..51fa55d --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,54 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' +} + +android { + compileSdk 32 + + defaultConfig { + applicationId "com.example.bump" + minSdk 29 + targetSdk 32 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } + buildFeatures { + viewBinding true + } +} + +dependencies { + + implementation 'com.google.android.gms:play-services-mlkit-barcode-scanning:16.1.4' + def camerax_version = "1.0.2" + implementation 'androidx.core:core-ktx:1.3.2' + implementation 'androidx.appcompat:appcompat:1.2.0' + implementation 'com.google.android.material:material:1.3.0' + implementation 'androidx.constraintlayout:constraintlayout:2.0.4' + implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5' + implementation 'androidx.navigation:navigation-ui-ktx:2.3.5' + implementation "androidx.camera:camera-camera2:$camerax_version" + implementation "androidx.camera:camera-lifecycle:$camerax_version" + implementation 'com.google.zxing:core:3.3.0' + implementation "androidx.camera:camera-view:1.0.0-alpha31" + testImplementation 'junit:junit:4.+' + androidTestImplementation 'androidx.test.ext:junit:1.1.2' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/example/bump/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/example/bump/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..9e42ff4 --- /dev/null +++ b/app/src/androidTest/java/com/example/bump/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.bump + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.bump", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f7cdebe --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/bump/CameraFragment.kt b/app/src/main/java/com/example/bump/CameraFragment.kt new file mode 100644 index 0000000..58df643 --- /dev/null +++ b/app/src/main/java/com/example/bump/CameraFragment.kt @@ -0,0 +1,244 @@ +package com.example.bump + +import android.Manifest +import android.annotation.SuppressLint +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.util.DisplayMetrics +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.camera.core.* +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import com.example.bump.databinding.FragmentSecondBinding +import com.google.mlkit.vision.barcode.Barcode +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.common.InputImage +import java.util.concurrent.Executors +import kotlin.IllegalStateException +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import android.view.WindowInsets + +import android.graphics.Insets + + + + + +class CameraFragment: Fragment() { + + private var previewView: PreviewView? = null + private var cameraProvider: ProcessCameraProvider? = null + private var cameraSelector: CameraSelector? = null + private var lensFacing = CameraSelector.LENS_FACING_BACK + private var previewUseCase: Preview? = null + private var analysisUseCase: ImageAnalysis? = null + + private var _binding: FragmentSecondBinding? = null + private val binding get() = _binding!! + + private val screenAspectRatio: Int + get() { + val activity = requireActivity() + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val windowMetrics = activity.windowManager.currentWindowMetrics + val insets: Insets = windowMetrics.windowInsets + .getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()) + windowMetrics.bounds.width() - insets.left - insets.right + } else { + val displayMetrics = DisplayMetrics() + activity.windowManager.defaultDisplay.getMetrics(displayMetrics) + return aspectRatio(displayMetrics.widthPixels, displayMetrics.heightPixels) + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savednstanceState: Bundle? + ): View { + + _binding = FragmentSecondBinding.inflate(inflater, container, false) + setupCamera() + return binding.root + } + + private fun setupCamera() { + previewView = binding.viewFinder + cameraSelector = CameraSelector.Builder().requireLensFacing(lensFacing).build() + ViewModelProvider( + this, ViewModelProvider.AndroidViewModelFactory.getInstance(super.requireActivity().application) + ).get(CameraXViewModel::class.java) + .processCameraProvider + .observe(this.viewLifecycleOwner) { provider: ProcessCameraProvider? -> + cameraProvider = provider + if (isCameraPermissionGranted()) { + bindCameraUseCases() + } else { + ActivityCompat.requestPermissions( + super.requireActivity(), + arrayOf(Manifest.permission.CAMERA), + PERMISSION_CAMERA_REQUEST + ) + } + } + } + + private fun bindCameraUseCases() { + bindPreviewUseCase() + bindAnalyseUseCase() + } + + private fun bindPreviewUseCase() { + if (cameraProvider == null) { + return + } + if (previewUseCase != null) { + cameraProvider!!.unbind(previewUseCase) + } + + previewUseCase = Preview.Builder() + .setTargetAspectRatio(screenAspectRatio) + .setTargetRotation(previewView!!.display.rotation) + .build() + previewUseCase!!.setSurfaceProvider(previewView!!.surfaceProvider) + + try { + cameraProvider!!.bindToLifecycle( + /* lifecycleOwner= */this, + cameraSelector!!, + previewUseCase + ) + } catch (illegalStateException: IllegalStateException) { + Log.e(TAG, illegalStateException.message ?: "IllegalStateException") + } catch (illegalArgumentException: IllegalArgumentException) { + Log.e(TAG, illegalArgumentException.message ?: "IllegalArgumentException") + } + } + + private fun bindAnalyseUseCase() { + // Note that if you know which format of barcode your app is dealing with, detection will be + // faster to specify the supported barcode formats one by one, e.g. + val bsc = BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + val barcodeScanner: BarcodeScanner = BarcodeScanning.getClient(bsc) + + if (cameraProvider == null) { + return + } + if (analysisUseCase != null) { + cameraProvider!!.unbind(analysisUseCase) + } + + analysisUseCase = ImageAnalysis.Builder() + .setTargetAspectRatio(screenAspectRatio) + .setTargetRotation(previewView!!.display.rotation) + .build() + + // Initialize our background executor + val cameraExecutor = Executors.newSingleThreadExecutor() + + analysisUseCase?.setAnalyzer( + cameraExecutor, + { imageProxy -> + processImageProxy(barcodeScanner, imageProxy) + } + ) + + try { + cameraProvider!!.bindToLifecycle( + /* lifecycleOwner= */this, + cameraSelector!!, + analysisUseCase + ) + } catch (illegalStateException: IllegalStateException) { + Log.e(TAG, illegalStateException.message ?: "IllegalStateException") + } catch (illegalArgumentException: IllegalArgumentException) { + Log.e(TAG, illegalArgumentException.message ?: "IllegalArgumentException") + } + } + + @SuppressLint("UnsafeExperimentalUsageError", "UnsafeOptInUsageError") + private fun processImageProxy( + barcodeScanner: BarcodeScanner, + imageProxy: ImageProxy + ) { + val inputImage = + InputImage.fromMediaImage(imageProxy.image!!, imageProxy.imageInfo.rotationDegrees) + + barcodeScanner.process(inputImage) + .addOnSuccessListener { barcodes -> + barcodes.forEach { + Log.d(TAG, it.rawValue!!) + } + } + .addOnFailureListener { + Log.e(TAG, it.message ?: it.toString()) + }.addOnCompleteListener { + // When the image is from CameraX analysis use case, must call image.close() on received + // images when finished using them. Otherwise, new images may not be received or the camera + // may stall. + imageProxy.close() + } + } + + /** + * [androidx.camera.core.ImageAnalysis], [androidx.camera.core.Preview] requires enum value of + * [androidx.camera.core.AspectRatio]. Currently it has values of 4:3 & 16:9. + * + * Detecting the most suitable ratio for dimensions provided in @params by counting absolute + * of preview ratio to one of the provided values. + * + * @param width - preview width + * @param height - preview height + * @return suitable aspect ratio + */ + private fun aspectRatio(width: Int, height: Int): Int { + val previewRatio = max(width, height).toDouble() / min(width, height) + if (abs(previewRatio - RATIO_4_3_VALUE) <= abs(previewRatio - RATIO_16_9_VALUE)) { + return AspectRatio.RATIO_4_3 + } + return AspectRatio.RATIO_16_9 + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + if (requestCode == PERMISSION_CAMERA_REQUEST) { + if (isCameraPermissionGranted()) { + bindCameraUseCases() + } else { + Log.e(TAG, "no camera permission") + } + } + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + } + + private fun isCameraPermissionGranted(): Boolean { + return ContextCompat.checkSelfPermission( + requireContext(), + Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + } + + companion object { + private val TAG = MainActivity::class.java.simpleName + private const val PERMISSION_CAMERA_REQUEST = 1 + + private const val RATIO_4_3_VALUE = 4.0 / 3.0 + private const val RATIO_16_9_VALUE = 16.0 / 9.0 + } +} diff --git a/app/src/main/java/com/example/bump/CameraXViewModel.kt b/app/src/main/java/com/example/bump/CameraXViewModel.kt new file mode 100644 index 0000000..2a17243 --- /dev/null +++ b/app/src/main/java/com/example/bump/CameraXViewModel.kt @@ -0,0 +1,48 @@ +package com.example.bump + +import android.app.Application +import android.util.Log +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.core.content.ContextCompat +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import java.util.concurrent.ExecutionException + +/** + * View model for interacting with CameraX. + * Create an instance which interacts with the camera service via the given application context. + */ +class CameraXViewModel(application: Application) : AndroidViewModel(application) { + private var cameraProviderLiveData: MutableLiveData? = null + + // Handle any errors (including cancellation) here. + val processCameraProvider: LiveData + get() { + if (cameraProviderLiveData == null) { + cameraProviderLiveData = MutableLiveData() + val cameraProviderFuture = + ProcessCameraProvider.getInstance(getApplication()) + cameraProviderFuture.addListener( + { + try { + cameraProviderLiveData!!.setValue(cameraProviderFuture.get()) + } catch (e: ExecutionException) { + // Handle any errors (including cancellation) here. + Log.e(TAG, "Unhandled exception", e) + } catch (e: InterruptedException) { + Log.e(TAG, "Unhandled exception", e) + } + }, + ContextCompat.getMainExecutor(getApplication()) + ) + } + return cameraProviderLiveData!! + } + + + + companion object { + private const val TAG = "CameraXViewModel" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/bump/FirstFragment.kt b/app/src/main/java/com/example/bump/FirstFragment.kt new file mode 100644 index 0000000..13c7eb0 --- /dev/null +++ b/app/src/main/java/com/example/bump/FirstFragment.kt @@ -0,0 +1,44 @@ +package com.example.bump + +import android.os.Bundle +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.navigation.fragment.findNavController +import com.example.bump.databinding.FragmentFirstBinding + +/** + * A simple [Fragment] subclass as the default destination in the navigation. + */ +class FirstFragment : Fragment() { + + private var _binding: FragmentFirstBinding? = null + + // This property is only valid between onCreateView and + // onDestroyView. + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + + _binding = FragmentFirstBinding.inflate(inflater, container, false) + return binding.root + + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.buttonFirst.setOnClickListener { + findNavController().navigate(R.id.action_FirstFragment_to_SecondFragment) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/bump/MainActivity.kt b/app/src/main/java/com/example/bump/MainActivity.kt new file mode 100644 index 0000000..fe14df8 --- /dev/null +++ b/app/src/main/java/com/example/bump/MainActivity.kt @@ -0,0 +1,58 @@ +package com.example.bump + +import android.os.Bundle +import com.google.android.material.snackbar.Snackbar +import androidx.appcompat.app.AppCompatActivity +import androidx.navigation.findNavController +import androidx.navigation.ui.AppBarConfiguration +import androidx.navigation.ui.navigateUp +import androidx.navigation.ui.setupActionBarWithNavController +import android.view.Menu +import android.view.MenuItem +import com.example.bump.databinding.ActivityMainBinding + +class MainActivity : AppCompatActivity() { + + private lateinit var appBarConfiguration: AppBarConfiguration + private lateinit var binding: ActivityMainBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + setSupportActionBar(binding.toolbar) + + val navController = findNavController(R.id.nav_host_fragment_content_main) + appBarConfiguration = AppBarConfiguration(navController.graph) + setupActionBarWithNavController(navController, appBarConfiguration) + + binding.fab.setOnClickListener { view -> + Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG) + .setAction("Action", null).show() + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + // Inflate the menu; this adds items to the action bar if it is present. + menuInflater.inflate(R.menu.menu_main, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + // Handle action bar item clicks here. The action bar will + // automatically handle clicks on the Home/Up button, so long + // as you specify a parent activity in AndroidManifest.xml. + return when (item.itemId) { + R.id.action_settings -> true + else -> super.onOptionsItemSelected(item) + } + } + + override fun onSupportNavigateUp(): Boolean { + val navController = findNavController(R.id.nav_host_fragment_content_main) + return navController.navigateUp(appBarConfiguration) + || super.onSupportNavigateUp() + } +} diff --git a/app/src/main/java/com/example/bump/SecondFragment.kt b/app/src/main/java/com/example/bump/SecondFragment.kt new file mode 100644 index 0000000..9ac1b12 --- /dev/null +++ b/app/src/main/java/com/example/bump/SecondFragment.kt @@ -0,0 +1,45 @@ +package com.example.bump + +import android.os.Bundle +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.navigation.fragment.findNavController +import com.example.bump.databinding.FragmentSecondBinding + +/** + * A simple [Fragment] subclass as the second destination in the navigation. + */ +class SecondFragment : Fragment() { + + private var _binding: FragmentSecondBinding? = null + + // This property is only valid between onCreateView and + // onDestroyView. + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + + _binding = FragmentSecondBinding.inflate(inflater, container, false) + return binding.root + + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.viewFinder + binding.viewFinder.bitmap + + } + + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..694cfd5 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/content_main.xml b/app/src/main/res/layout/content_main.xml new file mode 100644 index 0000000..4f68632 --- /dev/null +++ b/app/src/main/res/layout/content_main.xml @@ -0,0 +1,19 @@ + + + + + \ 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..ece0ba9 --- /dev/null +++ b/app/src/main/res/layout/fragment_camera.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_first.xml b/app/src/main/res/layout/fragment_first.xml new file mode 100644 index 0000000..aa6987d --- /dev/null +++ b/app/src/main/res/layout/fragment_first.xml @@ -0,0 +1,28 @@ + + + + + +